diff --git a/README.md b/README.md index f439188..4205e36 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,9 @@ Persistent terminal sessions. Run a process, detach, reconnect later. From anywh Uses [@xterm/headless](https://github.com/xtermjs/xterm.js/tree/master/headless) internally. +The durable system contract lives in +[docs/vrs](docs/vrs/spec.md). + ## Install ```sh diff --git a/docs/vrs/requirements.md b/docs/vrs/requirements.md new file mode 100644 index 0000000..783b45b --- /dev/null +++ b/docs/vrs/requirements.md @@ -0,0 +1,98 @@ +# pty requirements + +## Context + +The [README](../../README.md) is the concise purpose and user guide for `pty`. +These requirements define its durable, testable system constraints. The +implementation contract and validation map live in [spec.md](./spec.md). + +## Assumptions + +- **A01 Unix host:** Supported hosts provide Unix PTYs, Unix-domain sockets, + process signals, and atomic same-filesystem rename. The supported products are + macOS and Linux. +- **A02 Trusted user boundary:** One registry belongs to one trusted OS user. + Filesystem permissions are the access boundary; readonly mode is behavior, + not authorization. +- **A03 Terminal semantics:** Child output is an ordered terminal byte stream. + Reconstructing it requires a terminal emulator rather than line-oriented logs. + +## Acceptable tradeoffs + +- **T01 Per-session daemon:** Each session pays for an independent daemon in + exchange for client-independent lifetime and failure isolation. +- **T02 Shared grid:** All writable clients share the minimum requested rows and + minimum requested columns so every writer can represent the complete grid. +- **T03 Pre-1.0 compatibility:** Public storage and package APIs may evolve + before 1.0, but readers remain bounded and documented compatibility tiers are + preserved deliberately. + +## Requirements + +### Must preserve runtime meaning + +- **R01 Independent session lifetime:** A session child and terminal state + continue independently of creating, attached, detached, or observing clients; + failure of one session or client does not implicitly terminate another. +- **R02 Durable launch context:** Initial launch, explicit restart, and + policy-driven respawn preserve command, arguments, working directory, initial + geometry, lifetime policy, labels, tags, and child-environment policy. + Exact-environment mode is exclusive with inherited/isolate policy; inherited + removals precede assignments. Ordinary assigned values, including an empty + `NO_COLOR`, remain exact. `PTY_SESSION` and its generation token are + runtime-owned; absent or empty `TERM` selects `xterm-256color`, while a + nonempty terminal name is preserved. Historical metadata without removals + retains ambient-inheritance behavior. +- **R03 Ordered, generation-safe lifecycle:** Child output is drained before + exit is finalized. Restart, permanent reconciliation, abandonment, explicit + removal, and cleanup follow explicit policy and cannot mutate or delete a + replacement generation. Recovery after external registry unlink rebinds the + same supporting daemon and child without signaling, restarting, relaunching, + duplicating the provider, or disconnecting existing clients. + +### Must reconstruct one shared terminal + +- **R04 Ordered reconstruction:** Every valid attach or recognized peek that + emits terminal state sends effective geometry, exactly one screen baseline, + then post-cut data and at most one process exit in source order. A later mode + request supersedes an unfinished generation; reconnect starts a new one. +- **R05 Replaceable client roles:** A complete `ATTACH` makes its socket + writable, installs requested geometry, and enables input and resize. A + recognized `PEEK` makes it readonly and removes its geometry constraint. A + malformed attach changes neither role nor synchronization generation. +- **R06 Deterministic geometry:** Effective rows and columns are the independent + minima requested by writable clients. Attach, resize, and disconnect + recompute them; readonly observation never constrains them. Geometry changes + are visible before terminal bytes produced for the new size. +- **R07 Bounded stream protocol:** Packets are length-delimited, fragmented + input is reassembled, oversized input is rejected without unbounded + buffering, and reconnect or unsupported capability failure is explicit. +- **R08 Machine attach outcomes:** Machine attach preserves the framed + `GEOMETRY`, `SCREEN`, `DATA`, and `EXIT` stream on a caller-owned inherited + descriptor while stdin/stdout remain the controlling terminal. A clean stream + ends with exactly one `EXIT` when the session process ended or empty `DETACH` + when this client intentionally detached; EOF without either is truncation. + Administrative destruction is truncation unless process `EXIT` was observed. + +### Must expose durable state through one behavioral core + +- **R09 Stable, inspectable registry:** Registry root and filename-safe stable + id are explicit. Display names and tags are presentation metadata. Inventory + and status expose lifecycle, clients, requested/effective geometry, resources, + metadata, and any live-recovery capability without attaching or mutating the + session. +- **R10 Durable, compatible records:** Metadata retains the launch and lifecycle + fields needed for inspection and restart, preserves unknown fields on update, + and uses generation-aware atomic replacement. Events are external-readable + JSONL records with bounded retention. Readers reject structurally invalid or + unbounded input while retaining documented legacy fallbacks. Live recovery + authenticates the current private registry root, recovery directory, + generation, daemon process, launch identity, and metadata revision; it fails + closed on stale, replayed, interrupted-publication, tampered, wrong-root, or + path-replacement attempts while allowing an authenticated interrupted lock to + resume. +- **R11 Equivalent supported surfaces:** CLI commands, exported client/server/ + protocol/testing APIs, the shipped package entrypoint, local transport, and + remote routing preserve the applicable runtime, stream, geometry, registry, + and lifecycle contracts. A surface rejects unsupported capabilities instead + of silently weakening them; tests use real PTYs and processes. diff --git a/docs/vrs/spec.md b/docs/vrs/spec.md new file mode 100644 index 0000000..3354ec9 --- /dev/null +++ b/docs/vrs/spec.md @@ -0,0 +1,209 @@ +# pty specification + +This document specifies the current `pty` system. It builds on +[requirements.md](./requirements.md). + +## Status + +Draft until every mapped contract is present on the default branch. The test +matrix below is the executable validation boundary. + +## Scope + +This specification defines persistent session execution, ordered terminal +transport, registry state, and supported CLI/package surfaces. It does not +define a shell, window manager, multi-tenant authorization boundary, or product +orchestration policy. + +## Composition + +```text +CLI / package / testing / remote surfaces + | + +---------+---------+ + | | + ordered client stream durable registry + | | + +---------+---------+ + | + per-session runtime + | + child process + PTY +``` + +The runtime owns the child process and headless terminal model. The stream +projects one ordered terminal to ephemeral clients. The registry owns stable +identity and durable observations. Every public surface composes these three +sources rather than defining alternate semantics (R01, R09, R11). + +## Runtime and launch + +A session is a detached daemon containing one child PTY and one headless +terminal emulator. The daemon owns the socket and survives client disconnects +(R01). + +Launch environment assembly is ordered (R02): + +```text +replacement mode: copy env + +inherited mode: process.env -> remove internal server config +isolated mode: allowlisted process.env + LC_* + +policy modes only: base -> unsetEnv[] -> extraEnv{} +all modes: -> force PTY_SESSION + generation token + -> TERM absent/empty ? xterm-256color : preserve value +``` + +Replacement mode and the inherited/isolate policy options are mutually +exclusive. Metadata persists the selected mode and its removals/assignments. +Explicit restart reuses it. Permanent reconciliation re-reads a current +manifest declaration when available and otherwise uses persisted metadata. +Metadata predating `unsetEnv` retains historical ambient inheritance. + +On child exit, the runtime drains accepted output, records final screen and exit +state, emits the lifecycle event, and applies cleanup policy. Every mutating +cleanup/restart path compares stable id plus generation so stale work cannot +change a replacement session (R03). + +## Ordered client stream + +Packets use a five-byte header followed by a bounded payload: + +```text +[type: uint8][length: uint32BE][payload: length bytes] +``` + +The reader reassembles partial input and rejects declared payloads above 32 MiB +(R07). Unknown bounded message types are ignored for additive compatibility; +capability-specific surfaces fail closed when required packets are absent. + +### Synchronization + +For each admitted attach/peek generation (R04): + +```text +parser bytes before cut | parser bytes after cut | process exit + | | | + v v v +GEOMETRY -> SCREEN -----------> queued DATA -------> EXIT +``` + +The screen callback is the causal cut: `SCREEN` represents all earlier parser +writes; later data and exit queue behind it. A newer valid mode request +invalidates the unfinished generation. Reconnect creates a fresh generation. +A local machine detach may end with `DETACH` before a baseline is emitted. + +### Roles and geometry + +Role frames replace, rather than accumulate, socket state (R05): + +| Frame | Resulting role | Geometry membership | Input/resize | +| --- | --- | --- | --- | +| complete `ATTACH(rows, cols)` | writable | requested rows/cols | enabled | +| recognized `PEEK(flags)` | readonly | none | disabled | +| malformed `ATTACH` | unchanged | unchanged | unchanged | +| `STATUS` | unchanged | unchanged | unchanged | + +For writable request set `W`, shared geometry is (R06): + +```text +rows = min(client.rows for client in W) +cols = min(client.cols for client in W) +``` + +The dimensions are minimized independently. A changed `GEOMETRY` notification +precedes terminal output produced after the corresponding PTY resize. Removing +the last writable client leaves the last effective geometry stable. + +### Machine attach + +`attach --attach-stream-fd-v1 ` requires an inherited writable +descriptor `fd >= 3`. The packaged CLI runs without a wrapper child so the +descriptor, controlling terminal, signals, and process identity reach the +adapter unchanged (R08, R11). + +The adapter reframes only `GEOMETRY`, `SCREEN`, `DATA`, and terminal outcomes to +the descriptor; terminal interaction stays on stdin/stdout and diagnostics use +stderr. It flushes exactly one clean outcome before EOF: + +| Outcome | Meaning | +| --- | --- | +| `EXIT(code)` | the session process ended | +| empty `DETACH` | this local client intentionally detached | +| EOF without either | transport loss, reconnect give-up, descriptor failure, or abrupt administrative destruction | + +The last row is a non-zero truncation, not a third clean outcome (R07, R08). + +## Registry and lifecycle state + +`PTY_ROOT` selects one registry. A stable id owns socket, metadata, events, and +generation locks; display names and tags remain mutable lookup/presentation +fields (R09). + +Inventory is observational: it derives running/exited/vanished state and enriches +it with live status when available, but does not restart, reap, or attach. +Status reports client roles, requested/effective geometry, process resources, +and terminal modes. + +Metadata and events form two compatibility tiers (R10): + +| Record | Contract | +| --- | --- | +| metadata JSON | durable launch/lifecycle source; atomic generation-aware updates preserve unknown fields | +| event JSONL | externally readable observation stream; serialized append and bounded retention | +| socket packets | internal bounded protocol with documented legacy decoding fallbacks | + +Explicit lifecycle commands and `gc` own mutation. Cleanup is authorized by the +observed generation; removal wins over late daemon finalization, and permanent +respawn cannot overwrite a replacement (R03, R10). + +### Live registry recovery + +A supporting daemon may publish an opaque recovery capability only when it can +prove its process-start identity and both `PTY_ROOT` and `.recovery` are private +directories owned by the daemon user. If an external cleanup unlinks that live +session's socket, pid, and metadata paths, `recover --snapshot` authenticates a +complete retained metadata snapshot and asks the original daemon to rebind its +listener. It preserves the daemon generation, child process, provider launch, +terminal state, and attached clients; it does not probe by signal, restart, +relaunch, or replace an occupied pathname (R03, R09). + +The request/result exchange binds stable id, daemon pid and process-start token, +generation, launch identity, root and recovery-directory device/inode identity, +and the daemon's signed metadata revision. Metadata mutation advances the signed +revision before publishing the replacement record. Recovery therefore fails +closed after a partial publication and rejects missing, legacy, stale, replayed, +tampered, wrong-root, permission-downgraded, or path-replacement state. Success +republishes the socket, pid, and metadata with no-replace and owned-rollback +semantics and rotates the recovery secret. An +authenticated lock left by an interrupted recoverer may resume; other creation +locks remain authoritative and are never displaced (R10). + +## Surfaces + +The CLI, package entrypoint, exported client/server/protocol modules, testing +library, and remote route call the same behavioral core (R11). Completion +schemas preserve required option values. Remote streaming preserves local +packet order and fails explicitly when the peer lacks a capability. The testing +library drives real processes and PTYs and exposes screen, cursor, scrollback, +input, resize, and multi-client geometry without mocks. + +## Ownership and validation matrix + +| Requirement | Owning source | Primary executable evidence | +| --- | --- | --- | +| R01 | [server](../../src/server.ts), [spawn](../../src/spawn.ts) | [integration](../../tests/integration.test.ts), [exit reap](../../tests/exit-reap.test.ts), [shutdown](../../tests/shutdown-backstop.test.ts) | +| R02 | [server](../../src/server.ts), [spawn](../../src/spawn.ts), [sessions](../../src/sessions.ts), [ptyfile](../../src/ptyfile.ts) | [spawn options](../../tests/spawn-options.test.ts), [restart parity](../../tests/restart-launch-parity.test.ts), [restart scrub](../../tests/restart-env-scrub.test.ts), [ptyfile](../../tests/ptyfile.test.ts) | +| R03 | [server](../../src/server.ts), [sessions](../../src/sessions.ts), [recovery](../../src/recovery.ts) | [kill](../../tests/kill-wait.test.ts), [immediate reuse](../../tests/rm-immediate-reuse.test.ts), [generation guard](../../tests/gc-generation-guard.test.ts), [exit signal](../../tests/exit-signal.test.ts), [recovery](../../tests/recovery.test.ts) | +| R04 | [server](../../src/server.ts), [connection](../../src/connection.ts) | [integration](../../tests/integration.test.ts), [alternate screen](../../tests/screen-replay-altscreen.test.ts), [scrollback](../../tests/scrollback-fidelity.test.ts) | +| R05 | [server](../../src/server.ts) | [integration](../../tests/integration.test.ts) | +| R06 | [server](../../src/server.ts), [protocol](../../src/protocol.ts) | [effective geometry](../../tests/effective-geometry.test.ts), [resize](../../tests/resize-tui.test.ts), [status](../../tests/stats-cli.test.ts) | +| R07 | [protocol](../../src/protocol.ts), [connection](../../src/connection.ts), [remote](../../src/remote.ts) | [protocol](../../tests/protocol.test.ts), [connection](../../tests/connection.test.ts), [remote reconnect](../../tests/remote-reconnect.test.ts) | +| R08 | [client](../../src/client.ts), [CLI](../../src/cli.ts), [entrypoint](../../bin/pty) | [attach stream](../../tests/attach-stream.test.ts), [signals](../../tests/wrapper-signal-forwarding.test.ts) | +| R09 | [sessions](../../src/sessions.ts), [server](../../src/server.ts), [recovery](../../src/recovery.ts), [CLI](../../src/cli.ts) | [root](../../tests/pty-root.test.ts), [display name](../../tests/display-name.test.ts), [status](../../tests/stats-cli.test.ts), [list purity](../../tests/list-purity.test.ts), [recovery](../../tests/recovery.test.ts) | +| R10 | [sessions](../../src/sessions.ts), [events](../../src/events.ts), [recovery](../../src/recovery.ts), [protocol](../../src/protocol.ts) | [atomic writes](../../tests/atomic-writes.test.ts), [metadata events](../../tests/metadata-events.test.ts), [events](../../tests/events.test.ts), [recovery](../../tests/recovery.test.ts), [disk layout](../../tests/disk-layout-docs.test.ts) | +| R11 | [CLI](../../src/cli.ts), [client API](../../src/client-api.ts), [remote](../../src/remote.ts), [testing API](../../src/testing/index.ts) | [help](../../tests/help.test.ts), [completions](../../tests/completions.test.ts), [remote](../../tests/remote-fabric.test.ts), [screenshots](../../tests/screenshot.test.ts), [keys](../../tests/keys.test.ts) | + +`node scripts/verify-docs.ts --vrs-only` validates this two-document shape, +sequential requirement IDs, links, and complete requirement references. diff --git a/scripts/verify-docs.ts b/scripts/verify-docs.ts index b3e7564..7bc0213 100644 --- a/scripts/verify-docs.ts +++ b/scripts/verify-docs.ts @@ -7,6 +7,72 @@ import { fileURLToPath } from "node:url"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const projectRoot = path.join(__dirname, ".."); const docsPath = path.join(projectRoot, "docs", "testing.md"); +const vrsRoot = path.join(projectRoot, "docs", "vrs"); + +function verifyVrs(): void { + if (!fs.existsSync(vrsRoot)) { + console.error("VRS verification failed:\n- docs/vrs is missing"); + process.exit(1); + } + + const expected = ["requirements.md", "spec.md"]; + const actual = fs.readdirSync(vrsRoot).sort(); + const requirementsPath = path.join(vrsRoot, "requirements.md"); + const specPath = path.join(vrsRoot, "spec.md"); + const errors: string[] = []; + + if (actual.join("\n") !== expected.join("\n")) { + errors.push(`docs/vrs must contain only ${expected.join(" and ")}`); + } + if (!fs.existsSync(requirementsPath) || !fs.existsSync(specPath)) { + errors.push("docs/vrs requires requirements.md and spec.md"); + } else { + const requirements = fs.readFileSync(requirementsPath, "utf-8"); + const spec = fs.readFileSync(specPath, "utf-8"); + const ids = [...requirements.matchAll(/^- \*\*(R\d{2}) [^*]+:\*\*/gm)].map( + (match) => match[1], + ); + + if (ids.length === 0) errors.push("requirements.md defines no requirement IDs"); + if (!ids.every((id, index) => id === `R${String(index + 1).padStart(2, "0")}`)) { + errors.push("requirement IDs must be sequential in document order"); + } + if (!spec.includes("[requirements.md](./requirements.md)")) { + errors.push("spec.md must link its requirements.md"); + } + if (!spec.includes("## Status")) errors.push("spec.md must declare Status"); + + const references = new Set([...spec.matchAll(/\bR\d{2}\b/g)].map((match) => match[0])); + for (const id of ids) { + if (!references.has(id)) errors.push(`spec.md does not reference ${id}`); + } + for (const id of references) { + if (!ids.includes(id)) errors.push(`spec.md references unknown requirement ${id}`); + } + + for (const [file, content] of [ + [requirementsPath, requirements], + [specPath, spec], + ] as const) { + for (const match of content.matchAll(/\]\(([^)#]+)(?:#[^)]+)?\)/g)) { + if (/^[a-z]+:/i.test(match[1])) continue; + if (!fs.existsSync(path.resolve(path.dirname(file), match[1]))) { + errors.push(`${path.relative(projectRoot, file)} has broken link ${match[1]}`); + } + } + } + } + + if (errors.length > 0) { + console.error(`VRS verification failed:\n${errors.map((error) => `- ${error}`).join("\n")}`); + process.exit(1); + } + console.log("Verified 2 VRS documents and 11 requirement IDs"); +} + +verifyVrs(); + +if (process.argv.includes("--vrs-only")) process.exit(0); const content = fs.readFileSync(docsPath, "utf-8");