Repository navigation
2026 10 29 sf headers
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: selenium-flow's seven client headers are spelt Sf-…. Every old X- name is refused in one 400 that names each new header, and the retired session names, X-Session-Key and ?session=, are no longer known at all. X-MCP-Resources and every query parameter stay as they are.
Architecture: WORKSPACE_HEADER and the header column of SETTINGS carry the new names. CLIENT_HEADERS and from_client are derived from that column, so they follow with no code of their own. One table, OLD_HEADERS, replaces OLD_HEADER_NAME/OLD_HEADER, and Caller.from_request turns whatever old names it finds into one lazy refusal, in the place X-Session-Key's is set today. Everything else is words: what a model, a generated client and an operator read.
Tech Stack: Python 3.11+, FastMCP 4, Starlette, pytest. No ui/ change.
Revised 10-29, after Tasks 1 and 2 landed: Dr K revised ruling 10. The session names, X-Session-Key and ?session=, are retired rather than refused, and ruling 13 follows: ?session= is an unknown query parameter. Task 3 takes out what Tasks 1 and 2 built for them, and the docs task, now Task 4, says it. Tasks 1 and 2 are kept as they ran; where they name X-Session-Key or OLD_QUERY, Task 3 supersedes them.
Spec: docs/superpowers/specs/2026-10-29-sf-headers-design.md, settled on #90: rulings 1–13 stand, ruling 10 as revised, and this plan adds no ruling. Two of its line counts were short, and the tasks below cover the rest: README names the old headers at 48, 102, 105, 350 and 395, not only 102, and spec/schemas.py:212, spec/builder.py:660 and the wiki's Deployment.md:187 need the new name too.
The spec is the design. This plan settles the following where the spec left the call to it:
-
The table in
workspace/workspaces.pyis keyed by the spelling a person reads. A request's headers are looked up byold.lower(), sincevalues_oflowercases them. The entries are in the spec's order, and the refusal follows that order, not the order the request sent them in:OLD_HEADERS = { "X-Session-Key": "Sf-Workspace", "X-Workspace": "Sf-Workspace", "X-Browser": "Sf-Browser", "X-Version": "Sf-Version", "X-Window-Width": "Sf-Window-Width", "X-Window-Height": "Sf-Window-Height", "X-Page-Load-Timeout": "Sf-Page-Load-Timeout", "X-Script-Timeout": "Sf-Script-Timeout", } RENAMED = "`{old}` is now `{new}`: rename the header"
-
_old_headers(headers: Mapping[str, list[str]]) -> str | Nonereturns"; ".join(RENAMED.format(old=old, new=new) for every old name whose _names(...) is non-empty), orNonewhen there are none. A blank value is absent, because_namesdrops blanks. -
from_request's order.?session=is checked first and keeps precedence (OLD_QUERYalone). Then_old_headers, so with an old header the caller getsnamed, named_by, refusal = "", "", ValueError(that).OLD_HEADER_NAMEandOLD_HEADERgo, and nothing imports them. -
The copy that changes, and only the header's name changes in it:
-
UNNAMED: "…add ?workspace= to the URL, or send an Sf-Workspace header. …" -
BOTH: "…this request carries both an Sf-Workspace header and ?workspace=, …" - The handshake (
mcp/tools.py:166-167): "…or send an Sf-Workspace header. …" -
VERSION_FROM["client"]:"from Sf-Version or ?version=". - The OpenAPI header parameter's name is
Sf-Workspace. Its description ends: "Its old names, X-Workspace and X-Session-Key, are refused with a 400."
-
-
CARRIED = "sf-carried-workspace". Nothing else inmcp/clients.pychanges but its two comments: line 8 (X-MCP-Resourcesstays) and line 74, "never by an Sf-Workspace the snapshot kept". -
The gateway sentence, used word for word in AGENTS.md, the
workspaces.pydocstring and the wiki'sDeployment.md: Behind agentgateway, the gateway setsSf-Workspace, from?workspace=, the caller'sSf-Workspaceor the token's user, and drops the query string. A caller's oldX-header crosses and is refused.
- Python 3.11 is the floor: nothing 3.12+.
- No aliases (ruling 2). Nothing reads an old name except to refuse it.
-
X-MCP-Resources,?resources=, the query parameters, the OpenAPIx-mcp-*extensions and the response headers do not change (ruling 4, What does not change). - Earlier specs and plans,
docs/saga/, and the shipped CHANGELOG sections are history and are not edited. - Every published shape that moves is a golden. Regenerate it with
GOLDEN_UPDATE=1 pytest tests/test_golden.py, read the diff, and commit the goldens on their own (CONTRIBUTING.md). - Wiki pages are regenerated with
python scripts/generate_wiki.py, andtests/test_wiki.pypasses. The wiki is thedocs/submodule: a commit inside it, then the pointer here. - No new integration flow (AGENTS.md: less is more).
tests/integration/changes only its two literals. - Never commit
openapi.yamlorkubed/selenium_flow/http/static/. - The checks, as CI runs them:
- Python (
test.yml):ruff check .andpytest. The integration tests need a Grid and run in CI. - OpenAPI (
quality.yml):python scripts/generate_openapi.pysucceeds. - Changelog (
pr.yml): a new line under## [Unreleased].
- Python (
-
A runner profile that still sends
X-Workspace: deskthrough the gateway, which now setssf-workspace: kellybeside it. A person expects a 400 saying "X-Workspaceis nowSf-Workspace", not a call quietly run inkelly. Task 1 tests the pair. -
A proxy or HTTP library that adds
X-Version: 1.2unasked (ruling 11). A person expects a 400 namingX-Version, and the value never read as a version to pin. Task 1 tests both. -
Headers in any case:
SF-WORKSPACE: desknames the workspace, andX-WORKSPACE: deskis refused. Task 1 tests both over HTTP. -
An MCP client wired with
X-Workspace. A person expects the handshake andtools/listto answer, and the first tool call to fail with an error namingSf-Workspace. A dead connector would say nothing at all. Task 1 tests both over the MCP transport. -
?session=deskwithX-Browser: chrome. Superseded by ruling 10, revised:?session=is no name, so the caller hearsX-Browser's refusal alone. A client still sendingX-Session-Key: deskor?session=deskexpects to be told to name its workspace (UNNAMED), and with?workspace=deskbeside it, to work indesk. Task 3 tests both.
Files:
- Modify:
kubed/selenium_flow/workspace/workspaces.py: the module docstring (9–19),WORKSPACE_HEADER(111),OLD_HEADER_NAME/OLD_HEADER(113–119),UNNAMED/BOTH(125–136),from_request(214–217), and a new_old_headersbeside_names(273) - Modify:
kubed/selenium_flow/workspace/settings.py: the header column ofSETTINGS(131–138) - Modify:
kubed/selenium_flow/mcp/clients.py:CARRIED(45), the comments at 8 and 74 - Modify:
kubed/selenium_flow/core/session_caps.py:VERSION_FROM["client"](155) - Test:
tests/test_workspaces.py,tests/test_await_control.py,tests/test_tasks_contract.py,tests/test_routes.py, and every other test that sends an old literal (below)
Interfaces:
-
Produces:
WORKSPACE_HEADER == "sf-workspace";OLD_HEADERS: dict[str, str];RENAMED: str(format string,old,new);_old_headers(headers) -> str | None;CARRIED == "sf-carried-workspace";SETTINGS[name][1]issf-browser,sf-version,sf-window-width,sf-window-height,sf-page-load-timeout,sf-script-timeout.OLD_QUERYis unchanged.OLD_HEADERandOLD_HEADER_NAMEno longer exist. -
Step 1: Write the failing tests. In
tests/test_workspaces.py, importOLD_HEADERSandRENAMEDin place ofOLD_HEADER, and use itscaller_of:
NEW = {"sf-workspace": "desk", "sf-browser": "firefox", "sf-version": "130",
"sf-window-width": "1400", "sf-window-height": "900",
"sf-page-load-timeout": "40", "sf-script-timeout": "20"}
def test_each_new_header_is_read():
caller = caller_of(headers=NEW)
assert (caller.name, caller.named_by) == ("desk", "header")
assert caller.defaults == {"browser": "firefox", "version": "130", "width": 1400,
"height": 900, "page_load_timeout": 40, "script_timeout": 20}
@pytest.mark.parametrize("old, new", list(OLD_HEADERS.items()))
def test_each_old_header_is_refused_naming_its_new_one_even_beside_it(old, new): # rulings 2, 9
caller = caller_of(headers={"sf-workspace": "desk", old.lower(): "1"})
with pytest.raises(ValueError) as exc:
caller.name # noqa: B018
assert str(exc.value) == f"`{old}` is now `{new}`: rename the header"
def test_several_old_headers_are_one_refusal_in_the_tables_order():
caller = caller_of(headers={"x-script-timeout": "5", "x-workspace": "desk", "x-browser": "chrome"})
with pytest.raises(ValueError) as exc:
caller.library # noqa: B018
assert str(exc.value) == (
"`X-Workspace` is now `Sf-Workspace`: rename the header; "
"`X-Browser` is now `Sf-Browser`: rename the header; "
"`X-Script-Timeout` is now `Sf-Script-Timeout`: rename the header")
def test_a_blank_old_header_is_no_header():
assert caller_of(headers={"sf-workspace": "desk", "x-browser": " "}).name == "desk"
def test_x_session_key_points_straight_at_sf_workspace(): # ruling 10
with pytest.raises(ValueError, match=re.escape("`X-Session-Key` is now `Sf-Workspace`")):
caller_of(headers={"x-session-key": "desk"}).name # noqa: B018
def test_the_gateways_name_beside_a_callers_old_header_is_refused(): # Review Focus 1
with pytest.raises(ValueError, match=re.escape("`X-Workspace` is now `Sf-Workspace`")):
caller_of(headers={"sf-workspace": "kelly", "x-workspace": "desk"}).name # noqa: B018
def test_an_old_version_header_is_refused_and_never_read(): # Review Focus 2, ruling 11
caller = caller_of(headers={"sf-workspace": "desk", "x-version": "1.2"})
with pytest.raises(ValueError, match=re.escape("`X-Version` is now `Sf-Version`")):
caller.name # noqa: B018
assert "version" not in caller.defaults
def test_the_old_query_keeps_precedence_over_old_headers(): # Review Focus 5
caller = caller_of({"session": "desk"}, {"x-browser": "chrome"})
with pytest.raises(ValueError) as exc:
caller.name # noqa: B018
assert str(exc.value) == OLD_QUERY
def test_x_mcp_resources_is_still_read(): # ruling 4
assert caller_of(headers={"sf-workspace": "desk", "x-mcp-resources": "off"}).flags == {"resources": "off"}Also in tests/test_workspaces.py:
-
test_the_old_header_beside_x_workspace_is_refused_not_merged(60) andtest_the_old_header_is_refused_naming_the_new_one(1691) become the parametrised test above. Delete them. -
test_an_old_name_is_a_400_over_http(1449) takes(path, headers, error)witherrorthe expected text, and gains these rows. Review Focus 3 is the last two:-
("/browser", {"X-Workspace": "desk"}, "X-Workspaceis nowSf-Workspace: rename the header") -
("/browser", {"Sf-Workspace": "desk", "X-Browser": "chrome"}, "X-Browseris nowSf-Browser: rename the header") ("/browser", {"X-WORKSPACE": "desk"}, …the X-Workspace text…)-
("/browser?session=desk", {}, OLD_QUERY)stays.
Beside it,
test_a_new_header_in_any_case_names_the_workspace_over_http:GET /browserwithSF-WORKSPACE: deskanswers 200 withbody["workspace"] == "desk", usingtest_routes.py'sopen_serverpattern withresolvedpatched. -
In tests/test_await_control.py, with over_http (391). Review Focus 4 is the first test:
async def test_an_old_header_lists_tools_and_refuses_the_first_call(server):
async with over_http(server, "http://test/mcp", headers={"X-Workspace": "w1"}, mode="legacy") as client:
assert await client.list_tools()
result = await client.call_tool("await_control", raise_on_error=False)
assert result.is_error
assert "`X-Workspace` is now `Sf-Workspace`" in result.content[0].text
async def test_a_task_with_an_old_header_is_refused_at_submission(server):
async with over_http(server, "http://test/mcp?workspace=w1", headers={"X-Browser": "chrome"}) as client:
with pytest.raises(ToolError, match="did not run as a task"):
await call_tool_task(client, "await_control")test_the_carried_header_is_ignored_on_a_request (445) and test_the_workspace_reaches_the_worker_from_the_query (415) already pin a client-sent CARRIED being dropped. They read the constant, so they test the new name once it changes.
- Step 2: Run them to see them fail
Run: pytest tests/test_workspaces.py tests/test_await_control.py -v
Expected: FAIL. OLD_HEADERS cannot be imported, and sf-workspace names nothing.
-
Step 3: Implement the names, the table,
_old_headersand the branch infrom_request, as Design has them. In the module docstring, item 1 reads:Sf-Workspace, a header — what an admin pins inside a credential when one credential should mean one workspace, followed by Design's gateway sentence. The old-names paragraph reads: The names from before,?session=and theX-headers (X-Session-KeyandX-Workspaceamong them), are refused with the new spelling rather than ignored (specs 2026-10-09-workspaces-rename, ruling 1, and 2026-10-29-sf-headers, ruling 2). -
Step 4: Change every test literal. This command lists them:
Run: grep -rniE "x-(workspace|session-key|browser|version|window-width|window-height|page-load-timeout|script-timeout|selenium-flow-workspace)\b" tests --include=*.py
Each sent header becomes its Sf- name, in the case the test wrote it. tests/integration/conftest.py:171 and tests/integration/test_responsive.py:39 are included. Leave the hits that test a refusal. Two renames:
-
tests/test_tasks_contract.py:183:headers["sf-workspace"] == "desk". -
tests/test_routes.py:766and:775becometest_sf_workspace_names_the_workspace_over_httpandtest_sf_workspace_and_a_query_name_is_a_400. The first's docstring is ruling 12's: What an operator pins inside a credential, and what the gateway sets from?workspace=, the caller'sSf-Workspaceor the token's user. Claude.ai never sends it. The second asserts"Sf-Workspace" in response.json()["error"]. -
Step 5: Run the suite
Run: pytest && ruff check .
Expected: PASS. Then run Step 4's grep again. The only hits left should be the refusal tests.
- Step 6: Commit
feat!: request headers are Sf-…, and the X- names are refused naming the new one.
Files:
- Modify:
kubed/selenium_flow/mcp/tools.py: the module docstring (14), the handshake instructions (166–167) - Modify:
kubed/selenium_flow/spec/schemas.py:WORKSPACE_PARAMETERS(202–214), line 465 - Modify:
kubed/selenium_flow/spec/builder.py: 65, and the comment at 660 - Modify:
kubed/selenium_flow/flows/api.py: 211 - Modify:
kubed/selenium_flow/routes.py: the docstring (19) - Modify:
scripts/generate_wiki.py: 339, 439, 572 - Test:
tests/test_openapi.py(66–68) - Regenerate:
tests/golden/openapi.json; the wiki's generated pages
Interfaces:
-
Consumes:
WORKSPACE_HEADER(Task 1). -
Step 1: Write the failing test. In
test_every_operation_says_how_to_name_a_workspace:
assert {"Sf-Workspace", "workspace"} <= names, f"{method} {path}"
# The old names are refused, so they are not published (rulings 2 and 1 of the rename).
assert not {"X-Workspace", "X-Session-Key", "session"} & names, f"{method} {path}"- Step 2: Run it to see it fail
Run: pytest tests/test_openapi.py -v
Expected: FAIL, Sf-Workspace not among the parameters.
-
Step 3: Implement the parameter and its description as Design has them, and in every other file listed, swap
X-WorkspaceforSf-Workspaceand change nothing else in the sentence. -
Step 4: Regenerate and read the diffs
Run: GOLDEN_UPDATE=1 pytest tests/test_golden.py && python scripts/generate_wiki.py && pytest && ruff check . && python scripts/generate_openapi.py
Expected: PASS. The golden diff shows only the parameter's name and its description, on every operation, and the sentences at schemas.py:465, builder.py:65 and flows/api.py:211. In docs/, the generated pages (every tool page, Actions.md) change only in the header's name.
- Step 5: Commit the code, then the golden, then the wiki pages inside
docs/and the pointer:docs: the OpenAPI and the handshake name Sf-Workspace.
Files:
- Modify:
kubed/selenium_flow/workspace/workspaces.py: the module docstring's old-names paragraph (19–22), the comment above the old names (116–120),OLD_PARAM/OLD_QUERY(121–122), theX-Session-Keyrow ofOLD_HEADERS(124), andfrom_request's?session=branch (228–231) - Modify:
kubed/selenium_flow/spec/schemas.py: the header parameter's last sentence (212) and the query parameter's (224–225) - Test:
tests/test_workspaces.py,tests/test_openapi.py(67–68) - Regenerate:
tests/golden/openapi.json; the wiki's generated pages
Interfaces:
-
Produces:
OLD_PARAMandOLD_QUERYno longer exist.OLD_HEADERShas seven rows, from"X-Workspace"on. Nothing in the code namesX-Session-Keyor?session=. -
Step 1: Write the failing tests. In
tests/test_workspaces.py, importUNNAMEDif it is not already, dropOLD_QUERYfrom the imports, and add:
@pytest.mark.parametrize("params, headers", [({}, {"x-session-key": "desk"}), ({"session": "desk"}, {})])
def test_a_session_name_is_no_name(params, headers): # ruling 10, revised
caller = caller_of(params, headers)
with pytest.raises(ValueError) as exc:
caller.name # noqa: B018
assert str(exc.value) == UNNAMED
assert caller.library == GLOBAL_WORKSPACE
@pytest.mark.parametrize("params, headers", [({"workspace": "desk"}, {"x-session-key": "other"}),
({"workspace": "desk", "session": "other"}, {}),
({"session": "other"}, {"sf-workspace": "desk"})])
def test_a_session_name_beside_a_new_one_is_ignored(params, headers): # ruling 10, revised
assert caller_of(params, headers).name == "desk"Over HTTP, the two session rows leave test_an_old_name_is_a_400_over_http and become test_a_session_name_is_unnamed_over_http: GET /browser?session=desk, and GET /browser with X-Session-Key: desk, each answer 400 with {"error": UNNAMED}.
Delete test_x_session_key_points_straight_at_sf_workspace, test_the_old_query_keeps_precedence_over_old_headers and test_the_old_query_name_is_refused_naming_the_new_one. The parametrised test_each_old_header_is_refused_naming_its_new_one_even_beside_it follows the table on its own.
In tests/test_openapi.py, the second assertion becomes assert "X-Workspace" not in names, f"{method} {path}", with its comment: The old name is refused, so it is not published (ruling 2).
- Step 2: Run them to see them fail
Run: pytest tests/test_workspaces.py -v -k "session_name"
Expected: FAIL. ?session= is OLD_QUERY, and X-Session-Key is a refusal naming Sf-Workspace.
-
Step 3: Implement. Remove
OLD_PARAM,OLD_QUERY, theX-Session-Keyrow and the?session=branch, sofrom_requestreadsif (renamed := _old_headers(headers)) is not None:. The comment aboveOLD_HEADERScites only the sf-headers spec's ruling 2. The docstring's old-names paragraph reads: The oldX-headers (X-Workspaceamong them) are refused with the new spelling rather than ignored (spec 2026-10-29-sf-headers, ruling 2). Inspec/schemas.py, the header's description ends Its old name, X-Workspace, is refused with a 400., and the query parameter's loses its last sentence. -
Step 4: Regenerate and run every check
Run: GOLDEN_UPDATE=1 pytest tests/test_golden.py && python scripts/generate_wiki.py && pytest && ruff check . && python scripts/generate_openapi.py
Expected: PASS. The golden and the generated pages change only in those two descriptions. Then grep -rniE "session-key|\?session=|OLD_QUERY|OLD_PARAM" kubed scripts tests finds only the new tests.
- Step 5: Commit
feat!: the retired X-Session-Key and ?session= are no longer recognised.
Files:
- Modify:
README.md: 48, 102, 105, 350, 395 - Modify:
AGENTS.md: 32, 193, 521–525, 531–534, and the live control bullet namingx-selenium-flow-workspace - Modify:
skills/selenium-flow/SKILL.md(27);references/WORKSPACES.md(19),CONFIGURATION.md(64, 90, 128–133, 153),FLOWS.md(308),TROUBLESHOOTING.md(67) - Modify, inside
docs/:Installing.md(21–23, 117, 140),Workspaces.md(25, 28),Deployment.md(186–188),Administration.md(85),Home.md(53),Files.md(84),Flows.md(130),notes/await_control.notes.md,notes/open_session.notes.md - Modify:
CHANGELOG.md(## [Unreleased]) - Test:
tests/test_skill.py,tests/test_wiki.py,tests/test_readme.py
Interfaces: none produced.
Content:
-
The header's row in README:102,
WORKSPACES.md:19andWorkspaces.md:25reads:| **A header** | `Sf-Workspace: research-bot` | an operator pins one workspace to one credential |(ruling 12). Keep each file's own bolding. -
The old-names line in README:105 and
Workspaces.md:28: The oldX-headers (X-Workspace,X-Browser, …) are refused with a 400 naming the new spelling. Nothing in these files namesX-Session-Keyor?session=any more (ruling 10, revised). -
The gateway. AGENTS.md:193 is replaced by Design's gateway sentence, in bold like the rule it replaces.
Deployment.md:186–188is replaced by the same sentence, and themcp.providersentence before it stays. -
AGENTS.md:521–525:
**`Sf-Workspace`**, a header — what an admin pins inside a credential when one credential should mean one workspace, and what the gateway sets. A repeated header with two values is refused; it reportsnamed_by: header. -
AGENTS.md:531–534: The old names are refused, not ignored (spec 2026-10-29-sf-headers, rulings 2 and 9): any
X-header that was ours (X-Workspace,X-Browser,X-Version,X-Window-Width,X-Window-Height,X-Page-Load-Timeout,X-Script-Timeout), anywhere in a request, is a 400 naming each new spelling, even beside a new name.X-MCP-Resourcesis shared with mcp-kb and keeps its name. There are no aliases. The session names from before 10-09,X-Session-Keyand?session=, are retired: the server does not know them, so a request sending one names nothing by it (ruling 10, revised). -
AGENTS.md, live control:
x-selenium-flow-workspacebecomessf-carried-workspace. -
Installing.md's table gains?browser=/Sf-Browserand?version=/Sf-Version, which it has never listed (spec, Where it lands). Every other mention in these files swaps the header's name and nothing else. -
CHANGELOG, the spec's line, word for word:
- **BREAKING:** request headers are now `Sf-Workspace`, `Sf-Browser`, `Sf-Version`, `Sf-Window-Width`, `Sf-Window-Height`, `Sf-Page-Load-Timeout` and `Sf-Script-Timeout`; the `X-` names are refused with a 400 naming the new one, and the retired `X-Session-Key` and `?session=` are no longer recognised. -
Step 1: Write the content.
-
Step 2: Check nothing was missed
Run: grep -rniE "x-(workspace|session-key|browser|version|window-width|window-height|page-load-timeout|script-timeout|selenium-flow-workspace)\b" README.md AGENTS.md skills kubed scripts docs --exclude-dir=superpowers --exclude-dir=saga
Expected: only lines that name an old header as refused: OLD_HEADERS, the OpenAPI description, the old-names lines above, and the generated pages that carry that description; and the tests and the AGENTS.md line that say the session names are retired.
- Step 3: Regenerate, and run every check
Run: python scripts/generate_wiki.py && pytest tests/test_skill.py tests/test_wiki.py tests/test_readme.py -v && ruff check . && pytest && python scripts/generate_openapi.py
Expected: PASS, and generate_wiki.py changes nothing Task 2 did not.
- Step 4: Commit the wiki pages inside
docs/with the pointer, thendocs: Sf-Workspace, and what the gateway sets, thenchore: changelog for the Sf- headers.
The spec's Outside this repo list goes in the pull request's body word for word, and a person makes those changes. They land with the image bump:
- kubed-io/selenium's
policy.yamlandpolicy-pub.yaml; - kubed-io/github's
docs.jsonandcoder.json; - the cluster's
.mcp.json.
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