Repository navigation
2026 10 02 optimal shape
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (
- [x]) syntax for tracking.
Goal: Refactor selenium-flow so that every capability is one call through five owned stages (Caller, Capability, Recipe, Settle, Surface), delete the repetition that grew round by round, take the measured performance wins, and close the security gaps the review found, with no regression in any behaviour.
Architecture: Lock first (characterisation tests, goldens, parity, a JS harness, one fakes module), then move pure rules into framework-free modules behind a boundary test, then state the per-action recipe, the post-action settle and the secret bind once each, then generate both surfaces, the spec and the wiki from one capability table. Performance and security work ride on the seams this creates.
Tech Stack: Python 3.10+, FastMCP 4, Selenium 4 (WebDriver + BiDi), Starlette, Redis, pytest; Svelte 5, Vite 8, vitest 5.
Spec: docs/superpowers/specs/2026-10-02-optimal-shape-design.md — read its "The core abstraction", "Behaviour contract", "Plan of record" and "Rulings" before any task.
- No published shape changes: tool names, parameters, descriptions, annotations, HTTP paths, bodies, result keys, URIs, error sentences an agent reads, the OpenAPI document. The goldens of Task 3 are the proof; a golden is never updated in a commit that moves code.
- Every rule in
AGENTS.mdholds; a rule may move where it is enforced, never whether. -
tests/changes only by import path in a refactor commit; the pass count never drops below 1572 (unit) and 205 (vitest). Each task ends green:ruff check ., the unit suite, and for UI tasksnpm --prefix ui run check && npm --prefix ui run lint && npx vitest run. - Unit suite command in the pod:
cd /projects/modules/selenium-flow && S=/tmp/claude-1000/-projects-cluster/2222ebcd-ef22-431d-92ea-d22a6044b628/scratchpad; PYTHONPATH=$S/deps:. python3 -S -m pytest -q -m "not integration" tests --deselect tests/test_wiki.py::test_the_generated_pages_are_current -p no:cacheprovider. Ruff:$S/tools/bin/ruff check .. Never runtests/integrationin the pod. - No back-compat shims for the one user (AGENTS.md); a deleted name is deleted, and its callers are updated in the same commit.
- Every blind
exceptcarries# noqa: BLE001with its reason. Line length 88.pathliboveros.path. - Commits: one per task, message in the repo's voice (what changed and why, no "feat:"), trailer
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>. - Research reports with file:line evidence for every task are under
.superpowers/round3/understand/(gitignored); a task names the report sections it draws on.
Files: Create tests/fakes.py. Modify tests/conftest.py, tests/test_kept_files.py, tests/test_flowrun.py, tests/test_run_progress.py, tests/test_sessions.py, tests/site_data_fakes.py, tests/test_spare_tab.py, tests/test_skill.py, tests/test_file_surfaces.py, tests/bench/test_session_store.py. Evidence: tests-ci.md §2.
Produces: tests.fakes.FakeRedis (with FakePipeline, scan_iter, mget, set(ex=), get, delete, watch/multi/execute, ttl), FakeGrid, FakeActions, FakeClock, FakeBidi, FakeTabs; conftest re-exports RecordingActions, FakeGrid, manager, http.
- Diff every duplicate pair (
FakeRedisattest_sessions.py:418,bench/test_session_store.py:17;FakeGridatconftest.py:54,test_kept_files.py:81;FakeActionsattest_flowrun.py:16,test_kept_files.py:97;FakeClockattest_run_progress.py:148,test_flowrun.py:249;FakeBidi/FakeTabsatsite_data_fakes.py:69,77vstest_spare_tab.py:25,85) and write the union of their behaviours intotests/fakes.py, one class each, docstring naming what production method it answers. - Replace each copy with an import; delete the
http()redefinitions intest_skill.py:50andtest_file_surfaces.py:30in favour ofconftest.http. - Run the suite and the bench (
pytest tests/bench -q): same pass counts (1572; bench collects the same cases). - Mutation check: break
FakeRedis.mgetto return[]; the same tests that failed before the merge fail now (record which in the report); revert. - Commit:
Tests: one fakes module, no second copy of any double.
Files: Test: tests/test_actions_contract.py (new), tests/test_pointer.py, tests/test_assert.py. Evidence: critique.md §3 items 1–6; core-browser.md §1 ids X1, K6, X3, N1–N3, I2, I8, I9, S3, S4, S10, F2, G1, U5, U6, C2.
Use RecordingActions/FakeGrid and a scripted fake driver (add ScriptedDriver to tests/fakes.py: records every attribute access and call in order; current_url/title may raise UnexpectedAlertPresentException; switch_to.alert.text returns a canned string; execute_script returns canned values by script substring).
-
upload_file: a missingpathis a 400 with the sentence it emits today; the temp directory is removed after success and after failure; an unwritableTMPDIRis aRuntimeErrornaming it (U5, U6). - An open dialog is reported as page state, never raised, by every action's
page_state(X1);settled()expires as a normal outcome afterNAVIGATION_SETTLE(K6). -
navigate,extract,execute_scriptresult shapes, key by key (N1–N3);ensure_urlskips the navigation when the browser is already there (X3). -
interact: a refused argument never calls_at(I2) — parametrise over every action with the pattern oftest_an_impossible_hold_is_refused_before_the_browser_is_touched, marking withxfail(strict=True)the four that validate after reconnect today (frame,screenshot,extract,outline; Task 13 turns them green); presence vs clickable wait (I8);nudged/unglideablenotes (I9). -
open_sessionwindow size and timeouts replay (S3, S4);end_browserforgets the pointer (S10);frameindex default andin_frame(F2);dialog(read)leaves the prompt open and returns its text (G1);screenshot's three capture modes restore the window infinally(C2). - Each test, broken on purpose once (flip the assertion's subject in the code), fails; record the line you broke in the report.
- Commit:
Tests: pin what the browser layer promises before anything moves.
Files: Test: tests/test_flowdoc.py, tests/test_flowrun.py, tests/test_flows.py, tests/test_sessions.py, tests/test_routes.py, tests/test_errors.py, tests/test_main.py (new). Evidence: critique.md §3 items 7–13, 15; flows.md §1 D19, D20, R9, D8, D13, R27, X1, S21; session-store.md N8–N12, M36, M31, S12, S5, S25.
- Hand-edited flow shapes that 500 today (
required: "email", a listparameters, an unhashable stepid, a stringsteps): pin the current status withxfail(strict=True)and the expected 400 sentence in the test body (Task 16 flips them). -
assertis never continued past, at save and at run (D13, R27);args.secretis popped and the page read only when bound (X1); a flow save is not atomic today — pin that a crash mid-write leaves the old document unreadable? No: pin only the observable, the saved document reads back byte-equal (S21), so the atomic write of Task 10 has a test to keep. - Request naming: surrounding whitespace, case, a repeated
?session=a&session=b(today: last wins — pin it, the spec changes it in Task 7 so markxfail(strict=True)with the new sentence),describe()'snamed_byon each surface (M36),browser()(M31). - HTTP status table end to end through the ASGI app for every
errors.pyclass, includingStoreConflict→ 500 (S12) and a non-numericopened_at(S5). -
tests/test_main.py:main()with an unknown config key exits non-zero printing the key; Selenium's wire loggers are at INFO after boot;auth=offis logged when no token (runsmainin-process withuvicorn.runmonkeypatched to record its args). - Record today's interleaving of two concurrent calls on one session (two threads,
RecordingActionswith a barrier): both run; assert only that both complete and the record is consistent (Task 23 changes the ordering and will edit this one test — the only test a later task may edit). - SSE: with three connected
/admin/eventsclients, each receives the same payload sequence andlastsemantics (countgrid.statuscalls: today 3 per tick — pin the count with a comment; Task 18 edits that number). - Commit:
Tests: pin the flow, session, HTTP and boot edges.
Files: Create tests/golden/ (JSON), tests/test_golden.py, tests/golden_tools.py (helpers: normalise, load, compare with a readable diff). Evidence: research-testing-ui.md §A; mcp-spec.md §1.
- Snapshot, via the real server object (
SeleniumMCP) and the FastMCP client in-process: every tool'sname,description,inputSchema,annotations, for both client modes (?resources=onandoff), totests/golden/tools-<mode>.json. - Snapshot
build_spec()totests/golden/openapi.jsonwithinfo.versionnormalised. - Snapshot the run reports of the
test_flowrunfixture matrix (SIMPLE, guarded, failing, continue, out-of-time, cancelled, redacted URL, file-producing) withFakeClockso times are stable, totests/golden/flow-reports.json. - Snapshot
/admin/sessionsfor a cannedMemoryStoreof three records (one detached, one with site data, one with a reopen report) withrevfields kept and signed URLs replaced by<signed>, totests/golden/admin-sessions.json. - Snapshot every error envelope: raise each
errors.pyclass through a/browser/navigatecall and record status + body, totests/golden/errors.json. - Parity: for
navigate,extract,write,interact,screenshot(save=False),save_site_data, call the tool and the endpoint with the same args againstRecordingActions; the normalised results are equal (the screenshot's image block vs base64 is the one sanctioned difference: compare after decoding). - Mutation check: change one tool description character;
test_goldenfails naming the tool; revert. - Commit:
Tests: golden shapes for every published surface, and parity between the two.
Files: Create tests/js/run.mjs, tests/test_js_behaviour.py. Modify nothing in js/. Evidence: core-browser.md §1.8 O4, §2.7.
-
tests/js/run.mjs: loadsui/node_modules/jsdom, builds a document from an HTML string argument, evaluates a named script fromjs/withargumentsbound the way WebDriver binds them, prints the return as JSON. Skips (pytestskip) whennodeorui/node_modules/jsdomis absent, the waytest_every_script_parsesdoes. - Pin:
helpers.jsfinds the interactive ancestor of a text node;center.jsreturns the element's centre and viewport; the probe'sexplainpayload for a covered element; the outline's heading list for a canned page; the site-data fill script writes the given keys and nothing else (O4 list incore-browser.md§1.8). - Commit:
Tests: the page scripts run under jsdom, so a change to them is provable.
Files: Modify kubed/selenium_flow/session/sessions.py (Caller, http_request, name_in, requested, name_from, library_from), session/settings.py (resolve takes client=), mcp/clients.py (declared reads through the same reader), mcp/tools.py, routes.py, http/files.py, flows/api.py, secrets.py, server.py, tests/conftest.py (named_caller/unnamed_caller pass a Caller). Evidence: session-store.md §5.4; mcp-kb-compare.md "Multi-value request reads"; research-python-fastmcp.md (CurrentRequest/CurrentHeaders).
Produces: Caller.from_request(params: dict[str, list[str]], headers: dict[str, list[str]]) -> Caller, Caller.stdio(), Caller.name, .library, .named_by, .client (the clientInfo name or ""), .flags (declared resources/apps); request_values() -> tuple[dict[str, list[str]], dict[str, list[str]]] | None (one FastMCP dependency walk, no dead ImportError guard); SessionManager.describe(caller), .act(caller, call), .open_browser(caller, ...), .end_browser(caller) etc. take a Caller and never call requested().
- Write the failing tests: a repeated
?session=a&session=bis refused withthe request names two sessions: a, b(replace the Task 3 xfail); header beats nothing — header plus param is still the existing 400; off HTTPCaller.stdio();settings.resolve(client=None)gives no client defaults. - Implement
Caller.from_requestandrequest_values; make every entry point resolve the caller once (tools: in therun()closure; routes: from the Starlette request; flows/api, files, secrets: parameter) and pass it down. Deleterequested,name_in,name_from,library_from,clients.declared's own parsing (it takes theCaller), and the lazy imports that existed to break the cycle. -
conftest.named_callermonkeypatchesrequest_valuesonly (one seam), returning list-valued dicts. - Suite green; goldens unchanged; commit:
Sessions: the caller is read once at the edge and handed in.
Files: Create kubed/selenium_flow/names.py, kubed/selenium_flow/urls.py. Modify flows/library.py, session/store.py, secrets.py, core/site_data.py, core/actions.py, errors.py, core/browser.py, flows/run.py, config.py. Evidence: flows.md §5.4; session-store.md §5.3; mcp-kb-compare.md "Scrub in one place".
Produces: names.NAME, FILE_NAME, InvalidName, valid_name, valid_session_name, valid_file_name, GLOBAL_SESSION, STDIO_SESSION, RESERVED_SESSIONS, library_of; urls.origin_of, host_of, page_of, scrub (the one userinfo scrubber), normalize_url, public_url.
- Move, do not rewrite.
secrets.originstays its own rule: it keeps a written:443andtests/test_secrets.py:223pins that as a distinct origin, whileurls.origin_of(the site-data rule) drops default ports. The test written for this (tests/test_urls.py) shows where the two agree and where they differ (Copilot, #52). - The three scrubbers (
errors.py:129,core/browser.py:153,flows/run.py:228) callurls.scrub; theUSERINFOregex exists once (config.py:75imports it). -
core/no longer imports fromflows;grep -rn "from ..flows.library import valid_" kubedreturns nothing outsideflows/. - Commit:
Names and URLs: the identity and origin rules in two modules of their own.
Files: Create core/keys.py, core/naming.py, core/coerce.py, core/assertion.py. Modify core/actions.py, core/browser.py, mcp/tools.py, routes.py, flows/run.py, tests' import paths only. Evidence: core-browser.md §5 R1, §2.1, §2.5.
-
keys.py: the key vocabulary (K1–K4) andSUBMIT_KEYS;naming.py:_safe_name→safe_name,EXTENSIONS, the capture/print file naming;coerce.py:as_bool,as_int,_seconds→seconds;assertion.py: theassert_poll/hold engine as a class takingevaluate()and a clock. -
spare_tab,SPARE_*,ServiceWorkerAnsweredmove beside site data (Task 9 takes them; here they move tocore/spare.py). -
browser.pykeepsGrid,ReattachDriver, the waits,locator,page_state. DeleteActions.files,Actions.clear_files,Grid.timeout(or wire it to config: ruling — delete), the list fallback inprobe.outlineafter fixing the_Pagedouble in tests. - Commit:
Core: the pure rules in their own modules, and the vestiges gone.
Files: Create kubed/selenium_flow/site_data/__init__.py, site_data/snapshot.py, site_data/transfer.py, site_data/spare.py. Delete core/site_data.py, core/spare.py. Modify importers (session/sessions.py, mcp/resources.py, spec/builder.py, http/admin.py, core/actions.py, tests/site_data_fakes.py, tests' import paths). Add kubed.selenium_flow.site_data to pyproject.toml packages. Evidence: session-store.md §5.3, §5.7, §5.10.
-
snapshot.py:snapshot,forget,restorable,live_cookies,_Jar,view,views,site_view,summary, the constants (LIST_URI,CAPTURED,MASK,SW_REASON,LEFT_OUT,MAX_BYTES) with their values unchanged;view()/summary()count from_Jarwithout building details (test_site_data_views.pyunchanged, plus one new count test on_cookie_view). -
transfer.py:capture,restore, the fill/read scripts,BidiUnavailable;spare.py: the spare tab.matching_secretsmoves tosecrets.py. -
SessionRecord.at()splits intovisited(*urls, now)andpruned(now, ttl);rememberusesdataclasses.replaceso a new field survives a bind (test_an_open_drops_a_report_nobody_collectedstill passes:reopenedreset explicitly). -
test_packaging.pypasses with the new package; commit:Site data: the model in one module, the BiDi transfer in another.
Files: Create flows/template.py, flows/redact.py, flows/engine.py, flows/report.py, flows/shape.py (Task 16 fills it; create the skeleton here only if needed), flows/store.py. Modify flows/run.py (a façade: run() keeps its signature), flows/library.py, flows/api.py, secrets.py (imports flows.redact, not flows.run), http/admin.py. Evidence: flows.md §5.1, §5.5, §5.6.
Produces: template.PARAM_REFERENCE, references, substitute, with_defaults, check_params; redact.HIDDEN, hidden_forms, scrub, scrub_values, taints, redacted_fields; engine.Run (deadline, stop, seen, pages, was_at, reports), engine.Hooks protocol (before_step, after_step, before_save), engine.StepOutcome; report.summarise, hint_for, with_hint; store.SessionLayout, FlowStore, FileStore (the fourteen LocalFlowStore methods split by what they hold, fd-pinning listing moved unchanged); flow saves atomic via tmp + os.replace, CSafeDumper.
- Golden
flow-reports.json(Task 4) is the gate: byte-equal after the split.run.pyimports neitherroutesnormcp.guidance:RUNNABLE/method_forand the pointer function are passed in byflows/api.py. - Keep
flowrun.listed,flowrun.HIDDEN,flowrun.timere-exported fromrun.py(the tests monkeypatch them) — the one re-export the round allows, because the alternative edits 40 tests. - Cheap wins from
flows.md§5.6:_document_schemacached;DOCUMENT_KEYSshared by the MCP tool, the HTTP PUT and the schema;FLOW_ENDPOINTSderived fromFLOW_ROUTES;save_flowdisk work inanyio.to_thread.run_sync;summarieswithout a deep copy;test_every_field_a_save_or_read_returns_is_publishedextended to the run report. - Bench
tests/bench/test_library.pynumbers not worse (run before and after, paste both tables in the report). - Commit:
Flows: an engine, a template, a redactor and a reporter, and a store per kind.
Files: Create http/admin/__init__.py (register, same name), http/admin/sessions.py (sessions_payload, the row builders, the events stream), http/admin/flows.py, http/admin/files.py, http/admin/site_data.py, http/admin/page.py (page(), static_path), http/admin/signed.py (one signed-route factory). Delete http/admin.py. Modify http/answer.py (gains refused and the one read_body; delete admin.body_of), server.py, tests' import paths (_admin.static_path → http.admin.page.static_path). Add the package to pyproject.toml. Evidence: http-server.md §2, §5.1.
- Before the move, write
tests/test_admin_routes.py::test_the_route_table_is_unchanged: snapshot (method, path, name) of every admin route totests/golden/admin-routes.json. - Move; the three inline
status_forblocks (admin.py:994/1030/1080) go throughanswer.refused, which scrubs theOSErrorpath like the others; the three copy-paste signed routes become one factory call each. - Admin flow PUT/GET/DELETE call
flowapi's save/read/delete rather than re-implementing them. - Commit:
Admin: a package, with one error shaper, one body reader and one signed route.
Files: Create tests/test_boundaries.py. Modify mcp/skill.py (delete read, manifest_json, MANIFEST*, RESOURCE_URI), mcp/tools.py (delete SELECTOR), mcp/guidance.py (PAGES gains SITE_DATA.md), mcp/apps.py:21, flows/api.py:258, flows/document.py:834 (stale names), server.py:121 (one instructions source). Evidence: mcp-spec.md §2; mcp-kb-compare.md "Import-boundary proof".
-
test_boundaries.py: in a subprocess withfastmcp,starlette,mcp,uvicornblocked via asys.meta_pathfinder, import every module undercore/(exceptbrowser,pointer,probe,spare, which may import selenium),session/,flows/(exceptapi),site_data/snapshot,names,urls,errors,secrets; withseleniumalso blocked, importcore/keys,naming,coerce,assertion,site_data/snapshot,session/store,flows/template,redact,engine,report,names,urls. Fails naming the module and the forbidden import. - Delete the dead code;
grep -rnfor each removed name returns nothing;test_guidancecovers thePAGESaddition. - Commit:
A boundary the kernel cannot cross, and the dead code gone.
Files: Create core/recipe.py. Modify core/actions.py (each of the fourteen actions becomes validation + a body). Evidence: core-browser.md §2.2, §5 R2.
Produces: Recipe(reconnect, navigate=None, wait=None, retry_stale=False) with .run(session_id, body, url=..., selector=...) -> dict doing coerce/validate → reconnect → optional navigate (ensure_url) → optional wait (presence|clickable) → body (one stale retry when asked, wrapping move+gesture for interact) → page_state; WAIT_TIMEOUT the one default.
- Flip the Task 2
xfails:frame,screenshot,extract,outlinevalidate before reconnect (observable only as "no navigation on a 400"). - The
CountingDriver(add totests/fakes.py) asserts the driver call order per action is unchanged for the other ten. - Commit:
Actions: the one recipe every action follows, stated once.
Files: Modify session/store.py (update(key, fn) -> tuple[SessionRecord | None, Any]: fn returns (record, note); the note is from the last run), session/sessions.py (settle(caller, result, *, url=..., touch=True) used by act, _hold_again as a plain second write; delete the five .clear()-ing closures), flows/api.py (run_for calls settle), secrets.py (perform_write calls settle), http/admin/site_data.py (forget/clear read the note). Evidence: session-store.md §5.1, §5.2.
- Tests named in
session-store.md§5.1–5.2 pass unchanged;test_history.py's write-count tests still count one write per flush. - Commit:
Sessions: one settle after every action, and a write that answers.
Files: Create secrets_binding.py? No — create kubed/selenium_flow/binding.py. Modify flows/document.py (_check_step), flows/engine.py (resolve_step), secrets.py (prepare_write, perform_write). Evidence: flows.md §5.2; config-secrets.md §2.
Produces: binding.check_pair(args) -> list[str], bind_into(kwargs, catalogue, page, tool) -> tuple[dict, Guarded], after(result, hidden) -> dict; the refusal sentences (one value, one place, may not also navigate, pass text or secret, not both) as module constants.
- One parametrised test feeds the same bad
argsto the three entry points and asserts all three refuse with the same sentence. - Commit:
Secrets: the bind-and-scrub rule written once for the three places that need it.
Files: Create flows/shape.py. Modify flows/document.py (validate, _properties, required_params, with_defaults), flows/engine.py (preflight), http/admin/flows.py (_uses), flows/library.py (_step_count). Evidence: flows.md §5.3.
- Flip the Task 3
xfails: the four hand-edited shapes are 400s with the sentences pinned there;_check_paramssorts bystr(key); an unhashableidis a problem, not aseenmember. - Commit:
Flows: one tolerant view of a document, and no 500 for a hand-edited one.
Files: Create core/capabilities.py. Modify routes.py (ENDPOINTS, METHOD_ALIASES, ACTION_IN_PATH become the table's columns), mcp/tools.py (wrappers generated: signature kept literal per tool for FastMCP's schema, body generated), spec/builder.py, spec/schemas.py (RESPONSES keyed by the table), scripts/generate_wiki.py, tests/test_surfaces.py (EXPECTED stays a hand list, by design). Evidence: mcp-spec.md §5.1; core-browser.md §2.9.
Produces: CAPABILITIES: tuple[Capability, ...] with Capability(name, method, route, http_method, annotations, response, in_path=False); capability(name).
- Golden
tools-*.jsonandopenapi.jsonbyte-equal;test_surfaces.pyunchanged; the wiki regenerates with no diff (python scripts/generate_wiki.py --checkor the test). - Commit:
One table of capabilities, and both surfaces, the spec and the wiki read it.
Files: Modify http/admin/sessions.py. Test: tests/test_admin_events.py (new), tests/bench/test_admin.py (new: sessions_payload at 10 sessions × 1 MB). Evidence: profile-bench.md opp. 2; http-server.md §5.2.
- One task computes
sessions_payloadper interval while any stream is connected, fans the same payload out;GET /admin/sessionsserves the cached payload when under ~1 s old. Per-streamlastsemantics and error handling unchanged; the Task 3 test'sgrid.statuscount becomes 1 per tick for 3 clients (edit that number only);test_shutdownpasses. - Commit with the bench table before/after in the message body.
Files: Modify core/browser.py (a pooled requests.Session for the Grid calls, a reused connection for ReattachDriver; wait_for_* polling 0.2 s), js/center.js (viewport folded in). page_state keeps its WebDriver reads: Get Current URL and Get Title report the top-level page whatever frame is selected, and a script would report the frame's (Copilot, #52). Evidence: core-browser.md §5 R4, §3.1–3.3; profile-bench.md opp. 3.
-
CountingDriverasserts round trips per action before/after (table in the report); a fake Grid counts TCP connections: two reconnects share a pool; after a Grid restart the first call retries once. - Commit:
Browser: one connection pool, one script for page state, fewer trips per action.
Files: Modify site_data/snapshot.py (snapshot's size cap sizes once and subtracts; the sum is an upper bound of the serialisation, separators counted), http/admin/page.py (page() cached per (name, mount, console), Cache-Control: no-cache with an ETag), routes.py (/ready cached 2 s). Test: tests/bench/test_site_data.py (new: 400 × 20 KB), a Hypothesis-free property test comparing the new cap to the old loop over random inputs (keep the old loop in the test file). Evidence: profile-bench.md opp. 4; session-store.md §5.8; ui.md §3.
-
test_a_cookie_jar_over_the_cap_raises_and_saves_nothingand the two eviction tests unchanged. - Commit with the 400-origin time before/after.
Files: Modify ui/vite.config.ts (test.pool: 'vmThreads', unstubGlobals: true, restoreMocks: true), tests/test_routes.py (a module-scoped immutable app for test_every_endpoint_is_mounted), pyproject.toml (test extra gains pytest-randomly, pytest-xdist; addopts unchanged), .github/workflows/test.yml (-n 2 --dist loadfile once three random seeds are green locally — record the seeds in the report). Evidence: tests-ci.md §3; ui.md §3; research-testing-ui.md.
- vitest: 205 pass twice with shuffle; duration before/after in the report. Unit suite: pass count unchanged;
--durations=10before/after. - Commit:
Tests run faster and in any order.
Files: Modify core/actions.py (upload_file loses path; navigate, open_browser and every url= pass through urls.allowed_navigation(url) which admits http, https and about:blank (it reaches nothing, and an agent uses it to clear the page) and refuses every other scheme, data: included, with only http(s) URLs can be opened here; <scheme>: cannot; the restore's own landing on about:blank is internal and not a caller URL), mcp/tools.py (the path parameter removed from the tool), spec/schemas.py, secrets.py/binding.py (the leash takes the effective origin: Actions.page reports the frame's origin when in_frame, read with document.location.origin inside the frame), skills/selenium-flow/references/*.md, wiki/notes/upload_file.notes.md, CHANGELOG.md. Tests: tests/test_actions_contract.py, tests/test_secrets.py. Evidence: security.md F1, F2, F5; Rulings (3), (4).
- Goldens
tools-*.json,openapi.jsonare updated in this commit and this commit only (a shape change Dr K ruled). - Red first: a secret write inside a cross-origin frame is refused naming the frame's origin;
navigate(url="file:///etc/passwd")is a 400 with the sentence;upload_file(path=...)is an unknown argument on both surfaces. - Commit:
Security: no server-path upload, web schemes only, and the leash sees the frame.
Files: Create session/locks.py (a per-name lock registry, weak so an unused name costs nothing; in Redis mode still process-local — one replica, AGENTS.md "Scaling"). Modify core/recipe.py (acquires the session's lock around reconnect→page_state), secrets.py and flows/engine.py (a bound write — prepare_write's page read, the leash check, the bind and the typing — runs under the same lock as one unit, for a direct write and a flow step alike, so no other call can navigate or switch frames between the origin check and the keystrokes; Copilot, #52), session/sessions.py (end_browser and open_browser do not take it; end_browser sets the session's cancel from core/cancel.py so a running assert ends with its existing cancellation sentence before the browser is ended), flows/engine.py (the cancel flag is checked between steps, as today, and the lock is per step so a flow does not starve end_browser), CHANGELOG.md, AGENTS.md (a new paragraph under "A session is the thing"), saga Ch. 4 open question 2 marked answered by pointer to the spec. Tests: the Task 3 concurrency test edited to assert ordering; end_browser returns within one second while a 10 s assert holds the lock; two navigates on different sessions overlap; a navigate racing a bound write (direct and as a flow step) cannot land between the origin check and the typing. Evidence: profile-bench.md "Saga Ch.4"; Ruling (2).
- Commit:
Sessions: one browser-driving call at a time per session, and end_browser still interrupts.
Files: Modify config.py (SecuritySettings(Section): frame_ancestors: list[str] = [] described "Origins allowed to frame the admin page, e.g. a Nextcloud or Grafana host; empty forbids framing"), http/admin/page.py (Content-Security-Policy: frame-ancestors 'none' or the list, X-Content-Type-Options: nosniff; Referrer-Policy: same-origin), server.py (uvicorn access log through a filter that drops the query string, or access_log=False plus our own one-line log without the query — ruling: the filter), main.py (a tokenless server bound to a non-loopback address logs a WARNING with the sentence AUTH_TOKEN is not set: anyone who can reach <host>:<port> can drive every browser), http/answer.py (read_body caps JSON at 1 MiB and multipart at 64 MiB with a 413), flows/library.py (flow YAML capped at 1 MiB, 413/400 as the surface says), the Settings tab's generated wiki page, CHANGELOG.md. Tests: tests/test_config_schema.py, tests/test_ui_serving.py (headers), tests/test_main.py, tests/test_http_answer.py. Evidence: security.md F4, F7, F8; ui.md §4; Ruling (6).
- Commit:
Security: a frame-ancestors setting, quieter logs, a loud warning, and body caps.
Files: AGENTS.md (TTL 86400; the five stages as a section; "where behaviour lives" now names core/recipe.py and core/capabilities.py; the lock), CONTRIBUTING.md ("Where things live" rows for site_data/, http/admin/, flows/{template,redact,engine,report,shape,store}.py, names.py, urls.py, binding.py, tests/fakes.py, tests/golden/), README.md if it names upload_file(path=), wiki/ regenerated and the submodule pushed, CHANGELOG.md [Unreleased]: **BREAKING:** upload_file no longer takes path; send content with a filename, or a kept file's URI. · navigate and every url refuse non-web schemes (file:, chrome:, …). · One browser-driving call runs at a time per session; end_browser still interrupts a long assert. · security.frame_ancestors lets Nextcloud or Grafana frame the admin page. · The admin page and server logs no longer expose session names or signed links.
-
python scripts/generate_wiki.py;test_wikipasses; the wiki submodule is pushed before the PR. - Commit:
Docs: the shape the code has now.
- Push
optimal-shape; open the PR with a body listing every behaviour change (the four changelog lines), the perf tables, and the rulings; watchtest,ui,package,quality,integration,bench; answer every Copilot thread viagh(fix or decline with evidence) until Copilot asks for a human review. - Build the branch tag with
image.yml, deploy it to the cluster, drive it with selenium-flow itself (open a session on the admin page, History and Site data tabs, a flow run,end_browserduring anassert), then report to Dr K.
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