Repository navigation
2026 10 27 session touch ups
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: A workspace remembers only what a caller passed, and records where each stored setting came from. A version never follows a browser switch made in any layer. A node refusal blames only the caller's version. An untrusted certificate is a 400 that says what to do. The Session tab shows each setting's source, INSECURE, a failed Grid listing as unknown rather than reaped, off, ×, and a fold per workspace.
Architecture: settings.sourced walks the four layers (config < client < workspace < call) once. It returns the merged settings, each one's source, and where the version came from. Workspace stores the sources as set_by beside settings. open_browser inherits only the caller ones, and the reap replays them all. The version's origin travels beside the replay as a keyword, version_from, into session_caps.build, and not_created reads it. The admin row gains grid_listed, insecure and set_by, and the UI draws them.
Tech Stack: Python 3.11+, Selenium 4.48+, FastMCP 4, pytest; Svelte 5 runes and Vitest with jsdom in ui/.
Spec: docs/superpowers/specs/2026-10-27-session-touch-ups-design.md, written on #92 and finished here rather than replaced. Rulings 1–15 are settled, and nothing in it is open. This round only corrected its Where the code is line numbers and the pill styles' location against issue-92; the rulings did not change.
The spec is the design. What this plan settles where the spec left the call to it:
-
Names. The stored sources are
Workspace.set_by: dict[str, str], valuesconfig,clientorcaller(settings.SET_BY). The resolver issettings.sourced(...) -> Sourced. The version's origin on an open isversion_from:config,client,callorremembered, or None when no version is set.rememberedis acallerversion this call did not pass. It is a keyword ofActions.open_sessionandsession_caps.build, and never aSETTINGSkey, so a reap's**replaycannot carry it by accident. -
resolvekeeps its shape. It returnssourced(...).settings, so its many callers and tests stay as they are. -
Ruling 12 lives in
sourced. A call that passes a version other thanlatest, and no browser, getsbrowserset to the browser in effect,chromewhen no layer named one, with the sourcecaller. That keepssettingsandset_byconsistent in one pure function. -
Ruling 14, malformed.
set_byis valid only if it is a dict whose keys are exactlysettings' keys, with every value inSET_BY. Anything else, including a missingset_by, reads as an old record: no settings, noset_by, nosession_id. Everything else is kept: history, site data,sent,ran,ended,opened_by,grid_timeout,window_seen. -
remember(set_by=)stores a source for every key ofsettings, and a key with none given iscaller. Settings handed in directly are the caller's, which keeps the existing direct callers and tests right. -
Ruling 4's words. The 400's cause for a version is
version <v>,plus the following (session_caps.VERSION_FROM):-
client:from X-Version or ?version= -
call:from this call's version= -
remembered:remembered by this workspace (version="latest" forgets it)
A config version is never blamed. When the refusal is the 503 and a config version was sent, the 503 ends with
; the server's own session.version asks for <v>, for the operator. A config version is never listed among the causes of a 400 that raw keys earned. -
-
Ruling 5's field is per row:
grid_listed: bool, the same value on every row of one payload.header()hands the detail view a single row, so a top-level field would not reach the Session tab. The payloads that list no rows (no store, store failing) carry no rows, so there is nothing to put it on. While a row is unlisted (attachedand notgrid_listed), the payload'srecordinguses the storedrecordsetting as though the row were live, so it is not turned off by a failed listing. -
Ruling 5's words. On an unlisted row, the GRID SESSION card shows
grid · not answeringin place of LIVE VIEW or ENDED. IDLE TIMEOUT reads as live (300 s · read from the Grid node). CAPTURE reads bareonoroff, and RECORD showsonwhenrecording. Theopen/idlepill in the workspace list and the masthead readsunknown. Agent's call; Dr K may reword. -
Ruling 15's pills.
clientis a solid blue pill, likeenv, andcalleris solid ink, likeargs. That follows the Settings tab's order of weight: default < config < env < args. Pills go on the rows backed by a stored setting:- BROWSER: the sources of
browserandversion, deduplicated, in that order; - WINDOW: the sources of
widthandheight; - INSECURE: its source.
RECORD and CAPTURE carry none, since they are never carried over. A row whose setting is unset (the node's default) has no pill. The pills sit beside
.v, not inside it. Agent's call; Dr K may restyle. - BROWSER: the sources of
-
workspace://currentdoes not gain the sources. A model needs to know what a next open inherits, and the docstring now says that. The admin is who reads provenance. So no published shape moves for it. -
The certificate.
faults.Untrustedsays ruling 6's sentence and nothing else.browser.goraises itfrom Nonefor anInsecureCertificateException, as it doesUnresolved.errors.CALLERgains bothUntrustedand the bareInsecureCertificateException, for a click or other command that lands on such a page. A plainWebDriverExceptionquotingnet::ERR_CERT_*stays a 500, astest_unresolved_host.pypins today.
- Python 3.11 is the floor: nothing 3.12+.
-
workspace/settings.pyandworkspace/store.pyimport no protocol layer (tests/test_boundaries.py);faults.pyimports no driver. - No log line formats a Grid id, a capability value or a setting's value.
-
version_fromandset_bynever reach a model's result.set_byreaches only the admin payload. - No argument changes shape, and no tool gains a parameter:
test_request_schemas_are_the_tool_schemasandtests/test_surfaces.pypass unchanged in intent. - Every published shape that moves is a golden: regenerate with
GOLDEN_UPDATE=1 pytest tests/test_golden.py, read the diff, and commit goldens on their own (CONTRIBUTING.md). - Wiki pages are regenerated with
python scripts/generate_wiki.py, andtests/test_wiki.pypasses. The wiki is thedocs/submodule: a commit inside it, then the pointer here. - Changelog: only
## [Unreleased], one short line per entry, a fix prefixedFixed:. - No new integration flow (AGENTS.md: less is more).
- The UI copy rules: no helper text in a value.
- Never commit
kubed/selenium_flow/http/static/oropenapi.yaml. - The checks, as CI runs them:
- Python (
test.yml):ruff check .andpytest. - UI (
ui.yml):npm --prefix ui ci,npm --prefix ui run lint,npm --prefix ui run check,npm --prefix ui test,npm --prefix ui run build. - OpenAPI (
quality.yml):python scripts/generate_openapi.pysucceeds.
- Python (
-
A Redis-backed workspace holding a live browser when the upgrade lands. A person expects the next call to say "call open_session", not a 500 and not a browser of the wrong kind. The old browser is left for the Grid to reap, never quit, and its history and saved site data are still there. Task 2 tests
from_json. Task 4 tests thatresolveon that record raisesNoSessionwithactions.closed == [], and that the nextopen_session()restores the saved site data. -
An HTTP caller with
X-Browser: firefoxon a server withSESSION_VERSION=152. A person expects a Firefox at the latest version, not a 300 s wait for a Firefox 152. Task 1 tests the client layer's walk, and Task 4 tests it throughopen_browserwithcaller_of(headers={"x-browser": "firefox"}). -
A caller that resized, then calls
open_session(). A person expects the window they resized to, and not config's, even after config changed. Task 4 testsresizethen a changeddefaults["width"]thenopen_session(). -
The admin page while the Grid's
/statusfails. A person expects no workspace to read "reaped" or "idle" while its browser may be running. Task 6 testsgrid_listedfalse andrecordingkept. Task 7 tests the pane, the list pill and the masthead. -
open_session(version="152")and then the operator setsSESSION_BROWSER=firefox. A person expects the workspace to stay on Chrome 152, not to ask for a Firefox 152. Task 1 tests the pin, and Task 4 tests it through two opens with changeddefaults.
Files:
- Modify:
kubed/selenium_flow/workspace/settings.py(module docstring 1–28; newSET_BY,Sourced,sourced,version_frombesideresolveat 192) - Test:
tests/test_browser_choice.py
Interfaces:
- Produces:
CONFIG, CLIENT, CALLER = "config", "client", "caller"
SET_BY = (CONFIG, CLIENT, CALLER)
@dataclass(frozen=True)
class Sourced:
settings: dict
set_by: dict[str, str] # same keys as settings
version_from: str | None # "config" | "client" | "call" | "remembered" | None
def sourced(explicit: dict | None = None, defaults: dict | None = None,
previous: dict | None = None, client: dict | None = None) -> Sourced
def resolve(explicit=None, defaults=None, previous=None, client=None) -> dict # sourced(...).settings
def version_from(set_by: Mapping[str, str]) -> str | None # a reap's: config->config, client->client, caller->rememberedsourced coerces explicit exactly as resolve does today: strict browser, every other setting through its SETTINGS coercion, None skipped. previous is taken as already resolved, filtered to SETTINGS keys. Then it walks the layers (CONFIG, defaults), (CLIENT, client), (CALLER, previous), (CALLER, explicit):
for source, layer in layers:
named = layer.get("browser")
if named is not None and normalize_browser(named) != normalize_browser(settings.get("browser")):
settings.pop("version", None); set_by.pop("version", None) # ruling 2
for key, value in layer.items():
settings[key] = value; set_by[key] = sourceThen ruling 12: if the coerced explicit version is set and not LATEST, and explicit has no browser, set settings["browser"] = normalize_browser(settings.get("browser")) with set_by["browser"] = CALLER. version_from is "call" when explicit set a version, "remembered" when the version's source is CALLER otherwise, else the version's source, and None when version is not in settings.
- Step 1: Write the failing tests in
tests/test_browser_choice.py:
def test_each_value_says_which_layer_it_came_from():
s = sourced({"width": 800}, defaults={"browser": "chrome", "height": 900},
client={"script_timeout": 5}, previous={"page_load_timeout": 30})
assert s.set_by == {"browser": "config", "height": "config", "script_timeout": "client",
"page_load_timeout": "caller", "width": "caller"}
def test_a_config_version_is_dropped_by_an_explicit_other_browser(): # ruling 2
s = sourced({"browser": "firefox"}, defaults={"version": "152"})
assert "version" not in s.settings and s.version_from is None
def test_a_client_version_is_dropped_by_a_client_browser_other_than_configs():
s = sourced({}, defaults={"browser": "chrome"}, client={"browser": "firefox", "version": "9"})
assert s.settings["version"] == "9" # same layer: stands
s = sourced({}, defaults={"browser": "chrome", "version": "152"}, client={"browser": "firefox"})
assert "version" not in s.settings
def test_the_same_browser_keeps_the_version():
s = sourced({"browser": "chrome"}, defaults={"browser": "chrome", "version": "152"})
assert s.settings["version"] == "152" and s.version_from == "config"
def test_a_version_in_the_same_call_stands():
s = sourced({"browser": "firefox", "version": "140"}, defaults={"version": "152"})
assert s.settings["version"] == "140" and s.version_from == "call"
def test_a_remembered_version_says_so():
s = sourced({}, previous={"browser": "chrome", "version": "152"})
assert s.version_from == "remembered"
def test_a_version_passed_alone_pins_the_browser_in_effect(): # ruling 12
s = sourced({"version": "152"}, defaults={})
assert s.settings["browser"] == "chrome" and s.set_by["browser"] == "caller"
s = sourced({"version": "152"}, client={"browser": "firefox"})
assert s.settings["browser"] == "firefox" and s.set_by["browser"] == "caller"
def test_latest_pins_nothing():
s = sourced({"version": "latest"}, defaults={})
assert "browser" not in s.settings
def test_a_pinned_version_does_not_follow_a_later_config_browser(): # Review Focus 5
first = sourced({"version": "152"}, defaults={})
kept = {k: v for k, v in first.settings.items() if first.set_by[k] == "caller"}
s = sourced({}, defaults={"browser": "firefox"}, previous=kept)
assert s.settings == {"browser": "chrome", "version": "152"}
def test_resolve_is_sourceds_settings():
assert resolve({"width": 1}, defaults={"browser": "firefox"}) == {"browser": "firefox", "width": 1}
def test_version_from_a_stored_source():
assert [version_from({"version": s}) for s in ("config", "client", "caller")] == \
["config", "client", "remembered"]
assert version_from({}) is None- Step 2: Run them to see them fail
Run: pytest tests/test_browser_choice.py -v
Expected: FAIL at import, sourced not defined.
- Step 3: Implement
SET_BY,Sourced,sourced,version_from, andresolveassourced(...).settings. Rewrite the module docstring's cascade:
server default < client default < what this workspace's caller passed before < explicit. Only what a caller passed is remembered; the two defaults are read fresh at each open, so a changed config reaches every workspace at its next open.
- Step 4: Run them, and every existing
resolvetest
Run: pytest tests/test_browser_choice.py tests/test_workspaces.py tests/test_capture_open.py -v && ruff check .
Expected: PASS. The existing resolve tests hold, since none of them crosses a browser switch with a config or client version.
- Step 5: Commit
feat: the settings cascade says where each value came from.
Files:
- Modify:
kubed/selenium_flow/workspace/store.py:Workspace(98–151),from_json(181–208),reshaped(247–257), a new reader_set_bybeside_sent(290) - Test:
tests/test_workspaces.py; update the records built as JSON intests/test_history.py,tests/test_site_data.py,tests/test_site_data_save.py,tests/test_files_and_admin.py,tests/test_workspace_monitor.py. Each one that expects its settings orsession_idback gains a matchingset_by.
Interfaces:
- Consumes:
SET_BY,CALLER(Task 1).store.pyimports them from.settings, which imports nothing fromstore(checked). - Produces:
-
Workspace.set_by: dict = field(default_factory=dict), aftersettings; -
Workspace.reshaped(settings), which also setsset_by[k] = CALLERfor every key it is given (ruling 13); -
_set_by(raw, settings) -> dict | None, which is None unlessrawis a dict,set(raw) == set(settings), and every value is inSET_BY.
-
from_json: after reading settings, set_by = _set_by(data.get("set_by"), settings). When it is None, the record reads with settings={}, set_by={}, session_id="", and everything else as read. It stays pure: no log line.
- Step 1: Write the failing tests in
tests/test_workspaces.py:
OLD = {"session_id": "grid-a", "settings": {"browser": "firefox", "width": 1400},
"history": [{"origin": "https://a.test", "url": "https://a.test/x", "at": time.time()}],
"site_data": SNAPSHOT, # one `site_data.restorable` accepts, as tests/test_site_data_workspaces.py builds
"sent": [{"key": "browserName", "value": "firefox", "sources": ["option"]}],
"ran": {"version": "142.0", "node": "n", "live_view": True}}
def test_a_record_without_sources_loses_its_settings_and_its_browser(): # ruling 14
record = Workspace.from_json(json.dumps(OLD))
assert record.settings == {} and record.set_by == {} and record.session_id == ""
assert record.history == OLD["history"] and record.site_data == OLD["site_data"]
assert record.sent == OLD["sent"] and record.ran == OLD["ran"]
@pytest.mark.parametrize("set_by", [
[], {"browser": "caller"}, {"browser": "caller", "width": "nobody"},
{"browser": "caller", "width": "caller", "height": "caller"}])
def test_malformed_sources_read_as_an_old_record(set_by):
record = Workspace.from_json(json.dumps({**OLD, "set_by": set_by}))
assert record.settings == {} and record.session_id == ""
def test_a_new_record_reads_back_whole():
raw = {**OLD, "set_by": {"browser": "caller", "width": "config"}}
record = Workspace.from_json(json.dumps(raw))
assert record.settings == OLD["settings"] and record.set_by == raw["set_by"]
assert record.session_id == "grid-a"
assert Workspace.from_json(record.to_json()) == record
def test_a_resize_marks_the_window_as_the_callers(): # ruling 13
record = Workspace(settings={"width": 800, "height": 600}, set_by={"width": "config", "height": "config"})
assert record.reshaped({"width": 1024, "height": 768}).set_by == {"width": "caller", "height": "caller"}- Step 2: Run them to see them fail
Run: pytest tests/test_workspaces.py -k "sources or record or resize" -v
Expected: FAIL, Workspace has no set_by.
-
Step 3: Implement as above, then update the JSON-built records in the five test files listed.
-
Step 4: Run the suite
Run: pytest && ruff check .
Expected: PASS, apart from tests that assert the old cascade's freeze (config values inherited by a later explicit open), which Task 4 settles. List any other failure before going on.
- Step 5: Commit
feat: a workspace record keeps each setting's source.
Files:
- Modify:
kubed/selenium_flow/core/session_caps.py:Built(140–155),build(250–321),not_created(389–421); newVERSION_FROM - Modify:
kubed/selenium_flow/core/actions.py:open_session(273–347) - Test:
tests/test_session_caps.py,tests/test_session_open.py
Interfaces:
- Produces:
-
Built.version_from: str | None = None, set frombuild's argument only when a version is sent; -
build(..., version_from: str = "call"); -
Actions.open_session(..., *, version_from: str = "call", on_created=None, on_ready=None, on_quit=None); -
VERSION_FROM: dict[str, str], holding the three phrases in Design.
-
not_created: blamed = bool(built.raw_keys) or (built.version is not None and built.version_from != "config"). The version cause is f"version {built.version}, {VERSION_FROM[built.version_from]}" and is included only when the version is the caller's. When not blamed and built.version is a config version, the 503's text ends f"; the server's own session.version asks for {built.version}".
- Step 1: Write the failing tests. In
tests/test_session_caps.py:
REFUSED = "session not created: no such version"
def test_a_config_version_alone_is_not_the_callers(): # ruling 4
fault = not_created(REFUSED, build("chrome", version="152", version_from="config"))
assert not fault.blamed
assert str(fault).endswith("the server's own session.version asks for 152")
@pytest.mark.parametrize("origin, said", [
("client", "version 152, from X-Version or ?version="),
("call", "version 152, from this call's version="),
("remembered", 'version 152, remembered by this workspace (version="latest" forgets it)')])
def test_a_callers_version_is_blamed_naming_where_it_came_from(origin, said):
fault = not_created(REFUSED, build("chrome", version="152", version_from=origin))
assert fault.blamed and said in str(fault)
def test_raw_keys_are_blamed_without_naming_a_config_version():
fault = not_created(REFUSED, build("chrome", version="152", version_from="config", raw={"x:y": 1}))
assert fault.blamed and "version 152" not in str(fault)
def test_latest_has_no_origin():
assert build("chrome", version="latest", version_from="client").version_from is NoneIn tests/test_session_open.py, through the real POST /browser on a SeleniumMCP whose grid.open raises SessionNotCreatedException(REFUSED):
def test_a_refused_config_version_is_a_503(...): # Settings(session={"version": "152"})
assert open_over_http(server).status_code == 503
def test_a_refused_x_version_is_a_400_naming_it(...): # headers {"X-Version": "152"}
response = open_over_http(server, headers={"X-Version": "152"})
assert response.status_code == 400 and "from X-Version or ?version=" in response.json()["error"]
def test_a_refused_explicit_version_is_a_400(...): # json {"version": "152"}
assert open_over_http(server, json={"version": "152"}).status_code == 400open_over_http is a small helper in that file. Until Task 4 the header case is blamed as call, so these three pass only after Task 4. Write them here, mark the client one xfail(strict=True, reason="Task 4"), and remove the mark in Task 4.
- Step 2: Run them to see them fail
Run: pytest tests/test_session_caps.py tests/test_session_open.py -v
Expected: FAIL, build takes no version_from.
-
Step 3: Implement
VERSION_FROM, the field, the keyword and the two messages.Actions.open_sessionpassesversion_fromthrough tobuild. -
Step 4: Run them
Run: pytest tests/test_session_caps.py tests/test_session_open.py tests/test_errors.py -v && ruff check .
Expected: PASS, with the one xfail.
- Step 5: Commit
feat: a node's refusal blames a version only when it is the caller's.
Files:
- Modify:
kubed/selenium_flow/workspace/workspaces.py:resolved(495–576),open_browser(979–1116),remember(1137–1219) - Modify:
tests/conftest.py:RecordingActions.open_session(95) popsversion_fromintoself.opened_version_from: list, soopened_settingsstays the settings alone - Test:
tests/test_workspaces.py,tests/test_session_open.py(drop Task 3'sxfail)
Interfaces:
- Consumes:
sourced,version_from(Task 1);Workspace.set_by(Task 2);Actions.open_session(version_from=)(Task 3). - Produces:
Workspaces.remember(..., set_by: dict | None = None). It writesset_by={k: (set_by or {}).get(k, CALLER) for k in settings}.
open_browser:
-
inherited_settingsis the record's settings whoseset_byiscaller, still withoutrecordandcapture. - The version pre-pop (1014–1021) goes, because
sourceddoes that now. -
resolved = sourced(...); use.settingswhereverresolvedis used today. - Ruling 3:
session_caps.check(resolved.settings.get("capabilities"), remembered=wanted.get("capabilities") in (None, "")), still beforeend_browser. -
_opened(..., version_from=resolved.version_from, **resolved.settings). -
remember(..., set_by=resolved.set_by).
resolved (the reap) passes version_from=settings_module.version_from(record.set_by) when replay has a version, and passes set_by restricted to replay's keys, since record and capture may have been popped.
context() gains "set_by": dict(record.set_by).
- Step 1: Write the failing tests in
tests/test_workspaces.py, withmanager()andWorkspaces(actions, store, defaults=...):
def test_a_changed_config_reaches_the_next_explicit_open(): # ruling 1
ws = Workspaces(RecordingActions(), MemoryStore(), defaults={"browser": "chrome", "width": 800, "version": "150"})
ws.open_browser(caller)
ws.defaults = {"browser": "firefox", "width": 1200, "version": "152"}
ws.open_browser(caller)
last = ws.actions.opened_settings[-1]
assert (last["browser"], last["width"], last["version"]) == ("firefox", 1200, "152")
def test_a_dropped_client_header_no_longer_applies():
ws.open_browser(caller_of({"workspace": NAMED}, {"x-browser": "firefox"}))
ws.open_browser(caller_of({"workspace": NAMED}))
assert ws.actions.opened_settings[-1].get("browser") in (None, "chrome")
def test_only_what_the_caller_passed_is_carried():
ws = Workspaces(RecordingActions(), MemoryStore(), defaults={"width": 800})
ws.open_browser(caller, height=600)
assert ws.store.get(NAMED).set_by == {"width": "config", "height": "caller"}
def test_a_reap_replays_the_whole_stored_set(): ... # config width kept after the config changes; reap via grid.alive.discard
def test_x_browser_beats_a_config_version(): # Review Focus 2
ws = Workspaces(RecordingActions(), MemoryStore(), defaults={"version": "152"})
ws.open_browser(caller_of({"workspace": NAMED}, {"x-browser": "firefox"}))
assert "version" not in ws.actions.opened_settings[-1]
def test_a_resize_survives_a_config_change(): # Review Focus 3
... # open, workspaces.reshape(NAMED, {"width": 1024, "height": 768}), defaults width 1200, open_browser(caller)
# assert opened_settings[-1]["width"] == 1024
def test_a_passed_version_keeps_its_browser_after_session_browser_changes(): # Review Focus 5
ws.open_browser(caller, version="152"); ws.defaults = {"browser": "firefox"}; ws.open_browser(caller)
assert (last["browser"], last["version"]) == ("chrome", "152")
def test_a_remembered_owned_key_on_an_explicit_open_says_how_to_clear_it(): # ruling 3
# store an attached, alive record: settings {"capabilities": {"browserName": "x"}}, set_by caller
with pytest.raises(ValueError, match=re.escape("open_session(capabilities={})")):
ws.open_browser(caller)
assert ws.actions.closed == []
def test_the_version_origin_reaches_the_open():
ws.open_browser(caller, version="152")
assert ws.actions.opened_version_from[-1] == "call"
ws.open_browser(caller)
assert ws.actions.opened_version_from[-1] == "remembered"
# after a reap, still "remembered"; a config version reaped is "config"
def test_an_upgraded_record_asks_for_open_session_and_keeps_its_site_data(): # Review Focus 1
# a RedisStore over tests/fakes.py's FakeRedis, with OLD (Task 2) written raw under
# its key, its grid id alive in RecordingActions.grid: the JSON round trip is the upgrade
with pytest.raises(faults.NoSession):
ws.resolve(NAMED)
assert ws.actions.closed == [] # left for the Grid to reap
ws.open_browser(caller)
assert ws.actions.opened_settings[-1]["site_data"] == OLD["site_data"]
assert ws.store.get(NAMED).history[-1]["origin"] == "https://a.test"Existing tests that assert the old freeze, a config or client value inherited by a later explicit open, change to assert the new rule. Name each one changed in the commit body.
- Step 2: Run them to see them fail
Run: pytest tests/test_workspaces.py -v
Expected: FAIL.
-
Step 3: Implement as above. Remove the
xfailintests/test_session_open.py. -
Step 4: Run the suite
Run: pytest && ruff check .
Expected: PASS.
- Step 5: Commit
feat: a workspace carries over only what its caller passed.
Files:
- Modify:
kubed/selenium_flow/faults.py(newUntrustedbesideUnresolved, 62) - Modify:
kubed/selenium_flow/core/browser.py:go(744–760) - Modify:
kubed/selenium_flow/errors.py:CALLER(73–89) - Modify:
kubed/selenium_flow/spec/builder.py: the actions' 400 (225–230) andRESOURCE_400["open_session"](102–107) - Modify:
skills/selenium-flow/references/TROUBLESHOOTING.md, A certificate error (164–168) - Modify:
tests/test_golden.py(279–286:faults.Untrustedis built withcls()) - Create:
tests/test_untrusted_certificate.py - Regenerate:
tests/golden/errors.json,tests/golden/openapi.json; wiki pages
Interfaces:
- Produces:
faults.Untrusted(Exception), with no arguments and the messageUNTRUSTED:
UNTRUSTED = ("the site's certificate is not trusted: for a site with a self-signed "
"certificate, open_session(insecure=true); an insecure browser gets no saved site data")go adds except InsecureCertificateException: raise Untrusted() from None ahead of the WebDriverException clause. The descriptions:
- the actions' 400 adds
, a page whose certificate the browser does not trustafter the locator clause; -
RESOURCE_400["open_session"]adds, a first page whose certificate the browser does not trust.
TROUBLESHOOTING's entry quotes UNTRUSTED and keeps the existing advice. Under the new rule insecure is still remembered, because it is only ever the caller's.
- Step 1: Write the failing tests in
tests/test_untrusted_certificate.py, reusingtest_unresolved_host.py's shapes (a driver whosegetraisesInsecureCertificateException("insecure certificate https://u:p@a.test/x")):
def test_go_says_the_sentence_and_nothing_of_the_browsers_text():
with pytest.raises(faults.Untrusted) as caught:
browser.go(Untrusted(), "https://u:hunter2@a.test/x")
assert faults.message(caught.value) == UNTRUSTED and errors.status_for(caught.value) == 400
assert "hunter2" not in str(caught.value) and caught.value.__suppress_context__
def test_a_bare_insecure_certificate_exception_is_a_400():
assert errors.status_for(InsecureCertificateException("x")) == 400
def test_navigate_says_it(actions, monkeypatch): ... # pytest.raises(faults.Untrusted)
def test_an_open_whose_first_page_is_untrusted_leaves_no_browser(actions, monkeypatch): ... # quit == [id]
def test_over_http_open_session_is_a_400_with_the_sentence(server, monkeypatch):
assert response.status_code == 400 and response.json() == {"error": UNTRUSTED}- Step 2: Run them to see them fail
Run: pytest tests/test_untrusted_certificate.py -v
Expected: FAIL, faults.Untrusted not defined.
-
Step 3: Implement as above.
-
Step 4: Run them, regenerate, and read the diffs
Run: pytest tests/test_untrusted_certificate.py tests/test_unresolved_host.py tests/test_errors.py -v && GOLDEN_UPDATE=1 pytest tests/test_golden.py && python scripts/generate_wiki.py && pytest && ruff check .
Expected: PASS. The golden diff shows only the two new error rows and the two descriptions. test_unresolved_host.py's ERR_CERT_* cases still answer 500.
- Step 5: Commit the code, then the goldens, then the wiki pages:
feat: an untrusted certificate is a 400 that says to use insecure=true.
Files:
- Modify:
kubed/selenium_flow/http/admin/workspaces.py: the listing (403–409) and the row (502–570) - Modify:
ui/src/lib/types.ts(WorkspaceRow) - Test:
tests/test_files_and_admin.py; goldentests/golden/admin-workspaces.json
Interfaces:
-
Consumes:
Workspace.set_by(Task 2). -
Produces, on each row:
-
grid_listed: bool: whetheractions.grid.sessions()answered; -
insecure: bool:bool(settings.get("insecure")); -
set_by: dict[str, str]:dict(record.set_by); -
recording:bool((live or (sid and not grid_listed)) and settings.get("record")).
-
-
WorkspaceRowgainsgrid_listed?: boolean,insecure?: boolean,set_by?: Record<string, 'config' | 'client' | 'caller'>, andexport type SetBy = 'config' | 'client' | 'caller'. -
Step 1: Write the failing tests in
tests/test_files_and_admin.py:
def test_a_failed_grid_listing_is_said_and_a_recording_stays_on(): # ruling 5
# grid.sessions raises; one attached record with settings {"record": True}, set_by caller
row = payload()["workspaces"][0]
assert row["grid_listed"] is False and row["live"] is False and row["recording"] is True
def test_a_listing_that_answered_says_so():
assert payload()["workspaces"][0]["grid_listed"] is True
def test_the_row_carries_insecure_and_each_settings_source(): # rulings 7, 15
# record settings {"browser": "firefox", "insecure": True}, set_by {"browser": "config", "insecure": "caller"}
assert row["insecure"] is True and row["set_by"] == {"browser": "config", "insecure": "caller"}- Step 2: Run them to see them fail
Run: pytest tests/test_files_and_admin.py -v
Expected: FAIL.
-
Step 3: Implement the three fields and the
recordingrule, and the types. -
Step 4: Run them and regenerate the admin golden
Run: pytest tests/test_files_and_admin.py -v && GOLDEN_UPDATE=1 pytest tests/test_golden.py && pytest && ruff check . && npm --prefix ui ci && npm --prefix ui run check
Expected: PASS. The golden diff adds the three fields to each row and nothing else.
- Step 5: Commit the code, then the golden:
feat: the admin list says whether the Grid answered, and each setting's source.
Files:
- Modify:
ui/src/admin/SessionPane.svelte,ui/src/admin/Section.svelte,ui/src/lib/format.ts(captureFact150; newwindowSize,browserState),ui/src/lib/views/ContextView.svelte(27),ui/src/lib/WorkspaceList.svelte(19),ui/src/lib/WorkspaceSummary.svelte(17),ui/src/app.css(160–163) - Test:
ui/src/admin/SessionPane.test.ts,ui/src/admin/Section.test.ts,ui/src/lib/format.test.ts,ui/src/lib/WorkspaceList.test.ts,ui/src/lib/WorkspaceSummary.test.ts,ui/src/lib/views/ContextView.test.ts
Interfaces:
- Consumes:
grid_listed,insecure,set_by(Task 6). - Produces:
-
windowSize(w?: string | null): string | null:WxHtoW×H, null stays null; -
browserState(row: WorkspaceRow): 'open' | 'idle' | 'unknown':openwhenlive,unknownwhenattached && grid_listed === false, elseidle; -
captureFact:'off'when capture is off; -
Section's new propfold?: string, defaulting toid, as the key intofolds. The DOM id staysid. - CSS:
.pill.src.clientjoins theenvrule, and.pill.src.callerjoins theargsrule.
-
SessionPane:
-
const unlisted = row.attached && row.grid_listed === false. - WORKSPACE rows become
[key, value, sources[]]. BROWSER takes[set_by.browser, set_by.version]and WINDOW[set_by.width, set_by.height], each deduplicated with undefined dropped. INSECURE is'on'whenrow.insecure, with[set_by.insecure]. RECORD showsonwhen(row.live || unlisted) && row.recording. CAPTURE shows when live (captureFact) or unlisted (row.capture ? 'on' : 'off'). Pills are drawn in a.pillsspan after.v, onepill src <source>each. - WINDOW is
windowSize(row.window). - GRID SESSION: IDLE TIMEOUT is
idle(row.grid_timeout, row.live || unlisted). The last row is['grid', 'not answering']when unlisted, else today's LIVE VIEW or ENDED. -
<Section id="capabilitiesSection" fold={capabilities:${row.key}} ...>.
WorkspaceList and WorkspaceSummary draw browserState: open keeps pill live, while idle and unknown use the plain pill with that word. ContextView draws windowSize(data.window).
- Step 1: Write the failing tests. In
SessionPane.test.ts, keeping itsfact()helper (it reads.v, so pills do not disturb it):
test('each WORKSPACE row carries its sources as pills', ...)
// set_by {browser:'config', version:'caller', width:'client', height:'client'}
// browser row pills ['config','caller']; window row pills ['client']
test('a setting nobody set has no pill', ...) // set_by {} → no .pill.src in #workspaceCard
test('INSECURE shows only when on, idle too', ...) // insecure true, live false → 'on'; false → absent
test('CAPTURE reads off', ...) // capture false, live → 'off'
test('WINDOW reads with ×', ...) // '1280×900'
test('a row the Grid did not list is neither reaped nor idle', ...)
// {...LIVE, live:false, grid_listed:false}: no 'ended', 'grid' = 'not answering',
// 'idle timeout' = '300 s · read from the Grid node', capture 'on', record 'on'
test('the Capabilities fold is kept per workspace', ...)
// open on key 'a', unmount; key 'b' starts closed; key 'a' again starts openSection.test.ts: a fold key shared by two sections keeps one state, and a section with none uses its id. format.test.ts: windowSize('1400x1000') === '1400×1000', windowSize(null) === null, captureFact({capture:false}) === 'off', and the three browserState cases. WorkspaceList.test.ts and WorkspaceSummary.test.ts: an unlisted attached row's pill reads unknown. ContextView.test.ts: the window pill reads 1400×1000.
- Step 2: Run them to see them fail
Run: npm --prefix ui test
Expected: FAIL.
-
Step 3: Implement as above.
-
Step 4: Run every UI check
Run: npm --prefix ui run lint && npm --prefix ui run check && npm --prefix ui test && npm --prefix ui run build
Expected: PASS. Then look at it: npm --prefix ui run dev against docker compose up, and compare a live, an idle and a Grid-down workspace with design/session.md. Say if that could not be done.
- Step 5: Commit
feat: the Session tab shows where each setting came from, INSECURE, and a Grid that did not answer.
Files:
- Modify:
kubed/selenium_flow/mcp/tools.py:open_session's docstring (393–429) - Modify:
skills/selenium-flow/references/WORKSPACES.md, Choosing the browser (77–106). Also grepskills/for "remembered" and "keeps it" and make the rest agree. - Modify:
AGENTS.md(430: the cascade line; and theWorkspacerecord's paragraph if it says the record holds config values) - Modify:
CHANGELOG.md(## [Unreleased], at the top) - Regenerate:
tests/golden/tools-on.json,tests/golden/tools-off.json,tests/golden/openapi.json(the docstring is the tool's description); wiki pages - Test:
tests/test_skill.py,tests/test_guidance.py,tests/test_wiki.py,tests/test_surfaces.py
Interfaces: none produced.
Content:
-
Docstring. "Both remembered" and "All remembered" become one sentence after the options: Each is remembered when you pass it; one you leave out comes from the server's and your connection's defaults, read at each open.
version="latest"forgets a remembered version, and so does switching browser. Keep it as short as that. -
WORKSPACES.md. The table's last column reads keeps it if you passed it for
browser,version, the window, the timeouts andinsecure. Add one sentence under the table: what you leave out comes from the server's and your connection's defaults at each open, so a change there reaches you at your nextopen_session(). A version passed without a browser keeps the browser it ran on. -
AGENTS.md line 430:
env < client default < **what this workspace's caller passed before** < explicit argument, with one line saying only a caller's values are stored, each with its source (set_by), and that a reap replays the whole stored set. -
CHANGELOG, the spec's four lines:
Fixed: an explicit `open_session()` picks up changed server and connection defaults, and carries over only what you passed.On upgrading, workspaces forget their remembered settings and the browser they held, once; their history, saved site data and files stay.Fixed: an untrusted certificate is a 400 that says to use `insecure=true`.The Session tab's WORKSPACE card shows where each setting came from, and INSECURE.
-
Step 1: Write the content.
-
Step 2: Regenerate and run the published-shape tests
Run: GOLDEN_UPDATE=1 pytest tests/test_golden.py && python scripts/generate_wiki.py && pytest tests/test_skill.py tests/test_guidance.py tests/test_wiki.py tests/test_surfaces.py -v
Expected: PASS. The golden diff shows only open_session's description.
- Step 3: Run every check once more
Run: ruff check . && pytest && npm --prefix ui ci && npm --prefix ui run lint && npm --prefix ui run check && npm --prefix ui test && npm --prefix ui run build && python scripts/generate_openapi.py
Expected: PASS.
- Step 4: Commit
docs: open_session remembers only what you pass, then the goldens, the wiki pages, andchore: changelog for the session touch-ups.
The action pages are generated from openapi.yaml, which is itself generated from the live MCP tool schemas — so they describe the server that shipped, not the one someone remembered. Prose belongs in wiki-notes/<tool>.md in the repo.
selenium-flow · MIT
Start here
Guides
Lifecycle
Going places
Doing things
Getting things out
Site data
Console and network