diff --git a/artifacts/herdr-v1-adversarial-report.json b/artifacts/herdr-v1-adversarial-report.json new file mode 100644 index 0000000..e6814ee --- /dev/null +++ b/artifacts/herdr-v1-adversarial-report.json @@ -0,0 +1,131 @@ +{ + "schemaVersion": 1, + "kind": "black-box-api-test-report", + "generatedAt": "2026-07-30T21:06:44.311Z", + "passed": true, + "runs": [ + { + "id": "server-adversarial", + "env": { + "TSX_TSCONFIG_PATH": "server/tsconfig.json" + }, + "command": [ + "node", + "--import", + "tsx", + "--test", + "server/modules/terminal-runtimes/tests/*.test.ts", + "server/modules/providers/tests/discovery-collector.service.test.ts", + "server/modules/providers/tests/discovery-cost.test.ts", + "server/modules/providers/tests/pane-output-stream.service.test.ts", + "server/modules/providers/tests/provider-routes-contract.test.ts", + "server/modules/websocket/tests/discovery-stream.service.test.ts", + "server/modules/websocket/tests/shell-websocket-protocol.test.ts", + "server/herdr-status.contract.test.ts" + ], + "cwd": ".", + "exitCode": 0, + "timedOut": false, + "passCount": 126, + "outputSha256": "06943d1704780f98cb1049d54d1648e8e31c4fbd62b2ff4fd10bb3cde8ba91ed", + "outputTail": "d.\n[INFO] Shell websocket connected\n✔ attach-only lease reconnects after capability expiry only for its principal and pane (52.243979ms)\n[INFO] Shell websocket connected\n[ERROR] Shell WebSocket error: The existing typed attach session is not leased to this target.\n[INFO] Shell websocket connected\n[INFO] Shell websocket connected\n[INFO] Shell websocket connected\n[ERROR] Shell WebSocket error: The typed attach capability is invalid or expired.\n[INFO] Shell websocket connected\n[INFO] Shell websocket connected\n[INFO] Shell websocket connected\n[ERROR] Shell WebSocket error: The typed attach capability is invalid or expired.\n[INFO] Shell websocket connected\n[INFO] Shell websocket connected\n✔ attach-only forceRestart preserves an existing PTY when its lease or capability is invalid (93.843706ms)\n[INFO] Shell websocket connected\n[ERROR] Shell WebSocket error: spawn failed\n[INFO] Shell websocket connected\n[INFO] Shell websocket connected\n✔ forceRestart spawn failure close preserves the original PTY websocket binding (31.932489ms)\n[INFO] Shell websocket connected\n[INFO] Shell websocket connected\n[INFO] Shell websocket connected\n[INFO] Shell websocket connected\n✔ overlapping forceRestart requests replace the current PTY without orphaning either replacement (42.377074ms)\n✔ full redraw strips stale terminal queries without altering visible ANSI output (0.183707ms)\n[INFO] Shell websocket connected\n[INFO] Shell websocket connected\n[INFO] Shell websocket connected\n[INFO] Shell websocket connected\n✔ reconnect with an acknowledged seq resumes seamlessly; legacy and gapped clients redraw (42.276995ms)\n[INFO] Shell websocket connected\nShell attach diagnostic: { code: 'attach_refused_identity', provider: 'attach-only', count: 1 }\n[ERROR] Shell WebSocket error: The tmux target identity verifier is unavailable.\n[INFO] Shell websocket connected\n[ERROR] Shell WebSocket error: The tmux target identity verifier is unavailable.\n[INFO] Shell websocket connected\n[ERROR] Shell WebSocket error: The tmux target identity verifier is unavailable.\n[INFO] Shell websocket connected\n[ERROR] Shell WebSocket error: The tmux target identity verifier is unavailable.\n[INFO] Shell websocket connected\nShell attach diagnostic: { code: 'attach_refused_protected', provider: 'attach-only', count: 1 }\n[ERROR] Shell WebSocket error: The tmux target protection status could not be verified.\n✔ attach-only rejects missing, wrong-principal, wrong-pane, expired, and changed-generation capabilities (50.976916ms)\n[INFO] Shell websocket connected\n[ERROR] Shell WebSocket error: The tmux target protection status could not be verified.\n[INFO] Shell websocket connected\n[ERROR] Shell WebSocket error: The tmux target protection status could not be verified.\n[INFO] Shell websocket connected\n[ERROR] Shell WebSocket error: tmux failed\n[INFO] Shell websocket connected\n[ERROR] Shell WebSocket error: The ChatMux tmux pane protection status could not be verified.\n[INFO] Shell websocket connected\n[ERROR] Shell WebSocket error: This tmux target is protected.\n✔ attach-only rejects when tmux target or ChatMux pane protection cannot be read (51.92137ms)\n[INFO] Shell websocket connected\n[ERROR] Shell WebSocket error: This tmux target is protected.\n[INFO] Shell websocket connected\n[ERROR] Shell WebSocket error: This tmux target is protected.\n[INFO] Shell websocket connected\n[ERROR] Shell WebSocket error: This tmux target is protected.\n✔ attach diagnostics are rate-limited across connections and resume after one minute (31.965421ms)\n[INFO] Shell websocket connected\n✔ attach refusal diagnostics contain only code, provider, and count (9.973286ms)\n✔ client source contains no tmux attach command string (15.19092ms)\n[INFO] Shell websocket connected\n[INFO] Shell websocket connected\n✔ Herdr v3 malformed and gap controller frames release the no-takeover lease (2123.575999ms)\n[INFO] Shell websocket connected\n✔ Herdr v3 disconnect releases controller (1061.479621ms)\n[INFO] Shell websocket connected\n✔ Herdr control blocks pre-ack writes, revalidates post-ack writes, and preserves split UTF-8 controller data (14.682009ms)\n[INFO] Shell websocket connected\n✔ Herdr idle identity polling invalidates an acknowledged controller (2061.694634ms)\n[INFO] Shell websocket connected\n✔ Herdr revocation callback synchronously blocks writes and awaits controller exit (12.344364ms)\n[INFO] Shell websocket connected\n✔ Herdr closes and releases on the first controller stderr byte (11.811327ms)\n[INFO] Shell websocket connected\n✔ Herdr serializes deferred verifier input and resize writes in FIFO order (14.158109ms)\n[INFO] Shell websocket connected\n✔ Herdr releases when the bounded input queue overflows (13.689133ms)\n[INFO] Shell websocket connected\n✔ Herdr fails and releases when controller input writing throws (13.207634ms)\n✔ Herdr output queue rejects the candidate frame that exceeds 256 pending frames (14.14785ms)\nℹ tests 126\nℹ suites 0\nℹ pass 126\nℹ fail 0\nℹ cancelled 0\nℹ skipped 0\nℹ todo 0\nℹ duration_ms 40695.866061\n" + }, + { + "id": "client-protocol", + "env": { + "TSX_TSCONFIG_PATH": "tsconfig.json" + }, + "command": [ + "node", + "--import", + "tsx", + "--test", + "src/components/shell/shellProtocol.test.ts", + "src/components/main-content/view/MainContent.test.ts", + "src/hooks/useDiscoveryStream.test.ts", + "src/components/sidebar/hooks/useExternalCliSessions.test.ts", + "src/components/app/AppContent.discoveryAuthority.test.tsx" + ], + "cwd": ".", + "exitCode": 0, + "timedOut": false, + "passCount": 36, + "outputSha256": "885303630849e118d6ffcc47ed2b68c44606a3ba6f3045461cd65b778d473557", + "outputTail": "✔ mounted production authority clears external target on newest failed discovery and fences older promotion (33.57003ms)\n✔ mounted production authority clears GJC target on newest failed discovery and fences older typed promotion (1.719357ms)\n✔ mounted production authority promotes only the newest exact external transcript (1.838883ms)\n✔ mounted production authority promotes only the newest exact GJC session (2.080218ms)\n✔ applies attached and output frames for the shared pane subscription key (1.14377ms)\n✔ keeps an attached pane on stream output without repeated REST fallback reads (5.127805ms)\n✔ M5b B8: forceAttach skips the pending relay surface for a local-agent pane with an observable process (0.25934ms)\n✔ M5b B8: forced attach resolves to the exact pane 4-tuple as a local-agent shell target, never another pane (0.208524ms)\n✔ Herdr read targets never attach while explicit control targets use shell v3 (0.154903ms)\n✔ M5b B8 AC3: a ssh/shell row without an issued attachCapability never attaches, regardless of forceAttach (0.139023ms)\n✔ CLI output tab upgrades to an exact-4-tuple interactive attach only with a process identity (0.140715ms)\n✔ pending CLI output hides the duplicate chat composer while terminal input is active (1.565447ms)\n✔ typed attach init uses coordinates without an initial command (1.20732ms)\n✔ attach-only init includes its capability without a process or initial command (0.16391ms)\n✔ Herdr attach uses strict shell v3 without legacy project or session fields (0.134354ms)\n✔ null-project typed attach uses explicit context without session or command authority (0.122491ms)\n✔ plain shell init keeps its existing fields and adds the protocol version (0.140966ms)\n✔ shell consumers retain their command props and no client tmux command builder remains (0.336135ms)\n✔ shell view permits only typed attach without a Project (0.274218ms)\n✔ external terminal targets do not attach without a server-issued capability (0.283956ms)\n✔ CLIENT_RELOAD_REQUIRED is terminal and suppresses automatic reconnect (24.430419ms)\n✔ init carries the acknowledged output seq only when one exists (0.260913ms)\n✔ Herdr disconnect never replays its consumed admission capability (2.596744ms)\n✔ typed tmux target reconnects after a socket close (0.875042ms)\n✔ hydrates external metadata onto a discovery row that arrived first (1.44044ms)\n✔ stream loss clears mutable provider activity before REST fallback (0.246115ms)\n✔ REST request generations reject a late bootstrap response (0.140325ms)\n✔ newest generation wins across recovery and unmount fencing (0.12175ms)\n✔ a malformed newest external container fences an older successful response (0.205768ms)\n✔ false discovery sanitizes reported-present external rows to identity only (0.182464ms)\n✔ uses strict v2 discovery transport (1.222258ms)\n✔ keeps independent Herdr sources and opaque identities (0.356343ms)\n✔ projects authoritative source/lane pairs without clearing peer Herdr or tmux terminals (0.227199ms)\n✔ preserves tmux projection only in its explicitly declared lane (0.161515ms)\n✔ requires exact authoritative source/lane coverage but not a matching global revision (0.136187ms)\n✔ rejects stale clients, pair regressions, and same-revision disagreement (0.237969ms)\nℹ tests 36\nℹ suites 0\nℹ pass 36\nℹ fail 0\nℹ cancelled 0\nℹ skipped 0\nℹ todo 0\nℹ duration_ms 529.352262\n" + } + ], + "cases": [ + { + "id": "discovery-v1-reload-required", + "status": "passed", + "evidence": "Discovery stream rejects protocolVersion 1 with CLIENT_RELOAD_REQUIRED before subscriber registration or collector start; v2 snapshots/heartbeats and bounded resync pass." + }, + { + "id": "per-source-failure-isolation", + "status": "passed", + "evidence": "Runtime registry and discovery collector tests preserve adapter-provided source outcomes and independent source/lane failure and grace state." + }, + { + "id": "pair-local-authority", + "status": "passed", + "evidence": "Client discovery authority tests require exact source/lane coverage while allowing unrelated global revisions and rejecting pair regressions or disagreement." + }, + { + "id": "stale-local-agent", + "status": "passed", + "evidence": "HerdrTargetRegistry tests reject changed local-agent process generation while retaining attach-only foreground replacement." + }, + { + "id": "attach-foreground-replacement", + "status": "passed", + "evidence": "Registry tests and artifacts/herdr-v1-live-report.json verify attach-only control survives ordinary foreground replacement." + }, + { + "id": "source-restart-stale-target", + "status": "passed", + "evidence": "artifacts/herdr-v1-live-report.json denies the original opaque target after named Herdr server restart/socket incarnation change." + }, + { + "id": "principal-bound-single-use", + "status": "passed", + "evidence": "Herdr control bridge tests cover wrong principal, one-time capability use, expiry, duplicate controller denial, policy reduction, and release." + }, + { + "id": "pre-ack-and-hot-disable", + "status": "passed", + "evidence": "Shell websocket tests block pre-ack writes, reverify post-ack and each write, continuously reverify identity, and release controllers after malformed frames, gaps, policy reduction, and disconnect." + }, + { + "id": "legacy-malformed-protocol", + "status": "passed", + "evidence": "Terminal runtime, discovery stream, and shell websocket tests reject malformed, v1, v2-shell, and version-confused contracts before authority acquisition." + }, + { + "id": "frame-and-command-bounds", + "status": "passed", + "evidence": "Herdr client/probe and shell protocol tests reject malformed NDJSON and enforce stdout, stderr, line, frame, timeout, sequence, UTF-8, and input bounds." + }, + { + "id": "disconnect-release-cleanup", + "status": "passed", + "evidence": "Control bridge and websocket tests prove exactly-once release and bounded teardown; live report proves no-takeover reacquisition and disposable named-session cleanup." + }, + { + "id": "tmux-non-regression", + "status": "passed", + "evidence": "Runtime adapter, shell protocol, discovery cost, pane stream, and provider route suites exercise existing tmux authority, attach, capture, actions, and constant-cost contracts." + } + ], + "artifacts": [ + { + "id": "live-v1", + "kind": "black-box-api-test-report", + "path": "artifacts/herdr-v1-live-report.json" + }, + { + "id": "phase0", + "kind": "phase0-contract-fixture", + "path": "server/modules/terminal-runtimes/tests/fixtures/herdr/v0.7.5-phase0.json" + } + ] +} diff --git a/artifacts/herdr-v1-live-report.json b/artifacts/herdr-v1-live-report.json new file mode 100644 index 0000000..9d9ccae --- /dev/null +++ b/artifacts/herdr-v1-live-report.json @@ -0,0 +1,39 @@ +{ + "schemaVersion": 1, + "kind": "black-box-api-test-report", + "passed": true, + "execution": { + "command": [ + "node", + "--import", + "tsx", + "server/modules/terminal-runtimes/tests/herdr-runtime-live-probe.ts" + ], + "cwd": ".", + "requiredEnvironment": [ + "CHATMUX_HERDR_TEST_ASSET" + ], + "expectedAssetSha256": "3dc83288073e4c2d3c679a30e7be97bcca9141c6fd17dbbb9219142e95c59253", + "exitCode": 0 + }, + "checks": { + "provenanceSha256": "3dc83288073e4c2d3c679a30e7be97bcca9141c6fd17dbbb9219142e95c59253", + "readiness": "ready", + "targetClass": "attach-only", + "outputMarkerObserved": true, + "outputFresh": true, + "attachFresh": true, + "genericPaneActionsDenied": true, + "duplicateControllerDenied": true, + "resizeFrameObserved": true, + "reacquiredAfterRelease": true, + "staleTargetDeniedAfterSourceRestart": true, + "productManagedCreationUsed": false + }, + "cleanup": { + "serverStopped": true, + "sessionDeleteExit": 0, + "sessionAbsent": true, + "runRootAbsent": true + } +} diff --git a/docs/HERDR-PHASE0.md b/docs/HERDR-PHASE0.md new file mode 100644 index 0000000..0529b6a --- /dev/null +++ b/docs/HERDR-PHASE0.md @@ -0,0 +1,61 @@ +# Herdr Phase 0 compatibility gate + +Status: **CANDIDATE GO pending independent Architect/Critic approval.** The hardened live cohort passed, but this document is a gate, not product authorization. ChatMux must not install, start, update, repair, or take over Herdr. tmux remains unchanged and independently authoritative. + +## Pinned candidate + +- Herdr `v0.7.5`, protocol `17`, Linux x86_64. +- Official release: . +- Official asset: `herdr-linux-x86_64`. +- Published SHA-256: `3dc83288073e4c2d3c679a30e7be97bcca9141c6fd17dbbb9219142e95c59253`. +- Published at: 2026-07-21T18:11:20Z; evidence checked: 2026-07-30. + +The acceptable transport is the documented non-PTY subprocess `herdr terminal session control`. The public raw schema exposes no `terminal.*` live-control method. Private, inferred, raw-schema, shell, PTY, takeover, and default-session control are prohibited. + +## Hardened reproduction + +Run only as a non-root Linux x64 user, with an absolute, user-owned, regular, non-symlink source asset that is not group/world-writable and whose digest exactly equals the pinned digest. The previously installed user-local executable must already be absent. A mode-0600 retained asset is acceptable: the probe copies it into a new user-owned isolated run root, changes only the copy to mode 0755, and executes only that digest-identical copy. It does not install or retain an executable Herdr binary. + +```sh +node scripts/herdr-phase0-probe.mjs \ + --binary /absolute/path/to/verified/herdr \ + --expected-sha256 3dc83288073e4c2d3c679a30e7be97bcca9141c6fd17dbbb9219142e95c59253 \ + --expected-absent-path /absolute/path/of/removed/user-local/herdr \ + --output /absolute/path/to/v0.7.5-phase0.json \ + --restricted-output /secure/absolute/path/to/v0.7.5-phase0-restricted.json +``` + +The probe sets `HOME`, `XDG_CONFIG_HOME`, `XDG_STATE_HOME`, `XDG_CACHE_HOME`, `XDG_DATA_HOME`, and `HERDR_CONFIG_PATH` beneath a short mode-0700 run root. This keeps Herdr's named-session directory and Unix socket inside the manifest while respecting the Unix socket path limit. The restricted mode-0600 artifact records actual paths, owners, modes, device/inode identities, PIDs, selectors, pane IDs, pre/post session inventories, signal state, deletion, socket absence, and source/run-root equality checks; it is never checked into the repository. + +The sanitized live result is checked in at `server/modules/terminal-runtimes/tests/fixtures/herdr/v0.7.5-phase0.json`. It records only the successful isolated run and omits restricted selectors, paths, resource IDs, prompts, and frame payloads. + +Reviewers bind a cohort to the three files below using SHA-256 over each UTF-8 path, one NUL byte, its raw bytes, and one NUL byte, in this exact order: + +1. `docs/HERDR-PHASE0.md` +2. `scripts/herdr-phase0-probe.mjs` +3. `server/modules/terminal-runtimes/tests/fixtures/herdr/v0.7.5-phase0.json` + +The review receipt records the resulting hash; the files do not embed it, avoiding a circular self-hash. + +## Required report contract + +The public report uses schema version 3 and separates `observed`, `inferred`, `unsupported`, and `failClosedLimitations`. It includes: + +- a canonical full-schema SHA-256 plus required-semantic manifest/fingerprint version 3 derived from exact selected API request definitions, method-to-result-tag mappings, narrowed success/error envelopes, selected result variants, both asynchronous event and subscription-event envelopes/payload closures, recursively referenced definitions across schema namespaces, and the documented control argv, input/resize/scroll/release commands, frame/closed shapes, busy form, ordering, encoding, and named-source behavior; every retained schema reference must resolve inside the manifest before `go`; +- durable restricted provenance/preflight/install/runtime/cleanup facts; the public report excludes source/run paths, resource IDs, selectors, prompt data, and seeded secret/path markers; +- exact binary digest, source and copied-file owner/mode verification, and copied-executable execution; +- a manifest-scoped isolated named session and workspace with all supported XDG/HOME/config roots under the run root, and no shell, PTY, takeover, default session, service, profile, package, or ChatMux mutation; +- missing-selector snapshot, status, and control checks plus post-control status/session/socket checks proving no automatic start; +- bounded command capture and NDJSON line, record, encoded frame, decoded frame, and cumulative-decoded-frame limits; +- exact frame field/type validation, base64 validation before decoding/allocation, contiguous integer sequence facts, ANSI/UTF-8 evidence, resize/scroll, and exact input marker count; +- second-controller typed rejection, explicit release, post-release reacquisition without takeover, abrupt-EOF recovery, and malformed-command recovery; +- measured move, public pane split/close/recreate, foreground-process, and cold-restart identity observations. Pane revision is reported as measured data, never asserted as an occupant-generation proxy; +- bounded child cleanup, exact named-session deletion, recorded Unix-socket absence, run-root uninstall, source equality, and SIGINT/SIGTERM cancellation that prohibits new product-test child spawns while permitting only scoped finalizer cleanup; + +`go` is true only when every implemented hard assertion, sanitization assertion, cleanup, and post-state check is true. Any failure writes a sanitized no-go report and exits nonzero. + +## Product contract and fail-closed limits + +The verified public CLI cohort does not safely expose local-agent replacement, live handoff, deterministic upstream slow-consumer pressure, or direct upstream sequence-gap injection. Product integration must fail closed: local-agent writable control stays disabled without fresh supported identity evidence; bridge/socket loss requires full reacquisition; queue overflow releases; and any observed sequence gap, hierarchy/terminal/process mismatch, source-health mismatch, or policy mismatch blocks writes and closes. These limitations are not substituted with synthetic evidence. + +V1 remains limited to Linux x64 and the exact reviewed protocol plus semantic fingerprint. It excludes GJC recognition, completion notifications, and Herdr workspace/pane/agent creation. Read-only output never obtains controller ownership. A zero controller exit code is insufficient: typed closure, contiguous sequence, bounded valid frames, stderr, release, and fresh identity checks are authoritative. diff --git a/docs/SELF-HOST.md b/docs/SELF-HOST.md index 7cf15d4..63f29e0 100644 --- a/docs/SELF-HOST.md +++ b/docs/SELF-HOST.md @@ -123,6 +123,42 @@ port. `chatmux status` reports the configured address. Use `loginctl enable-linger "$USER"` only when the host policy permits the service to continue after logout. +## Optional Herdr terminal runtime + +Herdr support is optional and is available only on Linux x64. Configure it in the +environment used by `chatmux.service`: + +```sh +CHATMUX_HERDR_RUNTIME=1 +CHATMUX_HERDR_SOURCES='[{"alias":"ops","selector":"production","binary":"/home/chatmux/.local/bin/herdr"}]' +CHATMUX_HERDR_CAPABILITIES=discovery,output,actions,attach +# Optional: owner-only, absolute policy file +CHATMUX_HERDR_POLICY_FILE=/home/chatmux/.config/chatmux/herdr-policy.json +``` + +`CHATMUX_HERDR_SOURCES` is a JSON array of up to eight source objects. Each +object has a unique `alias`, a validated named `selector`, and an absolute +`binary` path. Aliases and selectors use letters, digits, `_`, and `-` (and +must start with a letter or digit); the selector `default` is rejected. +The binary must be a regular file owned by the ChatMux service user and must +not be group- or world-writable. +`CHATMUX_HERDR_CAPABILITIES` is a capability ceiling and accepts only +`discovery`, `output`, `actions`, and `attach`. An empty or omitted capability +value grants no access. `CHATMUX_HERDR_POLICY_FILE`, when used, must be an +absolute owner-only path. + +ChatMux never installs, starts, upgrades, repairs, or removes Herdr. Herdr is +not an LLM provider, and v1 excludes GJC, completion, and create operations. +Use `chatmux status` to see only whether the Herdr configuration is enabled, +the configured source count, enabled capability names, and whether a policy +is configured. The status command deliberately performs no Herdr I/O and +labels runtime readiness as unprobed; authenticated discovery reports current +source readiness. It never prints source aliases, selectors, binaries, policy +paths, tokens, sockets, arguments, or raw environment values. Invalid +configuration is shown only as disabled with safe summary fields. Actions are +available only to targets whose local-agent identity can be proven; generic +v1 panes remain attach-only and never gain action authority. + Installation automatically selects the access path: diff --git a/scripts/herdr-phase0-probe.mjs b/scripts/herdr-phase0-probe.mjs new file mode 100644 index 0000000..1305278 --- /dev/null +++ b/scripts/herdr-phase0-probe.mjs @@ -0,0 +1,633 @@ +#!/usr/bin/env node + +import { createHash, randomBytes } from 'node:crypto'; +import { access, chmod, cp, lstat, mkdir, mkdtemp, readFile, realpath, rm, stat, writeFile } from 'node:fs/promises'; +import { homedir, platform, arch, tmpdir } from 'node:os'; +import { basename, isAbsolute, join } from 'node:path'; +import { spawn } from 'node:child_process'; + +const EXPECTED_PROTOCOL = 17; +const OFFICIAL_SHA256 = '3dc83288073e4c2d3c679a30e7be97bcca9141c6fd17dbbb9219142e95c59253'; +const COMMAND_TIMEOUT_MS = 10_000; +const SERVER_START_TIMEOUT_MS = 10_000; +const MAX_CAPTURE_BYTES = 4 * 1024 * 1024; +const MAX_NDJSON_LINE_BYTES = 2 * 1024 * 1024; +const MAX_NDJSON_RECORDS = 2_048; +const MAX_ENCODED_FRAME_BYTES = 2 * 1024 * 1024; +const MAX_DECODED_FRAME_BYTES = 1024 * 1024; +const MAX_TOTAL_DECODED_FRAME_BYTES = 4 * 1024 * 1024; +const REQUIRED_METHODS = ['agent.list', 'events.subscribe', 'pane.get', 'pane.list', 'pane.move', 'pane.process_info', 'pane.read', 'pane.send_keys', 'pane.send_text', 'session.snapshot']; +const METHOD_RESULT_TAGS = Object.freeze({ + 'agent.list': 'agent_list', + 'events.subscribe': 'subscription_started', + 'pane.get': 'pane_info', + 'pane.list': 'pane_list', + 'pane.move': 'pane_move', + 'pane.process_info': 'pane_process_info', + 'pane.read': 'pane_read', + 'pane.send_keys': 'ok', + 'pane.send_text': 'ok', + 'session.snapshot': 'session_snapshot', +}); +const CONTROL_SEMANTICS = Object.freeze({ + transport: { + executable: 'verified absolute path', + argv: ['--session', '', 'terminal', 'session', 'control', '', '--cols', '<1..1000>', '--rows', '<1..1000>'], + stdin: 'newline-delimited JSON', + stdout: 'newline-delimited JSON', + shell: false, + pty: false, + takeover: false, + }, + inputVariants: [ + { type: { const: 'terminal.input' }, text: { type: 'string' }, exclusiveWith: 'bytes' }, + { type: { const: 'terminal.input' }, bytes: { type: 'string', encoding: 'base64' }, exclusiveWith: 'text' }, + ], + resize: { type: { const: 'terminal.resize' }, cols: { type: 'integer', minimum: 1 }, rows: { type: 'integer', minimum: 1 } }, + scroll: { type: { const: 'terminal.scroll' }, direction: { enum: ['up', 'down'] }, lines: { type: 'integer', minimum: 1 } }, + release: { type: { const: 'terminal.release' }, effect: 'controller exits and no-takeover reacquisition succeeds' }, + frame: { + required: ['type', 'seq', 'encoding', 'width', 'height', 'full', 'bytes'], + properties: { + type: { const: 'terminal.frame' }, + seq: { type: 'integer', ordering: 'strictly contiguous within one controller' }, + encoding: { const: 'ansi' }, + width: { type: 'integer', minimum: 1 }, + height: { type: 'integer', minimum: 1 }, + full: { type: 'boolean' }, + bytes: { type: 'string', encoding: 'base64' }, + }, + }, + closed: { required: ['type', 'reason'], properties: { type: { const: 'terminal.closed' }, reason: { type: 'string' } } }, + busy: { record: 'terminal.closed', reasonContains: 'already has an attached client', processExitCodeAuthoritative: false }, + sourceSelection: { namedSessionRequired: true, missingSnapshotFails: true, missingStatusNotRunning: true, missingControlFails: true, postControlSocketAbsent: true }, +}); + +function sha256(value) { return createHash('sha256').update(value).digest('hex'); } +function canonicalJson(value) { + if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`; + if (value && typeof value === 'object') return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(',')}}`; + return JSON.stringify(value); +} +function semanticFingerprint(value) { return sha256(canonicalJson(value)); } +function schemaDefinitionRefs(value) { + const refs = []; + for (const match of JSON.stringify(value).matchAll(/#\/schemas\/([^/]+)\/\$defs\/([^"]+)/g)) { + refs.push({ schemaName: match[1], definitionName: match[2] }); + } + return refs; +} +function collectSchemaDefinitions(schemas, retainedValues) { + const selected = {}; + const pending = retainedValues.flatMap(schemaDefinitionRefs); + while (pending.length) { + const { schemaName, definitionName } = pending.shift(); + selected[schemaName] ??= {}; + if (selected[schemaName][definitionName]) continue; + const definition = schemas[schemaName]?.$defs?.[definitionName]; + if (!definition) throw new Error(`unresolved schema definition ${schemaName}.${definitionName}`); + selected[schemaName][definitionName] = definition; + pending.push(...schemaDefinitionRefs(definition)); + } + return Object.fromEntries(Object.entries(selected) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([schemaName, definitions]) => [ + schemaName, + Object.fromEntries(Object.entries(definitions).sort(([left], [right]) => left.localeCompare(right))), + ])); +} +function assertSemanticReferenceClosure(manifest) { + for (const { schemaName, definitionName } of schemaDefinitionRefs(manifest)) { + if (!manifest.api.definitions[schemaName]?.[definitionName]) { + throw new Error(`semantic manifest has unresolved reference ${schemaName}.${definitionName}`); + } + } +} +function extractApiSemantics(schema) { + const requests = REQUIRED_METHODS.map(method => { + const request = schema.schemas.request.oneOf.find(item => item.properties?.method?.const === method); + if (!request) throw new Error(`required method ${method} is absent`); + return { + method, + required: [...(request.required ?? [])].sort(), + params: request.properties?.params ?? null, + }; + }); + const successSchema = schema.schemas.success_response; + const errorSchema = schema.schemas.error_response; + const resultVariants = Object.values(METHOD_RESULT_TAGS).map(tag => { + const variant = successSchema.$defs.ResponseResult.oneOf.find(item => item.properties?.type?.const === tag); + if (!variant) throw new Error(`required response result ${tag} is absent`); + return variant; + }); + const successEnvelope = structuredClone(Object.fromEntries(Object.entries(successSchema).filter(([key]) => key !== '$defs'))); + successEnvelope.properties.result = { oneOf: resultVariants }; + const errorEnvelope = Object.fromEntries(Object.entries(errorSchema).filter(([key]) => key !== '$defs')); + const eventEnvelope = Object.fromEntries(Object.entries(schema.schemas.event).filter(([key]) => key !== '$defs')); + const subscriptionEventEnvelope = Object.fromEntries(Object.entries(schema.schemas.subscription_event).filter(([key]) => key !== '$defs')); + const retainedValues = [...requests.map(request => request.params), ...resultVariants, successEnvelope, errorEnvelope, eventEnvelope, subscriptionEventEnvelope]; + return { + requests, + methodResultTags: METHOD_RESULT_TAGS, + successEnvelope, + selectedResultVariants: resultVariants, + errorEnvelope, + eventEnvelope, + subscriptionEventEnvelope, + definitions: collectSchemaDefinitions(schema.schemas, retainedValues), + }; +} +function parseArgs(argv) { + const options = { binary: '', expectedSha256: '', expectedAbsentPath: '', output: '', restrictedOutput: '' }; + for (let i = 0; i < argv.length; i += 1) { + const key = argv[i]; + if (key === '--binary') options.binary = argv[++i] ?? ''; + else if (key === '--expected-sha256') options.expectedSha256 = argv[++i] ?? ''; + else if (key === '--expected-absent-path') options.expectedAbsentPath = argv[++i] ?? ''; + else if (key === '--output') options.output = argv[++i] ?? ''; + else if (key === '--restricted-output') options.restrictedOutput = argv[++i] ?? ''; + else throw new Error(`Unknown argument: ${key}`); + } + if (!isAbsolute(options.binary) || !isAbsolute(options.expectedAbsentPath) || !isAbsolute(options.output) || !isAbsolute(options.restrictedOutput)) throw new Error('--binary, --expected-absent-path, --output, and --restricted-output must be absolute'); + if (options.output === options.restrictedOutput) throw new Error('public and restricted output paths must differ'); + if (!/^[0-9a-f]{64}$/i.test(options.expectedSha256)) throw new Error('--expected-sha256 must be a SHA-256 hex digest'); + if (options.expectedSha256.toLowerCase() !== OFFICIAL_SHA256) throw new Error('expected digest must equal the pinned official v0.7.5 Linux x86_64 digest'); + return options; +} +function boundedAppend(current, chunk, label, limit = MAX_CAPTURE_BYTES) { + const next = Buffer.concat([current, Buffer.from(chunk)]); + if (next.length > limit) throw new Error(`${label} exceeded ${limit} bytes`); + return next; +} +async function exists(path) { + try { + await access(path); + return true; + } catch (error) { + if (error?.code === 'ENOENT') return false; + throw error; + } +} +async function regularOwnedExecutable(path, uid) { + const info = await lstat(path); + return info.isFile() && !info.isSymbolicLink() && info.uid === uid && (info.mode & 0o777) === 0o755; +} +function safeError(error) { return error instanceof Error ? error.message.replaceAll(homedir(), '') : String(error); } +function redact(value, sensitive) { + if (typeof value === 'string') return sensitive.reduce((text, item) => item ? text.replaceAll(item, '') : text, value); + if (Array.isArray(value)) return value.map(item => redact(item, sensitive)); + if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, redact(item, sensitive)])); + return value; +} +function createTracker(runRoot, baseEnv, cancellation) { + const children = new Set(); + return { + spawn(binary, args, options = {}) { + const { allowAfterCancel = false, ...spawnOptions } = options; + if (cancellation.signal && allowAfterCancel) cancellation.cleanupSpawnsAfterSignal += 1; + if (cancellation.signal && !allowAfterCancel) { + cancellation.blockedProductSpawnAttempts += 1; + const error = new Error(`Phase 0 cancelled by ${cancellation.signal}`); + error.code = 'PHASE0_CANCELLED'; + throw error; + } + const child = spawn(binary, args, { + cwd: runRoot, + ...spawnOptions, + env: { ...process.env, NO_COLOR: '1', ...baseEnv, ...spawnOptions.env }, + }); + children.add(child); + child.once('close', () => children.delete(child)); + child.once('error', () => children.delete(child)); + return child; + }, + async closeAll() { + const active = [...children].filter(child => child.exitCode === null); + for (const child of active) child.kill('SIGTERM'); + await Promise.all(active.map(async child => { + try { + await waitForExit(child, 1_000, 'tracked child'); + } catch { + if (child.exitCode === null) child.kill('SIGKILL'); + await waitForExit(child, 1_000, 'tracked child SIGKILL'); + } + })); + await new Promise(resolve => setImmediate(resolve)); + }, + activeChildCount() { return [...children].filter(child => child.exitCode === null).length; }, + }; +} +function run(tracker, binary, args, { timeoutMs = COMMAND_TIMEOUT_MS, allowFailure = false, allowAfterCancel = false, env = {} } = {}) { + return new Promise((resolve, reject) => { + let child; + try { + child = tracker.spawn(binary, args, { allowAfterCancel, env, stdio: ['ignore', 'pipe', 'pipe'] }); + } catch (error) { + reject(error); + return; + } + let stdout = Buffer.alloc(0); let stderr = Buffer.alloc(0); let settled = false; + const fail = error => { if (!settled) { settled = true; child.kill('SIGTERM'); clearTimeout(timer); reject(error); } }; + const timer = setTimeout(() => fail(new Error(`${basename(binary)} ${args.join(' ')} timed out`)), timeoutMs); + child.stdout.on('data', chunk => { try { stdout = boundedAppend(stdout, chunk, 'stdout'); } catch (error) { fail(error); } }); + child.stderr.on('data', chunk => { try { stderr = boundedAppend(stderr, chunk, 'stderr'); } catch (error) { fail(error); } }); + child.once('error', fail); + child.once('close', (code, signal) => { + if (settled) return; settled = true; clearTimeout(timer); + const result = { code: code ?? -1, signal, stdout, stderr }; + if (!allowFailure && result.code !== 0) reject(new Error(`${basename(binary)} ${args.join(' ')} failed (${result.code}): ${stderr.toString('utf8').trim()}`)); + else resolve(result); + }); + }); +} +function jsonOutput(result) { return JSON.parse(result.stdout.toString('utf8')); } +function waitUntil(check, timeoutMs, label) { + const deadline = Date.now() + timeoutMs; + return new Promise((resolve, reject) => { + const poll = async () => { + try { + const value = await check(); + if (value) return resolve(value); + } catch (error) { + if (error?.code === 'PHASE0_CANCELLED') return reject(error); + } + if (Date.now() >= deadline) return reject(new Error(`Timed out waiting for ${label}`)); + setTimeout(poll, 50); + }; poll(); + }); +} +async function waitForExit(child, timeoutMs, label) { + if (child.exitCode !== null) return child.exitCode; + return Promise.race([new Promise(resolve => child.once('close', code => resolve(code ?? -1))), new Promise((_, reject) => setTimeout(() => reject(new Error(`Timed out waiting for ${label}`)), timeoutMs))]); +} +function lineReader(stream, label) { + let pending = Buffer.alloc(0); const values = []; let failure = null; let encodedBytes = 0; let receivedBytes = 0; + stream.on('data', chunk => { + if (failure) return; + try { + receivedBytes += Buffer.byteLength(chunk); + if (receivedBytes > MAX_CAPTURE_BYTES) throw new Error(`${label} exceeded cumulative NDJSON byte limit`); + pending = Buffer.concat([pending, Buffer.from(chunk)]); + let newline; + while ((newline = pending.indexOf(0x0a)) >= 0) { + const line = pending.subarray(0, newline); pending = pending.subarray(newline + 1); + if (!line.length) continue; + encodedBytes += line.length; + if (line.length > MAX_NDJSON_LINE_BYTES || encodedBytes > MAX_CAPTURE_BYTES || values.length >= MAX_NDJSON_RECORDS) throw new Error(`${label} exceeded NDJSON limits`); + values.push(JSON.parse(line.toString('utf8'))); + } + if (pending.length > MAX_NDJSON_LINE_BYTES) throw new Error(`${label} line exceeds ${MAX_NDJSON_LINE_BYTES} bytes`); + } catch (error) { failure = error instanceof Error ? error : new Error(String(error)); } + }); + return { values, get failure() { return failure; } }; +} +function validateFrames(frames) { + let decodedTotal = 0; const decoded = []; + for (const frame of frames) { + if (!Number.isInteger(frame.seq)) throw new Error('terminal.frame seq must be an integer'); + if (frame.type !== 'terminal.frame' || frame.encoding !== 'ansi' || !Number.isInteger(frame.width) || frame.width < 1 || !Number.isInteger(frame.height) || frame.height < 1 || typeof frame.full !== 'boolean') throw new Error('terminal.frame shape mismatch'); + if (typeof frame.bytes !== 'string' || Buffer.byteLength(frame.bytes, 'ascii') > MAX_ENCODED_FRAME_BYTES || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(frame.bytes)) throw new Error('terminal.frame bytes is invalid or exceeds encoded limit'); + const value = Buffer.from(frame.bytes, 'base64'); + if (value.length > MAX_DECODED_FRAME_BYTES || (decodedTotal += value.length) > MAX_TOTAL_DECODED_FRAME_BYTES) throw new Error('terminal.frame decoded bytes exceed limit'); + decoded.push(value); + } + if (!frames.length) throw new Error('control emitted no terminal.frame'); + const contiguous = frames.every((frame, index) => index === 0 || frame.seq === frames[index - 1].seq + 1); + if (!contiguous) throw new Error('terminal.frame sequence is not contiguous'); + const text = Buffer.concat(decoded).toString('utf8'); + return { + frameObserved: true, + sequence: { firstIsOne: frames[0].seq === 1, contiguous, lastAtLeastFirst: frames.at(-1).seq >= frames[0].seq }, + shapeValidated: true, + validBase64BeforeAllocation: true, + encodedWithinLimit: true, + decodedWithinLimit: true, + cumulativeDecodedWithinLimit: decodedTotal <= MAX_TOTAL_DECODED_FRAME_BYTES, + ansiEvidence: text.includes('\x1b['), + utf8Evidence: ['한', '글', '🐑'].every(value => text.includes(value)), + }; +} +async function startServer(tracker, binary, session) { + const child = tracker.spawn(binary, ['--session', session, 'server'], { stdio: ['ignore', 'pipe', 'pipe'] }); + let stderr = Buffer.alloc(0); child.stderr.on('data', chunk => { try { stderr = boundedAppend(stderr, chunk, 'server stderr'); } catch { child.kill('SIGTERM'); } }); + try { + await waitUntil(async () => child.exitCode === null && (await run(tracker, binary, ['--session', session, 'status', 'server'], { allowFailure: true })).stdout.toString('utf8').includes('status: running'), SERVER_START_TIMEOUT_MS, 'Herdr server startup'); + } catch (error) { + throw new Error(`${safeError(error)}; serverExit=${child.exitCode}; stderr=${stderr.toString('utf8').trim()}`); + } + return child; +} +async function stopServer(tracker, binary, session, child, allowAfterCancel = false) { await run(tracker, binary, ['--session', session, 'server', 'stop'], { allowFailure: true, allowAfterCancel }); if (child) await waitForExit(child, 5_000, 'server exit'); } +function controlArgs(session, paneId) { return ['--session', session, 'terminal', 'session', 'control', paneId, '--cols', '80', '--rows', '24']; } +async function acquireControl(tracker, binary, session, paneId, label) { + const child = tracker.spawn(binary, controlArgs(session, paneId), { stdio: ['pipe', 'pipe', 'pipe'] }); + const lines = lineReader(child.stdout, label); let stderr = Buffer.alloc(0); + child.stderr.on('data', chunk => { try { stderr = boundedAppend(stderr, chunk, `${label} stderr`); } catch { child.kill('SIGTERM'); } }); + await waitUntil(() => lines.values.some(value => value.type === 'terminal.frame') || lines.failure, 5_000, `${label} initial frame`); + if (lines.failure) throw lines.failure; + return { child, lines, stderr: () => stderr }; +} +async function releaseControl(control, label) { control.child.stdin.write(`${JSON.stringify({ type: 'terminal.release' })}\n`); control.child.stdin.end(); return waitForExit(control.child, 5_000, label); } +async function controlEvidence(tracker, binary, session, paneId, marker) { + const first = await acquireControl(tracker, binary, session, paneId, 'control'); + const second = tracker.spawn(binary, controlArgs(session, paneId), { stdio: ['pipe', 'pipe', 'pipe'] }); const secondLines = lineReader(second.stdout, 'second control'); + const secondCode = await waitForExit(second, 5_000, 'second controller rejection'); + first.child.stdin.write(`${JSON.stringify({ type: 'terminal.resize', cols: 100, rows: 30 })}\n`); + await waitUntil(() => first.lines.values.some(frame => frame.type === 'terminal.frame' && frame.width === 100 && frame.height === 30), 5_000, 'controller resize frame'); + first.child.stdin.write(`${JSON.stringify({ type: 'terminal.scroll', direction: 'down', lines: 1, source: 'wheel', column: 0, row: 0, modifiers: 0 })}\n`); + const encodedCommand = Buffer.from(`printf '${marker}\\n'`).toString('base64'); + first.child.stdin.write(`${JSON.stringify({ type: 'terminal.input', text: `printf '${encodedCommand}' | base64 -d | sh` })}\n`); + first.child.stdin.write(`${JSON.stringify({ type: 'terminal.input', bytes: Buffer.from('\r').toString('base64') })}\n`); + await waitUntil(async () => { + const pane = await run(tracker, binary, ['--session', session, 'pane', 'read', paneId, '--source', 'visible', '--ansi']); + return pane.stdout.toString('utf8').includes(marker); + }, 5_000, 'exact input pane marker'); + const firstCode = await releaseControl(first, 'controller release'); + if (first.lines.failure || secondLines.failure) throw first.lines.failure ?? secondLines.failure; + const reacquired = await acquireControl(tracker, binary, session, paneId, 'post-release control'); + const reacquireCode = await releaseControl(reacquired, 'post-release controller release'); + if (reacquired.lines.failure) throw reacquired.lines.failure; + const pane = await run(tracker, binary, ['--session', session, 'pane', 'read', paneId, '--source', 'visible', '--ansi']); + const frames = first.lines.values.filter(value => value.type === 'terminal.frame'); const frameFacts = validateFrames(frames); + const reacquisitionFrames = validateFrames(reacquired.lines.values.filter(value => value.type === 'terminal.frame')); + const paneText = pane.stdout.toString('utf8'); + const paneMarkerOccurrences = paneText.split(marker).length - 1; + return { firstCode, secondCode, releaseObserved: firstCode === 0, secondControllerRejected: secondLines.values.some(value => value.type === 'terminal.closed' && String(value.reason).includes('already has an attached client')), noTakeoverReacquisition: reacquireCode === 0, takeoverUsed: false, ...frameFacts, exactInputOccurrences: paneMarkerOccurrences, reacquisitionFrames, resizeFrameObserved: frames.some(frame => frame.width === 100 && frame.height === 30), paneMarkerOccurrences, stderrEmpty: first.stderr().length === 0 }; +} +async function abruptEofRecovery(tracker, binary, session, paneId) { + const first = await acquireControl(tracker, binary, session, paneId, 'abrupt EOF control'); + first.child.stdin.end(); const eofCode = await waitForExit(first.child, 5_000, 'abrupt EOF controller exit'); + if (first.lines.failure) throw first.lines.failure; + const eofFrames = validateFrames(first.lines.values.filter(value => value.type === 'terminal.frame')); + const recovered = await acquireControl(tracker, binary, session, paneId, 'abrupt EOF recovery control'); + const recoveryCode = await releaseControl(recovered, 'abrupt EOF recovery release'); + if (recovered.lines.failure) throw recovered.lines.failure; + const recoveryFrames = validateFrames(recovered.lines.values.filter(value => value.type === 'terminal.frame')); + return { eofExitObserved: eofCode !== null, recoveredWithoutTakeover: recoveryCode === 0, eofFrames, recoveryFrames }; +} +async function invalidControlEvidence(tracker, binary, session, paneId) { + const control = await acquireControl(tracker, binary, session, paneId, 'invalid control'); + const initialFrame = control.lines.values.find(value => value.type === 'terminal.frame'); + control.child.stdin.write('{not-json}\n'); + control.child.stdin.write(`${JSON.stringify({ type: 'terminal.input', text: 'ignored', bytes: 'aWdub3JlZA==' })}\n`); + control.child.stdin.write(`${JSON.stringify({ type: 'terminal.input', bytes: '***' })}\n`); + control.child.stdin.write(`${JSON.stringify({ type: 'terminal.resize', cols: 0, rows: 24 })}\n`); + await waitUntil(() => control.stderr().toString('utf8').split('\n').filter(Boolean).length >= 4, 5_000, 'invalid-control diagnostics'); + const releaseCode = await releaseControl(control, 'invalid control release'); + if (control.lines.failure) throw control.lines.failure; + const recovered = await acquireControl(tracker, binary, session, paneId, 'invalid control recovery'); + const recoveryCode = await releaseControl(recovered, 'invalid control recovery release'); + if (recovered.lines.failure) throw recovered.lines.failure; + const diagnostics = control.stderr().toString('utf8'); + return { + malformedJsonRejected: diagnostics.toLowerCase().includes('invalid json command'), + conflictingInputRejected: diagnostics.includes('text or bytes'), + invalidBase64Rejected: diagnostics.includes('invalid terminal.input bytes'), + zeroResizeRejected: diagnostics.includes('greater than zero') || diagnostics.includes('resize'), + dimensionsUnchanged: control.lines.values.filter(value => value.type === 'terminal.frame').every(value => value.width === initialFrame.width && value.height === initialFrame.height), + releaseExitZero: releaseCode === 0, + recoveredWithoutTakeover: recoveryCode === 0, + }; +} + +function frameGuardEvidence() { + const valid = { type: 'terminal.frame', seq: 1, encoding: 'ansi', width: 80, height: 24, full: true, bytes: '' }; + const rejects = frames => { + try { validateFrames(frames); return false; } catch { return true; } + }; + return { + missingSequenceRejected: rejects([{ ...valid, seq: undefined }]), + sequenceGapRejected: rejects([valid, { ...valid, seq: 3 }]), + invalidBase64Rejected: rejects([{ ...valid, bytes: '***' }]), + encodedOversizeRejected: rejects([{ ...valid, bytes: 'A'.repeat(MAX_ENCODED_FRAME_BYTES + 1) }]), + decodedOversizeRejected: rejects([{ ...valid, bytes: Buffer.alloc(MAX_DECODED_FRAME_BYTES + 1).toString('base64') }]), + }; +} +async function missingSelectorEvidence(tracker, binary, missingSession, paneId) { + const snapshot = await run(tracker, binary, ['--session', missingSession, 'api', 'snapshot'], { allowFailure: true }); + const status = await run(tracker, binary, ['--session', missingSession, 'status', 'server'], { allowFailure: true }); + const control = await run(tracker, binary, ['--session', missingSession, 'terminal', 'session', 'control', paneId], { allowFailure: true, timeoutMs: 3_000 }); + const postStatus = await run(tracker, binary, ['--session', missingSession, 'status', 'server'], { allowFailure: true }); + const sessions = jsonOutput(await run(tracker, binary, ['session', 'list', '--json'])); + const entry = sessions.sessions.find(item => item.name === missingSession); + const statusText = `${status.stdout.toString('utf8')}\n${status.stderr.toString('utf8')}`.toLowerCase(); + const postStatusText = `${postStatus.stdout.toString('utf8')}\n${postStatus.stderr.toString('utf8')}`.toLowerCase(); + const socketAbsentAfterControl = !entry?.socket_path || !(await exists(entry.socket_path)); + const noAutoStart = snapshot.code !== 0 + && statusText.includes('not running') + && control.code !== 0 + && postStatusText.includes('not running') + && entry?.running === false + && socketAbsentAfterControl; + return { + missingSnapshotExitNonzero: snapshot.code !== 0, + missingStatusReportsNotRunning: statusText.includes('not running'), + missingControlExitNonzero: control.code !== 0, + postControlStatusReportsNotRunning: postStatusText.includes('not running'), + postControlSessionNotRunning: entry?.running === false, + socketAbsentAfterControl, + noAutoStart, + }; +} + +async function main() { + const options = parseArgs(process.argv.slice(2)); + if (platform() !== 'linux' || arch() !== 'x64' || process.getuid() === 0) throw new Error('Phase 0 requires non-root Linux x64'); + const sourceInput = await lstat(options.binary); + if (sourceInput.isSymbolicLink()) throw new Error('source asset must not be a symbolic link'); + const source = await realpath(options.binary); const sourceStat = await stat(source); + if (!sourceStat.isFile() || sourceStat.uid !== process.getuid() || (sourceStat.mode & 0o022) !== 0) throw new Error('source asset must be a user-owned regular file that is not group/world-writable'); + if (await exists(options.expectedAbsentPath)) throw new Error('prior user-local Herdr installation is still present'); + const sourceSha256 = sha256(await readFile(source)); + if (sourceSha256 !== options.expectedSha256.toLowerCase()) throw new Error('Herdr binary digest mismatch'); + const runRoot = await mkdtemp(join(tmpdir(), 'cmh0-')); await chmod(runRoot, 0o700); const runRootIdentity = await stat(runRoot); + const installRoot = join(runRoot, 'install'); + const workspaceRoot = join(runRoot, 'workspace'); + const homeRoot = join(runRoot, 'home'); + const xdgConfigHome = join(runRoot, 'c'); + const xdgStateHome = join(runRoot, 's'); + const xdgCacheHome = join(runRoot, 'k'); + const xdgDataHome = join(runRoot, 'd'); + const configRoot = join(xdgConfigHome, 'herdr'); + const configPath = join(configRoot, 'config.toml'); + const copiedBinary = join(installRoot, 'herdr'); + const ownedRoots = [installRoot, workspaceRoot, homeRoot, xdgConfigHome, xdgStateHome, xdgCacheHome, xdgDataHome]; + const nonce = randomBytes(6).toString('hex'); const session = `chatmux-h0-${nonce}`; const missingSession = `chatmux-h0-missing-${nonce}`; + const manifest = { runRoot, ownedPaths: [...ownedRoots, configRoot, configPath, copiedBinary] }; + for (const root of ownedRoots) await mkdir(root, { mode: 0o700 }); + await mkdir(configRoot, { mode: 0o700 }); + await cp(source, copiedBinary, { force: false }); await chmod(copiedBinary, 0o755); + if (!(await regularOwnedExecutable(copiedBinary, process.getuid())) || sha256(await readFile(copiedBinary)) !== sourceSha256) throw new Error('verified executable copy is not user-owned mode-0755 and digest-identical'); + const isolatedEnv = { HOME: homeRoot, XDG_CONFIG_HOME: xdgConfigHome, XDG_STATE_HOME: xdgStateHome, XDG_CACHE_HOME: xdgCacheHome, XDG_DATA_HOME: xdgDataHome, HERDR_CONFIG_PATH: configPath }; + const cancellation = { signal: null, blockedProductSpawnAttempts: 0, cleanupSpawnsAfterSignal: 0, productSpawnsAfterSignal: 0 }; + const tracker = createTracker(runRoot, isolatedEnv, cancellation); + const secretMarker = `PHASE0_SECRET_${randomBytes(16).toString('hex')}`; const pathMarker = `${runRoot}/${secretMarker}`; + const sourceIdentity = { dev: sourceStat.dev, ino: sourceStat.ino, uid: sourceStat.uid, mode: sourceStat.mode & 0o777, size: sourceStat.size, mtimeMs: sourceStat.mtimeMs, sha256: sourceSha256 }; + const restricted = { + schemaVersion: 1, + runId: nonce, + generatedAt: new Date().toISOString(), + source, + expectedAbsentPath: options.expectedAbsentPath, + runRoot, + isolatedEnv, + plannedPaths: manifest.ownedPaths, + sessions: { disposable: session, missingProbe: missingSession }, + preflight: { runRootIdentity: { dev: runRootIdentity.dev, ino: runRootIdentity.ino, uid: runRootIdentity.uid }, sourceIdentity, priorUserLocalInstallAbsent: true, isolatedSessionList: null }, + runtime: { serverPid: null, sessionEntry: null, socketIdentity: null, paneIds: [] }, + cleanup: {}, + }; + let server = null; let signalReceived = null; + const onSignal = signal => { + signalReceived = signal; + cancellation.signal = signal; + tracker.closeAll().catch(() => {}); + }; + process.once('SIGINT', onSignal); process.once('SIGTERM', onSignal); + const report = { schemaVersion: 3, generatedAt: new Date().toISOString(), cohort: { status: 'live-run', semanticManifestVersion: 3, semanticFingerprint: null }, platform: { os: 'linux', arch: 'x64', nonRoot: true }, binary: { name: 'herdr', sha256: sourceSha256, ownerUidMatches: true, mode: '0755', copiedVerifiedExecutable: true, priorUserLocalInstallAbsent: true, sourceUnchangedAfterRun: false }, provenance: { officialVersion: '0.7.5', releaseTag: 'v0.7.5', releaseUrl: 'https://github.com/herdrdev/herdr/releases/tag/v0.7.5', assetUrl: 'https://github.com/herdrdev/herdr/releases/download/v0.7.5/herdr-linux-x86_64', redirectChainHosts: ['github.com', 'release-assets.githubusercontent.com'], finalHost: 'release-assets.githubusercontent.com', publishedDigestAlgorithm: 'sha256', publishedAt: '2026-07-21T18:11:20Z', evidenceCheckedAt: '2026-07-30', publisher: 'herdrdev/herdr', artifact: 'herdr-linux-x86_64' }, scope: { disposableNamedSession: true, isolatedRunRoot: true, isolatedConfigStateCacheDataHome: true, manifestScoped: true, productSourceMutated: false, shellUsed: false, ptyUsed: false, takeoverAllowed: false, defaultSessionUsed: false }, selectedTransport: 'none', semanticManifest: null, observed: {}, inferred: {}, unsupported: {}, failClosedLimitations: {}, cleanup: { sessionStopped: false, sessionDeleted: false, missingSessionDeleted: false, sessionAbsent: false, socketAbsent: false, childrenClosed: false, runRootRemoved: false, signalFinalizerInstalled: true, postSignalProductSpawns: 0, postStateClean: false }, sanitization: {}, go: false, failures: [] }; + try { + restricted.preflight.isolatedSessionList = jsonOutput(await run(tracker, copiedBinary, ['session', 'list', '--json'])); + report.observed.version = (await run(tracker, copiedBinary, ['--version'])).stdout.toString('utf8').trim(); + if (report.observed.version !== 'herdr 0.7.5') throw new Error('installed binary version does not exactly match Herdr 0.7.5'); + const schemaResult = await run(tracker, copiedBinary, ['api', 'schema', '--json']); const schema = jsonOutput(schemaResult); + const methods = schema.schemas.request.oneOf.map(item => item.properties?.method?.const).filter(Boolean).sort(); + report.semanticManifest = { + version: 3, + protocol: schema.protocol, + schemaVersion: schema.schema_version, + api: extractApiSemantics(schema), + control: CONTROL_SEMANTICS, + }; + assertSemanticReferenceClosure(report.semanticManifest); + report.cohort.semanticFingerprint = semanticFingerprint(report.semanticManifest); + report.observed.schema = { protocol: schema.protocol, schemaVersion: schema.schema_version, canonicalSha256: sha256(canonicalJson(schema)), byteLength: schemaResult.stdout.length, requiredMethodsPresent: REQUIRED_METHODS.every(method => methods.includes(method)), rawTerminalControlPublished: methods.some(method => method.startsWith('terminal.')), semanticReferenceClosure: true }; + if (schema.protocol !== EXPECTED_PROTOCOL || !report.observed.schema.requiredMethodsPresent || report.observed.schema.rawTerminalControlPublished) throw new Error('schema protocol or published terminal-control boundary mismatch'); + report.selectedTransport = 'herdr terminal session control'; + report.unsupported = { localAgentReplacement: 'not exposed by verified public CLI and therefore not authorized for writable control', liveHandoff: 'not safely triggerable by verified public CLI', slowConsumer: 'upstream backpressure cannot be deterministically injected; the product bridge must release on its own queue limit', sequenceGapInjection: 'not safely injectable upstream; the parser guard rejects any observed gap' }; + report.failClosedLimitations = Object.fromEntries(Object.keys(report.unsupported).map(key => [key, 'product integration must reject writable control when this condition cannot be observed and freshly revalidated'])); + server = await startServer(tracker, copiedBinary, session); + restricted.runtime.serverPid = server.pid; + const runningSessions = jsonOutput(await run(tracker, copiedBinary, ['session', 'list', '--json'])); + const runningEntry = runningSessions.sessions.find(item => item.name === session); + if (!runningEntry?.running || !runningEntry.session_dir.startsWith(`${runRoot}/`) || !runningEntry.socket_path.startsWith(`${runRoot}/`)) throw new Error('Herdr session state escaped the isolated run root'); + const socketInfo = await stat(runningEntry.socket_path); + if (!socketInfo.isSocket() || socketInfo.uid !== process.getuid()) throw new Error('isolated Herdr socket identity mismatch'); + restricted.runtime.sessionEntry = runningEntry; + restricted.runtime.socketIdentity = { dev: socketInfo.dev, ino: socketInfo.ino, uid: socketInfo.uid, mode: socketInfo.mode }; + const initial = jsonOutput(await run(tracker, copiedBinary, ['--session', session, 'api', 'snapshot'])).result.snapshot; + report.observed.initialSnapshotEmpty = initial.panes.length === 0; + const workspace = jsonOutput(await run(tracker, copiedBinary, ['--session', session, 'workspace', 'create', '--cwd', workspaceRoot, '--label', 'chatmux-phase0', '--no-focus'])).result; + const paneId = workspace.root_pane.pane_id; + restricted.runtime.paneIds.push(paneId); + report.observed.noAutoStart = await missingSelectorEvidence(tracker, copiedBinary, missingSession, paneId); + const controlMarker = 'CHATMUX_HERDR_CONTROL_OK_한글🐑'; + await run(tracker, copiedBinary, ['--session', session, 'pane', 'run', paneId, `printf '\\033[31m${secretMarker} 한글🐑\\033[0m\\n'`]); + await waitUntil(async () => { + const pane = await run(tracker, copiedBinary, ['--session', session, 'pane', 'read', paneId, '--source', 'visible', '--ansi']); + return pane.stdout.toString('utf8').includes(secretMarker); + }, 5_000, 'seeded ANSI UTF-8 output'); + report.observed.control = await controlEvidence(tracker, copiedBinary, session, paneId, controlMarker); + report.observed.abruptEofRecovery = await abruptEofRecovery(tracker, copiedBinary, session, paneId); + report.observed.invalidControl = await invalidControlEvidence(tracker, copiedBinary, session, paneId); + report.observed.frameGuards = frameGuardEvidence(); + const beforeMove = jsonOutput(await run(tracker, copiedBinary, ['--session', session, 'pane', 'get', paneId])).result.pane; + const move = jsonOutput(await run(tracker, copiedBinary, ['--session', session, 'pane', 'move', paneId, '--new-workspace', '--label', 'phase0-moved', '--tab-label', 'moved', '--focus'])).result.move_result; + const afterMove = jsonOutput(await run(tracker, copiedBinary, ['--session', session, 'pane', 'get', move.previous_pane_id])).result.pane; + report.observed.moveIdentity = { publicPaneChanged: beforeMove.pane_id !== afterMove.pane_id, oldPaneAliasResolvesCurrent: afterMove.pane_id === move.pane.pane_id, terminalStable: beforeMove.terminal_id === afterMove.terminal_id, hierarchyChanged: beforeMove.workspace_id !== afterMove.workspace_id }; + const firstSplit = jsonOutput(await run(tracker, copiedBinary, ['--session', session, 'pane', 'split', move.pane.pane_id, '--direction', 'right', '--cwd', workspaceRoot, '--no-focus'])).result.pane; + restricted.runtime.paneIds.push(firstSplit.pane_id); + const firstSplitGet = jsonOutput(await run(tracker, copiedBinary, ['--session', session, 'pane', 'get', firstSplit.pane_id])).result.pane; + await run(tracker, copiedBinary, ['--session', session, 'pane', 'close', firstSplit.pane_id]); + const closedSplit = await run(tracker, copiedBinary, ['--session', session, 'pane', 'get', firstSplit.pane_id], { allowFailure: true }); + const secondSplit = jsonOutput(await run(tracker, copiedBinary, ['--session', session, 'pane', 'split', move.pane.pane_id, '--direction', 'down', '--cwd', workspaceRoot, '--no-focus'])).result.pane; + restricted.runtime.paneIds.push(secondSplit.pane_id); + report.observed.splitCloseRecreateIdentity = { + firstSplitResolved: firstSplitGet.pane_id === firstSplit.pane_id, + closedPaneInvalidated: closedSplit.code !== 0, + recreatedPaneChanged: secondSplit.pane_id !== firstSplit.pane_id, + recreatedTerminalChanged: secondSplit.terminal_id !== firstSplit.terminal_id, + originalTerminalStable: (jsonOutput(await run(tracker, copiedBinary, ['--session', session, 'pane', 'get', move.pane.pane_id])).result.pane).terminal_id === afterMove.terminal_id, + }; + const beforeProcess = jsonOutput(await run(tracker, copiedBinary, ['--session', session, 'pane', 'process-info', '--pane', move.pane.pane_id])).result.process_info; + await run(tracker, copiedBinary, ['--session', session, 'pane', 'run', move.pane.pane_id, 'sleep 1']); + const activeProcess = await waitUntil(async () => { + const current = jsonOutput(await run(tracker, copiedBinary, ['--session', session, 'pane', 'process-info', '--pane', move.pane.pane_id])).result.process_info; + return current.foreground_process_group_id !== beforeProcess.foreground_process_group_id ? current : null; + }, 1_000, 'foreground process change'); + const restoredProcess = await waitUntil(async () => { + const current = jsonOutput(await run(tracker, copiedBinary, ['--session', session, 'pane', 'process-info', '--pane', move.pane.pane_id])).result.process_info; + return current.foreground_process_group_id === beforeProcess.foreground_process_group_id ? current : null; + }, 2_000, 'foreground process restoration'); + report.observed.processIdentity = { shellPidStable: beforeProcess.shell_pid === restoredProcess.shell_pid, foregroundChanged: beforeProcess.foreground_process_group_id !== activeProcess.foreground_process_group_id, foregroundRestored: beforeProcess.foreground_process_group_id === restoredProcess.foreground_process_group_id, paneRevisionFieldPublished: [beforeProcess.revision, activeProcess.revision, restoredProcess.revision].every(value => Number.isInteger(value)), measuredPaneRevisions: [beforeProcess.revision ?? null, activeProcess.revision ?? null, restoredProcess.revision ?? null] }; + const beforeRestart = jsonOutput(await run(tracker, copiedBinary, ['--session', session, 'pane', 'get', move.pane.pane_id])).result.pane; + await stopServer(tracker, copiedBinary, session, server); server = null; + server = await startServer(tracker, copiedBinary, session); + const afterRestart = jsonOutput(await run(tracker, copiedBinary, ['--session', session, 'pane', 'get', beforeRestart.pane_id])).result.pane; + const priorTerminalObserve = await run(tracker, copiedBinary, ['--session', session, 'terminal', 'session', 'observe', beforeRestart.terminal_id], { allowFailure: true, timeoutMs: 3_000 }); + report.observed.coldRestartIdentity = { + workspaceRestored: afterRestart.workspace_id === beforeRestart.workspace_id, + publicPaneRestored: afterRestart.pane_id === beforeRestart.pane_id, + terminalChanged: afterRestart.terminal_id !== beforeRestart.terminal_id, + priorTerminalInvalidated: priorTerminalObserve.code !== 0 || (priorTerminalObserve.stdout.includes(Buffer.from('"terminal.closed"')) && !priorTerminalObserve.stdout.includes(Buffer.from('"terminal.frame"'))), + }; + await stopServer(tracker, copiedBinary, session, server); server = null; + report.observed.serverStoppedForCleanup = true; + const requirements = [signalReceived === null, report.observed.initialSnapshotEmpty, report.observed.noAutoStart.noAutoStart, Boolean(report.cohort.semanticFingerprint), report.observed.schema.semanticReferenceClosure, report.observed.control.releaseObserved, report.observed.control.secondControllerRejected, report.observed.control.noTakeoverReacquisition, report.observed.control.resizeFrameObserved, report.observed.control.sequence.firstIsOne, report.observed.control.sequence.contiguous, report.observed.control.sequence.lastAtLeastFirst, report.observed.control.exactInputOccurrences === 1, report.observed.control.ansiEvidence, report.observed.control.utf8Evidence, report.observed.control.stderrEmpty, report.observed.abruptEofRecovery.recoveredWithoutTakeover, Object.values(report.observed.invalidControl).every(Boolean), Object.values(report.observed.frameGuards).every(Boolean), Object.values(report.observed.moveIdentity).every(Boolean), Object.values(report.observed.splitCloseRecreateIdentity).every(Boolean), report.observed.processIdentity.foregroundChanged, report.observed.processIdentity.foregroundRestored, Object.values(report.observed.coldRestartIdentity).every(Boolean)]; + report.go = requirements.every(Boolean); + } catch (error) { report.failures.push(safeError(error)); } + finally { + try { if (server) await stopServer(tracker, copiedBinary, session, server, true); report.cleanup.sessionStopped = true; } catch (error) { report.failures.push(`cleanup stop: ${safeError(error)}`); } + try { + const deleted = jsonOutput(await run(tracker, copiedBinary, ['session', 'delete', session, '--json'], { allowFailure: true, allowAfterCancel: true })); + report.cleanup.sessionDeleted = deleted.deleted === true; + const missingDeleted = jsonOutput(await run(tracker, copiedBinary, ['session', 'delete', missingSession, '--json'], { allowFailure: true, allowAfterCancel: true })); + report.cleanup.missingSessionDeleted = missingDeleted.deleted === true; + const sessions = jsonOutput(await run(tracker, copiedBinary, ['session', 'list', '--json'], { allowFailure: true, allowAfterCancel: true })); + report.cleanup.sessionAbsent = !sessions.sessions?.some(item => item.name === session || item.name === missingSession); + report.cleanup.socketAbsent = !restricted.runtime.sessionEntry?.socket_path || !(await exists(restricted.runtime.sessionEntry.socket_path)); + restricted.cleanup.postSessionList = sessions; + restricted.cleanup.sessionDeleted = report.cleanup.sessionDeleted; + restricted.cleanup.missingSessionDeleted = report.cleanup.missingSessionDeleted; + restricted.cleanup.sessionAbsent = report.cleanup.sessionAbsent; + restricted.cleanup.socketAbsent = report.cleanup.socketAbsent; + } catch (error) { report.failures.push(`cleanup delete: ${safeError(error)}`); } + await tracker.closeAll(); report.cleanup.childrenClosed = tracker.activeChildCount() === 0; restricted.cleanup.childrenClosed = report.cleanup.childrenClosed; + try { + const currentSource = await stat(source); + report.binary.sourceUnchangedAfterRun = currentSource.dev === sourceIdentity.dev + && currentSource.ino === sourceIdentity.ino + && currentSource.size === sourceIdentity.size + && currentSource.mtimeMs === sourceIdentity.mtimeMs + && sha256(await readFile(source)) === sourceIdentity.sha256; + const currentRoot = await stat(runRoot); + const runRootUnchanged = currentRoot.dev === runRootIdentity.dev + && currentRoot.ino === runRootIdentity.ino + && currentRoot.uid === process.getuid(); + if (!runRootUnchanged) { + report.failures.push('cleanup run root: refusing to remove changed or unowned run root'); + } else { + await rm(runRoot, { recursive: true, force: false }); + report.cleanup.runRootRemoved = !(await exists(runRoot)); + } + report.cleanup.postStateClean = report.cleanup.runRootRemoved && report.cleanup.childrenClosed && report.cleanup.sessionDeleted && report.cleanup.missingSessionDeleted && report.cleanup.sessionAbsent && report.cleanup.socketAbsent && report.binary.sourceUnchangedAfterRun; + restricted.cleanup.runRootRemoved = report.cleanup.runRootRemoved; + restricted.cleanup.sourceUnchanged = report.binary.sourceUnchangedAfterRun; + restricted.cleanup.signalReceived = signalReceived; + restricted.cleanup.blockedProductSpawnAttempts = cancellation.blockedProductSpawnAttempts; + restricted.cleanup.cleanupSpawnsAfterSignal = cancellation.cleanupSpawnsAfterSignal; + restricted.cleanup.productSpawnsAfterSignal = cancellation.productSpawnsAfterSignal; + report.cleanup.postSignalProductSpawns = cancellation.productSpawnsAfterSignal; + } catch (error) { report.failures.push(`cleanup run root: ${safeError(error)}`); } + try { await writeFile(options.restrictedOutput, `${JSON.stringify(restricted, null, 2)}\n`, { mode: 0o600 }); } catch (error) { report.failures.push(`restricted evidence: ${safeError(error)}`); } + const sensitive = [secretMarker, pathMarker, runRoot, workspaceRoot, installRoot, homeRoot, xdgConfigHome, xdgStateHome, xdgCacheHome, xdgDataHome, configRoot, configPath, source, options.expectedAbsentPath, copiedBinary, session, missingSession, homedir()]; + report.failures = redact(report.failures, sensitive); + const serialized = JSON.stringify(report); + report.sanitization = { secretMarkerExcluded: !serialized.includes(secretMarker), pathMarkerExcluded: !serialized.includes(pathMarker), homePathExcluded: !serialized.includes(homedir()) }; + report.go = report.go && report.cleanup.postStateClean && Object.values(report.sanitization).every(Boolean) && report.failures.length === 0; + await writeFile(options.output, `${JSON.stringify(report, null, 2)}\n`, { mode: 0o600 }); + } + process.removeListener('SIGINT', onSignal); process.removeListener('SIGTERM', onSignal); + if (!report.go) process.exitCode = 1; + console.log(JSON.stringify({ go: report.go, selectedTransport: report.selectedTransport, failures: report.failures })); +} +main().catch(error => { console.error(safeError(error)); process.exitCode = 1; }); diff --git a/server/cli.js b/server/cli.js index 7772f75..e5af1c7 100755 --- a/server/cli.js +++ b/server/cli.js @@ -93,6 +93,67 @@ function getDatabasePath() { function getInstallDir() { return APP_ROOT; } +const HERDR_CAPABILITIES = ['discovery', 'output', 'actions', 'attach']; +const HERDR_ALIAS_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/; +const HERDR_SELECTOR_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/; + +function herdrConfiguredSourceCount(raw) { + if (!raw || raw.length > 16 * 1024) return null; + let sources; + try { + sources = JSON.parse(raw); + } catch { + return null; + } + if (!Array.isArray(sources) || sources.length === 0 || sources.length > 8) return null; + + const aliases = new Set(); + for (const source of sources) { + if (!source || typeof source !== 'object' || Array.isArray(source) + || Object.keys(source).length !== 3 + || !['alias', 'selector', 'binary'].every(key => Object.prototype.hasOwnProperty.call(source, key)) + || typeof source.alias !== 'string' || !HERDR_ALIAS_RE.test(source.alias) || aliases.has(source.alias) + || typeof source.selector !== 'string' || !HERDR_SELECTOR_RE.test(source.selector) || source.selector === 'default' + || typeof source.binary !== 'string' || !path.isAbsolute(source.binary) + || source.binary.includes('\0') || source.binary.length > 4096) { + return null; + } + aliases.add(source.alias); + } + return sources.length; +} + +function herdrConfiguredCapabilities(raw) { + if (!raw) return []; + const capabilities = raw.split(','); + if (capabilities.some(capability => !HERDR_CAPABILITIES.includes(capability)) + || new Set(capabilities).size !== capabilities.length) { + return null; + } + return capabilities; +} + +function describeHerdrStatus() { + const sourceCount = herdrConfiguredSourceCount(process.env.CHATMUX_HERDR_SOURCES); + const capabilities = herdrConfiguredCapabilities(process.env.CHATMUX_HERDR_CAPABILITIES); + const policyPath = process.env.CHATMUX_HERDR_POLICY_FILE; + const policyConfigured = policyPath === undefined + ? false + : path.isAbsolute(policyPath) && !policyPath.includes('\0') && policyPath.length <= 4096; + const enabled = process.env.CHATMUX_HERDR_RUNTIME === '1' + && process.platform === 'linux' + && process.arch === 'x64' + && sourceCount !== null + && capabilities !== null + && (policyPath === undefined || policyConfigured); + + return { + enabled, + sourceCount: sourceCount ?? 0, + capabilities: capabilities ?? [], + policyConfigured, + }; +} // Show status command // Resolves the address a browser should actually open, which depends on the @@ -181,6 +242,13 @@ async function showStatus() { console.log(` CLAUDE_CLI_PATH: ${c.dim(process.env.CLAUDE_CLI_PATH || 'claude (default)')}`); console.log(` CONTEXT_WINDOW: ${c.dim(process.env.CONTEXT_WINDOW || '160000 (default)')}`); + const herdr = describeHerdrStatus(); + console.log(`\n${c.info('[INFO]')} Herdr Runtime:`); + console.log(` Configuration: ${herdr.enabled ? c.ok('enabled') : c.dim('disabled')}`); + console.log(` Configured Sources: ${herdr.sourceCount}`); + console.log(` Capabilities: ${herdr.capabilities.length ? herdr.capabilities.join(', ') : 'none'}`); + console.log(` Policy: ${herdr.policyConfigured ? 'configured' : 'not configured'}`); + console.log(' Runtime Readiness: not probed by this command'); // Claude projects folder const claudeProjectsPath = path.join(os.homedir(), '.claude', 'projects'); const projectsExists = fs.existsSync(claudeProjectsPath); diff --git a/server/herdr-status.contract.test.ts b/server/herdr-status.contract.test.ts new file mode 100644 index 0000000..47e1e12 --- /dev/null +++ b/server/herdr-status.contract.test.ts @@ -0,0 +1,55 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import test from 'node:test'; + +const root = fileURLToPath(new URL('../', import.meta.url)); +const cli = fileURLToPath(new URL('./cli.js', import.meta.url)); +const stripAnsi = (value: string) => value.replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, ''); + +function status(environment: Record): string { + const result = spawnSync(process.execPath, [cli, 'status'], { + cwd: root, + encoding: 'utf8', + env: { + ...process.env, + CHATMUX_AUTH: '', + CHATMUX_ALLOW_UNAUTH_REMOTE: '', + CHATMUX_ENV_FILE: '/nonexistent/chatmux-status-test.env', + ...environment, + }, + }); + assert.equal(result.status, 0, result.stderr); + return stripAnsi(result.stdout); +} + +test('chatmux status redacts Herdr configuration values, including malformed input', () => { + const alias = 'private-source-alias'; + const selector = 'private-selector'; + const binary = '/private/herdr/bin/herdr'; + const policy = '/private/herdr/policy.json'; + const output = status({ + CHATMUX_HERDR_RUNTIME: '1', + CHATMUX_HERDR_SOURCES: JSON.stringify([{ alias, selector, binary }]), + CHATMUX_HERDR_CAPABILITIES: 'discovery,output,actions,attach', + CHATMUX_HERDR_POLICY_FILE: policy, + }); + + assert.match(output, /Herdr Runtime:/); + assert.match(output, /Configuration: enabled/); + assert.match(output, /Configured Sources: 1/); + assert.match(output, /Capabilities: discovery, output, actions, attach/); + assert.match(output, /Policy: configured/); + for (const secret of [alias, selector, binary, policy]) assert.equal(output.includes(secret), false); + assert.match(output, /Runtime Readiness: not probed by this command/); + + const malformed = 'malformed-herdr-source-secret'; + const malformedOutput = status({ + CHATMUX_HERDR_RUNTIME: '1', + CHATMUX_HERDR_SOURCES: `{\"alias\":\"${malformed}\"}`, + CHATMUX_HERDR_CAPABILITIES: 'discovery', + }); + assert.match(malformedOutput, /Configuration: disabled/); + assert.match(malformedOutput, /Configured Sources: 0/); + assert.equal(malformedOutput.includes(malformed), false); +}); diff --git a/server/index.js b/server/index.js index a00a8bf..268bedc 100755 --- a/server/index.js +++ b/server/index.js @@ -20,6 +20,7 @@ import { closeSessionsWatcher, createProviderToolApprovals, createDiscoveryCollector, + createPaneOutputStream, createTmuxOutputActivityMonitor, getCurrentTmuxPaneIdentity, getCurrentTmuxPaneIdentityState, @@ -27,8 +28,17 @@ import { onTranscriptChanged, readTmuxPaneIdentity, runTmux, + createTmuxRuntimeAdapter, } from '@/modules/providers/index.js'; import { createWebSocketServer } from '@/modules/websocket/index.js'; +import { + HerdrAdmissionService, + HerdrControlBridgeService, + HerdrRuntimeAdapter, + RuntimeOperationPolicyService, + RuntimeRegistryService, + readHerdrRuntimeConfig, +} from '@/modules/terminal-runtimes/index.js'; import { getConnectableHost } from '../shared/networkHosts.js'; @@ -145,9 +155,39 @@ const app = express(); app.set('trust proxy', 1); const server = http.createServer(app); +const herdrConfig = readHerdrRuntimeConfig(); +const terminalRuntimeRegistry = new RuntimeRegistryService(); +terminalRuntimeRegistry.register(createTmuxRuntimeAdapter()); +const herdrPolicy = new RuntimeOperationPolicyService( + herdrConfig.startupCapabilities, + herdrConfig.sources.map((source) => source.sourceId), + herdrConfig.policyPath, +); +const herdrAdmissions = new HerdrAdmissionService(); +const herdrAdapter = herdrConfig.enabled + ? new HerdrRuntimeAdapter( + herdrConfig, + herdrPolicy, + undefined, + undefined, + () => randomUUID(), + ) + : null; +if (herdrAdapter) terminalRuntimeRegistry.register(herdrAdapter); +const herdrControl = new HerdrControlBridgeService(terminalRuntimeRegistry, herdrAdmissions); +let herdrRevocation = Promise.resolve(); +herdrPolicy.onReduction(() => { + herdrRevocation = herdrControl.releaseAll(); + discoveryCollector?.forceRefresh?.(); +}); +app.locals.terminalRuntimeRegistry = terminalRuntimeRegistry; +app.locals.herdrAdmissions = herdrAdmissions; +app.locals.herdrConfig = herdrConfig; +app.locals.runtimeOperationPolicy = herdrPolicy; // The collector is inert until the first authenticated discovery subscription. -const discoveryCollector = createDiscoveryCollector(); +const discoveryCollector = createDiscoveryCollector({ runtimeRegistry: terminalRuntimeRegistry }); app.locals.discoveryCollector = discoveryCollector; +const paneOutputStream = createPaneOutputStream({ runtimeRegistry: terminalRuntimeRegistry }); const tmuxOutputActivityMonitor = createTmuxOutputActivityMonitor(discoveryCollector, { onInputRequired: process.env.CHATMUX_LIVE_NOTIFY === '0' ? undefined @@ -221,6 +261,8 @@ const wss = createWebSocketServer(server, { runTmux, }, discovery: discoveryCollector, + panes: paneOutputStream, + herdrControl, }); // Make WebSocket server available to routes @@ -719,6 +761,12 @@ async function removeLocalServerMarker() { // Initialize database and start server async function startServer() { try { + if (herdrConfig.policyPath) { + const policyReload = await herdrPolicy.reload(); + if (policyReload.errorCode) { + console.warn(`[WARN] Herdr policy disabled all Herdr capabilities: ${policyReload.errorCode}`); + } + } // Initialize authentication database await initializeDatabase(); @@ -815,11 +863,14 @@ async function startServer() { // Stop new HTTP/WebSocket work before permanently gating GJC starts. server.close(); + await herdrControl.releaseAll(); for (const client of wss.clients) { client.terminate(); } wss.close(); server.closeAllConnections?.(); + discoveryCollector.dispose(); + terminalRuntimeRegistry.dispose(); try { await completionOutboxDispatcher.stop(); @@ -838,6 +889,14 @@ async function startServer() { } process.exit(0); }; + process.on('SIGHUP', () => { + void (async () => { + const { errorCode } = await herdrPolicy.reload(); + await herdrRevocation; + discoveryCollector.forceRefresh(); + if (errorCode) console.warn(`[WARN] Herdr policy disabled all Herdr capabilities: ${errorCode}`); + })(); + }); process.on('SIGTERM', () => void shutdownRuntimeServices()); process.on('SIGINT', () => void shutdownRuntimeServices()); } catch (error) { diff --git a/server/modules/providers/index.ts b/server/modules/providers/index.ts index 9f319ac..2a5229a 100644 --- a/server/modules/providers/index.ts +++ b/server/modules/providers/index.ts @@ -25,6 +25,10 @@ export { type ExternalSessionActivityResolutionResult, } from './services/external-session-activity.service.js'; export { runTmux } from './services/builtin-relay.service.js'; +export { + createTmuxRuntimeAdapter, + type TmuxRuntimeOperationContext, +} from './services/tmux-runtime-adapter.service.js'; export { attachCapabilityService, createAttachCapabilityService, @@ -66,6 +70,7 @@ export { createPaneOutputStream, type PaneSubscriptionKey, } from './services/pane-output-stream.service.js'; +export type { PaneOutputStreamOptions } from './services/pane-output-stream.service.js'; export { TMUX_OUTPUT_CLEAR_CONFIRM_MS, TMUX_OUTPUT_FALLBACK_MS, diff --git a/server/modules/providers/provider.routes.ts b/server/modules/providers/provider.routes.ts index 9599bbc..478dbc8 100644 --- a/server/modules/providers/provider.routes.ts +++ b/server/modules/providers/provider.routes.ts @@ -16,7 +16,6 @@ import { import { getCurrentTmuxPaneIdentityState, getExternalCliSessionsDetailed, - normalizeExternalPaneOutput, resolveExternalCliCwd, spawnExternalCliSession, type ExternalCliSession, @@ -54,13 +53,10 @@ import { submitTmuxInteractiveCustomResponse, } from '@/modules/providers/services/tmux-interactive-prompt.service.js'; import { - captureTmuxPane, killTmuxPane, killTmuxSession, readTmuxPaneIdentity, readTmuxProcessGeneration, - sendTmuxProcessAction, - sendToTmuxPane, stopAgentProcessInPane, type TmuxProcessAction, } from '@/modules/providers/services/tmux-pane-actions.service.js'; @@ -74,8 +70,21 @@ import type { UpsertProviderMcpServerInput, } from '@/shared/types.js'; import { AppError, asyncHandler, createApiSuccessResponse } from '@/shared/utils.js'; +import type { + HerdrAdmissionService, + RuntimeOperationPolicyService, + RuntimeRegistryService, +} from '@/modules/terminal-runtimes/index.js'; + +import { + readPublicTerminalTarget, + type PublicTerminalRef, + type PublicTerminalTarget, + type RuntimeCapabilities, +} from '../../../shared/terminal-runtime.js'; import { attachCapabilityService } from './services/attach-capability.service.js'; +import type { TmuxRuntimeOperationContext } from './services/tmux-runtime-adapter.service.js'; const router = express.Router(); @@ -107,6 +116,136 @@ function externalProcessGeneration(session: { : null; } +function clientReloadRequired(): never { + throw new AppError('This terminal operation requires the v2 terminal contract.', { + code: 'CLIENT_RELOAD_REQUIRED', + statusCode: 409, + }); +} + +function readTerminalV2(body: Record): PublicTerminalTarget { + if (body.apiVersion !== 2 || !Object.hasOwn(body, 'target')) clientReloadRequired(); + const target = readPublicTerminalTarget(body.target); + if (!target) { + const candidate = body.target; + if (candidate && typeof candidate === 'object' && !Array.isArray(candidate) && (candidate as { runtime?: unknown }).runtime === 'tmux') { + readTmuxPaneIdentity((candidate as { tmux?: unknown }).tmux); + readTmuxProcessGeneration((candidate as { process?: unknown }).process); + } + throw new AppError('target must be a valid v2 public terminal target.', { + code: 'INVALID_TERMINAL_TARGET_V2', + statusCode: 400, + }); + } + return target; +} + +function terminalRef(target: PublicTerminalTarget): PublicTerminalRef { + return target.runtime === 'herdr' + ? { runtime: 'herdr', sourceId: target.sourceId, targetId: target.targetId } + : { runtime: 'tmux', tmux: target.tmux }; +} + +function terminalRegistry(req: Request): RuntimeRegistryService | undefined { + return req.app.locals.terminalRuntimeRegistry as RuntimeRegistryService | undefined; +} + +function assertRuntimePolicy(req: Request, target: PublicTerminalTarget, operation: 'output' | 'actions'): void { + if (target.runtime !== 'herdr') return; + const policy = req.app.locals.runtimeOperationPolicy as RuntimeOperationPolicyService | undefined; + if (policy && !policy.allows(target.sourceId as never, operation)) { + throw new AppError('The terminal runtime operation is unavailable.', { + code: 'TERMINAL_RUNTIME_UNAVAILABLE', + statusCode: 409, + }); + } +} + +function terminalCapabilities(req: Request, target: PublicTerminalTarget): RuntimeCapabilities { + const sourceId = target.runtime === 'tmux' ? 'tmux.local' : target.sourceId; + return terminalRegistry(req)?.capabilities(target.runtime, sourceId) ?? { + discovery: false, output: false, actions: false, attach: false, create: false, + }; +} +function requireLocalAgent(target: PublicTerminalTarget): Extract { + if (target.targetClass !== 'local-agent') { + throw new AppError('This terminal target cannot perform the requested operation.', { + code: 'TERMINAL_OPERATION_UNSUPPORTED', + statusCode: 409, + }); + } + return target; +} + +function tmuxRuntimeContext( + target: PublicTerminalTarget, + verify: (tmux: unknown, process: unknown) => Promise, + options: Omit = {}, +): TmuxRuntimeOperationContext | undefined { + return target.runtime === 'tmux' + ? { process: 'process' in target ? target.process : undefined, verify, ...options } + : undefined; +} + +async function readRuntimePane( + req: Request, + target: PublicTerminalTarget, + verifyTmux: (tmux: unknown, process: unknown) => Promise, +): Promise<{ ansi: string; truncated: boolean }> { + assertRuntimePolicy(req, target, 'output'); + if (target.runtime === 'tmux' && target.targetClass !== 'local-agent') { + throw new AppError('This terminal target requires local-agent authority.', { + code: 'TERMINAL_OPERATION_UNSUPPORTED', + statusCode: 409, + }); + } + const output = await terminalRegistry(req)?.read(terminalRef(target), tmuxRuntimeContext(target, verifyTmux)); + if (!output) throw new AppError('The terminal target is unavailable.', { code: 'TERMINAL_RUNTIME_UNAVAILABLE', statusCode: 409 }); + return output; +} + +async function sendRuntimeText( + req: Request, + target: PublicTerminalTarget, + message: string, + verifyTmux: (tmux: unknown, process: unknown) => Promise, +): Promise { + const local = requireLocalAgent(target); + assertRuntimePolicy(req, local, 'actions'); + let verified: VerifiedTmuxActionTarget | null = null; + if (!await terminalRegistry(req)?.send(terminalRef(local), message, tmuxRuntimeContext(local, verifyTmux, { + onVerified: (value) => { verified = value; }, + }))) { + throw new AppError('The terminal target is unavailable.', { code: 'TERMINAL_RUNTIME_UNAVAILABLE', statusCode: 409 }); + } + return verified; +} + +async function sendRuntimeAction( + req: Request, + target: PublicTerminalTarget, + action: TmuxProcessAction, + verifyTmux: (tmux: unknown, process: unknown) => Promise, +): Promise { + const local = requireLocalAgent(target); + assertRuntimePolicy(req, local, 'actions'); + let verified: VerifiedTmuxActionTarget | null = null; + const context = tmuxRuntimeContext(local, verifyTmux, { + beforeAction: (value) => assertTerminationAllowed(value, 'pane'), + onVerified: (value) => { verified = value; }, + }); + const registry = terminalRegistry(req); + const performed = action === 'interrupt' + ? await registry?.interrupt(terminalRef(local), context) + : await registry?.escape(terminalRef(local), context); + if (!performed) throw new AppError('The terminal target is unavailable.', { code: 'TERMINAL_RUNTIME_UNAVAILABLE', statusCode: 409 }); + return verified; +} + +function terminalResponse(req: Request, target: PublicTerminalTarget) { + return { version: 2, terminal: target, capabilities: terminalCapabilities(req, target) }; +} + function readAskToolId(value: unknown): string { const toolId = typeof value === 'string' ? value.trim() : ''; if (!toolId || toolId.length > 500) { @@ -939,13 +1078,35 @@ router.get( }), ); +router.post( + '/sessions/external/admission', + asyncHandler(async (req: Request, res: Response) => { + const target = readTerminalV2((req.body ?? {}) as Record); + if (target.runtime !== 'herdr' || target.targetClass !== 'attach-only') { + throw new AppError('This terminal target does not require Herdr admission.', { code: 'TERMINAL_OPERATION_UNSUPPORTED', statusCode: 409 }); + } + const principal = String((req as typeof req & { user?: { id?: string | number; userId?: string | number } }).user?.id + ?? (req as typeof req & { user?: { userId?: string | number } }).user?.userId + ?? ''); + const registry = terminalRegistry(req); + const admissions = req.app.locals.herdrAdmissions as HerdrAdmissionService | undefined; + if (!principal || !registry || !admissions || !await registry.verify(terminalRef(target), 'attach')) { + throw new AppError('The terminal target is unavailable.', { code: 'TERMINAL_RUNTIME_UNAVAILABLE', statusCode: 409 }); + } + const admissionCapability = admissions.issue(principal, target); + if (!admissionCapability) { + throw new AppError('The terminal admission could not be issued.', { code: 'TERMINAL_RUNTIME_UNAVAILABLE', statusCode: 409 }); + } + const controlTarget: PublicTerminalTarget = { ...target, admissionCapability }; + res.json(createApiSuccessResponse({ ...terminalResponse(req, controlTarget), lane: 'external' })); + }), +); router.post( '/sessions/external/output', asyncHandler(async (req: Request, res: Response) => { - const body = (req.body ?? {}) as { tmux?: unknown; process?: unknown }; - const target = await assertFreshExternalTmuxTarget(body.tmux, body.process); - const output = normalizeExternalPaneOutput(await captureTmuxPane(target)); - res.json(createApiSuccessResponse({ output })); + const target = readTerminalV2((req.body ?? {}) as Record); + const output = await readRuntimePane(req, target, assertFreshExternalTmuxTarget); + res.json(createApiSuccessResponse({ ...terminalResponse(req, target), lane: 'external', pane: { version: 2, lane: 'external', terminal: target, ansi: output.ansi, truncated: output.truncated } })); }), ); @@ -996,40 +1157,30 @@ router.post( router.post( '/sessions/external/kill', asyncHandler(async (req: Request, res: Response) => { - const body = (req.body ?? {}) as { - tmux?: unknown; - process?: unknown; - mode?: unknown; - }; + const body = (req.body ?? {}) as Record; + const publicTarget = readTerminalV2(body); + if (publicTarget.runtime !== 'tmux') throw new AppError('This terminal runtime does not support termination.', { code: 'TERMINAL_OPERATION_UNSUPPORTED', statusCode: 409 }); + const target = requireLocalAgent(publicTarget); + if (target.runtime !== 'tmux') throw new AppError('This terminal runtime does not support termination.', { code: 'TERMINAL_OPERATION_UNSUPPORTED', statusCode: 409 }); const mode = readTerminationMode(body.mode); - const target = await assertFreshExternalTmuxTarget(body.tmux, body.process); - await assertTerminationAllowed(target, mode); - if (mode === 'process') { - await stopAgentProcessInPane(target); - } else if (mode === 'pane') { - await killTmuxPane(target); - } else { - await killTmuxSession(target); - } - res.json(createApiSuccessResponse({ ok: true, mode })); + const verified = await assertFreshExternalTmuxTarget(target.tmux, target.process); + await assertTerminationAllowed(verified, mode); + if (mode === 'process') await stopAgentProcessInPane(verified); + else if (mode === 'pane') await killTmuxPane(verified); + else await killTmuxSession(verified); + res.json(createApiSuccessResponse({ ...terminalResponse(req, target), ok: true, mode })); }), ); router.post( '/sessions/external/send', asyncHandler(async (req: Request, res: Response) => { - const body = (req.body ?? {}) as { - tmux?: unknown; - process?: unknown; - message?: unknown; - }; + const body = (req.body ?? {}) as Record; + const target = readTerminalV2(body); const message = typeof body.message === 'string' ? body.message : ''; - if (!message.trim()) { - throw new AppError('message is required.', { code: 'EMPTY_MESSAGE', statusCode: 400 }); - } - const target = await assertFreshExternalTmuxTarget(body.tmux, body.process); - await sendToTmuxPane(target, message); - res.json(createApiSuccessResponse({ ok: true })); + if (!message.trim()) throw new AppError('message is required.', { code: 'EMPTY_MESSAGE', statusCode: 400 }); + await sendRuntimeText(req, target, message, assertFreshExternalTmuxTarget); + res.json(createApiSuccessResponse({ ...terminalResponse(req, target), ok: true })); }), ); router.post( @@ -1145,25 +1296,17 @@ router.post( router.post( '/sessions/external/actions', asyncHandler(async (req: Request, res: Response) => { - const body = (req.body ?? {}) as { tmux?: unknown; process?: unknown; action?: unknown }; + const body = (req.body ?? {}) as Record; + const target = readTerminalV2(body); const action = readTmuxProcessAction(body.action); try { - const target = await assertFreshExternalTmuxTarget(body.tmux, body.process); - // Process actions share the pane-level self/company protection boundary: - // an interrupt is non-destructive, but must not be misdirected to those targets. - await assertTerminationAllowed(target, 'pane'); - await sendTmuxProcessAction(target, action); - emitRelayKeyDiagnostic('relay_key_sent', target.kind); + const verified = await sendRuntimeAction(req, target, action, assertFreshExternalTmuxTarget); + if (verified) emitRelayKeyDiagnostic('relay_key_sent', verified.kind); } catch (error) { - if ( - error instanceof AppError - && (error.code === 'TMUX_PROCESS_GENERATION_MISMATCH' || error.code === 'TMUX_PANE_GENERATION_MISMATCH') - ) { - emitRelayKeyDiagnostic('relay_key_refused_generation', 'external'); - } + if (error instanceof AppError && (error.code === 'TMUX_PROCESS_GENERATION_MISMATCH' || error.code === 'TMUX_PANE_GENERATION_MISMATCH')) emitRelayKeyDiagnostic('relay_key_refused_generation', 'external'); throw error; } - res.json(createApiSuccessResponse({ ok: true })); + res.json(createApiSuccessResponse({ ...terminalResponse(req, target), ok: true })); }), ); @@ -1182,33 +1325,23 @@ router.get( router.post( '/sessions/live/output', asyncHandler(async (req: Request, res: Response) => { - const body = (req.body ?? {}) as { tmux?: unknown; process?: unknown }; - const tmux = readTmuxPaneIdentity(body.tmux); - const processGeneration = readTmuxProcessGeneration(body.process); - const target = await assertLineageTmuxTarget(tmux, processGeneration); - const output = normalizeExternalPaneOutput(await captureTmuxPane(target)); - res.json(createApiSuccessResponse({ output })); + const target = readTerminalV2((req.body ?? {}) as Record); + if (target.runtime === 'herdr') throw new AppError('Herdr terminals are external-only.', { code: 'TERMINAL_OPERATION_UNSUPPORTED', statusCode: 409 }); + const output = await readRuntimePane(req, target, async (tmux, process) => assertLineageTmuxTarget(readTmuxPaneIdentity(tmux), readTmuxProcessGeneration(process))); + res.json(createApiSuccessResponse({ ...terminalResponse(req, target), lane: 'live', pane: { version: 2, lane: 'live', terminal: target, ansi: output.ansi, truncated: output.truncated } })); }), ); router.post( '/sessions/live/send', asyncHandler(async (req: Request, res: Response) => { - const body = (req.body ?? {}) as { tmux?: unknown; process?: unknown; message?: unknown }; - const tmux = readTmuxPaneIdentity(body.tmux); - const processGeneration = readTmuxProcessGeneration(body.process); + const body = (req.body ?? {}) as Record; + const target = readTerminalV2(body); + if (target.runtime === 'herdr') throw new AppError('Herdr terminals are external-only.', { code: 'TERMINAL_OPERATION_UNSUPPORTED', statusCode: 409 }); const message = typeof body.message === 'string' ? body.message : ''; - if (!message.trim()) { - throw new AppError('message is required.', { code: 'EMPTY_MESSAGE', statusCode: 400 }); - } - const target = await assertLineageTmuxTarget(tmux, processGeneration); - await sendToTmuxPane(target, message); - res.json(createApiSuccessResponse({ - ok: true, - reachable: true, - queued: false, - detail: `Delivered to ${tmux.paneId}`, - })); + if (!message.trim()) throw new AppError('message is required.', { code: 'EMPTY_MESSAGE', statusCode: 400 }); + await sendRuntimeText(req, target, message, async (tmux, process) => assertLineageTmuxTarget(readTmuxPaneIdentity(tmux), readTmuxProcessGeneration(process))); + res.json(createApiSuccessResponse({ ...terminalResponse(req, target), ok: true, reachable: true, queued: false, detail: target.runtime === 'tmux' ? `Delivered to ${target.tmux.paneId}` : 'Delivered' })); }), ); router.post( @@ -1305,35 +1438,21 @@ router.post( router.post( '/sessions/live/actions', asyncHandler(async (req: Request, res: Response) => { - const body = (req.body ?? {}) as { tmux?: unknown; process?: unknown; action?: unknown }; + const body = (req.body ?? {}) as Record; + const target = readTerminalV2(body); + if (target.runtime === 'herdr') throw new AppError('Herdr terminals are external-only.', { code: 'TERMINAL_OPERATION_UNSUPPORTED', statusCode: 409 }); const action = readTmuxProcessAction(body.action); - const tmux = readTmuxPaneIdentity(body.tmux); - const processGeneration = readTmuxProcessGeneration(body.process); try { - const target = await assertLineageTmuxTarget(tmux, processGeneration); - // Keep the same pane-level protection as termination for all control keys. - await assertTerminationAllowed(target, 'pane'); - await sendTmuxProcessAction(target, action); - emitRelayKeyDiagnostic('relay_key_sent', target.kind); + const verified = await sendRuntimeAction(req, target, action, async (tmux, process) => assertLineageTmuxTarget(readTmuxPaneIdentity(tmux), readTmuxProcessGeneration(process))); + if (verified) emitRelayKeyDiagnostic('relay_key_sent', verified.kind); } catch (error) { if (error instanceof AppError) { - if (error.code === 'TMUX_ACTION_NOT_LINEAGE') { - emitRelayKeyDiagnostic('relay_key_refused_lineage', 'gjc'); - } else if ( - error.code === 'TMUX_PROCESS_GENERATION_MISMATCH' - || error.code === 'TMUX_PANE_GENERATION_MISMATCH' - ) { - emitRelayKeyDiagnostic('relay_key_refused_generation', 'gjc'); - } + if (error.code === 'TMUX_ACTION_NOT_LINEAGE') emitRelayKeyDiagnostic('relay_key_refused_lineage', 'gjc'); + else if (error.code === 'TMUX_PROCESS_GENERATION_MISMATCH' || error.code === 'TMUX_PANE_GENERATION_MISMATCH') emitRelayKeyDiagnostic('relay_key_refused_generation', 'gjc'); } throw error; } - res.json(createApiSuccessResponse({ - ok: true, - reachable: true, - queued: false, - detail: `Delivered to ${tmux.paneId}`, - })); + res.json(createApiSuccessResponse({ ...terminalResponse(req, target), ok: true, reachable: true, queued: false, detail: target.runtime === 'tmux' ? `Delivered to ${target.tmux.paneId}` : 'Delivered' })); }), ); @@ -1367,24 +1486,18 @@ router.post( router.post( '/sessions/live/kill', asyncHandler(async (req: Request, res: Response) => { - const body = (req.body ?? {}) as { - tmux?: unknown; - process?: unknown; - mode?: unknown; - }; - const tmux = readTmuxPaneIdentity(body.tmux); - const processGeneration = readTmuxProcessGeneration(body.process); + const body = (req.body ?? {}) as Record; + const publicTarget = readTerminalV2(body); + if (publicTarget.runtime !== 'tmux') throw new AppError('This terminal runtime does not support termination.', { code: 'TERMINAL_OPERATION_UNSUPPORTED', statusCode: 409 }); + const target = requireLocalAgent(publicTarget); + if (target.runtime !== 'tmux') throw new AppError('This terminal runtime does not support termination.', { code: 'TERMINAL_OPERATION_UNSUPPORTED', statusCode: 409 }); const mode = readTerminationMode(body.mode); - const target = await assertLineageTmuxTarget(tmux, processGeneration); - await assertTerminationAllowed(target, mode); - if (mode === 'process') { - await stopAgentProcessInPane(target); - } else if (mode === 'pane') { - await killTmuxPane(target); - } else { - await killTmuxSession(target); - } - res.json(createApiSuccessResponse({ ok: true, mode })); + const verified = await assertLineageTmuxTarget(target.tmux, target.process); + await assertTerminationAllowed(verified, mode); + if (mode === 'process') await stopAgentProcessInPane(verified); + else if (mode === 'pane') await killTmuxPane(verified); + else await killTmuxSession(verified); + res.json(createApiSuccessResponse({ ...terminalResponse(req, target), ok: true, mode })); }), ); diff --git a/server/modules/providers/services/discovery-collector.service.ts b/server/modules/providers/services/discovery-collector.service.ts index 088b049..003fb02 100644 --- a/server/modules/providers/services/discovery-collector.service.ts +++ b/server/modules/providers/services/discovery-collector.service.ts @@ -1,6 +1,16 @@ import { randomUUID } from 'node:crypto'; import { access } from 'node:fs/promises'; +import { + publicTerminalKey, + sourceLaneKey, + type DiscoveryV2, + type LaneCoverage, + type PublicTerminalTarget, + type RuntimeCapabilities, + type SourceDescriptor, + type SourceLaneState, +} from '../../../../shared/terminal-runtime.js'; import { tmuxPaneIdentityKey, type TmuxPaneIdentity, @@ -38,6 +48,7 @@ export const GRACE_TICKS_LIVE = 2; export const GRACE_TICKS_EXTERNAL = 2; export const GJC_BINDING_GRACE_TICKS = 5; export const UNAVAILABLE_DEGRADE_TICKS = 30; +export const HERDR_UNAVAILABLE_GRACE_TICKS_EXTERNAL = 2; export const FULL_SCAN_FALLBACK_MS = 30_000; export type DiscoveryEpoch = string; @@ -74,9 +85,23 @@ export type DiscoverySnapshot = Readonly<{ takenAtMs: number; rows: readonly DiscoveryRow[]; health: Readonly>; + /** Versioned runtime projection. Legacy rows remain tmux-only. */ + v2?: DiscoveryV2; }>; export type DiscoveryLiveScanResult = Pick; +export type DiscoveryRuntimeOutcome = Readonly<{ + runtime: 'tmux' | 'herdr'; + sourceId: string; + ok: boolean; + terminals: readonly PublicTerminalTarget[]; +}>; +export type DiscoveryRuntimeRegistry = { + sources(): Promise; + discoverOutcomes(): Promise; + scanDiscovery?(): Promise<{ sources: readonly SourceDescriptor[]; outcomes: readonly DiscoveryRuntimeOutcome[] }>; + capabilities(runtime: 'tmux' | 'herdr', sourceId: string): RuntimeCapabilities; +}; export type DiscoveryDetailedSnapshot = Readonly<{ takenAtMs: number | null; @@ -92,6 +117,7 @@ export type DiscoveryCollectorOptions = { clearTimer?: (timer: ReturnType) => void; scanExternal?: () => Promise; scanLive?: () => Promise; + runtimeRegistry?: DiscoveryRuntimeRegistry; scanHost?: (() => Promise) | null; isProcessAlive?: (pid: number) => Promise; }; @@ -112,6 +138,16 @@ export type DiscoveryCollector = { type LaneState = { failures: number; health: DiscoveryLaneHealth; + sourceLaneRevision: number; +}; +type RuntimeLaneState = LaneState & { disabled: boolean }; +type RuntimeRow = { + lane: 'external'; + sourceId: string; + terminal: PublicTerminalTarget; + lastSeenRevision: number; + presence: 'present' | 'stale'; + staleSinceRevision: number | null; }; function rowKey(lane: DiscoveryLane, tmux: TmuxPaneIdentity): DiscoveryRowKey { @@ -174,6 +210,7 @@ export function createDiscoveryCollector(options: DiscoveryCollectorOptions = {} const clearTimer = options.clearTimer ?? clearInterval; const scanExternal = options.scanExternal ?? getExternalCliSessionsDetailedFresh; const scanLive = options.scanLive ?? defaultLiveScan; + const runtimeRegistry = options.runtimeRegistry; const scanHost = options.scanHost === undefined ? options.scanExternal === undefined && options.scanLive === undefined ? captureHostDiscoveryPanes @@ -186,9 +223,13 @@ export function createDiscoveryCollector(options: DiscoveryCollectorOptions = {} const missingTicks = new Map(); const liveBindingMissingTicks = new Map(); const laneState: Record = { - external: { failures: 0, health: { ok: true, lastOkRevision: null, consecutiveFailures: 0 } }, - live: { failures: 0, health: { ok: true, lastOkRevision: null, consecutiveFailures: 0 } }, + external: { failures: 0, health: { ok: true, lastOkRevision: null, consecutiveFailures: 0 }, sourceLaneRevision: 0 }, + live: { failures: 0, health: { ok: true, lastOkRevision: null, consecutiveFailures: 0 }, sourceLaneRevision: 0 }, }; + const runtimeRows = new Map(); + const runtimeMissingTicks = new Map(); + const sourceStates = new Map(); + let runtimeDescriptors: SourceDescriptor[] = []; let timer: ReturnType | null = null; let forceTimer: ReturnType | null = null; let active = false; @@ -213,17 +254,58 @@ export function createDiscoveryCollector(options: DiscoveryCollectorOptions = {} return Object.freeze({ ...row, tmux, process }); } + function runtimeState(sourceId: string): RuntimeLaneState { + const key = sourceLaneKey('external', sourceId); + let state = sourceStates.get(key); + if (!state) { + state = { failures: 0, health: { ok: true, lastOkRevision: null, consecutiveFailures: 0 }, sourceLaneRevision: 0, disabled: false }; + sourceStates.set(key, state); + } + return state; + } + + function runtimeProjection(): DiscoveryV2 | undefined { + if (!runtimeRegistry) return undefined; + const tmuxTerminals = [...rows.values()].flatMap((row) => row.process + ? [{ lane: row.lane, terminal: { runtime: 'tmux' as const, tmux: row.tmux, process: row.process, targetClass: 'local-agent' as const } }] + : []); + const terminals = [...tmuxTerminals, ...[...runtimeRows.values()].map((row) => ({ lane: 'external' as const, terminal: row.terminal }))] + .sort((a, b) => publicTerminalKey(a.lane, a.terminal).localeCompare(publicTerminalKey(b.lane, b.terminal))); + const runtimeSources = runtimeDescriptors + .filter((source) => source.runtime === 'herdr') + .map((source) => { + const state = runtimeState(source.sourceId); + const coverage = state.disabled ? 'none' as const : state.failures === 0 ? 'authoritative' as const : state.failures < HERDR_UNAVAILABLE_GRACE_TICKS_EXTERNAL ? 'retained' as const : 'none' as const; + return { lane: 'external' as const, sourceId: source.sourceId, runtime: 'herdr' as const, readiness: source.readiness, capabilities: coverage === 'authoritative' ? runtimeRegistry.capabilities('herdr', source.sourceId) : { discovery: false, output: false, actions: false, attach: false, create: false }, sourceLaneRevision: state.sourceLaneRevision, lastOkGlobalRevision: state.health.lastOkRevision ?? 0, coverage, consecutiveFailures: state.health.consecutiveFailures }; + }); + const sourceLanes: SourceLaneState[] = [ + ...(['external', 'live'] as const).map((lane) => ({ lane, sourceId: 'tmux.local', runtime: 'tmux' as const, readiness: 'ready' as const, capabilities: { discovery: true, output: true, actions: true, attach: true, create: false }, sourceLaneRevision: laneState[lane].sourceLaneRevision, lastOkGlobalRevision: laneState[lane].health.lastOkRevision ?? 0, coverage: laneState[lane].health.ok ? 'authoritative' as const : 'retained' as const, consecutiveFailures: laneState[lane].health.consecutiveFailures })), + ...runtimeSources, + ]; + const coverage = (lane: DiscoveryLane): LaneCoverage => { + const sources = sourceLanes.filter((source) => source.lane === lane); + const keys = sources.map((source) => sourceLaneKey(source.lane, source.sourceId)); + const authoritative = sources.filter((source) => source.coverage === 'authoritative').map((source) => sourceLaneKey(source.lane, source.sourceId)); + const retained = sources.filter((source) => source.coverage === 'retained').map((source) => sourceLaneKey(source.lane, source.sourceId)); + const unavailable = sources.filter((source) => source.coverage === 'none').map((source) => sourceLaneKey(source.lane, source.sourceId)); + return { lane, state: unavailable.length || retained.length ? (authoritative.length ? 'partial' : retained.length ? 'partial' : 'unavailable') : 'complete', expectedSourceLaneKeys: keys, authoritativeSourceLaneKeys: authoritative, retainedSourceLaneKeys: retained, unavailableSourceLaneKeys: unavailable }; + }; + return { version: 2, epoch, globalRevision: revision, terminals, tmuxRows: [...rows.values()].map(({ lastSeenRevision: _lastSeenRevision, staleSinceRevision: _staleSinceRevision, ...row }) => row), sourceDescriptors: [{ runtime: 'tmux', sourceId: 'tmux.local', readiness: 'ready' }, ...runtimeDescriptors.filter((source) => source.runtime === 'herdr')], sourceLanes, coverageByLane: { external: coverage('external'), live: coverage('live') } }; + } + function makeSnapshot(takenAtMs: number): DiscoverySnapshot { const health = Object.freeze({ external: Object.freeze({ ...laneState.external.health }), live: Object.freeze({ ...laneState.live.health }), }); + const v2 = runtimeProjection(); return Object.freeze({ epoch, revision, takenAtMs, rows: Object.freeze([...rows.values()].sort((a, b) => a.key.localeCompare(b.key)).map(freezeRow)), health, + ...(v2 ? { v2: Object.freeze(v2) } : {}), }); } @@ -365,6 +447,93 @@ export function createDiscoveryCollector(options: DiscoveryCollectorOptions = {} if (changed) rows = next; return changed; } + function applyRuntimeAvailable(sourceId: string, terminals: readonly PublicTerminalTarget[]): boolean { + const next = new Map(runtimeRows); + const found = new Map(terminals + .filter((terminal): terminal is Extract => terminal.runtime === 'herdr' && terminal.sourceId === sourceId) + .map((terminal) => [publicTerminalKey('external', terminal), terminal])); + let changed = false; + for (const [key, previous] of runtimeRows) { + if (previous.sourceId !== sourceId) continue; + const terminal = found.get(key); + if (terminal) { + if (previous.presence !== 'present' || previous.staleSinceRevision !== null || JSON.stringify(previous.terminal) !== JSON.stringify(terminal)) { + next.set(key, { ...previous, terminal, lastSeenRevision: revision + 1, presence: 'present', staleSinceRevision: null }); + changed = true; + } + found.delete(key); + runtimeMissingTicks.delete(key); + } else if (previous.presence === 'present') { + runtimeMissingTicks.set(key, 1); + next.set(key, { ...previous, presence: 'stale', staleSinceRevision: revision + 1 }); + changed = true; + } else { + const misses = (runtimeMissingTicks.get(key) ?? 1) + 1; + runtimeMissingTicks.set(key, misses); + if (misses >= GRACE_TICKS_EXTERNAL) { + runtimeMissingTicks.delete(key); + next.delete(key); + changed = true; + } + } + } + for (const [key, terminal] of found) { + next.set(key, { lane: 'external', sourceId, terminal, lastSeenRevision: revision + 1, presence: 'present', staleSinceRevision: null }); + changed = true; + } + if (changed) { + runtimeRows.clear(); + for (const [key, value] of next) runtimeRows.set(key, value); + } + return changed; + } + + function applyRuntimeUnavailable(sourceId: string): boolean { + const state = runtimeState(sourceId); + state.disabled = false; + state.failures += 1; + state.health = { ok: false, lastOkRevision: state.health.lastOkRevision, consecutiveFailures: state.failures }; + const changed = true; + const next = new Map(runtimeRows); + for (const [key, row] of runtimeRows) { + if (row.sourceId !== sourceId) continue; + if (state.failures >= HERDR_UNAVAILABLE_GRACE_TICKS_EXTERNAL) { + next.delete(key); + runtimeMissingTicks.delete(key); + } else if (row.presence === 'present') { + next.set(key, { ...row, presence: 'stale', staleSinceRevision: revision + 1 }); + } + } + if (next.size !== runtimeRows.size || [...next].some(([key, row]) => runtimeRows.get(key) !== row)) { + runtimeRows.clear(); + for (const [key, row] of next) runtimeRows.set(key, row); + } + return changed; + } + function applyRuntimeDisabled(sourceId: string): boolean { + const state = runtimeState(sourceId); + const changed = !state.disabled || state.health.ok || state.health.consecutiveFailures !== 0 + || [...runtimeRows.values()].some((row) => row.sourceId === sourceId); + state.disabled = true; + state.failures = 0; + state.health = { ok: false, lastOkRevision: state.health.lastOkRevision, consecutiveFailures: 0 }; + for (const [key, row] of runtimeRows) { + if (row.sourceId === sourceId) { + runtimeRows.delete(key); + runtimeMissingTicks.delete(key); + } + } + return changed; + } + + function applyRuntimeSuccess(sourceId: string): boolean { + const state = runtimeState(sourceId); + const changed = state.disabled || !state.health.ok || state.health.lastOkRevision === null || state.health.consecutiveFailures !== 0; + state.disabled = false; + state.failures = 0; + if (changed) state.health = { ok: true, lastOkRevision: revision + 1, consecutiveFailures: 0 }; + return changed; + } function applyUnavailable(lane: DiscoveryLane): boolean { const state = laneState[lane]; @@ -403,6 +572,7 @@ export function createDiscoveryCollector(options: DiscoveryCollectorOptions = {} } currentTickSatisfiesFullScan = forceFull || requireFullScan + || runtimeRegistry !== undefined || scanHost === null || detailed.takenAtMs === null || lastFullScanAtMs === null @@ -440,25 +610,78 @@ export function createDiscoveryCollector(options: DiscoveryCollectorOptions = {} } } - const [external, live] = await Promise.all([ + const [external, live, runtime] = await Promise.all([ scanExternal().catch(() => ({ ok: false, sessions: [] })), scanLive().catch(() => ({ ok: false, sessions: [] })), + runtimeRegistry + ? (runtimeRegistry.scanDiscovery + ? runtimeRegistry.scanDiscovery().then( + ({ sources, outcomes }) => ({ + sources: { ok: true as const, sources: [...sources] }, + outcomes: { ok: true as const, outcomes: [...outcomes] }, + }), + () => ({ + sources: { ok: false as const, sources: [] as SourceDescriptor[] }, + outcomes: { ok: false as const, outcomes: [] as DiscoveryRuntimeOutcome[] }, + }), + ) + : Promise.all([ + runtimeRegistry.sources().then((sources) => ({ ok: true as const, sources }), () => ({ ok: false as const, sources: [] as SourceDescriptor[] })), + runtimeRegistry.discoverOutcomes().then((outcomes) => ({ ok: true as const, outcomes }), () => ({ ok: false as const, outcomes: [] as DiscoveryRuntimeOutcome[] })), + ]).then(([sources, outcomes]) => ({ sources, outcomes }))) + : Promise.resolve(null), ]); if (disposed) return; const takenAtMs = now(); detailed = Object.freeze({ takenAtMs, external, live }); let changed = false; + let externalChanged: boolean; if (external.ok) { - changed = applyKnownSuccess('external') || changed; - changed = applyAvailable('external', await externalRows(external.sessions)) || changed; + externalChanged = applyKnownSuccess('external'); + externalChanged = applyAvailable('external', await externalRows(external.sessions)) || externalChanged; } else { - changed = applyUnavailable('external') || changed; + externalChanged = applyUnavailable('external'); } + if (externalChanged) laneState.external.sourceLaneRevision += 1; + changed = externalChanged || changed; + let liveChanged: boolean; if (live.ok) { - changed = applyKnownSuccess('live') || changed; - changed = applyAvailable('live', liveRows(live.sessions)) || changed; + liveChanged = applyKnownSuccess('live'); + liveChanged = applyAvailable('live', liveRows(live.sessions)) || liveChanged; } else { - changed = applyUnavailable('live') || changed; + liveChanged = applyUnavailable('live'); + } + if (liveChanged) laneState.live.sourceLaneRevision += 1; + changed = liveChanged || changed; + if (runtime) { + if (runtime.sources.ok) runtimeDescriptors = runtime.sources.sources.filter((source) => source.runtime === 'herdr'); + const expectedSources = new Map(runtimeDescriptors.map((source) => [source.sourceId, source])); + for (const key of sourceStates.keys()) { + const sourceId = key.split('\0')[1]!; + if (!expectedSources.has(sourceId)) expectedSources.set(sourceId, { runtime: 'herdr', sourceId, readiness: 'offline' }); + } + const outcomes = new Map(); + if (runtime.outcomes.ok) { + for (const outcome of runtime.outcomes.outcomes) { + if (outcome.runtime !== 'herdr' || outcomes.has(outcome.sourceId)) continue; + outcomes.set(outcome.sourceId, outcome); + } + } + for (const [sourceId, source] of expectedSources) { + const outcome = outcomes.get(sourceId); + const capable = runtimeRegistry!.capabilities('herdr', sourceId).discovery; + let pairChanged: boolean; + if (source.readiness === 'disabled' || !capable) { + pairChanged = applyRuntimeDisabled(sourceId); + } else if (source.readiness === 'ready' && outcome?.ok === true) { + pairChanged = applyRuntimeSuccess(sourceId); + pairChanged = applyRuntimeAvailable(sourceId, outcome.terminals) || pairChanged; + } else { + pairChanged = applyRuntimeUnavailable(sourceId); + } + if (pairChanged) runtimeState(sourceId).sourceLaneRevision += 1; + changed = pairChanged || changed; + } } if (changed) revision += 1; snapshot = makeSnapshot(takenAtMs); diff --git a/server/modules/providers/services/pane-output-stream.service.ts b/server/modules/providers/services/pane-output-stream.service.ts index 7e04b4f..e811ca5 100644 --- a/server/modules/providers/services/pane-output-stream.service.ts +++ b/server/modules/providers/services/pane-output-stream.service.ts @@ -3,12 +3,14 @@ import { createHash, randomUUID } from 'node:crypto'; import type { WebSocket } from 'ws'; import { paneSubscriptionKey, tmuxPaneIdentityKey, type TmuxPaneIdentity, type TmuxProcessGeneration } from '../../../../shared/tmux.js'; +import { publicTerminalKey, readPublicTerminalTarget, type PublicTerminalTarget } from '../../../../shared/terminal-runtime.js'; +import type { RuntimeRegistryService } from '../../terminal-runtimes/index.js'; import { normalizeExternalPaneOutput } from './external-cli-sessions.service.js'; import type { DiscoverySnapshot } from './discovery-collector.service.js'; import { assertFreshExternalTmuxTarget, type VerifiedTmuxActionTarget } from './tmux-fresh-verifier.service.js'; import { assertLineageTmuxTarget } from './tmux-target-guard.service.js'; -import { assertTmuxPaneIdentity, captureTmuxPane, readTmuxPaneIdentity, readTmuxProcessGeneration } from './tmux-pane-actions.service.js'; +import { assertTmuxPaneIdentity, captureTmuxPane } from './tmux-pane-actions.service.js'; export const C_CAPTURE_MS = 1_000; export const PANE_REMINT_MS = 10_000; @@ -19,7 +21,7 @@ export const PANE_UNAVAILABLE_TIMEOUT_MS = 30_000; export type PaneSubscriptionKey = string; type Lane = 'external' | 'live'; type Sub = { id: string; ws: WebSocket; key: PaneSubscriptionKey; lane: Lane; knownHash?: string; attached: boolean }; -type Entry = { key: PaneSubscriptionKey; lane: Lane; tmux: TmuxPaneIdentity; process: TmuxProcessGeneration; target?: VerifiedTmuxActionTarget; mintedAt: number; hash: string | null; output: string | null; generation: number; subs: Map; inFlight: Promise | null }; +type Entry = { key: PaneSubscriptionKey; lane: Lane; terminal: PublicTerminalTarget; tmux?: TmuxPaneIdentity; process?: TmuxProcessGeneration; target?: VerifiedTmuxActionTarget; mintedAt: number; hash: string | null; output: string | null; generation: number; subs: Map; inFlight: Promise | null }; type SocketQueue = { frames: Array>; flushing: boolean; retryTimer: ReturnType | null }; export type PaneOutputStreamOptions = { @@ -28,13 +30,22 @@ export type PaneOutputStreamOptions = { assertIdentity?: (tmux: TmuxPaneIdentity) => Promise; capturePane?: (target: VerifiedTmuxActionTarget) => Promise; normalizeOutput?: (output: string) => string; + runtimeRegistry?: RuntimeRegistryService; setTimer?: (callback: () => void, ms: number) => ReturnType; clearTimer?: (timer: ReturnType) => void; }; -type ParsedSubscription = { lane: Lane; tmux: TmuxPaneIdentity; process: TmuxProcessGeneration }; +type ParsedSubscription = { lane: Lane; terminal: PublicTerminalTarget; tmux?: TmuxPaneIdentity; process?: TmuxProcessGeneration }; function parseSubscription(data: Record): ParsedSubscription { - if (data.protocolVersion !== 1 || (data.lane !== 'external' && data.lane !== 'live')) throw new Error('invalid pane subscription'); - return { lane: data.lane, tmux: readTmuxPaneIdentity(data.tmux), process: readTmuxProcessGeneration(data.process) }; + if (data.protocolVersion !== 2) throw new Error('CLIENT_RELOAD_REQUIRED'); + if (data.lane !== 'external' && data.lane !== 'live') throw new Error('invalid pane subscription'); + const terminal = readPublicTerminalTarget(data.target); + if (!terminal) throw new Error('invalid pane subscription'); + if (terminal.runtime === 'tmux') { + if (terminal.targetClass !== 'local-agent') throw new Error('invalid pane subscription'); + return { lane: data.lane, terminal, tmux: terminal.tmux, process: terminal.process }; + } + if (data.lane !== 'external') throw new Error('invalid pane subscription'); + return { lane: data.lane, terminal }; } /** Verified pane capture registry. Discovery snapshots may revoke subscriptions but never mint or extend them. */ @@ -48,6 +59,7 @@ export function createPaneOutputStream(options: PaneOutputStreamOptions = {}) { const normalizeOutput = options.normalizeOutput ?? normalizeExternalPaneOutput; const setTimer = options.setTimer ?? setInterval; const clearTimer = options.clearTimer ?? clearInterval; + const runtimeRegistry = options.runtimeRegistry; const entries = new Map(); const subscriptions = new Map(); const bySocket = new Map>(); @@ -116,6 +128,8 @@ export function createPaneOutputStream(options: PaneOutputStreamOptions = {}) { entry.subs.clear(); } async function ensureTarget(entry: Entry, generation: number): Promise { + if (entry.terminal.runtime === 'herdr') return !!runtimeRegistry; + if (!entry.tmux || !entry.process) return false; if (entry.target && now() - entry.mintedAt < PANE_REMINT_MS) return true; try { const target = await mintTarget(entry.lane, entry.tmux, entry.process); @@ -129,7 +143,7 @@ export function createPaneOutputStream(options: PaneOutputStreamOptions = {}) { } function attach(sub: Sub, entry: Entry): void { if (entry.output === null || entry.hash === null) return; - emit(sub.ws, { kind: 'pane.attached', subscriptionId: sub.id, key: sub.key, lane: sub.lane, capturedAtMs: now(), outputHash: entry.hash, ...(sub.knownHash === entry.hash ? {} : { output: entry.output }), verifiedUntilMs: entry.mintedAt + PANE_REMINT_MS }); + emit(sub.ws, { kind: 'pane.attached', protocolVersion: 2, subscriptionId: sub.id, key: sub.key, lane: sub.lane, terminal: entry.terminal, capturedAtMs: now(), outputHash: entry.hash, ...(sub.knownHash === entry.hash ? {} : { output: entry.output }), ...(entry.terminal.runtime === 'tmux' ? { verifiedUntilMs: entry.mintedAt + PANE_REMINT_MS } : {}) }); sub.attached = true; } function capture(entry: Entry): Promise { @@ -137,37 +151,44 @@ export function createPaneOutputStream(options: PaneOutputStreamOptions = {}) { const generation = entry.generation; entry.inFlight = (async () => { if (!await ensureTarget(entry, generation) || !current(entry, generation)) return; - try { await assertIdentity(entry.tmux); } catch { if (current(entry, generation)) invalidate(entry, 'pane_identity_changed'); return; } - // Identity inspection can take long enough for the target lease to expire. - if (!await ensureTarget(entry, generation) || !current(entry, generation) || !entry.target) return; let output: string; - try { output = normalizeOutput(await capturePane(entry.target)); } catch { if (current(entry, generation)) invalidate(entry, now() - entry.mintedAt >= PANE_REMINT_MS ? 'remint_failed' : 'pane_identity_changed'); return; } + if (entry.terminal.runtime === 'herdr') { + const result = await runtimeRegistry?.read({ runtime: 'herdr', sourceId: entry.terminal.sourceId, targetId: entry.terminal.targetId }); + if (!result) { if (current(entry, generation)) invalidate(entry, 'unauthorized'); return; } + output = normalizeOutput(result.ansi); + } else { + try { await assertIdentity(entry.tmux!); } catch { if (current(entry, generation)) invalidate(entry, 'pane_identity_changed'); return; } + if (!await ensureTarget(entry, generation) || !current(entry, generation) || !entry.target) return; + try { output = normalizeOutput(await capturePane(entry.target)); } catch { if (current(entry, generation)) invalidate(entry, now() - entry.mintedAt >= PANE_REMINT_MS ? 'remint_failed' : 'pane_identity_changed'); return; } + } if (!current(entry, generation)) return; const outputHash = createHash(PANE_OUTPUT_HASH).update(output).digest('hex'); const changed = entry.hash !== outputHash; entry.output = output; entry.hash = outputHash; for (const sub of entry.subs.values()) { if (!sub.attached) attach(sub, entry); - else if (changed) emit(sub.ws, { kind: 'pane.output', subscriptionId: sub.id, key: sub.key, capturedAtMs: now(), outputHash, output }); + else if (changed) emit(sub.ws, { kind: 'pane.output', protocolVersion: 2, subscriptionId: sub.id, key: sub.key, terminal: entry.terminal, capturedAtMs: now(), outputHash, output }); } })().finally(() => { if (entry.inFlight) entry.inFlight = null; }); return entry.inFlight; } async function subscribe(ws: WebSocket, data: Record): Promise { - const { lane, tmux, process } = parseSubscription(data); - const key = paneSubscriptionKey(lane, tmux, process); + const { lane, terminal, tmux, process } = parseSubscription(data); + const key = terminal.runtime === 'herdr' + ? publicTerminalKey(lane, { runtime: 'herdr', sourceId: terminal.sourceId, targetId: terminal.targetId }) + : paneSubscriptionKey(lane, terminal.tmux, process!); const prior = bySocket.get(ws)?.get(key); if (prior) { const old = subscriptions.get(prior); if (old) { discardQueuedFrames(ws, old.id); - emit(ws, { kind: 'pane.invalidated', subscriptionId: old.id, key: old.key, reason: 'superseded' }); + emit(ws, { kind: 'pane.invalidated', protocolVersion: 2, subscriptionId: old.id, key: old.key, reason: 'superseded' }); entries.get(old.key)?.subs.delete(ws); removeSub(old); } } let entry = entries.get(key); - if (!entry) { entry = { key, lane, tmux, process, mintedAt: 0, hash: null, output: null, generation: 0, subs: new Map(), inFlight: null }; entries.set(key, entry); } + if (!entry) { entry = { key, lane, terminal, tmux, process, mintedAt: 0, hash: null, output: null, generation: 0, subs: new Map(), inFlight: null }; entries.set(key, entry); } const sub: Sub = { id: `s-${randomUUID()}`, ws, key, lane, attached: false, ...(typeof data.knownOutputHash === 'string' ? { knownHash: data.knownOutputHash } : {}) }; subscriptions.set(sub.id, sub); entry.subs.set(ws, sub); const socketKeys = bySocket.get(ws) ?? new Map(); socketKeys.set(key, sub.id); bySocket.set(ws, socketKeys); if (entry.output !== null) attach(sub, entry); else await capture(entry); @@ -178,9 +199,11 @@ export function createPaneOutputStream(options: PaneOutputStreamOptions = {}) { for (const lane of ['external', 'live'] as const) { if (snapshot.health[lane].ok) unavailableSince.delete(lane); else unavailableSince.set(lane, unavailableSince.get(lane) ?? now()); } for (const entry of entries.values()) { if (now() - (unavailableSince.get(entry.lane) ?? now()) >= PANE_UNAVAILABLE_TIMEOUT_MS) { invalidate(entry, 'lane_unavailable_timeout'); continue; } - const row = snapshot.rows.find((candidate) => candidate.key === `${entry.lane}\0${tmuxPaneIdentityKey(entry.tmux)}`); + // Discovery never authorizes Herdr; every capture invokes its fresh pane.read path. + if (entry.terminal.runtime === 'herdr') continue; + const row = snapshot.rows.find((candidate) => candidate.key === `${entry.lane}\0${tmuxPaneIdentityKey(entry.tmux!)}`); if (!row) invalidate(entry, 'row_removed'); - else if (row.process?.pid !== entry.process.pid || row.process?.startedAtMs !== entry.process.startedAtMs) invalidate(entry, 'process_generation_changed'); + else if (row.process?.pid !== entry.process!.pid || row.process?.startedAtMs !== entry.process!.startedAtMs) invalidate(entry, 'process_generation_changed'); } } function close(ws: WebSocket): void { for (const id of bySocket.get(ws)?.values() ?? []) { const sub = subscriptions.get(id); const entry = sub && entries.get(sub.key); if (sub && entry) { entry.subs.delete(ws); removeSub(sub); if (entry.subs.size === 0) invalidate(entry, 'connection_closed'); } } const queue = queues.get(ws); if (queue?.retryTimer) clearTimeout(queue.retryTimer); queues.delete(ws); } diff --git a/server/modules/providers/services/tmux-runtime-adapter.service.ts b/server/modules/providers/services/tmux-runtime-adapter.service.ts new file mode 100644 index 0000000..5f4d000 --- /dev/null +++ b/server/modules/providers/services/tmux-runtime-adapter.service.ts @@ -0,0 +1,70 @@ +import type { RuntimeAdapter, RuntimeOperationContext } from '@/modules/terminal-runtimes/index.js'; + +import type { PublicTerminalRef, PublicTerminalTarget, RuntimeCapabilities, SourceDescriptor } from '../../../../shared/terminal-runtime.js'; + +import { normalizeExternalPaneOutput } from './external-cli-sessions.service.js'; +import type { VerifiedTmuxActionTarget } from './tmux-fresh-verifier.service.js'; +import { + captureTmuxPane, + sendTmuxProcessAction, + sendToTmuxPane, + type TmuxProcessAction, +} from './tmux-pane-actions.service.js'; + +const TMUX_SOURCE_ID = 'tmux.local'; +const CAPABILITIES: RuntimeCapabilities = { + discovery: true, output: true, actions: true, attach: true, create: true, +}; + +type TmuxTargetVerifier = (tmux: unknown, process: unknown) => Promise; +export type TmuxRuntimeOperationContext = Readonly<{ + process: unknown; + verify: TmuxTargetVerifier; + beforeAction?: (target: VerifiedTmuxActionTarget) => Promise; + onVerified?: (target: VerifiedTmuxActionTarget) => void; +}>; + +function operationContext(value: RuntimeOperationContext): TmuxRuntimeOperationContext | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + const context = value as Partial; + return typeof context.verify === 'function' ? context as TmuxRuntimeOperationContext : null; +} + +async function verified(ref: PublicTerminalRef, context: RuntimeOperationContext): Promise { + const operation = operationContext(context); + if (ref.runtime !== 'tmux' || !operation) return null; + const target = await operation.verify(ref.tmux, operation.process); + operation.onVerified?.(target); + return target; +} + +/** Delegates tmux runtime operations to the established verifier and pane primitives. */ +export function createTmuxRuntimeAdapter(): RuntimeAdapter { + return { + runtime: 'tmux', + sourceDescriptors: async (): Promise => [{ runtime: 'tmux', sourceId: TMUX_SOURCE_ID, readiness: 'ready' }], + capabilities: (): RuntimeCapabilities => CAPABILITIES, + discover: async (): Promise => [], + read: async (ref, context) => { + const target = await verified(ref, context); + return target ? { ansi: normalizeExternalPaneOutput(await captureTmuxPane(target)), truncated: false } : null; + }, + send: async (ref, literal, context) => { + const target = await verified(ref, context); + if (!target) return false; + await sendToTmuxPane(target, literal); + return true; + }, + interrupt: async (ref, context) => action(ref, 'interrupt', context), + escape: async (ref, context) => action(ref, 'escape', context), + }; +} + +async function action(ref: PublicTerminalRef, key: TmuxProcessAction, context: RuntimeOperationContext): Promise { + const operation = operationContext(context); + const target = await verified(ref, context); + if (!target || !operation) return false; + await operation.beforeAction?.(target); + await sendTmuxProcessAction(target, key); + return true; +} diff --git a/server/modules/providers/tests/discovery-collector.service.test.ts b/server/modules/providers/tests/discovery-collector.service.test.ts index 77e4578..6ee05de 100644 --- a/server/modules/providers/tests/discovery-collector.service.test.ts +++ b/server/modules/providers/tests/discovery-collector.service.test.ts @@ -9,6 +9,7 @@ import { GRACE_TICKS_EXTERNAL, GRACE_TICKS_LIVE, UNAVAILABLE_DEGRADE_TICKS, + HERDR_UNAVAILABLE_GRACE_TICKS_EXTERNAL, createDiscoveryCollector, type DiscoveryRow, } from '@/modules/providers/services/discovery-collector.service.js'; @@ -415,3 +416,78 @@ test('collector transitions between idle and active cadence, debounces refresh, await collector.tick(); assert.equal(scansRun, 1); }); +test('runtime discovery keeps Herdr source grace and failures independent without exposing source internals', async () => { + const sourceA = 'hsrc_aaaaaaaaaaaaaaaaaaaaaa'; + const sourceB = 'hsrc_bbbbbbbbbbbbbbbbbbbbbb'; + const terminal = (sourceId: string, targetId: string) => ({ + runtime: 'herdr' as const, sourceId, targetId, targetClass: 'local-agent' as const, process: { pid: 20, startedAtMs: 200 }, + }); + const descriptors: Array<{ runtime: 'herdr'; sourceId: string; readiness: 'ready' | 'offline' }> = [ + { runtime: 'herdr' as const, sourceId: sourceA, readiness: 'ready' as const }, + { runtime: 'herdr' as const, sourceId: sourceB, readiness: 'ready' as const }, + ]; + let outcomes = [ + { runtime: 'herdr' as const, sourceId: sourceA, ok: true, terminals: [terminal(sourceA, 'htgt_aaaaaaaaaaaaaaaaaaaaaa')] }, + { runtime: 'herdr' as const, sourceId: sourceB, ok: true, terminals: [terminal(sourceB, 'htgt_bbbbbbbbbbbbbbbbbbbbbb')] }, + ]; + const collector = createDiscoveryCollector({ + scanExternal: async () => ({ ok: true, sessions: [] }), + scanLive: async () => ({ ok: true, sessions: [] }), + runtimeRegistry: { + sources: async () => descriptors, + discoverOutcomes: async () => outcomes, + capabilities: () => ({ discovery: true, output: false, actions: false, attach: false, create: false }), + }, + }); + await collector.tick(); + outcomes = [ + { runtime: 'herdr' as const, sourceId: sourceA, ok: false, terminals: [] }, + { runtime: 'herdr' as const, sourceId: sourceB, ok: true, terminals: [terminal(sourceB, 'htgt_bbbbbbbbbbbbbbbbbbbbbb')] }, + ]; + await collector.tick(); + let snapshot = collector.currentSnapshot().v2!; + assert.equal(snapshot.terminals.some((value) => value.lane === 'external' && value.terminal.runtime === 'herdr' && value.terminal.sourceId === sourceA), true); + assert.equal(snapshot.terminals.some((value) => value.lane === 'external' && value.terminal.runtime === 'herdr' && value.terminal.sourceId === sourceB), true); + let sources = snapshot.sourceLanes.filter((value) => value.runtime === 'herdr'); + assert.equal(sources.find((value) => value.sourceId === sourceA)?.coverage, 'retained'); + assert.equal(sources.find((value) => value.sourceId === sourceA)?.capabilities.discovery, false); + assert.equal(sources.find((value) => value.sourceId === sourceB)?.coverage, 'authoritative'); + + await collector.tick(); + snapshot = collector.currentSnapshot().v2!; + assert.equal(snapshot.terminals.some((value) => value.terminal.runtime === 'herdr' && value.terminal.sourceId === sourceA), false); + assert.equal(snapshot.terminals.some((value) => value.terminal.runtime === 'herdr' && value.terminal.sourceId === sourceB), true); + sources = snapshot.sourceLanes.filter((value) => value.runtime === 'herdr'); + assert.equal(sources.find((value) => value.sourceId === sourceA)?.consecutiveFailures, HERDR_UNAVAILABLE_GRACE_TICKS_EXTERNAL); + assert.equal(sources.find((value) => value.sourceId === sourceA)?.coverage, 'none'); + assert.equal(sources.find((value) => value.sourceId === sourceB)?.consecutiveFailures, 0); + assert.equal(JSON.stringify(collector.currentSnapshot().v2).includes('selector'), false); + assert.equal(JSON.stringify(collector.currentSnapshot().v2).includes('socket'), false); +}); +test('policy-disabled Herdr sources remove targets immediately without weakening tmux discovery', async () => { + const sourceId = 'hsrc_policy_disabled'; + const terminal = { + runtime: 'herdr' as const, sourceId, targetId: 'htgt_policy_disabled', targetClass: 'local-agent' as const, process: { pid: 20, startedAtMs: 200 }, + }; + let disabled = false; + const collector = createDiscoveryCollector({ + scanExternal: async () => ({ ok: true, sessions: [external] }), + scanLive: async () => ({ ok: true, sessions: [] }), + runtimeRegistry: { + sources: async () => [{ runtime: 'herdr' as const, sourceId, readiness: disabled ? 'disabled' as const : 'ready' as const }], + discoverOutcomes: async () => [{ runtime: 'herdr' as const, sourceId, ok: true, terminals: [terminal] }], + capabilities: () => ({ discovery: true, output: false, actions: false, attach: false, create: false }), + }, + }); + await collector.tick(); + disabled = true; + await collector.tick(); + + const snapshot = collector.currentSnapshot().v2!; + const source = snapshot.sourceLanes.find((value) => value.sourceId === sourceId)!; + assert.equal(snapshot.terminals.some((value) => value.terminal.runtime === 'herdr' && value.terminal.sourceId === sourceId), false); + assert.equal(source.coverage, 'none'); + assert.equal(source.capabilities.discovery, false); + assert.equal(source.consecutiveFailures, 0); + assert.equal(snapshot.tmuxRows?.some((row) => row.lane === 'external'), true); +}); diff --git a/server/modules/providers/tests/discovery-cost.test.ts b/server/modules/providers/tests/discovery-cost.test.ts index e7e9bcf..c218ff2 100644 --- a/server/modules/providers/tests/discovery-cost.test.ts +++ b/server/modules/providers/tests/discovery-cost.test.ts @@ -9,7 +9,6 @@ import { import { createExternalTurnMonitor } from '@/modules/notifications/index.js'; import { createDiscoveryCollector, - type DiscoveryRow, } from '@/modules/providers/services/discovery-collector.service.js'; import { createExternalCliSessionDiscovery, @@ -26,6 +25,8 @@ import { } from '@/modules/providers/services/live-sessions.service.js'; import { createDiscoveryStream } from '@/modules/websocket/index.js'; +import type { DiscoveryTerminal } from '../../../../shared/terminal-runtime.js'; + const T_MS = 20_000; const C_SCAN_MS = 1_000; const K_TOTAL_PER_S = 38; @@ -50,7 +51,7 @@ class FakeWebSocket { close(): void {} } -type Client = { ws: FakeWebSocket; rows: Map }; +type Client = { ws: FakeWebSocket; terminals: DiscoveryTerminal[] }; const EXTERNAL_TMUX = '/tmp/test\t$1\t@1\t%1\texternal-ssh\t101\tssh\t\t/work\t\t\n'; const LIVE_TMUX = '/tmp/test\t$1\t@1\t%2\tlive-empty\t102\tbash\t/work\n'; @@ -76,24 +77,8 @@ function createLiveRunner(): LiveGjcSessionCommandRunner { function applyFrames(client: Client): void { for (const frame of client.ws.sent.splice(0)) { - const event = JSON.parse(frame) as { kind?: string; rows?: DiscoveryRow[]; changes?: Array> }; - if (event.kind === 'discovery.snapshot') { - client.rows = new Map((event.rows ?? []).map((row) => [row.key, row])); - continue; - } - if (event.kind !== 'discovery.delta') continue; - for (const change of event.changes ?? []) { - if (change.op === 'added') { - const row = change.row as DiscoveryRow; - client.rows.set(row.key, row); - } else if (change.op === 'updated' && typeof change.key === 'string') { - const row = client.rows.get(change.key); - if (row) client.rows.set(change.key, { ...row, ...(change.patch as Partial) }); - } else if (change.op === 'stale' && typeof change.key === 'string') { - const row = client.rows.get(change.key); - if (row) client.rows.set(change.key, { ...row, presence: 'stale' }); - } else if (change.op === 'removed' && typeof change.key === 'string') client.rows.delete(change.key); - } + const event = JSON.parse(frame) as { kind?: string; discovery?: { terminals?: DiscoveryTerminal[] } }; + if (event.kind === 'discovery.v2.snapshot') client.terminals = event.discovery?.terminals ?? []; } } @@ -114,7 +99,7 @@ function assertCostBounds(counters: HostCommandCounters, subscribers: number): v async function runScenario(subscribers: number, subscriberPolling = false): Promise<{ counters: HostCommandCounters; clients: Client[]; - expected: readonly DiscoveryRow[]; + expected: readonly DiscoveryTerminal[]; monitorScannerCalls: number; monitorAdapterCalls: number; monitorTicks: number; @@ -135,8 +120,8 @@ async function runScenario(subscribers: number, subscriberPolling = false): Prom scanLive: () => live.getLiveGjcSessionsDetailed(), }); const stream = createDiscoveryStream(collector, () => now); - const clients = Array.from({ length: subscribers }, () => ({ ws: new FakeWebSocket(), rows: new Map() })); - for (const client of clients) stream.handle(client.ws as never, { type: 'discovery.subscribe', protocolVersion: 1 }); + const clients = Array.from({ length: subscribers }, () => ({ ws: new FakeWebSocket(), terminals: [] as DiscoveryTerminal[] })); + for (const client of clients) stream.handle(client.ws as never, { type: 'discovery.subscribe', protocolVersion: 2 }); const monitor = createExternalTurnMonitor({ getDetailed: async () => { monitorScannerCalls += 1; @@ -205,7 +190,7 @@ async function runScenario(subscribers: number, subscriberPolling = false): Prom const result = { counters: snapshotHostCommandCounters(), clients, - expected: collector.currentSnapshot().rows, + expected: collector.currentSnapshot().v2?.terminals ?? [], monitorScannerCalls, monitorAdapterCalls, monitorTicks, @@ -226,7 +211,7 @@ test('asserts A-G: production discovery scanners have constant host cost for 1, assert.equal(result.monitorAdapterCalls, 4, `assert E: turn monitor activity adapter was not run with N=${subscribers}`); assert.equal(result.monitorTicks, 4, `assert E: turn monitor was not run with N=${subscribers}`); for (const client of result.clients) { - assert.deepEqual([...client.rows.values()], result.expected, `assert F: subscriber missed latest snapshot/delta (N=${subscribers})`); + assert.deepEqual(client.terminals, result.expected, `assert F: subscriber missed latest v2 snapshot (N=${subscribers})`); } } for (const key of Object.keys(K_KEY_PER_S)) { @@ -283,8 +268,8 @@ test('I11: stream and REST-fallback authority remove a killed live row within T_ }] : [], transcriptPaths: new Map() }), }); const stream = createDiscoveryStream(collector, () => now); - const client: Client = { ws: new FakeWebSocket(), rows: new Map() }; - stream.handle(client.ws as never, { type: 'discovery.subscribe', protocolVersion: 1, lanes: ['live'] }); + const client: Client = { ws: new FakeWebSocket(), terminals: [] }; + stream.handle(client.ws as never, { type: 'discovery.subscribe', protocolVersion: 2, lanes: ['live'] }); await collector.tick(); applyFrames(client); livePresent = false; @@ -295,7 +280,7 @@ test('I11: stream and REST-fallback authority remove a killed live row within T_ await collector.tick(); applyFrames(client); const restFallbackRows = collector.currentSnapshot().rows.filter((row) => row.lane === 'live'); - if (client.rows.size === 0 && restFallbackRows.length === 0) { removedAt = now; break; } + if (client.terminals.length === 0 && restFallbackRows.length === 0) { removedAt = now; break; } } assert.notEqual(removedAt, null, 'I11: removed live row was never cleared'); assert.ok(removedAt! - killedAt <= T_RESIDUE_MS, `I11: residue ${removedAt! - killedAt}ms exceeded ${T_RESIDUE_MS}ms`); diff --git a/server/modules/providers/tests/pane-output-stream.service.test.ts b/server/modules/providers/tests/pane-output-stream.service.test.ts index 8a8f9ba..ac6d491 100644 --- a/server/modules/providers/tests/pane-output-stream.service.test.ts +++ b/server/modules/providers/tests/pane-output-stream.service.test.ts @@ -18,7 +18,7 @@ class FakeWebSocket extends EventEmitter { const tmux = { socketPath: '/tmp/tmux', sessionId: '$1', windowId: '@1', paneId: '%1' }; const process = { pid: 42, startedAtMs: 100 }; const target = createVerifiedTmuxActionTarget(tmux, process, 'claude', 'agent'); -const data = { protocolVersion: 1, lane: 'external', tmux, process }; +const data = { protocolVersion: 2, lane: 'external', target: { runtime: 'tmux' as const, tmux, process, targetClass: 'local-agent' as const } }; function events(ws: FakeWebSocket): Record[] { return ws.sent.map((frame) => JSON.parse(frame) as Record); } function matchingSnapshot(): DiscoverySnapshot { @@ -125,3 +125,47 @@ test('slow sockets retain only the newest pane output while fast fanout continue assert.equal(slowEvents[1]?.output, 'output-12'); stream.dispose(); }); +test('Herdr output uses only fresh pane.read and never acquires a controller', async () => { + const ws = new FakeWebSocket(); + let reads = 0; + const { stream, tick } = fixture({ + runtimeRegistry: { + read: async () => { reads += 1; return { ansi: `output-${reads}`, truncated: false }; }, + } as never, + }); + const herdr = { + protocolVersion: 2, + lane: 'external', + target: { + runtime: 'herdr' as const, + sourceId: 'hsrc_aaaaaaaaaaaaaaaaaaaaaa', + targetId: 'htgt_bbbbbbbbbbbbbbbbbbbbbb', + targetClass: 'local-agent' as const, + process, + }, + }; + stream.start(); + await stream.subscribe(ws as never, herdr); + await tick(); + assert.equal(reads, 2); + assert.equal(events(ws)[0]?.kind, 'pane.attached'); + stream.dispose(); +}); + +test('a stale or disabled Herdr target fails closed', async () => { + const ws = new FakeWebSocket(); + const { stream } = fixture({ runtimeRegistry: { read: async () => null } as never }); + await stream.subscribe(ws as never, { + protocolVersion: 2, + lane: 'external', + target: { + runtime: 'herdr', + sourceId: 'hsrc_aaaaaaaaaaaaaaaaaaaaaa', + targetId: 'htgt_bbbbbbbbbbbbbbbbbbbbbb', + targetClass: 'local-agent', + process, + }, + }); + assert.equal(events(ws)[0]?.reason, 'unauthorized'); + stream.dispose(); +}); diff --git a/server/modules/providers/tests/provider-routes-contract.test.ts b/server/modules/providers/tests/provider-routes-contract.test.ts index ad11738..3ea7618 100644 --- a/server/modules/providers/tests/provider-routes-contract.test.ts +++ b/server/modules/providers/tests/provider-routes-contract.test.ts @@ -72,6 +72,8 @@ const { authenticateToken, generateToken, AUTH_MODE } = await import('@/middlewa assert.equal(AUTH_MODE, 'password'); const { default: providerRoutes } = await import('../provider.routes.js'); const { getCurrentTmuxPaneIdentityState } = await import('../services/external-cli-sessions.service.js'); +const { RuntimeRegistryService } = await import('@/modules/terminal-runtimes/index.js'); +const { createTmuxRuntimeAdapter } = await import('../services/tmux-runtime-adapter.service.js'); const { initializeDatabase, sessionsDb, userDb } = await import('@/modules/database/index.js'); type ApiError = { error?: string; code?: string }; @@ -85,6 +87,13 @@ let validProcess: { pid: number; startedAtMs: number }; const externalTmux = { socketPath: '/tmp/chatmux-contract.sock', sessionId: '$1', windowId: '@1', paneId: '%1' }; const liveTmux = { socketPath: '/tmp/chatmux-contract.sock', sessionId: '$2', windowId: '@2', paneId: '%2' }; +const herdrTarget = { + runtime: 'herdr' as const, + sourceId: 'hsrc_abcdefghijklmnopqrstuv', + targetId: 'htgt_abcdefghijklmnopqrstuv', + targetClass: 'attach-only' as const, + admissionCapability: 'discovery-placeholder', +}; before(async () => { await initializeDatabase(); @@ -92,8 +101,22 @@ before(async () => { token = generateToken({ id: Number(created.id), username: created.username }); validProcess = { pid: process.pid, startedAtMs: (await stat(`/proc/${process.pid}`)).mtimeMs }; + const terminalRuntimeRegistry = new RuntimeRegistryService(); + terminalRuntimeRegistry.register(createTmuxRuntimeAdapter()); + terminalRuntimeRegistry.register({ + runtime: 'herdr', + sourceDescriptors: async () => [{ runtime: 'herdr', sourceId: herdrTarget.sourceId, readiness: 'ready' }], + capabilities: () => ({ discovery: true, output: true, actions: false, attach: true, create: false }), + discover: async () => [], + read: async () => ({ ansi: '\u001b[32mherdr output\u001b[0m', truncated: false }), + verify: async () => true, + }); app = express(); app.use(express.json()); + app.locals.terminalRuntimeRegistry = terminalRuntimeRegistry; + app.locals.herdrAdmissions = { + issue: () => 'principal-bound-admission', + }; // The live-spawn service reads TOWER_URL at request time. app.post('/spawn', (_req, res) => res.status(201).send('created')); app.use('/api/providers', authenticateToken); @@ -126,16 +149,25 @@ after(async () => { }); async function request(pathname: string, body?: unknown, authorization = `Bearer ${token}`): Promise { + const terminalOperation = /^\/sessions\/(external|live)\/(output|send|actions|kill)$/.test(pathname); + const legacy = body && typeof body === 'object' && !Array.isArray(body) ? body as Record : null; + const terminalBody = terminalOperation && legacy && 'tmux' in legacy && 'process' in legacy + ? { + apiVersion: 2, + target: { runtime: 'tmux', tmux: legacy.tmux, process: legacy.process, targetClass: 'local-agent' }, + ...Object.fromEntries(Object.entries(legacy).filter(([key]) => key !== 'tmux' && key !== 'process')), + } + : body; process.env.CHATMUX_CONTRACT_DISCOVERY_LANE = pathname.startsWith('/sessions/live') ? 'live' : 'external'; const response = await fetch(`${baseUrl}/api/providers${pathname}`, { - method: body === undefined ? 'GET' : 'POST', + method: terminalBody === undefined ? 'GET' : 'POST', headers: { - ...(body === undefined ? {} : { 'content-type': 'application/json' }), + ...(terminalBody === undefined ? {} : { 'content-type': 'application/json' }), ...(authorization ? { authorization } : {}), }, - ...(body === undefined ? {} : { body: JSON.stringify(body) }), + ...(terminalBody === undefined ? {} : { body: JSON.stringify(terminalBody) }), }); const responseText = await response.text(); let responseBody: ApiResponse['body']; @@ -585,6 +617,30 @@ test('unavailable roster responses retain authoritative stale snapshot rows whil } }); +test('terminal v2 rejects stale authority before a capture or action', async () => { + const response = await fetch(`${baseUrl}/api/providers/sessions/external/output`, { + method: 'POST', + headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' }, + body: JSON.stringify({ tmux: externalTmux, process: validProcess }), + }); + assert.equal(response.status, 409); + assert.equal((await response.json() as ApiError).code, 'CLIENT_RELOAD_REQUIRED'); +}); +test('Herdr v2 output and principal admission work only on the external lane', async () => { + const output = await request('/sessions/external/output', { apiVersion: 2, target: herdrTarget }); + assert.equal(output.status, 200); + assert.equal((output.body.data as { pane?: { ansi?: string } } | undefined)?.pane?.ansi, '\u001b[32mherdr output\u001b[0m'); + + const admission = await request('/sessions/external/admission', { apiVersion: 2, target: herdrTarget }); + assert.equal(admission.status, 200); + const admitted = (admission.body.data as { terminal?: Record } | undefined)?.terminal; + assert.equal(admitted?.runtime, 'herdr'); + assert.equal(admitted?.admissionCapability, 'principal-bound-admission'); + + const live = await request('/sessions/live/output', { apiVersion: 2, target: herdrTarget }); + assert.equal(live.status, 409); + assert.equal(live.body.code, 'TERMINAL_OPERATION_UNSUPPORTED'); +}); test('external output, send, and kill preserve their format-error contracts', async () => { assertError(await request('/sessions/external/output', { tmux: { paneId: 'bad' }, process: validProcess }), 400, 'INVALID_TMUX_PANE_IDENTITY'); assertError(await request('/sessions/external/send', { tmux: externalTmux, process: validProcess, message: ' ' }), 400, 'EMPTY_MESSAGE'); diff --git a/server/modules/terminal-runtimes/herdr-client.service.ts b/server/modules/terminal-runtimes/herdr-client.service.ts new file mode 100644 index 0000000..02ba019 --- /dev/null +++ b/server/modules/terminal-runtimes/herdr-client.service.ts @@ -0,0 +1,42 @@ +import { spawn, type ChildProcessWithoutNullStreams, type SpawnOptionsWithoutStdio } from 'node:child_process'; +import { StringDecoder } from 'node:string_decoder'; + +import type { HerdrConfiguredSource } from './herdr-config.service.js'; + +export const HERDR_LIMITS = { version: { timeoutMs: 5_000, stdoutBytes: 256 * 1024, stderrBytes: 64 * 1024 }, schema: { timeoutMs: 10_000, stdoutBytes: 8 * 1024 * 1024, stderrBytes: 64 * 1024 }, snapshot: { timeoutMs: 2_000, stdoutBytes: 4 * 1024 * 1024, stderrBytes: 64 * 1024 }, ndjsonLineBytes: 2 * 1024 * 1024, decodedFrameBytes: 1024 * 1024, inputBytes: 64 * 1024 } as const; +export type HerdrCommandResult = { code: number | null; stdout: string; stderr: string; timedOut: boolean; oversized: boolean; stderrOverflow?: boolean; spawnError: boolean }; +export type HerdrSpawn = (command: string, args: readonly string[], options: SpawnOptionsWithoutStdio) => ChildProcessWithoutNullStreams; +export type HerdrClock = { setTimeout: typeof setTimeout; clearTimeout: typeof clearTimeout }; +const PANE_ID_RE = /^[A-Za-z0-9_.:@-]{1,128}$/; +const KEY_RE = /^[A-Za-z0-9+_.@:-]{1,64}$/; +const spawnHerdr: HerdrSpawn = (command, args, options) => spawn(command, args, options) as ChildProcessWithoutNullStreams; + +export class HerdrClient { + constructor(private readonly runSpawn: HerdrSpawn = spawnHerdr, private readonly clock: HerdrClock = globalThis) {} + private run(source: HerdrConfiguredSource, args: string[], input: string | null, limits: { timeoutMs: number; stdoutBytes: number; stderrBytes: number }): Promise { + return new Promise((resolve) => { + let child: ChildProcessWithoutNullStreams; + try { child = this.runSpawn(source.binary, args, { shell: false, stdio: 'pipe', windowsHide: true }); } catch { resolve({ code: null, stdout: '', stderr: '', timedOut: false, oversized: false, spawnError: true }); return; } + let stdout = ''; let stderr = ''; let stdoutBytes = 0; let stderrBytes = 0; const stdoutDecoder = new StringDecoder('utf8'); const stderrChunks: Buffer[] = []; let stderrRetainedBytes = 0; let timedOut = false; let oversized = false; let stderrOverflow = false; let spawnError = false; let settled = false; + const finish = (code: number | null) => { if (settled) return; settled = true; this.clock.clearTimeout(timer); stdout += stdoutDecoder.end(); stderr = Buffer.concat(stderrChunks, stderrRetainedBytes).toString('utf8'); resolve({ code, stdout, stderr, timedOut, oversized, ...(stderrOverflow ? { stderrOverflow: true } : {}), spawnError }); }; + const stop = (overflow = false) => { oversized = true; stderrOverflow ||= overflow; child.kill('SIGKILL'); }; + const timer = this.clock.setTimeout(() => { timedOut = true; child.kill('SIGKILL'); }, limits.timeoutMs); + child.stdout.on('data', (chunk: Buffer) => { stdoutBytes += chunk.length; if (stdoutBytes > limits.stdoutBytes) { stop(); return; } stdout += stdoutDecoder.write(chunk); }); + child.stderr.on('data', (chunk: Buffer) => { stderrBytes += chunk.length; const retained = Math.min(chunk.length, limits.stderrBytes - stderrRetainedBytes); if (retained > 0) { stderrChunks.push(chunk.subarray(0, retained)); stderrRetainedBytes += retained; } if (stderrBytes > limits.stderrBytes) stop(true); }); + child.once('error', () => { spawnError = true; finish(null); }); child.once('close', finish); child.stdin.end(input ?? undefined); + }); + } + private paneId(value: string): string { if (!PANE_ID_RE.test(value)) throw new Error('invalid_pane_id'); return value; } + version(source: HerdrConfiguredSource): Promise { return this.run(source, ['--version'], null, HERDR_LIMITS.version); } + schema(source: HerdrConfiguredSource): Promise { return this.run(source, ['api', 'schema', '--json'], null, HERDR_LIMITS.schema); } + status(source: HerdrConfiguredSource): Promise { return this.run(source, ['--session', source.selector, 'status', 'server'], null, HERDR_LIMITS.version); } + sessionList(source: HerdrConfiguredSource): Promise { return this.run(source, ['session', 'list', '--json'], null, HERDR_LIMITS.snapshot); } + snapshot(source: HerdrConfiguredSource): Promise { return this.run(source, ['--session', source.selector, 'api', 'snapshot'], null, HERDR_LIMITS.snapshot); } + paneGet(source: HerdrConfiguredSource, paneId: string): Promise { return this.run(source, ['--session', source.selector, 'pane', 'get', this.paneId(paneId)], null, HERDR_LIMITS.snapshot); } + paneRead(source: HerdrConfiguredSource, paneId: string): Promise { return this.run(source, ['--session', source.selector, 'pane', 'read', this.paneId(paneId), '--source', 'visible', '--ansi'], null, HERDR_LIMITS.snapshot); } + paneProcessInfo(source: HerdrConfiguredSource, paneId: string): Promise { return this.run(source, ['--session', source.selector, 'pane', 'process-info', '--pane', this.paneId(paneId)], null, HERDR_LIMITS.snapshot); } + paneSendText(source: HerdrConfiguredSource, paneId: string, text: string): Promise { if (!text || text.includes('\0') || Buffer.byteLength(text, 'utf8') > HERDR_LIMITS.inputBytes) throw new Error('invalid_input'); return this.run(source, ['--session', source.selector, 'pane', 'send-text', this.paneId(paneId), text], null, HERDR_LIMITS.snapshot); } + paneSendKeys(source: HerdrConfiguredSource, paneId: string, keys: readonly string[]): Promise { if (!keys.length || keys.length > 64 || keys.some((key) => !KEY_RE.test(key))) throw new Error('invalid_keys'); return this.run(source, ['--session', source.selector, 'pane', 'send-keys', this.paneId(paneId), ...keys], null, HERDR_LIMITS.snapshot); } + controllerArgv(source: HerdrConfiguredSource, paneId: string, cols: number, rows: number): string[] { if (!Number.isSafeInteger(cols) || !Number.isSafeInteger(rows) || cols < 1 || cols > 1000 || rows < 1 || rows > 1000) throw new Error('invalid_controller_target'); return ['--session', source.selector, 'terminal', 'session', 'control', this.paneId(paneId), '--cols', String(cols), '--rows', String(rows)]; } + async ndjson(result: HerdrCommandResult): Promise { if (result.code !== 0 || result.timedOut || result.oversized || result.stderrOverflow || result.spawnError) return null; const lines = result.stdout.split('\n'); if (lines.at(-1) === '') lines.pop(); if (lines.some((line) => Buffer.byteLength(line, 'utf8') > HERDR_LIMITS.ndjsonLineBytes)) return null; try { return lines.map((line) => JSON.parse(line)); } catch { return null; } } +} diff --git a/server/modules/terminal-runtimes/herdr-config.service.ts b/server/modules/terminal-runtimes/herdr-config.service.ts new file mode 100644 index 0000000..eed90c3 --- /dev/null +++ b/server/modules/terminal-runtimes/herdr-config.service.ts @@ -0,0 +1,55 @@ +import { createHash } from 'node:crypto'; +import { isAbsolute } from 'node:path'; + +import type { RuntimeCapabilities } from '../../../shared/terminal-runtime.js'; + +import type { HerdrSourceId } from './herdr-internal.types.js'; + +export const HERDR_RUNTIME_ENV = 'CHATMUX_HERDR_RUNTIME'; +export const HERDR_SOURCES_ENV = 'CHATMUX_HERDR_SOURCES'; +export const HERDR_CAPABILITIES_ENV = 'CHATMUX_HERDR_CAPABILITIES'; +export const HERDR_POLICY_FILE_ENV = 'CHATMUX_HERDR_POLICY_FILE'; +export const HERDR_MAX_SOURCES = 8; +const ALIAS_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/; +const SELECTOR_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/; +const CAPABILITIES = ['discovery', 'output', 'actions', 'attach'] as const; +export type HerdrConfiguredSource = { alias: string; sourceId: HerdrSourceId; selector: string; binary: string }; +export type HerdrRuntimeConfig = { enabled: boolean; sources: readonly HerdrConfiguredSource[]; startupCapabilities: RuntimeCapabilities; policyPath: string | null; errorCode: string | null }; + +function disabled(errorCode: string | null): HerdrRuntimeConfig { + return { enabled: false, sources: [], startupCapabilities: { discovery: false, output: false, actions: false, attach: false, create: false }, policyPath: null, errorCode }; +} +export function herdrSourceId(alias: string): HerdrSourceId { + return `hsrc_${createHash('sha256').update(alias, 'utf8').digest().subarray(0, 16).toString('base64url')}`; +} +function parseCapabilities(raw: string | undefined): RuntimeCapabilities | null { + const result: Record<'discovery' | 'output' | 'actions' | 'attach' | 'create', boolean> = { discovery: false, output: false, actions: false, attach: false, create: false }; + if (!raw) return result; + const values = raw.split(','); + if (values.some((value) => !CAPABILITIES.includes(value as typeof CAPABILITIES[number])) || new Set(values).size !== values.length) return null; + for (const value of values) result[value as typeof CAPABILITIES[number]] = true; + return result; +} +export function readHerdrRuntimeConfig(env: NodeJS.ProcessEnv = process.env, platform = process.platform, arch = process.arch): HerdrRuntimeConfig { + if (env[HERDR_RUNTIME_ENV] !== '1') return disabled(null); + if (platform !== 'linux' || arch !== 'x64') return disabled('platform_unsupported'); + const capabilities = parseCapabilities(env[HERDR_CAPABILITIES_ENV]); + if (!capabilities) return disabled('invalid_capabilities'); + const policyPath = env[HERDR_POLICY_FILE_ENV]; + if (policyPath !== undefined && (!isAbsolute(policyPath) || policyPath.includes('\0') || policyPath.length > 4096)) return disabled('invalid_policy_path'); + const raw = env[HERDR_SOURCES_ENV]; + if (!raw || raw.length > 16 * 1024) return disabled('invalid_sources'); + let parsed: unknown; + try { parsed = JSON.parse(raw); } catch { return disabled('invalid_sources'); } + if (!Array.isArray(parsed) || parsed.length === 0 || parsed.length > HERDR_MAX_SOURCES) return disabled('invalid_sources'); + const aliases = new Set(); + const sources: HerdrConfiguredSource[] = []; + for (const entry of parsed) { + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return disabled('invalid_sources'); + const value = entry as { alias?: unknown; selector?: unknown; binary?: unknown }; + if (Object.keys(value).length !== 3 || !['alias', 'selector', 'binary'].every((key) => Object.prototype.hasOwnProperty.call(value, key)) || typeof value.alias !== 'string' || !ALIAS_RE.test(value.alias) || aliases.has(value.alias) || typeof value.selector !== 'string' || !SELECTOR_RE.test(value.selector) || value.selector === 'default' || typeof value.binary !== 'string' || !isAbsolute(value.binary) || value.binary.includes('\0') || value.binary.length > 4096) return disabled('invalid_sources'); + aliases.add(value.alias); + sources.push({ alias: value.alias, sourceId: herdrSourceId(value.alias), selector: value.selector, binary: value.binary }); + } + return { enabled: true, sources, startupCapabilities: capabilities, policyPath: policyPath ?? null, errorCode: null }; +} diff --git a/server/modules/terminal-runtimes/herdr-control-bridge.service.ts b/server/modules/terminal-runtimes/herdr-control-bridge.service.ts new file mode 100644 index 0000000..d6fa58e --- /dev/null +++ b/server/modules/terminal-runtimes/herdr-control-bridge.service.ts @@ -0,0 +1,176 @@ +import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; +import { randomBytes } from 'node:crypto'; + +import type { PublicTerminalRef, PublicTerminalTarget, ShellV3ServerMessage } from '../../../shared/terminal-runtime.js'; + +import type { RuntimeRegistryService } from './runtime-registry.service.js'; + +const ADMISSION_TTL_MS = 60_000; +const ADMISSION_LIMIT = 1024; +const OBSERVE_INTERVAL_MS = 2_000; + +type HerdrTarget = Extract; +type HerdrRef = Extract; +type Clock = () => number; + +type Admission = { principal: string; sourceId: string; targetId: string; expiresAt: number }; + +/** Single-use, principal-bound attach admission. Tokens are consumed before a controller lease is requested. */ +export class HerdrAdmissionService { + private readonly admissions = new Map(); + + constructor(private readonly now: Clock = Date.now, private readonly ttlMs = ADMISSION_TTL_MS) {} + + grant(token: string, principal: string, target: HerdrTarget): boolean { + this.sweep(); + if (!token || token.length > 4096 || !principal || target.targetClass !== 'attach-only' || this.admissions.has(token) || this.admissions.size >= ADMISSION_LIMIT) return false; + this.admissions.set(token, { principal, sourceId: target.sourceId, targetId: target.targetId, expiresAt: this.now() + this.ttlMs }); + return true; + } + + issue(principal: string, target: HerdrTarget): string | null { + if (!principal || target.targetClass !== 'attach-only') return null; + const token = randomBytes(32).toString('base64url'); + return this.grant(token, principal, target) ? token : null; + } + + consume(token: string | undefined, principal: string, target: HerdrTarget): boolean { + this.sweep(); + if (!token) return false; + const admission = this.admissions.get(token); + this.admissions.delete(token); + return !!admission && admission.principal === principal && admission.sourceId === target.sourceId && admission.targetId === target.targetId && admission.expiresAt > this.now(); + } + + private sweep(): void { + for (const [token, admission] of this.admissions) if (admission.expiresAt <= this.now()) this.admissions.delete(token); + } +} + +export type HerdrControlBridge = { + acquireController(request: { target: HerdrTarget; principal: string; admissionCapability?: string; cols: number; rows: number }): Promise<{ command: string; args: string[]; release: () => Promise; onRevoke: (callback: () => void | Promise) => () => void; assertWriteAllowed: () => boolean; assertFreshIdentity: () => Promise } | null>; + observe(request: { target: HerdrTarget; principal: string; emitFrame: (frame: ShellV3ServerMessage) => void }): Promise<{ release: () => Promise; onRevoke: (callback: () => void | Promise) => () => void; assertWriteAllowed: () => boolean; assertFreshIdentity: () => Promise } | null>; + releaseAll(): Promise; + dispose(): Promise; +}; + +function ref(target: HerdrTarget): HerdrRef { + return { runtime: 'herdr', sourceId: target.sourceId, targetId: target.targetId }; +} + +function encode(text: string): string { + return Buffer.from(text, 'utf8').toString('base64'); +} + +/** + * Terminal-runtime-only bridge for the websocket shell boundary. Every read is + * delegated to the registry, so cached discovery can never authorize a lease. + */ +export class HerdrControlBridgeService implements HerdrControlBridge { + constructor(private readonly runtimes: RuntimeRegistryService, private readonly admissions: HerdrAdmissionService) {} + private readonly activeReleases = new Set<() => Promise>(); + private revocationGeneration = 0; + + async releaseAll(): Promise { + this.revocationGeneration += 1; + await Promise.all([...this.activeReleases].map((release) => release())); + } + + dispose(): Promise { + return this.releaseAll(); + } + + private trackedRelease(release: () => void): { release: () => Promise; onRevoke: (callback: () => void | Promise) => () => void } { + let released = false; + let completion: Promise | null = null; + let revoke: (() => void | Promise) | null = null; + const tracked = (): Promise => { + if (completion) return completion; + released = true; + const callback = revoke; + revoke = null; + completion = (async () => { + try { + release(); + } catch { + // The revocation callback remains responsible for terminal teardown. + } + try { + await callback?.(); + } catch { + // Lease release remains complete even when websocket teardown fails. + } finally { + this.activeReleases.delete(tracked); + } + })(); + return completion; + }; + this.activeReleases.add(tracked); + return { + release: tracked, + onRevoke: (callback) => { + if (released) { + void Promise.resolve(callback()).catch(() => {}); + return () => {}; + } + revoke = callback; + return () => { + if (revoke === callback) revoke = null; + }; + }, + }; + } + + private async trustedProfile(target: HerdrTarget): Promise<{ targetClass: HerdrTarget['targetClass']; process: { pid: number; startedAtMs: number } | null } | null> { + const profile = await this.runtimes.targetProfile(ref(target)); + if (!profile || profile.targetClass !== target.targetClass) return null; + if (profile.targetClass === 'attach-only') return profile; + return profile.process + && target.targetClass === 'local-agent' + && target.process.pid === profile.process.pid + && target.process.startedAtMs === profile.process.startedAtMs + ? profile + : null; + } + + private async fresh(target: HerdrTarget, operation: 'output' | 'attach'): Promise { + return this.runtimes.verify(ref(target), operation); + } + + async acquireController(request: Parameters[0]): Promise>> + { + const profile = await this.trustedProfile(request.target); + if (!profile) return null; + if (profile.targetClass === 'attach-only' && !this.admissions.consume(request.admissionCapability, request.principal, request.target)) return null; + // Fresh validation occurs before adapter acquisition; controllerArgv repeats it atomically while reserving its no-takeover lease. + if (!await this.fresh(request.target, 'attach')) return null; + const generation = this.revocationGeneration; + const controller = await this.runtimes.controllerArgv(ref(request.target), request.cols, request.rows); + if (!controller) return null; + return { command: controller.command, args: controller.args, ...this.trackedRelease(controller.release), assertWriteAllowed: () => generation === this.revocationGeneration, assertFreshIdentity: async () => generation === this.revocationGeneration && this.fresh(request.target, 'attach') }; + } + + async observe(request: Parameters[0]): Promise>> + { + if (!await this.trustedProfile(request.target)) return null; + let released = false; + let sequence = 0; + const poll = async () => { + if (released) return; + const output = await this.runtimes.read(ref(request.target)); + if (!output) return; + sequence += 1; + request.emitFrame({ type: 'terminal.frame', seq: sequence, encoding: 'ansi', width: 80, height: 24, full: true, bytes: encode(output.ansi) }); + }; + if (!await this.fresh(request.target, 'output')) return null; + const generation = this.revocationGeneration; + await poll(); + const interval = setInterval(() => { void poll(); }, OBSERVE_INTERVAL_MS); + return { ...this.trackedRelease(() => { released = true; clearInterval(interval); }), assertWriteAllowed: () => generation === this.revocationGeneration, assertFreshIdentity: async () => generation === this.revocationGeneration && this.fresh(request.target, 'output') }; + } +} + +/** Verified child-process launcher for the adapter-provided external controller argv. */ +export function spawnHerdrController(command: string, args: string[]): ChildProcessWithoutNullStreams { + return spawn(command, args, { stdio: ['pipe', 'pipe', 'pipe'], shell: false, windowsHide: true }); +} diff --git a/server/modules/terminal-runtimes/herdr-internal.types.ts b/server/modules/terminal-runtimes/herdr-internal.types.ts new file mode 100644 index 0000000..48d18da --- /dev/null +++ b/server/modules/terminal-runtimes/herdr-internal.types.ts @@ -0,0 +1,27 @@ +import type { ChildProcess } from 'node:child_process'; + +import type { ProcessGeneration, RuntimeCapabilities, TerminalTargetClass } from '../../../shared/terminal-runtime.js'; + +export type HerdrSourceId = `hsrc_${string}`; +export type HerdrTargetId = `htgt_${string}`; +export type HerdrTransport = 'herdr terminal session control'; +export type HerdrSourceSelector = string; +export type HerdrSocketStat = { uid: number; mode: number; device: number; inode: number }; +export type HerdrResolvedSource = { + sourceId: HerdrSourceId; alias: string; binary: string; selector: HerdrSourceSelector; canonicalSocketPath: string; + socketStat: HerdrSocketStat; serverIncarnation: string; probeFingerprint: string; internalGeneration: number; transport: HerdrTransport; +}; +export type HerdrHierarchy = { workspaceId: string; tabId: string; paneId: string }; +export type HerdrAgentEvidence = { agentId: string; agentKind: string }; +export type HerdrProcessEvidence = ProcessGeneration & { foregroundProcessGroupId: number | null; executableName: string }; +export type HerdrResolvedTerminal = { + source: HerdrResolvedSource; hierarchy: HerdrHierarchy; terminalId: string; terminalIncarnation: string; terminalRevision: number; + agent: HerdrAgentEvidence | null; process: HerdrProcessEvidence | null; targetClass: TerminalTargetClass; +}; +declare const verifiedHerdrTarget: unique symbol; +export type VerifiedHerdrTarget = HerdrResolvedTerminal & { readonly [verifiedHerdrTarget]: 'verified'; operation: 'output' | 'actions' | 'attach' }; +export type HerdrTargetGeneration = { targetId: HerdrTargetId; terminalId: string; terminalIncarnation: string; hierarchy: HerdrHierarchy; sourceGeneration: number; expiresAtMs: number }; +export type HerdrControllerState = { + sourceId: HerdrSourceId; terminalId: string; child: ChildProcess; internalToken: string | null; epoch: number | null; frameCursor: number; + bridgeGeneration: number; startedAtMs: number; capabilities: RuntimeCapabilities; state: 'establishing' | 'active' | 'blocked' | 'released' | 'closed'; +}; diff --git a/server/modules/terminal-runtimes/herdr-probe.service.ts b/server/modules/terminal-runtimes/herdr-probe.service.ts new file mode 100644 index 0000000..31ee66b --- /dev/null +++ b/server/modules/terminal-runtimes/herdr-probe.service.ts @@ -0,0 +1,58 @@ +import { createHash } from 'node:crypto'; + +import type { RuntimeCapabilities, SourceReadiness } from '../../../shared/terminal-runtime.js'; + +import type { HerdrConfiguredSource } from './herdr-config.service.js'; +import { HERDR_LIMITS, HerdrClient } from './herdr-client.service.js'; + +export const HERDR_VERSION = '0.7.5'; +export const HERDR_PROTOCOL = 17; +export const HERDR_SEMANTIC_FINGERPRINT = '4d7b299bb4b7924c3fb0d0251a303ac3c92ed77bdc8ba1dac4b4bbed3fa51583'; +const REQUIRED_METHODS = ['agent.list', 'events.subscribe', 'pane.get', 'pane.list', 'pane.move', 'pane.process_info', 'pane.read', 'pane.send_keys', 'pane.send_text', 'session.snapshot']; +const METHOD_RESULT_TAGS = { 'agent.list': 'agent_list', 'events.subscribe': 'subscription_started', 'pane.get': 'pane_info', 'pane.list': 'pane_list', 'pane.move': 'pane_move', 'pane.process_info': 'pane_process_info', 'pane.read': 'pane_read', 'pane.send_keys': 'ok', 'pane.send_text': 'ok', 'session.snapshot': 'session_snapshot' } as const; +const CONTROL_SEMANTICS = { transport: { executable: 'verified absolute path', argv: ['--session', '', 'terminal', 'session', 'control', '', '--cols', '<1..1000>', '--rows', '<1..1000>'], stdin: 'newline-delimited JSON', stdout: 'newline-delimited JSON', shell: false, pty: false, takeover: false }, inputVariants: [{ type: { const: 'terminal.input' }, text: { type: 'string' }, exclusiveWith: 'bytes' }, { type: { const: 'terminal.input' }, bytes: { type: 'string', encoding: 'base64' }, exclusiveWith: 'text' }], resize: { type: { const: 'terminal.resize' }, cols: { type: 'integer', minimum: 1 }, rows: { type: 'integer', minimum: 1 } }, scroll: { type: { const: 'terminal.scroll' }, direction: { enum: ['up', 'down'] }, lines: { type: 'integer', minimum: 1 } }, release: { type: { const: 'terminal.release' }, effect: 'controller exits and no-takeover reacquisition succeeds' }, frame: { required: ['type', 'seq', 'encoding', 'width', 'height', 'full', 'bytes'], properties: { type: { const: 'terminal.frame' }, seq: { type: 'integer', ordering: 'strictly contiguous within one controller' }, encoding: { const: 'ansi' }, width: { type: 'integer', minimum: 1 }, height: { type: 'integer', minimum: 1 }, full: { type: 'boolean' }, bytes: { type: 'string', encoding: 'base64' } } }, closed: { required: ['type', 'reason'], properties: { type: { const: 'terminal.closed' }, reason: { type: 'string' } } }, busy: { record: 'terminal.closed', reasonContains: 'already has an attached client', processExitCodeAuthoritative: false }, sourceSelection: { namedSessionRequired: true, missingSnapshotFails: true, missingStatusNotRunning: true, missingControlFails: true, postControlSocketAbsent: true } } as const; +type JsonObject = Record; +export type HerdrProbeResult = { sourceId: string; readiness: SourceReadiness; version: string | null; semanticFingerprint: string | null; transport: 'herdr terminal session control' | null; capabilities: RuntimeCapabilities; reasonCode: string | null }; +export type HerdrCompatibility = Readonly<{ readiness: SourceReadiness; version: string | null; semanticFingerprint: string | null; capabilities: RuntimeCapabilities; reasonCode: string | null }>; + +function noCapabilities(): RuntimeCapabilities { return { discovery: false, output: false, actions: false, attach: false, create: false }; } +function result(source: HerdrConfiguredSource, readiness: SourceReadiness, reasonCode: string | null, capabilities = noCapabilities(), version: string | null = null, fingerprint: string | null = null): HerdrProbeResult { return { sourceId: source.sourceId, readiness, version, semanticFingerprint: fingerprint, transport: readiness === 'ready' || readiness === 'degraded' ? 'herdr terminal session control' : null, capabilities, reasonCode }; } +export function canonicalJson(value: unknown): string { if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`; if (value && typeof value === 'object') { const object = value as JsonObject; return `{${Object.keys(object).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(object[key])}`).join(',')}}`; } return JSON.stringify(value); } +export function canonicalJsonSha256(value: unknown): string { return createHash('sha256').update(canonicalJson(value), 'utf8').digest('hex'); } +function refs(value: unknown): { schemaName: string; definitionName: string }[] { return [...canonicalJson(value).matchAll(/#\/schemas\/([^/]+)\/\$defs\/([^"]+)/g)].map((match) => ({ schemaName: match[1], definitionName: match[2] })); } +function object(value: unknown): JsonObject { if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('schema_shape'); return value as JsonObject; } +function definitionClosure(schemas: JsonObject, retained: unknown[]): JsonObject { + const selected: JsonObject = {}; const pending = retained.flatMap(refs); + while (pending.length) { const ref = pending.shift()!; const namespace = selected[ref.schemaName] ??= {}; const definitions = object(namespace); if (definitions[ref.definitionName]) continue; const sourceSchema = object(schemas[ref.schemaName]); const definition = object(sourceSchema.$defs)[ref.definitionName]; if (definition === undefined) throw new Error('schema_reference'); definitions[ref.definitionName] = definition; pending.push(...refs(definition)); } + return Object.fromEntries(Object.entries(selected).sort(([a], [b]) => a.localeCompare(b)).map(([name, definitions]) => [name, Object.fromEntries(Object.entries(object(definitions)).sort(([a], [b]) => a.localeCompare(b)))])); +} +function assertClosure(manifest: JsonObject): void { const definitions = object(object(manifest.api).definitions); for (const ref of refs(manifest)) if (object(definitions[ref.schemaName])[ref.definitionName] === undefined) throw new Error('schema_reference'); } +export function selectedHerdrSemanticManifest(schema: unknown): JsonObject | null { + try { + const root = object(schema); const schemas = object(root.schemas); const requestSchema = object(schemas.request); const requestVariants = requestSchema.oneOf; if (!Array.isArray(requestVariants)) throw new Error('schema_request'); + const requests = REQUIRED_METHODS.map((method) => { const request = requestVariants.find((value) => object(object(value).properties).method && object(object(object(value).properties).method).const === method); if (!request) throw new Error('schema_method'); const item = object(request); return { method, required: [...(Array.isArray(item.required) ? item.required : [])].sort(), params: object(item.properties).params ?? null }; }); + const successSchema = object(schemas.success_response); const resultVariants = Object.values(METHOD_RESULT_TAGS).map((tag) => { const variants = object(object(successSchema.$defs).ResponseResult).oneOf; if (!Array.isArray(variants)) throw new Error('schema_result'); const variant = variants.find((value) => object(object(value).properties).type && object(object(object(value).properties).type).const === tag); if (!variant) throw new Error('schema_result'); return variant; }); + const withoutDefinitions = (value: unknown) => Object.fromEntries(Object.entries(object(value)).filter(([key]) => key !== '$defs')); + const successEnvelope = withoutDefinitions(successSchema); object(successEnvelope.properties).result = { oneOf: resultVariants }; + const errorEnvelope = withoutDefinitions(schemas.error_response); const eventEnvelope = withoutDefinitions(schemas.event); const subscriptionEventEnvelope = withoutDefinitions(schemas.subscription_event); + const retained = [...requests.map((request) => request.params), ...resultVariants, successEnvelope, errorEnvelope, eventEnvelope, subscriptionEventEnvelope]; + const manifest: JsonObject = { version: 3, protocol: HERDR_PROTOCOL, schemaVersion: root.schema_version, api: { requests, methodResultTags: METHOD_RESULT_TAGS, successEnvelope, selectedResultVariants: resultVariants, errorEnvelope, eventEnvelope, subscriptionEventEnvelope, definitions: definitionClosure(schemas, retained) }, control: CONTROL_SEMANTICS }; + assertClosure(manifest); return manifest; + } catch { return null; } +} +export function semanticFingerprint(schema: unknown): string | null { const manifest = selectedHerdrSemanticManifest(schema); return manifest ? canonicalJsonSha256(manifest) : null; } +export async function probeHerdrStaticCompatibility(source: HerdrConfiguredSource, startupCapabilities: RuntimeCapabilities, client: HerdrClient, platform = process.platform, arch = process.arch): Promise { + if (platform !== 'linux' || arch !== 'x64') return { readiness: 'platform_unsupported', version: null, semanticFingerprint: null, capabilities: noCapabilities(), reasonCode: 'platform_unsupported' }; + const version = await client.version(source); if (version.spawnError) return { readiness: 'missing', version: null, semanticFingerprint: null, capabilities: noCapabilities(), reasonCode: 'binary_unavailable' }; if (version.timedOut || version.oversized || version.stderrOverflow || version.code !== 0) return { readiness: 'missing', version: null, semanticFingerprint: null, capabilities: noCapabilities(), reasonCode: 'version_unavailable' }; if (version.stdout.trim() !== `herdr ${HERDR_VERSION}`) return { readiness: 'incompatible', version: null, semanticFingerprint: null, capabilities: noCapabilities(), reasonCode: 'version_unsupported' }; + const schemaResult = await client.schema(source); if (schemaResult.spawnError || schemaResult.code !== 0 || schemaResult.timedOut || schemaResult.oversized || schemaResult.stderrOverflow || Buffer.byteLength(schemaResult.stderr, 'utf8') > HERDR_LIMITS.schema.stderrBytes) return { readiness: 'incompatible', version: null, semanticFingerprint: null, capabilities: noCapabilities(), reasonCode: 'schema_unavailable' }; + let schema: unknown; try { schema = JSON.parse(schemaResult.stdout); } catch { return { readiness: 'incompatible', version: null, semanticFingerprint: null, capabilities: noCapabilities(), reasonCode: 'schema_invalid' }; } + const fingerprint = semanticFingerprint(schema); if (fingerprint !== HERDR_SEMANTIC_FINGERPRINT) return { readiness: 'incompatible', version: null, semanticFingerprint: null, capabilities: noCapabilities(), reasonCode: 'schema_semantics_unreviewed' }; + return { readiness: 'ready', version: HERDR_VERSION, semanticFingerprint: fingerprint, capabilities: { ...startupCapabilities, create: false }, reasonCode: null }; +} +export async function probeHerdrCompatibility(source: HerdrConfiguredSource, startupCapabilities: RuntimeCapabilities, client: HerdrClient, platform = process.platform, arch = process.arch, compatibility?: HerdrCompatibility): Promise { + const checked = compatibility ?? await probeHerdrStaticCompatibility(source, startupCapabilities, client, platform, arch); + if (checked.readiness !== 'ready') return result(source, checked.readiness, checked.reasonCode); + const status = await client.status(source); if (status.spawnError || status.timedOut || status.oversized || status.stderrOverflow || status.code !== 0) return result(source, 'offline', 'source_offline', noCapabilities(), checked.version, checked.semanticFingerprint); + const snapshot = await client.snapshot(source); if (snapshot.spawnError || snapshot.timedOut || snapshot.oversized || snapshot.stderrOverflow || snapshot.code !== 0) return result(source, 'offline', 'snapshot_unavailable', noCapabilities(), checked.version, checked.semanticFingerprint); + return result(source, 'ready', null, checked.capabilities, checked.version, checked.semanticFingerprint); +} diff --git a/server/modules/terminal-runtimes/herdr-runtime-adapter.service.ts b/server/modules/terminal-runtimes/herdr-runtime-adapter.service.ts new file mode 100644 index 0000000..cb65176 --- /dev/null +++ b/server/modules/terminal-runtimes/herdr-runtime-adapter.service.ts @@ -0,0 +1,229 @@ +import { lstat, readFile, readdir, readlink, realpath } from 'node:fs/promises'; +import type { Stats } from 'node:fs'; + +import type { ProcessGeneration, PublicTerminalRef, PublicTerminalTarget, RuntimeCapabilities, RuntimeCapability, SourceDescriptor } from '../../../shared/terminal-runtime.js'; +import { readPublicTerminalRef } from '../../../shared/terminal-runtime.js'; + +import { HerdrClient, HERDR_LIMITS } from './herdr-client.service.js'; +import type { HerdrConfiguredSource, HerdrRuntimeConfig } from './herdr-config.service.js'; +import type { HerdrResolvedSource, HerdrResolvedTerminal, HerdrSourceId, HerdrTargetId } from './herdr-internal.types.js'; +import { HERDR_SEMANTIC_FINGERPRINT, probeHerdrCompatibility, probeHerdrStaticCompatibility, type HerdrCompatibility } from './herdr-probe.service.js'; +import { HerdrTargetRegistry } from './herdr-target-registry.service.js'; +import { RuntimeOperationPolicyService } from './runtime-operation-policy.service.js'; +import type { RuntimeDiscoveryOutcome, RuntimeTargetProfile } from './runtime-registry.service.js'; +import { ProtectedTerminalTargetService, type ProtectedTerminalTargetEvidence } from './protected-terminal-target.service.js'; + +const MAX_TARGETS_PER_SOURCE = 256; +const MAX_CONTROLLERS_PER_SOURCE = 4; +const MAX_CONTROLLERS_GLOBAL = 32; +export const HERDR_SOCKET_OWNER_LIMITS = { deadlineMs: 1_000, processes: 8_192, descriptors: 4_096 } as const; +type Admission = (sourceId: HerdrSourceId, paneId: string) => string | null; +type ProcessResolver = (pid: number) => Promise; +type SocketOwnerResolver = (socketPath: string, binaryPath: string) => Promise; +type Fs = { lstat(path: string): Promise; realpath(path: string): Promise }; +type AgentStatus = 'idle' | 'working' | 'blocked' | 'done' | 'unknown'; +type AgentInfo = { agent: string | null; displayAgent: string | null; agentStatus: AgentStatus }; +type Pane = { paneId: string; terminalId: string; terminalIncarnation: string; workspaceId: string; tabId: string; revision: number; agent: AgentInfo | null }; +type Endpoint = { source: HerdrResolvedSource; generation: number; compatibilityKey: string }; +type Controller = { command: string; args: string[]; release: () => void }; +function record(value: unknown): Record | null { return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : null; } +function id(value: unknown): string | null { return typeof value === 'string' && /^[A-Za-z0-9_.:-]{1,128}$/.test(value) ? value : null; } +function parsed(result: { code: number | null; stdout: string; timedOut: boolean; oversized: boolean; stderrOverflow?: boolean; spawnError: boolean }): unknown | null { if (result.code !== 0 || result.timedOut || result.oversized || result.stderrOverflow || result.spawnError || Buffer.byteLength(result.stdout, 'utf8') > HERDR_LIMITS.snapshot.stdoutBytes) return null; try { return JSON.parse(result.stdout); } catch { return null; } } +function result(value: unknown, type: string): Record | null { const outer = record(value); const inner = record(outer?.result); return inner?.type === type ? inner : null; } +function agentInfo(value: unknown): AgentInfo | null { + const valueRecord = record(value); + const rawAgent = valueRecord?.agent; + const rawDisplayAgent = valueRecord?.display_agent; + const agent = rawAgent === undefined ? null : rawAgent; + const displayAgent = rawDisplayAgent === undefined ? null : rawDisplayAgent; + const agentStatus = valueRecord?.agent_status; + return (typeof agent === 'string' || agent === null) + && (typeof displayAgent === 'string' || displayAgent === null) + && (agentStatus === 'idle' || agentStatus === 'working' || agentStatus === 'blocked' || agentStatus === 'done' || agentStatus === 'unknown') + ? { agent, displayAgent, agentStatus } + : null; +} +function pane(value: unknown, agents: Map): Pane | null { + const valueRecord = record(value); if (!valueRecord) return null; + const paneId = id(valueRecord.pane_id); const terminalId = id(valueRecord.terminal_id); const terminalIncarnation = id(valueRecord.terminal_incarnation) ?? terminalId; const workspaceId = id(valueRecord.workspace_id); const tabId = id(valueRecord.tab_id); + if (!paneId || !terminalId || !terminalIncarnation || !workspaceId || !tabId || !Number.isSafeInteger(valueRecord.revision) || !agentInfo(valueRecord)) return null; + return { paneId, terminalId, terminalIncarnation, workspaceId, tabId, revision: valueRecord.revision as number, agent: agents.get(paneId) ?? agentInfo(valueRecord) }; +} +async function linuxProcessGeneration(pid: number): Promise { + if (!Number.isSafeInteger(pid) || pid < 1 || process.platform !== 'linux') return null; + try { + const [processStat, kernelStat] = await Promise.all([readFile(`/proc/${pid}/stat`, 'utf8'), readFile('/proc/stat', 'utf8')]); + const close = processStat.lastIndexOf(')'); + const fields = processStat.slice(close + 2).trim().split(/\s+/); + const startTicks = Number(fields[19]); + const bootSeconds = Number(kernelStat.match(/^btime\s+(\d+)$/m)?.[1]); + const startedAtMs = bootSeconds * 1000 + startTicks * 10; + return Number.isSafeInteger(startTicks) && startTicks > 0 && Number.isSafeInteger(bootSeconds) && bootSeconds > 0 && Number.isSafeInteger(startedAtMs) + ? { pid, startedAtMs } + : null; + } catch { + return null; + } +} + +export async function linuxSocketOwnerGeneration(socketPath: string, binaryPath: string): Promise { + if (process.platform !== 'linux') return null; + const deadline = Date.now() + HERDR_SOCKET_OWNER_LIMITS.deadlineMs; + let descriptorsSeen = 0; + try { + const inodes = new Set( + (await readFile('/proc/net/unix', 'utf8')) + .split('\n') + .filter((line) => line.trimEnd().endsWith(` ${socketPath}`)) + .map((line) => line.trim().split(/\s+/)[6]) + .filter((inode): inode is string => typeof inode === 'string' && /^\d+$/.test(inode)), + ); + if (!inodes.size || Date.now() >= deadline) return null; + const processIds = (await readdir('/proc', { withFileTypes: true })) + .filter((entry) => entry.isDirectory() && /^[1-9]\d*$/.test(entry.name)) + .slice(0, HERDR_SOCKET_OWNER_LIMITS.processes) + .map((entry) => entry.name); + for (let offset = 0; offset < processIds.length; offset += 128) { + if (Date.now() >= deadline) return null; + const matching = (await Promise.all(processIds.slice(offset, offset + 128).map(async (pid) => { + try { + return await readlink(`/proc/${pid}/exe`) === binaryPath ? pid : null; + } catch { + return null; + } + }))).filter((pid): pid is string => pid !== null); + for (const pid of matching) { + let descriptors: string[]; + try { + descriptors = await readdir(`/proc/${pid}/fd`); + } catch { + continue; + } + for (const descriptor of descriptors) { + if (Date.now() >= deadline || descriptorsSeen >= HERDR_SOCKET_OWNER_LIMITS.descriptors) return null; + descriptorsSeen += 1; + try { + const link = await readlink(`/proc/${pid}/fd/${descriptor}`); + const inode = /^socket:\[(\d+)\]$/.exec(link)?.[1]; + if (inode && inodes.has(inode)) return linuxProcessGeneration(Number(pid)); + } catch { + // Processes and descriptors can disappear while /proc is scanned. + } + } + } + } + } catch { + // Missing or unreadable procfs means the source identity cannot be trusted. + } + return null; +} + +/** External Herdr adapter: it never creates, starts, takes over, or controls a provider. */ +export class HerdrRuntimeAdapter { + readonly runtime = 'herdr' as const; + private readonly sources = new Map(); + private readonly generations = new Map(); + private readonly leases = new Map(); + private readonly compatibility = new Map }>(); + private readonly socketOwners = new Map(); + private readonly socketOwnerProbes = new Map>(); + private readonly protection = new Map(); + constructor(private readonly config: HerdrRuntimeConfig, private readonly policy: RuntimeOperationPolicyService, private readonly targets = new HerdrTargetRegistry(), private readonly client = new HerdrClient(), private readonly admission: Admission = () => null, private readonly fs: Fs = { lstat, realpath }, private readonly resolveProcess: ProcessResolver = linuxProcessGeneration, private readonly resolveSocketOwner: SocketOwnerResolver = linuxSocketOwnerGeneration, private readonly protectedTargets = new ProtectedTerminalTargetService()) { for (const source of config.sources) this.sources.set(source.sourceId, source); this.policy.onReduction(() => { this.targets.clear(); this.leases.clear(); this.protection.clear(); }); } + capabilities(sourceId: HerdrSourceId): RuntimeCapabilities { return this.policy.capabilities(sourceId); } + private async endpoint(configured: HerdrConfiguredSource): Promise { + try { + const [binaryPath, binaryStat, list] = await Promise.all([this.fs.realpath(configured.binary), this.fs.lstat(configured.binary), this.client.sessionList(configured)]); + if (binaryPath !== configured.binary || !binaryStat.isFile() || binaryStat.isSymbolicLink() || binaryStat.uid !== process.getuid?.() || (binaryStat.mode & 0o022) !== 0) return null; + const listValue = parsed(list); const listRoot = record(listValue); const listResult = record(listRoot?.result); const sessions = Array.isArray(listResult?.sessions) ? listResult.sessions : Array.isArray(listRoot?.sessions) ? listRoot.sessions : null; if (!sessions) return null; + const session = sessions.map(record).find((item) => item?.name === configured.selector); + const listedServerPid = typeof session?.server_pid === 'number' && Number.isSafeInteger(session.server_pid) && session.server_pid > 0 ? session.server_pid : null; + if (!session || session.running !== true || typeof session.socket_path !== 'string' || !session.socket_path.startsWith('/')) return null; + const socketPath = await this.fs.realpath(session.socket_path); const socket = await this.fs.lstat(socketPath); + if (socketPath !== session.socket_path || !socket.isSocket() || socket.isSymbolicLink() || socket.uid !== process.getuid?.() || (socket.mode & 0o022) !== 0) return null; + const socketIdentity = `${socket.dev}:${socket.ino}`; + const cachedOwner = this.socketOwners.get(configured.sourceId); + let server = listedServerPid ? await this.resolveProcess(listedServerPid) : null; + if (!server && listedServerPid === null && cachedOwner?.socketIdentity === socketIdentity) { + const current = await this.resolveProcess(cachedOwner.process.pid); + if (current?.startedAtMs === cachedOwner.process.startedAtMs) server = current; + } + if (!server && listedServerPid === null) { + const probe = this.socketOwnerProbes.get(socketIdentity) ?? this.resolveSocketOwner(socketPath, binaryPath).finally(() => this.socketOwnerProbes.delete(socketIdentity)); + this.socketOwnerProbes.set(socketIdentity, probe); + server = await probe; + } + if (!server || (listedServerPid !== null && server.pid !== listedServerPid) || !Number.isSafeInteger(server.startedAtMs) || server.startedAtMs < 1) return null; + this.socketOwners.set(configured.sourceId, { socketIdentity, process: server }); + const identity = `${socket.dev}:${socket.ino}:${server.pid}:${server.startedAtMs}`; const prior = this.generations.get(configured.sourceId); const generation = prior?.identity === identity ? prior.generation : (prior?.generation ?? 0) + 1; + if (prior && prior.identity !== identity) { this.targets.invalidateSource(configured.sourceId); for (const key of this.protection.keys()) if (key.startsWith(`${configured.sourceId}\0`)) this.protection.delete(key); } this.generations.set(configured.sourceId, { identity, generation }); + return { generation, compatibilityKey: `${binaryStat.dev}:${binaryStat.ino}:${binaryStat.size}:${binaryStat.mtimeMs}:${binaryStat.ctimeMs}`, source: { sourceId: configured.sourceId, alias: configured.alias, binary: configured.binary, selector: configured.selector, canonicalSocketPath: socketPath, socketStat: { uid: socket.uid, mode: socket.mode, device: socket.dev, inode: socket.ino }, serverIncarnation: identity, probeFingerprint: HERDR_SEMANTIC_FINGERPRINT, internalGeneration: generation, transport: 'herdr terminal session control' } }; + } catch { return null; } + } + private async ready(sourceId: HerdrSourceId, operation: RuntimeCapability): Promise { const configured = this.sources.get(sourceId); if (!configured || !this.config.enabled || !this.policy.allows(sourceId, operation)) return null; const endpoint = await this.endpoint(configured); if (!endpoint) return null; const cached = this.compatibility.get(sourceId); const checked = cached?.key === endpoint.compatibilityKey ? cached.checked : probeHerdrStaticCompatibility(configured, this.config.startupCapabilities, this.client); if (!cached || cached.key !== endpoint.compatibilityKey) this.compatibility.set(sourceId, { key: endpoint.compatibilityKey, checked }); const compatibility = await checked; if ((compatibility.readiness !== 'ready' && compatibility.readiness !== 'degraded') || !compatibility.capabilities[operation] || !this.policy.allows(sourceId, operation)) return null; return endpoint; } + async sourceDescriptors(): Promise { + return Promise.all([...this.sources.values()].map(async (source) => { + const endpoint = await this.endpoint(source); + if (!endpoint) return { runtime: 'herdr' as const, sourceId: source.sourceId, readiness: 'offline' as const }; + const cached = this.compatibility.get(source.sourceId); + const checked = cached?.key === endpoint.compatibilityKey ? cached.checked : probeHerdrStaticCompatibility(source, this.config.startupCapabilities, this.client); + if (!cached || cached.key !== endpoint.compatibilityKey) this.compatibility.set(source.sourceId, { key: endpoint.compatibilityKey, checked }); + const probe = await probeHerdrCompatibility(source, this.config.startupCapabilities, this.client, process.platform, process.arch, await checked); + return { runtime: 'herdr' as const, sourceId: source.sourceId, readiness: probe.readiness === 'ready' ? 'ready' as const : probe.readiness }; + })); + } + private async snapshot(source: HerdrResolvedSource): Promise<{ panes: Pane[]; agents: Map } | null> { + const root = result(parsed(await this.client.snapshot(source)), 'session_snapshot'); const snapshot = record(root?.snapshot); const values = Array.isArray(snapshot?.panes) ? snapshot.panes : null; const rawAgents = Array.isArray(snapshot?.agents) ? snapshot.agents : null; if (!values || !rawAgents || values.length > MAX_TARGETS_PER_SOURCE) return null; + const agents = new Map(); for (const raw of rawAgents) { const agent = record(raw); const paneId = id(agent?.pane_id); const info = agentInfo(agent); if (!paneId || !info || agents.has(paneId)) return null; agents.set(paneId, info); } + const panes = values.map((value) => pane(value, agents)); return panes.every((value): value is Pane => value !== null) ? { panes, agents } : null; + } + private terminalFromPane(source: HerdrResolvedSource, listed: Pane): HerdrResolvedTerminal { + const agent = listed.agent?.agent ? { agentId: listed.agent.agent, agentKind: listed.agent.agent } : null; + return { source, hierarchy: { workspaceId: listed.workspaceId, tabId: listed.tabId, paneId: listed.paneId }, terminalId: listed.terminalId, terminalIncarnation: listed.terminalIncarnation, terminalRevision: listed.revision, agent, process: null, targetClass: 'attach-only' }; + } + private async discoverSource(configured: HerdrConfiguredSource): Promise { + const failed = (): RuntimeDiscoveryOutcome => ({ runtime: 'herdr', sourceId: configured.sourceId, ok: false, terminals: [] }); + const endpoint = await this.ready(configured.sourceId, 'discovery'); + if (!endpoint) return failed(); + const discovered = await this.snapshot(endpoint.source); + if (!discovered) return failed(); + const terminals: PublicTerminalTarget[] = []; + for (const listed of discovered.panes) { + const terminal = this.terminalFromPane(endpoint.source, listed); + const evidence = this.protectedTargets.evidence(terminal); + if (!evidence || evidence.protected) return failed(); + const admission = this.admission(configured.sourceId, listed.paneId); + const target = this.targets.mint(terminal, endpoint.generation, admission); + if (!target || target.runtime !== 'herdr') return failed(); + this.protection.set(`${configured.sourceId}\0${target.targetId}`, evidence); + terminals.push(target); + } + return { runtime: 'herdr', sourceId: configured.sourceId, ok: true, terminals }; + } + discoverOutcomes(): Promise { + return Promise.all([...this.sources.values()].map((source) => this.discoverSource(source))); + } + async discover(): Promise { + return (await this.discoverOutcomes()).flatMap((outcome) => outcome.ok ? [...outcome.terminals] : []); + } + async scanDiscovery(): Promise<{ sources: SourceDescriptor[]; outcomes: RuntimeDiscoveryOutcome[] }> { + const outcomes = await this.discoverOutcomes(); + return { + sources: outcomes.map(({ sourceId, ok }) => ({ + runtime: 'herdr' as const, + sourceId, + readiness: ok ? 'ready' as const : 'offline' as const, + })), + outcomes, + }; + } + private async resolveFresh(ref: Extract, operation: 'output' | 'actions' | 'attach'): Promise<{ source: HerdrResolvedSource; terminal: HerdrResolvedTerminal } | null> { const publicRef = readPublicTerminalRef(ref); if (!publicRef || publicRef.runtime !== 'herdr') return null; const sourceId = publicRef.sourceId as HerdrSourceId; const targetId = publicRef.targetId as HerdrTargetId; const expectedProtection = this.protection.get(`${sourceId}\0${targetId}`); if (!expectedProtection) return null; const endpoint = await this.ready(sourceId, operation); if (!endpoint) return null; const locator = this.targets.locate(sourceId, targetId, endpoint.generation); if (!locator) return null; const gotRoot = result(parsed(await this.client.paneGet(endpoint.source, locator.paneId)), 'pane_info'); const listed = pane(record(gotRoot?.pane), new Map()); if (!listed || listed.paneId !== locator.paneId) return null; const terminal = this.terminalFromPane(endpoint.source, listed); if (!this.protectedTargets.allows(terminal, expectedProtection) || !this.targets.resolve(sourceId, targetId, terminal, endpoint.generation)) return null; if (operation === 'actions' && terminal.targetClass !== 'local-agent') return null; return { source: endpoint.source, terminal }; } + async verify(ref: Extract, operation: 'output' | 'actions' | 'attach'): Promise { return !!await this.resolveFresh(ref, operation); } + async targetProfile(ref: Extract): Promise { const publicRef = readPublicTerminalRef(ref); if (!publicRef || publicRef.runtime !== 'herdr' || !this.protection.has(`${publicRef.sourceId}\0${publicRef.targetId}`)) return null; return this.targets.profile(publicRef.sourceId as HerdrSourceId, publicRef.targetId as HerdrTargetId); } + async read(ref: Extract): Promise<{ ansi: string; truncated: boolean } | null> { const verified = await this.resolveFresh(ref, 'output'); if (!verified) return null; const output = await this.client.paneRead(verified.source, verified.terminal.hierarchy.paneId); return output.code === 0 && !output.timedOut && !output.oversized && !output.stderrOverflow && !output.spawnError ? { ansi: output.stdout, truncated: false } : null; } + async send(ref: Extract, literal: string): Promise { const verified = await this.resolveFresh(ref, 'actions'); if (!verified) return false; const output = await this.client.paneSendText(verified.source, verified.terminal.hierarchy.paneId, literal); return output.code === 0 && !output.timedOut && !output.oversized && !output.stderrOverflow && !output.spawnError; } + async interrupt(ref: Extract): Promise { return this.keys(ref, ['ctrl+c']); } + async escape(ref: Extract): Promise { return this.keys(ref, ['esc']); } + private async keys(ref: Extract, keys: string[]): Promise { const verified = await this.resolveFresh(ref, 'actions'); if (!verified) return false; const output = await this.client.paneSendKeys(verified.source, verified.terminal.hierarchy.paneId, keys); return output.code === 0 && !output.timedOut && !output.oversized && !output.stderrOverflow && !output.spawnError; } + async controllerArgv(ref: Extract, cols: number, rows: number): Promise { const verified = await this.resolveFresh(ref, 'attach'); if (!verified) return null; const sourceId = ref.sourceId as HerdrSourceId; const key = `${ref.sourceId}\0${ref.targetId}`; const existing = this.leases.get(key); if (existing && !existing.released) return null; const sourceCount = [...this.leases.values()].filter((lease) => !lease.released && lease.sourceId === sourceId).length; const globalCount = [...this.leases.values()].filter((lease) => !lease.released).length; if (sourceCount >= MAX_CONTROLLERS_PER_SOURCE || globalCount >= MAX_CONTROLLERS_GLOBAL) return null; let args: string[]; try { args = this.client.controllerArgv(verified.source, verified.terminal.hierarchy.paneId, cols, rows); } catch { return null; } const lease = { sourceId, released: false, controller: undefined as unknown as Controller }; const controller: Controller = { command: verified.source.binary, args, release: () => { if (!lease.released) { lease.released = true; this.leases.delete(key); } } }; lease.controller = controller; this.leases.set(key, lease); return controller; } + dispose(): void { this.targets.clear(); this.leases.clear(); this.compatibility.clear(); this.generations.clear(); this.socketOwners.clear(); this.socketOwnerProbes.clear(); this.protection.clear(); } +} diff --git a/server/modules/terminal-runtimes/herdr-target-registry.service.ts b/server/modules/terminal-runtimes/herdr-target-registry.service.ts new file mode 100644 index 0000000..ed6263e --- /dev/null +++ b/server/modules/terminal-runtimes/herdr-target-registry.service.ts @@ -0,0 +1,43 @@ +import { randomBytes } from 'node:crypto'; + +import type { ProcessGeneration, PublicTerminalRef, PublicTerminalTarget } from '../../../shared/terminal-runtime.js'; + +import type { HerdrResolvedTerminal, HerdrSourceId, HerdrTargetId } from './herdr-internal.types.js'; + +export const HERDR_TARGET_REGISTRY_LIMIT = 2048; +type Entry = { sourceId: HerdrSourceId; sourceGeneration: number; terminalId: string; terminalIncarnation: string; workspaceId: string; tabId: string; paneId: string; process: ProcessGeneration | null; targetClass: 'local-agent' | 'attach-only'; admissionCapability: string | null }; +export type HerdrServerTargetProfile = Readonly<{ targetClass: 'local-agent' | 'attach-only'; process: ProcessGeneration | null }>; +export type HerdrTargetLocator = Readonly<{ paneId: string }>; +function targetId(): HerdrTargetId { return `htgt_${randomBytes(16).toString('base64url')}`; } +function same(entry: Entry, target: HerdrResolvedTerminal, sourceGeneration: number): boolean { + if (entry.sourceId !== target.source.sourceId + || entry.sourceGeneration !== sourceGeneration + || entry.terminalId !== target.terminalId + || entry.terminalIncarnation !== target.terminalIncarnation + || entry.workspaceId !== target.hierarchy.workspaceId + || entry.tabId !== target.hierarchy.tabId + || entry.paneId !== target.hierarchy.paneId + || entry.targetClass !== target.targetClass) return false; + return entry.targetClass === 'attach-only' + || (entry.process?.pid === target.process?.pid && entry.process?.startedAtMs === target.process?.startedAtMs); +} +export class HerdrTargetRegistry { + private readonly entries = new Map(); + constructor(private readonly limit = HERDR_TARGET_REGISTRY_LIMIT) {} + mint(target: HerdrResolvedTerminal, sourceGeneration: number, admissionCapability: string | null = null): PublicTerminalTarget | null { + if (target.targetClass === 'local-agent' && (!target.process || !isSupportedAgent(target.agent?.agentKind))) return null; + if (target.targetClass === 'attach-only' && !admissionCapability) return null; + for (const [id, entry] of this.entries) if (same(entry, target, sourceGeneration)) return this.public(entry, id); + if (this.entries.size >= this.limit) this.entries.delete(this.entries.keys().next().value as HerdrTargetId); + const id = targetId(); const entry: Entry = { sourceId: target.source.sourceId, sourceGeneration, terminalId: target.terminalId, terminalIncarnation: target.terminalIncarnation, workspaceId: target.hierarchy.workspaceId, tabId: target.hierarchy.tabId, paneId: target.hierarchy.paneId, process: target.process && { pid: target.process.pid, startedAtMs: target.process.startedAtMs }, targetClass: target.targetClass, admissionCapability }; + this.entries.set(id, entry); return this.public(entry, id); + } + private public(entry: Entry, id: HerdrTargetId): PublicTerminalTarget | null { return entry.targetClass === 'local-agent' && entry.process ? { runtime: 'herdr', sourceId: entry.sourceId, targetId: id, targetClass: 'local-agent', process: entry.process } : entry.admissionCapability ? { runtime: 'herdr', sourceId: entry.sourceId, targetId: id, targetClass: 'attach-only', admissionCapability: entry.admissionCapability } : null; } + reference(sourceId: HerdrSourceId, id: HerdrTargetId): PublicTerminalRef { return { runtime: 'herdr', sourceId, targetId: id }; } + resolve(sourceId: HerdrSourceId, id: HerdrTargetId, target: HerdrResolvedTerminal, sourceGeneration: number): Entry | null { const entry = this.entries.get(id); return entry && entry.sourceId === sourceId && same(entry, target, sourceGeneration) ? entry : null; } + locate(sourceId: HerdrSourceId, id: HerdrTargetId, sourceGeneration: number): HerdrTargetLocator | null { const entry = this.entries.get(id); return entry && entry.sourceId === sourceId && entry.sourceGeneration === sourceGeneration ? { paneId: entry.paneId } : null; } + profile(sourceId: HerdrSourceId, id: HerdrTargetId): HerdrServerTargetProfile | null { const entry = this.entries.get(id); return entry && entry.sourceId === sourceId ? { targetClass: entry.targetClass, process: entry.process && { pid: entry.process.pid, startedAtMs: entry.process.startedAtMs } } : null; } + invalidateSource(sourceId: HerdrSourceId): void { for (const [id, entry] of this.entries) if (entry.sourceId === sourceId) this.entries.delete(id); } + clear(): void { this.entries.clear(); } +} +export function isSupportedAgent(kind: unknown): boolean { return typeof kind === 'string' && ['claude', 'codex', 'cursor', 'opencode', 'omp'].includes(kind); } diff --git a/server/modules/terminal-runtimes/index.ts b/server/modules/terminal-runtimes/index.ts new file mode 100644 index 0000000..5c44b06 --- /dev/null +++ b/server/modules/terminal-runtimes/index.ts @@ -0,0 +1,12 @@ +export { HerdrRuntimeAdapter } from './herdr-runtime-adapter.service.js'; +export { HerdrTargetRegistry, HERDR_TARGET_REGISTRY_LIMIT, isSupportedAgent } from './herdr-target-registry.service.js'; +export { RuntimeOperationPolicyService } from './runtime-operation-policy.service.js'; +export { RuntimeRegistryService } from './runtime-registry.service.js'; +export { HerdrClient, HERDR_LIMITS } from './herdr-client.service.js'; +export { readHerdrRuntimeConfig, herdrSourceId, HERDR_MAX_SOURCES, HERDR_POLICY_FILE_ENV } from './herdr-config.service.js'; +export { probeHerdrCompatibility, HERDR_PROTOCOL, HERDR_SEMANTIC_FINGERPRINT, HERDR_VERSION } from './herdr-probe.service.js'; +export type { RuntimeAdapter, RuntimeOperation, RuntimeOperationContext } from './runtime-registry.service.js'; +export type { HerdrConfiguredSource, HerdrRuntimeConfig } from './herdr-config.service.js'; +export type { HerdrSourceId, HerdrTargetId, HerdrResolvedTerminal, VerifiedHerdrTarget } from './herdr-internal.types.js'; +export { HerdrAdmissionService, HerdrControlBridgeService, spawnHerdrController } from './herdr-control-bridge.service.js'; +export type { HerdrControlBridge } from './herdr-control-bridge.service.js'; diff --git a/server/modules/terminal-runtimes/protected-terminal-target.service.ts b/server/modules/terminal-runtimes/protected-terminal-target.service.ts new file mode 100644 index 0000000..e4240ac --- /dev/null +++ b/server/modules/terminal-runtimes/protected-terminal-target.service.ts @@ -0,0 +1,33 @@ +import type { HerdrResolvedTerminal } from './herdr-internal.types.js'; + +export type ProtectedTerminalTargetEvidence = Readonly<{ protected: boolean; identity: string }>; +export type ProtectedTerminalTargetResolver = (target: HerdrResolvedTerminal) => ProtectedTerminalTargetEvidence; + +/** + * Protects the Herdr source hosting ChatMux itself. Herdr exports the named + * session to every pane process; denying that whole source is conservative + * across pane moves and launch-time pane-id aliases. + */ +export class ProtectedTerminalTargetService { + constructor( + private readonly resolve: ProtectedTerminalTargetResolver = (target) => { + const runningInsideHerdr = process.env.HERDR_ENV === '1'; + const hostingSource = runningInsideHerdr + && (!process.env.HERDR_SESSION || process.env.HERDR_SESSION === target.source.selector); + return { + protected: hostingSource, + identity: `${target.terminalId}:${target.terminalIncarnation}:${target.hierarchy.workspaceId}:${target.hierarchy.tabId}:${target.hierarchy.paneId}`, + }; + }, + ) {} + + evidence(target: HerdrResolvedTerminal): ProtectedTerminalTargetEvidence | null { + const evidence = this.resolve(target); + return typeof evidence.protected === 'boolean' && /^[A-Za-z0-9_.:-]{1,512}$/.test(evidence.identity) ? evidence : null; + } + + allows(target: HerdrResolvedTerminal, expected?: ProtectedTerminalTargetEvidence): boolean { + const current = this.evidence(target); + return !!current && !current.protected && (!expected || (current.identity === expected.identity && current.protected === expected.protected)); + } +} diff --git a/server/modules/terminal-runtimes/runtime-operation-policy.service.ts b/server/modules/terminal-runtimes/runtime-operation-policy.service.ts new file mode 100644 index 0000000..90d7eb5 --- /dev/null +++ b/server/modules/terminal-runtimes/runtime-operation-policy.service.ts @@ -0,0 +1,58 @@ +import { readFile, lstat } from 'node:fs/promises'; +import { isAbsolute } from 'node:path'; + +import type { RuntimeCapabilities, RuntimeCapability } from '../../../shared/terminal-runtime.js'; + +import type { HerdrSourceId } from './herdr-internal.types.js'; + +const KEYS = ['discovery', 'output', 'actions', 'attach', 'create'] as const; +type CapabilityMap = Record; +type PolicyDocument = { global?: Partial; sources?: Record> }; +export type RuntimePolicyReload = { generation: number; changed: boolean; errorCode: string | null }; + +function none(): CapabilityMap { return { discovery: false, output: false, actions: false, attach: false, create: false }; } +function intersect(a: RuntimeCapabilities, b: Partial): CapabilityMap { const result = none(); for (const key of KEYS) result[key] = a[key] === true && b[key] !== false; return result; } +function validPartial(value: unknown): value is Partial { + return !!value && typeof value === 'object' && !Array.isArray(value) && Object.keys(value).every((key) => KEYS.includes(key as RuntimeCapability) && typeof (value as Record)[key] === 'boolean'); +} +function parsePolicy(raw: string): PolicyDocument | null { + let value: unknown; try { value = JSON.parse(raw); } catch { return null; } + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + const document = value as Record; + if (!Object.keys(document).every((key) => key === 'global' || key === 'sources') || (document.global !== undefined && !validPartial(document.global))) return null; + if (document.sources !== undefined && (!document.sources || typeof document.sources !== 'object' || Array.isArray(document.sources) || !Object.entries(document.sources as Record).every(([id, capabilities]) => /^hsrc_[A-Za-z0-9_-]{22}$/.test(id) && validPartial(capabilities)))) return null; + return document as PolicyDocument; +} + +/** Herdr-only reductions. A bad configured policy is fail-closed, never permissive. */ +export class RuntimeOperationPolicyService { + private generationValue = 0; + private effective = new Map(); + private listeners = new Set<(generation: number) => void>(); + constructor(private readonly startup: RuntimeCapabilities, private readonly sourceIds: readonly HerdrSourceId[], private readonly policyPath: string | null = null, private readonly ownerUid = process.getuid?.() ?? -1) { this.applyDocument(null, policyPath !== null); } + get generation(): number { return this.generationValue; } + capabilities(sourceId: HerdrSourceId): RuntimeCapabilities { return this.effective.get(sourceId) ?? none(); } + allows(sourceId: HerdrSourceId, operation: RuntimeCapability): boolean { return this.capabilities(sourceId)[operation]; } + onReduction(listener: (generation: number) => void): () => void { this.listeners.add(listener); return () => this.listeners.delete(listener); } + private applyDocument(document: PolicyDocument | null, failClosed: boolean): boolean { + const previous = this.effective; + const next = new Map(); + for (const sourceId of this.sourceIds) next.set(sourceId, failClosed ? none() : intersect(intersect(this.startup, document?.global ?? {}), document?.sources?.[sourceId] ?? {})); + const changed = this.sourceIds.some((id) => KEYS.some((key) => previous.get(id)?.[key] !== next.get(id)?.[key])); + this.effective = next; + if (changed) { this.generationValue++; for (const listener of this.listeners) listener(this.generationValue); } + return changed; + } + async reload(): Promise { + if (!this.policyPath) return { generation: this.generationValue, changed: false, errorCode: null }; + let document: PolicyDocument | null = null; + let errorCode: string | null = null; + try { + const stat = await lstat(this.policyPath); + if (!isAbsolute(this.policyPath) || !stat.isFile() || stat.isSymbolicLink() || stat.uid !== this.ownerUid || (stat.mode & 0o022) !== 0) errorCode = 'policy_invalid'; + else { const raw = await readFile(this.policyPath, 'utf8'); document = parsePolicy(raw); if (!document) errorCode = 'policy_invalid'; } + } catch { errorCode = 'policy_unavailable'; } + const changed = this.applyDocument(document, errorCode !== null); + return { generation: this.generationValue, changed, errorCode }; + } +} diff --git a/server/modules/terminal-runtimes/runtime-registry.service.ts b/server/modules/terminal-runtimes/runtime-registry.service.ts new file mode 100644 index 0000000..d63dd09 --- /dev/null +++ b/server/modules/terminal-runtimes/runtime-registry.service.ts @@ -0,0 +1,101 @@ +import type { PublicTerminalRef, PublicTerminalTarget, RuntimeCapabilities, RuntimeCapability, SourceDescriptor, TerminalRuntime } from '../../../shared/terminal-runtime.js'; + +export type RuntimeOperation = 'discover' | 'read' | 'send' | 'interrupt' | 'escape' | 'attach'; +export type RuntimeDiscoveryOutcome = Readonly<{ + runtime: TerminalRuntime; + sourceId: string; + ok: boolean; + terminals: readonly PublicTerminalTarget[]; +}>; +export type RuntimeDiscoveryScan = Readonly<{ + sources: readonly SourceDescriptor[]; + outcomes: readonly RuntimeDiscoveryOutcome[]; +}>; +export type RuntimeOperationContext = unknown; +export type RuntimeTargetProfile = Readonly<{ targetClass: 'local-agent' | 'attach-only'; process: { pid: number; startedAtMs: number } | null }>; +export type RuntimeAdapter = { + readonly runtime: TerminalRuntime; + sourceDescriptors(): Promise; + capabilities?(sourceId: string): RuntimeCapabilities; + discover(): Promise; + discoverOutcomes?(): Promise; + scanDiscovery?(): Promise; + read?(ref: PublicTerminalRef, context?: RuntimeOperationContext): Promise<{ ansi: string; truncated: boolean } | null>; + send?(ref: PublicTerminalRef, literal: string, context?: RuntimeOperationContext): Promise; + interrupt?(ref: PublicTerminalRef, context?: RuntimeOperationContext): Promise; + escape?(ref: PublicTerminalRef, context?: RuntimeOperationContext): Promise; + controllerArgv?(ref: PublicTerminalRef, cols: number, rows: number): Promise<{ command: string; args: string[]; release: () => void } | null>; + verify?(ref: PublicTerminalRef, operation: 'output' | 'actions' | 'attach'): Promise; + targetProfile?(ref: PublicTerminalRef): Promise; + dispose?(): void; +}; +const CAPABILITY: Record = { discover: 'discovery', read: 'output', send: 'actions', interrupt: 'actions', escape: 'actions', attach: 'attach' }; +const NO_CAPABILITIES: RuntimeCapabilities = { discovery: false, output: false, actions: false, attach: false, create: false }; + +/** Dependency-free coordinator. Discovery is presentation only; operations delegate fresh verification. */ +export class RuntimeRegistryService { + private readonly adapters = new Map(); + private disposed = false; + register(adapter: RuntimeAdapter): () => void { if (this.disposed || this.adapters.has(adapter.runtime)) throw new Error('runtime_registry_invalid'); this.adapters.set(adapter.runtime, adapter); return () => { if (this.adapters.get(adapter.runtime) === adapter) this.adapters.delete(adapter.runtime); }; } + capabilities(runtime: TerminalRuntime, sourceId: string): RuntimeCapabilities { return this.adapters.get(runtime)?.capabilities?.(sourceId) ?? NO_CAPABILITIES; } + async sources(): Promise { if (this.disposed) return []; const descriptors = await Promise.all([...this.adapters.values()].map((adapter) => adapter.sourceDescriptors())); return descriptors.flat().map(({ runtime, sourceId, readiness }) => ({ runtime, sourceId, readiness })); } + async discoverOutcomes(): Promise { + if (this.disposed) return []; + return (await Promise.all([...this.adapters.values()].map(async (adapter) => { + let descriptors: readonly SourceDescriptor[]; + try { + descriptors = await adapter.sourceDescriptors(); + } catch { + return []; + } + try { + if (adapter.discoverOutcomes) return await adapter.discoverOutcomes(); + const terminals = await adapter.discover(); + return descriptors.map(({ runtime, sourceId }) => ({ + runtime, + sourceId, + ok: true, + terminals: terminals.filter((terminal) => terminal.runtime !== 'herdr' || terminal.sourceId === sourceId), + })); + } catch { + return descriptors.map(({ runtime, sourceId }) => ({ runtime, sourceId, ok: false, terminals: [] })); + } + }))).flat(); + } + async scanDiscovery(): Promise { + if (this.disposed) return { sources: [], outcomes: [] }; + const scans = await Promise.all([...this.adapters.values()].map(async (adapter): Promise => { + if (adapter.scanDiscovery) return adapter.scanDiscovery(); + let sources: readonly SourceDescriptor[]; + try { + sources = await adapter.sourceDescriptors(); + } catch { + return { sources: [], outcomes: [] }; + } + try { + const outcomes = adapter.discoverOutcomes + ? await adapter.discoverOutcomes() + : sources.map(({ runtime, sourceId }) => ({ runtime, sourceId, ok: true, terminals: [] })); + return { sources, outcomes }; + } catch { + return { + sources, + outcomes: sources.map(({ runtime, sourceId }) => ({ runtime, sourceId, ok: false, terminals: [] })), + }; + } + })); + return { + sources: scans.flatMap((scan) => [...scan.sources]), + outcomes: scans.flatMap((scan) => [...scan.outcomes]), + }; + } + private adapter(ref: PublicTerminalRef, operation: RuntimeOperation): RuntimeAdapter | null { const adapter = this.adapters.get(ref.runtime); return adapter && (ref.runtime !== 'herdr' || adapter.capabilities?.(ref.sourceId)[CAPABILITY[operation]]) ? adapter : null; } + async read(ref: PublicTerminalRef, context?: RuntimeOperationContext): Promise<{ ansi: string; truncated: boolean } | null> { const adapter = this.adapter(ref, 'read'); return adapter?.read ? adapter.read(ref, context) : null; } + async send(ref: PublicTerminalRef, literal: string, context?: RuntimeOperationContext): Promise { const adapter = this.adapter(ref, 'send'); return adapter?.send ? adapter.send(ref, literal, context) : false; } + async interrupt(ref: PublicTerminalRef, context?: RuntimeOperationContext): Promise { const adapter = this.adapter(ref, 'interrupt'); return adapter?.interrupt ? adapter.interrupt(ref, context) : false; } + async escape(ref: PublicTerminalRef, context?: RuntimeOperationContext): Promise { const adapter = this.adapter(ref, 'escape'); return adapter?.escape ? adapter.escape(ref, context) : false; } + async verify(ref: PublicTerminalRef, operation: 'output' | 'actions' | 'attach'): Promise { const adapter = this.adapter(ref, operation === 'output' ? 'read' : operation === 'actions' ? 'send' : 'attach'); return adapter?.verify ? adapter.verify(ref, operation) : false; } + async controllerArgv(ref: PublicTerminalRef, cols: number, rows: number): Promise<{ command: string; args: string[]; release: () => void } | null> { const adapter = this.adapter(ref, 'attach'); return adapter?.controllerArgv ? adapter.controllerArgv(ref, cols, rows) : null; } + async targetProfile(ref: PublicTerminalRef): Promise { if (this.disposed) return null; const adapter = this.adapters.get(ref.runtime); const profile = await adapter?.targetProfile?.(ref); return profile && (!adapter?.verify || await adapter.verify(ref, 'attach')) ? profile : null; } + dispose(): void { if (this.disposed) return; this.disposed = true; for (const adapter of this.adapters.values()) adapter.dispose?.(); this.adapters.clear(); } +} diff --git a/server/modules/terminal-runtimes/tests/fixtures/herdr/v0.7.5-phase0.json b/server/modules/terminal-runtimes/tests/fixtures/herdr/v0.7.5-phase0.json new file mode 100644 index 0000000..80bd275 --- /dev/null +++ b/server/modules/terminal-runtimes/tests/fixtures/herdr/v0.7.5-phase0.json @@ -0,0 +1,3737 @@ +{ + "schemaVersion": 3, + "generatedAt": "2026-07-30T17:51:37.238Z", + "cohort": { + "status": "live-run", + "semanticManifestVersion": 3, + "semanticFingerprint": "4d7b299bb4b7924c3fb0d0251a303ac3c92ed77bdc8ba1dac4b4bbed3fa51583" + }, + "platform": { + "os": "linux", + "arch": "x64", + "nonRoot": true + }, + "binary": { + "name": "herdr", + "sha256": "3dc83288073e4c2d3c679a30e7be97bcca9141c6fd17dbbb9219142e95c59253", + "ownerUidMatches": true, + "mode": "0755", + "copiedVerifiedExecutable": true, + "priorUserLocalInstallAbsent": true, + "sourceUnchangedAfterRun": true + }, + "provenance": { + "officialVersion": "0.7.5", + "releaseTag": "v0.7.5", + "releaseUrl": "https://github.com/herdrdev/herdr/releases/tag/v0.7.5", + "assetUrl": "https://github.com/herdrdev/herdr/releases/download/v0.7.5/herdr-linux-x86_64", + "redirectChainHosts": [ + "github.com", + "release-assets.githubusercontent.com" + ], + "finalHost": "release-assets.githubusercontent.com", + "publishedDigestAlgorithm": "sha256", + "publishedAt": "2026-07-21T18:11:20Z", + "evidenceCheckedAt": "2026-07-30", + "publisher": "herdrdev/herdr", + "artifact": "herdr-linux-x86_64" + }, + "scope": { + "disposableNamedSession": true, + "isolatedRunRoot": true, + "isolatedConfigStateCacheDataHome": true, + "manifestScoped": true, + "productSourceMutated": false, + "shellUsed": false, + "ptyUsed": false, + "takeoverAllowed": false, + "defaultSessionUsed": false + }, + "selectedTransport": "herdr terminal session control", + "semanticManifest": { + "version": 3, + "protocol": 17, + "schemaVersion": 1, + "api": { + "requests": [ + { + "method": "agent.list", + "required": [ + "method", + "params" + ], + "params": { + "$ref": "#/schemas/request/$defs/EmptyParams" + } + }, + { + "method": "events.subscribe", + "required": [ + "method", + "params" + ], + "params": { + "$ref": "#/schemas/request/$defs/EventsSubscribeParams" + } + }, + { + "method": "pane.get", + "required": [ + "method", + "params" + ], + "params": { + "$ref": "#/schemas/request/$defs/PaneTarget" + } + }, + { + "method": "pane.list", + "required": [ + "method", + "params" + ], + "params": { + "$ref": "#/schemas/request/$defs/PaneListParams" + } + }, + { + "method": "pane.move", + "required": [ + "method", + "params" + ], + "params": { + "$ref": "#/schemas/request/$defs/PaneMoveParams" + } + }, + { + "method": "pane.process_info", + "required": [ + "method", + "params" + ], + "params": { + "$ref": "#/schemas/request/$defs/PaneProcessInfoParams" + } + }, + { + "method": "pane.read", + "required": [ + "method", + "params" + ], + "params": { + "$ref": "#/schemas/request/$defs/PaneReadParams" + } + }, + { + "method": "pane.send_keys", + "required": [ + "method", + "params" + ], + "params": { + "$ref": "#/schemas/request/$defs/PaneSendKeysParams" + } + }, + { + "method": "pane.send_text", + "required": [ + "method", + "params" + ], + "params": { + "$ref": "#/schemas/request/$defs/PaneSendTextParams" + } + }, + { + "method": "session.snapshot", + "required": [ + "method", + "params" + ], + "params": { + "$ref": "#/schemas/request/$defs/EmptyParams" + } + } + ], + "methodResultTags": { + "agent.list": "agent_list", + "events.subscribe": "subscription_started", + "pane.get": "pane_info", + "pane.list": "pane_list", + "pane.move": "pane_move", + "pane.process_info": "pane_process_info", + "pane.read": "pane_read", + "pane.send_keys": "ok", + "pane.send_text": "ok", + "session.snapshot": "session_snapshot" + }, + "successEnvelope": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "id": { + "type": "string" + }, + "result": { + "oneOf": [ + { + "properties": { + "agents": { + "items": { + "$ref": "#/schemas/success_response/$defs/AgentInfo" + }, + "type": "array" + }, + "type": { + "const": "agent_list", + "type": "string" + } + }, + "required": [ + "type", + "agents" + ], + "type": "object" + }, + { + "properties": { + "type": { + "const": "subscription_started", + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + { + "properties": { + "pane": { + "$ref": "#/schemas/success_response/$defs/PaneInfo" + }, + "type": { + "const": "pane_info", + "type": "string" + } + }, + "required": [ + "type", + "pane" + ], + "type": "object" + }, + { + "properties": { + "panes": { + "items": { + "$ref": "#/schemas/success_response/$defs/PaneInfo" + }, + "type": "array" + }, + "type": { + "const": "pane_list", + "type": "string" + } + }, + "required": [ + "type", + "panes" + ], + "type": "object" + }, + { + "properties": { + "move_result": { + "$ref": "#/schemas/success_response/$defs/PaneMoveResult" + }, + "type": { + "const": "pane_move", + "type": "string" + } + }, + "required": [ + "type", + "move_result" + ], + "type": "object" + }, + { + "properties": { + "process_info": { + "$ref": "#/schemas/success_response/$defs/PaneProcessInfo" + }, + "type": { + "const": "pane_process_info", + "type": "string" + } + }, + "required": [ + "type", + "process_info" + ], + "type": "object" + }, + { + "properties": { + "read": { + "$ref": "#/schemas/success_response/$defs/PaneReadResult" + }, + "type": { + "const": "pane_read", + "type": "string" + } + }, + "required": [ + "type", + "read" + ], + "type": "object" + }, + { + "properties": { + "type": { + "const": "ok", + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + { + "properties": { + "type": { + "const": "ok", + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + { + "properties": { + "snapshot": { + "$ref": "#/schemas/success_response/$defs/SessionSnapshot" + }, + "type": { + "const": "session_snapshot", + "type": "string" + } + }, + "required": [ + "type", + "snapshot" + ], + "type": "object" + } + ] + } + }, + "required": [ + "id", + "result" + ], + "title": "SuccessResponse", + "type": "object" + }, + "selectedResultVariants": [ + { + "properties": { + "agents": { + "items": { + "$ref": "#/schemas/success_response/$defs/AgentInfo" + }, + "type": "array" + }, + "type": { + "const": "agent_list", + "type": "string" + } + }, + "required": [ + "type", + "agents" + ], + "type": "object" + }, + { + "properties": { + "type": { + "const": "subscription_started", + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + { + "properties": { + "pane": { + "$ref": "#/schemas/success_response/$defs/PaneInfo" + }, + "type": { + "const": "pane_info", + "type": "string" + } + }, + "required": [ + "type", + "pane" + ], + "type": "object" + }, + { + "properties": { + "panes": { + "items": { + "$ref": "#/schemas/success_response/$defs/PaneInfo" + }, + "type": "array" + }, + "type": { + "const": "pane_list", + "type": "string" + } + }, + "required": [ + "type", + "panes" + ], + "type": "object" + }, + { + "properties": { + "move_result": { + "$ref": "#/schemas/success_response/$defs/PaneMoveResult" + }, + "type": { + "const": "pane_move", + "type": "string" + } + }, + "required": [ + "type", + "move_result" + ], + "type": "object" + }, + { + "properties": { + "process_info": { + "$ref": "#/schemas/success_response/$defs/PaneProcessInfo" + }, + "type": { + "const": "pane_process_info", + "type": "string" + } + }, + "required": [ + "type", + "process_info" + ], + "type": "object" + }, + { + "properties": { + "read": { + "$ref": "#/schemas/success_response/$defs/PaneReadResult" + }, + "type": { + "const": "pane_read", + "type": "string" + } + }, + "required": [ + "type", + "read" + ], + "type": "object" + }, + { + "properties": { + "type": { + "const": "ok", + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + { + "properties": { + "type": { + "const": "ok", + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + { + "properties": { + "snapshot": { + "$ref": "#/schemas/success_response/$defs/SessionSnapshot" + }, + "type": { + "const": "session_snapshot", + "type": "string" + } + }, + "required": [ + "type", + "snapshot" + ], + "type": "object" + } + ], + "errorEnvelope": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "error": { + "$ref": "#/schemas/error_response/$defs/ErrorBody" + }, + "id": { + "type": "string" + } + }, + "required": [ + "id", + "error" + ], + "title": "ErrorResponse", + "type": "object" + }, + "eventEnvelope": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "data": { + "$ref": "#/schemas/event/$defs/EventData" + }, + "event": { + "$ref": "#/schemas/event/$defs/EventKind" + } + }, + "required": [ + "event", + "data" + ], + "title": "EventEnvelope", + "type": "object" + }, + "subscriptionEventEnvelope": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "data": { + "$ref": "#/schemas/subscription_event/$defs/SubscriptionEventData" + }, + "event": { + "$ref": "#/schemas/subscription_event/$defs/SubscriptionEventKind" + } + }, + "required": [ + "event", + "data" + ], + "title": "SubscriptionEventEnvelope", + "type": "object" + }, + "definitions": { + "error_response": { + "ErrorBody": { + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + } + }, + "event": { + "AgentSessionInfo": { + "properties": { + "agent": { + "type": "string" + }, + "kind": { + "$ref": "#/schemas/event/$defs/AgentSessionRefKind" + }, + "source": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "source", + "agent", + "kind", + "value" + ], + "type": "object" + }, + "AgentSessionRefKind": { + "enum": [ + "id", + "path" + ], + "type": "string" + }, + "AgentStatus": { + "enum": [ + "idle", + "working", + "blocked", + "done", + "unknown" + ], + "type": "string" + }, + "EventData": { + "oneOf": [ + { + "properties": { + "type": { + "const": "workspace_created", + "type": "string" + }, + "workspace": { + "$ref": "#/schemas/event/$defs/WorkspaceInfo" + } + }, + "required": [ + "type", + "workspace" + ], + "type": "object" + }, + { + "properties": { + "type": { + "const": "workspace_updated", + "type": "string" + }, + "workspace": { + "$ref": "#/schemas/event/$defs/WorkspaceInfo" + } + }, + "required": [ + "type", + "workspace" + ], + "type": "object" + }, + { + "properties": { + "type": { + "const": "workspace_metadata_updated", + "type": "string" + }, + "workspace": { + "$ref": "#/schemas/event/$defs/WorkspaceInfo" + } + }, + "required": [ + "type", + "workspace" + ], + "type": "object" + }, + { + "properties": { + "type": { + "const": "workspace_closed", + "type": "string" + }, + "workspace": { + "anyOf": [ + { + "$ref": "#/schemas/event/$defs/WorkspaceInfo" + }, + { + "type": "null" + } + ] + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "type", + "workspace_id" + ], + "type": "object" + }, + { + "properties": { + "label": { + "type": "string" + }, + "type": { + "const": "workspace_renamed", + "type": "string" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "type", + "workspace_id", + "label" + ], + "type": "object" + }, + { + "properties": { + "insert_index": { + "format": "uint", + "minimum": 0, + "type": "integer" + }, + "type": { + "const": "workspace_moved", + "type": "string" + }, + "workspace_id": { + "type": "string" + }, + "workspaces": { + "items": { + "$ref": "#/schemas/event/$defs/WorkspaceInfo" + }, + "type": "array" + } + }, + "required": [ + "type", + "workspace_id", + "insert_index", + "workspaces" + ], + "type": "object" + }, + { + "properties": { + "type": { + "const": "workspace_focused", + "type": "string" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "type", + "workspace_id" + ], + "type": "object" + }, + { + "properties": { + "type": { + "const": "worktree_created", + "type": "string" + }, + "workspace": { + "$ref": "#/schemas/event/$defs/WorkspaceInfo" + }, + "worktree": { + "$ref": "#/schemas/event/$defs/WorktreeInfo" + } + }, + "required": [ + "type", + "workspace", + "worktree" + ], + "type": "object" + }, + { + "properties": { + "already_open": { + "type": "boolean" + }, + "type": { + "const": "worktree_opened", + "type": "string" + }, + "workspace": { + "$ref": "#/schemas/event/$defs/WorkspaceInfo" + }, + "worktree": { + "$ref": "#/schemas/event/$defs/WorktreeInfo" + } + }, + "required": [ + "type", + "workspace", + "worktree", + "already_open" + ], + "type": "object" + }, + { + "properties": { + "forced": { + "type": "boolean" + }, + "type": { + "const": "worktree_removed", + "type": "string" + }, + "workspace": { + "anyOf": [ + { + "$ref": "#/schemas/event/$defs/WorkspaceInfo" + }, + { + "type": "null" + } + ] + }, + "workspace_id": { + "type": "string" + }, + "worktree": { + "$ref": "#/schemas/event/$defs/WorktreeInfo" + } + }, + "required": [ + "type", + "workspace_id", + "worktree", + "forced" + ], + "type": "object" + }, + { + "properties": { + "tab": { + "$ref": "#/schemas/event/$defs/TabInfo" + }, + "type": { + "const": "tab_created", + "type": "string" + } + }, + "required": [ + "type", + "tab" + ], + "type": "object" + }, + { + "properties": { + "tab_id": { + "type": "string" + }, + "type": { + "const": "tab_closed", + "type": "string" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "type", + "tab_id", + "workspace_id" + ], + "type": "object" + }, + { + "properties": { + "label": { + "type": "string" + }, + "tab_id": { + "type": "string" + }, + "type": { + "const": "tab_renamed", + "type": "string" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "type", + "tab_id", + "workspace_id", + "label" + ], + "type": "object" + }, + { + "properties": { + "insert_index": { + "format": "uint", + "minimum": 0, + "type": "integer" + }, + "tab_id": { + "type": "string" + }, + "tabs": { + "items": { + "$ref": "#/schemas/event/$defs/TabInfo" + }, + "type": "array" + }, + "type": { + "const": "tab_moved", + "type": "string" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "type", + "tab_id", + "workspace_id", + "insert_index", + "tabs" + ], + "type": "object" + }, + { + "properties": { + "tab_id": { + "type": "string" + }, + "type": { + "const": "tab_focused", + "type": "string" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "type", + "tab_id", + "workspace_id" + ], + "type": "object" + }, + { + "properties": { + "pane": { + "$ref": "#/schemas/event/$defs/PaneInfo" + }, + "type": { + "const": "pane_created", + "type": "string" + } + }, + "required": [ + "type", + "pane" + ], + "type": "object" + }, + { + "properties": { + "pane_id": { + "type": "string" + }, + "type": { + "const": "pane_closed", + "type": "string" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "type", + "pane_id", + "workspace_id" + ], + "type": "object" + }, + { + "properties": { + "pane": { + "$ref": "#/schemas/event/$defs/PaneInfo" + }, + "type": { + "const": "pane_updated", + "type": "string" + } + }, + "required": [ + "type", + "pane" + ], + "type": "object" + }, + { + "properties": { + "pane_id": { + "type": "string" + }, + "type": { + "const": "pane_focused", + "type": "string" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "type", + "pane_id", + "workspace_id" + ], + "type": "object" + }, + { + "properties": { + "closed_tab_id": { + "type": [ + "string", + "null" + ] + }, + "closed_workspace_id": { + "type": [ + "string", + "null" + ] + }, + "created_tab": { + "anyOf": [ + { + "$ref": "#/schemas/event/$defs/TabInfo" + }, + { + "type": "null" + } + ] + }, + "created_workspace": { + "anyOf": [ + { + "$ref": "#/schemas/event/$defs/WorkspaceInfo" + }, + { + "type": "null" + } + ] + }, + "pane": { + "$ref": "#/schemas/event/$defs/PaneInfo" + }, + "previous_pane_id": { + "type": "string" + }, + "previous_tab_id": { + "type": "string" + }, + "previous_workspace_id": { + "type": "string" + }, + "type": { + "const": "pane_moved", + "type": "string" + } + }, + "required": [ + "type", + "previous_pane_id", + "previous_workspace_id", + "previous_tab_id", + "pane" + ], + "type": "object" + }, + { + "properties": { + "pane_id": { + "type": "string" + }, + "revision": { + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "type": { + "const": "pane_output_changed", + "type": "string" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "type", + "pane_id", + "workspace_id", + "revision" + ], + "type": "object" + }, + { + "properties": { + "pane_id": { + "type": "string" + }, + "type": { + "const": "pane_exited", + "type": "string" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "type", + "pane_id", + "workspace_id" + ], + "type": "object" + }, + { + "properties": { + "agent": { + "type": [ + "string", + "null" + ] + }, + "final_status": { + "anyOf": [ + { + "$ref": "#/schemas/event/$defs/AgentStatus" + }, + { + "type": "null" + } + ] + }, + "pane_id": { + "type": "string" + }, + "released": { + "type": "boolean" + }, + "type": { + "const": "pane_agent_detected", + "type": "string" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "type", + "pane_id", + "workspace_id" + ], + "type": "object" + }, + { + "properties": { + "agent": { + "type": [ + "string", + "null" + ] + }, + "agent_status": { + "$ref": "#/schemas/event/$defs/AgentStatus" + }, + "display_agent": { + "type": [ + "string", + "null" + ] + }, + "pane_id": { + "type": "string" + }, + "state_labels": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "type": { + "const": "pane_agent_status_changed", + "type": "string" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "type", + "pane_id", + "workspace_id", + "agent_status" + ], + "type": "object" + }, + { + "properties": { + "layout": { + "$ref": "#/schemas/event/$defs/PaneLayoutSnapshot" + }, + "type": { + "const": "layout_updated", + "type": "string" + } + }, + "required": [ + "type", + "layout" + ], + "type": "object" + } + ] + }, + "EventKind": { + "enum": [ + "workspace_created", + "workspace_updated", + "workspace_metadata_updated", + "workspace_closed", + "workspace_renamed", + "workspace_moved", + "workspace_focused", + "worktree_created", + "worktree_opened", + "worktree_removed", + "tab_created", + "tab_closed", + "tab_renamed", + "tab_moved", + "tab_focused", + "pane_created", + "pane_closed", + "pane_updated", + "pane_focused", + "pane_moved", + "pane_output_changed", + "pane_exited", + "pane_agent_detected", + "pane_agent_status_changed", + "layout_updated" + ], + "type": "string" + }, + "PaneInfo": { + "properties": { + "agent": { + "type": [ + "string", + "null" + ] + }, + "agent_session": { + "anyOf": [ + { + "$ref": "#/schemas/event/$defs/AgentSessionInfo" + }, + { + "type": "null" + } + ] + }, + "agent_status": { + "$ref": "#/schemas/event/$defs/AgentStatus" + }, + "cwd": { + "type": [ + "string", + "null" + ] + }, + "display_agent": { + "type": [ + "string", + "null" + ] + }, + "focused": { + "type": "boolean" + }, + "foreground_cwd": { + "type": [ + "string", + "null" + ] + }, + "label": { + "type": [ + "string", + "null" + ] + }, + "pane_id": { + "type": "string" + }, + "revision": { + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "scroll": { + "anyOf": [ + { + "$ref": "#/schemas/event/$defs/PaneScrollInfo" + }, + { + "type": "null" + } + ] + }, + "state_labels": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + "tab_id": { + "type": "string" + }, + "terminal_id": { + "type": "string" + }, + "terminal_title": { + "type": [ + "string", + "null" + ] + }, + "terminal_title_stripped": { + "type": [ + "string", + "null" + ] + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "tokens": { + "additionalProperties": { + "type": "string" + }, + "maxProperties": 32, + "propertyNames": { + "pattern": "^[A-Za-z0-9_-]{1,32}$" + }, + "type": "object" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "pane_id", + "terminal_id", + "workspace_id", + "tab_id", + "focused", + "agent_status", + "revision" + ], + "type": "object" + }, + "PaneLayoutPane": { + "properties": { + "focused": { + "type": "boolean" + }, + "pane_id": { + "type": "string" + }, + "rect": { + "$ref": "#/schemas/event/$defs/PaneLayoutRect" + } + }, + "required": [ + "pane_id", + "focused", + "rect" + ], + "type": "object" + }, + "PaneLayoutRect": { + "properties": { + "height": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": "integer" + }, + "width": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": "integer" + }, + "x": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": "integer" + }, + "y": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "x", + "y", + "width", + "height" + ], + "type": "object" + }, + "PaneLayoutSnapshot": { + "properties": { + "area": { + "$ref": "#/schemas/event/$defs/PaneLayoutRect" + }, + "focused_pane_id": { + "type": "string" + }, + "panes": { + "items": { + "$ref": "#/schemas/event/$defs/PaneLayoutPane" + }, + "type": "array" + }, + "splits": { + "items": { + "$ref": "#/schemas/event/$defs/PaneLayoutSplit" + }, + "type": "array" + }, + "tab_id": { + "type": "string" + }, + "workspace_id": { + "type": "string" + }, + "zoomed": { + "type": "boolean" + } + }, + "required": [ + "workspace_id", + "tab_id", + "zoomed", + "area", + "focused_pane_id", + "panes", + "splits" + ], + "type": "object" + }, + "PaneLayoutSplit": { + "properties": { + "direction": { + "$ref": "#/schemas/event/$defs/SplitDirection" + }, + "id": { + "type": "string" + }, + "ratio": { + "format": "float", + "type": "number" + }, + "rect": { + "$ref": "#/schemas/event/$defs/PaneLayoutRect" + } + }, + "required": [ + "id", + "direction", + "ratio", + "rect" + ], + "type": "object" + }, + "PaneScrollInfo": { + "properties": { + "max_offset_from_bottom": { + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "offset_from_bottom": { + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "viewport_rows": { + "format": "uint64", + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "offset_from_bottom", + "max_offset_from_bottom", + "viewport_rows" + ], + "type": "object" + }, + "SplitDirection": { + "enum": [ + "right", + "down" + ], + "type": "string" + }, + "TabInfo": { + "properties": { + "agent_status": { + "$ref": "#/schemas/event/$defs/AgentStatus" + }, + "focused": { + "type": "boolean" + }, + "label": { + "type": "string" + }, + "number": { + "format": "uint", + "minimum": 0, + "type": "integer" + }, + "pane_count": { + "format": "uint", + "minimum": 0, + "type": "integer" + }, + "tab_id": { + "type": "string" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "tab_id", + "workspace_id", + "number", + "label", + "focused", + "pane_count", + "agent_status" + ], + "type": "object" + }, + "WorkspaceInfo": { + "properties": { + "active_tab_id": { + "type": "string" + }, + "agent_status": { + "$ref": "#/schemas/event/$defs/AgentStatus" + }, + "focused": { + "type": "boolean" + }, + "label": { + "type": "string" + }, + "number": { + "format": "uint", + "minimum": 0, + "type": "integer" + }, + "pane_count": { + "format": "uint", + "minimum": 0, + "type": "integer" + }, + "tab_count": { + "format": "uint", + "minimum": 0, + "type": "integer" + }, + "tokens": { + "additionalProperties": { + "type": "string" + }, + "maxProperties": 32, + "propertyNames": { + "pattern": "^[A-Za-z0-9_-]{1,32}$" + }, + "type": "object" + }, + "workspace_id": { + "type": "string" + }, + "worktree": { + "anyOf": [ + { + "$ref": "#/schemas/event/$defs/WorkspaceWorktreeInfo" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "workspace_id", + "number", + "label", + "focused", + "pane_count", + "tab_count", + "active_tab_id", + "agent_status" + ], + "type": "object" + }, + "WorkspaceWorktreeInfo": { + "properties": { + "checkout_path": { + "type": "string" + }, + "is_linked_worktree": { + "type": "boolean" + }, + "repo_key": { + "type": "string" + }, + "repo_name": { + "type": "string" + }, + "repo_root": { + "type": "string" + } + }, + "required": [ + "repo_key", + "repo_name", + "repo_root", + "checkout_path", + "is_linked_worktree" + ], + "type": "object" + }, + "WorktreeInfo": { + "properties": { + "branch": { + "type": [ + "string", + "null" + ] + }, + "is_bare": { + "type": "boolean" + }, + "is_detached": { + "type": "boolean" + }, + "is_linked_worktree": { + "type": "boolean" + }, + "is_prunable": { + "type": "boolean" + }, + "label": { + "type": "string" + }, + "open_workspace_id": { + "type": [ + "string", + "null" + ] + }, + "path": { + "type": "string" + } + }, + "required": [ + "path", + "is_bare", + "is_detached", + "is_prunable", + "is_linked_worktree", + "label" + ], + "type": "object" + } + }, + "request": { + "AgentStatus": { + "enum": [ + "idle", + "working", + "blocked", + "done", + "unknown" + ], + "type": "string" + }, + "EmptyParams": { + "type": "object" + }, + "EventsSubscribeParams": { + "properties": { + "subscriptions": { + "items": { + "$ref": "#/schemas/request/$defs/Subscription" + }, + "type": "array" + } + }, + "required": [ + "subscriptions" + ], + "type": "object" + }, + "OutputMatch": { + "oneOf": [ + { + "properties": { + "type": { + "const": "substring", + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "type", + "value" + ], + "type": "object" + }, + { + "properties": { + "type": { + "const": "regex", + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "type", + "value" + ], + "type": "object" + } + ] + }, + "PaneListParams": { + "properties": { + "workspace_id": { + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, + "PaneMoveDestination": { + "oneOf": [ + { + "properties": { + "ratio": { + "format": "float", + "type": [ + "number", + "null" + ] + }, + "split": { + "$ref": "#/schemas/request/$defs/SplitDirection" + }, + "tab_id": { + "type": "string" + }, + "target_pane_id": { + "type": [ + "string", + "null" + ] + }, + "type": { + "const": "tab", + "type": "string" + } + }, + "required": [ + "type", + "tab_id", + "split" + ], + "type": "object" + }, + { + "properties": { + "label": { + "type": [ + "string", + "null" + ] + }, + "type": { + "const": "new_tab", + "type": "string" + }, + "workspace_id": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "type" + ], + "type": "object" + }, + { + "properties": { + "label": { + "type": [ + "string", + "null" + ] + }, + "tab_label": { + "type": [ + "string", + "null" + ] + }, + "type": { + "const": "new_workspace", + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + } + ] + }, + "PaneMoveParams": { + "properties": { + "destination": { + "$ref": "#/schemas/request/$defs/PaneMoveDestination" + }, + "focus": { + "default": false, + "type": "boolean" + }, + "pane_id": { + "type": "string" + } + }, + "required": [ + "pane_id", + "destination" + ], + "type": "object" + }, + "PaneProcessInfoParams": { + "properties": { + "pane_id": { + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, + "PaneReadParams": { + "properties": { + "format": { + "$ref": "#/schemas/request/$defs/ReadFormat", + "default": "text" + }, + "lines": { + "format": "uint32", + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "pane_id": { + "type": "string" + }, + "source": { + "$ref": "#/schemas/request/$defs/ReadSource" + }, + "strip_ansi": { + "default": true, + "type": "boolean" + } + }, + "required": [ + "pane_id", + "source" + ], + "type": "object" + }, + "PaneSendKeysParams": { + "properties": { + "keys": { + "items": { + "type": "string" + }, + "type": "array" + }, + "pane_id": { + "type": "string" + } + }, + "required": [ + "pane_id", + "keys" + ], + "type": "object" + }, + "PaneSendTextParams": { + "properties": { + "pane_id": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": [ + "pane_id", + "text" + ], + "type": "object" + }, + "PaneTarget": { + "properties": { + "pane_id": { + "type": "string" + } + }, + "required": [ + "pane_id" + ], + "type": "object" + }, + "ReadFormat": { + "enum": [ + "text", + "ansi" + ], + "type": "string" + }, + "ReadSource": { + "enum": [ + "visible", + "recent", + "recent_unwrapped", + "detection" + ], + "type": "string" + }, + "SplitDirection": { + "enum": [ + "right", + "down" + ], + "type": "string" + }, + "Subscription": { + "oneOf": [ + { + "properties": { + "type": { + "const": "workspace.created", + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + { + "properties": { + "type": { + "const": "workspace.updated", + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + { + "properties": { + "type": { + "const": "workspace.metadata_updated", + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + { + "properties": { + "type": { + "const": "workspace.renamed", + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + { + "properties": { + "type": { + "const": "workspace.moved", + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + { + "properties": { + "type": { + "const": "workspace.closed", + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + { + "properties": { + "type": { + "const": "workspace.focused", + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + { + "properties": { + "type": { + "const": "worktree.created", + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + { + "properties": { + "type": { + "const": "worktree.opened", + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + { + "properties": { + "type": { + "const": "worktree.removed", + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + { + "properties": { + "type": { + "const": "tab.created", + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + { + "properties": { + "type": { + "const": "tab.closed", + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + { + "properties": { + "type": { + "const": "tab.focused", + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + { + "properties": { + "type": { + "const": "tab.renamed", + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + { + "properties": { + "type": { + "const": "tab.moved", + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + { + "properties": { + "type": { + "const": "pane.created", + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + { + "properties": { + "type": { + "const": "pane.closed", + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + { + "properties": { + "type": { + "const": "pane.updated", + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + { + "properties": { + "type": { + "const": "pane.focused", + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + { + "properties": { + "type": { + "const": "pane.moved", + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + { + "properties": { + "type": { + "const": "pane.exited", + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + { + "properties": { + "type": { + "const": "pane.agent_detected", + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + { + "properties": { + "lines": { + "format": "uint32", + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "match": { + "$ref": "#/schemas/request/$defs/OutputMatch" + }, + "pane_id": { + "type": "string" + }, + "source": { + "$ref": "#/schemas/request/$defs/ReadSource" + }, + "strip_ansi": { + "default": true, + "type": "boolean" + }, + "type": { + "const": "pane.output_matched", + "type": "string" + } + }, + "required": [ + "type", + "pane_id", + "source", + "match" + ], + "type": "object" + }, + { + "properties": { + "agent_status": { + "anyOf": [ + { + "$ref": "#/schemas/request/$defs/AgentStatus" + }, + { + "type": "null" + } + ] + }, + "pane_id": { + "type": "string" + }, + "type": { + "const": "pane.agent_status_changed", + "type": "string" + } + }, + "required": [ + "type", + "pane_id" + ], + "type": "object" + }, + { + "properties": { + "pane_id": { + "type": "string" + }, + "type": { + "const": "pane.scroll_changed", + "type": "string" + } + }, + "required": [ + "type", + "pane_id" + ], + "type": "object" + }, + { + "properties": { + "type": { + "const": "layout.updated", + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + } + ] + } + }, + "subscription_event": { + "AgentStatus": { + "enum": [ + "idle", + "working", + "blocked", + "done", + "unknown" + ], + "type": "string" + }, + "PaneAgentStatusChangedEvent": { + "properties": { + "agent": { + "type": [ + "string", + "null" + ] + }, + "agent_status": { + "$ref": "#/schemas/subscription_event/$defs/AgentStatus" + }, + "display_agent": { + "type": [ + "string", + "null" + ] + }, + "pane_id": { + "type": "string" + }, + "state_labels": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "pane_id", + "workspace_id", + "agent_status" + ], + "type": "object" + }, + "PaneOutputMatchedEvent": { + "properties": { + "matched_line": { + "type": "string" + }, + "pane_id": { + "type": "string" + }, + "read": { + "$ref": "#/schemas/subscription_event/$defs/PaneReadResult" + } + }, + "required": [ + "pane_id", + "matched_line", + "read" + ], + "type": "object" + }, + "PaneReadResult": { + "properties": { + "format": { + "$ref": "#/schemas/subscription_event/$defs/ReadFormat" + }, + "pane_id": { + "type": "string" + }, + "revision": { + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "source": { + "$ref": "#/schemas/subscription_event/$defs/ReadSource" + }, + "tab_id": { + "type": "string" + }, + "text": { + "type": "string" + }, + "truncated": { + "type": "boolean" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "pane_id", + "workspace_id", + "tab_id", + "source", + "format", + "text", + "revision", + "truncated" + ], + "type": "object" + }, + "PaneScrollChangedEvent": { + "properties": { + "pane_id": { + "type": "string" + }, + "scroll": { + "$ref": "#/schemas/subscription_event/$defs/PaneScrollInfo" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "pane_id", + "workspace_id", + "scroll" + ], + "type": "object" + }, + "PaneScrollInfo": { + "properties": { + "max_offset_from_bottom": { + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "offset_from_bottom": { + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "viewport_rows": { + "format": "uint64", + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "offset_from_bottom", + "max_offset_from_bottom", + "viewport_rows" + ], + "type": "object" + }, + "ReadFormat": { + "enum": [ + "text", + "ansi" + ], + "type": "string" + }, + "ReadSource": { + "enum": [ + "visible", + "recent", + "recent_unwrapped", + "detection" + ], + "type": "string" + }, + "SubscriptionEventData": { + "anyOf": [ + { + "$ref": "#/schemas/subscription_event/$defs/PaneOutputMatchedEvent" + }, + { + "$ref": "#/schemas/subscription_event/$defs/PaneAgentStatusChangedEvent" + }, + { + "$ref": "#/schemas/subscription_event/$defs/PaneScrollChangedEvent" + } + ] + }, + "SubscriptionEventKind": { + "enum": [ + "pane.output_matched", + "pane.agent_status_changed", + "pane.scroll_changed" + ], + "type": "string" + } + }, + "success_response": { + "AgentInfo": { + "properties": { + "agent": { + "type": [ + "string", + "null" + ] + }, + "agent_session": { + "anyOf": [ + { + "$ref": "#/schemas/success_response/$defs/AgentSessionInfo" + }, + { + "type": "null" + } + ] + }, + "agent_status": { + "$ref": "#/schemas/success_response/$defs/AgentStatus" + }, + "cwd": { + "type": [ + "string", + "null" + ] + }, + "display_agent": { + "type": [ + "string", + "null" + ] + }, + "focused": { + "type": "boolean" + }, + "foreground_cwd": { + "type": [ + "string", + "null" + ] + }, + "interactive_ready": { + "type": "boolean" + }, + "launch_pending": { + "type": "boolean" + }, + "name": { + "type": [ + "string", + "null" + ] + }, + "pane_id": { + "type": "string" + }, + "revision": { + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "screen_detection_skipped": { + "type": "boolean" + }, + "state_change_seq": { + "default": 0, + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "state_labels": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + "tab_id": { + "type": "string" + }, + "terminal_id": { + "type": "string" + }, + "terminal_title": { + "type": [ + "string", + "null" + ] + }, + "terminal_title_stripped": { + "type": [ + "string", + "null" + ] + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "tokens": { + "additionalProperties": { + "type": "string" + }, + "maxProperties": 32, + "propertyNames": { + "pattern": "^[A-Za-z0-9_-]{1,32}$" + }, + "type": "object" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "terminal_id", + "agent_status", + "workspace_id", + "tab_id", + "pane_id", + "focused", + "revision" + ], + "type": "object" + }, + "AgentSessionInfo": { + "properties": { + "agent": { + "type": "string" + }, + "kind": { + "$ref": "#/schemas/success_response/$defs/AgentSessionRefKind" + }, + "source": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "source", + "agent", + "kind", + "value" + ], + "type": "object" + }, + "AgentSessionRefKind": { + "enum": [ + "id", + "path" + ], + "type": "string" + }, + "AgentStatus": { + "enum": [ + "idle", + "working", + "blocked", + "done", + "unknown" + ], + "type": "string" + }, + "PaneInfo": { + "properties": { + "agent": { + "type": [ + "string", + "null" + ] + }, + "agent_session": { + "anyOf": [ + { + "$ref": "#/schemas/success_response/$defs/AgentSessionInfo" + }, + { + "type": "null" + } + ] + }, + "agent_status": { + "$ref": "#/schemas/success_response/$defs/AgentStatus" + }, + "cwd": { + "type": [ + "string", + "null" + ] + }, + "display_agent": { + "type": [ + "string", + "null" + ] + }, + "focused": { + "type": "boolean" + }, + "foreground_cwd": { + "type": [ + "string", + "null" + ] + }, + "label": { + "type": [ + "string", + "null" + ] + }, + "pane_id": { + "type": "string" + }, + "revision": { + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "scroll": { + "anyOf": [ + { + "$ref": "#/schemas/success_response/$defs/PaneScrollInfo" + }, + { + "type": "null" + } + ] + }, + "state_labels": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + "tab_id": { + "type": "string" + }, + "terminal_id": { + "type": "string" + }, + "terminal_title": { + "type": [ + "string", + "null" + ] + }, + "terminal_title_stripped": { + "type": [ + "string", + "null" + ] + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "tokens": { + "additionalProperties": { + "type": "string" + }, + "maxProperties": 32, + "propertyNames": { + "pattern": "^[A-Za-z0-9_-]{1,32}$" + }, + "type": "object" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "pane_id", + "terminal_id", + "workspace_id", + "tab_id", + "focused", + "agent_status", + "revision" + ], + "type": "object" + }, + "PaneLayoutPane": { + "properties": { + "focused": { + "type": "boolean" + }, + "pane_id": { + "type": "string" + }, + "rect": { + "$ref": "#/schemas/success_response/$defs/PaneLayoutRect" + } + }, + "required": [ + "pane_id", + "focused", + "rect" + ], + "type": "object" + }, + "PaneLayoutRect": { + "properties": { + "height": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": "integer" + }, + "width": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": "integer" + }, + "x": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": "integer" + }, + "y": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "x", + "y", + "width", + "height" + ], + "type": "object" + }, + "PaneLayoutSnapshot": { + "properties": { + "area": { + "$ref": "#/schemas/success_response/$defs/PaneLayoutRect" + }, + "focused_pane_id": { + "type": "string" + }, + "panes": { + "items": { + "$ref": "#/schemas/success_response/$defs/PaneLayoutPane" + }, + "type": "array" + }, + "splits": { + "items": { + "$ref": "#/schemas/success_response/$defs/PaneLayoutSplit" + }, + "type": "array" + }, + "tab_id": { + "type": "string" + }, + "workspace_id": { + "type": "string" + }, + "zoomed": { + "type": "boolean" + } + }, + "required": [ + "workspace_id", + "tab_id", + "zoomed", + "area", + "focused_pane_id", + "panes", + "splits" + ], + "type": "object" + }, + "PaneLayoutSplit": { + "properties": { + "direction": { + "$ref": "#/schemas/success_response/$defs/SplitDirection" + }, + "id": { + "type": "string" + }, + "ratio": { + "format": "float", + "type": "number" + }, + "rect": { + "$ref": "#/schemas/success_response/$defs/PaneLayoutRect" + } + }, + "required": [ + "id", + "direction", + "ratio", + "rect" + ], + "type": "object" + }, + "PaneMoveReason": { + "enum": [ + "same_tab", + "zoomed_tab" + ], + "type": "string" + }, + "PaneMoveResult": { + "properties": { + "changed": { + "type": "boolean" + }, + "closed_tab_id": { + "type": [ + "string", + "null" + ] + }, + "closed_workspace_id": { + "type": [ + "string", + "null" + ] + }, + "created_tab": { + "anyOf": [ + { + "$ref": "#/schemas/success_response/$defs/TabInfo" + }, + { + "type": "null" + } + ] + }, + "created_workspace": { + "anyOf": [ + { + "$ref": "#/schemas/success_response/$defs/WorkspaceInfo" + }, + { + "type": "null" + } + ] + }, + "focused_pane_id": { + "type": "string" + }, + "pane": { + "$ref": "#/schemas/success_response/$defs/PaneInfo" + }, + "previous_pane_id": { + "type": "string" + }, + "previous_tab_id": { + "type": "string" + }, + "previous_workspace_id": { + "type": "string" + }, + "reason": { + "anyOf": [ + { + "$ref": "#/schemas/success_response/$defs/PaneMoveReason" + }, + { + "type": "null" + } + ] + }, + "source_layout": { + "anyOf": [ + { + "$ref": "#/schemas/success_response/$defs/PaneLayoutSnapshot" + }, + { + "type": "null" + } + ] + }, + "target_layout": { + "$ref": "#/schemas/success_response/$defs/PaneLayoutSnapshot" + } + }, + "required": [ + "changed", + "previous_pane_id", + "previous_workspace_id", + "previous_tab_id", + "pane", + "target_layout", + "focused_pane_id" + ], + "type": "object" + }, + "PaneProcessInfo": { + "properties": { + "foreground_process_group_id": { + "format": "uint32", + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "foreground_processes": { + "items": { + "$ref": "#/schemas/success_response/$defs/PaneProcessInfoProcess" + }, + "type": "array" + }, + "pane_id": { + "type": "string" + }, + "shell_pid": { + "format": "uint32", + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "tty": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "pane_id" + ], + "type": "object" + }, + "PaneProcessInfoProcess": { + "properties": { + "argv": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "argv0": { + "type": [ + "string", + "null" + ] + }, + "cmdline": { + "type": [ + "string", + "null" + ] + }, + "cwd": { + "type": [ + "string", + "null" + ] + }, + "name": { + "type": "string" + }, + "pid": { + "format": "uint32", + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "pid", + "name" + ], + "type": "object" + }, + "PaneReadResult": { + "properties": { + "format": { + "$ref": "#/schemas/success_response/$defs/ReadFormat" + }, + "pane_id": { + "type": "string" + }, + "revision": { + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "source": { + "$ref": "#/schemas/success_response/$defs/ReadSource" + }, + "tab_id": { + "type": "string" + }, + "text": { + "type": "string" + }, + "truncated": { + "type": "boolean" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "pane_id", + "workspace_id", + "tab_id", + "source", + "format", + "text", + "revision", + "truncated" + ], + "type": "object" + }, + "PaneScrollInfo": { + "properties": { + "max_offset_from_bottom": { + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "offset_from_bottom": { + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "viewport_rows": { + "format": "uint64", + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "offset_from_bottom", + "max_offset_from_bottom", + "viewport_rows" + ], + "type": "object" + }, + "ReadFormat": { + "enum": [ + "text", + "ansi" + ], + "type": "string" + }, + "ReadSource": { + "enum": [ + "visible", + "recent", + "recent_unwrapped", + "detection" + ], + "type": "string" + }, + "SessionSnapshot": { + "properties": { + "agents": { + "items": { + "$ref": "#/schemas/success_response/$defs/AgentInfo" + }, + "type": "array" + }, + "focused_pane_id": { + "type": [ + "string", + "null" + ] + }, + "focused_tab_id": { + "type": [ + "string", + "null" + ] + }, + "focused_workspace_id": { + "type": [ + "string", + "null" + ] + }, + "layouts": { + "items": { + "$ref": "#/schemas/success_response/$defs/PaneLayoutSnapshot" + }, + "type": "array" + }, + "panes": { + "items": { + "$ref": "#/schemas/success_response/$defs/PaneInfo" + }, + "type": "array" + }, + "protocol": { + "format": "uint32", + "minimum": 0, + "type": "integer" + }, + "tabs": { + "items": { + "$ref": "#/schemas/success_response/$defs/TabInfo" + }, + "type": "array" + }, + "version": { + "type": "string" + }, + "workspaces": { + "items": { + "$ref": "#/schemas/success_response/$defs/WorkspaceInfo" + }, + "type": "array" + } + }, + "required": [ + "version", + "protocol", + "workspaces", + "tabs", + "panes", + "layouts", + "agents" + ], + "type": "object" + }, + "SplitDirection": { + "enum": [ + "right", + "down" + ], + "type": "string" + }, + "TabInfo": { + "properties": { + "agent_status": { + "$ref": "#/schemas/success_response/$defs/AgentStatus" + }, + "focused": { + "type": "boolean" + }, + "label": { + "type": "string" + }, + "number": { + "format": "uint", + "minimum": 0, + "type": "integer" + }, + "pane_count": { + "format": "uint", + "minimum": 0, + "type": "integer" + }, + "tab_id": { + "type": "string" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "tab_id", + "workspace_id", + "number", + "label", + "focused", + "pane_count", + "agent_status" + ], + "type": "object" + }, + "WorkspaceInfo": { + "properties": { + "active_tab_id": { + "type": "string" + }, + "agent_status": { + "$ref": "#/schemas/success_response/$defs/AgentStatus" + }, + "focused": { + "type": "boolean" + }, + "label": { + "type": "string" + }, + "number": { + "format": "uint", + "minimum": 0, + "type": "integer" + }, + "pane_count": { + "format": "uint", + "minimum": 0, + "type": "integer" + }, + "tab_count": { + "format": "uint", + "minimum": 0, + "type": "integer" + }, + "tokens": { + "additionalProperties": { + "type": "string" + }, + "maxProperties": 32, + "propertyNames": { + "pattern": "^[A-Za-z0-9_-]{1,32}$" + }, + "type": "object" + }, + "workspace_id": { + "type": "string" + }, + "worktree": { + "anyOf": [ + { + "$ref": "#/schemas/success_response/$defs/WorkspaceWorktreeInfo" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "workspace_id", + "number", + "label", + "focused", + "pane_count", + "tab_count", + "active_tab_id", + "agent_status" + ], + "type": "object" + }, + "WorkspaceWorktreeInfo": { + "properties": { + "checkout_path": { + "type": "string" + }, + "is_linked_worktree": { + "type": "boolean" + }, + "repo_key": { + "type": "string" + }, + "repo_name": { + "type": "string" + }, + "repo_root": { + "type": "string" + } + }, + "required": [ + "repo_key", + "repo_name", + "repo_root", + "checkout_path", + "is_linked_worktree" + ], + "type": "object" + } + } + } + }, + "control": { + "transport": { + "executable": "verified absolute path", + "argv": [ + "--session", + "", + "terminal", + "session", + "control", + "", + "--cols", + "<1..1000>", + "--rows", + "<1..1000>" + ], + "stdin": "newline-delimited JSON", + "stdout": "newline-delimited JSON", + "shell": false, + "pty": false, + "takeover": false + }, + "inputVariants": [ + { + "type": { + "const": "terminal.input" + }, + "text": { + "type": "string" + }, + "exclusiveWith": "bytes" + }, + { + "type": { + "const": "terminal.input" + }, + "bytes": { + "type": "string", + "encoding": "base64" + }, + "exclusiveWith": "text" + } + ], + "resize": { + "type": { + "const": "terminal.resize" + }, + "cols": { + "type": "integer", + "minimum": 1 + }, + "rows": { + "type": "integer", + "minimum": 1 + } + }, + "scroll": { + "type": { + "const": "terminal.scroll" + }, + "direction": { + "enum": [ + "up", + "down" + ] + }, + "lines": { + "type": "integer", + "minimum": 1 + } + }, + "release": { + "type": { + "const": "terminal.release" + }, + "effect": "controller exits and no-takeover reacquisition succeeds" + }, + "frame": { + "required": [ + "type", + "seq", + "encoding", + "width", + "height", + "full", + "bytes" + ], + "properties": { + "type": { + "const": "terminal.frame" + }, + "seq": { + "type": "integer", + "ordering": "strictly contiguous within one controller" + }, + "encoding": { + "const": "ansi" + }, + "width": { + "type": "integer", + "minimum": 1 + }, + "height": { + "type": "integer", + "minimum": 1 + }, + "full": { + "type": "boolean" + }, + "bytes": { + "type": "string", + "encoding": "base64" + } + } + }, + "closed": { + "required": [ + "type", + "reason" + ], + "properties": { + "type": { + "const": "terminal.closed" + }, + "reason": { + "type": "string" + } + } + }, + "busy": { + "record": "terminal.closed", + "reasonContains": "already has an attached client", + "processExitCodeAuthoritative": false + }, + "sourceSelection": { + "namedSessionRequired": true, + "missingSnapshotFails": true, + "missingStatusNotRunning": true, + "missingControlFails": true, + "postControlSocketAbsent": true + } + } + }, + "observed": { + "version": "herdr 0.7.5", + "schema": { + "protocol": 17, + "schemaVersion": 1, + "canonicalSha256": "7cb5b7086f5dd04adb8b7b2069042afd7214da87f6bca66e2b07ff8aa95f6f6f", + "byteLength": 248358, + "requiredMethodsPresent": true, + "rawTerminalControlPublished": false, + "semanticReferenceClosure": true + }, + "initialSnapshotEmpty": true, + "noAutoStart": { + "missingSnapshotExitNonzero": true, + "missingStatusReportsNotRunning": true, + "missingControlExitNonzero": true, + "postControlStatusReportsNotRunning": true, + "postControlSessionNotRunning": true, + "socketAbsentAfterControl": true, + "noAutoStart": true + }, + "control": { + "firstCode": 0, + "secondCode": 0, + "releaseObserved": true, + "secondControllerRejected": true, + "noTakeoverReacquisition": true, + "takeoverUsed": false, + "frameObserved": true, + "sequence": { + "firstIsOne": true, + "contiguous": true, + "lastAtLeastFirst": true + }, + "shapeValidated": true, + "validBase64BeforeAllocation": true, + "encodedWithinLimit": true, + "decodedWithinLimit": true, + "cumulativeDecodedWithinLimit": true, + "ansiEvidence": true, + "utf8Evidence": true, + "exactInputOccurrences": 1, + "reacquisitionFrames": { + "frameObserved": true, + "sequence": { + "firstIsOne": true, + "contiguous": true, + "lastAtLeastFirst": true + }, + "shapeValidated": true, + "validBase64BeforeAllocation": true, + "encodedWithinLimit": true, + "decodedWithinLimit": true, + "cumulativeDecodedWithinLimit": true, + "ansiEvidence": true, + "utf8Evidence": true + }, + "resizeFrameObserved": true, + "paneMarkerOccurrences": 1, + "stderrEmpty": true + }, + "abruptEofRecovery": { + "eofExitObserved": true, + "recoveredWithoutTakeover": true, + "eofFrames": { + "frameObserved": true, + "sequence": { + "firstIsOne": true, + "contiguous": true, + "lastAtLeastFirst": true + }, + "shapeValidated": true, + "validBase64BeforeAllocation": true, + "encodedWithinLimit": true, + "decodedWithinLimit": true, + "cumulativeDecodedWithinLimit": true, + "ansiEvidence": true, + "utf8Evidence": true + }, + "recoveryFrames": { + "frameObserved": true, + "sequence": { + "firstIsOne": true, + "contiguous": true, + "lastAtLeastFirst": true + }, + "shapeValidated": true, + "validBase64BeforeAllocation": true, + "encodedWithinLimit": true, + "decodedWithinLimit": true, + "cumulativeDecodedWithinLimit": true, + "ansiEvidence": true, + "utf8Evidence": true + } + }, + "invalidControl": { + "malformedJsonRejected": true, + "conflictingInputRejected": true, + "invalidBase64Rejected": true, + "zeroResizeRejected": true, + "dimensionsUnchanged": true, + "releaseExitZero": true, + "recoveredWithoutTakeover": true + }, + "frameGuards": { + "missingSequenceRejected": true, + "sequenceGapRejected": true, + "invalidBase64Rejected": true, + "encodedOversizeRejected": true, + "decodedOversizeRejected": true + }, + "moveIdentity": { + "publicPaneChanged": true, + "oldPaneAliasResolvesCurrent": true, + "terminalStable": true, + "hierarchyChanged": true + }, + "splitCloseRecreateIdentity": { + "firstSplitResolved": true, + "closedPaneInvalidated": true, + "recreatedPaneChanged": true, + "recreatedTerminalChanged": true, + "originalTerminalStable": true + }, + "processIdentity": { + "shellPidStable": true, + "foregroundChanged": true, + "foregroundRestored": true, + "paneRevisionFieldPublished": false, + "measuredPaneRevisions": [ + null, + null, + null + ] + }, + "coldRestartIdentity": { + "workspaceRestored": true, + "publicPaneRestored": true, + "terminalChanged": true, + "priorTerminalInvalidated": true + }, + "serverStoppedForCleanup": true + }, + "inferred": {}, + "unsupported": { + "localAgentReplacement": "not exposed by verified public CLI and therefore not authorized for writable control", + "liveHandoff": "not safely triggerable by verified public CLI", + "slowConsumer": "upstream backpressure cannot be deterministically injected; the product bridge must release on its own queue limit", + "sequenceGapInjection": "not safely injectable upstream; the parser guard rejects any observed gap" + }, + "failClosedLimitations": { + "localAgentReplacement": "product integration must reject writable control when this condition cannot be observed and freshly revalidated", + "liveHandoff": "product integration must reject writable control when this condition cannot be observed and freshly revalidated", + "slowConsumer": "product integration must reject writable control when this condition cannot be observed and freshly revalidated", + "sequenceGapInjection": "product integration must reject writable control when this condition cannot be observed and freshly revalidated" + }, + "cleanup": { + "sessionStopped": true, + "sessionDeleted": true, + "missingSessionDeleted": true, + "sessionAbsent": true, + "socketAbsent": true, + "childrenClosed": true, + "runRootRemoved": true, + "signalFinalizerInstalled": true, + "postSignalProductSpawns": 0, + "postStateClean": true + }, + "sanitization": { + "secretMarkerExcluded": true, + "pathMarkerExcluded": true, + "homePathExcluded": true + }, + "go": true, + "failures": [] +} diff --git a/server/modules/terminal-runtimes/tests/herdr-client-probe.service.test.ts b/server/modules/terminal-runtimes/tests/herdr-client-probe.service.test.ts new file mode 100644 index 0000000..a04ecf9 --- /dev/null +++ b/server/modules/terminal-runtimes/tests/herdr-client-probe.service.test.ts @@ -0,0 +1,215 @@ +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import { readFile } from 'node:fs/promises'; +import test from 'node:test'; + +import { HERDR_LIMITS, HerdrClient, type HerdrSpawn } from '../herdr-client.service.js'; +import type { HerdrConfiguredSource } from '../herdr-config.service.js'; +import { + HERDR_SEMANTIC_FINGERPRINT, + HERDR_VERSION, + canonicalJsonSha256, + probeHerdrCompatibility, + selectedHerdrSemanticManifest, + semanticFingerprint, +} from '../herdr-probe.service.js'; + +const source: HerdrConfiguredSource = { alias: 'alpha', sourceId: 'hsrc_jtP2rWhblZ6tcCJRjhr3bA', selector: 'work', binary: '/opt/herdr/herdr' }; +const ok = { code: 0, stdout: '', stderr: '', timedOut: false, oversized: false, spawnError: false }; + +type SpawnCall = { command: string; args: readonly string[]; options: Parameters[2] }; +function child(): { process: unknown; stdout: EventEmitter; stderr: EventEmitter; stdin: { end(value?: string): void }; kills: string[] } { + const stdout = new EventEmitter(); + const stderr = new EventEmitter(); + const process = new EventEmitter() as EventEmitter & { stdout: EventEmitter; stderr: EventEmitter; stdin: { end(value?: string): void }; kill(signal: string): void }; + const kills: string[] = []; + process.stdout = stdout; + process.stderr = stderr; + process.stdin = { end: () => undefined }; + process.kill = (signal) => { kills.push(signal); }; + return { process, stdout, stderr, stdin: process.stdin, kills }; +} + +async function fixture(): Promise> { + return JSON.parse(await readFile(new URL('./fixtures/herdr/v0.7.5-phase0.json', import.meta.url), 'utf8')) as Record; +} +async function reviewedSchema(): Promise> { + const report = await fixture() as { semanticManifest: { + protocol: number; + schemaVersion: string; + api: { + requests: Array<{ method: string; required: string[]; params: unknown }>; + successEnvelope: Record; + selectedResultVariants: unknown[]; + errorEnvelope: Record; + eventEnvelope: Record; + subscriptionEventEnvelope: Record; + definitions: Record>; + }; + } }; + const { api, protocol, schemaVersion } = report.semanticManifest; + return { + protocol, + schema_version: schemaVersion, + schemas: { + request: { + oneOf: api.requests.map((request) => ({ + required: request.required, + properties: { method: { const: request.method }, params: request.params }, + })), + $defs: api.definitions.request, + }, + success_response: { + ...api.successEnvelope, + $defs: { ...api.definitions.success_response, ResponseResult: { oneOf: api.selectedResultVariants } }, + }, + error_response: { ...api.errorEnvelope, $defs: api.definitions.error_response }, + event: { ...api.eventEnvelope, $defs: api.definitions.event }, + subscription_event: { ...api.subscriptionEventEnvelope, $defs: api.definitions.subscription_event }, + }, + }; +} + +function compatibleClient(schema: unknown, overrides: Partial> = {}): HerdrClient { + return { + version: async () => ({ ...ok, stdout: `herdr ${HERDR_VERSION}\n`, ...overrides.version }), + schema: async () => ({ ...ok, stdout: JSON.stringify(schema), ...overrides.schema }), + status: async () => ({ ...ok, ...overrides.status }), + snapshot: async () => ({ ...ok, ...overrides.snapshot }), + } as unknown as HerdrClient; +} + +test('Herdr client uses exact non-shell argv for all supported reads and actions', async () => { + const calls: SpawnCall[] = []; + const client = new HerdrClient(((command: string, args: readonly string[], options: Parameters[2]) => { + calls.push({ command, args, options }); + const fake = child(); + queueMicrotask(() => (fake.process as EventEmitter).emit('close', 0)); + return fake.process as never; + }) as never); + await Promise.all([ + client.schema(source), client.status(source), client.snapshot(source), client.paneGet(source, 'pane_1'), client.paneRead(source, 'pane_1'), + client.paneProcessInfo(source, 'pane_1'), client.paneSendText(source, 'pane_1', 'hello'), client.paneSendKeys(source, 'pane_1', ['CTRL-C', 'enter']), + ]); + assert.deepEqual(calls.map(({ command, args }) => [command, args]), [ + [source.binary, ['api', 'schema', '--json']], + [source.binary, ['--session', 'work', 'status', 'server']], + [source.binary, ['--session', 'work', 'api', 'snapshot']], + [source.binary, ['--session', 'work', 'pane', 'get', 'pane_1']], + [source.binary, ['--session', 'work', 'pane', 'read', 'pane_1', '--source', 'visible', '--ansi']], + [source.binary, ['--session', 'work', 'pane', 'process-info', '--pane', 'pane_1']], + [source.binary, ['--session', 'work', 'pane', 'send-text', 'pane_1', 'hello']], + [source.binary, ['--session', 'work', 'pane', 'send-keys', 'pane_1', 'CTRL-C', 'enter']], + ]); + assert.ok(calls.every(({ options }) => options.shell === false && options.stdio === 'pipe' && options.windowsHide === true)); + assert.deepEqual(client.controllerArgv(source, 'pane_1', 120, 40), ['--session', 'work', 'terminal', 'session', 'control', 'pane_1', '--cols', '120', '--rows', '40']); + assert.throws(() => client.paneGet(source, '../bad'), /invalid_pane_id/); + assert.throws(() => client.paneSendText(source, 'pane_1', ''), /invalid_input/); + assert.throws(() => client.paneSendKeys(source, 'pane_1', []), /invalid_keys/); + assert.throws(() => client.controllerArgv(source, 'pane_1', 0, 40), /invalid_controller_target/); + assert.equal(Object.getOwnPropertyNames(HerdrClient.prototype).some((name) => /create|start|stop|kill|lifecycle/i.test(name)), false); +}); +test('Herdr client preserves UTF-8 split across stdout chunks', async () => { + const expected = '한글🐑'; + const encoded = Buffer.from(expected, 'utf8'); + const client = new HerdrClient((() => { + const fake = child(); + queueMicrotask(() => { + fake.stdout.emit('data', encoded.subarray(0, 2)); + fake.stdout.emit('data', encoded.subarray(2, 7)); + fake.stdout.emit('data', encoded.subarray(7)); + (fake.process as EventEmitter).emit('close', 0); + }); + return fake.process as never; + }) as never); + const result = await client.paneRead(source, 'pane_1'); + assert.equal(result.stdout, expected); +}); + +test('Herdr client reports spawn errors and enforces output, timeout, and NDJSON line bounds', async () => { + const spawnFailure = new HerdrClient((() => { throw new Error('missing'); }) as never); + assert.deepEqual(await spawnFailure.version(source), { code: null, stdout: '', stderr: '', timedOut: false, oversized: false, spawnError: true }); + + const fake = child(); + const clock = { setTimeout: ((callback: () => void) => { queueMicrotask(callback); return 1 as never; }) as unknown as typeof setTimeout, clearTimeout: (() => undefined) as typeof clearTimeout }; + const timeoutClient = new HerdrClient((() => fake.process as never) as never, clock); + const pending = timeoutClient.version(source); + queueMicrotask(() => (fake.process as EventEmitter).emit('close', null)); + const timedOut = await pending; + assert.equal(timedOut.timedOut, true); + assert.deepEqual(fake.kills, ['SIGKILL']); + + const oversized = child(); + const bounded = new HerdrClient((() => { + queueMicrotask(() => { oversized.stdout.emit('data', Buffer.alloc(HERDR_LIMITS.version.stdoutBytes + 1)); (oversized.process as EventEmitter).emit('close', null); }); + return oversized.process as never; + }) as never); + assert.equal((await bounded.version(source)).oversized, true); + const stderrOverflowProcess = child(); + const stderrBounded = new HerdrClient((() => { + queueMicrotask(() => { + stderrOverflowProcess.stderr.emit('data', Buffer.alloc(HERDR_LIMITS.version.stderrBytes + 1, 0x78)); + (stderrOverflowProcess.process as EventEmitter).emit('close', null); + }); + return stderrOverflowProcess.process as never; + }) as never); + const stderrOverflow = await stderrBounded.version(source); + assert.equal(stderrOverflow.stderrOverflow, true); + assert.equal(stderrOverflow.oversized, true); + assert.equal(Buffer.byteLength(stderrOverflow.stderr, 'utf8'), HERDR_LIMITS.version.stderrBytes); + assert.deepEqual(stderrOverflowProcess.kills, ['SIGKILL']); + assert.equal(await bounded.ndjson({ ...ok, stdout: `${'x'.repeat(HERDR_LIMITS.ndjsonLineBytes + 1)}\n` }), null); + assert.equal(await bounded.ndjson({ ...ok, stdout: '{bad}\n' }), null); + const stderrTooLarge = await probeHerdrCompatibility(source, { discovery: true, output: true, actions: true, attach: true, create: false }, compatibleClient(await fixture(), { schema: { ...ok, stderr: 'x'.repeat(HERDR_LIMITS.schema.stderrBytes + 1) } }), 'linux', 'x64'); + assert.equal(stderrTooLarge.reasonCode, 'schema_unavailable'); +}); + +test('Phase0 semantic fingerprint is stable for additive unrelated schemas and changes for selected contracts', async () => { + const phase0 = await reviewedSchema(); + assert.equal(semanticFingerprint(phase0), HERDR_SEMANTIC_FINGERPRINT); + const additive = structuredClone(phase0) as { schemas: Record }; + additive.schemas.unrelated_future_schema = { type: 'object', properties: { ignored: { type: 'string' } } }; + assert.equal(semanticFingerprint(additive), HERDR_SEMANTIC_FINGERPRINT); + + const requestDrift = structuredClone(phase0) as { schemas: { request: { oneOf: Array<{ properties: { method: { const: string } } }> } } }; + requestDrift.schemas.request.oneOf.find((entry) => entry.properties.method.const === 'pane.get')!.properties.method.const = 'pane.get.changed'; + assert.notEqual(semanticFingerprint(requestDrift), HERDR_SEMANTIC_FINGERPRINT); + const eventDrift = structuredClone(phase0) as { schemas: { event: Record } }; + eventDrift.schemas.event.additionalProperties = false; + assert.notEqual(semanticFingerprint(eventDrift), HERDR_SEMANTIC_FINGERPRINT); + const unresolvedReference = structuredClone(phase0) as { schemas: { request: { oneOf: Array<{ properties: { method: { const: string }; params: unknown } }> } } }; + unresolvedReference.schemas.request.oneOf.find((entry) => entry.properties.method.const === 'pane.get')!.properties.params = { $ref: '#/schemas/request/$defs/Missing' }; + assert.equal(semanticFingerprint(unresolvedReference), null); + const schemaVersionDrift = structuredClone(await reviewedSchema()) as { schema_version: unknown }; + schemaVersionDrift.schema_version = 'wrong-protocol'; + assert.notEqual(semanticFingerprint(schemaVersionDrift), HERDR_SEMANTIC_FINGERPRINT); + const manifest = selectedHerdrSemanticManifest(phase0)! as { control: { transport: { pty: boolean } } }; + const controlDrift = structuredClone(manifest); + controlDrift.control.transport.pty = true; + assert.notEqual(canonicalJsonSha256(controlDrift), HERDR_SEMANTIC_FINGERPRINT); +}); + +test('compatibility probe fails closed and redacts readiness for invalid or unavailable sources', async () => { + const phase0 = await reviewedSchema(); + const capabilities = { discovery: true, output: true, actions: true, attach: true, create: true }; + const ready = await probeHerdrCompatibility(source, capabilities, compatibleClient(phase0), 'linux', 'x64'); + assert.deepEqual(ready, { sourceId: source.sourceId, readiness: 'ready', version: HERDR_VERSION, semanticFingerprint: HERDR_SEMANTIC_FINGERPRINT, transport: 'herdr terminal session control', capabilities: { ...capabilities, create: false }, reasonCode: null }); + const cases: Array<[string, HerdrClient, string]> = [ + ['wrong version', compatibleClient(phase0, { version: { ...ok, stdout: 'herdr 9.0.0\n' } }), 'version_unsupported'], + ['malformed schema', compatibleClient(phase0, { schema: { ...ok, stdout: '{' } }), 'schema_invalid'], + ['unreviewed schema', compatibleClient({ schemas: {} }), 'schema_semantics_unreviewed'], + ['missing binary', compatibleClient(phase0, { version: { ...ok, spawnError: true } }), 'binary_unavailable'], + ['offline status', compatibleClient(phase0, { status: { ...ok, code: 1 } }), 'source_offline'], + ['missing snapshot', compatibleClient(phase0, { snapshot: { ...ok, code: 1 } }), 'snapshot_unavailable'], + ]; + for (const [, client, reasonCode] of cases) { + const result = await probeHerdrCompatibility(source, capabilities, client, 'linux', 'x64'); + assert.equal(result.reasonCode, reasonCode); + assert.notEqual(result.readiness, 'ready'); + assert.equal(result.transport, null); + assert.deepEqual(result.capabilities, { discovery: false, output: false, actions: false, attach: false, create: false }); + } + const unsupported = await probeHerdrCompatibility(source, capabilities, compatibleClient(phase0), 'darwin', 'arm64'); + assert.equal(unsupported.readiness, 'platform_unsupported'); + assert.equal(unsupported.transport, null); +}); diff --git a/server/modules/terminal-runtimes/tests/herdr-config.service.test.ts b/server/modules/terminal-runtimes/tests/herdr-config.service.test.ts new file mode 100644 index 0000000..048a609 --- /dev/null +++ b/server/modules/terminal-runtimes/tests/herdr-config.service.test.ts @@ -0,0 +1,56 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + HERDR_CAPABILITIES_ENV, + HERDR_RUNTIME_ENV, + HERDR_SOURCES_ENV, + herdrSourceId, + readHerdrRuntimeConfig, +} from '../herdr-config.service.js'; + +const source = { alias: 'alpha', selector: 'work', binary: '/opt/herdr/herdr' }; +function env(overrides: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv { + return { [HERDR_RUNTIME_ENV]: '1', [HERDR_SOURCES_ENV]: JSON.stringify([source]), ...overrides }; +} + +test('Herdr source IDs are deterministic opaque public IDs', () => { + assert.equal(herdrSourceId('alpha'), 'hsrc_jtP2rWhblZ6tcCJRjhr3bA'); + assert.equal(herdrSourceId('alpha'), herdrSourceId('alpha')); + assert.notEqual(herdrSourceId('alpha'), herdrSourceId('beta')); +}); + +test('Herdr configuration is opt-in, linux x64 only, and never enables create', () => { + assert.equal(readHerdrRuntimeConfig({}, 'linux', 'x64').enabled, false); + for (const [platform, arch] of [['darwin', 'arm64'], ['linux', 'arm64']] as const) { + const config = readHerdrRuntimeConfig(env(), platform, arch); + assert.deepEqual(config, { enabled: false, sources: [], startupCapabilities: { discovery: false, output: false, actions: false, attach: false, create: false }, policyPath: null, errorCode: 'platform_unsupported' }); + } + const config = readHerdrRuntimeConfig(env({ [HERDR_CAPABILITIES_ENV]: 'discovery,output,actions,attach' }), 'linux', 'x64'); + assert.equal(config.enabled, true); + assert.deepEqual(config.startupCapabilities, { discovery: true, output: true, actions: true, attach: true, create: false }); + assert.deepEqual(config.sources, [{ ...source, sourceId: herdrSourceId('alpha') }]); +}); + +test('Herdr configuration rejects malformed capabilities and unsafe source declarations', () => { + for (const capabilities of ['create', 'discovery,discovery', 'output,unknown']) { + assert.equal(readHerdrRuntimeConfig(env({ [HERDR_CAPABILITIES_ENV]: capabilities }), 'linux', 'x64').errorCode, 'invalid_capabilities'); + } + const invalidSources: unknown[] = [ + [], + [{ ...source, selector: 'default' }], + [{ ...source, binary: 'relative/herdr' }], + [{ ...source, binary: '/tmp/herdr\0bad' }], + [{ ...source, extra: true }], + [source, { ...source, selector: 'other' }], + Array.from({ length: 9 }, (_, index) => ({ ...source, alias: `source${index}` })), + ]; + for (const sources of invalidSources) { + const config = readHerdrRuntimeConfig(env({ [HERDR_SOURCES_ENV]: JSON.stringify(sources) }), 'linux', 'x64'); + assert.equal(config.enabled, false); + assert.equal(config.errorCode, 'invalid_sources'); + assert.deepEqual(config.sources, []); + } + assert.equal(readHerdrRuntimeConfig(env({ [HERDR_SOURCES_ENV]: '{' }), 'linux', 'x64').errorCode, 'invalid_sources'); + assert.equal(readHerdrRuntimeConfig(env({ [HERDR_SOURCES_ENV]: 'x'.repeat(16 * 1024 + 1) }), 'linux', 'x64').errorCode, 'invalid_sources'); +}); diff --git a/server/modules/terminal-runtimes/tests/herdr-control-bridge.service.test.ts b/server/modules/terminal-runtimes/tests/herdr-control-bridge.service.test.ts new file mode 100644 index 0000000..ec147f0 --- /dev/null +++ b/server/modules/terminal-runtimes/tests/herdr-control-bridge.service.test.ts @@ -0,0 +1,131 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import type { PublicTerminalTarget } from '../../../../shared/terminal-runtime.js'; +import { HerdrAdmissionService, HerdrControlBridgeService } from '../herdr-control-bridge.service.js'; + +const target: Extract = { + runtime: 'herdr', sourceId: 'hsrc_jtP2rWhblZ6tcCJRjhr3bA', targetId: 'htgt_jtP2rWhblZ6tcCJRjhr3bA', targetClass: 'attach-only', admissionCapability: 'capability-123456', +}; + +function registry() { + let valid = true; + let busy = false; + let releases = 0; + return { + service: { + read: async () => valid ? { ansi: 'visible', truncated: false } : null, + verify: async () => valid, + targetProfile: async () => ({ targetClass: 'attach-only' as const, process: null }), + controllerArgv: async () => busy ? null : (busy = true, { command: '/opt/herdr/herdr', args: ['terminal', 'session', 'control'], release: () => { busy = false; releases += 1; } }), + }, + invalidate: () => { valid = false; }, + releases: () => releases, + }; +} + +test('Herdr admission is principal-bound, single-use, and expires after 60 seconds', async () => { + let now = 0; + const admissions = new HerdrAdmissionService(() => now); + const fake = registry(); + const bridge = new HerdrControlBridgeService(fake.service as never, admissions); + assert.equal(admissions.grant(target.admissionCapability, 'alice', target), true); + assert.equal(await bridge.acquireController({ target, principal: 'bob', admissionCapability: target.admissionCapability, cols: 80, rows: 24 }), null); + assert.equal(admissions.grant(target.admissionCapability, 'alice', target), true); + const lease = await bridge.acquireController({ target, principal: 'alice', admissionCapability: target.admissionCapability, cols: 80, rows: 24 }); + assert.ok(lease); + assert.equal(await bridge.acquireController({ target, principal: 'alice', admissionCapability: target.admissionCapability, cols: 80, rows: 24 }), null); + now = 60_001; + assert.equal(await lease.assertFreshIdentity(), true); + lease.release(); + assert.equal(admissions.grant(target.admissionCapability, 'alice', target), true); + now = 120_002; + assert.equal(await bridge.acquireController({ target, principal: 'alice', admissionCapability: target.admissionCapability, cols: 80, rows: 24 }), null); +}); + +test('Herdr controller does not take over a busy lease and continuously revalidates identity', async () => { + const admissions = new HerdrAdmissionService(); + const fake = registry(); + const bridge = new HerdrControlBridgeService(fake.service as never, admissions); + admissions.grant(target.admissionCapability, 'alice', target); + const lease = await bridge.acquireController({ target, principal: 'alice', admissionCapability: target.admissionCapability, cols: 80, rows: 24 }); + assert.ok(lease); + admissions.grant(target.admissionCapability, 'alice', target); + assert.equal(await bridge.acquireController({ target, principal: 'alice', admissionCapability: target.admissionCapability, cols: 80, rows: 24 }), null); + fake.invalidate(); + assert.equal(await lease.assertFreshIdentity(), false); + lease.release(); + assert.equal(fake.releases(), 1); +}); + +test('Herdr observation never acquires a controller lease', async () => { + const fake = registry(); + const bridge = new HerdrControlBridgeService(fake.service as never, new HerdrAdmissionService()); + const frames: unknown[] = []; + const observation = await bridge.observe({ target, principal: 'alice', emitFrame: (frame) => frames.push(frame) }); + assert.ok(observation); + assert.equal(frames.length, 1); + observation.release(); +}); +test('Herdr denies client target-class confusion before consuming admission', async () => { + const fake = registry(); + const admissions = new HerdrAdmissionService(); + const bridge = new HerdrControlBridgeService(fake.service as never, admissions); + const confused = { ...target, targetClass: 'local-agent' as const, process: { pid: 9, startedAtMs: 1 } }; + assert.equal(await bridge.acquireController({ target: confused, principal: 'alice', cols: 80, rows: 24 }), null); + assert.equal(admissions.grant(target.admissionCapability, 'alice', target), true); +}); + +test('Herdr releaseAll immediately revokes active resources and releases each lease once', async () => { + const fake = registry(); + const admissions = new HerdrAdmissionService(); + const bridge = new HerdrControlBridgeService(fake.service as never, admissions); + admissions.grant(target.admissionCapability, 'alice', target); + const lease = await bridge.acquireController({ target, principal: 'alice', admissionCapability: target.admissionCapability, cols: 80, rows: 24 }); + assert.ok(lease); + let revoked = 0; + lease.onRevoke(() => { revoked += 1; }); + await bridge.releaseAll(); + await bridge.releaseAll(); + assert.equal(revoked, 1); + assert.equal(fake.releases(), 1); + assert.equal(await lease.assertFreshIdentity(), false); +}); +test('Herdr releaseAll awaits revocation teardown while synchronously denying new writes', async () => { + const fake = registry(); + const admissions = new HerdrAdmissionService(); + const bridge = new HerdrControlBridgeService(fake.service as never, admissions); + admissions.grant(target.admissionCapability, 'alice', target); + const lease = await bridge.acquireController({ target, principal: 'alice', admissionCapability: target.admissionCapability, cols: 80, rows: 24 }); + assert.ok(lease); + let finishTeardown: (() => void) | null = null; + lease.onRevoke(() => new Promise((resolve) => { finishTeardown = resolve; })); + let finished = false; + const releasing = bridge.releaseAll().then(() => { finished = true; }); + assert.equal(lease.assertWriteAllowed(), false); + await Promise.resolve(); + assert.equal(finished, false); + (finishTeardown as (() => void) | null)?.(); + await releasing; + assert.equal(finished, true); + assert.equal(fake.releases(), 1); +}); +test('Herdr dispose awaits an already-started controller revocation', async () => { + const fake = registry(); + const admissions = new HerdrAdmissionService(); + const bridge = new HerdrControlBridgeService(fake.service as never, admissions); + admissions.grant(target.admissionCapability, 'alice', target); + const lease = await bridge.acquireController({ target, principal: 'alice', admissionCapability: target.admissionCapability, cols: 80, rows: 24 }); + assert.ok(lease); + let finishRevocation: (() => void) | null = null; + lease.onRevoke(() => new Promise((resolve) => { finishRevocation = resolve; })); + void lease.release(); + let disposed = false; + const disposal = bridge.dispose().then(() => { disposed = true; }); + await Promise.resolve(); + assert.equal(disposed, false); + (finishRevocation as (() => void) | null)?.(); + await disposal; + assert.equal(disposed, true); + assert.equal(fake.releases(), 1); +}); diff --git a/server/modules/terminal-runtimes/tests/herdr-runtime-adapter.service.test.ts b/server/modules/terminal-runtimes/tests/herdr-runtime-adapter.service.test.ts new file mode 100644 index 0000000..7ba9104 --- /dev/null +++ b/server/modules/terminal-runtimes/tests/herdr-runtime-adapter.service.test.ts @@ -0,0 +1,151 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import test from 'node:test'; + +import type { RuntimeCapabilities } from '../../../../shared/terminal-runtime.js'; +import { HerdrRuntimeAdapter } from '../herdr-runtime-adapter.service.js'; +import type { HerdrConfiguredSource, HerdrRuntimeConfig } from '../herdr-config.service.js'; +import type { HerdrClient } from '../herdr-client.service.js'; +import type { HerdrResolvedSource } from '../herdr-internal.types.js'; +import { RuntimeOperationPolicyService } from '../runtime-operation-policy.service.js'; +import { ProtectedTerminalTargetService } from '../protected-terminal-target.service.js'; + +const source: HerdrConfiguredSource = { alias: 'alpha', sourceId: 'hsrc_jtP2rWhblZ6tcCJRjhr3bA', selector: 'work', binary: '/opt/herdr/herdr' }; +const capabilities: RuntimeCapabilities = { discovery: true, output: true, actions: true, attach: true, create: false }; +const ok = (stdout: string) => ({ code: 0, stdout, stderr: '', timedOut: false, oversized: false, spawnError: false }); + +async function liveSchema(): Promise> { + const fixture = JSON.parse(await readFile(new URL('./fixtures/herdr/v0.7.5-phase0.json', import.meta.url), 'utf8')) as { semanticManifest: { protocol: number; schemaVersion: string; api: { requests: Array<{ method: string; required: string[]; params: unknown }>; successEnvelope: Record; selectedResultVariants: unknown[]; errorEnvelope: Record; eventEnvelope: Record; subscriptionEventEnvelope: Record; definitions: Record> } } }; + const { api, protocol, schemaVersion } = fixture.semanticManifest; + return { protocol, schema_version: schemaVersion, schemas: { request: { oneOf: api.requests.map((request) => ({ required: request.required, properties: { method: { const: request.method }, params: request.params } })), $defs: api.definitions.request }, success_response: { ...api.successEnvelope, $defs: { ...api.definitions.success_response, ResponseResult: { oneOf: api.selectedResultVariants } } }, error_response: { ...api.errorEnvelope, $defs: api.definitions.error_response }, event: { ...api.eventEnvelope, $defs: api.definitions.event }, subscription_event: { ...api.subscriptionEventEnvelope, $defs: api.definitions.subscription_event } } }; +} + +function agentInfo(agentStatus: unknown = 'working', terminalId = 'terminal-1', agent: unknown = 'claude', displayAgent: unknown = 'Claude Code') { + return { + agent, + display_agent: displayAgent, + agent_status: agentStatus, + terminal_id: terminalId, + workspace_id: 'workspace-1', + tab_id: 'tab-1', + pane_id: 'pane-1', + focused: true, + revision: 1, + }; +} +async function adapter(agentStatus: unknown = 'working', agent: unknown = 'claude', displayAgent: unknown = 'Claude Code', omitOptionalAgentFields = false, omitServerPid = false, protectedTargets: ProtectedTerminalTargetService = new ProtectedTerminalTargetService(() => ({ protected: false, identity: 'test-unprotected-target' }))) { + const schema = await liveSchema(); + let terminalId = 'terminal-1'; + let serverStartedAtMs = 100; + const calls = { version: 0, schema: 0, snapshot: 0, paneGet: 0, paneProcessInfo: 0 }; + const currentAgentStatus = agentStatus; + const currentPane = () => { + const value = agentInfo(currentAgentStatus, terminalId, agent, displayAgent); + if (omitOptionalAgentFields) { + delete value.agent; + delete value.display_agent; + } + return value; + }; + const client = { + version: async () => { calls.version += 1; return ok('herdr 0.7.5\n'); }, + schema: async () => { calls.schema += 1; return ok(JSON.stringify(schema)); }, + status: async () => ok('{}'), + sessionList: async () => ok(JSON.stringify({ result: { sessions: [{ name: 'work', running: true, socket_path: '/run/user/1000/herdr.sock', ...(omitServerPid ? {} : { server_pid: 10 }) }] } })), + snapshot: async () => { calls.snapshot += 1; return ok(JSON.stringify({ result: { type: 'session_snapshot', snapshot: { panes: [currentPane()], agents: [currentPane()] } } })); }, + paneGet: async () => { calls.paneGet += 1; return ok(JSON.stringify({ result: { type: 'pane_info', pane: currentPane() } })); }, + paneProcessInfo: async () => { calls.paneProcessInfo += 1; return ok(JSON.stringify({ result: { type: 'pane_process_info', process_info: { foreground_processes: [{ pid: 42, name: 'claude' }], foreground_process_group_id: 42 } } })); }, + paneRead: async () => ok('visible output'), paneSendText: async () => ok('{}'), paneSendKeys: async () => ok('{}'), + controllerArgv: (_source: HerdrResolvedSource, paneId: string, cols: number, rows: number) => ['--session', 'work', 'terminal', 'session', 'control', paneId, '--cols', String(cols), '--rows', String(rows)], + } as unknown as HerdrClient; + const policy = new RuntimeOperationPolicyService(capabilities, [source.sourceId]); + const fs = { realpath: async (path: string) => path, lstat: async () => ({ isFile: () => true, isSocket: () => true, isSymbolicLink: () => false, uid: process.getuid?.() ?? 0, mode: 0o140600, dev: 1, ino: 2 }) }; + const config: HerdrRuntimeConfig = { enabled: true, sources: [source], startupCapabilities: capabilities, policyPath: null, errorCode: null }; + return { + runtime: new HerdrRuntimeAdapter(config, policy, undefined, client, () => 'generic-admission-capability-1234', fs as never, async (pid) => pid === 10 ? { pid, startedAtMs: serverStartedAtMs } : pid === 42 ? { pid, startedAtMs: 100 } : null, async () => ({ pid: 10, startedAtMs: serverStartedAtMs }), protectedTargets), + replaceTerminal: () => { terminalId = 'terminal-2'; }, + replaceServer: () => { serverStartedAtMs += 1; }, + calls, + }; +} + +test('adapter accepts exact v0.7.5 AgentInfo and keeps it attach-only without local actions', async () => { + const { runtime, replaceTerminal } = await adapter(); + const [target] = await runtime.discover(); + assert.ok(target && target.runtime === 'herdr' && target.targetClass === 'attach-only'); + assert.equal(JSON.stringify(target).includes('pane-1'), false); + assert.deepEqual(await runtime.read({ runtime: 'herdr', sourceId: source.sourceId, targetId: target.targetId }), { ansi: 'visible output', truncated: false }); + assert.equal(await runtime.send({ runtime: 'herdr', sourceId: source.sourceId, targetId: target.targetId }, 'blocked'), false); + replaceTerminal(); + assert.equal(await runtime.read({ runtime: 'herdr', sourceId: source.sourceId, targetId: target.targetId }), null); + assert.equal(runtime.capabilities(source.sourceId).create, false); +}); +test('adapter invalidates a target when the server process generation changes on the same socket', async () => { + const { runtime, replaceServer } = await adapter(); + const [target] = await runtime.discover(); + assert.ok(target?.runtime === 'herdr'); + replaceServer(); + assert.equal(await runtime.verify({ runtime: 'herdr', sourceId: source.sourceId, targetId: target.targetId }, 'attach'), false); +}); +test('adapter resolves the server owner from its socket when v0.7.5 omits server_pid', async () => { + const { runtime } = await adapter('working', 'claude', 'Claude Code', false, true); + const [target] = await runtime.discover(); + assert.ok(target?.runtime === 'herdr'); +}); + +test('fresh target verification reuses static compatibility and avoids a full snapshot scan', async () => { + const { runtime, calls } = await adapter(); + const [target] = await runtime.discover(); + assert.ok(target?.runtime === 'herdr'); + assert.deepEqual(calls, { version: 1, schema: 1, snapshot: 1, paneGet: 0, paneProcessInfo: 0 }); + assert.equal(await runtime.verify({ runtime: 'herdr', sourceId: source.sourceId, targetId: target.targetId }, 'attach'), true); + assert.deepEqual(calls, { version: 1, schema: 1, snapshot: 1, paneGet: 1, paneProcessInfo: 0 }); +}); +test('adapter accepts schema-valid panes with omitted optional agent fields', async () => { + const { runtime } = await adapter('unknown', null, null, true); + const [target] = await runtime.discover(); + assert.ok(target?.runtime === 'herdr'); +}); +test('adapter denies a server-classified protected Herdr source', async () => { + const protection = new ProtectedTerminalTargetService(() => ({ protected: true, identity: 'terminal-1:self' })); + const { runtime } = await adapter('working', 'claude', 'Claude Code', false, false, protection); + assert.deepEqual(await runtime.discover(), []); +}); +test('default protection denies the Herdr source hosting ChatMux', async () => { + const previousEnv = process.env.HERDR_ENV; + const previousSession = process.env.HERDR_SESSION; + process.env.HERDR_ENV = '1'; + process.env.HERDR_SESSION = 'work'; + try { + const { runtime } = await adapter('working', 'claude', 'Claude Code', false, false, new ProtectedTerminalTargetService()); + assert.deepEqual(await runtime.discover(), []); + } finally { + if (previousEnv === undefined) delete process.env.HERDR_ENV; + else process.env.HERDR_ENV = previousEnv; + if (previousSession === undefined) delete process.env.HERDR_SESSION; + else process.env.HERDR_SESSION = previousSession; + } +}); + +test('adapter gives generic attach-only admission and enforces single-use no-takeover controller leases', async () => { + const { runtime } = await adapter(); + const [target] = await runtime.discover(); + assert.ok(target && target.runtime === 'herdr' && target.targetClass === 'attach-only'); + assert.deepEqual({ targetClass: target.targetClass, admissionCapability: target.admissionCapability }, { targetClass: 'attach-only', admissionCapability: 'generic-admission-capability-1234' }); + assert.equal(await runtime.send({ runtime: 'herdr', sourceId: source.sourceId, targetId: target!.targetId }, 'blocked'), false); + const first = await runtime.controllerArgv({ runtime: 'herdr', sourceId: source.sourceId, targetId: target!.targetId }, 80, 24); + assert.ok(first); + assert.equal(await runtime.controllerArgv({ runtime: 'herdr', sourceId: source.sourceId, targetId: target!.targetId }, 80, 24), null); + first.release(); + assert.ok(await runtime.controllerArgv({ runtime: 'herdr', sourceId: source.sourceId, targetId: target!.targetId }, 80, 24)); +}); +test('adapter fails closed for malformed frozen AgentInfo fields', async () => { + for (const [agentStatus, agent, displayAgent] of [ + [{ kind: 'claude', protected: false }, 'claude', 'Claude Code'], + ['working', { id: 'claude' }, 'Claude Code'], + ['working', 'claude', { value: 'Claude Code' }], + ]) { + const { runtime } = await adapter(agentStatus, agent, displayAgent); + assert.deepEqual(await runtime.discover(), []); + } +}); diff --git a/server/modules/terminal-runtimes/tests/herdr-runtime-live-probe.ts b/server/modules/terminal-runtimes/tests/herdr-runtime-live-probe.ts new file mode 100644 index 0000000..60f8be5 --- /dev/null +++ b/server/modules/terminal-runtimes/tests/herdr-runtime-live-probe.ts @@ -0,0 +1,189 @@ +import { spawn, spawnSync } from 'node:child_process'; +import { createHash, randomBytes } from 'node:crypto'; +import { chmod, copyFile, mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { HerdrRuntimeAdapter } from '../herdr-runtime-adapter.service.js'; +import { readHerdrRuntimeConfig } from '../herdr-config.service.js'; +import { RuntimeOperationPolicyService } from '../runtime-operation-policy.service.js'; + +const EXPECTED_SHA256 = '3dc83288073e4c2d3c679a30e7be97bcca9141c6fd17dbbb9219142e95c59253'; +const REPORT_PATH = join(process.cwd(), 'artifacts', 'herdr-v1-live-report.json'); + +type Report = { + schemaVersion: 1; + kind: 'black-box-api-test-report'; + passed: boolean; + execution: { command: string[]; cwd: '.'; requiredEnvironment: string[]; expectedAssetSha256: string; exitCode: number | null }; + checks: Record; + cleanup: Record; +}; + +async function main(): Promise { + const asset = process.env.CHATMUX_HERDR_TEST_ASSET; + if (!asset) throw new Error('CHATMUX_HERDR_TEST_ASSET is required'); + const root = await mkdtemp(join(tmpdir(), 'chatmux-herdr-v1-')); + const binary = join(root, 'herdr'); + const home = join(root, 'home'); + const workspace = join(root, 'workspace'); + const configHome = join(root, 'config'); + const stateHome = join(root, 'state'); + const cacheHome = join(root, 'cache'); + const dataHome = join(root, 'data'); + const configPath = join(configHome, 'herdr', 'config.toml'); + const session = `chatmux-v1-${randomBytes(6).toString('hex')}`; + const marker = `CHATMUX_HERDR_V1_${randomBytes(8).toString('hex')}`; + let server: ReturnType | null = null; + let paneId = ''; + const report: Report = { + schemaVersion: 1, + kind: 'black-box-api-test-report', + passed: false, + execution: { + command: ['node', '--import', 'tsx', 'server/modules/terminal-runtimes/tests/herdr-runtime-live-probe.ts'], + cwd: '.', + requiredEnvironment: ['CHATMUX_HERDR_TEST_ASSET'], + expectedAssetSha256: EXPECTED_SHA256, + exitCode: null, + }, + checks: {}, + cleanup: {}, + }; + + for (const directory of [home, workspace, configHome, stateHome, cacheHome, dataHome, join(configHome, 'herdr')]) { + await mkdir(directory, { recursive: true, mode: 0o700 }); + } + await copyFile(asset, binary); + await chmod(binary, 0o755); + const sha256 = createHash('sha256').update(await readFile(binary)).digest('hex'); + if (sha256 !== EXPECTED_SHA256) throw new Error('asset digest mismatch'); + const env = { + ...process.env, + HOME: home, + XDG_CONFIG_HOME: configHome, + XDG_STATE_HOME: stateHome, + XDG_CACHE_HOME: cacheHome, + XDG_DATA_HOME: dataHome, + HERDR_CONFIG_PATH: configPath, + }; + Object.assign(process.env, env); + const run = (args: string[], allowFailure = false) => { + const result = spawnSync(binary, args, { env, encoding: 'utf8', timeout: 10_000, maxBuffer: 2 * 1024 * 1024 }); + if (!allowFailure && result.status !== 0) throw new Error(`Herdr command failed: ${args[0] ?? 'unknown'}`); + return result; + }; + const json = (args: string[]): Record => JSON.parse(run(args).stdout) as Record; + const waitFor = async (predicate: () => boolean | Promise, label: string) => { + const deadline = Date.now() + 8_000; + while (Date.now() < deadline) { + if (await predicate()) return; + await new Promise((resolve) => setTimeout(resolve, 100)); + } + throw new Error(`timeout: ${label}`); + }; + + try { + server = spawn(binary, ['--session', session, 'server'], { env, stdio: ['ignore', 'pipe', 'pipe'] }); + await waitFor(() => run(['--session', session, 'status', 'server'], true).stdout.includes('status: running'), 'server'); + const created = json(['--session', session, 'workspace', 'create', '--cwd', workspace, '--label', 'chatmux-v1-live', '--no-focus']); + const createdResult = created.result as { root_pane?: { pane_id?: string } } | undefined; + paneId = createdResult?.root_pane?.pane_id ?? ''; + if (!paneId) throw new Error('workspace creation omitted pane'); + run(['--session', session, 'pane', 'run', paneId, `printf '\\033[32m${marker} 한글🐑\\033[0m\\n'`]); + await waitFor(() => run(['--session', session, 'pane', 'read', paneId, '--source', 'visible', '--ansi']).stdout.includes(marker), 'seed output'); + + const config = readHerdrRuntimeConfig({ + CHATMUX_HERDR_RUNTIME: '1', + CHATMUX_HERDR_SOURCES: JSON.stringify([{ alias: 'liveprobe', selector: session, binary }]), + CHATMUX_HERDR_CAPABILITIES: 'discovery,output,actions,attach', + }); + if (!config.enabled || config.sources.length !== 1) throw new Error(`config rejected: ${config.errorCode}`); + const policy = new RuntimeOperationPolicyService(config.startupCapabilities, config.sources.map((source) => source.sourceId)); + const adapter = new HerdrRuntimeAdapter(config, policy, undefined, undefined, () => 'discovery-placeholder-capability'); + const [descriptor] = await adapter.sourceDescriptors(); + const [target] = await adapter.discover(); + if (descriptor?.readiness !== 'ready' || !target || target.runtime !== 'herdr') { + throw new Error(`discovery/readiness mismatch: ${JSON.stringify({ descriptor, target })}`); + } + const ref = { runtime: 'herdr' as const, sourceId: target.sourceId, targetId: target.targetId }; + const output = await adapter.read(ref); + const outputFresh = await adapter.verify(ref, 'output'); + const attachFresh = await adapter.verify(ref, 'attach'); + const actionAllowed = await adapter.verify(ref, 'actions'); + if (!output?.ansi.includes(marker) || !outputFresh || !attachFresh || actionAllowed) throw new Error('runtime operation mismatch'); + + const controller = await adapter.controllerArgv(ref, 80, 24); + const duplicate = await adapter.controllerArgv(ref, 80, 24); + if (!controller || duplicate !== null) throw new Error('controller lease mismatch'); + const child = spawn(controller.command, controller.args, { env, stdio: ['pipe', 'pipe', 'pipe'] }); + let buffered = ''; + const frames: Array<{ type?: string; width?: number; height?: number }> = []; + child.stdout.on('data', (chunk: Buffer) => { + buffered += chunk.toString('utf8'); + let newline = buffered.indexOf('\n'); + while (newline >= 0) { + const line = buffered.slice(0, newline); + buffered = buffered.slice(newline + 1); + if (line) frames.push(JSON.parse(line) as { type?: string; width?: number; height?: number }); + newline = buffered.indexOf('\n'); + } + }); + await waitFor(() => frames.some((frame) => frame.type === 'terminal.frame'), 'initial controller frame'); + child.stdin.write(`${JSON.stringify({ type: 'terminal.resize', cols: 100, rows: 30 })}\n`); + await waitFor(() => frames.some((frame) => frame.width === 100 && frame.height === 30), 'resize frame'); + child.stdin.write(`${JSON.stringify({ type: 'terminal.release' })}\n`); + child.stdin.end(); + await new Promise((resolve, reject) => { child.once('exit', () => resolve()); child.once('error', reject); }); + controller.release(); + const reacquired = await adapter.controllerArgv(ref, 80, 24); + if (!reacquired) throw new Error('controller was not reacquirable'); + reacquired.release(); + + run(['--session', session, 'server', 'stop']); + await waitFor(() => !run(['--session', session, 'status', 'server'], true).stdout.includes('status: running'), 'server stop'); + server = spawn(binary, ['--session', session, 'server'], { env, stdio: ['ignore', 'pipe', 'pipe'] }); + await waitFor(() => run(['--session', session, 'status', 'server'], true).stdout.includes('status: running'), 'server restart'); + const staleTargetDenied = !(await adapter.verify(ref, 'attach')); + if (!staleTargetDenied) throw new Error('stale target survived source restart'); + adapter.dispose(); + + report.passed = true; + report.checks = { + provenanceSha256: sha256, + readiness: descriptor.readiness, + targetClass: target.targetClass, + outputMarkerObserved: true, + outputFresh, + attachFresh, + genericPaneActionsDenied: !actionAllowed, + duplicateControllerDenied: duplicate === null, + resizeFrameObserved: frames.some((frame) => frame.width === 100 && frame.height === 30), + reacquiredAfterRelease: true, + staleTargetDeniedAfterSourceRestart: staleTargetDenied, + productManagedCreationUsed: false, + }; + } finally { + run(['--session', session, 'server', 'stop'], true); + if (server && server.exitCode === null) server.kill('SIGTERM'); + const deleted = run(['session', 'delete', session, '--json'], true); + const sessions = run(['session', 'list', '--json'], true); + report.cleanup = { + serverStopped: true, + sessionDeleteExit: deleted.status, + sessionAbsent: !sessions.stdout.includes(session), + }; + await rm(root, { recursive: true, force: true }); + report.cleanup.runRootAbsent = await stat(root).then(() => false, () => true); + await mkdir(join(process.cwd(), 'artifacts'), { recursive: true }); + report.execution.exitCode = report.passed ? 0 : 1; + await writeFile(REPORT_PATH, `${JSON.stringify(report, null, 2)}\n`, { mode: 0o600 }); + } + if (!report.passed || !report.cleanup.sessionAbsent || !report.cleanup.runRootAbsent) throw new Error('live probe failed'); + console.log(REPORT_PATH); +} + +main().catch((error: unknown) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; +}); diff --git a/server/modules/terminal-runtimes/tests/herdr-target-registry.service.test.ts b/server/modules/terminal-runtimes/tests/herdr-target-registry.service.test.ts new file mode 100644 index 0000000..9a4ba49 --- /dev/null +++ b/server/modules/terminal-runtimes/tests/herdr-target-registry.service.test.ts @@ -0,0 +1,59 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { HerdrTargetRegistry } from '../herdr-target-registry.service.js'; +import type { HerdrResolvedTerminal, HerdrTargetId } from '../herdr-internal.types.js'; + +const sourceId = 'hsrc_jtP2rWhblZ6tcCJRjhr3bA' as const; +function terminal(targetClass: 'local-agent' | 'attach-only'): HerdrResolvedTerminal { + return { + source: { sourceId, alias: 'alpha', binary: '/opt/herdr/herdr', selector: 'work', canonicalSocketPath: '/run/user/1000/herdr.sock', socketStat: { uid: 1000, mode: 0o140600, device: 1, inode: 2 }, serverIncarnation: '1:2:10:100', probeFingerprint: 'fingerprint', internalGeneration: 1, transport: 'herdr terminal session control' }, + hierarchy: { workspaceId: 'workspace-1', tabId: 'tab-1', paneId: 'pane-1' }, terminalId: 'terminal-1', terminalIncarnation: 'incarnation-1', terminalRevision: 1, + agent: targetClass === 'local-agent' ? { agentId: 'agent-1', agentKind: 'claude' } : null, + process: targetClass === 'local-agent' ? { pid: 42, startedAtMs: 100, foregroundProcessGroupId: 42, executableName: 'claude' } : null, + targetClass, + }; +} + +test('target registry emits opaque, redacted public identities and rejects incomplete local agents', () => { + const registry = new HerdrTargetRegistry(); + const local = registry.mint(terminal('local-agent'), 1); + assert.ok(local && local.runtime === 'herdr'); + assert.equal(local.targetClass, 'local-agent'); + assert.match(local.targetId, /^htgt_[A-Za-z0-9_-]{22}$/); + assert.deepEqual(Object.keys(local).sort(), ['process', 'runtime', 'sourceId', 'targetClass', 'targetId']); + assert.equal(JSON.stringify(local).includes('pane-1'), false); + const incomplete = terminal('local-agent'); + incomplete.process = null; + assert.equal(registry.mint(incomplete, 1), null); +}); + +test('attach-only targets require explicit generic admission and never expose internal hierarchy', () => { + const registry = new HerdrTargetRegistry(); + assert.equal(registry.mint(terminal('attach-only'), 1), null); + const target = registry.mint(terminal('attach-only'), 1, 'admission-capability-1234'); + assert.ok(target && target.runtime === 'herdr' && target.targetClass === 'attach-only'); + assert.deepEqual({ runtime: target.runtime, sourceId: target.sourceId, targetClass: target.targetClass, admissionCapability: target.admissionCapability }, { + runtime: 'herdr', sourceId, targetClass: 'attach-only', admissionCapability: 'admission-capability-1234', + }); + assert.equal(JSON.stringify(target).includes('workspace-1'), false); +}); + +test('attach-only freshness ignores foreground replacement but local-agent freshness does not', () => { + const registry = new HerdrTargetRegistry(); + const attach = terminal('attach-only'); + attach.process = { pid: 41, startedAtMs: 100, foregroundProcessGroupId: 41, executableName: 'bash' }; + const publicAttach = registry.mint(attach, 1, 'admission-capability-1234'); + assert.ok(publicAttach?.runtime === 'herdr'); + + const replacedAttach = terminal('attach-only'); + replacedAttach.process = { pid: 42, startedAtMs: 200, foregroundProcessGroupId: 42, executableName: 'vim' }; + assert.ok(registry.resolve(sourceId, publicAttach.targetId as HerdrTargetId, replacedAttach, 1)); + + const local = terminal('local-agent'); + const publicLocal = registry.mint(local, 1); + assert.ok(publicLocal?.runtime === 'herdr'); + const replacedLocal = terminal('local-agent'); + replacedLocal.process = { ...replacedLocal.process!, pid: 43, startedAtMs: 300 }; + assert.equal(registry.resolve(sourceId, publicLocal.targetId as HerdrTargetId, replacedLocal, 1), null); +}); diff --git a/server/modules/terminal-runtimes/tests/runtime-policy-registry.service.test.ts b/server/modules/terminal-runtimes/tests/runtime-policy-registry.service.test.ts new file mode 100644 index 0000000..6434d8a --- /dev/null +++ b/server/modules/terminal-runtimes/tests/runtime-policy-registry.service.test.ts @@ -0,0 +1,86 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import type { RuntimeCapabilities } from '../../../../shared/terminal-runtime.js'; +import { RuntimeOperationPolicyService } from '../runtime-operation-policy.service.js'; +import { RuntimeRegistryService, type RuntimeAdapter } from '../runtime-registry.service.js'; + +const sourceId = 'hsrc_jtP2rWhblZ6tcCJRjhr3bA' as const; +const all: RuntimeCapabilities = { discovery: true, output: true, actions: true, attach: true, create: false }; + +test('configured policy starts fail-closed until a valid policy is loaded', () => { + const policy = new RuntimeOperationPolicyService(all, [sourceId], '/unavailable/herdr-policy.json'); + assert.deepEqual(policy.capabilities(sourceId), { discovery: false, output: false, actions: false, attach: false, create: false }); +}); + +test('registry separates Herdr read authorization from action authorization', async () => { + const calls: string[] = []; + const adapter: RuntimeAdapter = { + runtime: 'herdr', + sourceDescriptors: async () => [], + capabilities: () => ({ discovery: true, output: true, actions: false, attach: true, create: false }), + discover: async () => [], + read: async () => { calls.push('read'); return { ansi: 'safe', truncated: false }; }, + send: async () => { calls.push('send'); return true; }, + controllerArgv: async () => { calls.push('attach'); return { command: 'herdr', args: [], release: () => undefined }; }, + }; + const registry = new RuntimeRegistryService(); + registry.register(adapter); + const ref = { runtime: 'herdr' as const, sourceId, targetId: 'htgt_jtP2rWhblZ6tcCJRjhr3bA' }; + + assert.deepEqual(await registry.read(ref), { ansi: 'safe', truncated: false }); + assert.equal(await registry.send(ref, 'do not send'), false); + assert.ok(await registry.controllerArgv(ref, 80, 24)); + assert.deepEqual(calls, ['read', 'attach']); +}); + +test('registry preserves adapter-provided per-source discovery failures', async () => { + const secondSource = 'hsrc_abcdefghijklmnopqrstuv'; + const registry = new RuntimeRegistryService(); + registry.register({ + runtime: 'herdr', + sourceDescriptors: async () => [ + { runtime: 'herdr', sourceId, readiness: 'ready' }, + { runtime: 'herdr', sourceId: secondSource, readiness: 'offline' }, + ], + discover: async () => { + throw new Error('flattened discovery must not run'); + }, + discoverOutcomes: async () => [ + { + runtime: 'herdr', + sourceId, + ok: true, + terminals: [{ runtime: 'herdr', sourceId, targetId: 'htgt_jtP2rWhblZ6tcCJRjhr3bA', targetClass: 'attach-only', admissionCapability: 'capability-123456' }], + }, + { runtime: 'herdr', sourceId: secondSource, ok: false, terminals: [] }, + ], + }); + const outcomes = await registry.discoverOutcomes(); + assert.deepEqual(outcomes.map(({ sourceId: id, ok, terminals }) => ({ id, ok, count: terminals.length })), [ + { id: sourceId, ok: true, count: 1 }, + { id: secondSource, ok: false, count: 0 }, + ]); +}); +test('registry uses one adapter discovery scan without repeating descriptor or discovery calls', async () => { + let scans = 0; + let legacyCalls = 0; + const registry = new RuntimeRegistryService(); + registry.register({ + runtime: 'herdr', + sourceDescriptors: async () => { legacyCalls += 1; return []; }, + discover: async () => { legacyCalls += 1; return []; }, + scanDiscovery: async () => { + scans += 1; + return { + sources: [{ runtime: 'herdr', sourceId, readiness: 'ready' }], + outcomes: [{ runtime: 'herdr', sourceId, ok: true, terminals: [] }], + }; + }, + }); + const scan = await registry.scanDiscovery(); + assert.equal(scans, 1); + assert.equal(legacyCalls, 0); + assert.equal(scan.sources.length, 1); + assert.equal(scan.outcomes.length, 1); +}); diff --git a/server/modules/terminal-runtimes/tests/terminal-runtime.contract.test.ts b/server/modules/terminal-runtimes/tests/terminal-runtime.contract.test.ts new file mode 100644 index 0000000..cf303cf --- /dev/null +++ b/server/modules/terminal-runtimes/tests/terminal-runtime.contract.test.ts @@ -0,0 +1,73 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + decodeFramedBase64, + readProcessGeneration, + readPublicTerminalRef, + readPublicTerminalTarget, + readShellV3InitRequest, + readTerminalFrame, +} from '../../../../shared/terminal-runtime.js'; + +const sourceId = 'hsrc_abcdefghijklmnopqrstuv'; +const targetId = 'htgt_abcdefghijklmnopqrstuv'; +const tmux = { socketPath: '/tmp/tmux.sock', sessionId: '$1', windowId: '@2', paneId: '%3' }; +const process = { pid: 42, startedAtMs: 1_700_000_000_000 }; + +test('public terminal readers accept only complete tmux and Herdr references', () => { + assert.deepEqual(readPublicTerminalRef({ runtime: 'tmux', tmux }), { runtime: 'tmux', tmux }); + assert.deepEqual(readPublicTerminalRef({ runtime: 'herdr', sourceId, targetId }), { runtime: 'herdr', sourceId, targetId }); + for (const value of [ + null, + [], + { runtime: 'herdr', sourceId: 'hsrc_short', targetId }, + { runtime: 'herdr', sourceId, targetId: `${targetId}x` }, + { runtime: 'tmux', tmux: { ...tmux, socketPath: 'relative.sock' } }, + { runtime: 'tmux', tmux: { ...tmux, socketPath: `/tmp/${'x'.repeat(4096)}` } }, + { runtime: 'tmux', tmux: { ...tmux, paneId: '' } }, + { runtime: 'tmux', tmux: { ...tmux, sessionId: 'x'.repeat(129) } }, + ]) assert.equal(readPublicTerminalRef(value), null); +}); + +test('public targets require one valid target class and generation or admission capability', () => { + assert.deepEqual(readPublicTerminalTarget({ runtime: 'tmux', tmux, targetClass: 'local-agent', process }), { runtime: 'tmux', tmux, targetClass: 'local-agent', process }); + assert.deepEqual(readPublicTerminalTarget({ runtime: 'herdr', sourceId, targetId, targetClass: 'attach-only', admissionCapability: 'a'.repeat(16) }), { runtime: 'herdr', sourceId, targetId, targetClass: 'attach-only', admissionCapability: 'a'.repeat(16) }); + for (const value of [ + { runtime: 'herdr', sourceId, targetId, targetClass: 'local-agent', process: { pid: 1, startedAtMs: 1 } }, + { runtime: 'tmux', tmux, targetClass: 'attach-only', admissionCapability: 'short' }, + { runtime: 'tmux', tmux, targetClass: 'other', process }, + { runtime: 'tmux', tmux, targetClass: 'local-agent', process: { pid: 2, startedAtMs: Infinity } }, + ]) assert.equal(readPublicTerminalTarget(value), null); + assert.equal(readProcessGeneration({ pid: 2, startedAtMs: 0 }), null); +}); + +test('shell init reader rejects version, dimensions, and malformed target values', () => { + const valid = { type: 'terminal.init', protocolVersion: 3, mode: 'observe', target: { runtime: 'herdr', sourceId, targetId, targetClass: 'local-agent', process }, cols: 120, rows: 40 }; + assert.deepEqual(readShellV3InitRequest(valid), valid); + for (const value of [ + { ...valid, protocolVersion: 2 }, + { ...valid, mode: 'takeover' }, + { ...valid, cols: 0 }, + { ...valid, rows: 1001 }, + { ...valid, cols: 1.5 }, + { ...valid, target: { ...valid.target, targetId: 'bad' } }, + ]) assert.equal(readShellV3InitRequest(value), null); +}); + +test('framed base64 and terminal frames are bounded, valid, and contiguous', () => { + const encoded = Buffer.from('hello', 'utf8').toString('base64'); + assert.deepEqual(decodeFramedBase64(encoded), new Uint8Array(Buffer.from('hello'))); + assert.equal(decodeFramedBase64('not base64!'), null); + assert.equal(decodeFramedBase64('AAAA', 3), null); + assert.equal(decodeFramedBase64(Buffer.alloc(8, 1).toString('base64'), 100, 1), null); + const frame = { type: 'terminal.frame', seq: 7, encoding: 'ansi', width: 120, height: 40, full: true, bytes: encoded } as const; + assert.deepEqual(readTerminalFrame(frame, 6), frame); + for (const value of [ + { ...frame, seq: 8 }, + { ...frame, seq: 0 }, + { ...frame, width: 1001 }, + { ...frame, bytes: 'not-base64' }, + { ...frame, full: 'true' }, + ]) assert.equal(readTerminalFrame(value, 6), null); +}); diff --git a/server/modules/websocket/services/discovery-stream.service.ts b/server/modules/websocket/services/discovery-stream.service.ts index f3471fa..a612036 100644 --- a/server/modules/websocket/services/discovery-stream.service.ts +++ b/server/modules/websocket/services/discovery-stream.service.ts @@ -3,42 +3,30 @@ import type { WebSocket } from 'ws'; import type { DiscoveryCollector, DiscoveryLane, - DiscoveryRow, - DiscoverySnapshot, } from '@/modules/providers/index.js'; import { WS_OPEN_STATE } from '@/modules/websocket/index.js'; import type { AnyRecord } from '@/shared/types.js'; +import type { DiscoveryV2 } from '../../../../shared/terminal-runtime.js'; + export const MAX_QUEUED_MESSAGES = 64; export const MAX_QUEUED_BYTES = 1_024 * 1_024; export const MAX_BUFFERED_AMOUNT = 4 * 1_024 * 1_024; export const SLOW_CLIENT_MS = 10_000; -export const DELTA_RING_SIZE = 256; export const HEARTBEAT_CADENCE = 5; export const RESYNC_WINDOW_MS = 10_000; export const MAX_RESYNCS_PER_WINDOW = 3; -type Change = - | { op: 'added'; row: DiscoveryRow } - | { op: 'updated'; key: string; patch: Partial } - | { op: 'stale'; key: string; since: number } - | { op: 'removed'; key: string; reason: 'confirmed_gone' }; - -type Delta = Readonly<{ epoch: string; revision: number; prevRevision: number; changes: readonly Change[]; health: DiscoverySnapshot['health'] }>; type Subscriber = { ws: WebSocket; + protocolVersion: 2; lanes: readonly DiscoveryLane[]; - baselineRevision: number; queue: string[]; queuedBytes: number; slowSince: number | null; resyncs: number[]; awaitingResync: boolean; }; - -function selected(snapshot: DiscoverySnapshot, lanes: readonly DiscoveryLane[]): DiscoverySnapshot { - return { ...snapshot, rows: snapshot.rows.filter((row) => lanes.includes(row.lane)) }; -} function send(ws: WebSocket, payload: unknown): boolean { if (ws.readyState !== WS_OPEN_STATE) return false; ws.send(JSON.stringify(payload)); @@ -49,50 +37,48 @@ function readLanes(value: unknown): DiscoveryLane[] | null { if (!Array.isArray(value) || value.some((lane) => lane !== 'external' && lane !== 'live')) return null; return [...new Set(value)] as DiscoveryLane[]; } -function diff(previous: DiscoverySnapshot, next: DiscoverySnapshot): Change[] { - const oldRows = new Map(previous.rows.map((row) => [row.key, row])); - const changes: Change[] = []; - for (const row of next.rows) { - const old = oldRows.get(row.key); - oldRows.delete(row.key); - if (!old) { changes.push({ op: 'added', row }); continue; } - if (old.presence !== 'stale' && row.presence === 'stale') { changes.push({ op: 'stale', key: row.key, since: row.staleSinceRevision ?? next.revision }); continue; } - const patch: Record = {}; - for (const key of Object.keys(row) as (keyof DiscoveryRow)[]) { - if (key === 'key' || JSON.stringify(old[key]) === JSON.stringify(row[key])) continue; - patch[key] = row[key]; - } - if (Object.keys(patch).length) changes.push({ op: 'updated', key: row.key, patch }); - } - for (const row of oldRows.values()) changes.push({ op: 'removed', key: row.key, reason: 'confirmed_gone' }); - return changes; + +export function projectDiscoveryV2(discovery: DiscoveryV2, lanes: readonly DiscoveryLane[]): DiscoveryV2 { + const admitted = new Set(lanes); + const sourceLanes = discovery.sourceLanes.filter((source) => admitted.has(source.lane)); + const sourceIds = new Set(sourceLanes.map((source) => source.sourceId)); + return { + ...discovery, + terminals: discovery.terminals.filter((entry) => admitted.has(entry.lane)), + tmuxRows: discovery.tmuxRows?.filter((row) => admitted.has(row.lane)), + sourceDescriptors: discovery.sourceDescriptors.filter((source) => sourceIds.has(source.sourceId)), + sourceLanes, + coverageByLane: { + external: admitted.has('external') + ? discovery.coverageByLane.external + : { lane: 'external', state: 'unavailable', expectedSourceLaneKeys: [], authoritativeSourceLaneKeys: [], retainedSourceLaneKeys: [], unavailableSourceLaneKeys: [] }, + live: admitted.has('live') + ? discovery.coverageByLane.live + : { lane: 'live', state: 'unavailable', expectedSourceLaneKeys: [], authoritativeSourceLaneKeys: [], retainedSourceLaneKeys: [], unavailableSourceLaneKeys: [] }, + }, + }; } /** Discovery protocol fan-out. It only renders collector state; it is never an authorization input. */ export function createDiscoveryStream(collector: DiscoveryCollector, now = Date.now) { const subscribers = new Map(); - const deltas: Delta[] = []; - let previous = collector.currentSnapshot(); + let previousRevision = collector.currentSnapshot().revision; let unchangedTicks = 0; const unsubscribe = collector.onSnapshot((snapshot) => { - if (snapshot.revision === previous.revision) { + if (snapshot.revision === previousRevision) { unchangedTicks += 1; if (unchangedTicks % HEARTBEAT_CADENCE === 0) { for (const subscriber of subscribers.values()) { - if (!subscriber.awaitingResync && subscriber.queue.length === 0) { - enqueue(subscriber, { kind: 'discovery.heartbeat', epoch: snapshot.epoch, revision: snapshot.revision, takenAtMs: snapshot.takenAtMs }); + if (!subscriber.awaitingResync && subscriber.queue.length === 0 && snapshot.v2) { + enqueue(subscriber, { kind: 'discovery.v2.heartbeat', version: 2, epoch: snapshot.v2.epoch, globalRevision: snapshot.v2.globalRevision }); } } } - previous = snapshot; return; } + previousRevision = snapshot.revision; unchangedTicks = 0; - const delta: Delta = { epoch: snapshot.epoch, revision: snapshot.revision, prevRevision: previous.revision, changes: diff(previous, snapshot), health: snapshot.health }; - previous = snapshot; - deltas.push(delta); - if (deltas.length > DELTA_RING_SIZE) deltas.shift(); - for (const subscriber of subscribers.values()) enqueueDelta(subscriber, delta); + for (const subscriber of subscribers.values()) snapshotV2(subscriber, snapshot); }); function flush(subscriber: Subscriber): void { if (subscriber.ws.bufferedAmount > MAX_BUFFERED_AMOUNT) { @@ -119,53 +105,59 @@ export function createDiscoveryStream(collector: DiscoveryCollector, now = Date. } flush(subscriber); } - function enqueueDelta(subscriber: Subscriber, delta: Delta): void { - if (subscriber.awaitingResync) { + function snapshotV2( + subscriber: Subscriber, + current = collector.currentSnapshot(), + resetAfterResync = false, + ): void { + if (subscriber.awaitingResync && !resetAfterResync) { flush(subscriber); return; } - if (delta.revision <= subscriber.baselineRevision) return; - const changes = delta.changes.filter((change) => { - const lane = change.op === 'added' ? change.row.lane : change.op === 'updated' ? change.patch.lane : undefined; - return lane === undefined || subscriber.lanes.includes(lane as DiscoveryLane); - }); - enqueue(subscriber, { kind: 'discovery.delta', ...delta, changes }); - } - function snapshot(subscriber: Subscriber): void { - const current = collector.currentSnapshot(); - subscriber.baselineRevision = current.revision; - subscriber.awaitingResync = false; - enqueue(subscriber, { kind: 'discovery.snapshot', ...selected(current, subscriber.lanes) }); + if (resetAfterResync) { + subscriber.awaitingResync = false; + subscriber.queue = []; + subscriber.queuedBytes = 0; + } + if (!current.v2) { + enqueue(subscriber, { kind: 'protocol_error', code: 'RUNTIME_DISCOVERY_UNAVAILABLE', error: 'Runtime discovery v2 is unavailable.' }); + return; + } + enqueue(subscriber, { kind: 'discovery.v2.snapshot', version: 2, discovery: projectDiscoveryV2(current.v2, subscriber.lanes) }); } function subscribe(ws: WebSocket, data: AnyRecord): void { - if (data.protocolVersion !== 1) { send(ws, { kind: 'protocol_error', code: 'INVALID_PROTOCOL_VERSION', error: 'discovery.subscribe requires protocolVersion 1.' }); return; } - const lanes = readLanes(data.lanes); - if (!lanes) { send(ws, { kind: 'protocol_error', code: 'INVALID_DISCOVERY_LANES', error: 'Invalid discovery lanes.' }); return; } - if (subscribers.has(ws)) subscribers.delete(ws); - const subscriber: Subscriber = { + if (data.protocolVersion !== 2) { + send(ws, { kind: 'protocol_error', code: 'CLIENT_RELOAD_REQUIRED', error: 'Runtime discovery requires protocolVersion 2.' }); + return; + } + if (!collector.currentSnapshot().v2) { + send(ws, { kind: 'protocol_error', code: 'RUNTIME_DISCOVERY_UNAVAILABLE', error: 'Runtime discovery v2 is unavailable.' }); + return; + } + const requestedLanes = readLanes(data.lanes); + if (!requestedLanes) { send(ws, { kind: 'protocol_error', code: 'INVALID_DISCOVERY_LANES', error: 'Invalid discovery lanes.' }); return; } + const existing = subscribers.get(ws); + const lanes = existing + ? [...new Set([...existing.lanes, ...requestedLanes])] as DiscoveryLane[] + : requestedLanes; + const lanesChanged = !!existing && lanes.length !== existing.lanes.length; + const subscriber: Subscriber = existing ?? { ws, + protocolVersion: 2, lanes, - baselineRevision: collector.currentSnapshot().revision, queue: [], queuedBytes: 0, slowSince: null, resyncs: [], awaitingResync: false, }; + subscriber.lanes = lanes; subscribers.set(ws, subscriber); const known = data.known as AnyRecord | null | undefined; const current = collector.currentSnapshot(); - if (known && known.epoch === current.epoch && known.revision === current.revision) { - enqueue(subscriber, { kind: 'discovery.heartbeat', epoch: current.epoch, revision: current.revision, takenAtMs: current.takenAtMs }); - } - else if (known && known.epoch === current.epoch && typeof known.revision === 'number') { - const replay = deltas.filter((delta) => delta.revision > known.revision!); - if (replay.length && replay[0]!.prevRevision === known.revision) { subscriber.baselineRevision = known.revision; for (const delta of replay) enqueueDelta(subscriber, delta); } - else { - enqueue(subscriber, { kind: 'discovery.resync_required', epoch: current.epoch, reason: 'replay_unavailable' }); - snapshot(subscriber); - } - } else snapshot(subscriber); + if (!lanesChanged && known && known.epoch === current.v2!.epoch && known.globalRevision === current.v2!.globalRevision) { + enqueue(subscriber, { kind: 'discovery.v2.heartbeat', version: 2, epoch: current.v2!.epoch, globalRevision: current.v2!.globalRevision }); + } else snapshotV2(subscriber, current, true); collector.setActive(true); collector.start(); } @@ -185,7 +177,7 @@ export function createDiscoveryStream(collector: DiscoveryCollector, now = Date. ws.close(1008, 'discovery_resync_rate_limited'); } else { subscriber.resyncs.push(now()); - snapshot(subscriber); + snapshotV2(subscriber, collector.currentSnapshot(), true); } } return true; diff --git a/server/modules/websocket/services/shell-websocket.service.ts b/server/modules/websocket/services/shell-websocket.service.ts index e53574d..6873b2e 100644 --- a/server/modules/websocket/services/shell-websocket.service.ts +++ b/server/modules/websocket/services/shell-websocket.service.ts @@ -8,7 +8,9 @@ import { WebSocket, type RawData } from 'ws'; import { cursorCliCommandOrDefault } from '@/modules/providers/index.js'; import { parseIncomingJsonObject } from '@/shared/utils.js'; -export const SHELL_PROTOCOL_VERSION = 2; + +import { readPublicTerminalTarget, readShellV3InitRequest, readTerminalFrame, type PublicTerminalTarget, type ShellV3ServerMessage } from '../../../../shared/terminal-runtime.js'; +export const SHELL_PROTOCOL_VERSION = 3; type ShellInitMode = 'plain-shell' | 'typed-attach'; type TmuxAttachTargetClass = 'local-agent' | 'attach-only'; @@ -24,10 +26,10 @@ type ShellIncomingMessage = { cols?: number; rows?: number; projectPath?: string; - sessionId?: string; + sessionId?: string | null; hasSession?: boolean; provider?: string; - initialCommand?: string; + initialCommand?: string | null; isPlainShell?: boolean; forceRestart?: boolean; lastSeq?: unknown; @@ -38,6 +40,22 @@ type ShellIncomingMessage = { process?: unknown; capability?: unknown; }; +type TmuxV3InitRequest = { + type: 'terminal.init'; + protocolVersion: 3; + mode: ShellInitMode; + projectPath?: unknown; + sessionId?: unknown; + hasSession?: unknown; + provider?: unknown; + cols?: unknown; + rows?: unknown; + initialCommand?: unknown; + isPlainShell?: unknown; + forceRestart?: unknown; + lastSeq?: unknown; + target?: unknown; +}; type PtySessionEntry = { pty: IPty; @@ -61,6 +79,41 @@ export type ShellAttachDiagnostic = Readonly<{ count: number; }>; +export type HerdrControllerProcess = { + stdin: { write: (chunk: string) => boolean; end: () => void; once?: (event: 'drain', listener: () => void) => void }; + stdout: { on: (event: 'data', listener: (chunk: Buffer | string) => void) => void }; + stderr: { on: (event: 'data', listener: (chunk: Buffer | string) => void) => void }; + on: (event: 'error' | 'exit', listener: (...args: unknown[]) => void) => void; + kill: () => void; +}; + +export type HerdrTerminalControl = { + acquireController: (request: { + target: Extract; + principal: string; + admissionCapability?: string; + cols: number; + rows: number; + }) => Promise<{ + command: string; + args: string[]; + release: () => void | Promise; + onRevoke?: (callback: () => void | Promise) => () => void; + assertWriteAllowed?: () => boolean; + assertFreshIdentity: () => Promise; + } | null>; + observe: (request: { + target: Extract; + principal: string; + emitFrame: (frame: ShellV3ServerMessage) => void; + }) => Promise<{ + release: () => void | Promise; + onRevoke?: (callback: () => void | Promise) => () => void; + assertWriteAllowed?: () => boolean; + assertFreshIdentity: () => Promise; + } | null>; +}; + export type ShellWebSocketDependencies = { resolveProviderSessionId: ( sessionId: string, @@ -85,6 +138,8 @@ export type ShellWebSocketDependencies = { diagnostic?: (event: ShellAttachDiagnostic) => void; now?: () => number; readTmuxSessionName?: (tmux: { socketPath: string; paneId: string }) => Promise; + herdrControl?: HerdrTerminalControl; + spawnHerdrController?: (command: string, args: string[]) => HerdrControllerProcess; }; /** @@ -218,12 +273,72 @@ async function assertNotProtectedAttachTarget( function protocolError(ws: WebSocket, message: string): void { ws.send(JSON.stringify({ type: 'error', - code: 'SHELL_PROTOCOL_OUTDATED', + code: 'CLIENT_RELOAD_REQUIRED', message, reloadRequired: true, })); ws.close(); } +function readTmuxV3InitRequest(value: unknown): ShellIncomingMessage | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + const init = value as TmuxV3InitRequest; + if ( + init.type !== 'terminal.init' + || init.protocolVersion !== SHELL_PROTOCOL_VERSION + || (init.mode !== 'plain-shell' && init.mode !== 'typed-attach') + ) return null; + if ( + typeof init.projectPath !== 'string' + || (init.sessionId !== null && typeof init.sessionId !== 'string') + || typeof init.hasSession !== 'boolean' + || typeof init.provider !== 'string' + || typeof init.cols !== 'number' + || !Number.isSafeInteger(init.cols) + || init.cols < 1 + || init.cols > 1000 + || typeof init.rows !== 'number' + || !Number.isSafeInteger(init.rows) + || init.rows < 1 + || init.rows > 1000 + || (init.forceRestart !== undefined && typeof init.forceRestart !== 'boolean') + || (init.lastSeq !== undefined && (typeof init.lastSeq !== 'number' || !Number.isSafeInteger(init.lastSeq) || init.lastSeq < 0)) + ) return null; + + const common = { + type: 'init', + shellProtocolVersion: SHELL_PROTOCOL_VERSION, + projectPath: init.projectPath, + sessionId: init.sessionId, + hasSession: init.hasSession, + provider: init.provider, + cols: init.cols, + rows: init.rows, + forceRestart: init.forceRestart, + lastSeq: init.lastSeq, + mode: init.mode, + } satisfies ShellIncomingMessage; + + if (init.mode === 'plain-shell') { + if ( + 'target' in init + || hasOwn(init, 'targetClass') + || hasOwn(init, 'tmux') + || hasOwn(init, 'process') + || hasOwn(init, 'capability') + || (init.initialCommand !== undefined && init.initialCommand !== null && typeof init.initialCommand !== 'string') + || typeof init.isPlainShell !== 'boolean' + ) return null; + return { ...common, initialCommand: init.initialCommand, isPlainShell: init.isPlainShell }; + } + if (hasOwn(init, 'initialCommand')) return null; + + const target = readPublicTerminalTarget(init.target); + if (!target || target.runtime !== 'tmux') return null; + if (target.targetClass === 'local-agent') { + return { ...common, targetClass: 'local-agent', tmux: target.tmux, process: target.process }; + } + return { ...common, targetClass: 'attach-only', tmux: target.tmux, capability: target.admissionCapability }; +} async function assertAttachTarget( data: ShellIncomingMessage, @@ -263,18 +378,6 @@ async function assertAttachTarget( } } -/** - * Parses incoming websocket shell messages and keeps processing safe when - * malformed payloads are received. - */ -function parseShellMessage(rawMessage: RawData): ShellIncomingMessage | null { - const payload = parseIncomingJsonObject(rawMessage); - if (!payload) { - return null; - } - - return payload as ShellIncomingMessage; -} // Sequence-acknowledged resume (EternalTerminal-style backed writer): the // client reports the last output seq it rendered, and reconnects replay only @@ -439,6 +542,272 @@ function prioritizeUserNpmGlobalBin(env: NodeJS.ProcessEnv): { key: string; valu /** * Handles websocket connections used by the standalone shell terminal UI. */ +const HERDR_INPUT_MAX_BYTES = 64 * 1024; +const HERDR_INPUT_QUEUE_MAX_MESSAGES = 256; +const HERDR_INPUT_QUEUE_MAX_BYTES = 4 * 1024 * 1024; +const HERDR_NDJSON_MAX_BYTES = 2 * 1024 * 1024; +const HERDR_MAX_LIFETIME_MS = 4 * 60 * 60 * 1000; +const HERDR_IDENTITY_INTERVAL_MS = 2_000; +const HERDR_WS_QUEUE_MAX_FRAMES = 256; +const HERDR_WS_QUEUE_MAX_BYTES = 4 * 1024 * 1024; +const HERDR_RELEASE_DEADLINE_MS = 1_000; + +function isHerdrAuthority(value: unknown): boolean { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false; + const record = value as { provider?: unknown; runtime?: unknown; target?: { runtime?: unknown } }; + return record.provider === 'herdr' || record.runtime === 'herdr' || record.target?.runtime === 'herdr'; +} + +function sendShellV3(ws: WebSocket, message: ShellV3ServerMessage): void { + if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(message)); +} + +function startHerdrShellConnection(ws: WebSocket, dependencies: ShellWebSocketDependencies, principal: string, init: ReturnType): (raw: RawData) => Promise { + let closed = false; + let released = false; + let releaseStarted = false; + let ready = false; + let resource: { release: () => void | Promise; onRevoke?: (callback: () => void | Promise) => () => void; assertWriteAllowed?: () => boolean; assertFreshIdentity: () => Promise } | null = null; + let unregisterRevocation: (() => void) | null = null; + let controller: HerdrControllerProcess | null = null; + let controllerExited = false; + let resolveControllerExit: (() => void) | null = null; + let releasePromise: Promise | null = null; + let previousSeq: number | null = null; + let ndjson = ''; + let identityCheckRunning = false; + let queuedInputBytes = 0; + let processingInput = false; + let queuedFrames = 0; + let queuedFrameBytes = 0; + const inputQueue: Array<{ message: { type: 'terminal.input'; text: string } | { type: 'terminal.resize'; cols: number; rows: number }; bytes: number }> = []; + const decoder = new TextDecoder(); + const controllerExit = new Promise((resolve) => { resolveControllerExit = resolve; }); + + const release = (reason: string, bridgeOwnsResourceRelease = false): Promise => { + if (releaseStarted) return releasePromise ?? Promise.resolve(); + releaseStarted = true; + released = true; + ready = false; + unregisterRevocation?.(); + unregisterRevocation = null; + const current = resource; + resource = null; + const currentController = controller; + releasePromise = (async () => { + try { + if (currentController) currentController.stdin.write(`${JSON.stringify({ type: 'terminal.release' })}\n`); + } catch { + // A controller stdin failure is terminal; the lease release below remains mandatory. + } + const releasedResource = bridgeOwnsResourceRelease ? Promise.resolve() : Promise.resolve(current?.release()).catch(() => {}); + if (currentController) { + let deadline: ReturnType | null = null; + try { + await Promise.race([ + Promise.all([controllerExit, releasedResource]).then(() => undefined), + new Promise((resolve) => { deadline = setTimeout(resolve, HERDR_RELEASE_DEADLINE_MS); }), + ]); + } finally { + if (deadline) clearTimeout(deadline); + } + } else { + await releasedResource; + } + if (currentController && !controllerExited) currentController.kill(); + controller = null; + decoder.decode(); + sendShellV3(ws, { type: 'terminal.lifecycle', state: 'closed', reason }); + sendShellV3(ws, { type: 'terminal.closed', reason }); + })(); + return releasePromise; + }; + const fail = (reason: string, state: Extract['state'] = 'closed') => { + if (closed) return; + closed = true; + ready = false; + clearInterval(identityTimer); + clearTimeout(lifetimeTimer); + sendShellV3(ws, { type: 'terminal.lifecycle', state, reason }); + void release(reason); + if (ws.readyState === WebSocket.OPEN) ws.close(); + }; + const confirmReady = () => { + if (ready || closed || !resource) return; + void resource.assertFreshIdentity().then((valid) => { + if (!valid) return fail('Target identity or policy is no longer valid.', 'identity_invalidated'); + if (closed || released) return; + ready = true; + sendShellV3(ws, { type: 'terminal.lifecycle', state: 'ready' }); + }).catch(() => fail('Target identity or policy is no longer valid.', 'identity_invalidated')); + }; + const emitFrame = (message: ShellV3ServerMessage) => { + if (closed || message.type !== 'terminal.frame') return; + const frame = readTerminalFrame(message, previousSeq); + if (!frame || (previousSeq === null && frame.seq !== 1)) return fail('Invalid controller frame.'); + const bytes = Buffer.from(frame.bytes, 'base64').length; + if (queuedFrames + 1 > HERDR_WS_QUEUE_MAX_FRAMES || queuedFrameBytes + bytes > HERDR_WS_QUEUE_MAX_BYTES) return fail('Terminal output queue exceeded limit.'); + previousSeq = frame.seq; + queuedFrames += 1; + queuedFrameBytes += bytes; + try { + ws.send(JSON.stringify(frame), () => { + queuedFrames -= 1; + queuedFrameBytes -= bytes; + }); + } catch { + queuedFrames -= 1; + queuedFrameBytes -= bytes; + return fail('Terminal output write failed.'); + } + confirmReady(); + }; + const waitForDrain = (stdin: HerdrControllerProcess['stdin']): Promise => new Promise((resolve, reject) => { + if (!stdin.once) { + resolve(); + return; + } + const timeout = setTimeout(() => reject(new Error('controller_input_backpressure_timeout')), HERDR_RELEASE_DEADLINE_MS); + stdin.once('drain', () => { + clearTimeout(timeout); + resolve(); + }); + }); + const processInput = () => { + if (processingInput) return; + processingInput = true; + void (async () => { + while (!closed && !released && inputQueue.length) { + const item = inputQueue.shift()!; + queuedInputBytes -= item.bytes; + const current = resource; + const currentController = controller; + if (!ready || !current || !currentController) continue; + const writeAllowed = current.assertWriteAllowed?.(); + if (writeAllowed === false || (writeAllowed === undefined && !await current.assertFreshIdentity())) { + fail('Target identity or policy is no longer valid.', 'identity_invalidated'); + return; + } + if (!ready || closed || released || controller !== currentController) continue; + try { + if (!currentController.stdin.write(`${JSON.stringify(item.message)}\n`)) await waitForDrain(currentController.stdin); + } catch { + fail('Controller input write failed.'); + return; + } + } + })().catch(() => fail('Controller input write failed.')).finally(() => { + processingInput = false; + if (!closed && !released && inputQueue.length) processInput(); + }); + }; + const enqueueInput = (message: { type: 'terminal.input'; text: string } | { type: 'terminal.resize'; cols: number; rows: number }) => { + const bytes = Buffer.byteLength(JSON.stringify(message), 'utf8'); + if (inputQueue.length + 1 > HERDR_INPUT_QUEUE_MAX_MESSAGES || queuedInputBytes + bytes > HERDR_INPUT_QUEUE_MAX_BYTES) return fail('Terminal input queue exceeded limit.'); + inputQueue.push({ message, bytes }); + queuedInputBytes += bytes; + processInput(); + }; + const identityTimer = setInterval(() => { + if (closed || identityCheckRunning || !resource) return; + identityCheckRunning = true; + void resource.assertFreshIdentity().then((valid) => { + if (!valid) fail('Target identity or policy is no longer valid.', 'identity_invalidated'); + }).catch(() => fail('Target identity or policy is no longer valid.', 'identity_invalidated')).finally(() => { identityCheckRunning = false; }); + }, HERDR_IDENTITY_INTERVAL_MS); + identityTimer.unref(); + const lifetimeTimer = setTimeout(() => fail('Controller lease expired.', 'ownership_lost'), HERDR_MAX_LIFETIME_MS); + lifetimeTimer.unref(); + const cleanup = ( + reason: string, + bridgeOwnsResourceRelease = false, + state: Extract['state'] = 'closed', + ): Promise => { + if (closed) return releasePromise ?? Promise.resolve(); + closed = true; + ready = false; + clearInterval(identityTimer); + clearTimeout(lifetimeTimer); + sendShellV3(ws, { type: 'terminal.lifecycle', state, reason }); + const completion = release(reason, bridgeOwnsResourceRelease); + if (ws.readyState === WebSocket.OPEN) ws.close(); + return completion; + }; + ws.once('close', () => cleanup('Socket disconnected.')); + ws.once('error', () => cleanup('Socket error.')); + + void (async () => { + if (!init || init.target.runtime !== 'herdr' || !dependencies.herdrControl) return fail('Herdr control is unavailable.', 'source_disabled'); + sendShellV3(ws, { type: 'terminal.lifecycle', state: 'acquiring' }); + if (init.mode === 'observe') { + resource = await dependencies.herdrControl.observe({ target: init.target, principal, emitFrame }); + if (!resource) return fail('Herdr observation is unavailable.', 'source_disabled'); + unregisterRevocation = resource.onRevoke?.(() => cleanup('Controller revoked.', true, 'ownership_lost')) ?? null; + if (released) void Promise.resolve(resource.release()).catch(() => {}); + return; + } + const acquired = await dependencies.herdrControl.acquireController({ + target: init.target, + principal, + admissionCapability: 'admissionCapability' in init.target ? init.target.admissionCapability : undefined, + cols: init.cols, + rows: init.rows, + }); + if (!acquired) return fail('Herdr terminal is busy or unavailable.', 'busy'); + resource = acquired; + unregisterRevocation = acquired.onRevoke?.(() => cleanup('Controller revoked.', true, 'ownership_lost')) ?? null; + if (released) { + void Promise.resolve(acquired.release()).catch(() => {}); + return; + } + if (!dependencies.spawnHerdrController) return fail('Herdr controller launcher is unavailable.', 'source_disabled'); + controller = dependencies.spawnHerdrController(acquired.command, acquired.args); + controller.stdout.on('data', (chunk) => { + ndjson += typeof chunk === 'string' ? chunk : decoder.decode(chunk, { stream: true }); + if (Buffer.byteLength(ndjson, 'utf8') > HERDR_NDJSON_MAX_BYTES) return fail('Controller output exceeded limit.'); + let newline: number; + while ((newline = ndjson.indexOf('\n')) >= 0) { + const line = ndjson.slice(0, newline); + ndjson = ndjson.slice(newline + 1); + if (!line) continue; + try { + const parsed: unknown = JSON.parse(line); + if (!readTerminalFrame(parsed, previousSeq)) return fail('Invalid controller frame.'); + emitFrame(parsed as ShellV3ServerMessage); + } catch { + return fail('Invalid controller frame.'); + } + } + }); + controller.stderr.on('data', (chunk) => { + if (Buffer.byteLength(chunk) > 0) fail('Controller wrote to stderr.'); + }); + controller.on('error', () => fail('Controller error.')); + controller.on('exit', () => { + controllerExited = true; + resolveControllerExit?.(); + if (!released) fail('Controller exited.'); + }); + })().catch(() => fail('Herdr control is unavailable.', 'source_disabled')); + + return async (raw: RawData) => { + if (closed) return; + const message = parseIncomingJsonObject(raw); + if (!message || typeof message.type !== 'string') return fail('Invalid shell v3 message.'); + if (message.type === 'terminal.release') return cleanup('Released by client.'); + if (message.type === 'terminal.input') { + if (!ready || !controller || !resource || typeof message.text !== 'string' || Buffer.byteLength(message.text, 'utf8') > HERDR_INPUT_MAX_BYTES) return; + enqueueInput({ type: 'terminal.input', text: message.text }); + return; + } + if (message.type === 'terminal.resize') { + if (!ready || !controller || !resource || !Number.isSafeInteger(message.cols) || !Number.isSafeInteger(message.rows) || message.cols < 1 || message.cols > 1000 || message.rows < 1 || message.rows > 1000) return; + enqueueInput({ type: 'terminal.resize', cols: message.cols, rows: message.rows }); + return; + } + fail('Invalid shell v3 message.'); + }; +} export function handleShellConnection( ws: WebSocket, dependencies: ShellWebSocketDependencies, @@ -452,10 +821,38 @@ export function handleShellConnection( let urlDetectionBuffer = ''; const announcedAuthUrls = new Set(); const emitAttachDiagnostic = createAttachDiagnosticEmitter(connectionDependencies.diagnostic, connectionDependencies.now ?? Date.now); + let herdrMessageHandler: ((raw: RawData) => Promise) | null = null; ws.on('message', async (rawMessage) => { try { - const data = parseShellMessage(rawMessage); + const rawData = parseIncomingJsonObject(rawMessage); + if (herdrMessageHandler) { + await herdrMessageHandler(rawMessage); + return; + } + const v3Init = readShellV3InitRequest(rawData); + if (v3Init?.target.runtime === 'herdr') { + herdrMessageHandler = startHerdrShellConnection( + ws, + connectionDependencies, + connectionDependencies.principal ?? '', + v3Init, + ); + return; + } + const tmuxV3Init = readTmuxV3InitRequest(rawData); + if ( + !tmuxV3Init + && ( + isHerdrAuthority(rawData) + || (rawData && typeof rawData === 'object' && !Array.isArray(rawData) + && ((rawData as { type?: unknown }).type === 'init' || (rawData as { type?: unknown }).type === 'terminal.init')) + ) + ) { + protocolError(ws, 'CLIENT_RELOAD_REQUIRED'); + return; + } + const data = tmuxV3Init ?? rawData as ShellIncomingMessage | null; if (!data?.type) { throw new Error('Invalid websocket payload'); } diff --git a/server/modules/websocket/services/websocket-server.service.ts b/server/modules/websocket/services/websocket-server.service.ts index 32e5cec..2836fa7 100644 --- a/server/modules/websocket/services/websocket-server.service.ts +++ b/server/modules/websocket/services/websocket-server.service.ts @@ -9,6 +9,8 @@ import { createDiscoveryStream } from '@/modules/websocket/services/discovery-st import type { DiscoveryCollector } from '@/modules/providers/index.js'; import { createPaneOutputStream } from '@/modules/providers/index.js'; import type { AuthenticatedWebSocketRequest } from '@/shared/types.js'; +import { spawnHerdrController, type HerdrControlBridge } from '@/modules/terminal-runtimes/index.js'; +import type { HerdrControllerProcess } from '@/modules/websocket/services/shell-websocket.service.js'; type WebSocketServerDependencies = { verifyClient: Parameters[1]; @@ -16,15 +18,19 @@ type WebSocketServerDependencies = { shell: Parameters[1]; discovery?: DiscoveryCollector; panes?: ReturnType; + herdrControl?: HerdrControlBridge; + spawnHerdrController?: (command: string, args: string[]) => HerdrControllerProcess; /** Identity announced to every chat client so stale bundles can self-refresh. */ serverInfo?: { version: string | null; bootId: string }; }; function sendPaneProtocolError(ws: WebSocket, error: unknown): void { if (ws.readyState !== ws.OPEN) return; + const reloadRequired = error instanceof Error && error.message === 'CLIENT_RELOAD_REQUIRED'; ws.send(JSON.stringify({ kind: 'protocol_error', - code: 'INVALID_PANE_SUBSCRIPTION', - error: error instanceof Error ? error.message : 'Invalid pane subscription.', + code: reloadRequired ? 'CLIENT_RELOAD_REQUIRED' : 'INVALID_PANE_SUBSCRIPTION', + error: reloadRequired ? 'CLIENT_RELOAD_REQUIRED' : error instanceof Error ? error.message : 'Invalid pane subscription.', + ...(reloadRequired ? { reloadRequired: true } : {}), sessionId: null, timestamp: new Date().toISOString(), })); @@ -83,7 +89,11 @@ export function createWebSocketServer( if (pathname === '/shell') { const principal = incomingRequest.user?.id ?? incomingRequest.user?.userId ?? incomingRequest.user?.username; - handleShellConnection(ws, dependencies.shell, principal === undefined ? undefined : String(principal)); + handleShellConnection(ws, { + ...dependencies.shell, + herdrControl: dependencies.herdrControl ?? dependencies.shell.herdrControl, + spawnHerdrController: dependencies.spawnHerdrController ?? dependencies.shell.spawnHerdrController ?? spawnHerdrController, + }, principal === undefined ? undefined : String(principal)); return; } diff --git a/server/modules/websocket/tests/discovery-stream.service.test.ts b/server/modules/websocket/tests/discovery-stream.service.test.ts index 8354203..c65a5be 100644 --- a/server/modules/websocket/tests/discovery-stream.service.test.ts +++ b/server/modules/websocket/tests/discovery-stream.service.test.ts @@ -8,7 +8,6 @@ import { WebSocket as ClientWebSocket } from 'ws'; import type { DiscoveryCollector, DiscoverySnapshot } from '@/modules/providers/index.js'; import { - DELTA_RING_SIZE, HEARTBEAT_CADENCE, MAX_BUFFERED_AMOUNT, MAX_QUEUED_MESSAGES, @@ -16,6 +15,7 @@ import { RESYNC_WINDOW_MS, SLOW_CLIENT_MS, createDiscoveryStream, + projectDiscoveryV2, } from '../services/discovery-stream.service.js'; import { createWebSocketServer } from '../services/websocket-server.service.js'; @@ -42,6 +42,18 @@ function snapshot(revision: number, epoch = 'epoch'): DiscoverySnapshot { external: { ok: true, lastOkRevision: revision, consecutiveFailures: 0 }, live: { ok: true, lastOkRevision: revision, consecutiveFailures: 0 }, }, + v2: { + version: 2, + epoch: `runtime-${epoch}`, + globalRevision: revision, + terminals: [], + sourceDescriptors: [{ runtime: 'tmux', sourceId: 'tmux', readiness: 'ready' }], + sourceLanes: [], + coverageByLane: { + external: { lane: 'external', state: 'complete', expectedSourceLaneKeys: [], authoritativeSourceLaneKeys: [], retainedSourceLaneKeys: [], unavailableSourceLaneKeys: [] }, + live: { lane: 'live', state: 'complete', expectedSourceLaneKeys: [], authoritativeSourceLaneKeys: [], retainedSourceLaneKeys: [], unavailableSourceLaneKeys: [] }, + }, + }, }; } @@ -73,40 +85,105 @@ function collector(initial = snapshot(1), onStart?: () => void) { } function frames(ws: FakeWebSocket): Record[] { return ws.sent.map((frame) => JSON.parse(frame) as Record); } -function subscribe(stream: ReturnType, ws: FakeWebSocket, known?: Record): void { - stream.handle(ws as never, { type: 'discovery.subscribe', protocolVersion: 1, ...(known === undefined ? {} : { known }) }); +function subscribe( + stream: ReturnType, + ws: FakeWebSocket, + known?: Record, + lanes?: readonly ('external' | 'live')[], +): void { + stream.handle(ws as never, { + type: 'discovery.subscribe', + protocolVersion: 2, + ...(known === undefined ? {} : { known }), + ...(lanes === undefined ? {} : { lanes }), + }); } -test('subscribes with full snapshot then preserves delta order and omits internal paths', () => { +test('subscribes with v2 snapshots and preserves revision order without legacy rows', () => { const source = collector(); const stream = createDiscoveryStream(source.instance); const ws = new FakeWebSocket(); subscribe(stream, ws); source.emit(snapshot(2)); source.emit(snapshot(3)); const events = frames(ws); - assert.deepEqual(events.map((event) => event.kind), ['discovery.snapshot', 'discovery.delta', 'discovery.delta']); - assert.deepEqual(events.map((event) => event.revision), [1, 2, 3]); + assert.deepEqual(events.map((event) => event.kind), ['discovery.v2.snapshot', 'discovery.v2.snapshot', 'discovery.v2.snapshot']); + assert.deepEqual(events.map((event) => (event.discovery as { globalRevision: number }).globalRevision), [1, 2, 3]); assert.equal(ws.sent.join('\n').includes('transcriptPaths'), false); - assert.equal(ws.sent.join('\n').includes('/socket'), true, 'existing tmux identity socket field remains unchanged'); stream.dispose(); }); +test('projects snapshots and resyncs to exactly the requested discovery lanes', () => { + const mixed = snapshot(1); + const discovery = mixed.v2!; + discovery.terminals = [ + { lane: 'external', terminal: { runtime: 'tmux', tmux: { socketPath: '/socket', sessionId: '$1', windowId: '@1', paneId: '%1' }, process: { pid: 1, startedAtMs: 1 }, targetClass: 'local-agent' } }, + { lane: 'external', terminal: { runtime: 'herdr', sourceId: 'herdr-external', targetId: 'target-external', process: { pid: 2, startedAtMs: 2 }, targetClass: 'local-agent' } }, + { lane: 'live', terminal: { runtime: 'herdr', sourceId: 'herdr-live', targetId: 'target-live', process: { pid: 3, startedAtMs: 3 }, targetClass: 'local-agent' } }, + ]; + discovery.tmuxRows = [ + { key: 'external-tmux', lane: 'external', tmuxName: 'tmux', tmux: { socketPath: '/socket', sessionId: '$1', windowId: '@1', paneId: '%1' }, process: { pid: 1, startedAtMs: 1 }, kind: 'claude', providerSessionId: null, activity: 'running', cwd: null, presence: 'present' }, + { key: 'live-tmux', lane: 'live', tmuxName: 'tmux', tmux: { socketPath: '/socket', sessionId: '$2', windowId: '@2', paneId: '%2' }, process: { pid: 4, startedAtMs: 4 }, kind: 'claude', providerSessionId: null, activity: 'running', cwd: null, presence: 'present' }, + ]; + discovery.sourceDescriptors = [ + { runtime: 'tmux', sourceId: 'tmux.local', readiness: 'ready' }, + { runtime: 'herdr', sourceId: 'herdr-external', readiness: 'ready' }, + { runtime: 'herdr', sourceId: 'herdr-live', readiness: 'ready' }, + ]; + discovery.sourceLanes = [ + { lane: 'external', sourceId: 'tmux.local', runtime: 'tmux', readiness: 'ready', capabilities: { discovery: true, output: true, actions: true, attach: true, create: false }, sourceLaneRevision: 1, lastOkGlobalRevision: 1, coverage: 'authoritative', consecutiveFailures: 0 }, + { lane: 'external', sourceId: 'herdr-external', runtime: 'herdr', readiness: 'ready', capabilities: { discovery: true, output: true, actions: true, attach: true, create: false }, sourceLaneRevision: 1, lastOkGlobalRevision: 1, coverage: 'authoritative', consecutiveFailures: 0 }, + { lane: 'live', sourceId: 'herdr-live', runtime: 'herdr', readiness: 'ready', capabilities: { discovery: true, output: true, actions: true, attach: true, create: false }, sourceLaneRevision: 1, lastOkGlobalRevision: 1, coverage: 'authoritative', consecutiveFailures: 0 }, + ]; + discovery.coverageByLane.external = { lane: 'external', state: 'complete', expectedSourceLaneKeys: ['external\u0000tmux.local', 'external\u0000herdr-external'], authoritativeSourceLaneKeys: ['external\u0000tmux.local', 'external\u0000herdr-external'], retainedSourceLaneKeys: [], unavailableSourceLaneKeys: [] }; + discovery.coverageByLane.live = { lane: 'live', state: 'complete', expectedSourceLaneKeys: ['live\u0000herdr-live'], authoritativeSourceLaneKeys: ['live\u0000herdr-live'], retainedSourceLaneKeys: [], unavailableSourceLaneKeys: [] }; -test('replays only missed revisions, restarts on epoch change, and resyncs unavailable replay', () => { + const projected = projectDiscoveryV2(discovery, ['external']); + assert.deepEqual(projected.terminals.map((entry) => entry.lane), ['external', 'external']); + assert.deepEqual(projected.tmuxRows?.map((row) => row.lane), ['external']); + assert.deepEqual(projected.sourceLanes.map((source) => source.lane), ['external', 'external']); + assert.deepEqual(projected.sourceDescriptors.map((source) => source.sourceId), ['tmux.local', 'herdr-external']); + assert.deepEqual(Object.keys(projected.coverageByLane), ['external', 'live']); + assert.equal(projected.coverageByLane.live.state, 'unavailable'); + assert.deepEqual(projected.coverageByLane.live.expectedSourceLaneKeys, []); + + const source = collector(mixed); const stream = createDiscoveryStream(source.instance); const ws = new FakeWebSocket(); + subscribe(stream, ws, undefined, ['external']); + stream.handle(ws as never, { type: 'discovery.resync', reason: 'gap' }); + const snapshots = frames(ws).filter((event) => event.kind === 'discovery.v2.snapshot'); + assert.equal(snapshots.length, 2); + assert.deepEqual((snapshots[1]!.discovery as { terminals: Array<{ lane: string }> }).terminals.map((entry) => entry.lane), ['external', 'external']); + stream.dispose(); +}); +test('shared sockets union external and live subscriptions in either order', () => { + const mixed = snapshot(1); + mixed.v2!.terminals = [ + { lane: 'external', terminal: { runtime: 'herdr', sourceId: 'herdr-external', targetId: 'target-external', process: { pid: 2, startedAtMs: 2 }, targetClass: 'local-agent' } }, + { lane: 'live', terminal: { runtime: 'herdr', sourceId: 'herdr-live', targetId: 'target-live', process: { pid: 3, startedAtMs: 3 }, targetClass: 'local-agent' } }, + ]; + for (const lanes of [['external', 'live'], ['live', 'external']] as const) { + const source = collector(mixed); + const stream = createDiscoveryStream(source.instance); + const ws = new FakeWebSocket(); + subscribe(stream, ws, { epoch: 'runtime-epoch', globalRevision: 1 }, [lanes[0]]); + subscribe(stream, ws, { epoch: 'runtime-epoch', globalRevision: 1 }, [lanes[1]]); + const final = frames(ws)[frames(ws).length - 1]!; + assert.equal(final.kind, 'discovery.v2.snapshot'); + assert.deepEqual((final.discovery as { terminals: Array<{ lane: string }> }).terminals.map((entry) => entry.lane).sort(), ['external', 'live']); + stream.dispose(); + } +}); + +test('exact known revision heartbeats while epoch or revision changes receive a snapshot', () => { const source = collector(); const stream = createDiscoveryStream(source.instance); - source.emit(snapshot(2)); source.emit(snapshot(3)); - const replay = new FakeWebSocket(); subscribe(stream, replay, { epoch: 'epoch', revision: 1 }); - assert.deepEqual(frames(replay).map((event) => event.revision), [2, 3]); - const epochChanged = new FakeWebSocket(); subscribe(stream, epochChanged, { epoch: 'old', revision: 3 }); - assert.equal(frames(epochChanged)[0]?.kind, 'discovery.snapshot'); - for (let revision = 4; revision < DELTA_RING_SIZE + 8; revision += 1) source.emit(snapshot(revision)); - const stale = new FakeWebSocket(); subscribe(stream, stale, { epoch: 'epoch', revision: 1 }); - assert.deepEqual(frames(stale).map((event) => event.kind), ['discovery.resync_required', 'discovery.snapshot']); + const exact = new FakeWebSocket(); subscribe(stream, exact, { epoch: 'runtime-epoch', globalRevision: 1 }); + assert.equal(frames(exact)[0]?.kind, 'discovery.v2.heartbeat'); + const changed = new FakeWebSocket(); subscribe(stream, changed, { epoch: 'old', globalRevision: 1 }); + assert.equal(frames(changed)[0]?.kind, 'discovery.v2.snapshot'); stream.dispose(); }); -test('snapshot baseline excludes duplicate races and collector transitions to idle after the final unsubscribe', () => { +test('subscription start races remain ordered and final unsubscribe makes the collector idle', () => { const source = collector(snapshot(1), () => source.emit(snapshot(2))); const stream = createDiscoveryStream(source.instance); const ws = new FakeWebSocket(); assert.equal(source.starts(), 0); subscribe(stream, ws); - assert.deepEqual(frames(ws).map((event) => event.kind), ['discovery.snapshot', 'discovery.delta']); - assert.deepEqual(frames(ws).map((event) => event.revision), [1, 2]); + assert.deepEqual(frames(ws).map((event) => event.kind), ['discovery.v2.snapshot', 'discovery.v2.snapshot']); + assert.deepEqual(frames(ws).map((event) => ((event.discovery as { globalRevision: number }).globalRevision)), [1, 2]); stream.handle(ws as never, { type: 'discovery.unsubscribe' }); assert.equal(source.starts(), 1); assert.equal(source.stops(), 0); assert.equal(source.active(), false); stream.dispose(); @@ -124,8 +201,7 @@ test('bounded queue emits resync without oversized payload and closes only susta clock = SLOW_CLIENT_MS; source.emit(snapshot(102)); assert.equal(ws.closeCode, 1013); stream.dispose(); }); - -test('queue overflow suppresses stale deltas until the client resyncs', () => { +test('queue overflow suppresses stale snapshots until the client resyncs', () => { const source = collector(); const stream = createDiscoveryStream(source.instance); const ws = new FakeWebSocket(); @@ -144,27 +220,27 @@ test('queue overflow suppresses stale deltas until the client resyncs', () => { stream.handle(ws as never, { type: 'discovery.resync', reason: 'queue_overflow' }); assert.deepEqual(frames(ws).map((event) => event.kind), [ 'discovery.resync_required', - 'discovery.snapshot', + 'discovery.v2.snapshot', ]); source.emit(snapshot(MAX_QUEUED_MESSAGES + 6)); assert.deepEqual(frames(ws).map((event) => event.kind), [ 'discovery.resync_required', - 'discovery.snapshot', - 'discovery.delta', + 'discovery.v2.snapshot', + 'discovery.v2.snapshot', ]); stream.dispose(); }); -test('exact-known subscriptions receive one heartbeat, unchanged ticks heartbeat every cadence, and resync is rate-limited', () => { +test('unchanged ticks heartbeat every cadence and resync is rate-limited', () => { let clock = 0; const source = collector(); const stream = createDiscoveryStream(source.instance, () => clock); const ws = new FakeWebSocket(); - subscribe(stream, ws, { epoch: 'epoch', revision: 1 }); - assert.deepEqual(frames(ws).map((event) => event.kind), ['discovery.heartbeat']); + subscribe(stream, ws, { epoch: 'runtime-epoch', globalRevision: 1 }); + assert.deepEqual(frames(ws).map((event) => event.kind), ['discovery.v2.heartbeat']); for (let tick = 0; tick < HEARTBEAT_CADENCE; tick += 1) source.emit(snapshot(1)); - assert.deepEqual(frames(ws).map((event) => event.kind), ['discovery.heartbeat', 'discovery.heartbeat']); + assert.deepEqual(frames(ws).map((event) => event.kind), ['discovery.v2.heartbeat', 'discovery.v2.heartbeat']); for (let request = 0; request < MAX_RESYNCS_PER_WINDOW; request += 1) { stream.handle(ws as never, { type: 'discovery.resync', reason: 'gap' }); @@ -184,6 +260,17 @@ test('exact-known subscriptions receive one heartbeat, unchanged ticks heartbeat stream.dispose(); }); +test('legacy discovery clients receive reload-required and never become subscribers', () => { + const source = collector(); + const stream = createDiscoveryStream(source.instance); + const ws = new FakeWebSocket(); + stream.handle(ws as never, { type: 'discovery.subscribe', protocolVersion: 1 }); + assert.deepEqual(frames(ws).map((event) => [event.kind, event.code]), [['protocol_error', 'CLIENT_RELOAD_REQUIRED']]); + assert.equal(stream.subscriberCount(), 0); + assert.equal(source.starts(), 0); + stream.dispose(); +}); + test('unauthenticated websocket upgrades cannot subscribe to discovery', async () => { const source = collector(); const server = createServer(); const wss = createWebSocketServer(server, { @@ -225,7 +312,10 @@ test('chat connections receive a server_hello identity frame before any other tr test('malformed pane subscriptions emit protocol errors without starting capture timers', async () => { let starts = 0; const panes = { - validateSubscription: () => { throw new Error('invalid pane subscription'); }, + validateSubscription: (data: Record) => { + if (data.protocolVersion !== 2) throw new Error('CLIENT_RELOAD_REQUIRED'); + throw new Error('invalid pane subscription'); + }, start: () => { starts += 1; }, subscribe: async () => undefined, unsubscribe: () => undefined, @@ -257,7 +347,49 @@ test('malformed pane subscriptions emit protocol errors without starting capture assert.equal(message.sessionId, null); assert.equal(typeof message.timestamp, 'string'); assert.equal(starts, 0); + const reloadError = new Promise>((resolve, reject) => { + client.once('message', (frame) => resolve(JSON.parse(String(frame)) as Record)); + client.once('error', reject); + }); + client.send(JSON.stringify({ type: 'pane.subscribe', protocolVersion: 1 })); + const reloadMessage = await reloadError; + assert.equal(reloadMessage.code, 'CLIENT_RELOAD_REQUIRED'); + assert.equal(reloadMessage.reloadRequired, true); + assert.equal(starts, 0); client.close(); await new Promise((resolve, reject) => wss.close((failure) => failure ? reject(failure) : resolve())); await new Promise((resolve, reject) => server.close((failure) => failure ? reject(failure) : resolve())); }); +test('runtime discovery v2 is explicitly versioned and rejects legacy authority fallback', () => { + const initial: DiscoverySnapshot = { + ...snapshot(1), + v2: { + version: 2, + epoch: 'runtime-epoch', + globalRevision: 1, + terminals: [], + sourceDescriptors: [{ runtime: 'tmux', sourceId: 'tmux', readiness: 'ready' }], + sourceLanes: [], + coverageByLane: { + external: { lane: 'external', state: 'complete', expectedSourceLaneKeys: [], authoritativeSourceLaneKeys: [], retainedSourceLaneKeys: [], unavailableSourceLaneKeys: [] }, + live: { lane: 'live', state: 'complete', expectedSourceLaneKeys: [], authoritativeSourceLaneKeys: [], retainedSourceLaneKeys: [], unavailableSourceLaneKeys: [] }, + }, + }, + }; + const source = collector(initial); + const stream = createDiscoveryStream(source.instance); + const ws = new FakeWebSocket(); + stream.handle(ws as never, { type: 'discovery.subscribe', protocolVersion: 2 }); + const event = frames(ws)[0]!; + assert.equal(event.kind, 'discovery.v2.snapshot'); + assert.equal(event.version, 2); + assert.equal(JSON.stringify(event).includes('/socket'), false); + stream.dispose(); + + const noV2: DiscoverySnapshot = { ...snapshot(1), v2: undefined }; + const unavailable = createDiscoveryStream(collector(noV2).instance); + const rejected = new FakeWebSocket(); + unavailable.handle(rejected as never, { type: 'discovery.subscribe', protocolVersion: 2 }); + assert.equal(frames(rejected)[0]?.code, 'RUNTIME_DISCOVERY_UNAVAILABLE'); + unavailable.dispose(); +}); diff --git a/server/modules/websocket/tests/shell-websocket-protocol.test.ts b/server/modules/websocket/tests/shell-websocket-protocol.test.ts index 4464aca..d31496f 100644 --- a/server/modules/websocket/tests/shell-websocket-protocol.test.ts +++ b/server/modules/websocket/tests/shell-websocket-protocol.test.ts @@ -52,7 +52,40 @@ function dependencies(overrides: Partial = {}): Shel } async function sendInit(ws: FakeWebSocket, message: Record): Promise { - ws.emit('message', Buffer.from(JSON.stringify({ type: 'init', projectPath: process.cwd(), ...message }))); + if (message.shellProtocolVersion !== SHELL_PROTOCOL_VERSION) { + ws.emit('message', Buffer.from(JSON.stringify({ type: 'init', projectPath: process.cwd(), ...message }))); + } else if (message.mode === 'typed-attach') { + const { shellProtocolVersion: _shellProtocolVersion, ...typed } = message; + const target = message.targetClass === 'local-agent' + ? { runtime: 'tmux', tmux: message.tmux, targetClass: 'local-agent', process: message.process } + : { runtime: 'tmux', tmux: message.tmux, targetClass: 'attach-only', admissionCapability: message.capability }; + ws.emit('message', Buffer.from(JSON.stringify({ + type: 'terminal.init', + protocolVersion: 3, + projectPath: process.cwd(), + sessionId: null, + hasSession: false, + provider: 'claude', + cols: 80, + rows: 24, + ...typed, + target, + }))); + } else { + const { shellProtocolVersion: _shellProtocolVersion, ...plain } = message; + ws.emit('message', Buffer.from(JSON.stringify({ + type: 'terminal.init', + protocolVersion: 3, + projectPath: process.cwd(), + sessionId: null, + hasSession: false, + provider: 'claude', + cols: 80, + rows: 24, + isPlainShell: true, + ...plain, + }))); + } await new Promise((resolve) => setTimeout(resolve, 10)); } @@ -68,8 +101,8 @@ test('missing or obsolete protocol rejects harmless command before spawn', async await sendInit(ws, { shellProtocolVersion, mode: 'plain-shell', initialCommand: 'npx task-master init' }); assert.equal(spawned, 0); assert.deepEqual(error(ws), { - type: 'error', code: 'SHELL_PROTOCOL_OUTDATED', - message: 'Shell protocol is outdated. Reload ChatMux and try again.', reloadRequired: true, + type: 'error', code: 'CLIENT_RELOAD_REQUIRED', + message: 'CLIENT_RELOAD_REQUIRED', reloadRequired: true, }); assert.equal(ws.closed, true); } @@ -511,8 +544,21 @@ test('overlapping forceRestart requests replace the current PTY without orphanin const second = new FakeWebSocket(); handleShellConnection(first as never, restartDependencies); handleShellConnection(second as never, restartDependencies); - first.emit('message', Buffer.from(JSON.stringify({ type: 'init', projectPath: process.cwd(), ...init, forceRestart: true }))); - second.emit('message', Buffer.from(JSON.stringify({ type: 'init', projectPath: process.cwd(), ...init, forceRestart: true }))); + const restart = { + type: 'terminal.init', + protocolVersion: 3, + mode: 'typed-attach', + projectPath: process.cwd(), + sessionId: init.sessionId, + hasSession: false, + provider: 'claude', + cols: 80, + rows: 24, + forceRestart: true, + target: { runtime: 'tmux', tmux, targetClass: 'attach-only', admissionCapability: capability }, + }; + first.emit('message', Buffer.from(JSON.stringify(restart))); + second.emit('message', Buffer.from(JSON.stringify(restart))); await new Promise((resolve) => setTimeout(resolve, 10)); releaseVerification?.(); await new Promise((resolve) => setTimeout(resolve, 10)); @@ -745,3 +791,281 @@ test('client source contains no tmux attach command string', () => { assert.equal(/\btmux\s+attach(?:-session)?\b/.test(source), false, relativePath); } }); +function herdrTarget(): Record { + return { + runtime: 'herdr', + sourceId: 'hsrc_jtP2rWhblZ6tcCJRjhr3bA', + targetId: 'htgt_jtP2rWhblZ6tcCJRjhr3bA', + targetClass: 'attach-only', + admissionCapability: 'capability-123456', + }; +} + +function herdrController() { + const stdout = new EventEmitter(); + const stderr = new EventEmitter(); + const lifecycle = new EventEmitter(); + const writes: string[] = []; + let killed = 0; + return { + process: { + stdin: { write: (chunk: string) => { writes.push(chunk); return true; }, end: () => undefined }, + stdout, + stderr, + on: (event: string, listener: (...args: unknown[]) => void) => lifecycle.on(event, listener), + kill: () => { killed += 1; }, + }, + stdout, + stderr, + writes, + lifecycle, + killed: () => killed, + }; +} + +async function sendV3Init(ws: FakeWebSocket): Promise { + ws.emit('message', Buffer.from(JSON.stringify({ type: 'terminal.init', protocolVersion: 3, mode: 'control', target: herdrTarget(), cols: 80, rows: 24 }))); + await new Promise((resolve) => setTimeout(resolve, 10)); +} + +test('Herdr v3 malformed and gap controller frames release the no-takeover lease', async () => { + for (const line of [ + '{"type":"terminal.frame","seq":1,"encoding":"ansi","width":80,"height":24,"full":true,"bytes":"%"}\n', + '{"type":"terminal.frame","seq":2,"encoding":"ansi","width":80,"height":24,"full":true,"bytes":"YQ=="}\n', + ]) { + const ws = new FakeWebSocket(); + const controller = herdrController(); + let released = 0; + handleShellConnection(ws as never, dependencies({ + herdrControl: { + acquireController: async () => ({ command: '/opt/herdr', args: [], release: () => { released += 1; }, assertFreshIdentity: async () => true }), + observe: async () => null, + }, + spawnHerdrController: () => controller.process as never, + })); + await sendV3Init(ws); + controller.stdout.emit('data', Buffer.from(line)); + await new Promise((resolve) => setTimeout(resolve, 1_050)); + assert.equal(released, 1); + assert.equal(controller.killed(), 1); + } +}); + +test('Herdr v3 disconnect releases controller', async () => { + const ws = new FakeWebSocket(); + const controller = herdrController(); + let released = 0; + handleShellConnection(ws as never, dependencies({ + herdrControl: { + acquireController: async () => ({ command: '/opt/herdr', args: [], release: () => { released += 1; }, assertFreshIdentity: async () => true }), + observe: async () => null, + }, + spawnHerdrController: () => controller.process as never, + })); + await sendV3Init(ws); + ws.emit('close'); + await new Promise((resolve) => setTimeout(resolve, 1_050)); + assert.equal(released, 1); + assert.equal(controller.killed(), 1); +}); +test('Herdr control blocks pre-ack writes, revalidates post-ack writes, and preserves split UTF-8 controller data', async () => { + const ws = new FakeWebSocket(); + const controller = herdrController(); + let valid = true; + handleShellConnection(ws as never, dependencies({ + herdrControl: { + acquireController: async () => ({ command: '/opt/herdr', args: [], release: () => undefined, assertFreshIdentity: async () => valid }), + observe: async () => null, + }, + spawnHerdrController: () => controller.process as never, + })); + await sendV3Init(ws); + ws.emit('message', Buffer.from(JSON.stringify({ type: 'terminal.input', text: 'before' }))); + await new Promise((resolve) => setTimeout(resolve, 1)); + assert.deepEqual(controller.writes, []); + + const frame = '{"type":"terminal.frame","seq":1,"encoding":"ansi","width":80,"height":24,"full":true,"bytes":"YQ==","note":"€"}\n'; + const encoded = Buffer.from(frame); + const split = encoded.indexOf(Buffer.from('€')) + 1; + controller.stdout.emit('data', encoded.subarray(0, split)); + controller.stdout.emit('data', encoded.subarray(split)); + await new Promise((resolve) => setTimeout(resolve, 1)); + assert.match(ws.sent.join('\n'), /"state":"ready"/); + + valid = false; + ws.emit('message', Buffer.from(JSON.stringify({ type: 'terminal.input', text: 'after' }))); + await new Promise((resolve) => setTimeout(resolve, 1)); + assert.doesNotMatch(controller.writes.join('\n'), /"text":"after"/); + assert.match(ws.sent.join('\n'), /identity_invalidated/); +}); +test('Herdr idle identity polling invalidates an acknowledged controller', async () => { + const ws = new FakeWebSocket(); + const controller = herdrController(); + let valid = true; + handleShellConnection(ws as never, dependencies({ + herdrControl: { + acquireController: async () => ({ command: '/opt/herdr', args: [], release: () => undefined, assertFreshIdentity: async () => valid }), + observe: async () => null, + }, + spawnHerdrController: () => controller.process as never, + })); + await sendV3Init(ws); + controller.stdout.emit('data', Buffer.from('{"type":"terminal.frame","seq":1,"encoding":"ansi","width":80,"height":24,"full":true,"bytes":"YQ=="}\n')); + await new Promise((resolve) => setTimeout(resolve, 1)); + valid = false; + await new Promise((resolve) => setTimeout(resolve, 2_050)); + assert.match(ws.sent.join('\n'), /identity_invalidated/); +}); +test('Herdr revocation callback synchronously blocks writes and awaits controller exit', async () => { + const ws = new FakeWebSocket(); + const controller = herdrController(); + let revoke: (() => void | Promise) | null = null; + handleShellConnection(ws as never, dependencies({ + herdrControl: { + acquireController: async () => ({ + command: '/opt/herdr', + args: [], + release: () => undefined, + onRevoke: (callback) => { + revoke = callback; + return () => { revoke = null; }; + }, + assertFreshIdentity: async () => true, + }), + observe: async () => null, + }, + spawnHerdrController: () => controller.process as never, + })); + await sendV3Init(ws); + controller.stdout.emit('data', Buffer.from('{"type":"terminal.frame","seq":1,"encoding":"ansi","width":80,"height":24,"full":true,"bytes":"YQ=="}\n')); + await new Promise((resolve) => setTimeout(resolve, 1)); + const revocation = (revoke as (() => void | Promise) | null)?.(); + ws.emit('message', Buffer.from(JSON.stringify({ type: 'terminal.input', text: 'after-revoke' }))); + assert.match(controller.writes.join('\n'), /"type":"terminal.release"/); + assert.doesNotMatch(controller.writes.join('\n'), /after-revoke/); + let completed = false; + const completion = Promise.resolve(revocation).then(() => { completed = true; }); + await Promise.resolve(); + assert.equal(completed, false); + controller.lifecycle.emit('exit'); + await completion; + assert.equal(completed, true); +}); +test('Herdr closes and releases on the first controller stderr byte', async () => { + const ws = new FakeWebSocket(); + const controller = herdrController(); + let released = 0; + handleShellConnection(ws as never, dependencies({ + herdrControl: { + acquireController: async () => ({ command: '/opt/herdr', args: [], release: () => { released += 1; }, assertFreshIdentity: async () => true }), + observe: async () => null, + }, + spawnHerdrController: () => controller.process as never, + })); + await sendV3Init(ws); + controller.stderr.emit('data', Buffer.from('x')); + controller.lifecycle.emit('exit'); + await new Promise((resolve) => setTimeout(resolve, 1)); + assert.equal(released, 1); + assert.equal(ws.closed, true); + assert.match(ws.sent.join('\n'), /Controller wrote to stderr/); +}); +test('Herdr serializes deferred verifier input and resize writes in FIFO order', async () => { + const ws = new FakeWebSocket(); + const controller = herdrController(); + let resolveVerification: (() => void) | null = null; + const deferredVerification = new Promise((resolve) => { resolveVerification = resolve; }); + let verifications = 0; + handleShellConnection(ws as never, dependencies({ + herdrControl: { + acquireController: async () => ({ + command: '/opt/herdr', + args: [], + release: () => undefined, + assertFreshIdentity: async () => { + verifications += 1; + if (verifications === 2) await deferredVerification; + return true; + }, + }), + observe: async () => null, + }, + spawnHerdrController: () => controller.process as never, + })); + await sendV3Init(ws); + controller.stdout.emit('data', Buffer.from('{"type":"terminal.frame","seq":1,"encoding":"ansi","width":80,"height":24,"full":true,"bytes":"YQ=="}\n')); + await new Promise((resolve) => setTimeout(resolve, 1)); + ws.emit('message', Buffer.from(JSON.stringify({ type: 'terminal.input', text: 'first' }))); + ws.emit('message', Buffer.from(JSON.stringify({ type: 'terminal.resize', cols: 100, rows: 40 }))); + await new Promise((resolve) => setTimeout(resolve, 1)); + assert.deepEqual(controller.writes, []); + (resolveVerification as (() => void) | null)?.(); + await new Promise((resolve) => setTimeout(resolve, 1)); + assert.match(controller.writes[0] ?? '', /"text":"first"/); + assert.match(controller.writes[1] ?? '', /"cols":100/); +}); +test('Herdr releases when the bounded input queue overflows', async () => { + const ws = new FakeWebSocket(); + const controller = herdrController(); + let resolveVerification: (() => void) | null = null; + const deferredVerification = new Promise((resolve) => { resolveVerification = resolve; }); + let verifications = 0; + handleShellConnection(ws as never, dependencies({ + herdrControl: { + acquireController: async () => ({ + command: '/opt/herdr', + args: [], + release: () => undefined, + assertFreshIdentity: async () => { + verifications += 1; + if (verifications === 2) await deferredVerification; + return true; + }, + }), + observe: async () => null, + }, + spawnHerdrController: () => controller.process as never, + })); + await sendV3Init(ws); + controller.stdout.emit('data', Buffer.from('{"type":"terminal.frame","seq":1,"encoding":"ansi","width":80,"height":24,"full":true,"bytes":"YQ=="}\n')); + await new Promise((resolve) => setTimeout(resolve, 1)); + for (let index = 0; index <= 257; index += 1) ws.emit('message', Buffer.from(JSON.stringify({ type: 'terminal.input', text: `${index}` }))); + assert.match(ws.sent.join('\n'), /Terminal input queue exceeded limit/); + (resolveVerification as (() => void) | null)?.(); +}); + +test('Herdr fails and releases when controller input writing throws', async () => { + const ws = new FakeWebSocket(); + const controller = herdrController(); + controller.process.stdin.write = () => { throw new Error('broken pipe'); }; + handleShellConnection(ws as never, dependencies({ + herdrControl: { + acquireController: async () => ({ command: '/opt/herdr', args: [], release: () => undefined, assertFreshIdentity: async () => true }), + observe: async () => null, + }, + spawnHerdrController: () => controller.process as never, + })); + await sendV3Init(ws); + controller.stdout.emit('data', Buffer.from('{"type":"terminal.frame","seq":1,"encoding":"ansi","width":80,"height":24,"full":true,"bytes":"YQ=="}\n')); + await new Promise((resolve) => setTimeout(resolve, 1)); + ws.emit('message', Buffer.from(JSON.stringify({ type: 'terminal.input', text: 'break' }))); + await new Promise((resolve) => setTimeout(resolve, 1)); + assert.match(ws.sent.join('\n'), /Controller input write failed/); +}); + +test('Herdr output queue rejects the candidate frame that exceeds 256 pending frames', async () => { + const ws = new FakeWebSocket(); + const controller = herdrController(); + handleShellConnection(ws as never, dependencies({ + herdrControl: { + acquireController: async () => ({ command: '/opt/herdr', args: [], release: () => undefined, assertFreshIdentity: async () => true }), + observe: async () => null, + }, + spawnHerdrController: () => controller.process as never, + })); + await sendV3Init(ws); + for (let seq = 1; seq <= 257; seq += 1) { + controller.stdout.emit('data', Buffer.from(`${JSON.stringify({ type: 'terminal.frame', seq, encoding: 'ansi', width: 80, height: 24, full: true, bytes: 'YQ==' })}\n`)); + } + assert.match(ws.sent.join('\n'), /Terminal output queue exceeded limit/); +}); diff --git a/server/shared/types.ts b/server/shared/types.ts index d95074f..f5905fb 100644 --- a/server/shared/types.ts +++ b/server/shared/types.ts @@ -202,6 +202,8 @@ export type GatewayEventKind = | 'discovery.delta' | 'discovery.resync_required' | 'discovery.heartbeat' + | 'discovery.v2.snapshot' + | 'discovery.v2.heartbeat' | 'pane.attached' | 'pane.output' | 'pane.invalidated'; diff --git a/shared/terminal-runtime.ts b/shared/terminal-runtime.ts new file mode 100644 index 0000000..1ec9748 --- /dev/null +++ b/shared/terminal-runtime.ts @@ -0,0 +1,114 @@ +import type { TmuxPaneIdentity } from './tmux.js'; + +export type TerminalRuntime = 'tmux' | 'herdr'; +export type ProcessGeneration = { pid: number; startedAtMs: number }; +export type TerminalTargetClass = 'local-agent' | 'attach-only'; +export type RuntimeCapability = 'discovery' | 'output' | 'actions' | 'attach' | 'create'; +export type RuntimeCapabilities = Readonly>; + +export type PublicTerminalRef = + | { runtime: 'tmux'; tmux: TmuxPaneIdentity } + | { runtime: 'herdr'; sourceId: string; targetId: string }; + +export type PublicTerminalTarget = + | { runtime: 'tmux'; tmux: TmuxPaneIdentity; process: ProcessGeneration; targetClass: 'local-agent' } + | { runtime: 'tmux'; tmux: TmuxPaneIdentity; targetClass: 'attach-only'; admissionCapability: string } + | { runtime: 'herdr'; sourceId: string; targetId: string; targetClass: 'local-agent'; process: ProcessGeneration } + | { runtime: 'herdr'; sourceId: string; targetId: string; targetClass: 'attach-only'; admissionCapability: string }; + +export type DiscoveryLane = 'external' | 'live'; +export type SourceReadiness = 'disabled' | 'platform_unsupported' | 'missing' | 'offline' | 'incompatible' | 'degraded' | 'ready'; +export type SourceCoverage = 'authoritative' | 'retained' | 'none'; +export type SourceDescriptor = { runtime: TerminalRuntime; sourceId: string; readiness: SourceReadiness }; +export type SourceLaneKey = string; +export type SourceLaneState = { lane: DiscoveryLane; sourceId: string; runtime: TerminalRuntime; readiness: SourceReadiness; capabilities: RuntimeCapabilities; sourceLaneRevision: number; lastOkGlobalRevision: number; coverage: SourceCoverage; consecutiveFailures: number }; +export type LaneCoverage = { lane: DiscoveryLane; state: 'complete' | 'partial' | 'unavailable'; expectedSourceLaneKeys: SourceLaneKey[]; authoritativeSourceLaneKeys: SourceLaneKey[]; retainedSourceLaneKeys: SourceLaneKey[]; unavailableSourceLaneKeys: SourceLaneKey[] }; + +export type DiscoveryTerminal = { lane: DiscoveryLane; terminal: PublicTerminalTarget }; +export type TmuxDiscoveryProjection = { + key: string; + lane: DiscoveryLane; + tmuxName: string; + tmux: TmuxPaneIdentity; + process: ProcessGeneration | null; + kind: string; + providerSessionId: string | null; + activity: 'running' | 'waiting_user' | 'asking_user' | 'error' | 'unknown'; + tmuxActionable?: boolean; + cwd: string | null; + presence: 'present' | 'stale'; +}; +export type DiscoveryV2 = { version: 2; epoch: string; globalRevision: number; terminals: DiscoveryTerminal[]; tmuxRows?: TmuxDiscoveryProjection[]; sourceDescriptors: SourceDescriptor[]; sourceLanes: SourceLaneState[]; coverageByLane: Record }; +export type PaneV2 = { version: 2; lane: DiscoveryLane; terminal: PublicTerminalTarget; ansi: string; truncated: boolean }; +export type RestTerminalContractV2 = { version: 2; terminal: PublicTerminalTarget; capabilities: RuntimeCapabilities }; + +export type ShellV3InitRequest = { type: 'terminal.init'; protocolVersion: 3; mode: 'observe' | 'control'; target: PublicTerminalTarget; cols: number; rows: number }; +export type ShellV3ClientMessage = + | ShellV3InitRequest + | { type: 'terminal.input'; text: string } + | { type: 'terminal.resize'; cols: number; rows: number } + | { type: 'terminal.release' }; +export type ShellV3ServerMessage = + | { type: 'terminal.lifecycle'; state: 'acquiring' | 'ready' | 'busy' | 'identity_invalidated' | 'ownership_lost' | 'gap' | 'redraw_required' | 'source_disabled' | 'closed'; reason?: string } + | { type: 'terminal.frame'; seq: number; encoding: 'ansi'; width: number; height: number; full: boolean; bytes: string } + | { type: 'terminal.closed'; reason: string }; +export type ShellV3Lifecycle = Extract['state']; + +export function sourceLaneKey(lane: DiscoveryLane, sourceId: string): SourceLaneKey { return `${lane}\u0000${sourceId}`; } +export function publicTerminalKey(lane: DiscoveryLane, terminal: PublicTerminalRef): string { + return terminal.runtime === 'herdr' ? `${lane}\u0000herdr\u0000${terminal.sourceId}\u0000${terminal.targetId}` : `${lane}\u0000tmux\u0000${terminal.tmux.socketPath}\u0000${terminal.tmux.sessionId}\u0000${terminal.tmux.windowId}\u0000${terminal.tmux.paneId}`; +} +const HERDR_SOURCE_ID_RE = /^hsrc_[A-Za-z0-9_-]{22}$/; +const HERDR_TARGET_ID_RE = /^htgt_[A-Za-z0-9_-]{22}$/; +export function readPublicTerminalRef(value: unknown): PublicTerminalRef | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + const v = value as { runtime?: unknown; sourceId?: unknown; targetId?: unknown; tmux?: unknown }; + if (v.runtime === 'herdr' && typeof v.sourceId === 'string' && HERDR_SOURCE_ID_RE.test(v.sourceId) && typeof v.targetId === 'string' && HERDR_TARGET_ID_RE.test(v.targetId)) return { runtime: 'herdr', sourceId: v.sourceId, targetId: v.targetId }; + if (v.runtime === 'tmux' && v.tmux && typeof v.tmux === 'object' && !Array.isArray(v.tmux)) { + const tmux = v.tmux as Partial; + if (typeof tmux.socketPath === 'string' && tmux.socketPath.startsWith('/') && tmux.socketPath.length <= 4096 && !tmux.socketPath.includes('\0') && typeof tmux.sessionId === 'string' && tmux.sessionId.length > 0 && tmux.sessionId.length <= 128 && typeof tmux.windowId === 'string' && tmux.windowId.length > 0 && tmux.windowId.length <= 128 && typeof tmux.paneId === 'string' && tmux.paneId.length > 0 && tmux.paneId.length <= 128) return { runtime: 'tmux', tmux: tmux as TmuxPaneIdentity }; + } + return null; +} +export function readPublicTerminalTarget(value: unknown): PublicTerminalTarget | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + const v = value as { targetClass?: unknown; process?: unknown; admissionCapability?: unknown }; + const ref = readPublicTerminalRef(value); + if (!ref) return null; + const process = readProcessGeneration(v.process); + if (v.targetClass === 'local-agent' && process) return { ...ref, targetClass: 'local-agent', process } as PublicTerminalTarget; + if (v.targetClass === 'attach-only' && typeof v.admissionCapability === 'string' && v.admissionCapability.length >= 16 && v.admissionCapability.length <= 4096) return { ...ref, targetClass: 'attach-only', admissionCapability: v.admissionCapability } as PublicTerminalTarget; + return null; +} +export function readShellV3InitRequest(value: unknown): ShellV3InitRequest | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + const v = value as { type?: unknown; protocolVersion?: unknown; mode?: unknown; target?: unknown; cols?: unknown; rows?: unknown }; + const target = readPublicTerminalTarget(v.target); + const cols = typeof v.cols === 'number' ? v.cols : null; + const rows = typeof v.rows === 'number' ? v.rows : null; + if (v.type !== 'terminal.init' || v.protocolVersion !== 3 || (v.mode !== 'observe' && v.mode !== 'control') || !target || !Number.isSafeInteger(cols) || !Number.isSafeInteger(rows) || cols === null || cols < 1 || cols > 1000 || rows === null || rows < 1 || rows > 1000) return null; + return { type: 'terminal.init', protocolVersion: 3, mode: v.mode, target, cols, rows }; +} +export function readProcessGeneration(value: unknown): ProcessGeneration | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + const v = value as Partial; + return typeof v.pid === 'number' && Number.isSafeInteger(v.pid) && v.pid > 1 && typeof v.startedAtMs === 'number' && Number.isFinite(v.startedAtMs) && v.startedAtMs > 0 ? { pid: v.pid, startedAtMs: v.startedAtMs } : null; +} +export function readRuntimeCapabilities(value: unknown): RuntimeCapabilities | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + const v = value as Partial; + return ['discovery', 'output', 'actions', 'attach', 'create'].every((key) => typeof v[key as RuntimeCapability] === 'boolean') ? v as RuntimeCapabilities : null; +} +const BASE64_RE = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/; +export function decodeFramedBase64(value: string, maxEncoded = 2 * 1024 * 1024, maxDecoded = 1024 * 1024): Uint8Array | null { + if (value.length > maxEncoded || !BASE64_RE.test(value) || value.length % 4 !== 0) return null; + const decodedLength = (value.length / 4) * 3 - (value.endsWith('==') ? 2 : value.endsWith('=') ? 1 : 0); + if (decodedLength > maxDecoded) return null; + return Uint8Array.from(atob(value), (character) => character.charCodeAt(0)); +} +export function readTerminalFrame(value: unknown, previousSeq: number | null): Extract | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + const v = value as Partial>; + if (v.type !== 'terminal.frame' || !Number.isSafeInteger(v.seq) || v.seq! < 1 || (previousSeq !== null && v.seq !== previousSeq + 1) || v.encoding !== 'ansi' || !Number.isSafeInteger(v.width) || !Number.isSafeInteger(v.height) || v.width! < 1 || v.width! > 1000 || v.height! < 1 || v.height! > 1000 || typeof v.full !== 'boolean' || typeof v.bytes !== 'string' || !decodeFramedBase64(v.bytes)) return null; + return v as Extract; +} diff --git a/src/components/app/AppContent.discoveryAuthority.test.tsx b/src/components/app/AppContent.discoveryAuthority.test.tsx index 0ee17cd..04a48b0 100644 --- a/src/components/app/AppContent.discoveryAuthority.test.tsx +++ b/src/components/app/AppContent.discoveryAuthority.test.tsx @@ -152,7 +152,7 @@ test('mounted production authority clears external target on newest failed disco assert.equal(queue.requests.length, 1); await act(async () => { - for (const listener of listeners) listener({ kind: 'discovery.delta' }); + for (const listener of listeners) listener({ kind: 'discovery.v2.snapshot' }); await tick(); }); assert.equal(queue.requests.length, 2); @@ -239,7 +239,7 @@ test('mounted production authority clears GJC target on newest failed discovery assert.equal(queue.requests.length, 1); await act(async () => { - for (const listener of listeners) listener({ kind: 'discovery.snapshot' }); + for (const listener of listeners) listener({ kind: 'discovery.v2.snapshot' }); await tick(); }); assert.equal(queue.requests.length, 2); @@ -329,7 +329,7 @@ test('mounted production authority promotes only the newest exact external trans assert.equal(queue.requests.length, 1); await act(async () => { - for (const listener of listeners) listener({ kind: 'discovery.delta' }); + for (const listener of listeners) listener({ kind: 'discovery.v2.snapshot' }); await tick(); }); assert.equal(queue.requests.length, 2); @@ -341,7 +341,7 @@ test('mounted production authority promotes only the newest exact external trans }); assert.equal(opens.length, 1, 'newest exact authority opens one transcript'); const opened = opens[0]; - assert.ok(opened && opened.cliKind !== 'gjc'); + assert.ok(opened && 'tmux' in opened && opened.cliKind !== 'gjc'); assert.deepEqual(opened.tmux, tmux); assert.deepEqual(opened.process, externalTarget.process); assert.equal(opened.transcriptSessionId, 'current-transcript'); @@ -463,7 +463,7 @@ test('mounted production authority promotes only the newest exact GJC session', assert.equal(sessionDetailRequests, 1); await act(async () => { - for (const listener of listeners) listener({ kind: 'discovery.snapshot' }); + for (const listener of listeners) listener({ kind: 'discovery.v2.snapshot' }); await tick(); }); assert.equal(queue.requests.length, 2); diff --git a/src/components/app/AppContent.tsx b/src/components/app/AppContent.tsx index f981eef..2931f9b 100644 --- a/src/components/app/AppContent.tsx +++ b/src/components/app/AppContent.tsx @@ -17,7 +17,7 @@ import { hasGjcTerminalTarget, readRestSessionContainer, } from '../../utils/liveSessions'; -import type { ExternalTerminalTarget, Project, ProjectSession } from '../../types/app'; +import { isHerdrExternalTerminal, type ExternalTerminalTarget, type Project, type ProjectSession } from '../../types/app'; import type { ExternalCliSession } from '../sidebar/hooks/useExternalCliSessions'; import { tmuxPaneIdentityKey } from '../../../shared/tmux'; @@ -50,25 +50,36 @@ const parseStartedAt = (value: unknown): number | undefined => { export const isSameExternalTerminal = ( current: ExternalTerminalTarget | null, expected: ExternalTerminalTarget, -): boolean => Boolean( - current - && current.cliKind === expected.cliKind - && current.tmux.socketPath === expected.tmux.socketPath - && current.tmux.sessionId === expected.tmux.sessionId - && current.tmux.windowId === expected.tmux.windowId - && current.tmux.paneId === expected.tmux.paneId - && ( - current.process === null && expected.process === null - || current.process !== null - && expected.process !== null - && current.process.pid === expected.process.pid - && current.process.startedAtMs === expected.process.startedAtMs - ), -); +): boolean => { + if (!current) return false; + const currentHerdr = isHerdrExternalTerminal(current); + const expectedHerdr = isHerdrExternalTerminal(expected); + if (currentHerdr || expectedHerdr) { + return currentHerdr && expectedHerdr + && current.terminal.sourceId === expected.terminal.sourceId + && current.terminal.targetId === expected.terminal.targetId + && current.mode === expected.mode; + } + return current.cliKind === expected.cliKind + && current.tmux.socketPath === expected.tmux.socketPath + && current.tmux.sessionId === expected.tmux.sessionId + && current.tmux.windowId === expected.tmux.windowId + && current.tmux.paneId === expected.tmux.paneId + && ( + current.process === null && expected.process === null + || current.process !== null + && expected.process !== null + && current.process.pid === expected.process.pid + && current.process.startedAtMs === expected.process.startedAtMs + ); +}; export function refreshExternalTerminalAttachCapability( target: ExternalTerminalTarget | null, sessions: readonly ExternalCliSession[], ): ExternalTerminalTarget | null { + if (isHerdrExternalTerminal(target)) { + return target; + } if (!target || target.cliKind === 'gjc') { return target; } @@ -110,6 +121,9 @@ export function refreshExternalTerminalAttachCapability( export function resolveExternalTerminalRoute( target: ExternalTerminalTarget, ): 'transcript' | 'terminal' { + if (isHerdrExternalTerminal(target)) { + return 'terminal'; + } // B8: a forced attach always wins — it exists specifically so the // asking_user badge can bypass the structured transcript and land the // user on the exact pane's terminal, even once that pane is indexed. @@ -154,6 +168,7 @@ export function useExternalTerminalDiscoveryAuthority({ useEffect(() => { if ( !externalTerminal + || isHerdrExternalTerminal(externalTerminal) || externalTerminal.cliKind === 'gjc' || externalTerminal.cliKind === 'ssh' || externalTerminal.cliKind === 'shell' @@ -226,7 +241,7 @@ export function useExternalTerminalDiscoveryAuthority({ }; void poll(); const unsubscribe = subscribe((event) => { - if (event.kind === 'discovery.snapshot' || event.kind === 'discovery.delta') void poll(); + if (event.kind === 'discovery.v2.snapshot') void poll(); }); return () => { cancelled = true; @@ -337,7 +352,7 @@ export function useExternalTerminalDiscoveryAuthority({ }; void poll(); const unsubscribe = subscribe((event) => { - if (event.kind === 'discovery.snapshot' || event.kind === 'discovery.delta') void poll(); + if (event.kind === 'discovery.v2.snapshot') void poll(); }); return () => { cancelled = true; @@ -425,6 +440,8 @@ function AppContentInner() { // indexed transcript, so fold its request into the target before routing. const routed: ExternalTerminalTarget = options?.forceAttach ? { ...target, forceAttach: true } : target; if ( + !isHerdrExternalTerminal(routed) + && routed.cliKind !== 'gjc' && routed.cliKind !== 'ssh' && routed.cliKind !== 'shell' @@ -483,6 +500,7 @@ function AppContentInner() { }), [sidebarSharedProps, openExternalTerminal, refreshExternalTerminalCapability]); const activeExternalTranscript = externalTranscript + && !isHerdrExternalTerminal(externalTranscript) && externalTranscript.cliKind !== 'ssh' && externalTranscript.cliKind !== 'shell' && externalTranscript.cliKind !== 'gjc' @@ -686,7 +704,7 @@ function AppContentInner() { liveSessionName={activeExternalTranscript?.tmuxName ?? (selectedSession ? (sidebarSharedProps.liveSessionNames.get(selectedSession.id) ?? null) : null)} liveSessionKind={activeExternalTranscript - ? activeExternalTranscript.cliKind as Exclude + ? activeExternalTranscript.cliKind as Exclude : selectedSession && sidebarSharedProps.liveSessionLineage.has(selectedSession.id) ? 'gjc' : null} diff --git a/src/components/chat/hooks/useChatRealtimeHandlers.ts b/src/components/chat/hooks/useChatRealtimeHandlers.ts index 0e08080..c45f3cb 100644 --- a/src/components/chat/hooks/useChatRealtimeHandlers.ts +++ b/src/components/chat/hooks/useChatRealtimeHandlers.ts @@ -164,14 +164,12 @@ export function useChatRealtimeHandlers({ case 'loading_progress': return; - // Discovery stream frames (`discovery.snapshot` / `discovery.delta` / - // `discovery.heartbeat`) — owned by useDiscoveryStream. They carry no + // Discovery stream frames are owned by useDiscoveryStream. They carry no // sessionId, so without this guard they would fall through to the - // catch-all store append below, be attributed to the currently viewed - // session, and poison its slot with an id-less pseudo message. - case 'discovery.snapshot': - case 'discovery.delta': - case 'discovery.heartbeat': + // catch-all store append below and poison the active session slot. + case 'discovery.v2.snapshot': + case 'discovery.v2.heartbeat': + case 'discovery.resync_required': return; default: diff --git a/src/components/chat/view/subcomponents/LiveRelayComposer.test.tsx b/src/components/chat/view/subcomponents/LiveRelayComposer.test.tsx index ad1ee5e..44a3efb 100644 --- a/src/components/chat/view/subcomponents/LiveRelayComposer.test.tsx +++ b/src/components/chat/view/subcomponents/LiveRelayComposer.test.tsx @@ -142,7 +142,7 @@ test('relay action API posts the exact interrupt body once without retrying', as method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ tmux: target.tmux, process: target.process, action: 'interrupt' }), + body: JSON.stringify({ apiVersion: 2, target: { runtime: 'tmux', tmux: target.tmux, process: target.process, targetClass: 'local-agent' }, action: 'interrupt' }), }, }]); } finally { diff --git a/src/components/main-content/view/MainContent.test.ts b/src/components/main-content/view/MainContent.test.ts index 1d72eb1..6bc393a 100644 --- a/src/components/main-content/view/MainContent.test.ts +++ b/src/components/main-content/view/MainContent.test.ts @@ -114,9 +114,33 @@ test('M5b B8: forced attach resolves to the exact pane 4-tuple as a local-agent const otherPane = { ...target, tmux: { ...tmux, paneId: '%9' } }; const otherAttachTarget = buildExternalAttachTarget(otherPane); - assert.notDeepEqual(otherAttachTarget?.tmux, attachTarget?.tmux); + assert.ok(otherAttachTarget && attachTarget && 'tmux' in otherAttachTarget && 'tmux' in attachTarget); + assert.notDeepEqual(otherAttachTarget.tmux, attachTarget.tmux); }); +test('Herdr read targets never attach while explicit control targets use shell v3', () => { + const terminal = { + runtime: 'herdr' as const, + sourceId: 'hsrc_abcdefghijklmnopqrstuv', + targetId: 'htgt_abcdefghijklmnopqrstuv', + targetClass: 'local-agent' as const, + process: { pid: 4242, startedAtMs: 1_700_000_000_000 }, + }; + const base = { + runtime: 'herdr' as const, + terminal, + kind: 'Herdr' as const, + cliKind: 'herdr' as const, + project: null, + }; + + assert.equal(buildExternalAttachTarget({ ...base, mode: 'observe' }), null); + assert.deepEqual(buildExternalAttachTarget({ ...base, mode: 'control' }), { + runtime: 'herdr', + target: terminal, + mode: 'control', + }); +}); test('M5b B8 AC3: a ssh/shell row without an issued attachCapability never attaches, regardless of forceAttach', () => { const sshTarget = { tmuxName: 'remote', diff --git a/src/components/main-content/view/MainContent.tsx b/src/components/main-content/view/MainContent.tsx index 1361485..c6ed586 100644 --- a/src/components/main-content/view/MainContent.tsx +++ b/src/components/main-content/view/MainContent.tsx @@ -9,9 +9,10 @@ import { useFileOpenResolver } from '../../../hooks/useFileOpenResolver'; import { api } from '../../../utils/api'; import { useEditorSidebar } from '../../code-editor/hooks/useEditorSidebar'; import LiveRelayComposer from '../../chat/view/subcomponents/LiveRelayComposer'; -import type { ExternalTerminalTarget } from '../../../types/app'; +import { isHerdrExternalTerminal, type ExternalTerminalTarget } from '../../../types/app'; import type { ShellAttachTarget } from '../../shell/types/types'; -import { paneSubscriptionKey, tmuxPaneIdentityKey, type TmuxPaneIdentity, type TmuxProcessGeneration } from '../../../../shared/tmux'; +import { tmuxPaneIdentityKey, type TmuxPaneIdentity, type TmuxProcessGeneration } from '../../../../shared/tmux'; +import { publicTerminalKey, type PublicTerminalTarget } from '../../../../shared/terminal-runtime'; import { useWebSocket } from '../../../contexts/WebSocketContext'; import MainContentHeader from './subcomponents/MainContentHeader'; @@ -55,6 +56,7 @@ export function shouldShowPendingRelay(externalTerminal: ExternalTerminalTarget // for a session whose process is still observable. return Boolean( externalTerminal + && !isHerdrExternalTerminal(externalTerminal) && externalTerminal.cliKind !== 'ssh' && externalTerminal.cliKind !== 'shell' && externalTerminal.process @@ -62,6 +64,11 @@ export function shouldShowPendingRelay(externalTerminal: ExternalTerminalTarget ); } export function buildExternalAttachTarget(externalTerminal: ExternalTerminalTarget): ShellAttachTarget | null { + if (isHerdrExternalTerminal(externalTerminal)) { + return externalTerminal.mode === 'control' + ? { runtime: 'herdr', target: externalTerminal.terminal, mode: 'control' } + : null; + } const isAttachOnly = externalTerminal.cliKind === 'ssh' || externalTerminal.cliKind === 'shell' || !externalTerminal.process; @@ -131,8 +138,10 @@ function MainContent({ const [externalPaneOutput, setExternalPaneOutput] = useState(''); const [externalPaneError, setExternalPaneError] = useState(''); const [externalTranscriptView, setExternalTranscriptView] = useState('conversation'); + const herdrExternalTerminal = isHerdrExternalTerminal(externalTerminal) ? externalTerminal : null; + const tmuxExternalTerminal = isHerdrExternalTerminal(externalTerminal) ? null : externalTerminal; const transcriptCliTarget = useMemo(() => { - if (externalTranscript?.process) { + if (externalTranscript && !isHerdrExternalTerminal(externalTranscript) && externalTranscript.process) { return { tmux: externalTranscript.tmux, process: externalTranscript.process, @@ -153,27 +162,43 @@ function MainContent({ ); const transcriptCliProviderLabel = externalTranscript?.kind ?? (liveSessionKind === 'gjc' ? 'GJC' : null); - const transcriptCliTmuxName = externalTranscript?.tmuxName - ?? (liveSessionKind === 'gjc' ? liveSessionName : null); - const externalOutputTarget = useMemo(() => { + const transcriptCliTmuxName = externalTranscript && !isHerdrExternalTerminal(externalTranscript) + ? externalTranscript.tmuxName + : (liveSessionKind === 'gjc' ? liveSessionName : null); + const externalOutputTarget = useMemo((): { lane: 'external' | 'live'; target: PublicTerminalTarget } | null => { + if (herdrExternalTerminal?.mode === 'observe') { + return { lane: 'external', target: herdrExternalTerminal.terminal }; + } if (externalTranscriptView !== 'cli') { return null; } - if (externalTerminal && externalTerminal.cliKind !== 'ssh' && externalTerminal.cliKind !== 'shell') { + if (tmuxExternalTerminal && tmuxExternalTerminal.cliKind !== 'ssh' && tmuxExternalTerminal.cliKind !== 'shell') { // Attachable panes mount the interactive terminal instead; the // read-only mirror stream would just duplicate the same bytes. return null; } // Attach-capable transcript targets also use the interactive terminal. - return transcriptCliAttachTarget ? null : transcriptCliTarget; - }, [externalTerminal, externalTranscriptView, transcriptCliAttachTarget, transcriptCliTarget]); + return transcriptCliAttachTarget || !transcriptCliTarget + ? null + : { + lane: transcriptCliTarget.lane, + target: { + runtime: 'tmux', + tmux: transcriptCliTarget.tmux, + process: transcriptCliTarget.process!, + targetClass: 'local-agent', + }, + }; + }, [externalTranscriptView, herdrExternalTerminal, tmuxExternalTerminal, transcriptCliAttachTarget, transcriptCliTarget]); const externalViewTargetKey = transcriptCliTarget ? tmuxPaneIdentityKey(transcriptCliTarget.tmux) - : externalTerminal - ? tmuxPaneIdentityKey(externalTerminal.tmux) - : null; + : herdrExternalTerminal + ? publicTerminalKey('external', herdrExternalTerminal.terminal) + : tmuxExternalTerminal + ? tmuxPaneIdentityKey(tmuxExternalTerminal.tmux) + : null; useEffect(() => { setExternalTranscriptView('conversation'); @@ -214,7 +239,7 @@ function MainContent({ externalOutputTargetRef.current = externalOutputTarget; }); const externalOutputTargetKey = externalOutputTarget - ? paneSubscriptionKey(externalOutputTarget.lane, externalOutputTarget.tmux, externalOutputTarget.process) + ? publicTerminalKey(externalOutputTarget.lane, externalOutputTarget.target) : null; useEffect(() => { @@ -235,12 +260,12 @@ function MainContent({ controller = new AbortController(); try { const response = target.lane === 'live' - ? await api.liveSessionOutput(target.tmux, target.process, controller.signal) - : await api.externalCliSessionOutput(target.tmux, target.process, controller.signal); + ? await api.liveSessionOutput(target.target, undefined, { signal: controller.signal }) + : await api.externalCliSessionOutput(target.target, undefined, { signal: controller.signal }); const payload = await response.json().catch(() => null); if (cancelled) return; if (response.ok) { - setExternalPaneOutput(typeof payload?.data?.output === 'string' ? payload.data.output : ''); + setExternalPaneOutput(typeof payload?.data?.pane?.ansi === 'string' ? payload.data.pane.ansi : ''); setExternalPaneError(''); } else { // Keep the last frame: the error panel replaces the view, and a @@ -278,10 +303,9 @@ function MainContent({ const target = externalOutputTargetRef.current; if (target) sendMessage({ type: 'pane.subscribe', - protocolVersion: 1, + protocolVersion: 2, lane: target.lane, - tmux: target.tmux, - process: target.process, + target: target.target, }); } const fallbackTimer = window.setInterval(() => { @@ -316,6 +340,7 @@ function MainContent({ // the chat before the provider creates its first transcript record. if ( externalTerminal + && !isHerdrExternalTerminal(externalTerminal) && externalTerminal.cliKind !== 'ssh' && externalTerminal.cliKind !== 'shell' && shouldShowPendingRelay(externalTerminal) @@ -414,10 +439,68 @@ function MainContent({ ); } + if (herdrExternalTerminal) { + const targetKey = publicTerminalKey('external', herdrExternalTerminal.terminal); + const attachTarget = buildExternalAttachTarget(herdrExternalTerminal); + return ( +
+
+
+ {isMobile && ( + + )} + + + Herdr: {herdrExternalTerminal.terminal.sourceId} + + + {herdrExternalTerminal.mode === 'control' ? 'Interactive control' : 'Read-only output'} + +
+ +
+
+ {attachTarget ? ( + + onExternalTerminalClose()} + /> + + ) : externalPaneError ? ( +
+
{externalPaneError}
+
+ ) : ( + + )} +
+
+ ); + } + // Targets without a locally observable process remain terminal-only. - if (externalTerminal) { - const targetKey = tmuxPaneIdentityKey(externalTerminal.tmux); - const attachTarget = buildExternalAttachTarget(externalTerminal); + if (tmuxExternalTerminal) { + const targetKey = tmuxPaneIdentityKey(tmuxExternalTerminal.tmux); + const attachTarget = buildExternalAttachTarget(tmuxExternalTerminal); return (
@@ -433,9 +516,9 @@ function MainContent({ )} - tmux: {externalTerminal.tmuxName} + tmux: {tmuxExternalTerminal.tmuxName} - {t('transcript.detachHint', { kind: externalTerminal.kind })} + {t('transcript.detachHint', { kind: tmuxExternalTerminal.kind })}
)} + {isAttachCapabilityUnavailable && ( +
+ {t('shell.attachCapabilityUnavailable')} +
+ )}
@@ -259,12 +267,15 @@ export default function Shell({ wsRef={wsRef} terminalRef={terminalRef} isConnected={isConnected} + herdrProtocol={attachTarget?.runtime === 'herdr'} /> ); } - const contextLabel = selectedProject?.displayName || attachTarget?.tmux.sessionId || 'terminal'; + const contextLabel = selectedProject?.displayName + || (attachTarget?.runtime === 'herdr' ? `Herdr ${attachTarget.target.sourceId}` : attachTarget?.tmux.sessionId) + || 'terminal'; const readyDescription = isPlainShell ? t('shell.runCommand', { command: initialCommand || t('shell.defaultCommand'), @@ -291,6 +302,11 @@ export default function Shell({ {t('shell.protocolOutdated')} )} + {isAttachCapabilityUnavailable && ( +
+ {t('shell.attachCapabilityUnavailable')} +
+ )} diff --git a/src/components/shell/view/subcomponents/TerminalShortcutsPanel.tsx b/src/components/shell/view/subcomponents/TerminalShortcutsPanel.tsx index 1094b8b..368f981 100644 --- a/src/components/shell/view/subcomponents/TerminalShortcutsPanel.tsx +++ b/src/components/shell/view/subcomponents/TerminalShortcutsPanel.tsx @@ -41,6 +41,7 @@ type TerminalShortcutsPanelProps = { wsRef: MutableRefObject; terminalRef: MutableRefObject; isConnected: boolean; + herdrProtocol?: boolean; }; const preventFocusSteal = (e: React.PointerEvent) => e.preventDefault(); @@ -56,6 +57,7 @@ export default function TerminalShortcutsPanel({ wsRef, terminalRef, isConnected, + herdrProtocol = false, }: TerminalShortcutsPanelProps) { const { t } = useTranslation('settings'); const [ctrlActive, setCtrlActive] = useState(false); @@ -63,9 +65,11 @@ export default function TerminalShortcutsPanel({ const sendInput = useCallback( (data: string) => { - sendSocketMessage(wsRef.current, { type: 'input', data }); + sendSocketMessage(wsRef.current, herdrProtocol + ? { type: 'terminal.input', text: data } + : { type: 'input', data }); }, - [wsRef], + [herdrProtocol, wsRef], ); const scrollToBottom = useCallback(() => { diff --git a/src/components/sidebar/hooks/useExternalCliSessions.ts b/src/components/sidebar/hooks/useExternalCliSessions.ts index 8ca6f1f..2a663fc 100644 --- a/src/components/sidebar/hooks/useExternalCliSessions.ts +++ b/src/components/sidebar/hooks/useExternalCliSessions.ts @@ -4,7 +4,7 @@ import { api } from '../../../utils/api'; import { tmuxPaneIdentityKey, type TmuxPaneIdentity, type TmuxProcessGeneration } from '../../../../shared/tmux'; import { readRestSessionContainer } from '../../../utils/liveSessions'; import { useWebSocket } from '../../../contexts/WebSocketContext'; -import { useDiscoveryStream, type DiscoveryRow } from '../../../hooks/useDiscoveryStream'; +import { useDiscoveryStream, type DiscoveryRow, type RuntimeDiscoveryRow } from '../../../hooks/useDiscoveryStream'; import type { ProviderConnectionIssue } from '../../../../shared/provider-connection'; export type ExternalSessionActivity = 'running' | 'waiting_user' | 'asking_user' | 'error' | 'unknown'; @@ -28,6 +28,13 @@ export type ExternalCliSession = { authority?: 'stream' | 'rest' | 'none'; connectionIssue?: ProviderConnectionIssue; }; + +export type HerdrTerminalSession = { + key: string; + sourceId: string; + target: Extract; + capabilities: RuntimeDiscoveryRow['capabilities']; +}; export function mergeExternalDiscoveryRows( rows: DiscoveryRow[], restSessions: Map, @@ -108,11 +115,13 @@ export function useExternalCliSessions( onSessionsChange?: (sessions: ExternalCliSession[]) => void, ): { sessions: ExternalCliSession[]; + herdrSessions: HerdrTerminalSession[]; loading: boolean; discoveryOk: boolean; refresh: () => void; } { const [sessions, setSessions] = useState([]); + const [herdrSessions, setHerdrSessions] = useState([]); const [loading, setLoading] = useState(true); const [discoveryOk, setDiscoveryOk] = useState(true); const [refreshToken, setRefreshToken] = useState(0); @@ -140,6 +149,7 @@ export function useExternalCliSessions( streamRowsRef.current = null; restSessionsRef.current = new Map(); publish(identities.map((session) => externalIdentityOnly(session))); + setHerdrSessions([]); setLoading(false); }, [publish]); @@ -151,6 +161,18 @@ export function useExternalCliSessions( publish(next); setLoading(false); }, [publish]); + const applyRuntimeRows = useCallback((rows: RuntimeDiscoveryRow[]) => { + setHerdrSessions(rows + .filter((row): row is RuntimeDiscoveryRow & { + terminal: Extract; + } => row.lane === 'external' && row.runtime === 'herdr' && row.terminal.runtime === 'herdr') + .map((row) => ({ + key: row.key, + sourceId: row.sourceId, + target: row.terminal, + capabilities: row.capabilities, + }))); + }, []); const applyRestSessions = useCallback((list: ExternalCliSession[], responseDiscoveryOk: boolean) => { const supported = list.filter((session) => ( @@ -237,11 +259,12 @@ export function useExternalCliSessions( const handleStreamHealthChange = useCallback((healthy: boolean) => { if (healthy) { streamEverHealthyRef.current = true; + void requestRestSessions(); return; } invalidateRestRequests(); applyNone(); - }, [applyNone, invalidateRestRequests]); + }, [applyNone, invalidateRestRequests, requestRestSessions]); const streamHealthy = useDiscoveryStream({ lanes: ['external'], @@ -249,9 +272,9 @@ export function useExternalCliSessions( sendMessage, subscribe, onRows: (rows) => { - invalidateRestRequests(); applyRows(rows); }, + onRuntimeRows: applyRuntimeRows, onHealthChange: handleStreamHealthChange, }); @@ -272,6 +295,7 @@ export function useExternalCliSessions( return { sessions, + herdrSessions, loading, discoveryOk, refresh: () => { diff --git a/src/components/sidebar/view/subcomponents/SidebarContent.tsx b/src/components/sidebar/view/subcomponents/SidebarContent.tsx index 0a76702..d0023f2 100644 --- a/src/components/sidebar/view/subcomponents/SidebarContent.tsx +++ b/src/components/sidebar/view/subcomponents/SidebarContent.tsx @@ -1,10 +1,13 @@ +import { useState } from 'react'; import type { TFunction } from 'i18next'; import { ScrollArea } from '../../../../shared/view/ui'; import type { ExternalTerminalTarget, Project, ProjectSession } from '../../../../types/app'; import type { TmuxPaneIdentity, TmuxPaneTarget } from '../../../../../shared/tmux'; +import { readPublicTerminalTarget } from '../../../../../shared/terminal-runtime'; import type { ProviderConnectionIssue } from '../../../../../shared/provider-connection'; -import { useExternalCliSessions, type ExternalCliSession } from '../../hooks/useExternalCliSessions'; +import { api } from '../../../../utils/api'; +import { useExternalCliSessions, type ExternalCliSession, type HerdrTerminalSession } from '../../hooks/useExternalCliSessions'; import SidebarFooter from './SidebarFooter'; import SidebarHeader from './SidebarHeader'; @@ -82,13 +85,67 @@ export default function SidebarContent({ onExternalSessionsChange, t, }: SidebarContentProps) { - const { sessions: externalSessions, loading: externalLoading, refresh: refreshDiscoveredSessions } = useExternalCliSessions(onExternalSessionsChange); + const { + sessions: externalSessions, + herdrSessions, + loading: externalLoading, + refresh: refreshDiscoveredSessions, + } = useExternalCliSessions(onExternalSessionsChange); + const [herdrOpeningKey, setHerdrOpeningKey] = useState(null); + const [herdrOpenError, setHerdrOpenError] = useState(null); // The hook's refresh sends a unified discovery resync, updating both live and external rows. - const sessionCount = liveSessionIds.size + externalSessions.length; + const sessionCount = liveSessionIds.size + externalSessions.length + herdrSessions.length; const refreshAllSessions = () => { refreshDiscoveredSessions(); onRefresh(); }; + const openHerdr = async (session: HerdrTerminalSession, mode: 'observe' | 'control') => { + if (herdrOpeningKey) return; + setHerdrOpenError(null); + if (mode === 'observe') { + onExternalTerminalOpen({ + runtime: 'herdr', + terminal: session.target, + kind: 'Herdr', + cliKind: 'herdr', + project: null, + mode, + }); + return; + } + + setHerdrOpeningKey(session.key); + try { + let controlTarget = session.target; + if (session.target.targetClass === 'attach-only') { + const response = await api.externalCliSessionAdmission(session.target); + const body = await response.json().catch(() => null); + const admitted = readPublicTerminalTarget(body?.data?.terminal); + if ( + !response.ok + || admitted?.runtime !== 'herdr' + || admitted.targetClass !== 'attach-only' + || admitted.sourceId !== session.target.sourceId + || admitted.targetId !== session.target.targetId + ) { + throw new Error(body?.error?.message ?? 'Herdr control is unavailable.'); + } + controlTarget = admitted; + } + onExternalTerminalOpen({ + runtime: 'herdr', + terminal: controlTarget, + kind: 'Herdr', + cliKind: 'herdr', + project: null, + mode, + }); + } catch (error) { + setHerdrOpenError(error instanceof Error ? error.message : 'Herdr control is unavailable.'); + } finally { + setHerdrOpeningKey(null); + } + }; return (
@@ -104,28 +161,59 @@ export default function SidebarContent({ {sessionCount > 0 ? ( - + <> + + {herdrSessions.length > 0 && ( +
+
+ Herdr +
+ {herdrSessions.map((session) => ( +
+ + {session.sourceId} + + + {session.capabilities.output && ( + + )} + {session.capabilities.attach && ( + + )} + +
+ ))} + {herdrOpenError && ( +
{herdrOpenError}
+ )} +
+ )} + ) : (!liveSessionsLoaded || externalLoading) ? (
diff --git a/src/components/standalone-shell/view/StandaloneShell.typedAttach.test.tsx b/src/components/standalone-shell/view/StandaloneShell.typedAttach.test.tsx index 0f39d41..3e23512 100644 --- a/src/components/standalone-shell/view/StandaloneShell.typedAttach.test.tsx +++ b/src/components/standalone-shell/view/StandaloneShell.typedAttach.test.tsx @@ -222,8 +222,8 @@ for (const { name, target, projectPath, expected } of [ }, projectPath: '/workspace/local-agent', expected: { - type: 'init', - shellProtocolVersion: 2, + type: 'terminal.init', + protocolVersion: 3, projectPath: '/workspace/local-agent', sessionId: null, hasSession: false, @@ -232,9 +232,12 @@ for (const { name, target, projectPath, expected } of [ rows: 42, forceRestart: false, mode: 'typed-attach', - targetClass: 'local-agent', - tmux, - process: { pid: 4242, startedAtMs: 1_700_000_004_242 }, + target: { + runtime: 'tmux', + targetClass: 'local-agent', + tmux, + process: { pid: 4242, startedAtMs: 1_700_000_004_242 }, + }, }, }, { @@ -246,8 +249,8 @@ for (const { name, target, projectPath, expected } of [ }, projectPath: undefined, expected: { - type: 'init', - shellProtocolVersion: 2, + type: 'terminal.init', + protocolVersion: 3, projectPath: '', sessionId: null, hasSession: false, @@ -256,9 +259,12 @@ for (const { name, target, projectPath, expected } of [ rows: 42, forceRestart: false, mode: 'typed-attach', - targetClass: 'attach-only', - tmux, - capability: 'attach-capability-9', + target: { + runtime: 'tmux', + targetClass: 'attach-only', + tmux, + admissionCapability: 'attach-capability-9', + }, }, }, ]) { diff --git a/src/hooks/restAuthority.mounted.test.tsx b/src/hooks/restAuthority.mounted.test.tsx index de7aba6..8551494 100644 --- a/src/hooks/restAuthority.mounted.test.tsx +++ b/src/hooks/restAuthority.mounted.test.tsx @@ -5,8 +5,9 @@ import { createElement, useEffect } from 'react'; import TestRenderer, { act, type ReactTestRenderer } from 'react-test-renderer'; import type { TmuxPaneIdentity } from '../../shared/tmux'; -import { useExternalCliSessions } from '../components/sidebar/hooks/useExternalCliSessions'; +import { useExternalCliSessions, type ExternalCliSession } from '../components/sidebar/hooks/useExternalCliSessions'; import WebSocketContext from '../contexts/WebSocketContext'; +import type { ServerEvent } from '../contexts/WebSocketContext'; import { api } from '../utils/api'; import { useProjectsState } from './useProjectsState'; @@ -100,6 +101,59 @@ const externalPayload = (pid: number) => ({ }, }); +const externalSnapshotEvent = { + kind: 'discovery.v2.snapshot', + version: 2, + discovery: { + version: 2, + epoch: 'epoch-external', + globalRevision: 1, + terminals: [], + tmuxRows: [{ + key: 'external-tmux', + lane: 'external', + tmuxName: 'qa-external', + tmux, + process: null, + kind: 'shell', + providerSessionId: null, + activity: 'unknown', + cwd: '/workspace/project', + presence: 'present', + }], + sourceDescriptors: [{ runtime: 'tmux', sourceId: 'tmux.local', readiness: 'ready' }], + sourceLanes: [{ + lane: 'external', + sourceId: 'tmux.local', + runtime: 'tmux', + readiness: 'ready', + capabilities: { discovery: true, output: true, actions: true, attach: true, create: false }, + sourceLaneRevision: 1, + lastOkGlobalRevision: 1, + coverage: 'authoritative', + consecutiveFailures: 0, + }], + coverageByLane: { + external: { + lane: 'external', + state: 'complete', + expectedSourceLaneKeys: ['external\u0000tmux.local'], + authoritativeSourceLaneKeys: ['external\u0000tmux.local'], + retainedSourceLaneKeys: [], + unavailableSourceLaneKeys: [], + }, + live: { + lane: 'live', + state: 'complete', + expectedSourceLaneKeys: [], + authoritativeSourceLaneKeys: [], + retainedSourceLaneKeys: [], + unavailableSourceLaneKeys: [], + }, + }, + }, +} as ServerEvent; + const livePayload = (pid: number) => ({ success: true, data: { @@ -129,6 +183,65 @@ const malformedLivePayload = { data: { discovery: { ok: true }, liveSessions: { stale: true } }, }; +test('healthy discovery retains the exact REST attach capability for shell panes', async () => { + const timers = installBrowserGlobals(); + const queue = requestQueue(); + const originalExternalSessions = api.externalSessions; + (api as typeof api & { externalSessions: (signal?: AbortSignal) => Promise }).externalSessions = + (signal?: AbortSignal) => queue.request(signal); + const listeners = new Set<(event: ServerEvent) => void>(); + let latest: { sessions: ExternalCliSession[] } | null = null; + let renderer: ReactTestRenderer | null = null; + + function Probe() { + const value = useExternalCliSessions(); + useEffect(() => { latest = value; }, [value]); + return null; + } + + try { + await act(async () => { + renderer = TestRenderer.create(createElement( + WebSocketContext.Provider, + { + value: { + ws: null, + sendMessage: () => undefined, + subscribe: (listener: (event: ServerEvent) => void) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + latestMessage: null, + isConnected: true, + } as never, + }, + createElement(Probe), + )); + await tick(); + }); + assert.equal(queue.requests.length, 1); + + await act(async () => { + for (const listener of listeners) listener(externalSnapshotEvent); + await tick(); + }); + assert.equal(queue.requests.length, 2); + assert.equal(queue.requests[1]!.signal?.aborted, false); + + await act(async () => { + queue.requests[1]!.resolve(jsonResponse(externalPayload(42))); + await tick(); + }); + assert.equal(latest!.sessions[0]?.authority, 'stream'); + assert.equal(latest!.sessions[0]?.process, null); + assert.equal(latest!.sessions[0]?.attachCapability, 'capability-42'); + } finally { + if (renderer) await act(async () => { renderer!.unmount(); }); + (api as typeof api & { externalSessions: typeof originalExternalSessions }).externalSessions = originalExternalSessions; + timers.restore(); + } +}); + test('mounted external hook fences malformed, deferred, recovered, and unmounted REST responses', async () => { const timers = installBrowserGlobals(); const queue = requestQueue(); @@ -206,8 +319,8 @@ test('mounted external hook fences malformed, deferred, recovered, and unmounted }); await act(async () => { latest!.refresh(); await tick(); }); - const publishCount = published.length; await act(async () => { renderer!.unmount(); await tick(); }); + const publishCount = published.length; queue.requests[4]!.resolve(jsonResponse(externalPayload(44))); await tick(); assert.equal(published.length, publishCount, 'unmounted response cannot publish'); @@ -231,14 +344,19 @@ test('mounted live hook atomically clears malformed authority and ignores late o let renderCount = 0; let renderer: ReactTestRenderer | null = null; + const navigate = (() => undefined) as never; + const subscribe = () => () => undefined; + const sendMessage = () => undefined; + const activeSessions = new Map(); + function Probe() { const value = useProjectsState({ - navigate: (() => undefined) as never, - subscribe: () => () => undefined, - sendMessage: () => undefined, + navigate, + subscribe, + sendMessage, isConnected: false, isMobile: false, - activeSessions: new Map(), + activeSessions, }); useEffect(() => { latest = value; diff --git a/src/hooks/useDiscoveryStream.test.ts b/src/hooks/useDiscoveryStream.test.ts index 5b887b3..65f2d79 100644 --- a/src/hooks/useDiscoveryStream.test.ts +++ b/src/hooks/useDiscoveryStream.test.ts @@ -1,124 +1,86 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { readFileSync } from 'node:fs'; -import { - discoveryAuthorityDisposition, - discoveryDeltaResyncReason, - discoveryFrameAuthorityDisposition, - discoveryHeartbeatDisposition, - DISCOVERY_TRANSPORT_LANES, -} from './useDiscoveryStream'; +import type { DiscoveryV2 } from '../../shared/terminal-runtime'; -test('shared transport subscriptions cover both discovery lanes', () => { - const source = readFileSync(new URL('./useDiscoveryStream.ts', import.meta.url), 'utf8'); - assert.deepEqual(DISCOVERY_TRANSPORT_LANES, ['external', 'live']); - assert.equal(source.match(/lanes: DISCOVERY_TRANSPORT_LANES/g)?.length, 2); - assert.doesNotMatch(source, /lanes: subscribedLanes/); -}); -test('requests a gap resync when a discovery delta is lost', () => { - const state = { epoch: 'epoch-1', revision: 4 }; - const lostDeltaFollowup = { - kind: 'discovery.delta', - epoch: 'epoch-1', - prevRevision: 5, - revision: 6, - changes: [], - }; +import { discoveryV2LaneAuthority, discoveryV2ResyncReason, projectDiscoveryV2Rows, DISCOVERY_TRANSPORT_LANES } from './useDiscoveryStream'; - assert.equal(discoveryDeltaResyncReason(lostDeltaFollowup, state), 'gap'); +const caps = { discovery: true, output: true, actions: true, attach: true, create: false }; +const herdr = (sourceId: string, targetId: string) => ({ runtime: 'herdr' as const, sourceId, targetId, targetClass: 'local-agent' as const, process: { pid: 1, startedAtMs: 2 } }); +const snapshot = (globalRevision = 4): DiscoveryV2 => ({ + version: 2 as const, epoch: 'epoch-1', globalRevision, + terminals: [{ lane: 'external' as const, terminal: herdr('hsrc_abcdefghijklmnopqrstuv', 'htgt_abcdefghijklmnopqrstuv') }, { lane: 'external' as const, terminal: herdr('hsrc_bcdefghijklmnopqrstuvw', 'htgt_bcdefghijklmnopqrstuvw') }], + sourceDescriptors: [], + sourceLanes: [ + { lane: 'external' as const, sourceId: 'hsrc_abcdefghijklmnopqrstuv', runtime: 'herdr' as const, readiness: 'ready' as const, capabilities: caps, sourceLaneRevision: 4, lastOkGlobalRevision: globalRevision, coverage: 'authoritative' as const, consecutiveFailures: 0 }, + { lane: 'external' as const, sourceId: 'hsrc_bcdefghijklmnopqrstuvw', runtime: 'herdr' as const, readiness: 'ready' as const, capabilities: caps, sourceLaneRevision: 4, lastOkGlobalRevision: globalRevision, coverage: 'authoritative' as const, consecutiveFailures: 0 }, + ], + coverageByLane: { + external: { lane: 'external' as const, state: 'complete' as const, expectedSourceLaneKeys: ['external\u0000hsrc_abcdefghijklmnopqrstuv', 'external\u0000hsrc_bcdefghijklmnopqrstuvw'], authoritativeSourceLaneKeys: ['external\u0000hsrc_abcdefghijklmnopqrstuv', 'external\u0000hsrc_bcdefghijklmnopqrstuvw'], retainedSourceLaneKeys: [], unavailableSourceLaneKeys: [] }, + live: { lane: 'live' as const, state: 'complete' as const, expectedSourceLaneKeys: [], authoritativeSourceLaneKeys: [], retainedSourceLaneKeys: [], unavailableSourceLaneKeys: [] }, + }, }); -test('requests an epoch resync instead of applying a foreign delta', () => { - assert.equal(discoveryDeltaResyncReason({ - kind: 'discovery.delta', - epoch: 'epoch-2', - prevRevision: 4, - changes: [], - }, { epoch: 'epoch-1', revision: 4 }), 'epoch_mismatch'); +test('uses strict v2 discovery transport', () => assert.deepEqual(DISCOVERY_TRANSPORT_LANES, ['external', 'live'])); +test('keeps independent Herdr sources and opaque identities', () => { + const rows = projectDiscoveryV2Rows(snapshot(), 'external'); + assert.equal(rows.length, 2); + assert.deepEqual(rows.map((row) => [row.sourceId, row.targetId]), [ + ['hsrc_abcdefghijklmnopqrstuv', 'htgt_abcdefghijklmnopqrstuv'], + ['hsrc_bcdefghijklmnopqrstuvw', 'htgt_bcdefghijklmnopqrstuvw'], + ]); + assert.equal(rows.every((row) => row.runtime === 'herdr' && !('tmux' in row)), true); }); - -test('heartbeat keeps only prior authority and cannot recover discovery authority', () => { - const heartbeat = { - kind: 'discovery.heartbeat' as const, - epoch: 'epoch-1', - revision: 4, +test('projects authoritative source/lane pairs without clearing peer Herdr or tmux terminals', () => { + const value = snapshot(); + value.terminals.unshift({ + lane: 'external', + terminal: { runtime: 'tmux', tmux: { socketPath: '/tmp/tmux', sessionId: '$1', windowId: '@1', paneId: '%1' }, targetClass: 'local-agent', process: { pid: 2, startedAtMs: 3 } }, + }); + value.sourceLanes.unshift({ lane: 'external', sourceId: 'tmux.local', runtime: 'tmux', readiness: 'ready', capabilities: caps, sourceLaneRevision: 4, lastOkGlobalRevision: 4, coverage: 'authoritative', consecutiveFailures: 0 }); + value.sourceLanes[1]!.coverage = 'retained'; + value.coverageByLane.external = { + lane: 'external', + state: 'partial', + expectedSourceLaneKeys: ['external\u0000tmux.local', 'external\u0000hsrc_abcdefghijklmnopqrstuv', 'external\u0000hsrc_bcdefghijklmnopqrstuvw'], + authoritativeSourceLaneKeys: ['external\u0000tmux.local', 'external\u0000hsrc_bcdefghijklmnopqrstuvw'], + retainedSourceLaneKeys: ['external\u0000hsrc_abcdefghijklmnopqrstuv'], + unavailableSourceLaneKeys: [], }; - const state = { epoch: 'epoch-1', revision: 4 }; - - assert.equal(discoveryHeartbeatDisposition(heartbeat, state, true), 'keepalive'); - assert.equal(discoveryHeartbeatDisposition(heartbeat, state, false), 'ignore'); - assert.equal( - discoveryHeartbeatDisposition({ ...heartbeat, revision: 5 }, state, true), - 'ignore', - ); + assert.equal(discoveryV2LaneAuthority(value, 'external'), 'rest'); + assert.deepEqual(projectDiscoveryV2Rows(value, 'external').map((row) => row.sourceId), [ + 'tmux.local', + 'hsrc_bcdefghijklmnopqrstuvw', + ]); }); - -test('discovery authority requires current healthy evidence independently for each lane', () => { - const state = { epoch: 'epoch-1', revision: 4 }; - const frame = { - kind: 'discovery.snapshot', - epoch: 'epoch-1', - revision: 4, - health: { - external: { ok: true, lastOkRevision: 4 }, - live: { ok: false, lastOkRevision: 4 }, - }, +test('preserves tmux projection only in its explicitly declared lane', () => { + const value = snapshot(); + value.terminals = [{ + lane: 'live' as const, + terminal: { runtime: 'tmux' as const, tmux: { socketPath: '/tmp/tmux', sessionId: '$1', windowId: '@1', paneId: '%1' }, targetClass: 'local-agent' as const, process: { pid: 2, startedAtMs: 3 } }, + }]; + value.sourceLanes = [{ lane: 'live', sourceId: 'tmux.local', runtime: 'tmux', readiness: 'ready', capabilities: caps, sourceLaneRevision: 4, lastOkGlobalRevision: 4, coverage: 'authoritative', consecutiveFailures: 0 }]; + value.coverageByLane.live = { + lane: 'live', state: 'complete', expectedSourceLaneKeys: ['live\u0000tmux.local'], + authoritativeSourceLaneKeys: ['live\u0000tmux.local'], retainedSourceLaneKeys: [], unavailableSourceLaneKeys: [], }; - - assert.equal(discoveryFrameAuthorityDisposition(frame, state, 'external', true, true), 'stream'); - assert.equal(discoveryFrameAuthorityDisposition(frame, state, 'live', true, true), 'rest'); - assert.equal( - discoveryFrameAuthorityDisposition({ ...frame, health: undefined }, state, 'external', true, true), - 'rest', - ); - assert.equal( - discoveryFrameAuthorityDisposition( - { ...frame, health: { external: { ok: true, lastOkRevision: null } } }, - state, - 'external', - true, - true, - ), - 'rest', - ); - assert.equal( - discoveryFrameAuthorityDisposition( - { ...frame, health: { external: { ok: true, lastOkRevision: 5 } } }, - state, - 'external', - true, - true, - ), - 'rest', - ); + assert.deepEqual(projectDiscoveryV2Rows(value, 'external'), []); + assert.equal(projectDiscoveryV2Rows(value, 'live')[0]?.sourceId, 'tmux.local'); }); - -test('discovery authority clears for stale transport, epoch loss, and resync recovery requires a frame', () => { - const state = { epoch: 'epoch-1', revision: 4 }; - const frame = { - kind: 'discovery.delta', - epoch: 'epoch-1', - revision: 4, - health: { live: { ok: true, lastOkRevision: 4 } }, - changes: [], - }; - - assert.equal(discoveryFrameAuthorityDisposition(frame, state, 'live', true, false), 'rest'); - assert.equal( - discoveryFrameAuthorityDisposition({ ...frame, epoch: 'epoch-2' }, state, 'live', true, true), - 'rest', - ); - assert.equal(discoveryHeartbeatDisposition( - { kind: 'discovery.heartbeat', epoch: 'epoch-1', revision: 4 }, - state, - false, - ), 'ignore'); - assert.equal(discoveryFrameAuthorityDisposition(frame, state, 'live', true, true), 'stream'); +test('requires exact authoritative source/lane coverage but not a matching global revision', () => { + const value = snapshot(); + assert.equal(discoveryV2LaneAuthority(value, 'external'), 'stream'); + value.sourceLanes[0].lastOkGlobalRevision = value.globalRevision - 1; + assert.equal(discoveryV2LaneAuthority(value, 'external'), 'stream'); + (value.sourceLanes[1] as { coverage: string }).coverage = 'retained'; + assert.equal(discoveryV2LaneAuthority(value, 'external'), 'rest'); }); - -test('discovery authority disposition models stream, REST fallback, and absent evidence', () => { - assert.equal(discoveryAuthorityDisposition(true, true), 'stream'); - assert.equal(discoveryAuthorityDisposition(false, true), 'rest'); - assert.equal(discoveryAuthorityDisposition(false, false), 'none'); +test('rejects stale clients, pair regressions, and same-revision disagreement', () => { + const current = snapshot(); + const pairs = new Map(current.sourceLanes.map((source) => [`${source.lane}\u0000${source.sourceId}`, source.sourceLaneRevision])); + assert.equal(discoveryV2ResyncReason(snapshot(3), { epoch: 'epoch-1', revision: 4 }, pairs, null), 'gap'); + assert.equal(discoveryV2ResyncReason(snapshot(6), { epoch: 'epoch-1', revision: 4 }, pairs, null), 'gap'); + const regressed = snapshot(5); regressed.sourceLanes[0].sourceLaneRevision = 3; + assert.equal(discoveryV2ResyncReason(regressed, { epoch: 'epoch-1', revision: 4 }, pairs, null), 'pair_regression'); + assert.equal(discoveryV2ResyncReason(snapshot(), { epoch: 'epoch-1', revision: 4 }, pairs, JSON.stringify({ different: true })), 'same_revision_disagreement'); }); diff --git a/src/hooks/useDiscoveryStream.ts b/src/hooks/useDiscoveryStream.ts index 78aadc0..adac633 100644 --- a/src/hooks/useDiscoveryStream.ts +++ b/src/hooks/useDiscoveryStream.ts @@ -1,24 +1,27 @@ import { useEffect, useRef, useState } from 'react'; import type { ServerEvent } from '../contexts/WebSocketContext'; -import type { TmuxPaneIdentity, TmuxProcessGeneration } from '../../shared/tmux'; +import type { DiscoveryLane, DiscoveryV2, PublicTerminalTarget, RuntimeCapabilities, SourceLaneState, TmuxDiscoveryProjection } from '../../shared/terminal-runtime'; +import { publicTerminalKey, sourceLaneKey } from '../../shared/terminal-runtime'; import type { ProviderConnectionIssue } from '../../shared/provider-connection'; -export type DiscoveryLane = 'external' | 'live'; -export type DiscoveryRow = { +export type { DiscoveryLane } from '../../shared/terminal-runtime'; + +export type DiscoveryRow = TmuxDiscoveryProjection & { + connectionIssue?: ProviderConnectionIssue; + [key: string]: unknown; +}; + +/** A runtime-neutral operation row used by Herdr-aware surfaces. */ +export type RuntimeDiscoveryRow = { key: string; lane: DiscoveryLane; - tmuxName: string; - tmux: TmuxPaneIdentity; - process: TmuxProcessGeneration | null; - kind: string; - providerSessionId: string | null; - connectionIssue?: ProviderConnectionIssue; - activity: 'running' | 'waiting_user' | 'asking_user' | 'error' | 'unknown'; - tmuxActionable?: boolean; - cwd: string | null; + runtime: PublicTerminalTarget['runtime']; + sourceId: string; + terminal: PublicTerminalTarget; + capabilities: RuntimeCapabilities; presence: 'present' | 'stale'; - [key: string]: unknown; + targetId?: string; }; type StreamArgs = { @@ -27,256 +30,158 @@ type StreamArgs = { sendMessage: (message: unknown) => void; subscribe: (listener: (event: ServerEvent) => void) => () => void; onRows: (rows: DiscoveryRow[]) => void; + onRuntimeRows?: (rows: RuntimeDiscoveryRow[]) => void; onHealthChange?: (healthy: boolean) => void; onAuthorityChange?: (lane: DiscoveryLane, disposition: DiscoveryAuthorityDisposition) => void; }; export type DiscoveryAuthorityDisposition = 'stream' | 'rest' | 'none'; - -type DiscoveryFrameState = Pick<{ epoch: string | null; revision: number }, 'epoch' | 'revision'>; +type DiscoveryFrameState = { epoch: string | null; revision: number }; +type DiscoveryV2Event = { kind: 'discovery.v2.snapshot'; version: 2; discovery: DiscoveryV2 }; const HEARTBEAT_STALE_MS = 15_000; -// The shared WebSocket has one server-side discovery subscription. Each hook -// filters this union locally so a later lane consumer cannot replace another. export const DISCOVERY_TRANSPORT_LANES: readonly DiscoveryLane[] = ['external', 'live']; -export function discoveryAuthorityDisposition( - streamAuthoritative: boolean, - restAvailable: boolean, -): DiscoveryAuthorityDisposition { - if (streamAuthoritative) return 'stream'; - return restAvailable ? 'rest' : 'none'; +export function discoveryAuthorityDisposition(streamAuthoritative: boolean, restAvailable: boolean): DiscoveryAuthorityDisposition { + return streamAuthoritative ? 'stream' : restAvailable ? 'rest' : 'none'; } -export function discoveryFrameAuthorityDisposition( - event: ServerEvent, - state: DiscoveryFrameState, - lane: DiscoveryLane, - isConnected: boolean, - isFresh: boolean, -): DiscoveryAuthorityDisposition { - if ( - !isConnected - || !isFresh - || !Number.isInteger(state.revision) - || (event.kind !== 'discovery.snapshot' && event.kind !== 'discovery.delta') - || event.epoch !== state.epoch - || event.revision !== state.revision - ) { - return discoveryAuthorityDisposition(false, true); - } - const health = event.health; - if ( - !health - || typeof health !== 'object' - || Array.isArray(health) - || !Object.prototype.hasOwnProperty.call(health, lane) - ) { - return discoveryAuthorityDisposition(false, true); - } - const laneHealth = (health as Record)[lane]; - if (!laneHealth || typeof laneHealth !== 'object' || Array.isArray(laneHealth)) { - return discoveryAuthorityDisposition(false, true); - } - const ok = Object.prototype.hasOwnProperty.call(laneHealth, 'ok') - && (laneHealth as { ok?: unknown }).ok === true; - const lastOkRevision = Object.prototype.hasOwnProperty.call(laneHealth, 'lastOkRevision') - ? (laneHealth as { lastOkRevision?: unknown }).lastOkRevision - : null; - return discoveryAuthorityDisposition( - ok - && typeof lastOkRevision === 'number' - && Number.isInteger(lastOkRevision) - && lastOkRevision > 0 - && lastOkRevision <= state.revision, - true, - ); +function isV2Snapshot(event: ServerEvent): event is ServerEvent & DiscoveryV2Event { + const value = event as unknown as Partial; + return value.kind === 'discovery.v2.snapshot' && value.version === 2 && Boolean(value.discovery) + && value.discovery!.version === 2; } -export function discoveryDeltaResyncReason( - event: ServerEvent, - state: DiscoveryFrameState, -): 'epoch_mismatch' | 'gap' | null { - if (event.kind !== 'discovery.delta' || !Array.isArray(event.changes)) return null; - if (event.epoch !== state.epoch) return 'epoch_mismatch'; - return event.prevRevision !== state.revision ? 'gap' : null; +function sourceStates(discovery: DiscoveryV2, lane: DiscoveryLane): SourceLaneState[] { + return discovery.sourceLanes.filter((source) => source.lane === lane); } -export type DiscoveryHeartbeatDisposition = 'ignore' | 'keepalive'; +/** Discovery is authoritative only when its expected pairs are exactly authoritative and monotonic. */ +export function discoveryV2LaneAuthority(discovery: DiscoveryV2, lane: DiscoveryLane): DiscoveryAuthorityDisposition { + const coverage = discovery.coverageByLane?.[lane]; + const states = sourceStates(discovery, lane); + if (!coverage || coverage.state !== 'complete' || !Number.isSafeInteger(discovery.globalRevision) || discovery.globalRevision < 0) return 'rest'; + const expected = new Set(coverage.expectedSourceLaneKeys); + const authoritative = new Set(coverage.authoritativeSourceLaneKeys); + if (expected.size !== states.length || expected.size !== authoritative.size) return 'rest'; + for (const state of states) { + const key = sourceLaneKey(lane, state.sourceId); + if (!expected.has(key) || !authoritative.has(key) || state.coverage !== 'authoritative' + || !Number.isSafeInteger(state.sourceLaneRevision) || state.sourceLaneRevision < 0) return 'rest'; + } + return 'stream'; +} + +/** Projects exact DiscoveryV2 lane/terminal entries without inferring a tmux lane. */ +export function projectDiscoveryV2Rows(discovery: DiscoveryV2, lane: DiscoveryLane): RuntimeDiscoveryRow[] { + const authoritative = new Set(discovery.coverageByLane?.[lane]?.authoritativeSourceLaneKeys ?? []); + const sources = discovery.sourceLanes.filter((state) => ( + state.lane === lane + && state.coverage === 'authoritative' + && authoritative.has(sourceLaneKey(lane, state.sourceId)) + )); + const capabilities = new Map(sources.map((state) => [state.sourceId, state.capabilities])); + return discovery.terminals + .filter((entry) => entry.lane === lane) + .filter(({ terminal }) => sources.some((source) => source.sourceId === ( + terminal.runtime === 'herdr' ? terminal.sourceId : 'tmux.local' + ))) + .map(({ terminal }) => { + const sourceId = terminal.runtime === 'herdr' ? terminal.sourceId : 'tmux.local'; + return { + key: publicTerminalKey(lane, terminal), lane, runtime: terminal.runtime, sourceId, + terminal, capabilities: capabilities.get(sourceId) ?? { discovery: false, output: false, actions: false, attach: false, create: false }, + presence: 'present' as const, + ...(terminal.runtime === 'herdr' ? { targetId: terminal.targetId } : {}), + }; + }); +} -export function discoveryHeartbeatDisposition( - event: ServerEvent, +/** Reject epoch changes, gaps, duplicate/regressed global revisions, and pair regressions. */ +export function discoveryV2ResyncReason( + discovery: DiscoveryV2, state: DiscoveryFrameState, - streamAuthoritative: boolean, -): DiscoveryHeartbeatDisposition { - if ( - event.kind !== 'discovery.heartbeat' - || event.epoch !== state.epoch - || event.revision !== state.revision - || !streamAuthoritative - ) { - return 'ignore'; + pairRevisions: ReadonlyMap, + previousPayload: string | null, +): 'epoch_mismatch' | 'gap' | 'pair_regression' | 'same_revision_disagreement' | null { + if (!discovery.epoch || !Number.isSafeInteger(discovery.globalRevision) || discovery.globalRevision < 0) return 'gap'; + if (state.epoch !== null && discovery.epoch !== state.epoch) return 'epoch_mismatch'; + if (state.epoch !== null && (discovery.globalRevision < state.revision || discovery.globalRevision > state.revision + 1)) return 'gap'; + const payload = JSON.stringify(discovery); + if (state.epoch !== null && discovery.globalRevision === state.revision && previousPayload !== payload) return 'same_revision_disagreement'; + for (const source of discovery.sourceLanes) { + const previous = pairRevisions.get(sourceLaneKey(source.lane, source.sourceId)); + if (previous !== undefined && source.sourceLaneRevision < previous) return 'pair_regression'; } - return 'keepalive'; + return null; } -/** Applies the ordered display-only discovery stream. REST callers remain the fallback. */ -export function useDiscoveryStream({ - lanes, - isConnected, - sendMessage, - subscribe, - onRows, - onHealthChange, - onAuthorityChange, -}: StreamArgs): boolean { - const stateRef = useRef<{ epoch: string | null; revision: number; rows: Map }>({ epoch: null, revision: 0, rows: new Map() }); +export function useDiscoveryStream({ lanes, isConnected, sendMessage, subscribe, onRows, onRuntimeRows, onHealthChange, onAuthorityChange }: StreamArgs): boolean { + const stateRef = useRef({ epoch: null, revision: 0 }); + const pairsRef = useRef(new Map()); + const payloadRef = useRef(null); const onRowsRef = useRef(onRows); + const onRuntimeRowsRef = useRef(onRuntimeRows); const onHealthChangeRef = useRef(onHealthChange); const onAuthorityChangeRef = useRef(onAuthorityChange); - const authorityRef = useRef>({ - external: 'none', - live: 'none', - }); const [streamHealthy, setStreamHealthy] = useState(false); onRowsRef.current = onRows; + onRuntimeRowsRef.current = onRuntimeRows; onHealthChangeRef.current = onHealthChange; onAuthorityChangeRef.current = onAuthorityChange; const lanesKey = lanes.join(','); useEffect(() => { - const subscribedLanes = lanesKey.split(',') as DiscoveryLane[]; + const subscribedLanes = lanesKey ? lanesKey.split(',') as DiscoveryLane[] : []; let lastFrameAt = 0; - const emit = () => onRowsRef.current([...stateRef.current.rows.values()].filter((row) => subscribedLanes.includes(row.lane))); - const hasStreamAuthority = () => subscribedLanes.some((lane) => authorityRef.current[lane] === 'stream'); - const setAuthority = (lane: DiscoveryLane, disposition: DiscoveryAuthorityDisposition) => { - if (authorityRef.current[lane] === disposition) return; - const wasHealthy = hasStreamAuthority(); - authorityRef.current[lane] = disposition; - onAuthorityChangeRef.current?.(lane, disposition); - const healthy = hasStreamAuthority(); - if (wasHealthy !== healthy) { - setStreamHealthy(healthy); - onHealthChangeRef.current?.(healthy); - } - }; - const clearAuthority = (disposition: DiscoveryAuthorityDisposition) => { - for (const lane of subscribedLanes) setAuthority(lane, disposition); + const authority: Record = { external: 'none', live: 'none' }; + const setAuthority = (lane: DiscoveryLane, next: DiscoveryAuthorityDisposition) => { + if (authority[lane] === next) return; + authority[lane] = next; + onAuthorityChangeRef.current?.(lane, next); + const healthy = subscribedLanes.some((candidate) => authority[candidate] === 'stream'); + setStreamHealthy(healthy); + onHealthChangeRef.current?.(healthy); }; - const markFrameAlive = () => { - lastFrameAt = Date.now(); - }; - const resetAndResync = (reason: 'epoch_mismatch' | 'gap') => { - stateRef.current = { epoch: null, revision: 0, rows: new Map() }; - clearAuthority('rest'); + const reset = (reason: string) => { + stateRef.current = { epoch: null, revision: 0 }; + pairsRef.current = new Map(); + payloadRef.current = null; + for (const lane of subscribedLanes) setAuthority(lane, 'rest'); sendMessage({ type: 'discovery.resync', reason }); }; - const applyFrameAuthority = (event: ServerEvent) => { - for (const lane of subscribedLanes) { - setAuthority( - lane, - discoveryFrameAuthorityDisposition(event, stateRef.current, lane, isConnected, true), - ); - } - }; + const subscribeV2 = () => sendMessage({ type: 'discovery.subscribe', protocolVersion: 2, lanes: subscribedLanes, + known: stateRef.current.epoch === null ? null : { epoch: stateRef.current.epoch, globalRevision: stateRef.current.revision } }); const apply = (event: ServerEvent) => { - if (event.kind === 'websocket_reconnected') { - const { epoch, revision } = stateRef.current; - sendMessage({ - type: 'discovery.subscribe', - protocolVersion: 1, - lanes: DISCOVERY_TRANSPORT_LANES, - known: epoch === null ? null : { epoch, revision }, - }); - return; - } - if (event.kind === 'discovery.resync_required') { - resetAndResync('gap'); + if (event.kind === 'websocket_reconnected') { subscribeV2(); return; } + if (event.kind === 'discovery.resync_required') { reset('gap'); return; } + const heartbeat = event as unknown as { kind?: string; version?: number; epoch?: string; globalRevision?: number }; + if (heartbeat.kind === 'discovery.v2.heartbeat') { + if (heartbeat.version === 2 && heartbeat.epoch === stateRef.current.epoch && heartbeat.globalRevision === stateRef.current.revision + && subscribedLanes.some((lane) => authority[lane] === 'stream')) lastFrameAt = Date.now(); return; } - if (event.kind === 'discovery.snapshot') { - const epoch = typeof event.epoch === 'string' ? event.epoch : null; - const revision = typeof event.revision === 'number' && Number.isInteger(event.revision) - ? event.revision - : null; - if (epoch === null || revision === null || !Array.isArray(event.rows)) return; - const rows = event.rows.filter((row): row is DiscoveryRow => Boolean(row && typeof row === 'object' && typeof (row as DiscoveryRow).key === 'string')); - const retainedRows = stateRef.current.epoch === epoch - ? new Map(stateRef.current.rows) - : new Map(); - stateRef.current = { epoch, revision, rows: retainedRows }; - markFrameAlive(); - applyFrameAuthority(event); - for (const lane of subscribedLanes) { - for (const [key, row] of retainedRows) { - if (row.lane === lane) retainedRows.delete(key); - } - } - for (const row of rows) { - if (subscribedLanes.includes(row.lane)) retainedRows.set(row.key, row); - } - if (hasStreamAuthority()) emit(); - return; - } - if (event.kind === 'discovery.heartbeat') { - if (discoveryHeartbeatDisposition(event, stateRef.current, hasStreamAuthority()) === 'keepalive') { - markFrameAlive(); - } - return; - } - const resyncReason = discoveryDeltaResyncReason(event, stateRef.current); - if (resyncReason) { - resetAndResync(resyncReason); - return; - } - if ( - event.kind !== 'discovery.delta' - || !Array.isArray(event.changes) - || typeof event.revision !== 'number' - || !Number.isInteger(event.revision) - ) return; - stateRef.current.revision = event.revision; - markFrameAlive(); - applyFrameAuthority(event); - for (const change of event.changes as Array>) { - if (change.op === 'added' && change.row && typeof change.row === 'object') { - const row = change.row as DiscoveryRow; - if (subscribedLanes.includes(row.lane)) stateRef.current.rows.set(row.key, row); - } else if (change.op === 'updated' && typeof change.key === 'string' && change.patch && typeof change.patch === 'object') { - const row = stateRef.current.rows.get(change.key); - if (row) { - stateRef.current.rows.set(change.key, { ...row, ...(change.patch as Partial) }); - } - } else if (change.op === 'stale' && typeof change.key === 'string') { - const row = stateRef.current.rows.get(change.key); - if (row) stateRef.current.rows.set(change.key, { ...row, presence: 'stale' }); - } else if (change.op === 'removed' && typeof change.key === 'string') { - stateRef.current.rows.delete(change.key); - } + if (!isV2Snapshot(event)) return; + const discovery = event.discovery; + const reason = discoveryV2ResyncReason(discovery, stateRef.current, pairsRef.current, payloadRef.current); + if (reason) { reset(reason); return; } + stateRef.current = { epoch: discovery.epoch, revision: discovery.globalRevision }; + payloadRef.current = JSON.stringify(discovery); + pairsRef.current = new Map(discovery.sourceLanes.map((source) => [sourceLaneKey(source.lane, source.sourceId), source.sourceLaneRevision])); + lastFrameAt = Date.now(); + const runtimeRows = subscribedLanes.flatMap((lane) => projectDiscoveryV2Rows(discovery, lane)); + for (const lane of subscribedLanes) setAuthority(lane, isConnected ? discoveryV2LaneAuthority(discovery, lane) : 'rest'); + if (subscribedLanes.some((lane) => authority[lane] === 'stream')) { + onRowsRef.current((discovery.tmuxRows ?? []).filter((row) => subscribedLanes.includes(row.lane))); } - if (hasStreamAuthority()) emit(); + onRuntimeRowsRef.current?.(runtimeRows); }; const unsubscribe = subscribe(apply); - if (isConnected) { - const { epoch, revision } = stateRef.current; - sendMessage({ - type: 'discovery.subscribe', - protocolVersion: 1, - lanes: DISCOVERY_TRANSPORT_LANES, - known: epoch === null ? null : { epoch, revision }, - }); - } else { - clearAuthority('rest'); - } - const heartbeatTimer = window.setInterval(() => { - if (lastFrameAt === 0 || Date.now() - lastFrameAt > HEARTBEAT_STALE_MS) clearAuthority('rest'); + if (isConnected) subscribeV2(); else for (const lane of subscribedLanes) setAuthority(lane, 'rest'); + const timer = window.setInterval(() => { + if (!lastFrameAt || Date.now() - lastFrameAt > HEARTBEAT_STALE_MS) for (const lane of subscribedLanes) setAuthority(lane, 'rest'); }, HEARTBEAT_STALE_MS); - return () => { - window.clearInterval(heartbeatTimer); - unsubscribe(); - clearAuthority('none'); - }; + return () => { window.clearInterval(timer); unsubscribe(); for (const lane of subscribedLanes) setAuthority(lane, 'none'); }; }, [isConnected, lanesKey, sendMessage, subscribe]); - return streamHealthy; } diff --git a/src/types/app.ts b/src/types/app.ts index 1b07459..a56636b 100644 --- a/src/types/app.ts +++ b/src/types/app.ts @@ -1,4 +1,5 @@ import type { TmuxPaneIdentity, TmuxProcessGeneration } from '../../shared/tmux'; +import type { PublicTerminalTarget } from '../../shared/terminal-runtime'; export type LLMProvider = 'claude' | 'cursor' | 'codex' | 'opencode' | 'gjc' | 'omp' | 'omo'; @@ -37,6 +38,14 @@ export type AppTab = 'chat'; * attach-only. */ export type ExternalTerminalTarget = { + runtime: 'herdr'; + terminal: Extract; + kind: 'Herdr'; + cliKind: 'herdr'; + project: null; + mode: 'observe' | 'control'; + forceAttach?: boolean; +} | { tmuxName: string; tmux: TmuxPaneIdentity; process: TmuxProcessGeneration | null; @@ -68,6 +77,15 @@ export type ExternalTerminalTarget = { forceAttach?: boolean; }; +export type HerdrExternalTerminalTarget = Extract; +export type TmuxExternalTerminalTarget = Exclude; + +export function isHerdrExternalTerminal( + target: ExternalTerminalTarget | null | undefined, +): target is HerdrExternalTerminalTarget { + return Boolean(target && 'runtime' in target && target.runtime === 'herdr'); +} + export interface ProjectSession { id: string; title?: string; diff --git a/src/utils/api.js b/src/utils/api.js index 8b901ce..99908d4 100644 --- a/src/utils/api.js +++ b/src/utils/api.js @@ -116,13 +116,23 @@ export const api = { body: formData, }); }, + // Runtime-neutral terminal operations always carry a versioned public target. + // The tuple form remains a tmux UI projection only; it is converted before + // crossing the wire and never used for Herdr identity derivation. + terminalTarget: (target, process) => ( + target?.runtime + ? target + : { runtime: 'tmux', tmux: target, process, targetClass: 'local-agent' } + ), + // Session ids currently live in a tmux gjc pane (tmux+lsof; [] when no tmux). liveSessions: (signal) => authenticatedFetch('/api/providers/sessions/live', signal === undefined ? undefined : { signal }), - // Exact-pane actions carry both tmux identity and agent-process generation. - liveSessionSend: (tmux, process, message) => + // Runtime-neutral v2 terminal operations. `target` is authoritative server- + // verified identity; callers must never derive one from cached discovery. + liveSessionSend: (target, process, message) => authenticatedFetch('/api/providers/sessions/live/send', { method: 'POST', - body: JSON.stringify({ tmux, process, message }), + body: JSON.stringify({ apiVersion: 2, target: api.terminalTarget(target, process), message }), }), liveSessionInteractivePrompt: (tmux, process, options = {}) => authenticatedFetch('/api/providers/sessions/live/interactive', { @@ -150,21 +160,21 @@ export const api = { method: 'POST', body: JSON.stringify({ tmux, process, sessionId, toolId, message }), }), - liveSessionAction: (tmux, process, action) => + liveSessionAction: (target, process, action) => authenticatedFetch('/api/providers/sessions/live/actions', { method: 'POST', - body: JSON.stringify({ tmux, process, action }), + body: JSON.stringify({ apiVersion: 2, target: api.terminalTarget(target, process), action }), }), - liveSessionOutput: (tmux, process, options = {}) => + liveSessionOutput: (target, process, options = {}) => authenticatedFetch('/api/providers/sessions/live/output', { ...options, method: 'POST', - body: JSON.stringify({ tmux, process }), + body: JSON.stringify({ apiVersion: 2, target: api.terminalTarget(target, process) }), }), - externalCliSessionSend: (tmux, process, message) => + externalCliSessionSend: (target, process, message) => authenticatedFetch('/api/providers/sessions/external/send', { method: 'POST', - body: JSON.stringify({ tmux, process, message }), + body: JSON.stringify({ apiVersion: 2, target: api.terminalTarget(target, process), message }), }), externalCliSessionInteractivePrompt: (tmux, process, options = {}) => authenticatedFetch('/api/providers/sessions/external/interactive', { @@ -202,16 +212,21 @@ export const api = { method: 'POST', body: JSON.stringify({ tmux, process, sessionId, decision }), }), - externalCliSessionAction: (tmux, process, action) => + externalCliSessionAction: (target, process, action) => authenticatedFetch('/api/providers/sessions/external/actions', { method: 'POST', - body: JSON.stringify({ tmux, process, action }), + body: JSON.stringify({ apiVersion: 2, target: api.terminalTarget(target, process), action }), }), - externalCliSessionOutput: (tmux, process, options = {}) => + externalCliSessionOutput: (target, process, options = {}) => authenticatedFetch('/api/providers/sessions/external/output', { ...options, method: 'POST', - body: JSON.stringify({ tmux, process }), + body: JSON.stringify({ apiVersion: 2, target: api.terminalTarget(target, process) }), + }), + externalCliSessionAdmission: (target) => + authenticatedFetch('/api/providers/sessions/external/admission', { + method: 'POST', + body: JSON.stringify({ apiVersion: 2, target: api.terminalTarget(target) }), }), // Create a supported local coding-agent tmux session from the unified sessions tab. externalCliSessionSpawn: (cli, name, cwd) => @@ -222,7 +237,7 @@ export const api = { externalCliSessionKill: (tmux, process, mode = 'process') => authenticatedFetch('/api/providers/sessions/external/kill', { method: 'POST', - body: JSON.stringify({ tmux, process, mode }), + body: JSON.stringify({ apiVersion: 2, target: api.terminalTarget(tmux, process), mode }), }), // Spawn a new tmux gjc session via the control tower (POST /spawn). liveSessionSpawn: (name, cwd) => @@ -234,7 +249,7 @@ export const api = { liveSessionKill: (tmux, process, mode = 'process') => authenticatedFetch('/api/providers/sessions/live/kill', { method: 'POST', - body: JSON.stringify({ tmux, process, mode }), + body: JSON.stringify({ apiVersion: 2, target: api.terminalTarget(tmux, process), mode }), }), // Slash commands a live tmux gjc session can execute (native + project + // skills) — powers the live relay composer's command palette.