Skip to content

2026 10 26 session capabilities

kubed-io[bot] edited this page Oct 27, 2026 · 11 revisions

Session capabilities, and named options for the ones we treat specially: 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: open_session takes version and a raw capabilities object, an operator's session.capabilities rides under every open, every capability sent is recorded with its source, and the admin UI gets a Session tab showing what was asked, what the Grid gave and what was sent.

Architecture: One pure builder, core/session_caps.py, turns the browser kind, the named options, the config capabilities and the raw object into the exact dict sent, each key's sources, and what lost a vendor-block merge. Grid sends that dict and nothing else. Actions.open_session builds it, opens, reads what ran, and turns a SessionNotCreatedException into a fault errors.py can classify. Workspaces refuses an owned key before ending the held browser, remembers version and capabilities as settings, and keeps sent, ran and ended on the record for the admin.

Tech Stack: Python 3.11+, Selenium 4.48+, pydantic 2 (JsonValue), pydantic-settings, FastMCP 4, pytest; Svelte 5 runes and Vitest with jsdom in ui/.

Spec: docs/superpowers/specs/2026-10-26-session-capabilities-design.md, finished on this branch rather than replaced. Rulings 1–22 are #82's, read by Dr K. Round 1 added rulings 23–31, from checking the spec against the code at the head of issue-82; they were read right on the pull request. Round 2 settled ruling 23's open question (Dr K): insecure always sends acceptInsecureCerts, true or false, on every browser, with the source option. Nothing is open.

Design

The spec is the design. What this plan settles beyond it:

  • Names. The builder is session_caps.build(...) -> Built; the owned-key check is session_caps.check(...); the failure classifier is session_caps.not_created(...). The faults are faults.NoNodeTook (always 503) and faults.NodeRefused (400 when blamed, else 503).
  • Where the config capabilities live. server.py sets actions.config_capabilities = settings.session.capabilities, the way it already sets actions.hide and actions.capture. Actions.open_session passes them to build.
  • Where the owned-key 400 is raised early. Workspaces.open_browser calls session_caps.check(resolved.get("capabilities")) beside the record and capture checks, before end_browser. build runs the same check, so the reopen path (ruling 26) is covered without a second rule.
  • What Actions.open_session returns beyond today: version (as ran), ignored (only when not empty), and two private keys, sent and ran, that Workspaces stores and removes before anything reaches a caller.
  • The sent order follows design/session.md: option keys, then config and raw keys in the order given, then the vendor block, then flow's three keys.

Global Constraints

  • Python 3.11 is the floor (CONTRIBUTING.md): nothing 3.12+.
  • core/session_caps.py imports no selenium and no protocol library. It joins NO_SELENIUM in tests/test_boundaries.py, because config.py, which is in that set, imports it.
  • No log line formats a capability value, the sent set, ran.node with a Grid id, or a raw object. Errors quote only what ruling 25 allows.
  • No session_id in any result a caller reads (AGENTS.md, "One contract, and no id in it"). sent and ran never reach a model; ran.version does, as version.
  • An argument never changes shape (CONTRIBUTING.md, "Changing an argument's shape"). version and capabilities are new optional arguments.
  • The tool's type hints are its schema: version: str | None, capabilities: dict[str, JsonValue] | None, with JsonValue from pydantic.
  • Every published shape that moves is a golden. Regenerate with GOLDEN_UPDATE=1 pytest, read the diff, and commit the goldens on their own, never with code (CONTRIBUTING.md).
  • The wiki pages are regenerated with python scripts/generate_wiki.py inside the docs/ submodule, and tests/test_wiki.py passes.
  • Changelog: only ## [Unreleased], a flat list with no headings, one short line per entry, a fix prefixed Fixed: (CHANGELOG.md as it stands).
  • No new integration flow. One existing flow changes its URL (ruling 31).
  • 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.
    • The OpenAPI lint (quality.yml) runs on the generated document: python scripts/generate_openapi.py must succeed.

Review Focus

  1. A Firefox browser, with insecure on or off. Selenium's Firefox default used to send acceptInsecureCerts: true whatever insecure said. A person expects Firefox to follow insecure exactly as Chrome does, and an insecure browser of either kind to get no saved site data. Task 1 pins acceptInsecureCerts as False by default and True with insecure, on both browsers, with the source option. Task 2 pins that _options(build("firefox")).to_capabilities() carries acceptInsecureCerts: False and no moz:debuggerAddress or pageLoadStrategy.
  2. A raw capabilities carrying a credential: a cloud vendor's options with an access key, or a proxy with a password. A person expects it never in a log and never in an error a model reads. Task 3 tests that the 503 and the node-refusal 400 quote neither a vendor block nor proxy.
  3. A remembered raw key that an upgrade makes owned, met at a silent reopen after a reap. A person expects their call to fail with words that say how to fix it, not a browser of a different shape. Task 6 tests that the reopen raises the 400 with open_session(capabilities={}) in it.
  4. A typo in a raw vendor block, such as "args": "--lang=fr" as a string. A person expects a 400 saying what shape it must be, before their held browser is ended. Task 1 tests the refusal; Task 6 tests that the held browser survives it.
  5. A workspace record written before this change: no sent, ran or ended, settings without version. A person expects the admin list, the Session tab and workspace://current to show it without error. Task 6 tests from_json on old and malformed records; Task 8 tests the Session tab with none of the three fields.

Task 1: The builder

Files:

  • Create: kubed/selenium_flow/core/session_caps.py
  • Test: tests/test_session_caps.py
  • Modify: tests/test_boundaries.py (NO_SELENIUM gains "core.session_caps")

Interfaces:

  • Consumes: CHROME, FIREFOX, normalize_browser from core/defaults.py.
  • Produces:
SOURCES = ("flow", "option", "config", "raw")
LATEST = "latest"
VENDOR: dict[str, str]          # {CHROME: "goog:chromeOptions", FIREFOX: "moz:firefoxOptions"}
OWNED: dict[str, str]           # key -> the end of its refusal (copy below)

@dataclass(frozen=True)
class Built:
    browser: str
    send: dict[str, Any]              # exactly what is sent
    sent: list[dict]                  # [{"key", "value", "sources": [..]}], in send's order
    ignored: list[str]                # ruling 28
    raw_keys: tuple[str, ...]         # top-level keys the raw object set
    version: str | None               # the browserVersion sent, or None

def check(raw: Mapping[str, Any] | None, *, where: str = "capabilities",
          remembered: bool = False) -> None          # raises ValueError
def build(browser: str | None, *, version: str | None = None, insecure: bool = False,
          record: bool = False, video_name: str | None = None,
          config: Mapping[str, Any] | None = None, raw: Mapping[str, Any] | None = None,
          option_args: Sequence[str] = ()) -> Built  # raises ValueError

The flow layer's values move here from Grid._options, with their comments, which say why each exists:

  • Every browser: unhandledPromptBehavior: "ignore", se:downloadsEnabled: True, webSocketUrl: True.
  • Chrome's block: args ["--no-sandbox", "--disable-dev-shm-usage"]; prefs profile.default_content_setting_values.automatic_downloads: 1, credentials_enable_service: False, profile.password_manager_enabled: False, profile.password_manager_leak_detection: False.
  • Firefox's block: prefs browser.download.folderList: 2, browser.download.useDownloadDir: True, browser.helperApps.neverAsk.saveToDisk: "application/pdf,image/png,application/octet-stream", pdfjs.disabled: True, signon.rememberSignons: False.

The option layer, with the source option: browserName always; browserVersion unless version is unset or latest; acceptInsecureCerts always, bool(insecure), on every browser (ruling 23, Dr K); se:recordVideo: True and se:videoName when record is on; option_args into the browser's own vendor block.

The refusal copy is f"{key} cannot be sent in {where}: {OWNED[key]}", plus "; this workspace remembers it, so clear it with open_session(capabilities={})" when remembered. OWNED:

Key Value
unhandledPromptBehavior selenium-flow keeps dialogs open for the dialog tool
se:downloadsEnabled the downloads folder needs it
webSocketUrl site data, capture and the monitor need it
browserName use browser= instead
browserVersion use version= instead
acceptInsecureCerts use insecure=true or insecure=false instead
se:recordVideo, se:videoName use record=true instead
timeouts use page_load_timeout= and script_timeout= instead

A vendor block of the wrong shape (ruling 27) is f"{block}.args in {where} must be a list of strings" or f"{block}.prefs in {where} must be an object", checked by check for both vendor keys whatever the browser.

  • Step 1: Write the failing tests in tests/test_session_caps.py, one per row of the spec's two tables and per ruling:
def test_flow_sends_its_three_keys_and_its_block_on_both_browsers():
    for name in ("chrome", "firefox"):
        built = build(name)
        assert built.send["unhandledPromptBehavior"] == "ignore"
        assert built.send["se:downloadsEnabled"] is True
        assert built.send["webSocketUrl"] is True
        assert built.send["browserName"] == name
    assert build("chrome").send["goog:chromeOptions"]["args"] == ["--no-sandbox", "--disable-dev-shm-usage"]
    assert build("firefox").send["moz:firefoxOptions"]["prefs"]["signon.rememberSignons"] is False

def test_nothing_selenium_adds_on_its_own_is_sent():                  # ruling 23
    for name in ("chrome", "firefox"):
        send = build(name).send
        assert not {"pageLoadStrategy", "moz:debuggerAddress"} & send.keys()

@pytest.mark.parametrize("name", ["chrome", "firefox"])
def test_insecure_always_sends_accept_insecure_certs(name):           # ruling 23, Dr K
    assert build(name).send["acceptInsecureCerts"] is False
    assert build(name, insecure=False).send["acceptInsecureCerts"] is False
    assert build(name, insecure=True).send["acceptInsecureCerts"] is True
    assert source(build(name), "acceptInsecureCerts") == ["option"]

def test_options_produce_their_keys_with_the_option_source():
    built = build("firefox", version="142", insecure=True, record=True, video_name="w")
    assert built.send["browserVersion"] == "142"
    assert built.send["se:recordVideo"] is True and built.send["se:videoName"] == "w"
    assert source(built, "browserVersion") == ["option"]

def test_latest_and_no_version_send_no_browser_version():             # ruling 19
    assert "browserVersion" not in build("chrome", version="latest").send
    assert "browserVersion" not in build("chrome").send
    assert build("chrome", version="latest").version is None

def test_config_keys_are_sent_with_the_config_source():
    built = build("chrome", config={"kubed:pool": "default"})
    assert built.send["kubed:pool"] == "default" and source(built, "kubed:pool") == ["config"]

def test_raw_overrides_config_and_reads_raw():                        # ruling 14
    built = build("chrome", config={"kubed:pool": "default"}, raw={"kubed:pool": "headless"})
    assert built.send["kubed:pool"] == "headless" and source(built, "kubed:pool") == ["raw"]
    assert built.raw_keys == ("kubed:pool",)

@pytest.mark.parametrize("key", sorted(OWNED))
def test_an_owned_raw_key_is_refused_naming_what_controls_it(key):   # ruling 15
    with pytest.raises(ValueError, match=re.escape(f"{key} cannot be sent in capabilities: {OWNED[key]}")):
        build("chrome", raw={key: True})

def test_a_remembered_owned_key_says_how_to_clear_it():              # ruling 26
    with pytest.raises(ValueError, match=re.escape("open_session(capabilities={})")):
        check({"browserName": "chrome"}, remembered=True)

def test_vendor_args_merge_ours_first_without_duplicates():           # ruling 5
    built = build("chrome", option_args=["--x"], config={"goog:chromeOptions": {"args": ["--y", "--no-sandbox"]}},
                  raw={"goog:chromeOptions": {"args": ["--z", "--y"]}})
    assert built.send["goog:chromeOptions"]["args"] == ["--no-sandbox", "--disable-dev-shm-usage", "--x", "--y", "--z"]
    assert source(built, "goog:chromeOptions") == ["flow", "option", "config", "raw"]

def test_vendor_prefs_merge_flow_wins_and_the_loss_is_listed():       # ruling 28
    built = build("chrome", config={"goog:chromeOptions": {"prefs": {"a": 1}}},
                  raw={"goog:chromeOptions": {"prefs": {"a": 2, "credentials_enable_service": True,
                                                         "profile.password_manager_enabled": False}}})
    prefs = built.send["goog:chromeOptions"]["prefs"]
    assert prefs["a"] == 2 and prefs["credentials_enable_service"] is False
    assert built.ignored == ["goog:chromeOptions.prefs.credentials_enable_service"]

def test_other_block_keys_come_from_config_then_raw():
    built = build("chrome", config={"goog:chromeOptions": {"binary": "/a"}}, raw={"goog:chromeOptions": {"binary": "/b"}})
    assert built.send["goog:chromeOptions"]["binary"] == "/b"

def test_the_other_browsers_block_is_sent_untouched():
    built = build("chrome", raw={"moz:firefoxOptions": {"args": ["-foo"]}})
    assert built.send["moz:firefoxOptions"] == {"args": ["-foo"]} and source(built, "moz:firefoxOptions") == ["raw"]

@pytest.mark.parametrize("block", [{"args": "--lang=fr"}, {"args": [1]}, {"prefs": []}])
def test_a_vendor_block_of_the_wrong_shape_is_refused(block):        # ruling 27
    with pytest.raises(ValueError, match="goog:chromeOptions"):
        build("firefox", raw={"goog:chromeOptions": block})

def test_sent_follows_the_design_order():
    keys = [e["key"] for e in build("firefox", version="142", config={"kubed:pool": "d"}).sent]
    assert keys == ["browserName", "browserVersion", "acceptInsecureCerts", "kubed:pool", "moz:firefoxOptions",
                    "unhandledPromptBehavior", "se:downloadsEnabled", "webSocketUrl"]

def test_build_never_mutates_its_inputs():
    config, raw = {"goog:chromeOptions": {"args": ["--y"]}}, {"goog:chromeOptions": {"prefs": {"a": 1}}}
    build("chrome", config=copy.deepcopy(config), raw=copy.deepcopy(raw))
    # assert the originals compare equal to fresh copies after two builds

source(built, key) is a three-line helper in the test file returning that entry's sources.

  • Step 2: Run them to see them fail

Run: pytest tests/test_session_caps.py -v Expected: FAIL at import, kubed.selenium_flow.core.session_caps not found.

  • Step 3: Implement core/session_caps.py with the interfaces above. Deep-copy every input value that is sent, so a later merge never mutates the config or a stored record.

  • Step 4: Run the tests and the boundary test

Run: pytest tests/test_session_caps.py tests/test_boundaries.py -v && ruff check . Expected: PASS, and ruff clean.

  • Step 5: Commit feat: build the capabilities a session sends, with each key's source.

Task 2: The Grid sends exactly the built set

Files:

  • Modify: kubed/selenium_flow/core/browser.py: Grid._options (215–308), Grid.open (310–321), Grid.reconnect (323–359, unchanged in behaviour)
  • Modify tests that call the old signature: tests/test_browser_choice.py (53–121), tests/test_screenshot_saving.py (504–515 and the opening double at 550), tests/test_site_data.py:60
  • Test: tests/test_browser_choice.py

Interfaces:

  • Consumes: Built, build (Task 1).
  • Produces:
    • Grid._options(self, built: Built | None = None): a ChromeOptions or FirefoxOptions subclass instance for built.browser, with enable_bidi true, whose to_capabilities() returns a deep copy of built.send. None means build(None): cheap, valid, and what reconnect passes.
    • Grid.open(self, built: Built) -> RemoteWebDriver.

Selenium's to_capabilities() rebuilds the vendor block from the object's own fields, so the subclass overrides to_capabilities instead of setting keys into it (spec, Research).

  • Step 1: Rewrite the existing option tests against build, keeping each test's name and intent: _options("chrome", insecure=True) becomes _options(build("chrome", insecure=True)), and so on. Assert on to_capabilities(), never on an Options property such as accept_insecure_certs or experimental_options: those read Selenium's own fields, which no longer hold what is sent. The opening double in test_screenshot_saving.py takes built and asserts on built.send.get("acceptInsecureCerts"). Add:
@pytest.mark.parametrize("name", ["chrome", "firefox"])
def test_what_is_sent_is_exactly_what_was_built(name):
    built = build(name, version="9", config={"kubed:pool": "d"}, raw={"x:y": 1})
    assert Grid("http://grid.invalid")._options(built).to_capabilities() == built.send

def test_firefox_no_longer_accepts_every_certificate():               # ruling 23
    caps = Grid("http://grid.invalid")._options(build("firefox")).to_capabilities()
    assert caps["acceptInsecureCerts"] is False
    assert "moz:debuggerAddress" not in caps and "pageLoadStrategy" not in caps

def test_the_inert_options_are_valid():
    assert Grid("http://grid.invalid")._options().to_capabilities()["browserName"] == "chrome"
  • Step 2: Run them to see them fail

Run: pytest tests/test_browser_choice.py tests/test_screenshot_saving.py tests/test_site_data.py -v Expected: FAIL, _options does not take a Built.

  • Step 3: Implement _options(built=None) and open(built) as above.

  • Step 4: Run them to see them pass

Run: pytest tests/test_browser_choice.py tests/test_screenshot_saving.py tests/test_site_data.py -v && ruff check . Expected: PASS. Done differently: Actions.open_session already passes session_caps.build(name, insecure=, record=, video_name=) to grid.open, so the full suite stays green; Task 5 adds version, capabilities and the config default to that call.

  • Step 5: Commit feat: send the built capabilities and nothing Selenium adds.

Task 3: What a refused session means

Files:

  • Modify: kubed/selenium_flow/faults.py (new types)
  • Modify: kubed/selenium_flow/core/session_caps.py (not_created, asked, the constants)
  • Modify: kubed/selenium_flow/errors.py (UNAVAILABLE, status_for)
  • Modify: kubed/selenium_flow/spec/builder.py (255–264: the 400 and 503 descriptions, per action)
  • Test: tests/test_session_caps.py, tests/test_errors.py; golden tests/golden/errors.json, tests/golden/openapi.json

Interfaces:

  • Consumes: Built (Task 1), guidance.pointer.
  • Produces:
# faults.py
class NoNodeTook(ConnectionError): ...             # always 503
class NodeRefused(Exception):
    def __init__(self, text: str, *, blamed: bool): ...   # .blamed

# session_caps.py
QUEUE_TIMEOUT = ("New session request timed out", "Timed out creating session")
GRID_SIDE = ("Unable to find owning node for slot", "Interrupted when creating the session",
             "An error occurred creating the session",
             "No capabilities found in session request payload", "Client has gone away")
def asked(built: Built) -> str          # "chrome 152, kubed:pool=headless"
def not_created(said: str, built: Built) -> Exception | None

asked quotes what ruling 25 allows: browserName and browserVersion joined by a space, then platformName and every key with a : outside goog:, moz:, ms:, safari: and se:, a scalar as key=value and anything else as the key alone.

not_created takes the driver's message (faults.message(exc)) and returns:

  • for a QUEUE_TIMEOUT string: NoNodeTook(f"no Grid node took this browser before the Grid gave up waiting: it asked for {asked(built)}. The Grid may have no node with these capabilities, or none free; see {guidance.pointer('TROUBLESHOOTING.md')}");
  • for a GRID_SIDE string: None, and the caller re-raises the original, a 503 as today;
  • otherwise, with blamed = bool(built.raw_keys) or built.version is not None:
    • blamed: NodeRefused(f"the Grid node refused this browser: {said}. The usual cause is what this open asked for: {cause}; see {pointer}", blamed=True), where cause names the raw keys (names only) and version <v>;
    • not blamed: NodeRefused(f"the Grid node could not start this browser: {said}. Nothing this open asked for is unusual, so trying again may succeed", blamed=False).

errors.status_for: NodeRefused is 400 when blamed, else 503, checked before UNAVAILABLE; NoNodeTook joins UNAVAILABLE.

The POST /browser descriptions for open_session only: the 400 adds "a capability selenium-flow controls, a vendor block of the wrong shape, or a browser a Grid node refused for a capability or version you asked for"; the 503 adds "no node took the browser before the Grid's queue timed out, or a node could not start it". end_browser keeps today's text.

  • Step 1: Write the failing tests. In tests/test_session_caps.py:
def test_the_grid_strings_are_pinned():
    assert QUEUE_TIMEOUT == ("New session request timed out", "Timed out creating session")
    assert GRID_SIDE == (...)   # the five strings above, verbatim

@pytest.mark.parametrize("said", QUEUE_TIMEOUT)
def test_a_queue_timeout_is_no_node_took_with_what_was_asked(said):
    built = build("chrome", version="152", config={"kubed:pool": "headless"})
    fault = not_created(f"Could not start a new session. {said}", built)
    assert isinstance(fault, NoNodeTook)
    assert "it asked for chrome 152, kubed:pool=headless" in str(fault)

@pytest.mark.parametrize("said", GRID_SIDE)
def test_a_grid_side_failure_is_left_alone(said):
    assert not_created(said, build("chrome")) is None

def test_a_refusal_with_raw_keys_or_a_version_is_blamed():
    assert not_created("session not created: x", build("chrome", raw={"x:y": 1})).blamed
    assert not_created("session not created: x", build("chrome", version="152")).blamed

def test_a_refusal_with_only_config_is_not_blamed():
    assert not not_created("x", build("chrome", config={"kubed:pool": "d"})).blamed

def test_no_message_quotes_a_vendor_block_or_a_proxy():               # Review Focus 2
    built = build("chrome", version="1", raw={"proxy": {"httpProxy": "u:SECRET@h"},
                  "bstack:options": {"accessKey": "SECRET"}, "goog:chromeOptions": {"args": ["--SECRET"]}})
    for said in ("New session request timed out", "session not created: no"):
        assert "SECRET" not in str(not_created(said, built))

In tests/test_errors.py: NoNodeTook is 503; NodeRefused(blamed=True) is 400; NodeRefused(blamed=False) is 503; a bare SessionNotCreatedException is still 503.

  • Step 2: Run them to see them fail

Run: pytest tests/test_session_caps.py tests/test_errors.py -v Expected: FAIL, the names do not exist.

  • Step 3: Implement the faults, asked, not_created, the errors.py mapping and the descriptions.

  • Step 4: Run them, then regenerate the goldens

Run: pytest tests/test_session_caps.py tests/test_errors.py -v && GOLDEN_UPDATE=1 pytest tests/test_golden.py && git diff tests/golden/ Expected: PASS. The golden diff shows only the new error rows and the two descriptions.

  • Step 5: Commit the code, then the goldens on their own: feat: say why a session was not created, and whose fault it was; test: goldens for the session-not-created answers.

Task 4: version and capabilities as settings, and the config default

Files:

  • Modify: kubed/selenium_flow/workspace/settings.py (SETTINGS 82–97, FROM_CONFIG 101, the module docstring's count of settings)
  • Modify: kubed/selenium_flow/config.py (SessionSettings 177–191)
  • Modify: kubed/selenium_flow/server.py (after self.actions is built, 103–106)
  • Modify: kubed/selenium_flow/core/actions.py (Actions.__init__: self.config_capabilities: dict = {})
  • Test: tests/test_browser_choice.py (the resolve and from_client tests), tests/test_config_load.py, tests/test_config_wiring.py

Interfaces:

  • Consumes: check, LATEST (Task 1).
  • Produces:
    • SETTINGS["version"] = ("version", "x-version", _as_version): a stripped non-empty string, LATEST for any casing of latest, None for blank.
    • SETTINGS["capabilities"] = (None, None, _as_capabilities): a dict is copied; a JSON string that parses to an object is that object; anything else raises ValueError("capabilities must be an object"). {} is kept.
    • FROM_CONFIG gains "version".
    • SessionSettings.version: str | None = Field(None, description="The browser version new sessions ask for, such as 152.")
    • SessionSettings.capabilities: Annotated[dict[str, JsonValue], NoDecode] = Field(default_factory=dict, description="W3C capabilities every new session sends, under the caller's."), with a mode="before" validator that json.loads a string, and an after validator calling check(value, where="session.capabilities").
    • Actions.config_capabilities: dict, set by server.py to dict(settings.session.capabilities).

CLIENT_PARAMS and CLIENT_HEADERS in workspace/workspaces.py are derived from SETTINGS, so ?version= and X-Version need no change there. routes.opened passes every SETTINGS key of the body, so the endpoint takes both arguments with no change either.

  • Step 1: Write the failing tests
def test_version_comes_from_the_client_and_latest_is_kept():
    assert from_client({"version": "152"}, {})["version"] == "152"
    assert from_client({}, {"x-version": " 9 "})["version"] == "9"
    assert resolve({"version": "Latest"}, previous={"version": "152"})["version"] == "latest"

def test_capabilities_have_no_client_layer_and_an_empty_object_clears():
    assert "capabilities" not in from_client({"capabilities": "{}"}, {"x-capabilities": "{}"})
    assert resolve({"capabilities": {}}, previous={"capabilities": {"a:b": 1}})["capabilities"] == {}

def test_capabilities_as_a_json_string_are_read_and_anything_else_refused():
    assert resolve({"capabilities": '{"a:b": 1}'})["capabilities"] == {"a:b": 1}
    with pytest.raises(ValueError, match="capabilities must be an object"):
        resolve({"capabilities": "[1]"})

In tests/test_config_load.py: SESSION_CAPABILITIES='{"kubed:pool": "default"}' loads as that dict; SESSION_VERSION=152 loads; SESSION_CAPABILITIES='{"browserName": "x"}' raises ConfigError whose text contains browserName cannot be sent in session.capabilities: use browser= instead; a file with session: {capabilities: {kubed:pool: default}} loads. In tests/test_config_wiring.py: a server built with that setting has server.actions.config_capabilities == {"kubed:pool": "default"}, and workspace_settings.from_settings includes version.

  • Step 2: Run them to see them fail

Run: pytest tests/test_browser_choice.py tests/test_config_load.py tests/test_config_wiring.py -v Expected: FAIL.

  • Step 3: Implement the settings, the config fields and the wiring.

  • Step 4: Run them to see them pass

Run: pytest tests/test_browser_choice.py tests/test_config_load.py tests/test_config_wiring.py tests/test_config_schema.py tests/test_admin_settings.py -v && ruff check . Expected: PASS. If the Settings tab's admin test pins the leaf list, it gains the two leaves.

  • Step 5: Commit feat: version and capabilities settings, and session.capabilities in config.

Done beyond the plan: putting version and capabilities in SETTINGS sends them to Actions.open_session as keywords (open_browser's **resolved, a reopen's **replay), so it already takes version= and capabilities= and passes them to build with config=self.config_capabilities; tests/test_session_open.py holds that. The Settings tab shows an object setting as its JSON, — when empty (SettingsPane.svelte), and the wiki's Configuration page is regenerated.


Task 5: Actions.open_session builds, opens, and reads what ran

Files:

  • Modify: kubed/selenium_flow/core/actions.py: open_session (220–306), _set_up (308–421)
  • Test: tests/test_session_open.py (new), tests/test_capture_open.py (StubGrid.open takes built)

Interfaces:

  • Consumes: build, not_created (Tasks 1 and 3), Grid.open(built) (Task 2), Actions.config_capabilities (Task 4).
  • Produces: Actions.open_session(..., version=None, capabilities=None, ...), with version and capabilities beside browser as keywords, so a reopen's **replay reaches them. Its result gains:
    • version: ran["version"];
    • ignored: built.ignored, only when not empty;
    • settings.version when version was given (latest included), and settings.capabilities when the raw object is not empty;
    • private sent: built.sent, and ran: {"version": str | None, "node": str | None, "live_view": bool | None}, read from getattr(driver, "caps", None) or {} (browserVersion, se:containerName, se:vncEnabled).

Already there from Task 4: the two keywords, and build(..., version=, config=self.config_capabilities, raw=capabilities); tests/test_session_open.py exists with its stub Grid/Driver, and the first test below is in it. build runs before grid.open, so its ValueError costs no browser. A SessionNotCreatedException from grid.open is passed through not_created(faults.message(exc), built): a fault it returns is raised from exc, and None re-raises the original. No browser exists at that point, so nothing is quit.

  • Step 1: Write the failing tests in tests/test_session_open.py, with a stub Grid whose open(built) records built and returns a driver stub with caps:
def test_the_open_sends_the_built_set_with_the_config_default():
    actions = Actions(grid); actions.config_capabilities = {"kubed:pool": "default"}
    actions.open_session(browser="chrome", version="152", capabilities={"x:y": 1})
    assert grid.built.send["kubed:pool"] == "default" and grid.built.send["x:y"] == 1
    assert grid.built.send["browserVersion"] == "152"

def test_the_result_says_what_ran_and_keeps_sent_and_ran_for_the_workspace():
    result = Actions(grid_with_caps({"browserVersion": "152.0.1", "se:containerName": "node-a",
                                     "se:vncEnabled": True})).open_session(version="152")
    assert result["version"] == "152.0.1" and result["settings"]["version"] == "152"
    assert result["ran"] == {"version": "152.0.1", "node": "node-a", "live_view": True}
    assert [e["key"] for e in result["sent"]][0] == "browserName"
    assert "ignored" not in result

def test_an_owned_key_opens_nothing():
    with pytest.raises(ValueError):
        Actions(grid).open_session(capabilities={"browserName": "x"})
    assert grid.built is None

def test_a_queue_timeout_raises_no_node_took():
    grid.fails = SessionNotCreatedException("New session request timed out")
    with pytest.raises(NoNodeTook):
        Actions(grid).open_session()

def test_a_grid_side_failure_is_raised_as_it_was():
    grid.fails = SessionNotCreatedException("Unable to find owning node for slot")
    with pytest.raises(SessionNotCreatedException):
        Actions(grid).open_session()
  • Step 2: Run them to see them fail

Run: pytest tests/test_session_open.py -v Expected: FAIL.

  • Step 3: Implement as above. StubGrid.open in tests/test_capture_open.py already takes one positional argument and needs no change unless it reads name.

  • Step 4: Run the whole suite

Run: pytest && ruff check . Expected: PASS except tests that pin open_session's result or tool schema, which Tasks 6 and 7 settle. List any other failure before going on.

  • Step 5: Commit feat: open_session sends version and capabilities, and reports what ran.

Done beyond the plan: actions.OPEN_PRIVATE = ("session_id", "sent", "ran"), and open_browser already drops all three from what it tells a caller, so nothing leaks before Task 6 stores them. A node's reason also loses Selenium's "; For documentation on this error, please visit: …" suffix (4.51 adds it to the message), tested in test_session_caps.py.


Task 6: The workspace remembers the inputs and keeps the session's facts

Files:

  • Modify: kubed/selenium_flow/workspace/store.py: Workspace (97–139), from_json (168–193), detached (256–262), new readers beside _timeout (280)
  • Modify: kubed/selenium_flow/workspace/workspaces.py: describe (339–415), resolved (485–560), open_browser (816–936), remember (957–1032), end_browser (1110–1164)
  • Test: tests/test_workspaces.py, tests/test_history.py (if it pins to_json's fields), tests/test_recording_open.py and tests/test_workspace_monitor.py (doubles that return no sent)

Interfaces:

  • Consumes: check (Task 1); Actions.open_session's version, ignored, sent, ran (Task 5).
  • Produces:
    • Workspace.sent: list = [], Workspace.ran: dict | None = None, Workspace.ended: dict | None = None, read by _sent, _ran, _ended. _sent keeps entries with a string key and a list of known sources; _ran keeps version/node as str | None and live_view as bool | None; _ended keeps {"at": float, "by": "admin" | "caller"}.
    • Workspace.detached(self, ended: dict | None = None) -> Workspace: no browser, keeps sent and ran, sets ended.
    • Workspaces.remember(..., sent: list | None = None, ran: dict | None = None): writes both and sets ended=None.
    • describe() gains "version": (record.ran or {}).get("version"), None without a record.

In open_browser, before resolve: when wanted.get("browser") is given and normalize_browser of it differs from the inherited browser, drop version from inherited_settings (ruling 20). After resolve, with the record and capture checks and before end_browser: session_caps.check(resolved.get("capabilities")). The told result drops sent and ran, as it drops session_id. In resolved (the reopen), call session_caps.check(replay.get("capabilities"), remembered=True) before _opened (ruling 26), so a remembered key that became owned is refused in words that say how to clear it, and no browser is opened. end_browser detaches with ended={"at": time.time(), "by": "admin" if caller.named_by == "admin" else "caller"}.

  • Step 1: Write the failing tests in tests/test_workspaces.py, using manager() and RecordingActions from conftest.py (extend RecordingActions.open_session to return sent, ran and version when given them):
def test_an_owned_key_is_refused_before_the_held_browser_ends():          # Review Focus 4
    ws = opened_workspace()
    with pytest.raises(ValueError, match="browserName"):
        ws.open_browser(caller, capabilities={"browserName": "x"})
    with pytest.raises(ValueError, match="goog:chromeOptions.args"):
        ws.open_browser(caller, capabilities={"goog:chromeOptions": {"args": "--x"}})
    assert ws.actions.ended == []

def test_version_and_capabilities_are_remembered_and_carried():
    ws.open_browser(caller, version="152", capabilities={"kubed:pool": "a"})
    ws.open_browser(caller)
    assert ws.actions.last_open["version"] == "152" and ws.actions.last_open["capabilities"] == {"kubed:pool": "a"}

def test_an_empty_object_and_latest_clear_what_was_remembered():
    ws.open_browser(caller, version="152", capabilities={"kubed:pool": "a"})
    ws.open_browser(caller, version="latest", capabilities={})
    settings = ws.store.get(name).settings
    assert settings["version"] == "latest" and settings["capabilities"] == {}

def test_switching_browser_drops_a_remembered_version():                  # ruling 20
    ws.open_browser(caller, browser="chrome", version="152")
    ws.open_browser(caller, browser="firefox")
    assert "version" not in ws.actions.last_open

def test_a_reap_replays_version_and_capabilities(): ...                   # reopen through resolve

def test_a_reopen_refuses_a_remembered_key_that_became_owned():           # Review Focus 3
    store_record(settings={"browser": "chrome", "capabilities": {"browserName": "x"}}, dead=True)
    with pytest.raises(ValueError, match=re.escape("open_session(capabilities={})")):
        ws.resolve(name)

def test_the_record_keeps_sent_and_ran_and_who_ended_it():
    ws.open_browser(caller)
    record = ws.store.get(name)
    assert record.sent and record.ran["version"] and record.ended is None
    ws.end_browser(Caller(name, "admin"))
    ended = ws.store.get(name)
    assert ended.sent == record.sent and ended.ended["by"] == "admin"
    ws.open_browser(caller); ws.end_browser(caller)
    assert ws.store.get(name).ended["by"] == "caller"

def test_the_result_never_carries_sent_or_ran():
    told = ws.open_browser(caller)
    assert "sent" not in told and "ran" not in told and "session_id" not in told

def test_describe_reports_the_version_that_ran(): ...

@pytest.mark.parametrize("raw", [{}, {"sent": "x", "ran": [], "ended": {"by": "nobody"}},
                                 {"sent": [{"key": 1}], "ran": {"version": 5}, "ended": {"at": "x"}}])
def test_old_and_malformed_records_read_as_none(raw):                     # Review Focus 5
    record = Workspace.from_json(json.dumps({"session_id": "a", **raw}))
    assert record.sent == [] and record.ended is None
    assert record.ran in (None, {"version": None, "node": None, "live_view": None})
  • Step 2: Run them to see them fail

Run: pytest tests/test_workspaces.py -v Expected: FAIL.

  • Step 3: Implement as above.

  • Step 4: Run the suite

Run: pytest && ruff check . Expected: PASS except the tool schema and response goldens (Task 7).

  • Step 5: Commit feat: workspaces remember version and capabilities, and keep what each session sent.

Done beyond the plan: workspace://current's published schema (spec/schemas.py, RESPONSES["current_workspace"]) gains version, so the OpenAPI golden and the current_workspace wiki page move here rather than in Task 7. RecordingActions.open_session returns sent and ran (never version).


Task 7: The tool, the endpoint and the published contract

Files:

  • Modify: kubed/selenium_flow/mcp/tools.py: open_session (357–409)
  • Modify: kubed/selenium_flow/core/capabilities.py: the open_session row's response (214–254)
  • Modify: docs/notes/open_session.notes.md (in the wiki submodule) only if its prose contradicts the new docstring
  • Regenerate: tests/golden/tools-on.json, tools-off.json, openapi.json; the wiki pages with python scripts/generate_wiki.py
  • Test: tests/test_surfaces.py, tests/test_openapi.py, tests/test_wiki.py, tests/test_routes.py

Interfaces:

  • Consumes: Workspaces.open_browser(caller, ..., version=, capabilities=) (Task 6).
  • Produces: the tool's signature gains version: str | None = None after browser, and capabilities: dict[str, JsonValue] | None = None last before fresh. The response schema gains version ({"type": ["string", "null"], "description": "The browser version the Grid started."}) and ignored ({"type": "array", "items": {"type": "string"}, "description": "Vendor-block entries in capabilities that selenium-flow's own settings overrode."}).

The docstring is prompt (AGENTS.md). Rewrite it as one set of named options, then the escape hatch, in this order and these facts:

  1. What it does, and that no arguments come back to the same browser, window and page (today's first two paragraphs).
  2. browser and version: version="152" asks the Grid for that version and the result says which ran; version="latest" forgets a remembered one; switching browser forgets it too. Both are remembered.
  3. width/height, page_load_timeout/script_timeout, insecure: remembered (today's sentences).
  4. record, capture: not inherited (today's sentences).
  5. fresh, restore_site_data (today's sentences).
  6. capabilities: "Only when no option above covers what you need: extra W3C capabilities sent as given, best effort, and remembered whole; {} forgets them. A key an option controls is refused with the option to use. Inside goog:chromeOptions or moz:firefoxOptions your args and prefs are added to selenium-flow's, and the result's ignored lists any of yours that lost."
  • Step 1: Write the failing test in tests/test_routes.py: POST /browser with {"version": "152", "capabilities": {"kubed:pool": "a"}} reaches open_browser with both; {"capabilities": {"browserName": "x"}} answers 400 with the refusal text. In tests/test_surfaces.py, the tool's input schema has version and capabilities, and capabilities accepts nested JSON (an MCP call with {"goog:chromeOptions": {"args": ["--a"]}} validates).

  • Step 2: Run it to see it fail

Run: pytest tests/test_routes.py tests/test_surfaces.py -v Expected: FAIL.

  • Step 3: Implement the signature, the docstring and the response fields.

  • Step 4: Regenerate and check every published shape

Run: GOLDEN_UPDATE=1 pytest tests/test_golden.py && python scripts/generate_wiki.py && python scripts/generate_openapi.py && pytest && ruff check . Expected: PASS, test_request_schemas_are_the_tool_schemas and tests/test_wiki.py included. Read the golden diff: only open_session's schema and response move.

  • Step 5: Commit the code, then the goldens, then the wiki pages (a commit inside docs/ and the pointer bump): feat: open_session takes version and capabilities on both surfaces.

Task 8: The admin payload and the Session tab

Files:

  • Modify: kubed/selenium_flow/http/admin/workspaces.py: the row (501–560)
  • Modify: ui/src/lib/types.ts (WorkspaceRow), ui/src/lib/WorkspaceSummary.svelte (the masthead), ui/src/admin/router.svelte.ts, ui/src/admin/WorkspaceDetail.svelte (311–345), ui/src/admin/Section.svelte, ui/src/admin/SettingsPane.svelte (68–71), ui/src/app.css
  • Create: ui/src/admin/SessionPane.svelte, ui/src/admin/SessionPane.test.ts
  • Modify: tests/integration/flows/keep-a-screenshot.yaml:25 (#/workspaces/${workspace}/files)
  • Test: tests/test_files_and_admin.py (the row), golden tests/golden/admin-workspaces.json; ui/src/admin/router.test.ts, ui/src/lib/WorkspaceSummary.test.ts, ui/src/admin/WorkspaceDetail.test.ts, ui/src/admin/Section.test.ts

Interfaces:

  • Consumes: Workspace.sent, .ran, .ended, .settings["version"] (Task 6).
  • Produces:
    • The row gains sent (list, [] when none), ran (object or null), ended (object or null) and asked_version (settings.version, or null).
    • WorkspaceRow gains sent?: SentCapability[], ran?: { version: string | null; node: string | null; live_view: boolean | null } | null, ended?: { at: number; by: 'admin' | 'caller' } | null, asked_version?: string | null, with interface SentCapability { key: string; value: unknown; sources: ('flow' | 'config' | 'option' | 'raw')[] }.
    • Tab gains 'session', first in the tab bar; parse defaults to 'session' and accepts 'files' as a segment; hashes.workspace(key) is '#/workspaces/<key>' (Session) and hashes.files(key) is '#/workspaces/<key>/files'.
    • Section gains collapsed?: boolean (default false): folds[id] ?? !collapsed.
    • The .pill.src.* rules move from SettingsPane.svelte to app.css, with flow styled as default, option as env and raw as args.

SessionPane.svelte draws design/session.md:

  • WORKSPACE: BROWSER ${browser} · ${asked_version ?? 'latest'} (latest also when asked_version is latest); WINDOW; RECORD on only when live and recording; CAPTURE (captureFact) only when live.
  • GRID SESSION, only when started or ran is set: BROWSER ${browser} ${ran?.version ?? version}; ID; NODE (ran?.node ?? node); STARTED; IDLE TIMEOUT (idle(...)); then LIVE VIEW yes/no from ran?.live_view when live, else ENDED per the spec's table: ended gives <toLocaleString> · ended by the admin|caller; otherwise attached && !live gives reaped after ${grid_timeout} s idle, or gone from the Grid with no timeout.
  • Capabilities: <Section id="capabilitiesSection" title="Capabilities" count={sent.length} collapsed>, one mono row per entry: key, value (a vendor block as args N · prefs N, listing only the parts present; any other object as compact JSON), and one pill src <source> per source.

The masthead (WorkspaceSummary.svelte) keeps the mark, name, pills and last page and loses its groups; the pills move unchanged (#83 renames them).

  • Step 1: Write the failing tests. Python: the admin row carries the four fields for a record with them, and []/null for an old record. Vitest, in SessionPane.test.ts:
it('shows the asked browser as latest when no version was asked', ...)        // 'chrome · latest'
it('shows what ran, and LIVE VIEW yes from se:vncEnabled', ...)
it('shows ENDED by the admin on an idle workspace and no RECORD or CAPTURE row', ...)
it('says reaped after 300 s idle when the Grid no longer lists the browser', ...)
it('hides the GRID SESSION card when no browser was ever opened', ...)
it('starts Capabilities collapsed, counts the rows, and opens on a click', ...)
it('summarises a vendor block and draws one pill per source', ...)       // 'prefs 6', pills flow + raw
it('draws a row with no sent, ran or ended without error', ...)           // Review Focus 5

router.test.ts: parse('#/workspaces/k') is the session tab; parse('#/workspaces/k/files') is files; hashes.files('k'). Section.test.ts: a collapsed section starts closed. WorkspaceDetail.test.ts: the Session tab is first and selected on #/workspaces/k, and the Files tab goes to /files.

  • Step 2: Run them to see them fail

Run: pytest tests/test_files_and_admin.py -v && npm --prefix ui ci && npm --prefix ui test Expected: FAIL.

  • Step 3: Implement the payload, the router, Section.collapsed, the pills, SessionPane and the masthead; update keep-a-screenshot.yaml.

  • Step 4: Run every check

Run: GOLDEN_UPDATE=1 pytest tests/test_golden.py && pytest && ruff check . && 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, open a workspace, and compare the three states with design/session.md (the text wins over png/session-idle.png).

  • Step 5: Commit the code, then the admin golden: feat: the admin UI's Session tab shows what was asked, what ran and what was sent.

Done as: the two cards reuse the header's .groups / .group / .fact styles, which already drew the design's cards. The masthead (WorkspaceSummary.svelte) keeps the mark, name, pills and last page; its tests for the facts moved to SessionPane.test.ts, and HistoryPane.test.ts's tab-order test now starts with Session. The pane waits for the row (m.headed) rather than drawing a default browser. The admin golden's detached workspace now carries sent, ran, ended and a version, so the shape is pinned. Not checked by eye against design/session.md in a running UI: no Grid here.


Task 9: The skill and the changelog

Files:

  • Modify: skills/selenium-flow/references/WORKSPACES.md (new section Choosing the browser), skills/selenium-flow/references/TROUBLESHOOTING.md (three entries), skills/selenium-flow/references/CONFIGURATION.md (SESSION_VERSION, SESSION_CAPABILITIES, ?version= / X-Version), skills/selenium-flow/SKILL.md (one routing row, 111–125)
  • Modify: CHANGELOG.md (## [Unreleased])
  • Test: tests/test_skill.py, tests/test_guidance.py, tests/test_readme.py

Interfaces: none produced. The skill quotes the error texts from Task 3 and the refusal copy from Task 1 exactly.

Content, written for a model deciding what to do next:

  • Choosing the browser: the named options as one set (what each controls, which are remembered, which are not); version="152" and reading version back from the result; version="latest"; a browser switch forgetting the version.
  • TROUBLESHOOTING: Sending a capability no option covers (the escape hatch, last resort, owned keys refused with the option named, vendor blocks merged and ignored); No node took this browser (the 503: check version and the capabilities you sent, then the Grid console; it waited as long as the Grid does); A node refused the browser (the 400: a raw key or the version is the usual cause; drop it with capabilities={} or version="latest"; the 503 form means retry).
  • SKILL.md row: | Asking for a browser version, or a capability no option covers | skill://selenium-flow/references/WORKSPACES.md |.

Changelog lines, under ## [Unreleased], at the top:

  • `open_session` takes `version` and raw `capabilities`, and `session.capabilities` sends a default with every browser; the admin UI's new Session tab shows what was asked, what ran and what was sent.

  • Fixed: Firefox accepts a self-signed certificate only with `insecure=true`, like Chrome.

  • Step 1: Write the content.

  • Step 2: Run the skill and README tests

Run: pytest tests/test_skill.py tests/test_guidance.py tests/test_readme.py tests/test_skills_extension.py -v && ruff check . Expected: PASS: every linked reference exists and every file is linked.

  • 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: the skill learns version and capabilities and chore: changelog for session capabilities.

Clone this wiki locally