Repository navigation
2026 10 28 headless design
Status: SETTLED 2026-10-28, issue #86, pull request #97, for rulings 1-7 and
9-10. One question is open: ruling 8 is not borne out by the measurement (see
its note and Research), and its claims are withheld pending Dr K.
Rulings marked Dr K are his, from the design chat of 10-25/26 and the issue
thread of 10-28. Rulings marked agent's call were told to him on #86 or #97
and not challenged. This spec builds on 2026-10-26-session-capabilities-design.md
(#82, which left option_args and an owned-key row for this issue),
2026-10-27-session-touch-ups-design.md (ruling 1: a workspace remembers only
what a caller passed; ruling 14: set_by's keys equal the settings' keys) and
2026-10-27-live-view-design.md (ruling 19: this issue adds the headless
reason to the unavailable state). The admin UI is drawn in the Penpot file
Admin UI: Workspace · Session and the live-unavailable state on
Workspace · Live.
Finished on #97 from the draft #86 left, not replaced. Checked against the
code on issue-86, this round changed three of the agent's calls and nothing
of Dr K's: the guard covers both vendor blocks (ruling 6), the admin row gains
headless (Where it shows), and the tests are placed where the code's own
tests are (Tests).
open_session(headless=true) opens a browser that draws nothing on the
node's screen. Once passed, the workspace remembers it, the way it remembers
insecure, and selenium-flow knows what it rules out: no recording and no
live view. It is one of the special options #82 promotes out of raw
capabilities.
-
Headless is a named option, not a flag in raw capabilities (Dr K, 10-25). selenium-flow has to know it to apply the rules below, and cannot reliably spot it in a free-form
argslist. #82 ruling 6 already lists it. -
An
open_sessionoption, remembered the wayinsecureis (Dr K, 10-28; replaces 10-26's "like the window").- No query parameter, no header, no
session.headlessconfig default. A header or query value would be scoped to the workspace anyway, since the connection names the workspace, so it would only be a second way to say the same thing. - Once passed, it is remembered. An
open_session()with no arguments, and the reopen after a reap, bring the browser back headless, so a chat that picks up a reaped headless task carries on as it was. - An explicit
headless=falsebeats the remembered value, and is remembered in its turn.
- No query parameter, no header, no
-
A headless browser cannot record and has no live view (Dr K, 10-26). The rules belong to that browser.
-
Recording a headless browser is a 400 that says where the value came from (Dr K, 10-28). It holds whether headless was passed or remembered. It is checked in
open_browserright afterRECORDING_OFF, after the cascade and beforeend_browser, so it costs no browser. A server with recording switched off saysRECORDING_OFFfirst, sinceheadless=falsewould not help there (agent's call). The wording (agent's call, after Dr K's example):- passed in this call: a headless browser draws nothing to film: record=true cannot be asked with headless=true;
- remembered: this workspace's browser is headless, remembered from an earlier open: pass headless=false to record.
#82 ruling 1 (best effort) is about raw capabilities and does not apply to a named option.
-
The live view is off for as long as that browser lives (Dr K, 10-28). How (agent's call):
ran.live_viewstays what the Grid said (se:vncEnabled), because a headless browser on a node with a screen still reports VNC. The reason is a separate check on the record'ssettings.headless, which is what that browser was opened with: nothing but an open writes it, andresizechanges only the window.- the proxy's
look()(http/admin/live.py) refuses with a newHEADLESS = "this browser is headless: it has no live view", afterNO_BROWSERand beforeNO_LIVE_VIEW. The mint answers 404 with it and the socket closes 4004 with it, as for the other reasons; -
liveUnavailable(ui/src/admin/liveview.svelte.ts) returnsno live view · headlessright afterno live view · no browser, beforethis Grid has no VNC.
Control rides on the live view, so it goes with it; nothing else changes. The GRID SESSION card's LIVE VIEW row keeps saying what the Grid reported.
- the proxy's
-
The headless flag in raw capabilities is refused (Dr K, 10-28: the guard stays; #82's reserved row). The details are the agent's call:
-
What counts. In
goog:chromeOptions.argsormoz:firefoxOptions.args, an arg that readsheadlessonce its leading dashes and any=valueare stripped, in any case (--headless,--headless=new,--headless=old,-headless,-HEADLESS). Firefox reads its arguments without regard to case; refusing--HEADLESSfor Chrome, which would ignore it, costs nobody anything. AndMOZ_HEADLESSinmoz:firefoxOptions.env, whatever its value, which is Firefox's other switch. -
In both vendor blocks, whatever the browser (changed on #97). The
draft said the browser's own block only, but
checkhas no browser to ask: the config is checked at boot and serves both browsers, and the caller'scapabilitiesare remembered whole across a switch of browser, so a-headlesssitting in a Chrome workspace's Firefox block would take effect, unguarded, atopen_session(browser="firefox"). The other browser's block is still sent untouched, as today. - In raw
capabilitiesit is a 400. Insession.capabilitiesit is aConfigErrorat boot (Dr K, 10-28): a headless flag in config would be a config default by another name, and ruling 2 gives headless none. The only reason to make every browser headless is a Grid with no display, which ruling 9 puts out of scope; when that comes back it gets a named option, not a raw flag. A boot error is also the cheapest way to fail: the operator sees it at once, never an agent mid-task. -
The sentence is #82 ruling 15's, naming where the flag sits and the
option: goog:chromeOptions.args --headless=new cannot be sent in
capabilities: use headless=true or headless=false instead, and
moz:firefoxOptions.env.MOZ_HEADLESS cannot be sent in …. The same words
for
session.capabilities, asacceptInsecureCertsalready has there thoughinsecurehas no config default either. A remembered one gets #82 ruling 26's way out, clear it with open_session(capabilities={}), on an explicit open and on a reap alike.
-
What counts. In
-
The flag sent (from the issue):
--headless=newingoog:chromeOptions.args,-headlessinmoz:firefoxOptions.args, throughsession_caps.build(option_args=…), sourceoption. Nothing is sent forheadless=false: headful is the browser's own default. -
The window is not capped by the screen (from the issue). A headful window cannot exceed the node's virtual screen; a headless one can. The
open_sessiondocstring says so, since it is the reason to choose headless for a very large window, and so doesscreenshot's, sincefull_pagegrows the window to the page and is where a person meets the cap. Neither names a size: the screen is the node's, not ours. The plan's first task measures it before the sentence ships. Not borne out (2026-10-28, see Research): a headful window and a full-page screenshot both outgrew the node's screen, so neither docstring nor the skill says the window is capped; pending Dr K. -
A Grid with no display (
SE_START_XVFB=false) is out of scope (Dr K, 10-28). Ours has a display. The plan's first task still captures the error a headed browser gets on such a node, and records it under Research below, so it can become a clear 400 later. An explicit ask that cannot be honoured fails; nothing is quietly dropped from an explicit open. -
"The headless default" goes from the prose (agent's call, #97). Four places call the browser's default window "the headless default" (
open_sessionandresizedocstrings,READING_PAGES.md,INTERACTION.md), from before the Grid's browsers were known to be headful. With aheadlessoption beside them that reads as a claim about it, so they say "the default window".
headless: bool | None on open_session: the tool (mcp/tools.py), the
endpoint (POST /browser, by the schema: routes.py passes every
SETTINGS key in the body) and the action (Actions.open_session in
core/actions.py). The action turns it into option_args for
session_caps.build and reports headless: true in the result's settings
when set, as insecure is.
headless joins SETTINGS in workspace/settings.py as
(None, None, _as_flag), beside insecure, and stays out of FROM_CONFIG.
So:
-
from_clientnever reads it, and config has no default for it; -
_as_flagkeepsfalse, so an explicitheadless=falsebeats a rememberedtrue; - it is stored in
Workspace.settingswithset_by: caller, carried to an explicitopen_session()(it is not excluded asrecordandcaptureare), and replayed after a reap (Workspaces.resolved); - the
set_bykeys stay equal to the settings' keys (touch-ups ruling 14), whichrememberalready guarantees.
Ruling 4 picks its wording by whether this call passed headless (its
coerced value in wanted is not None) or the value came from the inherited
settings.
- the
open_sessionresult'ssettings, andworkspace://current'ssettings, which is the record's; - the admin row, as
headless: boolbesideinsecure(http/admin/workspaces.py,WorkspaceRow); - the admin Session tab's WORKSPACE card:
HEADLESS onwith its source pill when set, idle included, since an explicit open carries it over (#82 ruling 11); the Capabilities section already lists the vendor block with itsoptionpill oncebuildsends the arg; - the Live tab, greyed with
no live view · headless(ruling 5).
The open_session docstring and the skill (references/WORKSPACES.md,
Choosing the browser) say when to choose it (speed, or nobody watching; a large
window is not a reason until ruling 8's note is settled) and when not to: bot checks detect a
headless browser more easily, so headful stays the default for real sites.
They also say that it is remembered until headless=false, and that it rules
out record and the live view. TROUBLESHOOTING.md and
CONFIGURATION.md list the refused flag where they list the other owned keys.
From the issue (2026-10-25, cluster Grid 4.48.0, Chrome 152, Firefox 155):
| Node | Browser | Result |
|---|---|---|
| has a screen (ours) | headful | live view and recording work |
| has a screen | --headless |
nothing on screen: an empty live view, an empty recording |
no screen (SE_START_XVFB=false) |
headful | Chrome likely refuses to start; not tested |
| no screen | --headless |
works; no live view, no recording |
- On docker-selenium,
SE_START_XVFBdecides whether a node has a screen. VNC and the recorder share or film that screen and nothing else. - WebDriver and BiDi do not need a screen: screenshots (rendered by the
browser itself),
extract, capture and site data all work headless. - Today's Chrome
--headless=newis the same browser binary, not the old separate headless shell.
Measured on 2026-10-28 by the plan's Task 1:
-
The error on a node with no screen (ruling 9): not measured. The runner that ran the plan has no container runtime, and the cluster Grid has a screen, so the no-screen case could not be reproduced.
-
The window (ruling 8) was measured on the cluster Grid through selenium-flow, not on the docker images. Its screen is 1920x1080, device pixel ratio 1. After
resize(3000, 4000):Browser Mode outer inner screenChrome 152.0.7977.82 headful 3000x4000 2992x3853 1920x1080 Chrome 152.0.7977.82 --headless=new3000x4000 3000x3857 800x600 Firefox 155.0.1 headful 3000x4000 2998x3888 1920x1080 Firefox 155.0.1 -headless3000x4000 3000x3914 1366x768 A headful viewport screenshot was 2992x3853 on Chrome.
full_pageof a 9000px page gave 1272x8853 on Chrome opened at 1280x900, and 2998x8888 on Firefox at 3000x4000. Only Chrome's headlessuserAgentcontains "Headless". -
The node's screen did not cap a headful window or a full-page screenshot on this Grid, so ruling 8 is not borne out: neither docstring says the window is capped (agent's call, pending Dr K).
-
test_session_caps.py: the flag refused in either block on either browser, every spelling,MOZ_HEADLESS, the remembered way out; the option arg, its source, and nothing sent for false. -
test_config_load.py: the flag insession.capabilitiesstops the boot with aConfigErrornamingheadless=. -
test_session_open.py: the action sends the arg and reportssettings.headless. -
test_workspaces.py(where the cascade's tests are):headlessremembered by an explicitopen_session()and replayed after a reap;falsebeating a rememberedtrue; no?headless=/X-Headlessread; a remembered raw flag refused on an explicit open and on a reap. -
test_recording_open.py(whereRECORDING_OFFis tested):record=truewith headless refused before the held browser ends, in both wordings, andRECORDING_OFFfirst. -
test_admin_live.py: the mint and the socket refuse a headless record withHEADLESS. -
test_files_and_admin.py: the row'sheadless. -
liveview.test.ts,SessionPane.test.ts: the headless branch and its place in the order; the HEADLESS row. -
test_surfaces.pyand the goldens move with the schema. - No integration flow: nothing a person would see break that a unit test misses (AGENTS.md).
- Headless nodes on the Grid (an operator choice in
kubed-io/selenium). - A Grid with no display (ruling 9).
- Detecting a headless browser that selenium-flow did not ask to be headless,
such as a
binarypointing at Chrome's headless shell.
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