| D-M6-18-0 |
2026-10-21 |
Base int/m1-wave53 at d3e290d, branch feat/M6-18-console-terminal in its own worktree with the stacked builds merged first with --no-ff: feat/M2-48-terminal-conformance at 7264ca5, feat/M4-28-admin-agents at b06cd0e and feat/M6-04-console-admin at 67b348d. Their conflicts were resolved as unions for the append-only files (rulings, changelogs, ARB files by key, then flutter gen-l10n) and with the base for code the base already integrated (the M2-20, M2-21, M2-27 and M2-46 parts the stacked branches carried in older versions); the generated REST client stays the base's. Nothing is pushed and nothing is merged into another branch; the integration takes the commits after the merges. |
Task of the steering (stacked build); the base holds the integrated versions of the shared dependencies. |
| D-M6-18-1 |
2026-10-21 |
Console step-up rule (Design note 1) in authz/domain: FreshFacts.StepUpUserVerified is the fact of 08 6.3 point 3, and FreshStepUp passes a console request only with a passkey step-up that has it, fresh and unconsumed; this replaces the fail-closed placeholder of M2-40 and its console test cases. Every refusal of the fresh step-up in the console (missing, password with TOTP, OIDC, a device key, a passkey without user verification, too old or consumed) is 401 step_up_required with problem reason passkey_required (domain.ConsolePasskeyRequired, Decision.PasskeyRequired, authorize.ReasonPasskeyRequired); the audit reason stays terminal_step_up_not_fresh of the closed list of 07 4.1. identity reads the fact without a table change: GetConsoleAuthState and LockSessionForStepUpConsumption compute step_up_user_verified from the used step-up challenge of the session whose finished_at equals step_up_at and whose webauthn_session says user_verification = required; ConsumeFresh checks it again on the locked row and answers with the same reason (FreshStepUpCheck.PasskeyRequired). A challenge the cleanup job already deleted counts as not verified (fail closed). The quick reattach stays never granted to a console actor. 08 6.3 states the wider reason. |
08 6.3 names passkey_required for point 3, while TM-T12 and requirement 12 of the brief need it for an attach after a detach, whose last passkey step-up is consumed; in the console a new passkey is the only way on, so one reason for every refusal tells the console what to ask for. Reading the stored challenge keeps the fact where M1-40 stores it. |
| D-M6-18-2 |
2026-10-21 |
Stream authentication (Design note 2) in wsrelay/stream.go: an upgrade with the console cookie needs exactly one Origin equal to server.public_url, else 403 before the upgrade, also without any Origin; without the cookie the M2-44 rule stays (no Origin allowed for native apps). The cookie of the upgrade is kept for identify, where the console path authenticates it through the same Authenticator (pre-step-up gate with action terminal.stream): the principal must be a console session and its device the device_id of identify, then rl.terminal and authorize terminal.stream, which admits only the device of the session's current token. An identify without a token on a connection without the cookie, a token on a console connection, another device or an unknown cookie close with 4004; a revoked console session with 4012. |
One credential per path (M6-01 Design note 4); authenticating at identify sees a revocation between upgrade and identify; the device check reuses the rule of the app devices. |
| D-M6-18-3 |
2026-10-21 |
Pins in the console (Design note 3): IndexedDbHostKeyPinStore keeps one IndexedDB database bothy.terminal.pins with the object store host_key_pins (per host the Noise public key, key generation, fingerprint, first seen). The console controller deletes the database before E7 on the logout, on every answer with the clearing cookie and at a start without a session; a failed deletion still shows E7. The ephemeral key comes from the controller's default factory (bothy_noise with cryptography) in the tab's memory. HostEvents is not passed: A8 and the view reload through REST on opening and after each own action. The transport learns the device_id of identify from the grant (session.device_id, recorded per stream_path by ConsoleTerminalApi), because the console session's REST answer carries none. |
R-CL-15 and ADR 0070 item 3; the session already ended on the server when the deletion fails, and a pin in the console never protects against the server. |
| D-M6-18-4 |
2026-10-21 |
Pinning statement and A8 (Design note 4): the console wraps the dialogs of terminal_ui (ConsoleTerminalPrompts) and adds the sentence "In the browser this check only notices a key change that the server reports; it does not protect against the server itself." below the dialog "Open terminal" and the key change warning; packages/terminal_ui is unchanged. The page "Hosts and terminal" (/console/hosts, /console/hosts/<id>) hosts A8 of admin_ui with the step-up adapter of M6-04 and adds "Open terminal" on a host's page. The terminal step-up of the console is the passkey only (PasskeyStepUpPrompt over ConsoleActionStepUp restricted to the passkey, userVerification: required); without a passkey it shows a notice and cancels. |
24 5.1 and 01 5.5; the brief allows no change to terminal_ui, and the client decides nothing about freshness (15 1 principle 1). |
| D-M6-18-5 |
2026-10-21 |
Browser tests (Design note 5): make console-e2e gains phase 3 with the overlay infra/compose/test/compose.console-e2e-terminal.yaml: the host image of M2-48 as console-host (privileged, root, as termhost) on the internal network consolehosts with api and its TLS sidecar, trusting only the test CA; the runner builds bothy-host with tools/console-e2e/host-bin.Dockerfile (the Go image of the server), installs it, and pairs it with https://api:8443 from the code the seed hands over in a mode 0600 file it deletes at once. The seed confirms the host with declared_server_host: true. The patrol web test integration_test/terminal/terminal_test.dart (bundle terminal_bundle.dart, Dockerfile targets console-e2e-terminal-build and console-e2e-terminal-web) opens, measures, detaches and reattaches; headless/terminal_suites.py checks at its synchronisation points the passkey cases with the virtual authenticator (isUserVerified true or false), passkey_required after a password step-up and for an attach without a new passkey, 403 for the upgrade from a foreign origin, no pin database after logout and no CSP violation with an open terminal. The 4004 of an identify without token on a connection without the console session is a Go test. |
The brief's suites in the browser as far as Chromium has a virtual authenticator; the host is declared because api is not its own address (do not assert reported_is_server_host). |
| D-M6-18-6 |
2026-10-21 |
Measurement without a gate (Design note 6): the patrol test writes markers from the host shell and a burst (the host test image has no output generator and no pv, so the burst is not rate-limited) and records frame-to-paint and throughput next to the desktop targets client.perf.terminal_frame_to_paint_p95 and client.perf.terminal_throughput_min (from docs/operations/configuration.md) with the versions of xterm and cryptography from pubspec.lock; the runner prints one line with only numbers and versions. 24 12 records the values. |
The console has no target of its own (PLAN 12 risk); a gate without a target would be invented. |
| D-M6-18-7 |
2026-10-21 |
Names of the merged code that differ from the brief (D-W-100): the spec chapters on this base carry German file names (08-identitaet.md, 15-client-architektur.md, 19-sicherheit.md, 01-produkt.md), so the changes of this ticket are written there in English; the fresh step-up port is authz.FreshStepUpCheck with FreshStepUpFacts; "declares the host as the server host" is the field declared_server_host of confirmHost; the route already existed as consoleNavHosts; the brief's make check runs as the targeted tests of the touched components (steering 2026-10-21). |
D-W-100 and the steering rule on verification. |
| D-M6-18-8 |
2026-10-24 |
Port to main: the ticket's own commits from feat/M6-18-console-terminal (built on int/m1-wave53) are cherry-picked onto origin/main as feat/M6-18-console-terminal-main; M2-48, M4-28, M4-35, M4-37 and M6-04 are already on main, so the stacked merges drop out. On main the chapters have English names, so the console rule stands in docs/spec/08-identity.md 6.3 and code comments cite 08-identity.md and 15-client-architecture.md; the rows of this ticket and the changelog entries are fragments. Conflicts with code merged since the build are unions: Decision carries both Detail (M2-09) and PasskeyRequired, the console routes keep the pages of M6-07, M6-08, M6-09 and M8-08 next to hosts, phase 2 of console-e2e keeps the webhook receiver of M6-08 before phase 3 replaces the admin overlay, and host-bin.Dockerfile takes the Go image of the server Dockerfile on main. The docstrings of the new Python follow the pydocstyle rules main enforces. |
Rules of the landing lane (fragments, English chapter names on main). |
| D-M6-18-9 |
2026-10-24 |
The burst of the measurement is acknowledged per chunk: 64 chunks of 64 KiB, and the program waits for a typed line feed after each chunk, which the patrol test sends once the chunk's marker is in the terminal model. The first CI runs of the port failed phase 3 because the unacknowledged 4 MiB burst outran the slow runner: the host closed its data connection with 1000 when its send queue was full, the session went detached and the patrol test waited for the end of the burst in vain. A full local run had passed, so the old test depended on the speed of the machine. The terminal has no flow control toward the PTY by design (the host's send queue and terminal.relay_buffer_max_bytes disconnect a slow reader, 24 6.2), so the test bounds what is in flight instead. The throughput now includes one round trip per chunk; it stays a measurement without a gate (D-M6-18-6). |
Deterministic tests (a test must not fail on a loaded or slow machine); the measured behaviour of the product is unchanged. |