Repository navigation
2026 10 26 session capabilities
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.
The spec is the design. What this plan settles beyond it:
-
Names. The builder is
session_caps.build(...) -> Built; the owned-key check issession_caps.check(...); the failure classifier issession_caps.not_created(...). The faults arefaults.NoNodeTook(always 503) andfaults.NodeRefused(400 whenblamed, else 503). -
Where the config capabilities live.
server.pysetsactions.config_capabilities = settings.session.capabilities, the way it already setsactions.hideandactions.capture.Actions.open_sessionpasses them tobuild. -
Where the owned-key 400 is raised early.
Workspaces.open_browsercallssession_caps.check(resolved.get("capabilities"))beside the record and capture checks, beforeend_browser.buildruns the same check, so the reopen path (ruling 26) is covered without a second rule. -
What
Actions.open_sessionreturns beyond today:version(as ran),ignored(only when not empty), and two private keys,sentandran, thatWorkspacesstores 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.
- Python 3.11 is the floor (
CONTRIBUTING.md): nothing 3.12+. -
core/session_caps.pyimports noseleniumand no protocol library. It joinsNO_SELENIUMintests/test_boundaries.py, becauseconfig.py, which is in that set, imports it. - No log line formats a capability value, the sent set,
ran.nodewith a Grid id, or a raw object. Errors quote only what ruling 25 allows. - No
session_idin any result a caller reads (AGENTS.md, "One contract, and no id in it").sentandrannever reach a model;ran.versiondoes, asversion. - An argument never changes shape (
CONTRIBUTING.md, "Changing an argument's shape").versionandcapabilitiesare new optional arguments. - The tool's type hints are its schema:
version: str | None,capabilities: dict[str, JsonValue] | None, withJsonValuefrompydantic. - 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.pyinside thedocs/submodule, andtests/test_wiki.pypasses. - Changelog: only
## [Unreleased], a flat list with no headings, one short line per entry, a fix prefixedFixed:(CHANGELOG.mdas it stands). - No new integration flow. One existing flow changes its URL (ruling 31).
- 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. - The OpenAPI lint (
quality.yml) runs on the generated document:python scripts/generate_openapi.pymust succeed.
- Python (
-
A Firefox browser, with
insecureon or off. Selenium's Firefox default used to sendacceptInsecureCerts: truewhateverinsecuresaid. A person expects Firefox to followinsecureexactly as Chrome does, and an insecure browser of either kind to get no saved site data. Task 1 pinsacceptInsecureCertsasFalseby default andTruewithinsecure, on both browsers, with the sourceoption. Task 2 pins that_options(build("firefox")).to_capabilities()carriesacceptInsecureCerts: Falseand nomoz:debuggerAddressorpageLoadStrategy. -
A raw
capabilitiescarrying a credential: a cloud vendor's options with an access key, or aproxywith 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 norproxy. -
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. -
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. -
A workspace record written before this change: no
sent,ranorended, settings withoutversion. A person expects the admin list, the Session tab andworkspace://currentto show it without error. Task 6 testsfrom_jsonon old and malformed records; Task 8 tests the Session tab with none of the three fields.
Files:
- Create:
kubed/selenium_flow/core/session_caps.py - Test:
tests/test_session_caps.py - Modify:
tests/test_boundaries.py(NO_SELENIUMgains"core.session_caps")
Interfaces:
- Consumes:
CHROME,FIREFOX,normalize_browserfromcore/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 ValueErrorThe 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"];prefsprofile.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:
prefsbrowser.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 buildssource(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.pywith 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.
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 theopeningdouble 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): aChromeOptionsorFirefoxOptionssubclass instance forbuilt.browser, withenable_biditrue, whoseto_capabilities()returns a deep copy ofbuilt.send.Nonemeansbuild(None): cheap, valid, and whatreconnectpasses. -
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 onto_capabilities(), never on an Options property such asaccept_insecure_certsorexperimental_options: those read Selenium's own fields, which no longer hold what is sent. Theopeningdouble intest_screenshot_saving.pytakesbuiltand asserts onbuilt.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)andopen(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.
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; goldentests/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 | Noneasked 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_TIMEOUTstring: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_SIDEstring: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), wherecausenames the raw keys (names only) andversion <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).
- blamed:
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, theerrors.pymapping 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.
Files:
- Modify:
kubed/selenium_flow/workspace/settings.py(SETTINGS82–97,FROM_CONFIG101, the module docstring's count of settings) - Modify:
kubed/selenium_flow/config.py(SessionSettings177–191) - Modify:
kubed/selenium_flow/server.py(afterself.actionsis built, 103–106) - Modify:
kubed/selenium_flow/core/actions.py(Actions.__init__:self.config_capabilities: dict = {}) - Test:
tests/test_browser_choice.py(theresolveandfrom_clienttests),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,LATESTfor any casing oflatest, 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 raisesValueError("capabilities must be an object").{}is kept. -
FROM_CONFIGgains"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 amode="before"validator thatjson.loadsa string, and an after validator callingcheck(value, where="session.capabilities"). -
Actions.config_capabilities: dict, set byserver.pytodict(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.
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.opentakesbuilt)
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, ...), withversionandcapabilitiesbesidebrowseras keywords, so a reopen's**replayreaches them. Its result gains:-
version:ran["version"]; -
ignored:built.ignored, only when not empty; -
settings.versionwhenversionwas given (latestincluded), andsettings.capabilitieswhen the raw object is not empty; - private
sent:built.sent, andran:{"version": str | None, "node": str | None, "live_view": bool | None}, read fromgetattr(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 whoseopen(built)recordsbuiltand returns a driver stub withcaps:
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.openintests/test_capture_open.pyalready takes one positional argument and needs no change unless it readsname. -
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.
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 pinsto_json's fields),tests/test_recording_open.pyandtests/test_workspace_monitor.py(doubles that return nosent)
Interfaces:
- Consumes:
check(Task 1);Actions.open_session'sversion,ignored,sent,ran(Task 5). - Produces:
-
Workspace.sent: list = [],Workspace.ran: dict | None = None,Workspace.ended: dict | None = None, read by_sent,_ran,_ended._sentkeeps entries with a stringkeyand a list of knownsources;_rankeepsversion/nodeasstr | Noneandlive_viewasbool | None;_endedkeeps{"at": float, "by": "admin" | "caller"}. -
Workspace.detached(self, ended: dict | None = None) -> Workspace: no browser, keepssentandran, setsended. -
Workspaces.remember(..., sent: list | None = None, ran: dict | None = None): writes both and setsended=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, usingmanager()andRecordingActionsfromconftest.py(extendRecordingActions.open_sessionto returnsent,ranandversionwhen 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).
Files:
- Modify:
kubed/selenium_flow/mcp/tools.py:open_session(357–409) - Modify:
kubed/selenium_flow/core/capabilities.py: theopen_sessionrow'sresponse(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 withpython 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 = Noneafterbrowser, andcapabilities: dict[str, JsonValue] | None = Nonelast beforefresh. The response schema gainsversion({"type": ["string", "null"], "description": "The browser version the Grid started."}) andignored({"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:
- What it does, and that no arguments come back to the same browser, window and page (today's first two paragraphs).
-
browserandversion:version="152"asks the Grid for that version and the result says which ran;version="latest"forgets a remembered one; switchingbrowserforgets it too. Both are remembered. -
width/height,page_load_timeout/script_timeout,insecure: remembered (today's sentences). -
record,capture: not inherited (today's sentences). -
fresh,restore_site_data(today's sentences). -
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'signoredlists any of yours that lost."
-
Step 1: Write the failing test in
tests/test_routes.py:POST /browserwith{"version": "152", "capabilities": {"kubed:pool": "a"}}reachesopen_browserwith both;{"capabilities": {"browserName": "x"}}answers 400 with the refusal text. Intests/test_surfaces.py, the tool's input schema hasversionandcapabilities, andcapabilitiesaccepts 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.
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), goldentests/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) andasked_version(settings.version, or null). -
WorkspaceRowgainssent?: SentCapability[],ran?: { version: string | null; node: string | null; live_view: boolean | null } | null,ended?: { at: number; by: 'admin' | 'caller' } | null,asked_version?: string | null, withinterface SentCapability { key: string; value: unknown; sources: ('flow' | 'config' | 'option' | 'raw')[] }. -
Tabgains'session', first in the tab bar;parsedefaults to'session'and accepts'files'as a segment;hashes.workspace(key)is'#/workspaces/<key>'(Session) andhashes.files(key)is'#/workspaces/<key>/files'. -
Sectiongainscollapsed?: boolean(default false):folds[id] ?? !collapsed. - The
.pill.src.*rules move fromSettingsPane.sveltetoapp.css, withflowstyled asdefault,optionasenvandrawasargs.
- The row gains
SessionPane.svelte draws design/session.md:
-
WORKSPACE: BROWSER
${browser} · ${asked_version ?? 'latest'}(latestalso whenasked_versionislatest); WINDOW; RECORDononly when live and recording; CAPTURE (captureFact) only when live. -
GRID SESSION, only when
startedorranis set: BROWSER${browser} ${ran?.version ?? version}; ID; NODE (ran?.node ?? node); STARTED; IDLE TIMEOUT (idle(...)); then LIVE VIEWyes/nofromran?.live_viewwhen live, else ENDED per the spec's table:endedgives<toLocaleString> · ended by the admin|caller; otherwiseattached && !livegivesreaped after ${grid_timeout} s idle, orgone from the Gridwith no timeout. -
Capabilities:
<Section id="capabilitiesSection" title="Capabilities" count={sent.length} collapsed>, one mono row per entry: key, value (a vendor block asargs N · prefs N, listing only the parts present; any other object as compact JSON), and onepill 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, inSessionPane.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 5router.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,SessionPaneand the masthead; updatekeep-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.
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 readingversionback 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: checkversionand 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 withcapabilities={}orversion="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 capabilitiesandchore: changelog for session capabilities.
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