Skip to content

2026 10 28 headless design

kubed-io[bot] edited this page Oct 28, 2026 · 7 revisions

Headless: a remembered setting that rules out recording and the live view

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).

Brief

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.

Rulings

  1. 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 args list. #82 ruling 6 already lists it.

  2. An open_session option, remembered the way insecure is (Dr K, 10-28; replaces 10-26's "like the window").

    • No query parameter, no header, no session.headless config 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=false beats the remembered value, and is remembered in its turn.
  3. A headless browser cannot record and has no live view (Dr K, 10-26). The rules belong to that browser.

  4. 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_browser right after RECORDING_OFF, after the cascade and before end_browser, so it costs no browser. A server with recording switched off says RECORDING_OFF first, since headless=false would 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.

  5. The live view is off for as long as that browser lives (Dr K, 10-28). How (agent's call): ran.live_view stays 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's settings.headless, which is what that browser was opened with: nothing but an open writes it, and resize changes only the window.

    • the proxy's look() (http/admin/live.py) refuses with a new HEADLESS = "this browser is headless: it has no live view", after NO_BROWSER and before NO_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) returns no live view · headless right after no live view · no browser, before this 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.

  6. 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.args or moz:firefoxOptions.args, an arg that reads headless once its leading dashes and any =value are stripped, in any case (--headless, --headless=new, --headless=old, -headless, -HEADLESS). Firefox reads its arguments without regard to case; refusing --HEADLESS for Chrome, which would ignore it, costs nobody anything. And MOZ_HEADLESS in moz: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 check has no browser to ask: the config is checked at boot and serves both browsers, and the caller's capabilities are remembered whole across a switch of browser, so a -headless sitting in a Chrome workspace's Firefox block would take effect, unguarded, at open_session(browser="firefox"). The other browser's block is still sent untouched, as today.
    • In raw capabilities it is a 400. In session.capabilities it is a ConfigError at 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, as acceptInsecureCerts already has there though insecure has 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.
  7. The flag sent (from the issue): --headless=new in goog:chromeOptions.args, -headless in moz:firefoxOptions.args, through session_caps.build(option_args=…), source option. Nothing is sent for headless=false: headful is the browser's own default.

  8. 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_session docstring says so, since it is the reason to choose headless for a very large window, and so does screenshot's, since full_page grows 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.

  9. 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.

  10. "The headless default" goes from the prose (agent's call, #97). Four places call the browser's default window "the headless default" (open_session and resize docstrings, READING_PAGES.md, INTERACTION.md), from before the Grid's browsers were known to be headful. With a headless option beside them that reads as a claim about it, so they say "the default window".

Design

The input

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.

The cascade

headless joins SETTINGS in workspace/settings.py as (None, None, _as_flag), beside insecure, and stays out of FROM_CONFIG. So:

  • from_client never reads it, and config has no default for it;
  • _as_flag keeps false, so an explicit headless=false beats a remembered true;
  • it is stored in Workspace.settings with set_by: caller, carried to an explicit open_session() (it is not excluded as record and capture are), and replayed after a reap (Workspaces.resolved);
  • the set_by keys stay equal to the settings' keys (touch-ups ruling 14), which remember already 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.

Where it shows

  • the open_session result's settings, and workspace://current's settings, which is the record's;
  • the admin row, as headless: bool beside insecure (http/admin/workspaces.py, WorkspaceRow);
  • the admin Session tab's WORKSPACE card: HEADLESS on with 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 its option pill once build sends the arg;
  • the Live tab, greyed with no live view · headless (ruling 5).

The prompt

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.

Research

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_XVFB decides 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=new is 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 screen
    Chrome 152.0.7977.82 headful 3000x4000 2992x3853 1920x1080
    Chrome 152.0.7977.82 --headless=new 3000x4000 3000x3857 800x600
    Firefox 155.0.1 headful 3000x4000 2998x3888 1920x1080
    Firefox 155.0.1 -headless 3000x4000 3000x3914 1366x768

    A headful viewport screenshot was 2992x3853 on Chrome. full_page of a 9000px page gave 1272x8853 on Chrome opened at 1280x900, and 2998x8888 on Firefox at 3000x4000. Only Chrome's headless userAgent contains "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).

Tests (for the plan)

  • 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 in session.capabilities stops the boot with a ConfigError naming headless=.
  • test_session_open.py: the action sends the arg and reports settings.headless.
  • test_workspaces.py (where the cascade's tests are): headless remembered by an explicit open_session() and replayed after a reap; false beating a remembered true; no ?headless= / X-Headless read; a remembered raw flag refused on an explicit open and on a reap.
  • test_recording_open.py (where RECORDING_OFF is tested): record=true with headless refused before the held browser ends, in both wordings, and RECORDING_OFF first.
  • test_admin_live.py: the mint and the socket refuse a headless record with HEADLESS.
  • test_files_and_admin.py: the row's headless.
  • liveview.test.ts, SessionPane.test.ts: the headless branch and its place in the order; the HEADLESS row.
  • test_surfaces.py and the goldens move with the schema.
  • No integration flow: nothing a person would see break that a unit test misses (AGENTS.md).

Out of scope

  • 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 binary pointing at Chrome's headless shell.

Clone this wiki locally