Skip to content

2026 10 27 session touch ups

kubed-io[bot] edited this page Oct 27, 2026 · 1 revision

Session touch-ups: the cascade's edges, and what the live test found: Implementation Plan

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.

Design

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], values config, client or caller (settings.SET_BY). The resolver is settings.sourced(...) -> Sourced. The version's origin on an open is version_from: config, client, call or remembered, or None when no version is set. remembered is a caller version this call did not pass. It is a keyword of Actions.open_session and session_caps.build, and never a SETTINGS key, so a reap's **replay cannot carry it by accident.

  • resolve keeps its shape. It returns sourced(...).settings, so its many callers and tests stay as they are.

  • Ruling 12 lives in sourced. A call that passes a version other than latest, and no browser, gets browser set to the browser in effect, chrome when no layer named one, with the source caller. That keeps settings and set_by consistent in one pure function.

  • Ruling 14, malformed. set_by is valid only if it is a dict whose keys are exactly settings' keys, with every value in SET_BY. Anything else, including a missing set_by, reads as an old record: no settings, no set_by, no session_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 of settings, and a key with none given is caller. 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 (attached and not grid_listed), the payload's recording uses the stored record setting 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 answering in place of LIVE VIEW or ENDED. IDLE TIMEOUT reads as live (300 s · read from the Grid node). CAPTURE reads bare on or off, and RECORD shows on when recording. The open/idle pill in the workspace list and the masthead reads unknown. Agent's call; Dr K may reword.

  • Ruling 15's pills. client is a solid blue pill, like env, and caller is solid ink, like args. 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 browser and version, deduplicated, in that order;
    • WINDOW: the sources of width and height;
    • 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.

  • workspace://current does 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.Untrusted says ruling 6's sentence and nothing else. browser.go raises it from None for an InsecureCertificateException, as it does Unresolved. errors.CALLER gains both Untrusted and the bare InsecureCertificateException, for a click or other command that lands on such a page. A plain WebDriverException quoting net::ERR_CERT_* stays a 500, as test_unresolved_host.py pins today.

Global Constraints

  • Python 3.11 is the floor: nothing 3.12+.
  • workspace/settings.py and workspace/store.py import no protocol layer (tests/test_boundaries.py); faults.py imports no driver.
  • No log line formats a Grid id, a capability value or a setting's value.
  • version_from and set_by never reach a model's result. set_by reaches only the admin payload.
  • No argument changes shape, and no tool gains a parameter: test_request_schemas_are_the_tool_schemas and tests/test_surfaces.py pass 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, and tests/test_wiki.py passes. The wiki is the docs/ submodule: a commit inside it, then the pointer here.
  • Changelog: only ## [Unreleased], one short line per entry, a fix prefixed Fixed:.
  • 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/ or openapi.yaml.
  • The checks, as CI runs them:
    • Python (test.yml): ruff check . and pytest.
    • 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.py succeeds.

Review Focus

  1. 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 that resolve on that record raises NoSession with actions.closed == [], and that the next open_session() restores the saved site data.
  2. An HTTP caller with X-Browser: firefox on a server with SESSION_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 through open_browser with caller_of(headers={"x-browser": "firefox"}).
  3. 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 tests resize then a changed defaults["width"] then open_session().
  4. The admin page while the Grid's /status fails. A person expects no workspace to read "reaped" or "idle" while its browser may be running. Task 6 tests grid_listed false and recording kept. Task 7 tests the pane, the list pill and the masthead.
  5. open_session(version="152") and then the operator sets SESSION_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 changed defaults.

Task 1: The cascade walks its layers, and says where each value came from

Files:

  • Modify: kubed/selenium_flow/workspace/settings.py (module docstring 1–28; new SET_BY, Sourced, sourced, version_from beside resolve at 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->remembered

sourced 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] = source

Then 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, and resolve as sourced(...).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 resolve test

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.

Task 2: The record keeps each setting's source, and forgets a record without them

Files:

  • Modify: kubed/selenium_flow/workspace/store.py: Workspace (98–151), from_json (181–208), reshaped (247–257), a new reader _set_by beside _sent (290)
  • Test: tests/test_workspaces.py; update the records built as JSON in tests/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 or session_id back gains a matching set_by.

Interfaces:

  • Consumes: SET_BY, CALLER (Task 1). store.py imports them from .settings, which imports nothing from store (checked).
  • Produces:
    • Workspace.set_by: dict = field(default_factory=dict), after settings;
    • Workspace.reshaped(settings), which also sets set_by[k] = CALLER for every key it is given (ruling 13);
    • _set_by(raw, settings) -> dict | None, which is None unless raw is a dict, set(raw) == set(settings), and every value is in SET_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.

Task 3: A node refusal blames only the caller's version

Files:

  • Modify: kubed/selenium_flow/core/session_caps.py: Built (140–155), build (250–321), not_created (389–421); new VERSION_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 from build'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 None

In 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 == 400

open_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_session passes version_from through to build.

  • 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.

Task 4: Workspaces inherit only what a caller passed

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) pops version_from into self.opened_version_from: list, so opened_settings stays the settings alone
  • Test: tests/test_workspaces.py, tests/test_session_open.py (drop Task 3's xfail)

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 writes set_by={k: (set_by or {}).get(k, CALLER) for k in settings}.

open_browser:

  • inherited_settings is the record's settings whose set_by is caller, still without record and capture.
  • The version pre-pop (1014–1021) goes, because sourced does that now.
  • resolved = sourced(...); use .settings wherever resolved is used today.
  • Ruling 3: session_caps.check(resolved.settings.get("capabilities"), remembered=wanted.get("capabilities") in (None, "")), still before end_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, with manager() and Workspaces(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 xfail in tests/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.

Task 5: An untrusted certificate is a 400 that says what to do

Files:

  • Modify: kubed/selenium_flow/faults.py (new Untrusted beside Unresolved, 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) and RESOURCE_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.Untrusted is built with cls())
  • 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 message UNTRUSTED:
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 trust after 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, reusing test_unresolved_host.py's shapes (a driver whose get raises InsecureCertificateException("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.

Task 6: The admin row says whether the Grid answered, and where each setting came from

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; golden tests/golden/admin-workspaces.json

Interfaces:

  • Consumes: Workspace.set_by (Task 2).

  • Produces, on each row:

    • grid_listed: bool: whether actions.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")).
  • WorkspaceRow gains grid_listed?: boolean, insecure?: boolean, set_by?: Record<string, 'config' | 'client' | 'caller'>, and export 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 recording rule, 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.

Task 7: The Session tab

Files:

  • Modify: ui/src/admin/SessionPane.svelte, ui/src/admin/Section.svelte, ui/src/lib/format.ts (captureFact 150; new windowSize, 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: WxH to W×H, null stays null;
    • browserState(row: WorkspaceRow): 'open' | 'idle' | 'unknown': open when live, unknown when attached && grid_listed === false, else idle;
    • captureFact: 'off' when capture is off;
    • Section's new prop fold?: string, defaulting to id, as the key into folds. The DOM id stays id.
    • CSS: .pill.src.client joins the env rule, and .pill.src.caller joins the args rule.

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' when row.insecure, with [set_by.insecure]. RECORD shows on when (row.live || unlisted) && row.recording. CAPTURE shows when live (captureFact) or unlisted (row.capture ? 'on' : 'off'). Pills are drawn in a .pills span after .v, one pill 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 its fact() 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 open

Section.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.

Task 8: What a model and a maintainer are told, and the changelog

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 grep skills/ for "remembered" and "keeps it" and make the rest agree.
  • Modify: AGENTS.md (430: the cascade line; and the Workspace record'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 and insecure. 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 next open_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, and chore: changelog for the session touch-ups.

Clone this wiki locally