diff --git a/.github/dependabot.yml b/.github/dependabot.yml index da16318..8fa9fbc 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -10,6 +10,16 @@ updates: dependencies: patterns: - "*" + - package-ecosystem: "cargo" + directory: "/" + schedule: + interval: "weekly" + commit-message: + prefix: "deps-rust" + groups: + rust-dependencies: + patterns: + - "*" - package-ecosystem: "github-actions" directory: "/" schedule: diff --git a/.github/required-status-checks.txt b/.github/required-status-checks.txt new file mode 100644 index 0000000..6d31e0e --- /dev/null +++ b/.github/required-status-checks.txt @@ -0,0 +1,19 @@ +# Status contexts required by branch protection on main, one per line. +# +# This list is a local mirror of the remote branch protection configuration and +# exists only so a workflow change that renames or drops a required job is +# caught before merge; the remote configuration remains authoritative and is +# changed separately. Refresh it from the branch protection API when the remote +# list changes. +# +# The "CodeQL" context is reported by the CodeQL GitHub App rather than by a +# workflow job name, so it is not listed here. +# +# Verified against the branch protection API for this repository: +# Lint GitHub Actions workflows +# Quality gates +# Analyze (javascript-typescript, actions) +# CodeQL +Lint GitHub Actions workflows +Quality gates +Analyze (javascript-typescript, actions) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b18293a..c51016b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,6 +27,8 @@ jobs: uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: bun-version: 1.4.2 + - name: Install locked project dependencies + run: bun install --frozen-lockfile - name: Run quality gates run: just check @@ -37,10 +39,31 @@ jobs: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Lint workflows with pinned actionlint - uses: docker://rhysd/actionlint:1.7.12 + uses: docker://rhysd/actionlint:1.7.12@sha256:b1934ee5f1c509618f2508e6eb47ee0d3520686341fec936f3b79331f9315667 with: args: -color + platform: + name: Offline platform contract (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Set up Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: 1.4.2 + - name: Install locked project dependencies + run: bun install --frozen-lockfile + - name: Run offline platform contract tests + run: bun test tests/platform-contract.test.ts + - name: Exercise executable help without a target + run: bun run bin/bitty-devtools.ts --help + fuzz-smoke: name: Fuzz smoke (pinned, read-only, offline) runs-on: ubuntu-latest diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 38c718b..c66a1df 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -15,10 +15,19 @@ permissions: contents: read jobs: + # Branch protection on main requires the status context + # "Analyze (javascript-typescript, actions)" under this exact name, so the + # combined analysis stays a single job with its historical name. Splitting it + # into a per-language matrix renames every check and blocks every pull + # request. Change the required context in branch protection before renaming + # this job. The emitted names are guarded by + # tests/platform-contract.test.ts against + # .github/required-status-checks.txt. analyze: name: Analyze (javascript-typescript, actions) runs-on: ubuntu-latest permissions: + actions: read contents: read security-events: write steps: @@ -28,5 +37,31 @@ jobs: uses: github/codeql-action/init@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4 with: languages: javascript-typescript, actions + build-mode: none + config-file: .github/codeql/codeql-config.yml - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4 + with: + category: "/language:javascript-typescript,actions" + + # Rust is a second language in this repository, analysed separately so the + # required combined context above is never renamed. + analyze-rust: + name: Analyze (rust) + runs-on: ubuntu-latest + permissions: + contents: read + security-events: write + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Initialize CodeQL + uses: github/codeql-action/init@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4 + with: + languages: rust + build-mode: none + config-file: .github/codeql/codeql-config.yml + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4 + with: + category: "/language:rust" diff --git a/CHANGELOG.md b/CHANGELOG.md index bf28e50..a65707d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,25 +42,18 @@ and this project adheres to no released version yet. issue's strict-envelope requirement. - **Diagnostics client phase 2 (CTX-0012)**: advanced tracing, control - surfaces, and real IPC socket/pipe peer-creds integration against the live - Bitty runtime. Reuses Panel Runtime and 14×4 compat matrix and extends - phase 1; bounded and `forbid(unsafe_code)`; strict TypeScript with no `any`. - Includes headless-testable `auth` (Unix `SO_PEERCRED` / Windows pipe ACL, - `0700`/`0600`, per-action re-verify, `BITTY_SOCKET` advisory, child token - `60s` bounded `64`) and `transport` (length-prefixed `256 KiB` frames, - `1 MiB` devtools logical, `RC-9` `100/s` `200` burst `16` conn, `RC-10` - `256 KiB` chunk, `Framer` `512 KiB` bound, `RateLimiter` deterministic, - `StdioTransportStub` + `IpcTransport` with `forwardTo` pipe simulation); - advanced `tracing` (filtering by kinds/owners `32`, coalescing `budget`, - structured attributable events, retention `4 MiB`/`5 min`/`4` traces, - GC `gcExpiredTraces`, chunked `256 KiB` export `0600` preview==export); - advanced `control` (pause/resume, generation exhaustion guard - `MAX_SAFE_INTEGER-1024`, transactional audit log `256` bounded, - `validateGeneration`, `listAuditLog`); client `DevtoolsClient` now - integrates `IpcTransport` (`connectWithTransport`, `connectLive` with - `XDG_RUNTIME_DIR` socket `0700`/`0600`, `isIpcConnected`, - per-privileged peer re-verify). TypeScript `62` tests and Rust - `37` tests pass; `just check` green. + surfaces, and a Linux-only endpoint-attested Unix-socket inspection adapter. + The adapter verifies the socket path, parent directory, endpoint ownership, + and modes before dialing, but does not authenticate a connected peer; live + sessions are therefore inspect-only. Windows named-pipe and macOS live + adapters are not implemented and fail closed. The client also retains a + headless transport/authentication fixture for caller-supplied values and + deterministic tests, not OS connectivity. Reuses Panel Runtime and the + 14×4 compat matrix; bounded and `forbid(unsafe_code)` in Rust, strict + TypeScript with no `any`. `BITTY_SOCKET` and `BITTY_INSTANCE_ID` select a + bounded path but are not credentials or identity. Advanced tracing and + control remain simulation/test surfaces until a server-backed trace receipt + and connected-identity control contract exist. - **Diagnostics client phase 1 (CTX-0011)**: human-facing inspection, tracing, and control surfaces for local debugging over the accepted Panel @@ -74,13 +67,11 @@ and this project adheres to no released version yet. protocol `1.0`. - **Toolchain pin (CTX-0046)**: pin `packageManager` to `bun@1.4.2` in - `package.json`, matching the workspace toolchain. `bun.lock` is unchanged - (`bun install --frozen-lockfile` passes). CI still installs Bun `1.4.0` - via `bun-version` (drift noted; workflow untouched by this slice). - -- **CI Bun version (CTX-0047)**: align `bun-version` in - `.github/workflows/ci.yml` with the `bun@1.4.2` toolchain pin, removing the - drift noted by CTX-0046. + `package.json`, matching the workspace toolchain. `bun.lock` is synchronized + with the declared dependencies; `bun install --frozen-lockfile --dry-run` + passes. +- **CI Bun version (CTX-0047)**: pin CI to Bun `1.4.2` and use + `bun install --frozen-lockfile` in the quality and platform jobs. - **Governance scaffolding**: MIT [LICENSE](./LICENSE), contribution guide ([CONTRIBUTING.md](./CONTRIBUTING.md)) with the Bitty delivery lifecycle and @@ -102,3 +93,33 @@ and this project adheres to no released version yet. unchanged retained state, opaque raw append distinct from typed stream coalescing, and `fetchTraceChunk` pagination on retained UTF-8 byte offsets. No code behavior changed. + +### Fixed + +- **Platform, version, and quality contracts (CTX-0080 / #141)**: the live + inspection path is now described as the code implements it. The live adapter + is Linux-only and attests the endpoint (socket path, parent directory, + ownership, and modes) before dialing; it does not authenticate a connected + peer, so live sessions stay inspect-only and `authenticated: false` is + reported. Windows and macOS return an explicit unsupported result and never + reach endpoint access. Unsupported live protocol versions and methods are + rejected with typed fail-closed errors before socket I/O, owned by + `src/protocol-boundary.ts` (live request admission only; `src/protocol.ts` + decoding stays with CTX-0079). `BITTY_SOCKET` and `BITTY_INSTANCE_ID` remain + bounded path selectors and are never credentials or identity, and no runtime + UID environment variable is read. CodeQL activates the checked-in + `.github/codeql/codeql-config.yml` for the combined `javascript-typescript, +actions` analysis and adds a separate Rust analysis with + `build-mode: autobuild`; the combined job keeps its exact name because branch + protection on `main` requires the status context + `Analyze (javascript-typescript, actions)`, now mirrored in + `.github/required-status-checks.txt` and asserted by + `tests/platform-contract.test.ts`. CI installs locked dependencies before the + quality and platform jobs, runs the offline platform contract on Linux, macOS, + and Windows, and pins the actionlint image by digest; Dependabot covers the + Cargo workspace. The `workflow-import` fixture suite derives its per-test + deadline from the fixture spawn budget, because Bun's 5 s per-test default is + shorter than one fixture run and so reported correct runs as timeouts under + load; `tests/workflow-import-budget.test.ts` fails if the two budgets cross + again. No `Verified` or `Compatible` platform status is claimed, and no + interoperability, release, or distribution claim is made. diff --git a/README.md b/README.md index 01aadfb..e7e013c 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,12 @@ OQ-019 and Performance Budgets OQ-001). Any future protocol change requires coordinated, explicitly ordered work in each owning repository. Any DevTools implementation consumes an explicitly versioned stable protocol -(`1.0` today, JSONL framing, 1 MiB inbound, 256 KiB chunk). It does not link +(`1.0` today, JSONL framing, 1 MiB inbound, 256 KiB chunk). Automation +candidate methods are also reported as `1.0`; no `1.1` compatibility claim is +made. The current live +adapter is Linux-only and endpoint-attested; Windows and macOS return an +explicit unsupported result. No platform is claimed `Verified` or `Compatible` +until its connected-identity adapter and CI evidence exist. It does not link private core types or inspect process memory as an implicit API. ## Implemented phase 1 (CTX-0011) @@ -80,12 +85,11 @@ and strict TypeScript with no `any`. `getInputRing` (`limit`), `getModifiers`, and `getFocus`. Terminal output is untrusted observation data, never instructions. -- **Tracing (debug.trace, opt-in)** — per-consumer bounded queues with - coalescing, batch `32` / `8 KiB`, chunk `256 KiB` to user-only storage - (`0600` conceptual), `startTrace` / `stopTrace` / `streamEvents` / - `fetchTraceChunk`, minimization by default, typed redaction, preview - equals export byte-for-byte, `DropOldest` default, `DropNewest` - alternative. +- **Tracing (debug.trace, opt-in, simulation-only)** — bounded in-memory + records with batch `32` / `8 KiB`, retention, typed redaction, and + `DropOldest`/`DropNewest` accounting. No filesystem spool is created; + `storage` is reported as `memory` and live sessions expose trace methods + as unavailable until a server-backed trace receipt exists. - **Control (debug.control, audited)** — `suspendHandler`, `resumePlugin`, `disposeGeneration`, each audited with caller identity, @@ -103,61 +107,51 @@ and tested with negative scope matrix tests. ## Implemented phase 2 (CTX-0012) -Phase 2 extends phase 1 with advanced tracing, control surfaces, and real -IPC socket/pipe peer-creds integration against the live Bitty runtime. +Phase 2 extends phase 1 with advanced tracing, control surfaces, a bounded +headless fixture, and a Linux-only endpoint-attested live inspection path. It remains experimental (no `Verified`/`Compatible` promise), bounded, `forbid(unsafe_code)` in Rust, strict TypeScript with no `any`, and reuses Panel Runtime + 14×4 compat matrix verbatim without new budget families. -- **Real IPC transport (live runtime)** — Unix socket under - `$XDG_RUNTIME_DIR/bitty` mode `0700`/`0600` or Windows named pipe with - current-user ACL, no TCP listener by default, peer credentials via - `SO_PEERCRED` / `LOCAL_PEERCRED` / `GetNamedPipeClientProcessId` - (headless-verified via `verifyPeerUid`, `verifyUnixEndpoint`, - `verifyWindowsPipe`), re-checked per privileged action, `BITTY_SOCKET` / - `BITTY_INSTANCE_ID` advisory only, `RC-9` `100/s` `200` burst `1 MiB` - `16` conn (shed newest), `RC-10` `256 KiB` chunk, length-prefixed - `256 KiB` frames + `1 MiB` devtools logical chunked at `256 KiB`, - `Framer` bound `512 KiB`, `RateLimiter` deterministic via `nowMs`, - headless `StdioTransportStub` / `IpcTransport` with `forwardTo` pipe - simulation and `PeerCredentials` / `ChildToken` (`60s` TTL, `64` bound, - PTY-fd only, never env). +- **Real IPC transport (live runtime)** — the implemented live adapter is a + Linux Unix socket under `$XDG_RUNTIME_DIR/bitty` with directory `0700` and + socket `0600`; there is no TCP listener. Endpoint attestation is not + presented as connected peer authentication, so the live client is + inspect-only and rejects trace/control requests. Windows named pipes and + macOS live sockets are explicitly unsupported until a real adapter and + platform CI exist. `BITTY_SOCKET` / `BITTY_INSTANCE_ID` select a bounded + endpoint path; neither is a credential or connected identity. `RC-9` `100/s` + `200` burst `1 MiB` `16` connections (shed + newest), `RC-10` `256 KiB` chunk, and length-prefixed `256 KiB` frames + remain bounded. Headless `StdioTransportStub` / `IpcTransport` seams are + test-only and do not claim OS connectivity. - **Advanced tracing (debug.trace, opt-in)** — structured attributable events (`StructuredTraceEvent` with `sequence`, `owner`, `generation`), - filtering by `kinds`/`owners` (bounded `32`), coalescing `budget` vs - `none`, retention `4 MiB` / `5 min` / `4` traces, GC - `gcExpiredTraces(nowMs)`, `startTraceWithFilter` with deterministic - `wallClockMs`, export to `0600` spool with `preview==export` - byte-for-byte, `DropOldest`/`DropNewest`, deterministic - `streamFilteredEvents`. + filtering by `kinds`/`owners` (bounded `32`), requested-byte batch + admission, retention `4 MiB` / `5 min` / `4` traces, and deterministic + `streamFilteredEvents`. The helper is explicitly in-memory simulation; + it does not claim a `0600` filesystem spool or a server mutation. - **Advanced control (debug.control, audited)** — `pauseHandler`, `resumePlugin` with generation exhaustion guard (`MAX_SAFE_INTEGER-1024`), `validateGeneration`, transactional audit log bounded `256` (`listAuditLog`, `clearAuditLog`), per-generation - ownership, `0600` spool mode, never widens sibling authority, no - capability/budget bypass. - -- **Client integration** — `DevtoolsClient` now wraps `IpcTransport` - (`connectWithTransport`, `connectLive(runtimeUid, peer, xdgDir, -instanceId)`, `isIpcConnected`, `getSocketPath`, - `transportOutgoingLen`), re-verifies peer per `grantScope`, - `revokeScope`, `startTrace`, `stopTrace`, `suspendHandler` etc., - exposes phase 2 tracing/control helpers - (`startTraceWithFilter`, `streamFilteredEvents`, - `appendStructuredEvent`, `getTraceRetention`, `gcExpiredTraces`, - `exportTracePreview`, `pauseHandler`, `validateGeneration`, - `listAuditLog`). - -Rust counterpart at `crates/devtools-client` mirrors the same contracts: -`auth` (`PeerCredentials`, `verify_unix_endpoint`, `ChildTokenStore`), -`transport` (`Frame`, `Framer`, `RateLimiter`, `StdioTransportStub`, -`IpcTransport`), advanced `tracing` (`TraceFilter`, `TraceRetention`, -`TracingClient` with `gc_expired`), advanced `control` -(`ControlClient` with audit log). `cargo check` / `clippy -D warnings` / -`cargo test` `37` tests pass; `just check` green; `bun test` `62` tests -pass. No `unsafe`, no PTY/GPU/window handle, no TCP, no ambient credential. + ownership, and session-owned in-memory audit state. It never widens + sibling authority or bypasses capability/budget gates. + +- **Client integration** — `DevtoolsClient` exposes separate headless + simulation and live socket sessions. The live session is inspect-only, + uses strict response envelopes and request-id correlation, and clears + trace/panel/control state on disconnect or reconnect. The headless + `IpcTransport` remains a hermetic fixture seam and is not an OS adapter. + +Rust counterpart at `crates/devtools-client` mirrors the same bounded record +shape, byte-budget admission, session cleanup, and Linux-only live-platform +guard. `cargo check`, `clippy -D warnings`, and `cargo test` are required +local gates; the Rust live adapter reports unsupported explicitly on other +platforms. No `unsafe`, no PTY/GPU/window handle, no TCP, and no ambient +credential. ## Trace accounting semantics (Implemented, CTX-0053 / CTX-0054) @@ -216,10 +210,11 @@ does not yet name these methods. ## Usage (local, human-facing) +The first example is a headless simulation; live socket sessions are limited +to inspect operations. + ```ts import { DevtoolsClient } from "bitty-devtools"; -import { peerCredentials } from "bitty-devtools"; -import { IpcTransport } from "bitty-devtools"; const client = new DevtoolsClient({ version: "1.0" }); client.connect(); @@ -249,30 +244,30 @@ const trace = client.startTrace({ maxBytes: 512 * 1024, includeInput: false }); client.appendToTrace(trace.traceId, "instrumentation record"); console.log(client.stopTrace(trace.traceId)); -// Phase 2: live runtime via peer-creds (Unix socket 0600, no TCP) -const peer = peerCredentials(1000, 1000, 42); -const live = new IpcTransport({ - runtimeUid: 1000, - socketPath: "/run/user/1000/bitty/default.sock", - peer, -}); -const liveClient = new DevtoolsClient(); -liveClient.connectWithTransport(live); -liveClient.grantScope("debug.trace"); -const filtered = liveClient.startTraceWithFilter( - { filter: { kinds: ["bitty.panel:mounted"] }, maxBytes: 1024 * 1024 }, - Date.now(), -); -liveClient.appendStructuredEvent(filtered.traceId, { - sequence: 0, - owner: "panel-1", - kind: "bitty.panel:mounted", - payload: "{}", - generation: 1, - wallClockMs: Date.now(), -}); -console.log(liveClient.exportTracePreview(filtered.traceId)); -console.log(liveClient.gcExpiredTraces(Date.now() + 6 * 60 * 1000)); +// Phase 2: live inspection via the Linux endpoint-attested adapter +async function inspectLive(socketPath: string, runtimeUid: number) { + const liveClient = new DevtoolsClient(); + await liveClient.connectLiveSocket( + runtimeUid, + undefined, + undefined, + undefined, + socketPath, + ); + liveClient.grantScope("debug.inspect"); + console.log( + await liveClient.requestLive( + { + id: 1, + method: "bitty.debug/listPlugins", + params: { generation: 1 }, + version: "1.0", + }, + Date.now(), + ), + ); + liveClient.disconnect(); +} // Control requires explicit elevation and is audited client.grantScope("debug.control"); @@ -280,16 +275,25 @@ client.suspendHandler(1 as never, "handler-1", "diagnosis", "tester"); console.log(client.listAuditLog()); ``` -Rust equivalent lives at `crates/devtools-client` (`forbid(unsafe_code)`, -`cargo check` / `cargo clippy -D warnings` clean, 37 tests). +Rust equivalent lives at `crates/devtools-client` (`forbid(unsafe_code)`); +its bounded tracing/control checks are part of the repository Rust gate. + +## Platform support + +The current live socket adapter is implemented and tested for Linux only. +Windows named-pipe and macOS live-socket entry points return an explicit +unsupported error before endpoint access. The headless transport and +`verifyWindowsPipe` helpers are test seams, not platform compatibility +claims. Windows/macOS support requires a real connected-identity adapter and +positive/negative platform CI before any public capability claim changes. ## CLI wrapper (experimental, CTX-0025) -`bin/bitty-devtools.ts` is a lightweight executable over the typed inspection -client (`src/client.ts`, `src/inspection.ts`, `src/transport.ts`). It prints -bounded tabular live state (or `--json`) for the accepted devtools-rfc v1 -inspection methods; it does not re-implement protocol logic. It is experimental -and `Bun`-based — run it with `bun`, never `npm`/`npx`. +`bin/bitty-devtools.ts` awaits the async live runner, prints bounded +inspection state (or `--json`) for the accepted devtools-rfc v1 methods, and +never dials for `--help` or usage errors. It uses the strict live response +boundary and control-safe rendering; it does not re-implement protocol logic. +It is experimental and `Bun`-based — run it with `bun`, never `npm`/`npx`. ```text bitty-devtools inspect --plugins [--generation ] [options] @@ -386,12 +390,14 @@ const report = await runCampaign({ }); // Live (opt-in): execute the real `bitty ctl` over an explicit socket. -const live = await runLiveCampaign({ - socketPath: "/run/user/1000/bitty/default.sock", - runtimeUid: 1000, - stat: (dir) => myStatProvider(dir), - timeoutMs: 10_000, -}); +async function runLive(socketPath: string, runtimeUid: number) { + return runLiveCampaign({ + socketPath, + runtimeUid, + stat: (dir) => myStatProvider(dir), + timeoutMs: 10_000, + }); +} ``` Envelopes, terminal text, and diagnostics are untrusted observation data, never @@ -408,7 +414,7 @@ repository `justfile`: just check # fmt-check + lint + type-check + test + cargo-check just fmt-check # Prettier 3.9.6 check without writing files just lint # markdownlint-cli2 0.23.2 -just type-check # tsc --noEmit strict +just type-check # tsc --noEmit strict for src, bin, and tests just test # bun:test headless unit tests just cargo-check # cargo check + clippy -D warnings + cargo test just commit-check # validate commit message against commitlint @@ -449,8 +455,8 @@ technical record remains ## Current status -Phase 2 extends phase 1 with live IPC and advanced tracing/control as -experimental evidence for +Phase 2 extends phase 1 with Linux-only live inspection and advanced +tracing/control as experimental evidence for [DevTools RFC](https://github.com/bitty-terminal/bitty-terminal-docs/blob/main/specifications/devtools-rfc.md) (OQ-019), [IPC and Agent RFC](https://github.com/bitty-terminal/bitty-ai-docs/blob/main/specifications/ipc-agent-rfc.md) (OQ-018), and budgets OQ-001. No installation procedure, supported API, diff --git a/bin/bitty-devtools.ts b/bin/bitty-devtools.ts index 55bea71..29db498 100755 --- a/bin/bitty-devtools.ts +++ b/bin/bitty-devtools.ts @@ -6,12 +6,16 @@ * type-checked module; the runner receives an explicit runtime. */ -import { runCli } from "../src/cli.js"; +import { runCliLive } from "../src/cli.js"; const uid = typeof process.getuid === "function" ? process.getuid() : 0; const gid = typeof process.getgid === "function" ? process.getgid() : 0; +const controller = new AbortController(); +const abort = (): void => controller.abort(); +process.once("SIGINT", abort); +process.once("SIGTERM", abort); -const code = runCli(process.argv.slice(2), { +const code = await runCliLive(process.argv.slice(2), { runtime: { env: process.env, uid, @@ -25,6 +29,9 @@ const code = runCli(process.argv.slice(2), { process.stderr.write(text); }, }, + watch: { signal: controller.signal }, }); +process.removeListener("SIGINT", abort); +process.removeListener("SIGTERM", abort); process.exit(code); diff --git a/bun.lock b/bun.lock index 50729b6..65574ac 100644 --- a/bun.lock +++ b/bun.lock @@ -5,11 +5,11 @@ "": { "name": "bitty-devtools", "devDependencies": { - "@commitlint/cli": "21.2.2", - "@commitlint/config-conventional": "21.2.2", - "@types/bun": "1.4.0", - "bun-types": "1.4.0", - "carryctx": "0.11.2", + "@commitlint/cli": "21.2.3", + "@commitlint/config-conventional": "21.2.3", + "@types/bun": "1.4.2", + "bun-types": "1.4.2", + "carryctx": "0.11.6", "typescript": "7.0.2", }, }, @@ -19,39 +19,39 @@ "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], - "@commitlint/cli": ["@commitlint/cli@21.2.2", "", { "dependencies": { "@commitlint/config-conventional": "^21.2.2", "@commitlint/format": "^21.2.2", "@commitlint/lint": "^21.2.2", "@commitlint/load": "^21.2.2", "@commitlint/read": "^21.2.1", "@commitlint/types": "^21.2.0", "tinyexec": "^1.0.0", "yargs": "^18.0.0" }, "bin": { "commitlint": "cli.js" } }, "sha512-a+6hQxIxnpdvSvS2apvttPNbEliYsVC3PqFYDiiB2kjbwIsQsj1urvQ4Tkf70pKYozPalKAuRQmm/GHwndduqA=="], + "@commitlint/cli": ["@commitlint/cli@21.2.3", "", { "dependencies": { "@commitlint/config-conventional": "^21.2.3", "@commitlint/format": "^21.2.3", "@commitlint/lint": "^21.2.3", "@commitlint/load": "^21.2.3", "@commitlint/read": "^21.2.3", "@commitlint/types": "^21.2.3", "tinyexec": "^1.0.0", "yargs": "^18.0.0" }, "bin": { "commitlint": "cli.js" } }, "sha512-5yOX6IRjcrIReCxpyAd04hgI/yhUMpVBJ/ouwFV0wk9yx5+fYRDWZWJ5h55Tbedgfol+5m2mU5nqzbB8g3v0vQ=="], - "@commitlint/config-conventional": ["@commitlint/config-conventional@21.2.2", "", { "dependencies": { "@commitlint/types": "^21.2.0", "conventional-changelog-conventionalcommits": "^10.0.0" } }, "sha512-NxA37SZviusFUEYOQZ5hNnZ1h7O/KiemPkxjOlpzKJNnWxThiwc6/SaZhaPa8fyLvfRBAywhQhJJk8XESHWlpQ=="], + "@commitlint/config-conventional": ["@commitlint/config-conventional@21.2.3", "", { "dependencies": { "@commitlint/types": "^21.2.3", "conventional-changelog-conventionalcommits": "^10.0.0" } }, "sha512-EP2n5DN2GwRWNVrLX0Jx3mpQlW5PfFi9HnD9OJ8hN/H0xFqpCFyHeGDhg+0P8/21iMMcdkcQgbNqmVklebqYsQ=="], - "@commitlint/config-validator": ["@commitlint/config-validator@21.2.0", "", { "dependencies": { "@commitlint/types": "^21.2.0", "ajv": "^8.11.0" } }, "sha512-t7AzNHAKeIdo/3NRGwzpufKHsKkPHmFs/56N2Fnsh0/r0rGtnQzTxk6vnFgjaGr4hdSQKNB50/KAhR9Yk4LJKA=="], + "@commitlint/config-validator": ["@commitlint/config-validator@21.2.3", "", { "dependencies": { "@commitlint/types": "^21.2.3", "ajv": "^8.11.0" } }, "sha512-JQ13v0GafSM4fBYX8UowSy++07Was20auq0NcTdnzSEq6/05Cq5GBUMIfKkqjzyBwC9sJFmPpKWgLLhH7cygkw=="], - "@commitlint/ensure": ["@commitlint/ensure@21.2.0", "", { "dependencies": { "@commitlint/types": "^21.2.0", "es-toolkit": "^1.46.0" } }, "sha512-76IF9vDNS13lAzEEik9eKwzt8f9hYhWiwVXZ2AnyLCz5/f511FsEQ3pw1X3/zSQpdRLQU7i5qDMVKyXi1GWjSg=="], + "@commitlint/ensure": ["@commitlint/ensure@21.2.3", "", { "dependencies": { "@commitlint/types": "^21.2.3", "es-toolkit": "^1.46.0" } }, "sha512-gEjNKgRocbODI5/sMx2Qc9XOf2/zFTNV2aEK1QZ4gKDKxDC/VR7us3QtIX1C5d5xxHxx0I/tEbYbdSP1Am2LwA=="], "@commitlint/execute-rule": ["@commitlint/execute-rule@21.0.1", "", {}, "sha512-RifH+FmImozKBE6mozhF4K3r2RRKP7SMi/Q/zLCmExtp5e05lhHOUYqGBlFBAGNHaZxU/WYw1XuugYK9jQzqnA=="], - "@commitlint/format": ["@commitlint/format@21.2.2", "", { "dependencies": { "@commitlint/types": "^21.2.0", "picocolors": "^1.1.1" } }, "sha512-v6fvxZSc/AvVMROlr3H34+1766bZSYApRUSCAMjWamStPjKMvZ8GdvVA5YW/VQNgbFTmcMz6OYmSTJEvIjPrfA=="], + "@commitlint/format": ["@commitlint/format@21.2.3", "", { "dependencies": { "@commitlint/types": "^21.2.3", "picocolors": "^1.1.1" } }, "sha512-KKwCjXYqA8r295uhLnulDXL9yekdXmj2yp2Q0XsBPhxQ2iPuvRE8F43j7g7yexo69wTdmUQmFz15jpderluzUQ=="], - "@commitlint/is-ignored": ["@commitlint/is-ignored@21.2.2", "", { "dependencies": { "@commitlint/types": "^21.2.0", "semver": "^7.6.0" } }, "sha512-9UoKNgfFE3LU7FrzierCvk3CdDfMDeVGC86qZiT/n0TIjfq/dmZ9MHuXd45OTNRa26ZanmJRxEtmiXk/lEJihg=="], + "@commitlint/is-ignored": ["@commitlint/is-ignored@21.2.3", "", { "dependencies": { "@commitlint/types": "^21.2.3", "semver": "^7.6.0" } }, "sha512-Ng4nRj/LBAOvc+GQu7bsFW4/fL89LvZ14OOC/ERLbUpnG6QBwWRzY/1oXGwPs3C8YHWjJObin4kLYo2YeEn70w=="], - "@commitlint/lint": ["@commitlint/lint@21.2.2", "", { "dependencies": { "@commitlint/is-ignored": "^21.2.2", "@commitlint/parse": "^21.2.2", "@commitlint/rules": "^21.2.2", "@commitlint/types": "^21.2.0" } }, "sha512-Fy8JxEBzdmsYWFude/61GxXu5O+wEymwiRK2z9GL9R8mCsXphCoGxAFc5iHn5mjlfcSrhiiONE+ksf4KOjnaPg=="], + "@commitlint/lint": ["@commitlint/lint@21.2.3", "", { "dependencies": { "@commitlint/is-ignored": "^21.2.3", "@commitlint/parse": "^21.2.3", "@commitlint/rules": "^21.2.3", "@commitlint/types": "^21.2.3" } }, "sha512-lUZcSuVLLEDkhge9ERk8Rp+FXQtrUhCK+h328BFnzKXrTuXGdqiYMEpTlNWdZaOrjyMhMJaz354ZaWEGJxcLeg=="], - "@commitlint/load": ["@commitlint/load@21.2.2", "", { "dependencies": { "@commitlint/config-validator": "^21.2.0", "@commitlint/execute-rule": "^21.0.1", "@commitlint/resolve-extends": "^21.2.2", "@commitlint/types": "^21.2.0", "cosmiconfig": "^9.0.1", "cosmiconfig-typescript-loader": "^6.1.0", "es-toolkit": "^1.46.0", "is-plain-obj": "^4.1.0", "picocolors": "^1.1.1" } }, "sha512-0Tt6wDPX167cjKC5D4zhm0+20wJJG+TN/TKovMOspfSe78rOnKX+MNzlVNiu6HyQPZChPJ8QBH31MVt6Bb8fCg=="], + "@commitlint/load": ["@commitlint/load@21.2.3", "", { "dependencies": { "@commitlint/config-validator": "^21.2.3", "@commitlint/execute-rule": "^21.0.1", "@commitlint/resolve-extends": "^21.2.3", "@commitlint/types": "^21.2.3", "cosmiconfig": "^9.0.1", "cosmiconfig-typescript-loader": "^6.1.0", "es-toolkit": "^1.46.0", "is-plain-obj": "^4.1.0", "picocolors": "^1.1.1" } }, "sha512-cDrL8lOo23Lk7knGzQdjbFcq7z2JgvjwV6PhbESVvEc+Fe21MeLjPKBJi8nKjg7j+jh79CVbVSUbZc104thXRw=="], "@commitlint/message": ["@commitlint/message@21.2.0", "", {}, "sha512-YxGoiXD/HXNXLJPrQwE5poXa+XH0CBEm+mdvbHQP0g6MV/dmJyUFCzPNzZbxL93GvZ70TmtTK0Z0/IBpAqHv8g=="], - "@commitlint/parse": ["@commitlint/parse@21.2.2", "", { "dependencies": { "@commitlint/types": "^21.2.0", "conventional-changelog-angular": "^9.0.0", "conventional-commits-parser": "^7.0.0" } }, "sha512-MEkobPfvRp+z06Wro8HMG1BDGHzZmj82A1LH1nWeG3ipHpg/x4m6v3wEDvMBIKjRFUnfR3nBeFs3MVCr7UdAmg=="], + "@commitlint/parse": ["@commitlint/parse@21.2.3", "", { "dependencies": { "@commitlint/types": "^21.2.3", "conventional-changelog-angular": "^9.0.0", "conventional-commits-parser": "^7.0.0" } }, "sha512-Jt61QpF1xA88damCELe8I0FlDZPSpXNc8IhN7z5kZ0EcDYPjdZ+ZHPvsSXl69r2MIlLnNKeHhOn2cmVpziFE9w=="], - "@commitlint/read": ["@commitlint/read@21.2.1", "", { "dependencies": { "@commitlint/top-level": "^21.2.0", "@commitlint/types": "^21.2.0", "@conventional-changelog/git-client": "^3.0.0", "tinyexec": "^1.0.0" } }, "sha512-hUW7EJQnNTL0vPOmVMNK4CrnrNBN0nN+JJHReFkdHO5y4iyHeEmTBwuC15OCqUTjxWo7idnH1LftfpWVIaPWIA=="], + "@commitlint/read": ["@commitlint/read@21.2.3", "", { "dependencies": { "@commitlint/top-level": "^21.2.0", "@commitlint/types": "^21.2.3", "@conventional-changelog/git-client": "^3.0.0", "tinyexec": "^1.0.0" } }, "sha512-szAepuLA1vTTr7fuudEg7jYyVHudf+FH+vptU91bhxE1WnE8TKrfTAi44HVllz8XpI8Pvp4GjR/iP5Nshgh+og=="], - "@commitlint/resolve-extends": ["@commitlint/resolve-extends@21.2.2", "", { "dependencies": { "@commitlint/config-validator": "^21.2.0", "@commitlint/types": "^21.2.0", "es-toolkit": "^1.46.0", "global-directory": "^5.0.0", "resolve-from": "^5.0.0" } }, "sha512-RPkJ/IFi7sMUUVbZLqwWFtWw/zRDcfFsmrPSiTMrt5wb7AdxOr86EGQFvmGzef5QKV5IPBWWCujqVTw1RWX44A=="], + "@commitlint/resolve-extends": ["@commitlint/resolve-extends@21.2.3", "", { "dependencies": { "@commitlint/config-validator": "^21.2.3", "@commitlint/types": "^21.2.3", "es-toolkit": "^1.46.0", "global-directory": "^5.0.0", "resolve-from": "^5.0.0" } }, "sha512-bqJdmAtXgAQel+l02uIsF7rHgKgx1AGZdGBjam7uK4XW54ZAQtB6XAIUXezYprQiCcRMpBh4fHs5JodXK4mVZA=="], - "@commitlint/rules": ["@commitlint/rules@21.2.2", "", { "dependencies": { "@commitlint/ensure": "^21.2.0", "@commitlint/message": "^21.2.0", "@commitlint/to-lines": "^21.0.1", "@commitlint/types": "^21.2.0" } }, "sha512-eplQzyYkBjYB1HyyRj8hkcK11Y9DU9nuBz7uOKEd6NpE9NGDytLFCAnlRE+OoiK/5sHEJsaz2RGhuWBvYzIbNA=="], + "@commitlint/rules": ["@commitlint/rules@21.2.3", "", { "dependencies": { "@commitlint/ensure": "^21.2.3", "@commitlint/message": "^21.2.0", "@commitlint/to-lines": "^21.0.1", "@commitlint/types": "^21.2.3" } }, "sha512-5+YnI/LuTx3JDl9GjqfP2z9x96M8KKDrd+4jOel/T0L1BB3oAmBlSN8xppf63QsudiVEgR8C0gRoAMrPVrms6g=="], "@commitlint/to-lines": ["@commitlint/to-lines@21.0.1", "", {}, "sha512-bd1BFII7p1EQZre9Kaj+kKaMFP3cFCdt21K7DItVux9XP5WjLgJ0/Uy1pJJh9aPwVJ6SKg62PxqlZaHI8hQAXw=="], "@commitlint/top-level": ["@commitlint/top-level@21.2.0", "", { "dependencies": { "escalade": "^3.2.0" } }, "sha512-Y5gmQ+KxzqCrBFJfLvFEPvvwD3LDiNZoTT2yeFBm96M8qhmqSzQc5DvX3rheAaAMjyIvMXOCLS/mWfdpONsjyQ=="], - "@commitlint/types": ["@commitlint/types@21.2.0", "", { "dependencies": { "conventional-commits-parser": "^7.0.0", "picocolors": "^1.1.1" } }, "sha512-7zVFCDB2reMvJH5dmbKnOQPjZEvjdJTH8jc0U/PIPU1r3/+vf5pD1HlfitV2MWsWXrvu7u39iY1lyLUPOaN0Gw=="], + "@commitlint/types": ["@commitlint/types@21.2.3", "", { "dependencies": { "conventional-commits-parser": "^7.0.0", "picocolors": "^1.1.1" } }, "sha512-sGEA473TlCvhCGNoTBY9OXZVWdscIXdGt0dm8EpB+2MRfmt8LNh/X56P/T3nCtEB+eH5qGEa3odphSAoUxSfSQ=="], "@conventional-changelog/git-client": ["@conventional-changelog/git-client@3.1.2", "", { "dependencies": { "@simple-libs/child-process-utils": "^2.0.0", "@simple-libs/stream-utils": "^2.0.0", "semver": "^7.5.2" }, "peerDependencies": { "conventional-commits-filter": "^6.0.1", "conventional-commits-parser": "^7.1.2" }, "optionalPeers": ["conventional-commits-filter", "conventional-commits-parser"] }, "sha512-jZqwnJwf7nboIlAcw/mkOjVa6DexCcUOgT2oOQgkoi3z9vR8tGFkcMy2BFcYwjhL9sYcDDXkRQDayiDieCoW7A=="], @@ -61,7 +61,7 @@ "@simple-libs/stream-utils": ["@simple-libs/stream-utils@2.0.0", "", {}, "sha512-fCTuZK4QBa+39Oz9l4OGfJfz+GpwCp3AqO7Zch3to99xHPgstVsRFpeQ8LNd2o1Gv8raL2mCFwiaHh7bFSp5DQ=="], - "@types/bun": ["@types/bun@1.4.0", "", { "dependencies": { "bun-types": "1.4.0" } }, "sha512-K+lZULY23vRgK/CfTjFIV+tyifaNdSMlPh9j+6mQ/cLfpOznLyAuzgV/JQysyECpkBQLVMSyvjlr2fBUSA9wFQ=="], + "@types/bun": ["@types/bun@1.4.2", "", { "dependencies": { "bun-types": "1.4.2" } }, "sha512-GimotNn7+ZV0uVArItBbriZsR1oNf0+WTzPkdcFrzShI7k2norL0uzEaJT8T33dWr7O/c9ZDuAFQrctKCi72oQ=="], "@types/node": ["@types/node@26.3.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-L3fgrnchriRC2ExBflb8j4uZZURHZfQsmQeyVzhjcHW4kkwVyo8/0h1B2MVzMTrYUJYu6G7EWs14hW/L9putqw=="], @@ -115,21 +115,21 @@ "argue-cli": ["argue-cli@3.1.0", "", {}, "sha512-DhBpBfXL4SS2uC0N922MMajKR3CdrTG0u2or1PNYgXMsrSzViJrbtvT0nCLlLGUI0plam/ZZCs7aAauHtW9thw=="], - "bun-types": ["bun-types@1.4.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-iIKw23BspnQQYd3prITOBxeUsxBHnwzX6YJfGMuNOZzeNcMmVqzIIVGRm1l69ogaPQmb4wB6BN8mA5bE9YuC5Q=="], + "bun-types": ["bun-types@1.4.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w=="], "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], - "carryctx": ["carryctx@0.11.2", "", { "optionalDependencies": { "carryctx-cli-darwin-arm64": "0.11.2", "carryctx-cli-darwin-x64": "0.11.2", "carryctx-cli-linux-arm64-gnu": "0.11.2", "carryctx-cli-linux-x64-gnu": "0.11.2", "carryctx-cli-windows-x64": "0.11.2" }, "bin": { "carryctx": "bin/carryctx.js" } }, "sha512-y3W7daVypKgMAFnfAnCPpK5rVBHtBkj/DFkLLXzLfrm5YCtRWP9j1+QsqBc7FulnpnfRZXSX5g6ryLdnPNpakw=="], + "carryctx": ["carryctx@0.11.6", "", { "optionalDependencies": { "carryctx-cli-darwin-arm64": "0.11.6", "carryctx-cli-darwin-x64": "0.11.6", "carryctx-cli-linux-arm64-gnu": "0.11.6", "carryctx-cli-linux-x64-gnu": "0.11.6", "carryctx-cli-windows-x64": "0.11.6" }, "bin": { "carryctx": "bin/carryctx.js" } }, "sha512-dugNgLJZYIdkHKDQUzcrrdOX9zjcBAg070CsHLMXw8uBIJ07dl4hfz7bGGYt8WnCJCp1OPAlXqVUUzOBweUNRg=="], - "carryctx-cli-darwin-arm64": ["carryctx-cli-darwin-arm64@0.11.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-INAh35F7TU8DObdW5zIpab1KiW64vm/AxkJRrYeV5cqNP4AIccG/W7oH+m1Z8Fpm7LbpUtpgwkREsuzJmctyBA=="], + "carryctx-cli-darwin-arm64": ["carryctx-cli-darwin-arm64@0.11.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-JSkDoYne98SKAcrTIoZqodXAzmn8getqG25xx9yf5m6kGkXkayrOfvDpgfhxHsLknC+tAX7IYzLcNx5hjRNXqA=="], - "carryctx-cli-darwin-x64": ["carryctx-cli-darwin-x64@0.11.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-hSt5X4YEsrnZ9CKL7fTNiYb5UFlUDHPwg11ES5v/JuVvTHpDDPzmvMMiMKg/pQKoHLDrieWUi3LwudeC3LKRZA=="], + "carryctx-cli-darwin-x64": ["carryctx-cli-darwin-x64@0.11.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-A6Xogh4Q5T/Ns/uQqKxc3KUqzlSGD2xAVGlfA6M7xicRaxH52PJZjGvxAZpjnI3LIlKXiqwoiJa54TXvuL4pjQ=="], - "carryctx-cli-linux-arm64-gnu": ["carryctx-cli-linux-arm64-gnu@0.11.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-jUnsNMfFJz5zO1ZqOFryPR1TN3Vx3Iw0p1bTqCvq0yoe8sInZAAnyzqDpTTBRToYIISSsZI9e+4TonSpOt3fMQ=="], + "carryctx-cli-linux-arm64-gnu": ["carryctx-cli-linux-arm64-gnu@0.11.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-IvOGo2miAuJQUo06CjsL8VHn8K1IrGIpjcCh2dLfzDTVarxm1O91URYDJzWIpp8M6EDcGip3vx1RG3mG4RDq6A=="], - "carryctx-cli-linux-x64-gnu": ["carryctx-cli-linux-x64-gnu@0.11.2", "", { "os": "linux", "cpu": "x64" }, "sha512-xRsVxeTISRlRri/D//k0ZjAEmvEZA2ICjKMBqor3/jzpgvW+fqPcum/Pot3tJ0t3U2epXBH7uavH9LyojZwjMQ=="], + "carryctx-cli-linux-x64-gnu": ["carryctx-cli-linux-x64-gnu@0.11.6", "", { "os": "linux", "cpu": "x64" }, "sha512-QZ4gOXxDdJGkssIS7N8L1H2jarvvk+HdOuMR/79IuJccV8Lt4YPQhCcouwSlh2l+GMkqICg/LjudVxCj3Q1FtQ=="], - "carryctx-cli-windows-x64": ["carryctx-cli-windows-x64@0.11.2", "", { "os": "win32", "cpu": "x64" }, "sha512-OXijwEEnCi/X9WLom+S7YGcmrCDoTYrGwQplvwewULOJLVZjPHmVBXgbAlPvJtOf2E8mkXRTTyhyqdlBnpfutA=="], + "carryctx-cli-windows-x64": ["carryctx-cli-windows-x64@0.11.6", "", { "os": "win32", "cpu": "x64" }, "sha512-mRCr33aIM4gFOaLGX4fE8f7vEnaOEvJ2uMOpL+M+NtYu3t5sh/bhpiTSJ2lzc7bnpNQTYR8H06KjStljUJtoCQ=="], "cliui": ["cliui@9.0.1", "", { "dependencies": { "string-width": "^7.2.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w=="], diff --git a/crates/devtools-client/Cargo.toml b/crates/devtools-client/Cargo.toml index 29d52d7..5571b40 100644 --- a/crates/devtools-client/Cargo.toml +++ b/crates/devtools-client/Cargo.toml @@ -3,7 +3,7 @@ name = "bitty-devtools-client" version = "0.0.1" edition = "2024" rust-version = "1.85" -description = "Human-facing diagnostics client over Panel Runtime and compat matrix (phase 2, live IPC with peer-creds, advanced tracing and control)" +description = "Human-facing diagnostics client over Panel Runtime and compat matrix (phase 2, Linux-only endpoint-attested live inspection, advanced tracing and control)" license = "MIT OR Apache-2.0" repository = "https://github.com/bitty-terminal/bitty-devtools" publish = false diff --git a/crates/devtools-client/src/auth.rs b/crates/devtools-client/src/auth.rs index fb6f993..459da5b 100644 --- a/crates/devtools-client/src/auth.rs +++ b/crates/devtools-client/src/auth.rs @@ -1,11 +1,13 @@ #![forbid(unsafe_code)] -//! Peer-credential authentication for IPC (phase 2, live runtime). +//! Headless authentication and endpoint-policy helpers for DevTools. //! -//! Mirrors `bitty-ipc` auth contract: Unix socket 0700/0600, Windows named -//! pipe ACL, peer UID equality via `SO_PEERCRED` paradigm. Verification is -//! headless and bounded, requiring no `unsafe`. The platform seam that -//! extracts `PeerCredentials` via `getsockopt(SO_PEERCRED)` lives outside -//! this crate; here we only verify already-extracted triples. +//! Mirrors `bitty-ipc` policy values: Unix socket 0700/0600, Windows named +//! pipe ACL, and peer UID equality. Verification is bounded and requires no +//! `unsafe`. This module does not extract `SO_PEERCRED` or +//! `GetNamedPipeClientProcessId` values and does not establish a live peer +//! identity; it verifies caller-supplied triples in the headless fixture. +//! The implemented live adapter separately attests endpoint ownership and +//! mode and remains inspect-only. pub const DIR_MODE: u32 = 0o700; pub const SOCKET_MODE: u32 = 0o600; @@ -149,7 +151,8 @@ pub fn short_instance_hash(instance: &str) -> String { /// Resolve the Unix socket path with `bitty-ipc` precedence and a portable /// `AF_UNIX` bound. /// -/// Precedence: non-empty `BITTY_SOCKET` (advisory) wins verbatim; otherwise +/// Precedence: non-empty `BITTY_SOCKET` (the explicit dial target) wins +/// verbatim; otherwise /// `/bitty/.sock` where `base` is `XDG_RUNTIME_DIR` or /// `/run/user/`, and `instance` is `BITTY_INSTANCE_ID` or `default`. /// diff --git a/crates/devtools-client/src/compat.rs b/crates/devtools-client/src/compat.rs index 44bbd14..8fbd4d2 100644 --- a/crates/devtools-client/src/compat.rs +++ b/crates/devtools-client/src/compat.rs @@ -118,8 +118,29 @@ pub fn check_matrix_invariants() -> Result<(), String> { if !seen.insert(e.surface) { return Err(format!("duplicate {}", e.surface)); } - if e.corpus_rel.is_empty() { - return Err(format!("empty corpus for {}", e.surface)); + if e.corpus_rel.is_empty() || e.corpus_rel.len() > 256 { + return Err(format!("invalid corpus for {}", e.surface)); + } + if e.corpus_rel.starts_with('/') + || e.corpus_rel.contains("..") + || e.corpus_rel.contains('\\') + || e.corpus_rel.bytes().any(|byte| byte < 0x20 || byte == 0x7f) + { + return Err(format!("unsafe corpus path for {}", e.surface)); + } + if e.category.is_empty() + || e.category.len() > 64 + || e.category.bytes().any(|byte| byte < 0x20 || byte == 0x7f) + { + return Err(format!("invalid category for {}", e.surface)); + } + if e.description.is_empty() + || e.description.len() > 256 + || e.description + .bytes() + .any(|byte| byte < 0x20 || byte == 0x7f) + { + return Err(format!("invalid description for {}", e.surface)); } } if MATRIX.first().unwrap().surface != "shell" { @@ -134,6 +155,10 @@ pub fn check_matrix_invariants() -> Result<(), String> { Ok(()) } +pub fn validate_matrix_shape() -> Result<(), String> { + check_matrix_invariants() +} + /// Minimal JSON string escape for matrix fields (mirrors the upstream /// compat-lab helper; current descriptions are plain ASCII but the helper /// keeps the generator correct if text ever gains quotes or controls). diff --git a/crates/devtools-client/src/control.rs b/crates/devtools-client/src/control.rs index 8db93d8..701e19a 100644 --- a/crates/devtools-client/src/control.rs +++ b/crates/devtools-client/src/control.rs @@ -2,8 +2,8 @@ //! Control surface (debug.control, audited, no bypass) — phase 2 advanced. //! //! Phase 2 adds: generation exhaustion guard, transactional audit log, -//! pause/resume with reactivation, per-generation ownership, peer-creds -//! re-verification hooks, bounded audit retrieval. +//! pause/resume with reactivation, per-generation ownership, fixture peer-value +//! re-verification hooks, and bounded audit retrieval. use crate::bounds::GENERATION_RESERVE; @@ -276,6 +276,10 @@ impl ControlClient { self.audit_log.clear(); Ok(()) } + + pub fn clear_session_state(&mut self) { + self.audit_log.clear(); + } } #[cfg(test)] @@ -329,6 +333,17 @@ mod tests { assert_eq!(logs[super::MAX_AUDIT_LOG - 1].at_ms, 265); } + #[test] + fn clear_session_state_clears_audit() { + let mut client = ControlClient::new(); + client + .suspend_handler_audited(true, "h", "c", "caller", 1, 0) + .unwrap(); + assert_eq!(client.audit_count(), 1); + client.clear_session_state(); + assert_eq!(client.audit_count(), 0); + } + #[test] fn pause_and_resume() { assert!(pause_handler(true, "h", "reason", "caller").is_ok()); diff --git a/crates/devtools-client/src/ipc_socket.rs b/crates/devtools-client/src/ipc_socket.rs index 91f5322..ad5e462 100644 --- a/crates/devtools-client/src/ipc_socket.rs +++ b/crates/devtools-client/src/ipc_socket.rs @@ -1,4 +1,4 @@ -//! Live Unix IPC socket seam for DevTools (CTX-0036, H-DEV-06). +//! Linux-only live Unix IPC socket seam for DevTools (CTX-0036, H-DEV-06). //! //! `transport::IpcTransport` is a headless stub: `connect()` verifies //! caller-supplied mode values and flips a flag without ever dialing the OS @@ -17,26 +17,44 @@ //! mode `0600` and owned by the runtime UID. Anything else refuses to dial. //! - Responses are untrusted observation data: bounded at 256 KiB, framed //! exactly once, and returned as raw bytes for the caller to decode. -//! - Unix only. The Windows named-pipe dial stays with CTX-0043 and is not -//! duplicated here; on non-Unix targets every entry point fails closed. +//! - Linux endpoint attestation is the only implemented live target. Windows +//! and macOS entry points fail closed; no alternate adapter is implied here. //! - No `unsafe`: only blocking std I/O with read/write timeouts. -use crate::auth::{AuthError, DIR_MODE, SOCKET_MODE, resolve_socket_path}; -use crate::transport::{MAX_FRAME_BYTES, TransportError, decode_frame, encode_frame}; +use crate::auth::{AuthError, MAX_SOCKET_PATH_BYTES, resolve_socket_path}; +#[cfg(unix)] +use crate::auth::{DIR_MODE, SOCKET_MODE}; +use crate::transport::TransportError; +#[cfg(target_os = "linux")] +use crate::transport::{MAX_FRAME_BYTES, decode_frame, encode_frame}; /// Per-dial and per-response timeout (matches the TS seam). pub const LIVE_SOCKET_TIMEOUT_SECS: u64 = 5; +pub const LIVE_SOCKET_SUPPORTED_OS: &str = "linux"; + +#[must_use] +pub fn live_socket_supported() -> bool { + cfg!(target_os = "linux") +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct LiveSocketIdentity { + pub runtime_uid: u32, + pub authenticated: bool, +} + /// Resolved dial target: the attested path plus the owning runtime UID. #[derive(Debug, Clone, PartialEq, Eq)] pub struct LiveSocketEndpoint { /// Attested socket path to dial. pub socket_path: String, - /// Owning runtime UID (endpoint owner + dialing peer). + /// Expected endpoint owner UID; this is not a connected peer identity. pub runtime_uid: u32, } -/// Dial configuration: explicit path wins, otherwise advisory discovery. +/// Dial configuration: explicit path wins, otherwise environment-based +/// endpoint selection. The selector is not a credential. #[derive(Debug, Clone, PartialEq, Eq, Default)] pub struct LiveSocketConfig { /// Explicit socket path (skips discovery). @@ -45,14 +63,14 @@ pub struct LiveSocketConfig { pub runtime_uid: u32, /// `XDG_RUNTIME_DIR` override for discovery. pub xdg_runtime_dir: Option, - /// `BITTY_SOCKET` advisory override for discovery. + /// `BITTY_SOCKET` path selector for discovery. pub bitty_socket: Option, /// Instance id for discovery (`default` when absent). pub instance_id: Option, } -/// Resolve the dial target: explicit path wins, otherwise the advisory -/// `BITTY_SOCKET` / `XDG_RUNTIME_DIR` / instance discovery from `auth`. +/// Resolve the dial target: explicit path wins, otherwise +/// `BITTY_SOCKET` / `XDG_RUNTIME_DIR` / instance selection from `auth`. pub fn resolve_live_socket_endpoint( config: &LiveSocketConfig, ) -> Result { @@ -66,12 +84,27 @@ pub fn resolve_live_socket_endpoint( ) .map_err(|e| TransportError::Unauthenticated(e.to_string()))?, }; + if socket_path.is_empty() + || !socket_path.starts_with('/') + || socket_path.as_bytes().contains(&0) + || socket_path.contains('\\') + || socket_path + .split('/') + .skip(1) + .any(|part| part.is_empty() || part == "." || part == "..") + || socket_path.len() > MAX_SOCKET_PATH_BYTES + { + return Err(TransportError::Unauthenticated( + "live socket path must be an absolute bounded AF_UNIX path".to_string(), + )); + } Ok(LiveSocketEndpoint { socket_path, runtime_uid: config.runtime_uid, }) } +#[cfg(unix)] fn parent_dir_of(path: &str) -> &str { match path.rfind('/') { Some(0) | None => "/", @@ -79,6 +112,19 @@ fn parent_dir_of(path: &str) -> &str { } } +#[cfg(unix)] +fn path_ancestors(path: &str) -> Vec { + let mut current = String::new(); + let mut ancestors = Vec::new(); + for part in path.split('/').filter(|part| !part.is_empty()) { + current.push('/'); + current.push_str(part); + ancestors.push(current.clone()); + } + ancestors +} + +#[cfg(unix)] fn leaf_of(path: &str) -> &str { match path.rfind('/') { Some(i) => &path[i + 1..], @@ -86,7 +132,14 @@ fn leaf_of(path: &str) -> &str { } } -/// Attest the endpoint before dialing (Unix only). +fn unsupported_platform_error() -> TransportError { + TransportError::Unauthenticated(format!( + "live Unix socket transport is unsupported on {}; Linux endpoint attestation is the only implemented live adapter", + std::env::consts::OS + )) +} + +/// Attest the endpoint before dialing (Linux only). /// /// The parent directory must be `0700` and runtime-owned, the socket must /// exist, be a real socket (not a symlink), be mode `0600`, and be @@ -94,6 +147,9 @@ fn leaf_of(path: &str) -> &str { /// `devtools::prepare_socket_dir` / `attest_bound_socket`. #[cfg(unix)] pub fn attest_live_socket_endpoint(endpoint: &LiveSocketEndpoint) -> Result<(), TransportError> { + if !live_socket_supported() { + return Err(unsupported_platform_error()); + } use std::os::unix::fs::FileTypeExt; let fail = |message: String| TransportError::Unauthenticated(message); @@ -101,15 +157,22 @@ pub fn attest_live_socket_endpoint(endpoint: &LiveSocketEndpoint) -> Result<(), return Err(fail("socket path contains NUL".to_string())); } let parent = parent_dir_of(&endpoint.socket_path); + for component in path_ancestors(parent) { + let metadata = std::fs::symlink_metadata(&component).map_err(|_| { + fail(format!( + "socket path component '{component}' does not exist" + )) + })?; + if metadata.file_type().is_symlink() { + return Err(fail(format!( + "socket path component '{component}' is a symlink (refusing to dial)" + ))); + } + } let dir_meta = std::fs::symlink_metadata(parent) .map_err(|_| fail(format!("socket directory '{parent}' does not exist")))?; let sock_meta = std::fs::symlink_metadata(&endpoint.socket_path) .map_err(|_| fail(format!("socket '{}' does not exist", endpoint.socket_path)))?; - if dir_meta.file_type().is_symlink() { - return Err(fail(format!( - "socket directory '{parent}' is a symlink (refusing to dial)" - ))); - } if sock_meta.file_type().is_symlink() { return Err(fail(format!( "socket '{}' is a symlink (refusing to dial)", @@ -153,26 +216,25 @@ pub fn attest_live_socket_endpoint(endpoint: &LiveSocketEndpoint) -> Result<(), Ok(()) } -/// Non-Unix stub: there is no `AF_UNIX` dial here (named pipe stays with -/// CTX-0043), so attestation always fails closed. +/// Non-Linux stub: there is no verified live adapter, so attestation always +/// fails closed. #[cfg(not(unix))] pub fn attest_live_socket_endpoint(endpoint: &LiveSocketEndpoint) -> Result<(), TransportError> { - Err(TransportError::Unauthenticated(format!( - "live Unix socket dial is unsupported on this platform (refusing '{}')", - endpoint.socket_path - ))) + let _ = endpoint; + Err(unsupported_platform_error()) } -/// One live `AF_UNIX` connection (Unix only): owns the stream, frames one +/// One live `AF_UNIX` connection (Linux only): owns the stream, frames one /// request/response round trip, and closes on drop. -#[cfg(unix)] +#[cfg(target_os = "linux")] #[derive(Debug)] pub struct LiveSocketConnection { stream: std::os::unix::net::UnixStream, socket_path: String, + identity: LiveSocketIdentity, } -#[cfg(unix)] +#[cfg(target_os = "linux")] impl LiveSocketConnection { /// The attested path this connection dialed. #[must_use] @@ -180,6 +242,11 @@ impl LiveSocketConnection { &self.socket_path } + #[must_use] + pub fn identity(&self) -> LiveSocketIdentity { + self.identity + } + /// Write one framed request and read the next framed response payload /// (raw bytes, still to be JSON-decoded by the caller). Bounded at one /// 256 KiB frame each way, matching the `bitty-ipc` framing. Times out @@ -235,15 +302,18 @@ impl LiveSocketConnection { } } -/// Dial the live socket (Unix only). Attests the endpoint first (fail +/// Dial the live socket (Linux only). Attests the endpoint first (fail /// closed), then opens one blocking `AF_UNIX` connection with timeouts. /// The caller owns the connection. -#[cfg(unix)] +#[cfg(target_os = "linux")] pub fn connect_live_socket( config: &LiveSocketConfig, ) -> Result { use std::time::Duration; + if !live_socket_supported() { + return Err(unsupported_platform_error()); + } let endpoint = resolve_live_socket_endpoint(config)?; attest_live_socket_endpoint(&endpoint)?; let stream = std::os::unix::net::UnixStream::connect(&endpoint.socket_path) @@ -256,14 +326,24 @@ pub fn connect_live_socket( .map_err(|_| TransportError::TransportClosed)?; Ok(LiveSocketConnection { stream, - socket_path: endpoint.socket_path, + socket_path: endpoint.socket_path.clone(), + identity: LiveSocketIdentity { + runtime_uid: endpoint.runtime_uid, + authenticated: false, + }, }) } -/// Non-Unix stub: no `AF_UNIX` dial here, fail closed. -#[cfg(not(unix))] -pub fn connect_live_socket(_config: &LiveSocketConfig) -> Result<(), TransportError> { - Err(TransportError::TransportClosed) +/// Non-Linux connection type retained for cross-target API compatibility. +#[cfg(not(target_os = "linux"))] +#[derive(Debug)] +pub struct LiveSocketConnection; + +#[cfg(not(target_os = "linux"))] +pub fn connect_live_socket( + _config: &LiveSocketConfig, +) -> Result { + Err(unsupported_platform_error()) } impl From for TransportError { @@ -272,7 +352,7 @@ impl From for TransportError { } } -#[cfg(all(test, unix))] +#[cfg(all(test, target_os = "linux"))] mod tests { use super::*; use std::io::{Read, Write}; @@ -333,6 +413,8 @@ mod tests { }; let mut conn = connect_live_socket(&config).unwrap(); assert_eq!(conn.socket_path(), endpoint.socket_path); + assert_eq!(conn.identity().runtime_uid, uid); + assert!(!conn.identity().authenticated); let request = br#"{"id":7,"method":"bitty.debug/listPlugins","params":{},"version":"1.0"}"#; let raw = conn.request_response(request, 0).unwrap(); let decoded: serde_like::JsonResponse = serde_like::parse(&raw); @@ -341,6 +423,16 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + #[test] + fn live_path_rejects_dot_segments() { + let config = LiveSocketConfig { + socket_path: Some("/run/user/1000/bitty/../other.sock".to_string()), + runtime_uid: 1000, + ..LiveSocketConfig::default() + }; + assert!(resolve_live_socket_endpoint(&config).is_err()); + } + #[test] fn wrong_mode_fails_closed_before_dial() { let dir = test_dir("wrong-mode"); diff --git a/crates/devtools-client/src/lib.rs b/crates/devtools-client/src/lib.rs index 3a74e7f..1627736 100644 --- a/crates/devtools-client/src/lib.rs +++ b/crates/devtools-client/src/lib.rs @@ -3,10 +3,10 @@ //! //! Phase 2 extends phase 1 with advanced tracing (filtering, retention/GC, //! structured events, coalescing), control surfaces (audit log, generation -//! guards, pause/resume), and real IPC socket/pipe peer-creds integration -//! against the live Bitty runtime. This crate **reuses** the Panel Runtime -//! envelope and does not own the core debug protocol (devtools-rfc OQ-019, -//! performance budgets OQ-001). All operations are bounded, fail-closed, +//! guards, pause/resume), a headless transport fixture, and a Linux-only +//! endpoint-attested live socket inspection path. This crate **reuses** the +//! Panel Runtime envelope and does not own the core debug protocol (devtools-rfc +//! OQ-019, performance budgets OQ-001). All operations are bounded, fail-closed, //! and scope-checked. No TCP listener, no ambient credential. //! //! - Connection alone grants no authority; `debug.inspect` is default. @@ -23,7 +23,8 @@ pub mod inspection; pub mod ipc_socket; pub mod protocol; pub mod redaction; -pub mod tracing; +#[allow(dead_code)] +mod tracing; pub mod transport; pub use compat::{MATRIX, MatrixEntry, REFERENCE_TERMS}; diff --git a/crates/devtools-client/src/tracing.rs b/crates/devtools-client/src/tracing.rs index 74e3fed..5539c3b 100644 --- a/crates/devtools-client/src/tracing.rs +++ b/crates/devtools-client/src/tracing.rs @@ -3,8 +3,8 @@ //! //! Phase 2 adds filtering, structured events, retention/GC, coalescing control, //! deterministic wall-clock, and chunked export with preview==export. All -//! bounds from devtools-rfc are preserved, peer-creds re-checked per privileged -//! action via transport seam. +//! bounds from devtools-rfc are preserved. The headless transport fixture +//! re-checks caller-supplied peer values; the live Linux path is inspect-only. use crate::bounds::{ BUS_BATCH_MAX_BYTES, BUS_BATCH_MAX_EVENTS, BUS_EVENT_MAX_BYTES, CHUNK_BYTES, MAX_TRACE_BYTES, @@ -107,17 +107,17 @@ impl TraceOptions { return Err(TracingError::Invalid("filter.kinds >32".to_string())); } for k in kinds { - if k.len() > 64 { + if k.is_empty() || k.len() > 64 { return Err(TracingError::Invalid("filter kind 1..64".to_string())); } } } if let Some(ref owners) = f.owners { - if owners.len() > 32 { - return Err(TracingError::Invalid("filter.owners >32".to_string())); + if owners.is_empty() || owners.len() > 32 { + return Err(TracingError::Invalid("filter.owners 1..32".to_string())); } for o in owners { - if o.len() > 64 { + if o.is_empty() || o.len() > 64 { return Err(TracingError::Invalid("filter owner 1..64".to_string())); } } @@ -187,16 +187,63 @@ pub struct StructuredTraceEvent { pub wall_clock_ms: u64, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BatchRecord { + pub owner: String, + pub kind: String, + pub payload: String, +} + #[derive(Debug, Clone)] pub struct ObservabilityBatch { pub sequence: u64, pub drop_count: u64, - pub records: Vec<(String, String)>, + pub records: Vec, pub wall_clock_ms: u64, pub coalesced_count: u64, pub policy: DropPolicy, } +fn json_quote(value: &str) -> String { + let mut output = String::with_capacity(value.len() + 2); + output.push('"'); + for character in value.chars() { + match character { + '"' => output.push_str("\\\""), + '\\' => output.push_str("\\\\"), + '\u{0008}' => output.push_str("\\b"), + '\u{000c}' => output.push_str("\\f"), + '\n' => output.push_str("\\n"), + '\r' => output.push_str("\\r"), + '\t' => output.push_str("\\t"), + c if c <= '\u{001f}' => { + output.push_str(&format!("\\u{:04x}", c as u32)); + } + c => output.push(c), + } + } + output.push('"'); + output +} + +fn batch_json_bytes(records: &[BatchRecord]) -> usize { + let mut json = String::from("["); + for (index, record) in records.iter().enumerate() { + if index > 0 { + json.push(','); + } + json.push_str("{\"owner\":"); + json.push_str(&json_quote(&record.owner)); + json.push_str(",\"kind\":"); + json.push_str(&json_quote(&record.kind)); + json.push_str(",\"payload\":"); + json.push_str(&json_quote(&record.payload)); + json.push('}'); + } + json.push(']'); + json.len() +} + pub fn stream_events( types: &[String], max_events: usize, @@ -224,18 +271,29 @@ pub fn stream_events( return Err(TracingError::Invalid("event type empty".to_string())); } } - let records = types - .iter() - .take(max_events) - .map(|t| (t.clone(), "{\"count\":1}".to_string())) - .collect::>(); - let bytes = format!("{records:?}").len(); - if bytes > BUS_EVENT_MAX_BYTES * 4 { - return Err(TracingError::Invalid("batch too large".to_string())); + if types.len() > 256 { + return Err(TracingError::Invalid("event types >256".to_string())); } + let mut records = Vec::new(); + let mut drop_count = 0u64; + for kind in types.iter().take(max_events) { + let candidate = BatchRecord { + owner: "panel-1".to_string(), + kind: kind.clone(), + payload: "{\"count\":1}".to_string(), + }; + let mut next = records.clone(); + next.push(candidate.clone()); + if batch_json_bytes(&next) > max_bytes { + drop_count += 1; + continue; + } + records = next; + } + drop_count += types.len().saturating_sub(max_events) as u64; Ok(ObservabilityBatch { sequence: 42, - drop_count: 0, + drop_count, records, wall_clock_ms: 0, coalesced_count: 0, @@ -263,13 +321,12 @@ pub fn stream_filtered_events( if max_bytes == 0 || max_bytes > BUS_BATCH_MAX_BYTES { return Err(TracingError::Invalid("maxBytes 1..8192".to_string())); } - // Default kinds when filter missing let kinds_vec: Vec = if let Some(ref k) = filter.kinds { if k.len() > 32 { return Err(TracingError::Invalid("filter.kinds >32".to_string())); } - for kk in k { - if kk.len() > 64 { + for kind in k { + if kind.is_empty() || kind.len() > 64 { return Err(TracingError::Invalid("filter kind 1..64".to_string())); } } @@ -277,39 +334,57 @@ pub fn stream_filtered_events( } else { vec!["bitty.panel:mounted".to_string()] }; + let owner = filter + .owners + .as_ref() + .and_then(|owners| owners.first()) + .cloned() + .unwrap_or_else(|| "panel-1".to_string()); + if owner.is_empty() || owner.len() > 64 { + return Err(TracingError::Invalid("filter owner 1..64".to_string())); + } + if let Some(ref owners) = filter.owners { + if owners.is_empty() || owners.len() > 32 { + return Err(TracingError::Invalid("filter.owners 1..32".to_string())); + } + for value in owners { + if value.is_empty() || value.len() > 64 { + return Err(TracingError::Invalid("filter owner 1..64".to_string())); + } + } + } let mut seen = BTreeSet::new(); let mut coalesced = 0u64; let mut records = Vec::new(); - for k in kinds_vec { - if records.len() >= max_events { - break; - } - let owner = filter - .owners - .as_ref() - .and_then(|v| v.first().cloned()) - .unwrap_or_else(|| "panel-1".to_string()); - let key = format!("{owner}:{k}"); + let mut drop_count = 0u64; + for kind in kinds_vec { + let key = format!("{owner}:{kind}"); if seen.contains(&key) { coalesced += 1; continue; } seen.insert(key); - records.push((owner, format!("{k}:{}", "{\"count\":1}"))); - // Actually records is (String,String) where second is payload; keep kind in first? Use owner/kind split elsewhere. - // For compatibility, store as (kind, payload) but we need owner. We'll encode owner in first part. - } - // Rebuild to (owner,kind) style: the test helper expects (String,String) where first is type string; we keep simple. - let recs: Vec<(String, String)> = records; - let bytes = format!("{recs:?}").len(); - if bytes > BUS_BATCH_MAX_BYTES { - return Err(TracingError::Invalid("batch too large".to_string())); + if records.len() >= max_events { + drop_count += 1; + continue; + } + let candidate = BatchRecord { + owner: owner.clone(), + kind, + payload: "{\"count\":1}".to_string(), + }; + let mut next = records.clone(); + next.push(candidate.clone()); + if batch_json_bytes(&next) > max_bytes { + drop_count += 1; + continue; + } + records = next; } - let _ = filter.kinds.as_deref().unwrap_or(&[]); Ok(ObservabilityBatch { sequence: now_ms, - drop_count: 0, - records: recs, + drop_count, + records, wall_clock_ms: now_ms, coalesced_count: coalesced, policy: DropPolicy::DropOldest, @@ -368,8 +443,8 @@ impl TracingClient { } opts.validate()?; let retention = opts.effective_retention(); - let id = format!("trace-{}", self.next_id); self.next_id += 1; + let id = format!("trace-{}", self.next_id); self.traces.insert( id.clone(), TraceState { @@ -399,7 +474,13 @@ impl TracingClient { Ok((s.bytes, s.drops)) } - pub fn append_to_trace(&mut self, trace_id: &str, data: &str) -> Result<(), TracingError> { + pub fn append_to_trace( + &mut self, + scope_ok: bool, + trace_id: &str, + data: &str, + ) -> Result<(), TracingError> { + self.require_trace(scope_ok)?; let rec = self .traces .get_mut(trace_id) @@ -432,9 +513,11 @@ impl TracingClient { pub fn append_structured( &mut self, + scope_ok: bool, trace_id: &str, event: StructuredTraceEvent, ) -> Result<(), TracingError> { + self.require_trace(scope_ok)?; let rec = self .traces .get_mut(trace_id) @@ -445,8 +528,14 @@ impl TracingClient { BUS_EVENT_MAX_BYTES ))); } - if event.kind.len() > 64 { - return Err(TracingError::Invalid("kind >64".to_string())); + if event.owner.is_empty() || event.owner.len() > 64 { + return Err(TracingError::Invalid("owner 1..64".to_string())); + } + if event.kind.is_empty() || event.kind.len() > 64 { + return Err(TracingError::Invalid("kind 1..64".to_string())); + } + if event.generation == 0 { + return Err(TracingError::Invalid("generation >=1".to_string())); } if let Some(ref filter) = rec.options.filter { if let Some(ref kinds) = filter.kinds { @@ -514,14 +603,20 @@ impl TracingClient { Ok(expired) } - #[must_use] - pub fn trace_count(&self) -> usize { - self.traces.len() + pub fn trace_count(&self, scope_ok: bool) -> Result { + self.require_trace(scope_ok)?; + Ok(self.traces.len()) } - #[must_use] - pub fn list_traces(&self) -> Vec { - self.traces.keys().cloned().collect() + pub fn list_traces(&self, scope_ok: bool) -> Result, TracingError> { + self.require_trace(scope_ok)?; + Ok(self.traces.keys().cloned().collect()) + } + + pub fn clear_session_state(&mut self) { + self.traces.clear(); + self.next_id = 0; + self.global_seq = 0; } } @@ -567,12 +662,56 @@ mod tests { assert!(opts2.validate().is_err()); } + #[test] + fn low_level_accessors_require_trace_scope() { + let mut client = TracingClient::new(); + let id = client + .start_trace(TraceOptions::default(), true, 0) + .unwrap(); + assert!(client.append_to_trace(false, &id, "x").is_err()); + assert!(client.list_traces(false).is_err()); + client.stop_trace(&id, true).unwrap(); + } + #[test] fn batch_limits() { assert!(stream_events(&["a".to_string()], 33, 1024, true, false).is_err()); assert!(stream_events(&["a".to_string()], 1, 9000, true, false).is_err()); } + #[test] + fn batch_uses_requested_bytes_and_structured_records() { + let record = BatchRecord { + owner: "panel-1".to_string(), + kind: "one".to_string(), + payload: "{\"count\":1}".to_string(), + }; + let max_bytes = batch_json_bytes(std::slice::from_ref(&record)); + let batch = stream_events( + &["one".to_string(), "two".to_string()], + 2, + max_bytes, + true, + false, + ) + .unwrap(); + assert_eq!(batch.records.len(), 1); + assert_eq!(batch.records[0], record); + assert_eq!(batch.drop_count, 1); + } + + #[test] + fn clear_session_state_removes_old_trace_ids() { + let mut client = TracingClient::new(); + let id = client + .start_trace(TraceOptions::default(), true, 0) + .unwrap(); + client.clear_session_state(); + assert_eq!(client.trace_count(true).unwrap(), 0); + assert!(client.list_traces(true).unwrap().is_empty()); + assert!(client.append_to_trace(true, &id, "late").is_err()); + } + #[test] fn retention_and_gc() { let mut c = TracingClient::new(); @@ -586,10 +725,10 @@ mod tests { 0, ) .unwrap(); - assert_eq!(c.trace_count(), 1); + assert_eq!(c.trace_count(true).unwrap(), 1); let expired = c.gc_expired(2000, true).unwrap(); assert_eq!(expired, vec![id]); - assert_eq!(c.trace_count(), 0); + assert_eq!(c.trace_count(true).unwrap(), 0); } #[test] @@ -626,7 +765,7 @@ mod tests { } else { retained.push_str(record); } - c.append_to_trace(&id, record).unwrap(); + c.append_to_trace(true, &id, record).unwrap(); let state = c.traces.get_mut(&id).unwrap(); assert_eq!(state.chunks.concat(), retained); assert_eq!(state.bytes, retained.len()); @@ -681,7 +820,7 @@ mod tests { ) .unwrap(); let before = format!("{:?}", c.traces[&id]); - c.append_structured(&id, event.clone()).unwrap(); + c.append_structured(true, &id, event.clone()).unwrap(); let state = c.traces.get_mut(&id).unwrap(); if limit < json.len() { assert_eq!(state.drops, 1); @@ -693,7 +832,7 @@ mod tests { assert_eq!(state.events.len(), 1); assert_eq!(state.events[0].payload, event.payload); let accepted = format!("{state:?}"); - c.append_structured(&id, event.clone()).unwrap(); + c.append_structured(true, &id, event.clone()).unwrap(); let state = c.traces.get_mut(&id).unwrap(); assert_eq!(state.drops, 1); state.drops = 0; @@ -715,7 +854,7 @@ mod tests { generation: 1, wall_clock_ms: 10, }; - c.append_structured(&id, event).unwrap(); + c.append_structured(true, &id, event).unwrap(); let state = &c.traces[&id]; assert_eq!(state.events[0].payload, "[REDACTED]"); assert!(state.chunks.concat().contains("\"payload\":\"[REDACTED]\"")); @@ -730,7 +869,7 @@ mod tests { 0, ) .unwrap(); - c.append_to_trace(&raw, "password=example").unwrap(); + c.append_to_trace(true, &raw, "password=example").unwrap(); assert_eq!(c.traces[&raw].chunks, vec!["[REDACTED]"]); assert_eq!(c.stop_trace(&raw, true).unwrap(), (10, 0)); } @@ -755,7 +894,7 @@ mod tests { generation: 1, wall_clock_ms: 0, }; - c.append_structured(&id, ev).unwrap(); + c.append_structured(true, &id, ev).unwrap(); // filtered out, drops incremented let state = c.traces.get(&id).unwrap(); assert_eq!(state.drops, 1); diff --git a/justfile b/justfile index 50e4409..1a1b17d 100644 --- a/justfile +++ b/justfile @@ -21,11 +21,13 @@ cargo-check: # TypeScript type check (strict, no any). type-check: - bunx --bun tsc -p tsconfig.json --noEmit + bunx --bun tsc -p tsconfig.check.json --noEmit # TypeScript unit tests (bun:test, headless; no live socket or GUI). +# Serialized: the workflow-import fixture suite drives real child processes and +# must not compete with itself for the same temporary state. test: - bun test + bun test --max-concurrency=1 # Validate a commit message against commitlint.config.ts. # Versions are pinned in package.json / bun.lock; run `bun install` first. diff --git a/lefthook.yml b/lefthook.yml index 9c21974..84b3344 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -4,11 +4,8 @@ commit-msg: commit-check: run: just commit-check {1} +# The pre-commit hook intentionally runs the same full quality gate as CI. pre-commit: commands: - lint: - glob: "*.md" - run: just lint - fmt-check: - glob: "*.md" - run: just fmt-check + check: + run: just check diff --git a/package.json b/package.json index dd44e57..9077be2 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "bitty-devtools", "private": true, "version": "0.0.1", - "description": "Human-facing diagnostics client for local debugging over Panel Runtime and compat matrix (phase 2, live IPC via Unix socket 0600 / Windows pipe with peer-creds, advanced tracing and control).", + "description": "Human-facing diagnostics client for local debugging over Panel Runtime and compat matrix (phase 2, Linux-only endpoint-attested live inspection, advanced tracing and control).", "type": "module", "packageManager": "bun@1.4.2", "exports": { @@ -16,8 +16,8 @@ }, "scripts": { "build": "tsc -p tsconfig.json", - "test": "bun test", - "check:types": "tsc -p tsconfig.json --noEmit", + "test": "bun test --max-concurrency=1", + "check:types": "tsc -p tsconfig.check.json --noEmit", "fmt:check": "prettier --check . --ignore-unknown", "lint": "markdownlint-cli2" }, diff --git a/src/auth.ts b/src/auth.ts index 85e6389..db67f95 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -1,15 +1,17 @@ /** - * Peer-credential authentication for IPC (phase 2, live runtime). + * Headless authentication and endpoint-policy helpers for DevTools. * - * This module reuses the accepted IPC auth contract from `bitty-ipc` - * (Unix socket 0600 / 0700 directory, Windows named pipe ACL) without - * introducing ambient credentials. Verification is headless and bounded, - * requiring no unsafe. Real `SO_PEERCRED` / `GetNamedPipeClientProcessId` - * extraction lives in the platform seam; this file only verifies - * already-extracted credentials so tests run anywhere without a live socket. + * This module reuses the accepted IPC policy values from `bitty-ipc` without + * introducing ambient credentials. Verification is bounded and requires no + * unsafe code. It does not extract `SO_PEERCRED` or + * `GetNamedPipeClientProcessId` values and does not establish a live peer + * identity; callers supply already-extracted values only to the headless + * fixture. The implemented live adapter attests endpoint ownership and mode + * separately and remains inspect-only. * * All checks are fail-closed: directory mode, socket mode, owner UID, - * peer UID equality, and re-check before each privileged action. + * supplied peer UID equality, and re-check before each privileged fixture + * action. */ export const DIR_MODE = 0o700 as const; @@ -163,7 +165,7 @@ export function verifyWindowsPipe( } // --------------------------------------------------------------------------- -// Endpoint discovery (advisory, never credential) +// Endpoint selection (a path selector, never a credential) // --------------------------------------------------------------------------- export type EndpointConfig = { @@ -196,7 +198,8 @@ export function shortInstanceHash(instance: string): string { * Resolve the Unix socket path with `bitty-ipc` precedence and a portable * `AF_UNIX` bound. * - * Precedence: non-empty `BITTY_SOCKET` (advisory) wins verbatim; otherwise + * Precedence: non-empty `BITTY_SOCKET` (the explicit dial target) wins + * verbatim; otherwise * `/bitty/.sock` where `base` is `XDG_RUNTIME_DIR` or * `/run/user/`, and `instance` is `BITTY_INSTANCE_ID` or `default`. * @@ -234,7 +237,7 @@ export function resolveSocketPath(config: EndpointConfig): string { } export function isBittyEnvDiscoverySafe(): string { - return "BITTY_SOCKET and BITTY_INSTANCE_ID are advisory identifiers, never credentials. Every request still requires SO_PEERCRED / pipe-ACL and per-request scope evaluation."; + return "BITTY_SOCKET and BITTY_INSTANCE_ID select a bounded endpoint path, never credentials or connected identity. The live Linux adapter attests endpoint ownership and mode; every request still requires per-request scope evaluation."; } // --------------------------------------------------------------------------- diff --git a/src/automation.ts b/src/automation.ts index 4f53b27..4d66188 100644 --- a/src/automation.ts +++ b/src/automation.ts @@ -28,7 +28,7 @@ */ import { BOUNDS } from "./bounds.js"; -import { PROTOCOL_VERSION } from "./protocol.js"; +import { decodeResponse, PROTOCOL_VERSION } from "./protocol.js"; import type { IpcRequest, IpcResponse } from "./transport.js"; /** Wire method for `synthesizeInput` (key/mouse/wheel/paste synthesis). */ @@ -728,6 +728,26 @@ export function dragTrajectory( // Client // --------------------------------------------------------------------------- +function decodeTransportResponse(response: IpcResponse): IpcResponse { + let encoded: string | undefined; + try { + encoded = JSON.stringify(response); + } catch { + encoded = undefined; + } + if (encoded === undefined) { + throw new AutomationError("InvalidResult", "response is not serializable"); + } + try { + return decodeResponse(encoded) as IpcResponse; + } catch (error) { + throw new AutomationError( + "InvalidResult", + error instanceof Error ? error.message : "invalid response envelope", + ); + } +} + export class AutomationClient { private nextRequestId = 1; @@ -809,9 +829,11 @@ export class AutomationClient { ); const id = this.nextRequestId; this.nextRequestId += 1; - const response = transport.request( - { id, method, params, version: PROTOCOL_VERSION }, - Date.now(), + const response = decodeTransportResponse( + transport.request( + { id, method, params, version: PROTOCOL_VERSION }, + Date.now(), + ), ); if (response.error !== undefined) { throw this.mapServerError(response.error); diff --git a/src/cli.ts b/src/cli.ts index 392e991..14835f8 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -2,8 +2,8 @@ * bitty-devtools CLI wrapper (CTX-0025). * * A lightweight executable over the existing typed diagnostics client. It - * parses `inspect` selectors, resolves a connection from explicit flags or the - * advisory environment (`BITTY_SOCKET` / `BITTY_INSTANCE_ID` / + * parses `inspect` selectors, resolves a connection from explicit flags or + * environment-selected endpoint paths (`BITTY_SOCKET` / `BITTY_INSTANCE_ID` / * `XDG_RUNTIME_DIR`), dispatches through {@link DevtoolsClient} inspection * methods, and renders bounded tabular or JSON output. * @@ -24,6 +24,7 @@ import { BOUNDS, BoundError, truncateToChars } from "./bounds.js"; import { generation } from "./panel-runtime.js"; +import type { Generation } from "./panel-runtime.js"; import { DevtoolsClient } from "./client.js"; import { InspectionError } from "./inspection.js"; import type { @@ -39,7 +40,6 @@ import type { } from "./tracing.js"; import { AuthError, peerCredentials, resolveSocketPath } from "./auth.js"; import { IpcTransport, TransportError } from "./transport.js"; -import { connectLiveSocket } from "./ipc-socket.js"; import { ProtocolErrorImpl } from "./protocol.js"; import { EXIT_CONFIG, @@ -73,6 +73,7 @@ export const WATCH_JITTER_FRACTION = 0.1 as const; /** Cap for `--max-ticks` (tests use a bounded value at or below this). */ export const WATCH_MAX_TICKS = 1000 as const; +export const WATCH_MAX_FAILED_ATTEMPTS = 100 as const; export const DEFAULT_TRACE_DURATION_MS = 10000 as const; export const DEFAULT_TRACE_MAX_BYTES = 524288 as const; @@ -168,7 +169,7 @@ Selectors (exactly one): --subscriptions List event subscriptions for --plugin. --budgets Show RC-1/RC-2/RC-4/RC-5 budgets for --plugin. -Trace (requires --wire-trace, live socket, debug.trace): +Trace (requires --wire-trace and the headless simulation transport): start Start a wire trace with bounded duration and bytes. stop Stop a wire trace and show redacted previews. fetch-chunk Fetch one 262144-byte chunk with continuation. @@ -185,7 +186,7 @@ Options: --include-input Presence-only input capture opt-in, default off. --trace-id Trace id for stop and fetch-chunk. --offset Byte offset for fetch-chunk. - --socket Explicit Bitty IPC socket path (advisory). + --socket Explicit bounded Bitty IPC socket dial target. --instance Instance id under $XDG_RUNTIME_DIR/bitty/.sock. --json Emit bounded, pretty-printed JSON instead of a table. -h, --help Show this help. @@ -197,9 +198,9 @@ Connection: With no --socket/--instance, the CLI reads BITTY_SOCKET, then BITTY_INSTANCE_ID with XDG_RUNTIME_DIR. It fails closed when no instance is selected. Read-only; requires the debug.inspect scope and never fabricates - data for a server method the core has not implemented. Trace verbs use the - live socket only, require debug.trace, spool 0600, and never read - BITTY_WIRE_TRACE. Methods and fields follow the accepted devtools-rfc v1.`; + data for a server method the core has not implemented. The live socket is + inspect-only; trace verbs require the headless simulation transport and never + read BITTY_WIRE_TRACE. Methods and fields follow the accepted devtools-rfc v1.`; function takeValue( argv: readonly string[], @@ -661,7 +662,7 @@ export type CliDeps = { }; /** - * Resolve the socket path from explicit options then advisory environment. + * Resolve the socket path from explicit options then environment selection. * * Returns `null` when no instance is selected. All resolved values are routed * through {@link resolveSocketPath} so its length/NUL/instance validation runs @@ -715,9 +716,28 @@ function resolveSocket( } } +function sanitizeDisplayText(value: string): string { + return Array.from(value, (character) => { + const codePoint = character.codePointAt(0); + if (codePoint === 0x09 || codePoint === 0x0a || codePoint === 0x0d) { + return character === "\t" ? "\\t" : character === "\n" ? "\\n" : "\\r"; + } + if ( + codePoint !== undefined && + (codePoint < 0x20 || + codePoint === 0x7f || + (codePoint >= 0x80 && codePoint <= 0x9f)) + ) { + return `\\u${codePoint.toString(16).padStart(4, "0")}`; + } + return character; + }).join(""); +} + function boundCell(value: string): string { - const { text, truncated } = truncateToChars(value, MAX_CELL_CHARS); - return truncated ? `${text}...` : text; + const safe = sanitizeDisplayText(value); + const { text, truncated } = truncateToChars(safe, MAX_CELL_CHARS - 3); + return truncated ? `${text}...` : safe; } function renderTable( @@ -789,14 +809,20 @@ function renderBudgets(budget: BudgetSnapshot): string { /** Recursively bound string fields so `--json` matches the table path (N2). */ function boundJsonValue(value: unknown, depth = 0): unknown { if (typeof value === "string") return boundCell(value); - if (depth >= MAX_JSON_DEPTH) return value; + if (typeof value === "number" && !Number.isFinite(value)) return null; + if (depth >= MAX_JSON_DEPTH) return "[depth-limit]"; if (Array.isArray(value)) { - return value.map((entry) => boundJsonValue(entry, depth + 1)); + return value + .slice(0, BOUNDS.MAX_PANELS_PER_WINDOW) + .map((entry) => boundJsonValue(entry, depth + 1)); } if (value !== null && typeof value === "object") { const bounded: Record = {}; - for (const [key, entry] of Object.entries(value)) { - bounded[key] = boundJsonValue(entry, depth + 1); + for (const [key, entry] of Object.entries(value).slice( + 0, + BOUNDS.MAX_TOPICS_TOTAL, + )) { + bounded[boundCell(key)] = boundJsonValue(entry, depth + 1); } return bounded; } @@ -804,7 +830,11 @@ function boundJsonValue(value: unknown, depth = 0): unknown { } function toJson(value: unknown): string { - return JSON.stringify(boundJsonValue(value), null, 2); + const json = JSON.stringify(boundJsonValue(value), null, 2); + if (new TextEncoder().encode(json).length > BOUNDS.MAX_SNAPSHOT_JSON_BYTES) { + throw new InspectionError("InvalidResult", "rendered JSON exceeds 16 KiB"); + } + return json; } function renderTraceStart(result: TraceStartResult, json: boolean): string { @@ -815,7 +845,7 @@ function renderTraceStart(result: TraceStartResult, json: boolean): string { ["FIELD", "VALUE"], [ ["traceId", result.traceId], - ["spoolPath", result.spoolPath], + ["storage", result.storage], ["chunkBytes", String(result.chunkBytes)], ["startWallClockMs", String(result.startWallClockMs)], ], @@ -1032,7 +1062,7 @@ async function runWatchTickLive( if (signal?.aborted) return { kind: "cancelled" }; if (!client.isIpcConnected()) return { kind: "cancelled" }; try { - const output = await dispatchLive(client, options, nowMs); + const output = await dispatchLive(client, options, nowMs, signal); if (signal?.aborted) return { kind: "cancelled" }; return { kind: "frame", output }; } catch (error) { @@ -1099,6 +1129,7 @@ async function runWatchLoopWith( else external.addEventListener("abort", onExternalAbort, { once: true }); } let frames = 0; + let failedAttempts = 0; let pending: unknown = null; const settle = (): number => { if (frames >= 1) return EXIT_OK; @@ -1108,6 +1139,35 @@ async function runWatchLoopWith( } return EXIT_RUNTIME; }; + const sleepWithCancellation = (delayMs: number): Promise => { + if (controller.signal.aborted) return Promise.resolve(); + return new Promise((resolve, reject) => { + let settled = false; + let onAbort: () => void = () => {}; + const cleanup = (): void => { + controller.signal.removeEventListener("abort", onAbort); + }; + const finish = (): void => { + if (settled) return; + settled = true; + cleanup(); + resolve(); + }; + const fail = (error: unknown): void => { + if (settled) return; + settled = true; + cleanup(); + reject(error); + }; + onAbort = (): void => finish(); + controller.signal.addEventListener("abort", onAbort, { once: true }); + try { + void sleep(delayMs).then(finish, fail); + } catch (error) { + fail(error); + } + }); + }; try { for (;;) { if (controller.signal.aborted || !shouldContinue()) return settle(); @@ -1119,10 +1179,12 @@ async function runWatchLoopWith( runtime.stdout(`${outcome.output}\n`); frames += 1; if (maxTicks !== null && frames >= maxTicks) return EXIT_OK; - await sleep(delay); + await sleepWithCancellation(delay); } else if (outcome.kind === "rateLimited") { pending = outcome.error; - await sleep(delay); + failedAttempts += 1; + if (failedAttempts >= WATCH_MAX_FAILED_ATTEMPTS) return settle(); + await sleepWithCancellation(delay); } else if (outcome.kind === "denied") { runtime.stderr(`bitty-devtools: ${formatCliError(outcome.error)}\n`); return exitCodeForError(outcome.error); @@ -1174,85 +1236,32 @@ async function dispatchLive( client: DevtoolsClient, options: InspectOptions, nowMs: number, + signal?: AbortSignal, ): Promise { switch (options.selector) { case "plugins": { - const response = await client.requestLive( - { - id: 1, - method: "bitty.debug/listPlugins", - params: { - generation: options.generation, - }, - version: "1.0", - }, + const plugins = await client.listPluginsLive( + (options.generation ?? undefined) as Generation | undefined, nowMs, + signal, ); - if (response.error !== undefined) { - throw new InspectionError( - response.error.code, - `${response.error.category}: ${response.error.message}`, - ); - } - // The accepted result envelope is `{ "plugins": [...] }` - // (devtools-rfc v1); fail closed on a mistyped envelope. - const envelope = response.result as { plugins?: unknown }; - if (!Array.isArray(envelope?.plugins)) { - throw new InspectionError( - "InvalidResult", - "listPlugins result.plugins: expected an array", - ); - } - const plugins = envelope.plugins as Parameters[0]; return options.json ? toJson(plugins) : renderPlugins(plugins); } case "subscriptions": { - const plugin = requirePlugin(options); - const response = await client.requestLive( - { - id: 1, - method: "bitty.debug/listSubscriptions", - params: { pluginId: plugin }, - version: "1.0", - }, + const subs = await client.listSubscriptionsLive( + requirePlugin(options), nowMs, + signal, ); - if (response.error !== undefined) { - throw new InspectionError( - response.error.code, - `${response.error.category}: ${response.error.message}`, - ); - } - if (!Array.isArray(response.result)) { - throw new InspectionError( - "InvalidResult", - "listSubscriptions result: expected an array", - ); - } - const subs = response.result as Parameters[0]; return options.json ? toJson(subs) : renderSubscriptions(subs); } case "budgets": { - const plugin = requirePlugin(options); - const response = await client.requestLive( - { - id: 1, - method: "bitty.debug/getBudgets", - params: { - pluginId: plugin, - generation: options.generation ?? DEFAULT_GENERATION, - }, - version: "1.0", - }, + const budget = await client.getBudgetsLive( + requirePlugin(options), + (options.generation ?? DEFAULT_GENERATION) as Generation, nowMs, + signal, ); - if (response.error !== undefined) { - throw new InspectionError( - response.error.code, - `${response.error.category}: ${response.error.message}`, - ); - } - const budget = response.result as Parameters[0]; return options.json ? toJson(budget) : renderBudgets(budget); } default: @@ -1260,27 +1269,35 @@ async function dispatchLive( } } +function safeErrorText(value: string): string { + return [...sanitizeDisplayText(value)].slice(0, 512).join(""); +} + export function formatCliError(error: unknown): string { - if (error instanceof CliUsageError) return error.message; - if (error instanceof CliConfigError) return error.message; + if (error instanceof CliUsageError) return safeErrorText(error.message); + if (error instanceof CliConfigError) return safeErrorText(error.message); if (error instanceof InspectionError) { - return `${error.code}: ${error.message}`; + return safeErrorText(`${error.code}: ${error.message}`); } if (error instanceof TracingError) { - return `${error.code}: ${error.message}`; + return safeErrorText(`${error.code}: ${error.message}`); } if (error instanceof BoundError) { - return `${error.bound}: ${error.message}`; + return safeErrorText(`${error.bound}: ${error.message}`); + } + if (error instanceof AuthError) { + return safeErrorText(`${error.code}: ${error.message}`); } - if (error instanceof AuthError) return `${error.code}: ${error.message}`; if (error instanceof TransportError) { - return `${error.code}: ${error.message}`; + return safeErrorText(`${error.code}: ${error.message}`); } if (error instanceof ProtocolErrorImpl) { - return `${error.error.category}/${error.error.code}: ${error.error.message}`; + return safeErrorText( + `${error.error.category}/${error.error.code}: ${error.error.message}`, + ); } - if (error instanceof Error) return error.message; - return String(error); + if (error instanceof Error) return safeErrorText(error.message); + return safeErrorText(String(error)); } export function exitCodeForError(error: unknown): number { @@ -1299,6 +1316,13 @@ export function exitCodeForError(error: unknown): number { return expectedExitForError("Denied", error.code); } if (error instanceof TransportError) { + if ( + error.code === "InvalidFrame" || + error.code === "FrameTooLarge" || + error.code === "FrameTruncated" + ) { + return EXIT_GENERIC; + } return expectedExitForError("Unavailable", error.code); } if (error instanceof ProtocolErrorImpl) { @@ -1510,7 +1534,6 @@ export async function runCliLive( deps: CliDeps, ): Promise { const { runtime } = deps; - void connectLiveSocket; let command: CliCommand; try { @@ -1530,36 +1553,16 @@ export async function runCliLive( command.kind === "trace-stop" || command.kind === "trace-fetch" ) { - const traceOptions = command.options; - let traceSocketPath: string | null; - try { - traceSocketPath = resolveSocket(traceOptions, runtime); - } catch (error) { - runtime.stderr(`bitty-devtools: ${formatCliError(error)}\n`); - return exitCodeForError(error); - } - if (traceSocketPath === null) { + const injected = deps.transport ?? null; + if (injected === null) { runtime.stderr( - "bitty-devtools: no connected Bitty instance; pass --socket or " + - "--instance , or set BITTY_SOCKET / BITTY_INSTANCE_ID with " + - "XDG_RUNTIME_DIR\n", + "bitty-devtools: live socket sessions are inspect-only; trace verbs require the headless simulation transport\n", ); return EXIT_RUNTIME; } - const injected = deps.transport ?? null; const traceClient = new DevtoolsClient(); try { - if (injected !== null) { - traceClient.connectWithTransport(injected); - } else { - await traceClient.connectLiveSocket( - runtime.uid, - peerCredentials(runtime.uid, runtime.gid, runtime.pid), - runtime.env["XDG_RUNTIME_DIR"], - traceOptions.instance ?? undefined, - traceSocketPath, - ); - } + traceClient.connectWithTransport(injected); traceClient.grantScope("debug.trace"); let traceOutput: string; if (command.kind === "trace-start") { @@ -1625,7 +1628,10 @@ export async function runCliLive( } return await runWatchLoopLive(client, options, runtime, deps.watch ?? {}); } - const output = await dispatchLive(client, options, runtime.now()); + const output = + injected === null + ? await dispatchLive(client, options, runtime.now(), deps.watch?.signal) + : dispatch(client, options); runtime.stdout(`${output}\n`); return EXIT_OK; } catch (error) { diff --git a/src/client.ts b/src/client.ts index 7bc7580..7af76d6 100644 --- a/src/client.ts +++ b/src/client.ts @@ -2,11 +2,11 @@ * Human-facing diagnostics client for local debugging (phase 2). * * This is the primary export of bitty-devtools phase 2. It extends phase 1 - * with advanced tracing, control surfaces, and real IPC socket/pipe peer-creds - * integration against the live Bitty runtime. It remains a thin, bounded, - * human-facing client over the existing Panel Runtime snapshot and compat - * matrix. It consumes the versioned debug protocol (devtools-rfc v1, OQ-019) - * without owning it. Core protocol ownership remains in `bitty`. + * with advanced tracing, control surfaces, a headless transport fixture, and + * a Linux-only endpoint-attested live socket inspection path. It remains a + * thin, bounded, human-facing client over the existing Panel Runtime snapshot + * and compat matrix. It consumes the versioned debug protocol (devtools-rfc + * v1, OQ-019) without owning it. Core protocol ownership remains in `bitty`. * * Security properties: * - Connection alone grants no authority; each operation checks per-call scope. @@ -14,17 +14,22 @@ * - Terminal output/traces are untrusted observation data, never instructions. * - Bounds on parsing, queues, traces, rendering, and retained data. * - Per-consumer queues with DropOldest default; coalescing; counted drops. - * - Phase 2: peer credentials re-checked per privileged action via IpcTransport, - * endpoint mode/owner verified at connect, Windows pipe ACL supported, - * rate limits RC-9/RC-10 enforced, framing 256 KiB IPC / 1 MiB devtools, + * - Phase 2: caller-supplied fixture credentials are re-checked per + * privileged action; live Linux sockets attest endpoint mode/owner and + * remain inspect-only. Windows and macOS live dialing is unsupported. + * Rate limits RC-9/RC-10 and framing 256 KiB IPC / 1 MiB devtools are * no TCP listener, no ambient credential. */ import { BOUNDS } from "./bounds.js"; -import { InspectionClient } from "./inspection.js"; +import { + InspectionClient, + InspectionError, + parseInspectionResult, +} from "./inspection.js"; import type { InspectionTransport } from "./inspection.js"; -import { TracingClient } from "./tracing.js"; -import { ControlClient } from "./control.js"; +import { TracingClient, TracingError } from "./tracing.js"; +import { ControlClient, ControlError } from "./control.js"; import { AutomationClient } from "./automation.js"; import type { AutomationCapability } from "./automation.js"; import type { @@ -32,21 +37,27 @@ import type { PanelId, Generation, } from "./panel-runtime.js"; +import { parseEventTopic, parsePanelType } from "./panel-runtime.js"; import type { DebugScope } from "./protocol.js"; import { PROTOCOL_VERSION, + decodeResponse, negotiateVersion, validateFrameBytes, } from "./protocol.js"; -import { generateMatrixJson } from "./compat-matrix.js"; +import { validateLiveRequest } from "./protocol-boundary.js"; +import { + generateMatrixJson, + validateMatrixJsonDocument, +} from "./compat-matrix.js"; +import type { MatrixJson } from "./compat-matrix.js"; import { IpcTransport } from "./transport.js"; import type { IpcRequest, IpcResponse } from "./transport.js"; import { TransportError } from "./transport.js"; import { resolveSocketPath } from "./auth.js"; import type { PeerCredentials } from "./auth.js"; -import { decodeResponse } from "./protocol.js"; import { connectLiveSocket, resolveLiveSocketEndpoint } from "./ipc-socket.js"; -import type { LiveSocketConnection } from "./ipc-socket.js"; +import type { LiveSocketConnection, LiveSocketIdentity } from "./ipc-socket.js"; export type ClientConfig = { maxConnections?: number; @@ -65,26 +76,327 @@ export type SessionState = { socketPath: string | null; }; +function decodeLiveResponse(raw: Uint8Array, expectedId: number): IpcResponse { + let text: string; + try { + text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode( + raw, + ); + } catch { + throw new TransportError("InvalidFrame", "response is not valid UTF-8"); + } + const response = decodeResponse(text); + if (response.id !== expectedId) { + throw new TransportError( + "InvalidFrame", + `response id ${response.id} does not match request ${expectedId}`, + ); + } + return response; +} + +const PANEL_STATES = new Set([ + "Declared", + "Created", + "Mounted", + "Focused", + "Suspended", + "Disposed", +]); +const OVERLAY_KINDS = new Set(["Modal", "NonModal", "Tooltip", "Palette"]); + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function requireSafeInteger( + value: unknown, + field: string, + minimum = 0, +): number { + if (!Number.isSafeInteger(value) || (value as number) < minimum) { + throw new Error(`${field} must be a safe integer >= ${minimum}`); + } + return value as number; +} + +function requireBoundedString( + value: unknown, + field: string, + maxBytes: number, +): string { + if (typeof value !== "string") throw new Error(`${field} must be a string`); + if (new TextEncoder().encode(value).length > maxBytes) { + throw new Error(`${field} exceeds ${maxBytes} bytes`); + } + return value; +} + +function requireExactObjectFields( + value: Record, + required: readonly string[], + optional: readonly string[] = [], + field = "object", +): void { + const allowed = new Set([...required, ...optional]); + for (const key of Object.keys(value)) { + if (!allowed.has(key)) throw new Error(`${field}.${key} is not allowed`); + } + for (const key of required) { + if (!Object.hasOwn(value, key)) { + throw new Error(`${field}.${key} is required`); + } + } +} + +export function validatePanelSnapshot( + value: unknown, +): asserts value is PanelRuntimeSnapshot { + if (!isRecord(value)) throw new Error("panel snapshot must be an object"); + requireExactObjectFields( + value, + [ + "generation", + "panels", + "panelsPerWorkspace", + "totalPanels", + "topics", + "overlays", + "config", + ], + [], + "panel snapshot", + ); + requireSafeInteger(value["generation"], "panel snapshot.generation", 1); + if (!Array.isArray(value["panels"])) { + throw new Error("panel snapshot.panels must be an array"); + } + const panels = value["panels"]; + if (panels.length > BOUNDS.MAX_PANELS_PER_WINDOW) { + throw new Error("panel snapshot.panels exceeds the panel bound"); + } + const panelIds = new Set(); + for (const [index, panel] of panels.entries()) { + if (!isRecord(panel)) { + throw new Error(`panel snapshot.panels[${index}] must be an object`); + } + requireExactObjectFields( + panel, + ["id", "generation", "state", "type"], + ["workspace", "view", "title"], + `panel snapshot.panels[${index}]`, + ); + const id = requireSafeInteger(panel["id"], `panel ${index}.id`, 1); + if (panelIds.has(id)) throw new Error(`duplicate panel id ${id}`); + panelIds.add(id); + requireSafeInteger(panel["generation"], `panel ${index}.generation`, 1); + if ( + typeof panel["state"] !== "string" || + !PANEL_STATES.has(panel["state"]) + ) { + throw new Error(`panel ${index}.state is invalid`); + } + if ( + typeof panel["type"] !== "string" || + parsePanelType(panel["type"]) === null + ) { + throw new Error(`panel ${index}.type is invalid`); + } + if (panel["workspace"] !== undefined) { + requireSafeInteger(panel["workspace"], `panel ${index}.workspace`, 1); + } + if (panel["view"] !== undefined) { + requireSafeInteger(panel["view"], `panel ${index}.view`, 1); + } + if (panel["title"] !== undefined) { + requireBoundedString(panel["title"], `panel ${index}.title`, 128); + } + } + const totalPanels = requireSafeInteger( + value["totalPanels"], + "panel snapshot.totalPanels", + ); + if (totalPanels > BOUNDS.MAX_PANELS_PER_WINDOW) { + throw new Error("panel snapshot.totalPanels exceeds the panel bound"); + } + if (!(value["panelsPerWorkspace"] instanceof Map)) { + throw new Error("panel snapshot.panelsPerWorkspace must be a Map"); + } + const workspaceCounts = value["panelsPerWorkspace"] as Map; + if (workspaceCounts.size > BOUNDS.MAX_PANELS_PER_WINDOW) { + throw new Error("panel snapshot workspace map exceeds the panel bound"); + } + let mappedPanels = 0; + for (const [workspace, count] of workspaceCounts as Map) { + requireSafeInteger(workspace, "panel snapshot.workspace", 1); + requireSafeInteger(count, "panel snapshot.workspace count"); + mappedPanels += count as number; + if (!Number.isSafeInteger(mappedPanels)) { + throw new Error("panel snapshot workspace counts exceed safe bounds"); + } + } + if (mappedPanels !== totalPanels || panels.length !== totalPanels) { + throw new Error( + "panel snapshot panel/workspace counts do not match totalPanels", + ); + } + if (!Array.isArray(value["topics"])) { + throw new Error("panel snapshot.topics must be an array"); + } + if (value["topics"].length > BOUNDS.MAX_TOPICS_TOTAL) { + throw new Error("panel snapshot.topics exceeds the topic bound"); + } + const topics = new Set(); + for (const [index, topic] of value["topics"].entries()) { + if (typeof topic !== "string") + throw new Error(`topic ${index} must be a string`); + const parsed = parseEventTopic(topic); + if (topics.has(parsed)) throw new Error(`duplicate topic ${parsed}`); + topics.add(parsed); + } + if (!Array.isArray(value["overlays"])) { + throw new Error("panel snapshot.overlays must be an array"); + } + if (value["overlays"].length > BOUNDS.MAX_OVERLAYS_PER_WINDOW) { + throw new Error("panel snapshot.overlays exceeds the overlay bound"); + } + const overlayIds = new Set(); + for (const [index, overlay] of value["overlays"].entries()) { + if (!isRecord(overlay)) + throw new Error(`overlay ${index} must be an object`); + requireExactObjectFields( + overlay, + ["id", "kind", "bounds", "text", "generation", "truncated"], + ["tooltip"], + `panel snapshot.overlays[${index}]`, + ); + const id = requireSafeInteger(overlay["id"], `overlay ${index}.id`, 1); + if (overlayIds.has(id)) throw new Error(`duplicate overlay id ${id}`); + overlayIds.add(id); + if ( + typeof overlay["kind"] !== "string" || + !OVERLAY_KINDS.has(overlay["kind"]) + ) { + throw new Error(`overlay ${index}.kind is invalid`); + } + requireSafeInteger(overlay["generation"], `overlay ${index}.generation`, 1); + if (typeof overlay["truncated"] !== "boolean") { + throw new Error(`overlay ${index}.truncated must be boolean`); + } + if (!isRecord(overlay["bounds"])) { + throw new Error(`overlay ${index}.bounds must be an object`); + } + requireExactObjectFields( + overlay["bounds"], + ["x", "y", "width", "height"], + [], + `overlay ${index}.bounds`, + ); + for (const key of ["x", "y", "width", "height"] as const) { + requireSafeInteger( + overlay["bounds"][key], + `overlay ${index}.bounds.${key}`, + ); + } + if ( + (overlay["bounds"]["width"] as number) < 1 || + (overlay["bounds"]["height"] as number) < 1 + ) { + throw new Error(`overlay ${index}.bounds must be positive`); + } + requireBoundedString(overlay["text"], `overlay ${index}.text`, 128); + if (overlay["tooltip"] !== undefined) { + requireBoundedString(overlay["tooltip"], `overlay ${index}.tooltip`, 256); + } + } + if (!isRecord(value["config"])) { + throw new Error("panel snapshot.config must be an object"); + } + requireExactObjectFields( + value["config"], + [ + "maxPanelsPerWorkspace", + "maxPanelsPerWindow", + "maxTopicsTotal", + "maxSubscriptionsPerPanel", + ], + [], + "panel snapshot.config", + ); + const maxPanelsPerWorkspace = value["config"][ + "maxPanelsPerWorkspace" + ] as number; + for (const count of workspaceCounts.values()) { + if ((count as number) > maxPanelsPerWorkspace) { + throw new Error( + "panel snapshot workspace count exceeds configured bound", + ); + } + } + for (const [key, limit] of [ + ["maxPanelsPerWorkspace", BOUNDS.MAX_PANELS_PER_WORKSPACE], + ["maxPanelsPerWindow", BOUNDS.MAX_PANELS_PER_WINDOW], + ["maxTopicsTotal", BOUNDS.MAX_TOPICS_TOTAL], + ["maxSubscriptionsPerPanel", BOUNDS.MAX_SUBSCRIPTIONS_PER_PANEL], + ] as const) { + const configured = requireSafeInteger( + value["config"][key], + `panel snapshot.config.${key}`, + 1, + ); + if (configured > limit) + throw new Error(`panel snapshot.config.${key} exceeds ${limit}`); + } +} + +export function validateCompatMatrixDocument( + value: unknown, +): asserts value is MatrixJson { + validateMatrixJsonDocument(value); +} + +export function parseCompatMatrixJsonBounded(raw: string): MatrixJson { + if (new TextEncoder().encode(raw).length > BOUNDS.MAX_SNAPSHOT_JSON_BYTES) { + throw new Error("compat matrix exceeds 16 KiB"); + } + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + throw new Error("invalid compat matrix JSON"); + } + validateMatrixJsonDocument(parsed); + return parsed; +} + export class DevtoolsClient { private session: SessionState; private panelSnapshot: PanelRuntimeSnapshot | null = null; private readonly inspection: InspectionClient; - private readonly tracing: TracingClient; - private readonly control: ControlClient; + private tracing: TracingClient; + private control: ControlClient; private transport: IpcTransport | null = null; private liveSocket: LiveSocketConnection | null = null; + private liveRequestId = 1; private readonly config: ClientConfig; /** - * Live inspection seam. Dispatches over the connected `IpcTransport` and - * reports disconnected when there is none, so inspection uses real IPC - * whenever connected. The snapshot closure below is an explicit injected - * fallback for the headless/unit-test path only (`setPanelSnapshot`), never - * the production path. + * Synchronous headless inspection seam. Live sessions use the async typed + * methods below, which dispatch through `requestLive`; they never fall back + * to this in-memory transport. */ private readonly inspectionTransport: InspectionTransport = { - isConnected: () => this.transport !== null && this.transport.isConnected(), + isConnected: () => + this.liveSocket === null && + this.transport !== null && + this.transport.isConnected(), request: (request, nowMs) => { + if (this.liveSocket !== null) { + throw new TransportError( + "TransportClosed", + "synchronous inspection is unavailable on live sessions; use the async live method", + ); + } const transport = this.transport; if (transport === null) { throw new Error("no connected inspection transport"); @@ -113,19 +425,53 @@ export class DevtoolsClient { this.control = new ControlClient(); } + private resetSessionState(): void { + this.session.connected = false; + this.session.transport = null; + this.session.socketPath = null; + this.panelSnapshot = null; + this.tracing = new TracingClient(); + this.control = new ControlClient(); + this.session.scopes.clear(); + this.session.generation = 1 as Generation; + this.liveRequestId = 1; + } + + private closeLiveSocket(): void { + if (this.liveSocket === null) return; + try { + this.liveSocket.close(); + } catch { + return; + } finally { + this.liveSocket = null; + } + } + // ------------------------------------------------------------------------- // Connection and scope lifecycle (per-client, least-privilege, revocable) // ------------------------------------------------------------------------- connect(): SessionState { + this.closeLiveSocket(); + if (this.transport !== null) this.transport.disconnect(); + this.transport = null; + this.resetSessionState(); this.session.connected = true; - this.session.scopes.clear(); - // Headless transport for tests; live runtime path resolved when config provides peer + // Explicit socket configuration still selects the bounded headless fixture; + // use connectLiveSocket() for the Linux endpoint-attested OS socket path. if ( this.config.socketPath !== undefined || this.config.runtimeUid !== undefined ) { - const runtimeUid = this.config.runtimeUid ?? 1000; + if ( + this.config.socketPath !== undefined && + this.config.runtimeUid === undefined + ) { + this.session.connected = false; + throw new Error("runtimeUid is required when socketPath is configured"); + } + const runtimeUid = this.config.runtimeUid ?? 0; const socketPath = this.config.socketPath ?? resolveSocketPath({ @@ -149,7 +495,7 @@ export class DevtoolsClient { // use the mock fallback. this.transport = null; this.session.transport = null; - this.session.socketPath = socketPath; + this.session.socketPath = null; this.session.connected = false; throw error; } @@ -157,18 +503,23 @@ export class DevtoolsClient { return { ...this.session, scopes: new Set(this.session.scopes) }; } - /** Phase 2: connect with explicit IPC transport and peer-creds verification. */ + /** Connect to the bounded headless fixture with caller-supplied peer values. */ connectWithTransport(transport: IpcTransport): SessionState { + this.closeLiveSocket(); + if (this.transport !== null && this.transport !== transport) { + this.transport.disconnect(); + } + this.transport = null; + this.resetSessionState(); transport.connect(); this.transport = transport; this.session.connected = true; - this.session.scopes.clear(); this.session.transport = transport; this.session.socketPath = transport.getSocketPath(); return { ...this.session, scopes: new Set(this.session.scopes) }; } - /** Phase 2: connect via live runtime socket path (XDG_RUNTIME_DIR/bitty). */ + /** Connect the headless fixture using a resolved endpoint and peer values. */ connectLive( runtimeUid: number, peer: PeerCredentials, @@ -192,17 +543,22 @@ export class DevtoolsClient { * Unlike `connectLive` (which only verifies caller-supplied mode values * on the headless stub), this opens a live connection: each inspection * request writes one framed request and decodes the next framed response. - * The stub transport stays attached for rate limiting and scope checks; - * the socket supplies the bytes. Opt-in and async: headless callers keep + * The headless transport is retained only as a bounded request codec and + * rate limiter; the socket supplies the response bytes and typed live + * methods never use its in-memory queues. Opt-in and async: headless callers keep * using `connect` / `connectWithTransport`. */ async connectLiveSocket( runtimeUid: number, - peer: PeerCredentials, + _peer?: PeerCredentials, xdgRuntimeDir?: string, instanceId?: string, socketPath?: string, ): Promise { + this.closeLiveSocket(); + if (this.transport !== null) this.transport.disconnect(); + this.transport = null; + this.resetSessionState(); const endpoint = resolveLiveSocketEndpoint({ socketPath, runtimeUid, @@ -216,13 +572,20 @@ export class DevtoolsClient { const t = new IpcTransport({ runtimeUid, socketPath: endpoint.socketPath, - peer, + peer: null, }); - t.connect(); + try { + t.connect(); + } catch (error) { + live.close(); + this.session.connected = false; + this.session.transport = null; + this.session.socketPath = null; + throw error; + } this.transport = t; this.liveSocket = live; this.session.connected = true; - this.session.scopes.clear(); this.session.transport = t; this.session.socketPath = endpoint.socketPath; return { ...this.session, scopes: new Set(this.session.scopes) }; @@ -231,52 +594,230 @@ export class DevtoolsClient { /** * One live request/response round trip over the socket opened by * `connectLiveSocket`. Encodes the request with the shared framing, - * writes it, decodes the next framed response, and validates the - * envelope (id match + `decodeResponse`). + * writes it, decodes the next framed response, and validates the closed + * response envelope, including the expected request id. */ - async requestLive(req: IpcRequest, nowMs: number): Promise { + async requestLive( + req: IpcRequest, + nowMs: number, + signal?: AbortSignal, + ): Promise { const live = this.liveSocket; const transport = this.transport; - if (live === null || transport === null || !live.isOpen()) { - throw new Error( + if ( + live === null || + transport === null || + !live.isOpen() || + !transport.isConnected() + ) { + throw new TransportError( + "TransportClosed", "no live socket connection: call connectLiveSocket first", ); } - transport.verifyPeerForPrivilegedAction(); + this.requireScope("debug.inspect"); + validateLiveRequest(req, "debug.inspect"); + if (signal?.aborted) { + throw new TransportError("TransportClosed", "request cancelled"); + } transport.getRateLimiter().check(nowMs); const frames = transport.encodeRequest(req); - let raw: Uint8Array | null = null; - for (const frame of frames) { - raw = await live.requestResponse(frame.slice(4), nowMs); + if (frames.length !== 1) { + throw new TransportError( + "FrameTooLarge", + "live request requires a server continuation contract", + ); } - if (raw === null) { - throw new Error(`no response for id ${req.id} (${req.method})`); + const raw = await live.requestResponse( + frames[0]!.slice(4), + nowMs, + req.id, + signal, + ); + return decodeLiveResponse(raw, req.id); + } + + private async liveResult( + method: string, + params: Record, + nowMs: number, + signal?: AbortSignal, + ): Promise { + if (this.liveSocket === null) { + throw new TransportError( + "TransportClosed", + "no live socket connection: call connectLiveSocket first", + ); } - const response: IpcResponse = decodeResponse(new TextDecoder().decode(raw)); - if (response.id !== req.id) { + if (this.liveRequestId > Number.MAX_SAFE_INTEGER) { throw new TransportError( - "InvalidFrame", - `response id ${response.id} != request id ${req.id}`, + "TransportFull", + "live request id space exhausted", ); } - return response; + const response = await this.requestLive( + { + id: this.liveRequestId++, + method, + params, + version: PROTOCOL_VERSION, + }, + nowMs, + signal, + ); + if (response.error !== undefined) { + throw new InspectionError( + response.error.code, + `${response.error.category}: ${response.error.message}`, + ); + } + return parseInspectionResult(method, params, response.result) as T; + } + + getLiveIdentity(): LiveSocketIdentity | null { + return this.liveSocket?.identity ?? null; + } + + async listPluginsLive( + generation?: Generation, + nowMs = Date.now(), + signal?: AbortSignal, + ): Promise> { + return this.liveResult( + "bitty.debug/listPlugins", + { generation: generation ?? null }, + nowMs, + signal, + ); + } + + async getPluginLive( + pluginId: string, + nowMs = Date.now(), + signal?: AbortSignal, + ): Promise> { + return this.liveResult( + "bitty.debug/getPlugin", + { pluginId }, + nowMs, + signal, + ); + } + + async listSubscriptionsLive( + pluginId: string, + nowMs = Date.now(), + signal?: AbortSignal, + ): Promise> { + return this.liveResult( + "bitty.debug/listSubscriptions", + { pluginId }, + nowMs, + signal, + ); + } + + async getBudgetsLive( + pluginId: string, + gen: Generation, + nowMs = Date.now(), + signal?: AbortSignal, + ): Promise> { + return this.liveResult( + "bitty.debug/getBudgets", + { pluginId, generation: gen }, + nowMs, + signal, + ); + } + + async getQueueSnapshotLive( + pluginId: string, + nowMs = Date.now(), + signal?: AbortSignal, + ): Promise> { + return this.liveResult( + "bitty.debug/getQueueSnapshot", + { pluginId }, + nowMs, + signal, + ); + } + + async getSnapshotForTerminalLive( + terminalId: string, + nowMs = Date.now(), + signal?: AbortSignal, + ): Promise> { + return this.liveResult( + "bitty.debug/getSnapshot", + { terminalId, scope: "semantic" }, + nowMs, + signal, + ); + } + + async listHandlesLive( + pluginId: string, + nowMs = Date.now(), + signal?: AbortSignal, + ): Promise> { + return this.liveResult( + "bitty.debug/listHandles", + { pluginId }, + nowMs, + signal, + ); + } + + async getGridTextLive( + options: { rows?: number; cols?: number } = {}, + nowMs = Date.now(), + signal?: AbortSignal, + ): Promise> { + return this.liveResult( + "bitty.debug/getGridText", + { ...options }, + nowMs, + signal, + ); + } + + async getInputRingLive( + options: { limit?: number } = {}, + nowMs = Date.now(), + signal?: AbortSignal, + ): Promise> { + return this.liveResult( + "bitty.debug/getInputRing", + { ...options }, + nowMs, + signal, + ); + } + + async getModifiersLive( + nowMs = Date.now(), + signal?: AbortSignal, + ): Promise> { + return this.liveResult("bitty.debug/getModifiers", {}, nowMs, signal); + } + + async getFocusLive( + nowMs = Date.now(), + signal?: AbortSignal, + ): Promise> { + return this.liveResult("bitty.debug/getFocus", {}, nowMs, signal); } disconnect(): void { - if (this.liveSocket !== null) { - try { - this.liveSocket.close(); - } catch { - // Close is best-effort; a closed socket must not mask disconnect. - } - this.liveSocket = null; - } + this.closeLiveSocket(); if (this.transport !== null) { this.transport.disconnect(); this.transport = null; } + this.resetSessionState(); this.session.connected = false; - this.session.scopes.clear(); this.session.transport = null; this.session.socketPath = null; } @@ -301,33 +842,33 @@ export class DevtoolsClient { if (!["debug.inspect", "debug.trace", "debug.control"].includes(scope)) { throw new Error(`unknown scope ${scope}`); } - if (this.transport !== null) { - this.transport.verifyPeerForPrivilegedAction(); + if (this.liveSocket !== null && scope !== "debug.inspect") { + throw new TransportError( + "Unauthenticated", + "live socket sessions are inspect-only; trace and control are unavailable", + ); } - if (scope === "debug.control") { - this.session.scopes.add("debug.inspect"); - this.session.scopes.add("debug.trace"); - this.session.scopes.add("debug.control"); - } else if (scope === "debug.trace") { - this.session.scopes.add("debug.inspect"); - this.session.scopes.add("debug.trace"); - } else { - this.session.scopes.add(scope); + if (this.liveSocket === null && this.transport !== null) { + this.transport.verifyPeerForPrivilegedAction(); } + this.session.scopes.add(scope); } revokeScope(scope: DebugScope): void { this.requireConnected(); - if (this.transport !== null) { + if (!["debug.inspect", "debug.trace", "debug.control"].includes(scope)) { + throw new Error(`unknown scope ${scope}`); + } + if (this.liveSocket !== null && scope !== "debug.inspect") { + throw new TransportError( + "Unauthenticated", + "live socket sessions are inspect-only; trace and control are unavailable", + ); + } + if (this.liveSocket === null && this.transport !== null) { this.transport.verifyPeerForPrivilegedAction(); } this.session.scopes.delete(scope); - if (scope === "debug.inspect") { - this.session.scopes.delete("debug.trace"); - this.session.scopes.delete("debug.control"); - } else if (scope === "debug.trace") { - this.session.scopes.delete("debug.control"); - } } currentScope(): DebugScope[] { @@ -339,11 +880,49 @@ export class DevtoolsClient { throw new Error("not connected: call connect() first"); } - private activeScope(): string { - if (this.session.scopes.has("debug.control")) return "debug.control"; - if (this.session.scopes.has("debug.trace")) return "debug.trace"; - if (this.session.scopes.has("debug.inspect")) return "debug.inspect"; - return ""; + private requireScope(scope: DebugScope): void { + this.requireConnected(); + if (!this.session.scopes.has(scope)) { + throw new Error(`${scope} scope required`); + } + } + + private requireHeadlessInspection(operation: string): void { + if (this.liveSocket !== null) { + throw new TransportError( + "TransportClosed", + `${operation} is unavailable synchronously on live sessions; use ${operation}Live`, + ); + } + } + + private requireSimulationOnly(operation: string): void { + if (this.liveSocket !== null) { + throw new TransportError( + "TransportClosed", + `${operation} is unavailable on the inspect-only live socket session`, + ); + } + } + + private requireTraceAccess(operation: string): void { + this.requireConnected(); + this.requireSimulationOnly(operation); + if (!this.session.scopes.has("debug.trace")) { + throw new TracingError("ScopeDenied", "debug.trace scope required"); + } + if (this.transport !== null) { + this.transport.verifyPeerForPrivilegedAction(); + } + } + + private requireControlAccess(operation: string): void { + this.requireConnected(); + this.requireSimulationOnly(operation); + if (!this.session.scopes.has("debug.control")) { + throw new ControlError("ScopeDenied", "debug.control scope required"); + } + this.requirePeerForControl(); } private requirePeerForControl(): void { @@ -357,22 +936,20 @@ export class DevtoolsClient { // ------------------------------------------------------------------------- setPanelSnapshot(snapshot: PanelRuntimeSnapshot): void { + this.requireSimulationOnly("setPanelSnapshot"); this.requireConnected(); - if (snapshot.totalPanels > BOUNDS.MAX_PANELS_PER_WINDOW) { - throw new Error( - `totalPanels ${snapshot.totalPanels} > ${BOUNDS.MAX_PANELS_PER_WINDOW}`, - ); - } - if (snapshot.topics.length > BOUNDS.MAX_TOPICS_TOTAL) { - throw new Error( - `topics ${snapshot.topics.length} > ${BOUNDS.MAX_TOPICS_TOTAL}`, - ); - } - this.panelSnapshot = snapshot; + validatePanelSnapshot(snapshot); + const detached = structuredClone(snapshot); + validatePanelSnapshot(detached); + this.panelSnapshot = detached; } getPanelSnapshot(): PanelRuntimeSnapshot | null { - return this.panelSnapshot; + this.requireHeadlessInspection("getPanelSnapshot"); + this.requireScope("debug.inspect"); + return this.panelSnapshot === null + ? null + : structuredClone(this.panelSnapshot); } // ------------------------------------------------------------------------- @@ -382,52 +959,59 @@ export class DevtoolsClient { listPlugins( generation?: Generation, ): ReturnType { - this.requireConnected(); - return this.inspection.listPlugins(this.activeScope(), generation); + this.requireHeadlessInspection("listPlugins"); + this.requireScope("debug.inspect"); + return this.inspection.listPlugins("debug.inspect", generation); } getPlugin(pluginId: string): ReturnType { - this.requireConnected(); - return this.inspection.getPlugin(this.activeScope(), pluginId); + this.requireHeadlessInspection("getPlugin"); + this.requireScope("debug.inspect"); + return this.inspection.getPlugin("debug.inspect", pluginId); } listSubscriptions( pluginId: string, ): ReturnType { - this.requireConnected(); - return this.inspection.listSubscriptions(this.activeScope(), pluginId); + this.requireHeadlessInspection("listSubscriptions"); + this.requireScope("debug.inspect"); + return this.inspection.listSubscriptions("debug.inspect", pluginId); } getBudgets( pluginId: string, gen: Generation, ): ReturnType { - this.requireConnected(); - return this.inspection.getBudgets(this.activeScope(), pluginId, gen); + this.requireHeadlessInspection("getBudgets"); + this.requireScope("debug.inspect"); + return this.inspection.getBudgets("debug.inspect", pluginId, gen); } getQueueSnapshot( pluginId: string, ): ReturnType { - this.requireConnected(); - return this.inspection.getQueueSnapshot(this.activeScope(), pluginId); + this.requireHeadlessInspection("getQueueSnapshot"); + this.requireScope("debug.inspect"); + return this.inspection.getQueueSnapshot("debug.inspect", pluginId); } getSnapshotForTerminal( terminalId: string, previewText: string, ): ReturnType { - this.requireConnected(); + this.requireHeadlessInspection("getSnapshotForTerminal"); + this.requireScope("debug.inspect"); return this.inspection.getSnapshotForTerminal( - this.activeScope(), + "debug.inspect", terminalId, previewText, ); } listHandles(pluginId: string): ReturnType { - this.requireConnected(); - return this.inspection.listHandles(this.activeScope(), pluginId); + this.requireHeadlessInspection("listHandles"); + this.requireScope("debug.inspect"); + return this.inspection.listHandles("debug.inspect", pluginId); } /** @@ -439,35 +1023,41 @@ export class DevtoolsClient { rows?: number; cols?: number; }): ReturnType { - this.requireConnected(); - return this.inspection.getGridText(this.activeScope(), options); + this.requireHeadlessInspection("getGridText"); + this.requireScope("debug.inspect"); + return this.inspection.getGridText("debug.inspect", options); } getInputRing(options?: { limit?: number; }): ReturnType { - this.requireConnected(); - return this.inspection.getInputRing(this.activeScope(), options); + this.requireHeadlessInspection("getInputRing"); + this.requireScope("debug.inspect"); + return this.inspection.getInputRing("debug.inspect", options); } getModifiers(): ReturnType { - this.requireConnected(); - return this.inspection.getModifiers(this.activeScope()); + this.requireHeadlessInspection("getModifiers"); + this.requireScope("debug.inspect"); + return this.inspection.getModifiers("debug.inspect"); } getFocus(): ReturnType { - this.requireConnected(); - return this.inspection.getFocus(this.activeScope()); + this.requireHeadlessInspection("getFocus"); + this.requireScope("debug.inspect"); + return this.inspection.getFocus("debug.inspect"); } panelSummary(): ReturnType { - this.requireConnected(); - return this.inspection.panelSummary(this.activeScope()); + this.requireHeadlessInspection("panelSummary"); + this.requireScope("debug.inspect"); + return this.inspection.panelSummary("debug.inspect"); } compatMatrixSummary(): ReturnType { - this.requireConnected(); - return this.inspection.compatMatrixSummary(this.activeScope()); + this.requireHeadlessInspection("compatMatrixSummary"); + this.requireScope("debug.inspect"); + return this.inspection.compatMatrixSummary("debug.inspect"); } // ------------------------------------------------------------------------- @@ -477,24 +1067,21 @@ export class DevtoolsClient { startTrace( opts: Parameters[1], ): ReturnType { - this.requireConnected(); - if (this.transport !== null) this.transport.verifyPeerForPrivilegedAction(); - return this.tracing.startTrace(this.activeScope(), opts); + this.requireTraceAccess("startTrace"); + return this.tracing.startTrace("debug.trace", opts); } startTraceWithFilter( opts: Parameters[1], nowMs?: number, ): ReturnType { - this.requireConnected(); - if (this.transport !== null) this.transport.verifyPeerForPrivilegedAction(); - return this.tracing.startTraceWithFilter(this.activeScope(), opts, nowMs); + this.requireTraceAccess("startTraceWithFilter"); + return this.tracing.startTraceWithFilter("debug.trace", opts, nowMs); } stopTrace(traceId: string): ReturnType { - this.requireConnected(); - if (this.transport !== null) this.transport.verifyPeerForPrivilegedAction(); - return this.tracing.stopTrace(this.activeScope(), traceId); + this.requireTraceAccess("stopTrace"); + return this.tracing.stopTrace("debug.trace", traceId); } streamEvents( @@ -502,8 +1089,8 @@ export class DevtoolsClient { batch: { maxEvents: number; maxBytes: number }, signal?: AbortSignal, ): ReturnType { - this.requireConnected(); - return this.tracing.streamEvents(this.activeScope(), types, batch, signal); + this.requireTraceAccess("streamEvents"); + return this.tracing.streamEvents("debug.trace", types, batch, signal); } streamFilteredEvents( @@ -512,10 +1099,9 @@ export class DevtoolsClient { nowMs?: number, signal?: AbortSignal, ): ReturnType { - this.requireConnected(); - if (this.transport !== null) this.transport.verifyPeerForPrivilegedAction(); + this.requireTraceAccess("streamFilteredEvents"); return this.tracing.streamFilteredEvents( - this.activeScope(), + "debug.trace", filter, batch, nowMs, @@ -527,44 +1113,45 @@ export class DevtoolsClient { traceId: string, offset: number, ): ReturnType { - this.requireConnected(); - return this.tracing.fetchTraceChunk(this.activeScope(), traceId, offset); + this.requireTraceAccess("fetchTraceChunk"); + return this.tracing.fetchTraceChunk("debug.trace", traceId, offset); } appendToTrace(traceId: string, data: string): void { - this.tracing.appendToTrace(traceId, data); + this.requireTraceAccess("appendToTrace"); + this.tracing.appendToTrace("debug.trace", traceId, data); } appendStructuredEvent( traceId: string, - event: Parameters[1], + event: Parameters[2], ): void { - this.tracing.appendStructuredEvent(traceId, event); + this.requireTraceAccess("appendStructuredEvent"); + this.tracing.appendStructuredEvent("debug.trace", traceId, event); } getTraceRetention( traceId: string, ): ReturnType { - this.requireConnected(); - return this.tracing.getRetention(traceId); + this.requireTraceAccess("getTraceRetention"); + return this.tracing.getRetention("debug.trace", traceId); } gcExpiredTraces(nowMs: number): string[] { - this.requireConnected(); - if (this.transport !== null) this.transport.verifyPeerForPrivilegedAction(); - return this.tracing.gcExpiredTraces(nowMs, this.activeScope()); + this.requireTraceAccess("gcExpiredTraces"); + return this.tracing.gcExpiredTraces(nowMs, "debug.trace"); } exportTracePreview( traceId: string, ): ReturnType { - this.requireConnected(); - return this.tracing.exportPreview(traceId, this.activeScope()); + this.requireTraceAccess("exportTracePreview"); + return this.tracing.exportPreview(traceId, "debug.trace"); } listTraces(): string[] { - this.requireConnected(); - return this.tracing.listTraces(); + this.requireTraceAccess("listTraces"); + return this.tracing.listTraces("debug.trace"); } // ------------------------------------------------------------------------- @@ -577,10 +1164,9 @@ export class DevtoolsClient { cause: string, caller?: string, ): ReturnType { - this.requireConnected(); - this.requirePeerForControl(); + this.requireControlAccess("suspendHandler"); return this.control.suspendHandler( - this.activeScope(), + "debug.control", panelId, handlerId, cause, @@ -594,10 +1180,9 @@ export class DevtoolsClient { reason: string, caller?: string, ): ReturnType { - this.requireConnected(); - this.requirePeerForControl(); + this.requireControlAccess("pauseHandler"); return this.control.pauseHandler( - this.activeScope(), + "debug.control", panelId, handlerId, reason, @@ -610,9 +1195,8 @@ export class DevtoolsClient { gen: Generation, caller?: string, ): ReturnType { - this.requireConnected(); - this.requirePeerForControl(); - return this.control.resumePlugin(this.activeScope(), panelId, gen, caller); + this.requireControlAccess("resumePlugin"); + return this.control.resumePlugin("debug.control", panelId, gen, caller); } disposeGeneration( @@ -620,10 +1204,9 @@ export class DevtoolsClient { gen: Generation, caller?: string, ): ReturnType { - this.requireConnected(); - this.requirePeerForControl(); + this.requireControlAccess("disposeGeneration"); return this.control.disposeGeneration( - this.activeScope(), + "debug.control", panelId, gen, caller, @@ -633,12 +1216,13 @@ export class DevtoolsClient { validateGeneration( gen: Generation, ): ReturnType { + this.requireControlAccess("validateGeneration"); return this.control.validateGeneration(gen); } listAuditLog(limit?: number): ReturnType { - this.requireConnected(); - return this.control.listAuditLog(this.activeScope(), limit); + this.requireControlAccess("listAuditLog"); + return this.control.listAuditLog("debug.control", limit); } // ------------------------------------------------------------------------- @@ -655,6 +1239,7 @@ export class DevtoolsClient { capabilities: Iterable, ): AutomationClient { this.requireConnected(); + this.requireSimulationOnly("automationClient"); const scopes = new Set(this.session.scopes); for (const capability of capabilities) scopes.add(capability); return new AutomationClient(this.inspectionTransport, scopes); @@ -665,9 +1250,11 @@ export class DevtoolsClient { // ------------------------------------------------------------------------- generateCompatMatrixJson(): string { - this.requireConnected(); - this.inspection.compatMatrixSummary(this.activeScope()); - return generateMatrixJson(); + this.requireScope("debug.inspect"); + this.inspection.compatMatrixSummary("debug.inspect"); + const json = generateMatrixJson(); + parseCompatMatrixJsonBounded(json); + return json; } validateFrame(raw: string): void { @@ -675,8 +1262,34 @@ export class DevtoolsClient { } withCancellation(fn: (signal: AbortSignal) => T, signal?: AbortSignal): T { - if (signal?.aborted) throw new Error("cancelled"); - return fn(signal ?? new AbortController().signal); + const controller = new AbortController(); + const externalAbort = (): void => controller.abort(); + if (signal !== undefined) { + if (signal.aborted) controller.abort(); + else signal.addEventListener("abort", externalAbort, { once: true }); + } + const cleanup = (): void => { + signal?.removeEventListener("abort", externalAbort); + }; + if (controller.signal.aborted) { + cleanup(); + throw new TransportError("TransportClosed", "cancelled"); + } + try { + const result = fn(controller.signal); + if ( + result !== null && + typeof result === "object" && + typeof (result as { then?: unknown }).then === "function" + ) { + return Promise.resolve(result).finally(cleanup) as T; + } + cleanup(); + return result; + } catch (error) { + cleanup(); + throw error; + } } /** For tests: expose underlying transport stub lengths. */ diff --git a/src/compat-matrix.ts b/src/compat-matrix.ts index ac8eb64..97eb046 100644 --- a/src/compat-matrix.ts +++ b/src/compat-matrix.ts @@ -188,8 +188,28 @@ export function checkMatrixInvariants(): void { seen.add(e.surface); if (e.corpusRel.length === 0) throw new Error(`empty corpusRel for ${e.surface}`); - if (e.corpusRel.length > 256) + if (new TextEncoder().encode(e.corpusRel).length > 256) throw new Error(`corpusRel too long for ${e.surface}`); + if ( + e.corpusRel.startsWith("/") || + e.corpusRel.includes("\\") || + e.corpusRel.split("/").some((part) => part === ".." || part === "") || + /[\u0000-\u001f\u007f]/.test(e.corpusRel) + ) { + throw new Error(`invalid corpusRel for ${e.surface}`); + } + for (const [field, value] of [ + ["category", e.category], + ["description", e.description], + ] as const) { + if ( + value.length === 0 || + new TextEncoder().encode(value).length > 256 || + /[\u0000-\u001f\u007f]/.test(value) + ) { + throw new Error(`invalid ${field} for ${e.surface}`); + } + } } const first = MATRIX[0]; if (first === undefined || first.surface !== "shell") @@ -252,18 +272,192 @@ export function generateMatrixJson(): string { return json; } +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function requireExactFields( + value: Record, + fields: readonly string[], + field: string, +): void { + for (const key of Object.keys(value)) { + if (!fields.includes(key)) + throw new Error(`${field}.${key} is not allowed`); + } + for (const key of fields) { + if (!Object.hasOwn(value, key)) { + throw new Error(`${field}.${key} is required`); + } + } +} + +function requireString( + value: Record, + key: string, + field: string, + maxBytes: number, +): string { + const result = value[key]; + if (typeof result !== "string" || result.length === 0) { + throw new Error(`${field}.${key} must be a non-empty string`); + } + if (new TextEncoder().encode(result).length > maxBytes) { + throw new Error(`${field}.${key} exceeds its byte bound`); + } + return result; +} + +function requireInteger( + value: Record, + key: string, + field: string, +): number { + const result = value[key]; + if (!Number.isSafeInteger(result) || (result as number) < 0) { + throw new Error(`${field}.${key} must be a nonnegative integer`); + } + return result as number; +} + +export function validateMatrixJsonDocument( + document: unknown, +): asserts document is MatrixJson { + if (!isRecord(document)) throw new Error("matrix must be an object"); + requireExactFields( + document, + ["version", "generated", "matrixLen", "bounds", "entries"], + "matrix", + ); + if (document["version"] !== 1 || document["matrixLen"] !== MATRIX_LEN) { + throw new Error("matrix version or length is invalid"); + } + requireString(document, "generated", "matrix", 32); + if (!isRecord(document["bounds"])) + throw new Error("matrix.bounds must be an object"); + requireExactFields( + document["bounds"], + [ + "MAX_CORPUS_BYTES", + "MAX_ACTIONS", + "MAX_SNAPSHOT_JSON_BYTES", + "GRID", + "CANONICAL_HASH_VERSION", + ], + "matrix.bounds", + ); + const expectedBounds: Record = { + MAX_CORPUS_BYTES: BOUNDS.MAX_CORPUS_BYTES, + MAX_ACTIONS: BOUNDS.MAX_ACTIONS, + MAX_SNAPSHOT_JSON_BYTES: BOUNDS.MAX_SNAPSHOT_JSON_BYTES, + CANONICAL_HASH_VERSION: 5, + }; + for (const [key, expected] of Object.entries(expectedBounds)) { + if (document["bounds"][key] !== expected) { + throw new Error(`matrix.bounds.${key} is invalid`); + } + } + if (document["bounds"]["GRID"] !== "80x24") { + throw new Error("matrix.bounds.GRID is invalid"); + } + if ( + !Array.isArray(document["entries"]) || + document["entries"].length !== MATRIX_LEN + ) { + throw new Error("matrix entries must be 14"); + } + const seen = new Set(); + for (const [index, value] of document["entries"].entries()) { + if (!isRecord(value)) + throw new Error(`matrix.entries[${index}] must be an object`); + const field = `matrix.entries[${index}]`; + requireExactFields( + value, + [ + "surface", + "category", + "corpusRel", + "description", + "bytesLen", + "actionsLen", + "stateHash", + "width", + "height", + "generation", + "self", + "references", + ], + field, + ); + const surface = requireString(value, "surface", field, 64); + if ( + !MATRIX.some((entry) => entry.surface === surface) || + seen.has(surface) || + surface !== MATRIX[index]?.surface + ) { + throw new Error(`${field}.surface is invalid`); + } + seen.add(surface); + for (const key of ["category", "corpusRel", "description"] as const) { + const text = requireString(value, key, field, 256); + if (/[\u0000-\u001f\u007f]/.test(text)) { + throw new Error(`${field}.${key} contains control characters`); + } + } + const corpus = value["corpusRel"]; + if ( + typeof corpus !== "string" || + corpus.startsWith("/") || + corpus.includes("\\") || + corpus.split("/").some((part) => part === ".." || part === "") + ) { + throw new Error(`${field}.corpusRel is invalid`); + } + const bytesLen = requireInteger(value, "bytesLen", field); + const actionsLen = requireInteger(value, "actionsLen", field); + if (bytesLen < 1 || actionsLen < 1) { + throw new Error(`${field} contains an empty matrix entry`); + } + for (const key of ["width", "height", "generation"] as const) { + const valueNumber = requireInteger(value, key, field); + if (valueNumber < 1) throw new Error(`${field}.${key} must be positive`); + } + if (bytesLen > BOUNDS.MAX_CORPUS_BYTES || actionsLen > BOUNDS.MAX_ACTIONS) { + throw new Error(`${field} exceeds matrix bounds`); + } + const stateHash = requireString(value, "stateHash", field, 16); + if (!/^[0-9a-f]{16}$/.test(stateHash)) { + throw new Error(`${field}.stateHash is invalid`); + } + if (value["self"] !== "PASS") throw new Error(`${field}.self is invalid`); + if (!isRecord(value["references"])) { + throw new Error(`${field}.references must be an object`); + } + requireExactFields( + value["references"], + ["ghostty", "kitty", "wezterm", "alacritty"], + `${field}.references`, + ); + for (const key of ["ghostty", "kitty", "wezterm", "alacritty"] as const) { + if (value["references"][key] !== "SKIP") { + throw new Error(`${field}.references.${key} is invalid`); + } + } + } + return; +} + export function parseMatrixJsonBounded(raw: string): MatrixJson { const bytes = new TextEncoder().encode(raw).length; - if (bytes > 16 * 1024) throw new Error(`matrix json ${bytes} > 16 KiB`); + if (bytes > BOUNDS.MAX_SNAPSHOT_JSON_BYTES) { + throw new Error(`matrix json ${bytes} > 16 KiB`); + } let parsed: unknown; try { parsed = JSON.parse(raw); } catch { throw new Error("invalid matrix json"); } - const doc = parsed as MatrixJson; - if (doc.version !== 1) throw new Error("matrix version must be 1"); - if (doc.entries.length !== MATRIX_LEN) - throw new Error("matrix entries must be 14"); - return doc; + validateMatrixJsonDocument(parsed); + return parsed; } diff --git a/src/control.ts b/src/control.ts index 519af26..6968280 100644 --- a/src/control.ts +++ b/src/control.ts @@ -3,8 +3,9 @@ * * Provides suspend/resume/dispose for diagnosis plus phase 2 advanced control * surfaces: per-generation ownership checks, generation exhaustion guard, - * transactional audit log, reactivation requirements, and peer-creds re-verification - * hooks for live runtime. Each invocation is audited with caller identity and + * transactional audit log, reactivation requirements, and peer-value + * re-verification hooks for the headless transport fixture. Each invocation is + * audited with caller identity and * affects only the owning (PanelId, generation). Cannot bypass a capability or * budget gate, never widens sibling authority, uses transactional fail-closed * semantics. Host-side budget/capability gates remain authoritative; this client diff --git a/src/index.ts b/src/index.ts index 110bcd7..795a92e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,8 +7,8 @@ * Keep bounded, forbid unsafe (TypeScript strict, no any/unsafe), scope-checked. * * Phase 2 adds: advanced tracing with filtering/retention/GC, control surfaces - * with audit log and generation guards, and real IPC socket/pipe peer-creds - * integration against the live Bitty runtime via transport/auth modules. + * with audit log and generation guards, a bounded transport fixture, and a + * Linux-only endpoint-attested live inspection path. */ export * from "./bounds.js"; @@ -21,7 +21,6 @@ export * from "./transport.js"; export * from "./queue.js"; export * from "./campaign.js"; export * from "./inspection.js"; -export * from "./tracing.js"; export * from "./control.js"; export * from "./automation.js"; export * from "./client.js"; diff --git a/src/inspection.ts b/src/inspection.ts index 11a0fe4..6ae615a 100644 --- a/src/inspection.ts +++ b/src/inspection.ts @@ -20,7 +20,7 @@ import { MATRIX, REFERENCE_TERMS, } from "./compat-matrix.js"; -import { PROTOCOL_VERSION } from "./protocol.js"; +import { decodeResponse, PROTOCOL_VERSION } from "./protocol.js"; import type { IpcRequest, IpcResponse } from "./transport.js"; export type PluginState = @@ -273,11 +273,15 @@ function requireNonEmptyString( record: Record, key: string, field: string, + maxBytes = 256, ): string { const value = requireString(record, key, field); if (value.length === 0) { throw parseError(`${field}.${key}`, "must not be empty"); } + if (new TextEncoder().encode(value).length > maxBytes) { + throw parseError(`${field}.${key}`, "exceeds byte bound"); + } return value; } @@ -311,7 +315,7 @@ function requireGeneration( field: string, ): Generation { const value = record[key]; - if (typeof value !== "number" || !Number.isInteger(value) || value < 1) { + if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 1) { throw parseError(`${field}.${key}`, "expected a positive integer"); } return generation(value); @@ -339,10 +343,21 @@ function requireStringArray( record: Record, key: string, field: string, + maxItems = 256, + maxBytes = 256, ): string[] { const value = record[key]; - if (!Array.isArray(value) || !value.every((c) => typeof c === "string")) { - throw parseError(`${field}.${key}`, "expected an array of strings"); + if (!Array.isArray(value) || value.length > maxItems) { + throw parseError(`${field}.${key}`, "expected a bounded string array"); + } + for (const [index, item] of value.entries()) { + if ( + typeof item !== "string" || + item.length === 0 || + new TextEncoder().encode(item).length > maxBytes + ) { + throw parseError(`${field}.${key}[${index}]`, "invalid string item"); + } } return value as string[]; } @@ -618,16 +633,132 @@ function focusSnapshotFrom( function pluginSummaryFrom(value: unknown, field = "plugin"): PluginSummary { const r = requireRecord(value, field); + requireOnlyKeys( + r, + ["id", "version", "generation", "state", "manifestHash", "capabilities"], + field, + ); return { - id: requireNonEmptyString(r, "id", field), - version: requireNonEmptyString(r, "version", field), + id: requireNonEmptyString(r, "id", field, 256), + version: requireNonEmptyString(r, "version", field, 128), generation: requireGeneration(r, "generation", field), state: requirePluginState(r, "state", field), - manifestHash: requireNonEmptyString(r, "manifestHash", field), - capabilities: requireStringArray(r, "capabilities", field), + manifestHash: requireNonEmptyString(r, "manifestHash", field, 256), + capabilities: requireStringArray(r, "capabilities", field, 256, 128), }; } +function decodeTransportResponse(response: IpcResponse): IpcResponse { + let encoded: string | undefined; + try { + encoded = JSON.stringify(response); + } catch { + encoded = undefined; + } + if (encoded === undefined) { + throw new InspectionError("InvalidResult", "response is not serializable"); + } + try { + return decodeResponse(encoded) as IpcResponse; + } catch (error) { + throw new InspectionError( + "InvalidResult", + error instanceof Error ? error.message : "invalid response envelope", + ); + } +} + +export function parseInspectionResult( + method: string, + params: Record, + result: unknown, +): unknown { + const response: IpcResponse = { + jsonrpc: "2.0", + id: 1, + result, + version: PROTOCOL_VERSION, + }; + const transport: InspectionTransport = { + isConnected: () => true, + request: () => response, + }; + const client = new InspectionClient(transport); + const stringParam = (key: string): string => { + const value = params[key]; + if (typeof value !== "string") { + throw new InspectionError("InvalidParams", `${key} must be a string`); + } + return value; + }; + switch (method) { + case "bitty.debug/listPlugins": { + const generation = params["generation"]; + if (generation !== undefined && generation !== null) { + if ( + typeof generation !== "number" || + !Number.isSafeInteger(generation) || + generation < 1 + ) { + throw new InspectionError("InvalidParams", "generation is invalid"); + } + return client.listPlugins("debug.inspect", generation as Generation); + } + return client.listPlugins("debug.inspect"); + } + case "bitty.debug/getPlugin": + return client.getPlugin("debug.inspect", stringParam("pluginId")); + case "bitty.debug/listSubscriptions": + return client.listSubscriptions("debug.inspect", stringParam("pluginId")); + case "bitty.debug/getBudgets": { + const generation = params["generation"]; + if ( + typeof generation !== "number" || + !Number.isSafeInteger(generation) || + generation < 1 + ) { + throw new InspectionError("InvalidParams", "generation is invalid"); + } + return client.getBudgets( + "debug.inspect", + stringParam("pluginId"), + generation as Generation, + ); + } + case "bitty.debug/getQueueSnapshot": + return client.getQueueSnapshot("debug.inspect", stringParam("pluginId")); + case "bitty.debug/getSnapshot": + return client.getSnapshotForTerminal( + "debug.inspect", + stringParam("terminalId"), + typeof params["previewText"] === "string" ? params["previewText"] : "", + ); + case "bitty.debug/listHandles": + return client.listHandles("debug.inspect", stringParam("pluginId")); + case "bitty.debug/getGridText": { + const options: { rows?: number; cols?: number } = {}; + if (params["rows"] !== undefined) options.rows = params["rows"] as number; + if (params["cols"] !== undefined) options.cols = params["cols"] as number; + return client.getGridText("debug.inspect", options); + } + case "bitty.debug/getInputRing": { + const options: { limit?: number } = {}; + if (params["limit"] !== undefined) + options.limit = params["limit"] as number; + return client.getInputRing("debug.inspect", options); + } + case "bitty.debug/getModifiers": + return client.getModifiers("debug.inspect"); + case "bitty.debug/getFocus": + return client.getFocus("debug.inspect"); + default: + throw new InspectionError( + "InvalidResult", + `unsupported method ${method}`, + ); + } +} + export class InspectionClient { private nextRequestId = 1; @@ -654,9 +785,11 @@ export class InspectionClient { } const id = this.nextRequestId; this.nextRequestId += 1; - const response = transport.request( - { id, method, params, version: PROTOCOL_VERSION }, - Date.now(), + const response = decodeTransportResponse( + transport.request( + { id, method, params, version: PROTOCOL_VERSION }, + Date.now(), + ), ); if (response.error !== undefined) { throw new InspectionError( @@ -682,6 +815,7 @@ export class InspectionClient { generation: generationFilter ?? null, }); const envelope = requireRecord(result, "listPlugins result"); + requireOnlyKeys(envelope, ["plugins"], "listPlugins result"); const list = requireArray( envelope["plugins"], "listPlugins result.plugins", @@ -715,7 +849,7 @@ export class InspectionClient { } if (this.isLive()) { const result = this.rpc("bitty.debug/getPlugin", { pluginId }); - if (result === null || result === undefined) return null; + if (result === null) return null; return pluginSummaryFrom(result, "getPlugin result"); } const snap = this.snapshot(); @@ -734,8 +868,8 @@ export class InspectionClient { listSubscriptions(scope: string, pluginId: string): SubscriptionInfo[] { this.requireInspect(scope); - if (pluginId.length > 128) - throw new InspectionError("InvalidPluginId", "pluginId too long"); + if (pluginId.length === 0 || pluginId.length > 128) + throw new InspectionError("InvalidPluginId", "pluginId must be 1..128"); if (this.isLive()) { const result = this.rpc("bitty.debug/listSubscriptions", { pluginId }); const list = requireArray(result, "listSubscriptions result"); @@ -743,15 +877,20 @@ export class InspectionClient { return list.slice(0, MAX_SUBSCRIPTIONS).map((entry, i) => { const field = `listSubscriptions result[${i}]`; const r = requireRecord(entry, field); - const policy = requireNonEmptyString(r, "policy", field); + requireOnlyKeys( + r, + ["eventType", "queueDepth", "queuedBytes", "dropCount", "policy"], + field, + ); + const policy = requireNonEmptyString(r, "policy", field, 32); if (policy !== "DropOldest" && policy !== "DropNewest") { throw parseError(`${field}.policy`, `unknown policy ${policy}`); } return { - eventType: requireNonEmptyString(r, "eventType", field), - queueDepth: requireNumber(r, "queueDepth", field), - queuedBytes: requireNumber(r, "queuedBytes", field), - dropCount: requireNumber(r, "dropCount", field), + eventType: requireNonEmptyString(r, "eventType", field, 64), + queueDepth: requireUnsignedInt(r, "queueDepth", field), + queuedBytes: requireUnsignedInt(r, "queuedBytes", field), + dropCount: requireUnsignedInt(r, "dropCount", field), policy, }; }); @@ -781,8 +920,8 @@ export class InspectionClient { getBudgets(scope: string, pluginId: string, gen: Generation): BudgetSnapshot { this.requireInspect(scope); - if (pluginId.length > 128) - throw new InspectionError("InvalidPluginId", "pluginId too long"); + if (pluginId.length === 0 || pluginId.length > 128) + throw new InspectionError("InvalidPluginId", "pluginId must be 1..128"); if (this.isLive()) { const result = this.rpc("bitty.debug/getBudgets", { pluginId, @@ -790,21 +929,36 @@ export class InspectionClient { }); const field = "getBudgets result"; const r = requireRecord(result, field); + const wouldExceedLuaLimits = requireBoolean( + r, + "would_exceed_lua_limits", + field, + ); + requireOnlyKeys( + r, + [ + "generation", + "rc1Instructions", + "rc1WallMs", + "rc2MemoryBytes", + "rc4Tasks", + "rc4Timers", + "rc5QueueDepth", + "would_exceed_lua_limits", + ], + field, + ); return { pluginId, generation: requireGeneration(r, "generation", field), - rc1Instructions: requireNumber(r, "rc1Instructions", field), - rc1WallMs: requireNumber(r, "rc1WallMs", field), - rc2MemoryBytes: requireNumber(r, "rc2MemoryBytes", field), - rc4Tasks: requireNumber(r, "rc4Tasks", field), - rc4Timers: requireNumber(r, "rc4Timers", field), - rc5QueueDepth: requireNumber(r, "rc5QueueDepth", field), + rc1Instructions: requireUnsignedInt(r, "rc1Instructions", field), + rc1WallMs: requireUnsignedInt(r, "rc1WallMs", field), + rc2MemoryBytes: requireUnsignedInt(r, "rc2MemoryBytes", field), + rc4Tasks: requireUnsignedInt(r, "rc4Tasks", field), + rc4Timers: requireUnsignedInt(r, "rc4Timers", field), + rc5QueueDepth: requireUnsignedInt(r, "rc5QueueDepth", field), // RFC v1 verdict spelling is snake_case (devtools-rfc:341). - wouldExceedLuaLimits: requireBoolean( - r, - "would_exceed_lua_limits", - field, - ), + wouldExceedLuaLimits, }; } return { @@ -822,62 +976,98 @@ export class InspectionClient { getQueueSnapshot(scope: string, pluginId: string): QueueSnapshot { this.requireInspect(scope); - if (pluginId.length > 128) - throw new InspectionError("InvalidPluginId", "pluginId too long"); + if (pluginId.length === 0 || pluginId.length > 128) + throw new InspectionError("InvalidPluginId", "pluginId must be 1..128"); if (this.isLive()) { const result = this.rpc("bitty.debug/getQueueSnapshot", { pluginId }); const field = "getQueueSnapshot result"; const r = requireRecord(result, field); + const invariantQueueBounds = requireBoolean( + r, + "invariant_queue_bounds", + field, + ); + const invariantGlobalBounds = requireBoolean( + r, + "invariant_global_bounds", + field, + ); + requireOnlyKeys( + r, + [ + "perSubscription", + "perPlugin", + "global", + "invariant_queue_bounds", + "invariant_global_bounds", + ], + field, + ); const perSubscription = requireRecord( r["perSubscription"], `${field}.perSubscription`, ); + requireOnlyKeys( + perSubscription, + ["limit", "current"], + `${field}.perSubscription`, + ); const perPlugin = requireRecord(r["perPlugin"], `${field}.perPlugin`); + requireOnlyKeys( + perPlugin, + ["events", "bytes", "limitEvents", "limitBytes"], + `${field}.perPlugin`, + ); const global = requireRecord(r["global"], `${field}.global`); + requireOnlyKeys( + global, + ["events", "bytes", "limitEvents", "limitBytes"], + `${field}.global`, + ); return { perSubscription: { - limit: requireNumber( + limit: requireUnsignedInt( perSubscription, "limit", `${field}.perSubscription`, ), - current: requireNumber( + current: requireUnsignedInt( perSubscription, "current", `${field}.perSubscription`, ), }, perPlugin: { - events: requireNumber(perPlugin, "events", `${field}.perPlugin`), - bytes: requireNumber(perPlugin, "bytes", `${field}.perPlugin`), - limitEvents: requireNumber( + events: requireUnsignedInt(perPlugin, "events", `${field}.perPlugin`), + bytes: requireUnsignedInt(perPlugin, "bytes", `${field}.perPlugin`), + limitEvents: requireUnsignedInt( perPlugin, "limitEvents", `${field}.perPlugin`, ), - limitBytes: requireNumber( + limitBytes: requireUnsignedInt( perPlugin, "limitBytes", `${field}.perPlugin`, ), }, global: { - events: requireNumber(global, "events", `${field}.global`), - bytes: requireNumber(global, "bytes", `${field}.global`), - limitEvents: requireNumber(global, "limitEvents", `${field}.global`), - limitBytes: requireNumber(global, "limitBytes", `${field}.global`), + events: requireUnsignedInt(global, "events", `${field}.global`), + bytes: requireUnsignedInt(global, "bytes", `${field}.global`), + limitEvents: requireUnsignedInt( + global, + "limitEvents", + `${field}.global`, + ), + limitBytes: requireUnsignedInt( + global, + "limitBytes", + `${field}.global`, + ), }, // RFC v1 verdict spelling is snake_case (devtools-rfc:340). - invariantQueueBounds: requireBoolean( - r, - "invariant_queue_bounds", - field, - ), - invariantGlobalBounds: requireBoolean( - r, - "invariant_global_bounds", - field, - ), + invariantQueueBounds, + invariantGlobalBounds, }; } return { @@ -930,20 +1120,26 @@ export class InspectionClient { "bitty.debug/getSnapshot returned the runtime-stats snapshot, not the RFC semantic snapshot; bitty must implement a semantic getSnapshot or expose a distinct method", ); } + requireOnlyKeys( + r, + ["cursor", "modeFlags", "semanticZoneCount", "preview"], + field, + ); const serverPreview = requireString(r, "preview", field); const cursor = requireRecord(r["cursor"], `${field}.cursor`); - const modeFlags = requireStringArray(r, "modeFlags", field); + requireOnlyKeys(cursor, ["row", "col"], `${field}.cursor`); + const modeFlags = requireStringArray(r, "modeFlags", field, 64, 64); const bounded = serverPreview.slice(0, MAX_PREVIEW_CHARS); const { text, marker } = redactPreview(bounded, "terminal.preview"); return { terminalId, scope: "semantic", cursor: { - row: requireNumber(cursor, "row", `${field}.cursor`), - col: requireNumber(cursor, "col", `${field}.cursor`), + row: requireUnsignedInt(cursor, "row", `${field}.cursor`), + col: requireUnsignedInt(cursor, "col", `${field}.cursor`), }, modeFlags, - semanticZoneCount: requireNumber(r, "semanticZoneCount", field), + semanticZoneCount: requireUnsignedInt(r, "semanticZoneCount", field), preview: text, redactionMarker: { redacted: marker.redacted, @@ -971,8 +1167,8 @@ export class InspectionClient { listHandles(scope: string, pluginId: string): HandleInfo[] { this.requireInspect(scope); - if (pluginId.length > 128) - throw new InspectionError("InvalidPluginId", "pluginId too long"); + if (pluginId.length === 0 || pluginId.length > 128) + throw new InspectionError("InvalidPluginId", "pluginId must be 1..128"); if (this.isLive()) { const result = this.rpc("bitty.debug/listHandles", { pluginId }); const list = requireArray(result, "listHandles result"); @@ -980,10 +1176,11 @@ export class InspectionClient { return list.slice(0, MAX_HANDLES).map((entry, i) => { const field = `listHandles result[${i}]`; const r = requireRecord(entry, field); + requireOnlyKeys(r, ["handle", "capability", "refCount"], field); return { - handle: requireNonEmptyString(r, "handle", field), - capability: requireNonEmptyString(r, "capability", field), - refCount: requireNumber(r, "refCount", field), + handle: requireNonEmptyString(r, "handle", field, 256), + capability: requireNonEmptyString(r, "capability", field, 128), + refCount: requireUnsignedInt(r, "refCount", field), }; }); } diff --git a/src/ipc-socket.ts b/src/ipc-socket.ts index cd41420..0e80621 100644 --- a/src/ipc-socket.ts +++ b/src/ipc-socket.ts @@ -1,5 +1,5 @@ /** - * Live Unix IPC socket seam for DevTools (CTX-0036, H-DEV-06). + * Linux-only live Unix IPC socket seam for DevTools (CTX-0036, H-DEV-06). * * `IpcTransport` in `transport.ts` is a headless stub: `connect()` verifies * caller-supplied mode values and flips a flag without ever dialing the OS @@ -17,14 +17,19 @@ * mode `0600` and owned by the runtime UID. Anything else refuses to dial. * - Responses are untrusted observation data: bounded at 256 KiB, framed * exactly once, and returned as raw bytes for the caller to decode. - * - No TCP is involved; on non-Unix platforms `isLiveSocketSupported()` - * reports false and `connectLiveSocket()` refuses (Windows named-pipe - * dialing stays with CTX-0043 and is not duplicated here). + * - No TCP is involved. Linux endpoint attestation is the only implemented + * live adapter; + * `isLiveSocketSupported()` reports false and `connectLiveSocket()` refuses + * on Windows and macOS rather than implying an unverified live adapter. */ -import { DIR_MODE, SOCKET_MODE, resolveSocketPath } from "./auth.js"; import { - Frame, + DIR_MODE, + MAX_SOCKET_PATH_BYTES, + SOCKET_MODE, + resolveSocketPath, +} from "./auth.js"; +import { Framer, MAX_FRAME_BYTES, TransportError, @@ -32,11 +37,27 @@ import { } from "./transport.js"; export const LIVE_SOCKET_TIMEOUT_MS = 5_000 as const; +export const LIVE_SOCKET_MAX_TIMEOUT_MS = 60_000 as const; export const LIVE_SOCKET_MAX_PENDING_FRAMES = 64 as const; +export const LIVE_SOCKET_SUPPORTED_PLATFORM = "linux" as const; + +type LiveSocketPlatform = typeof LIVE_SOCKET_SUPPORTED_PLATFORM | "unsupported"; + +export type LiveSocketIdentity = { + kind: "endpoint-attested"; + runtimeUid: number; + peer: null; + authenticated: false; +}; + +export type SymlinkProbe = (path: string) => { + isSymbolicLink(): boolean; +}; + export type LiveSocketConfig = { - /** Resolved socket path, or advisory discovery inputs. */ + /** Resolved socket path, or environment-selected endpoint inputs. */ socketPath?: string; runtimeUid: number; xdgRuntimeDir?: string; @@ -44,6 +65,7 @@ export type LiveSocketConfig = { instanceId?: string; /** Per-dial and per-response timeout. */ timeoutMs?: number; + lstatSync?: SymlinkProbe; }; type BunSocketHandle = { @@ -72,29 +94,28 @@ type UnixStat = { isSocket(): boolean; }; -/** - * Non-following symlink probe. `Bun.file().stat()` follows symlinks, so its - * `isSymbolicLink()` can never fire on the attested path; use `node:fs` - * `lstatSync` (same `getBuiltinModule` pattern as `auth.ts`, no new deps). - * Returns false when lstat is unavailable rather than failing closed here: - * the mode/uid/socket-kind checks below still gate the dial. - */ -function isSymlinkNoFollow(path: string): boolean { +function isSymlinkNoFollow(path: string, probe?: SymlinkProbe): boolean | null { + if (probe !== undefined) { + try { + return probe(path).isSymbolicLink(); + } catch { + return null; + } + } const proc = globalThis as unknown as { process?: { getBuiltinModule?: (id: string) => unknown }; }; const getBuiltin = proc.process?.getBuiltinModule; - if (typeof getBuiltin !== "function") return false; + if (typeof getBuiltin !== "function") return null; try { const fs = getBuiltin.call(proc.process, "node:fs") as { lstatSync?: (p: string) => { isSymbolicLink?: () => boolean }; }; const stat = fs.lstatSync?.(path); - return typeof stat?.isSymbolicLink === "function" - ? stat.isSymbolicLink() - : false; + if (typeof stat?.isSymbolicLink !== "function") return null; + return stat.isSymbolicLink(); } catch { - return false; + return null; } } @@ -110,11 +131,27 @@ function resolveBun(): BunRuntime | null { return bun; } -/** True on Unix runtimes where `Bun.connect({ unix })` can dial. */ +export function liveSocketPlatform(): LiveSocketPlatform { + const proc = globalThis as { process?: { platform?: string } }; + return proc.process?.platform === LIVE_SOCKET_SUPPORTED_PLATFORM + ? LIVE_SOCKET_SUPPORTED_PLATFORM + : "unsupported"; +} + export function isLiveSocketSupported(): boolean { - if (resolveBun() === null) return false; + return ( + resolveBun() !== null && + liveSocketPlatform() === LIVE_SOCKET_SUPPORTED_PLATFORM + ); +} + +function unsupportedPlatformError(): TransportError { const proc = globalThis as { process?: { platform?: string } }; - return proc.process?.platform !== "win32"; + const platform = proc.process?.platform ?? "unknown"; + return new TransportError( + "TransportClosed", + `live Unix socket transport is unsupported on ${platform}; Linux endpoint attestation is the only implemented live adapter`, + ); } export type LiveSocketEndpoint = { @@ -122,13 +159,38 @@ export type LiveSocketEndpoint = { runtimeUid: number; }; +function assertBoundedSocketPath(socketPath: string): void { + if ( + socketPath.length === 0 || + !socketPath.startsWith("/") || + socketPath.includes("\0") || + socketPath.includes("\\") || + socketPath + .split("/") + .slice(1) + .some((part) => part.length === 0 || part === "." || part === "..") || + new TextEncoder().encode(socketPath).length > MAX_SOCKET_PATH_BYTES + ) { + throw new TransportError( + "Unauthenticated", + "live socket path must be an absolute bounded AF_UNIX path", + ); + } +} + /** - * Resolve the dial target: explicit path wins, otherwise the advisory - * `BITTY_SOCKET` / `XDG_RUNTIME_DIR` / instance discovery from `auth.ts`. + * Resolve the dial target: explicit path wins, otherwise + * `BITTY_SOCKET` / `XDG_RUNTIME_DIR` / instance selection from `auth.ts`. */ export function resolveLiveSocketEndpoint( config: LiveSocketConfig, ): LiveSocketEndpoint { + if (!Number.isSafeInteger(config.runtimeUid) || config.runtimeUid < 0) { + throw new TransportError( + "Unauthenticated", + "runtimeUid must be a nonnegative safe integer", + ); + } const socketPath = config.socketPath ?? resolveSocketPath({ @@ -137,6 +199,7 @@ export function resolveLiveSocketEndpoint( bittySocket: config.bittySocket, instanceId: config.instanceId, }); + assertBoundedSocketPath(socketPath); return { socketPath, runtimeUid: config.runtimeUid }; } @@ -145,6 +208,17 @@ function parentDirOf(path: string): string { return index <= 0 ? "/" : path.slice(0, index); } +function pathAncestors(path: string): string[] { + const parts = path.split("/").filter((part) => part.length > 0); + const ancestors: string[] = []; + let current = ""; + for (const part of parts) { + current += `/${part}`; + ancestors.push(current); + } + return ancestors; +} + function leafOf(path: string): string { const index = path.lastIndexOf("/"); return index < 0 ? path : path.slice(index + 1); @@ -158,7 +232,12 @@ function leafOf(path: string): string { */ export async function attestLiveSocketEndpoint( endpoint: LiveSocketEndpoint, + lstatSync?: SymlinkProbe, ): Promise { + if (liveSocketPlatform() !== LIVE_SOCKET_SUPPORTED_PLATFORM) { + throw unsupportedPlatformError(); + } + assertBoundedSocketPath(endpoint.socketPath); const bun = resolveBun(); if (bun === null) { throw new TransportError("TransportClosed", "no Bun runtime for IPC dial"); @@ -187,18 +266,30 @@ export async function attestLiveSocketEndpoint( if (sockStat === null) { throw fail(`socket '${endpoint.socketPath}' does not exist`); } - // `Bun.file().stat()` follows symlinks, so probe link-ness separately - // with a non-following lstat: a symlink at either path refuses to dial. - if (isSymlinkNoFollow(parent)) { - throw fail(`socket directory '${parent}' is a symlink (refusing to dial)`); + for (const component of pathAncestors(parent)) { + const componentSymlink = isSymlinkNoFollow(component, lstatSync); + if (componentSymlink === null) { + throw fail("endpoint symlink state could not be verified"); + } + if (componentSymlink) { + throw fail( + `socket path component '${component}' is a symlink (refusing to dial)`, + ); + } + } + const socketSymlink = isSymlinkNoFollow(endpoint.socketPath, lstatSync); + if (socketSymlink === null) { + throw fail("endpoint symlink state could not be verified"); } - if (isSymlinkNoFollow(endpoint.socketPath)) { + if (socketSymlink) { throw fail( `socket '${endpoint.socketPath}' is a symlink (refusing to dial)`, ); } - if (typeof sockStat.isSocket === "function" && !sockStat.isSocket()) { - throw fail(`'${endpoint.socketPath}' is not a socket`); + if (typeof sockStat.isSocket !== "function" || !sockStat.isSocket()) { + throw fail( + `'${endpoint.socketPath}' is not a socket or its type is unverifiable`, + ); } if ((dirStat.mode & 0o777) !== DIR_MODE) { throw fail( @@ -225,14 +316,20 @@ export async function attestLiveSocketEndpoint( export type LiveSocketConnection = { /** The attested path this connection dialed. */ socketPath: string; + /** Identity is explicit: endpoint attestation is not peer authentication. */ + identity: LiveSocketIdentity; /** True once the OS socket is open. */ isOpen(): boolean; /** - * Write one framed request and resolve with the next framed response - * payload (raw bytes, still to be JSON-decoded by the caller). Bounded at - * one 256 KiB frame each way, matching the `bitty-ipc` framing. + * Write one framed request and resolve with the response carrying the same + * request id. The optional id is used when the payload cannot be inspected. */ - requestResponse(requestJson: Uint8Array, nowMs: number): Promise; + requestResponse( + requestJson: Uint8Array, + nowMs: number, + requestId?: number, + signal?: AbortSignal, + ): Promise; close(): void; }; @@ -241,190 +338,414 @@ export type LiveSocketConnection = { * opens one `AF_UNIX` connection. The caller owns the connection and must * `close()` it. */ +function requestIdFromPayload(payload: Uint8Array): number { + let text: string; + try { + text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode( + payload, + ); + } catch { + throw new TransportError("InvalidFrame", "request payload is not UTF-8"); + } + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + throw new TransportError("InvalidFrame", "request payload is not JSON"); + } + if ( + parsed === null || + typeof parsed !== "object" || + Array.isArray(parsed) || + typeof (parsed as { id?: unknown }).id !== "number" || + !Number.isSafeInteger((parsed as { id: number }).id) || + (parsed as { id: number }).id < 0 + ) { + throw new TransportError("InvalidFrame", "request payload has no safe id"); + } + return (parsed as { id: number }).id; +} + +function responseIdFromPayload(payload: Uint8Array): number | null { + let text: string; + try { + text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode( + payload, + ); + } catch { + return null; + } + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + return null; + } + if ( + parsed === null || + typeof parsed !== "object" || + Array.isArray(parsed) || + typeof (parsed as { id?: unknown }).id !== "number" || + !Number.isSafeInteger((parsed as { id: number }).id) || + (parsed as { id: number }).id < 0 + ) { + return null; + } + return (parsed as { id: number }).id; +} + +function closeSocket(socket: BunSocketHandle): void { + try { + socket.end(); + } catch { + try { + socket.close(); + } catch { + return; + } + } +} + export async function connectLiveSocket( config: LiveSocketConfig, ): Promise { + if (liveSocketPlatform() !== LIVE_SOCKET_SUPPORTED_PLATFORM) { + throw unsupportedPlatformError(); + } const bun = resolveBun(); if (bun === null) { throw new TransportError("TransportClosed", "no Bun runtime for IPC dial"); } - const proc = globalThis as { process?: { platform?: string } }; - if (proc.process?.platform === "win32") { + const endpoint = resolveLiveSocketEndpoint(config); + await attestLiveSocketEndpoint(endpoint, config.lstatSync); + const timeoutMs = config.timeoutMs ?? LIVE_SOCKET_TIMEOUT_MS; + if ( + !Number.isSafeInteger(timeoutMs) || + timeoutMs <= 0 || + timeoutMs > LIVE_SOCKET_MAX_TIMEOUT_MS + ) { throw new TransportError( "TransportClosed", - "live Unix socket dial is unsupported on win32 (named pipe only)", + `timeoutMs must be in 1..${LIVE_SOCKET_MAX_TIMEOUT_MS}`, ); } - const endpoint = resolveLiveSocketEndpoint(config); - await attestLiveSocketEndpoint(endpoint); - const timeoutMs = config.timeoutMs ?? LIVE_SOCKET_TIMEOUT_MS; - let handle: BunSocketHandle | null = null; - let dialError: unknown = null; - // Retained inbound bytes for the framed response stream. The handler is - // registered up front at dial time: Bun dispatches to the handler captured - // at `connect`, so swapping it later would silently drop the response. + let socket: BunSocketHandle | null = null; + let dialTimer: ReturnType | null = null; + let dialSettled = false; + let resolveDial!: (handle: BunSocketHandle) => void; + let rejectDial!: (error: unknown) => void; + const opened = new Promise((resolve, reject) => { + resolveDial = resolve; + rejectDial = reject; + }); + const failDial = (error: TransportError): void => { + if (dialSettled) return; + dialSettled = true; + if (dialTimer !== null) clearTimeout(dialTimer); + if (socket !== null) closeSocket(socket); + rejectDial(error); + }; + dialTimer = setTimeout(() => { + failDial( + new TransportError( + "TransportClosed", + `dial '${endpoint.socketPath}' timed out after ${timeoutMs}ms`, + ), + ); + }, timeoutMs); + const inbound = new Framer(); - let framingFailed = false; - let framingError: unknown = null; - const pending: Frame[] = []; - const failFraming = (error: unknown): void => { - framingFailed = true; - framingError = error; + const pending = new Map< + number, + { + resolve: (value: Uint8Array) => void; + reject: (error: unknown) => void; + timer: ReturnType; + detach: () => void; + } + >(); + const pendingOrder: number[] = []; + const retained: Array<{ id: number | null; payload: Uint8Array }> = []; + let open = false; + let terminalError: TransportError | null = null; + let nextFallbackId = 0; + const removePending = (id: number): void => { + pending.delete(id); + const index = pendingOrder.indexOf(id); + if (index >= 0) pendingOrder.splice(index, 1); + }; + const failConnection = (error: TransportError, remember = false): void => { + if (remember) terminalError = error; + if (!open && pending.size === 0 && retained.length === 0) return; + open = false; inbound.clear(); - pending.length = 0; - const failed = responseFailed; - responseSettled = null; - responseFailed = null; - failed?.(error); + const waiters = [...pending.values()]; + pending.clear(); + pendingOrder.length = 0; + retained.length = 0; + for (const waiter of waiters) { + clearTimeout(waiter.timer); + waiter.detach(); + waiter.reject(error); + } + if (socket !== null) closeSocket(socket); }; - let responseSettled: ((value: Uint8Array) => void) | null = null; - let responseFailed: ((error: unknown) => void) | null = null; - const opened = new Promise((resolve, reject) => { - const timer = setTimeout(() => { - reject( + const onData = (_socket: BunSocketHandle, data: Uint8Array): void => { + if (!open) return; + try { + for (const frame of inbound.pushBytes(new Uint8Array(data))) { + const id = responseIdFromPayload(frame.payload); + let waiter = id === null ? undefined : pending.get(id); + if (waiter === undefined && id === null && pendingOrder.length > 0) { + const oldest = pendingOrder[0]; + waiter = oldest === undefined ? undefined : pending.get(oldest); + } + if (waiter !== undefined) { + const key = id ?? pendingOrder[0]; + if (key !== undefined) removePending(key); + waiter.resolve(frame.payload.slice()); + continue; + } + if (retained.length >= LIVE_SOCKET_MAX_PENDING_FRAMES) { + throw new TransportError( + "TransportFull", + `pending frames exceed ${LIVE_SOCKET_MAX_PENDING_FRAMES}`, + ); + } + retained.push({ id, payload: frame.payload.slice() }); + } + } catch (error) { + failConnection( + error instanceof TransportError + ? error + : new TransportError("InvalidFrame", "invalid response frame"), + true, + ); + } + }; + const onError = (_socket: BunSocketHandle, error: Error): void => { + if (!open) { + failDial( new TransportError( "TransportClosed", - `dial '${endpoint.socketPath}' timed out after ${timeoutMs}ms`, + `socket error on '${endpoint.socketPath}': ${error.message}`, ), ); - }, timeoutMs); - bun - .connect({ - unix: endpoint.socketPath, - socket: { - data(_socket, data) { - if (framingFailed) return; - try { - const frames = inbound.pushBytes(new Uint8Array(data)); - for (const frame of frames) { - if (pending.length >= LIVE_SOCKET_MAX_PENDING_FRAMES) { - throw new TransportError( - "TransportFull", - `pending frames exceed ${LIVE_SOCKET_MAX_PENDING_FRAMES}`, - ); - } - pending.push(frame); - } - } catch (error) { - failFraming(error); - return; - } - if (responseSettled === null && responseFailed === null) return; - const next = pending.shift(); - if (!next) return; - const settled = responseSettled; - responseSettled = null; - responseFailed = null; - settled?.(next.payload.slice()); - }, - error(_socket, error) { - dialError = error; - clearTimeout(timer); - reject( - new TransportError( - "TransportClosed", - `socket error on '${endpoint.socketPath}': ${error.message}`, - ), - ); - }, - open(socket) { - clearTimeout(timer); - resolve(socket); - }, - close() {}, - }, - }) - .catch((error: unknown) => { - clearTimeout(timer); - dialError = error; - reject( - new TransportError( - "TransportClosed", - `dial '${endpoint.socketPath}' failed: ${error instanceof Error ? error.message : String(error)}`, - ), - ); - }); - }); + return; + } + failConnection( + new TransportError( + "TransportClosed", + `socket error on '${endpoint.socketPath}': ${error.message}`, + ), + ); + }; + const onClose = (): void => { + if (!open) { + failDial( + new TransportError( + "TransportClosed", + `socket '${endpoint.socketPath}' closed before open`, + ), + ); + return; + } + failConnection( + new TransportError( + "TransportClosed", + `socket '${endpoint.socketPath}' closed`, + ), + ); + }; + const onOpen = (handle: BunSocketHandle): void => { + socket = handle; + if (dialSettled) { + closeSocket(handle); + return; + } + dialSettled = true; + if (dialTimer !== null) clearTimeout(dialTimer); + resolveDial(handle); + }; + try { - handle = await opened; + const connecting = bun.connect({ + unix: endpoint.socketPath, + socket: { + data: onData, + error: onError, + open: onOpen, + close: onClose, + }, + }); + void connecting.catch((error: unknown) => { + failDial( + new TransportError( + "TransportClosed", + `dial '${endpoint.socketPath}' failed: ${error instanceof Error ? error.message : String(error)}`, + ), + ); + }); } catch (error) { - if (dialError instanceof TransportError) throw dialError; - throw error; + failDial( + new TransportError( + "TransportClosed", + `dial '${endpoint.socketPath}' failed: ${error instanceof Error ? error.message : String(error)}`, + ), + ); } - const socket = handle; - let open = true; + + const openedSocket = await opened; + socket = openedSocket; + open = true; + const identity: LiveSocketIdentity = { + kind: "endpoint-attested", + runtimeUid: endpoint.runtimeUid, + peer: null, + authenticated: false, + }; return { socketPath: endpoint.socketPath, + identity, isOpen: () => open, - requestResponse: async ( + requestResponse: ( requestJson: Uint8Array, nowMs: number, - ): Promise => { - void nowMs; - if (!open) { - throw new TransportError("TransportClosed", "live socket is closed"); - } - if (framingFailed) { - throw framingError instanceof TransportError - ? framingError - : new TransportError("TransportClosed", "framing failed"); - } - if (requestJson.length > MAX_FRAME_BYTES) { - throw new TransportError( - "FrameTooLarge", - `request ${requestJson.length} > ${MAX_FRAME_BYTES}`, - ); - } - socket.write(encodeFrame(requestJson)); - socket.flush(); - if (pending.length > 0) { - return Promise.resolve(pending.shift()!.payload.slice()); + requestId?: number, + signal?: AbortSignal, + ) => { + let id: number; + try { + void nowMs; + if (terminalError !== null) throw terminalError; + if (!open || socket === null) { + throw new TransportError("TransportClosed", "live socket is closed"); + } + if (requestJson.length > MAX_FRAME_BYTES) { + throw new TransportError( + "FrameTooLarge", + `request ${requestJson.length} > ${MAX_FRAME_BYTES}`, + ); + } + if (requestId !== undefined) { + id = requestId; + } else { + try { + id = requestIdFromPayload(requestJson); + } catch { + if (nextFallbackId >= Number.MAX_SAFE_INTEGER) { + throw new TransportError( + "TransportFull", + "fallback request id space exhausted", + ); + } + id = nextFallbackId++; + } + } + if (!Number.isSafeInteger(id) || id < 0) { + throw new TransportError( + "InvalidFrame", + "request id must be a safe integer", + ); + } + if (pending.has(id)) { + throw new TransportError("TransportFull", `request ${id} is pending`); + } + if (signal?.aborted) { + throw new TransportError("TransportClosed", "request cancelled"); + } + if (pending.size >= LIVE_SOCKET_MAX_PENDING_FRAMES) { + throw new TransportError( + "TransportFull", + `pending requests exceed ${LIVE_SOCKET_MAX_PENDING_FRAMES}`, + ); + } + } catch (error) { + return Promise.reject(error); } return new Promise((resolve, reject) => { let settled = false; + const cleanup = (): void => { + clearTimeout(timer); + signal?.removeEventListener("abort", onAbort); + removePending(id); + }; const timer = setTimeout(() => { if (settled) return; settled = true; - responseSettled = null; - responseFailed = null; - reject( - new TransportError( - "TransportClosed", - `no response from '${endpoint.socketPath}' within ${timeoutMs}ms`, - ), + cleanup(); + const error = new TransportError( + "TransportClosed", + `no response for request ${id} within ${timeoutMs}ms`, ); + failConnection(error, true); + reject(error); }, timeoutMs); - responseSettled = (value: Uint8Array): void => { + const onAbort = (): void => { if (settled) return; settled = true; - clearTimeout(timer); - responseSettled = null; - responseFailed = null; - resolve(value); - }; - responseFailed = (error: unknown): void => { - if (settled) return; - settled = true; - clearTimeout(timer); - responseSettled = null; - responseFailed = null; + cleanup(); + const error = new TransportError( + "TransportClosed", + `request ${id} cancelled`, + ); + failConnection(error, true); reject(error); }; + pending.set(id, { + resolve: (value) => { + if (settled) return; + settled = true; + cleanup(); + resolve(value); + }, + reject: (error) => { + if (settled) return; + settled = true; + cleanup(); + reject(error); + }, + timer, + detach: () => signal?.removeEventListener("abort", onAbort), + }); + pendingOrder.push(id); + signal?.addEventListener("abort", onAbort, { once: true }); + const retainedIndex = retained.findIndex( + (frame) => frame.id === id || frame.id === null, + ); + const queued = + retainedIndex >= 0 ? retained.splice(retainedIndex, 1)[0] : undefined; + try { + const wire = encodeFrame(requestJson); + const written = socket?.write(wire); + if (written !== wire.length) { + throw new TransportError( + "TransportClosed", + "socket write was partial", + ); + } + socket?.flush(); + if (queued !== undefined) { + const waiter = pending.get(id); + waiter?.resolve(queued.payload); + } + } catch (error) { + const connectionError = + error instanceof TransportError + ? error + : new TransportError("TransportClosed", "socket write failed"); + const waiter = pending.get(id); + waiter?.reject(error); + failConnection(connectionError, true); + } }); }, close: () => { - open = false; - try { - socket.end(); - } catch { - try { - socket.close(); - } catch { - // Close is best-effort; a closed socket must not throw. - } - } + failConnection( + new TransportError("TransportClosed", "live socket closed"), + ); }, }; } diff --git a/src/panel-runtime.ts b/src/panel-runtime.ts index e9e8e1d..2b60d6f 100644 --- a/src/panel-runtime.ts +++ b/src/panel-runtime.ts @@ -103,7 +103,7 @@ export type EventTopic = string & { readonly __brand: "EventTopic" }; const TOPIC_RE = /^[a-z][a-z0-9_-]*\.[a-z][a-z0-9_-]*:[a-z][a-z0-9_.-]*$/; export function parseEventTopic(raw: string): EventTopic { - if (raw.length === 0 || raw.length > 64) + if (raw.length === 0 || new TextEncoder().encode(raw).length > 64) throw new Error("topic must be 1..64 bytes"); if (!TOPIC_RE.test(raw)) throw new Error(`invalid topic grammar: ${raw}`); if (raw.startsWith("bitty.") && !raw.startsWith("bitty.panel:")) { diff --git a/src/protocol-boundary.ts b/src/protocol-boundary.ts new file mode 100644 index 0000000..7212c41 --- /dev/null +++ b/src/protocol-boundary.ts @@ -0,0 +1,90 @@ +/** + * CTX-0080 live request admission boundary. + * + * CTX-0079 owns src/protocol.ts decoding, duplicate-key handling, payload + * bounds, and redaction. This module owns only the live request admission + * checks added by CTX-0080: supported protocol version, registered method, + * exact scope, and the shared protocol encoder's size/shape validation. + */ + +import { + encodeRequest, + isSupportedVersion, + PROTOCOL_VERSION, + ProtocolErrorImpl, +} from "./protocol.js"; +import type { DebugScope, RequestFrame } from "./protocol.js"; +import type { IpcRequest } from "./transport.js"; + +const METHOD_SCOPES = new Map([ + ["bitty.debug/listPlugins", "debug.inspect"], + ["bitty.debug/getPlugin", "debug.inspect"], + ["bitty.debug/listSubscriptions", "debug.inspect"], + ["bitty.debug/getBudgets", "debug.inspect"], + ["bitty.debug/getQueueSnapshot", "debug.inspect"], + ["bitty.debug/getSnapshot", "debug.inspect"], + ["bitty.debug/listHandles", "debug.inspect"], + ["bitty.debug/getGridText", "debug.inspect"], + ["bitty.debug/getInputRing", "debug.inspect"], + ["bitty.debug/getModifiers", "debug.inspect"], + ["bitty.debug/getFocus", "debug.inspect"], + ["bitty.debug/streamEvents", "debug.trace"], + ["bitty.debug/startTrace", "debug.trace"], + ["bitty.debug/stopTrace", "debug.trace"], + ["bitty.debug/fetchTraceChunk", "debug.trace"], + ["bitty.debug/captureFrame", "debug.trace"], + ["bitty.debug/frameHash", "debug.trace"], + ["bitty.debug/suspendHandler", "debug.control"], + ["bitty.debug/resumePlugin", "debug.control"], + ["bitty.debug/disposeGeneration", "debug.control"], + ["bitty.debug/synthesizeInput", "debug.control"], +]); + +function requestError(code: string, message: string): ProtocolErrorImpl { + return new ProtocolErrorImpl({ category: "usage", code, message }); +} + +export function isRegisteredLiveMethod(method: string): boolean { + return METHOD_SCOPES.has(method); +} + +export function isValidLiveMethodForScope( + method: string, + scope: DebugScope, +): boolean { + return METHOD_SCOPES.get(method) === scope; +} + +export function validateLiveRequest( + request: IpcRequest, + scope: DebugScope = "debug.inspect", +): void { + if (!isSupportedVersion(request.version)) { + throw requestError( + "UnsupportedVersion", + `live requests require protocol version ${PROTOCOL_VERSION}`, + ); + } + if (!isValidLiveMethodForScope(request.method, scope)) { + throw requestError( + "UnknownMethod", + "live request method is not registered", + ); + } + const frame: RequestFrame = { + jsonrpc: "2.0", + id: request.id, + method: request.method, + ...(request.params === undefined ? {} : { params: request.params }), + version: request.version, + }; + try { + encodeRequest(frame); + } catch (error) { + if (error instanceof ProtocolErrorImpl) throw error; + throw requestError( + "InvalidRequest", + "live request failed protocol validation", + ); + } +} diff --git a/src/tracing.ts b/src/tracing.ts index 9fffe22..3e00016 100644 --- a/src/tracing.ts +++ b/src/tracing.ts @@ -3,15 +3,15 @@ * * Reuses the observability pipeline envelope from devtools-rfc: per-subscription * 64, per-plugin 1024/256 KiB, global 8192/2 MiB, DropOldest default, batch - * 32/8 KiB, chunked at 256 KiB to user-only storage (0600). Minimization by - * default, input markers require explicit includeInput:true plus typed redaction. - * Preview equals export before transmission. + * 32/8 KiB, and 256 KiB continuation chunks in memory. Minimization is the + * default; input markers require explicit `includeInput: true` plus typed + * redaction. Preview equals export before transmission. * * Phase 2 adds: advanced filtering, structured attributable events, retention * and GC policy, coalescing control, deterministic wall-clock, export preview * with chunked continuation, and DropOldest/DropNewest policies. All bounds are - * preserved and peer-creds are re-checked per privileged action via the IPC - * transport seam. + * preserved. The headless transport fixture re-checks caller-supplied peer + * values per privileged action; the Linux live socket path is inspect-only. */ import { BOUNDS, assertBounded, assertStringBounded } from "./bounds.js"; @@ -49,7 +49,8 @@ export type TraceRetention = { export type TraceStartResult = { traceId: string; - spoolPath: string; + spoolPath: null; + storage: "memory"; chunkBytes: number; startWallClockMs: number; filter?: TraceFilter; @@ -63,7 +64,7 @@ export type TraceStopResult = { previews: string[]; exportBytesEstimate: number; truncated: boolean; - spoolMode: string; + spoolMode: "memory"; }; export type TraceChunk = { @@ -85,10 +86,16 @@ export type StructuredTraceEvent = { coalesced?: boolean; }; +export type ObservabilityBatchRecord = { + owner: string; + kind: string; + payload: string; +}; + export type ObservabilityBatch = { sequence: number; dropCount: number; - records: Array<{ owner: string; kind: string; payload: string }>; + records: ObservabilityBatchRecord[]; wallClockMs: number; coalescedCount: number; policy: "DropOldest" | "DropNewest"; @@ -119,6 +126,41 @@ const DEFAULT_RETENTION: Required = { maxTraces: MAX_TRACES_PER_SESSION, }; +function validateBatch(batch: { maxEvents: number; maxBytes: number }): void { + if (!Number.isSafeInteger(batch.maxEvents) || batch.maxEvents <= 0) { + throw new TracingError( + "InvalidBatch", + "maxEvents must be a positive integer", + ); + } + if (!Number.isSafeInteger(batch.maxBytes) || batch.maxBytes <= 0) { + throw new TracingError( + "InvalidBatch", + "maxBytes must be a positive integer", + ); + } + assertBounded("maxEvents", batch.maxEvents, BOUNDS.BUS_BATCH_MAX_EVENTS); + assertBounded("maxBytes", batch.maxBytes, BOUNDS.BUS_BATCH_MAX_BYTES); +} + +function admitBatchRecords( + records: ObservabilityBatchRecord[], + maxBytes: number, +): { records: ObservabilityBatchRecord[]; dropCount: number } { + const encoder = new TextEncoder(); + const admitted: ObservabilityBatchRecord[] = []; + let dropCount = 0; + for (const record of records) { + const candidate = [...admitted, record]; + if (encoder.encode(JSON.stringify(candidate)).length > maxBytes) { + dropCount += 1; + continue; + } + admitted.push(record); + } + return { records: admitted, dropCount }; +} + export class TracingClient { private traces = new Map< string, @@ -141,7 +183,7 @@ export class TracingClient { private globalSequence = 0; private requireTrace(scope: string): void { - if (scope !== "debug.trace" && scope !== "debug.control") { + if (scope !== "debug.trace") { throw new TracingError("ScopeDenied", "debug.trace scope required"); } } @@ -162,11 +204,33 @@ export class TracingClient { const filter = opts.filter; if (filter?.kinds !== undefined) { assertBounded("filter.kinds", filter.kinds.length, 32); - for (const k of filter.kinds) assertStringBounded("filter kind", k, 64); + for (const k of filter.kinds) { + assertStringBounded("filter kind", k, 64); + if (k.length === 0) { + throw new TracingError( + "InvalidFilter", + "filter kind must not be empty", + ); + } + } } if (filter?.owners !== undefined) { assertBounded("filter.owners", filter.owners.length, 32); - for (const o of filter.owners) assertStringBounded("filter owner", o, 64); + if (filter.owners.length === 0) { + throw new TracingError( + "InvalidFilter", + "filter.owners must not be empty", + ); + } + for (const o of filter.owners) { + assertStringBounded("filter owner", o, 64); + if (o.length === 0) { + throw new TracingError( + "InvalidFilter", + "filter owner must not be empty", + ); + } + } } const retention: Required = { maxBytes: @@ -232,7 +296,6 @@ export class TracingClient { } const validated = this.validateOptions(opts); const traceId = `trace-${this.nextTrace++}`; - const spoolPath = `/tmp/bitty-traces/${traceId}.jsonl`; const startWallClockMs = Date.now(); this.traces.set(traceId, { options: validated, @@ -250,7 +313,8 @@ export class TracingClient { }); return { traceId, - spoolPath, + spoolPath: null, + storage: "memory", chunkBytes: BOUNDS.CHUNK_BYTES, startWallClockMs, filter: validated.filter, @@ -274,7 +338,6 @@ export class TracingClient { } const validated = this.validateOptions(opts); const traceId = `trace-${this.nextTrace++}`; - const spoolPath = `/run/user/1000/bitty/traces/${traceId}.jsonl`; this.traces.set(traceId, { options: validated, bytes: 0, @@ -291,7 +354,8 @@ export class TracingClient { }); return { traceId, - spoolPath, + spoolPath: null, + storage: "memory", chunkBytes: BOUNDS.CHUNK_BYTES, startWallClockMs: wall, filter: validated.filter, @@ -317,7 +381,7 @@ export class TracingClient { previews, exportBytesEstimate, truncated, - spoolMode: "0600", + spoolMode: "memory", }; this.traces.delete(traceId); return result; @@ -330,27 +394,27 @@ export class TracingClient { signal?: AbortSignal, ): ObservabilityBatch { this.requireTrace(scope); - if (signal?.aborted) + if (signal?.aborted) { throw new TracingError("Cancelled", "stream cancelled"); - assertBounded("maxEvents", batch.maxEvents, BOUNDS.BUS_BATCH_MAX_EVENTS); - assertBounded("maxBytes", batch.maxBytes, BOUNDS.BUS_BATCH_MAX_BYTES); + } + validateBatch(batch); + assertBounded("event types", types.length, 256); for (const t of types) { assertStringBounded("eventType", t, 64); - if (t.length === 0) + if (t.length === 0) { throw new TracingError("InvalidType", "event type must not be empty"); + } } - const records = types.slice(0, batch.maxEvents).map((t) => ({ + const candidates = types.slice(0, batch.maxEvents).map((t) => ({ owner: "panel-1", kind: t, payload: JSON.stringify({ count: 1 }), })); - const bytes = new TextEncoder().encode(JSON.stringify(records)).length; - assertBounded("batch bytes", bytes, BOUNDS.BUS_BATCH_MAX_BYTES); - const dropCount = 0; + const admitted = admitBatchRecords(candidates, batch.maxBytes); return { sequence: this.globalSequence++, - dropCount, - records, + dropCount: types.length - admitted.records.length, + records: admitted.records, wallClockMs: Date.now(), coalescedCount: 0, policy: "DropOldest", @@ -366,35 +430,55 @@ export class TracingClient { signal?: AbortSignal, ): ObservabilityBatch { this.requireTrace(scope); - if (signal?.aborted) + if (signal?.aborted) { throw new TracingError("Cancelled", "stream cancelled"); - assertBounded("maxEvents", batch.maxEvents, BOUNDS.BUS_BATCH_MAX_EVENTS); - assertBounded("maxBytes", batch.maxBytes, BOUNDS.BUS_BATCH_MAX_BYTES); + } + validateBatch(batch); const kinds = filter.kinds ?? ["bitty.panel:mounted"]; + const owners = filter.owners ?? ["panel-1"]; assertBounded("filter.kinds", kinds.length, 32); - for (const k of kinds) assertStringBounded("eventType", k, 64); + assertBounded("filter.owners", owners.length, 32); + if (owners.length === 0) { + throw new TracingError( + "InvalidFilter", + "filter.owners must not be empty", + ); + } + + for (const k of kinds) { + assertStringBounded("eventType", k, 64); + if (k.length === 0) { + throw new TracingError("InvalidType", "event type must not be empty"); + } + } + for (const owner of owners) { + assertStringBounded("event owner", owner, 64); + if (owner.length === 0) { + throw new TracingError("InvalidType", "event owner must not be empty"); + } + } + const owner = owners[0] ?? "panel-1"; const wall = nowMs ?? Date.now(); - // Coalescing: merge successive budget records from same owner - const seen = new Map(); + const seen = new Set(); let coalescedCount = 0; - const records: Array<{ owner: string; kind: string; payload: string }> = []; - for (const k of kinds) { - if (records.length >= batch.maxEvents) break; - const owner = filter.owners?.[0] ?? "panel-1"; - const key = `${owner}:${k}`; + const candidates: ObservabilityBatchRecord[] = []; + for (const kind of kinds) { + const key = `${owner}:${kind}`; if (seen.has(key)) { coalescedCount += 1; continue; } - seen.set(key, 1); - records.push({ owner, kind: k, payload: JSON.stringify({ count: 1 }) }); + seen.add(key); + candidates.push({ owner, kind, payload: JSON.stringify({ count: 1 }) }); } - const bytes = new TextEncoder().encode(JSON.stringify(records)).length; - assertBounded("batch bytes", bytes, BOUNDS.BUS_BATCH_MAX_BYTES); + const admitted = admitBatchRecords( + candidates.slice(0, batch.maxEvents), + batch.maxBytes, + ); return { sequence: this.globalSequence++, - dropCount: 0, - records, + dropCount: candidates.length - admitted.records.length, + records: admitted.records, wallClockMs: wall, coalescedCount, policy: "DropOldest", @@ -456,7 +540,8 @@ export class TracingClient { } /** Append bounded records to a trace (internal, for testing). Bounded 8 KiB per record. */ - appendToTrace(traceId: string, data: string): void { + appendToTrace(scope: string, traceId: string, data: string): void { + this.requireTrace(scope); const rec = this.traces.get(traceId); if (rec === undefined) throw new TracingError("NotFound", `trace ${traceId} not found`); @@ -500,7 +585,12 @@ export class TracingClient { } /** Phase 2: append structured attributable event (bounded). */ - appendStructuredEvent(traceId: string, event: StructuredTraceEvent): void { + appendStructuredEvent( + scope: string, + traceId: string, + event: StructuredTraceEvent, + ): void { + this.requireTrace(scope); const rec = this.traces.get(traceId); if (rec === undefined) throw new TracingError("NotFound", `trace ${traceId} not found`); @@ -509,12 +599,32 @@ export class TracingClient { event.payload, BOUNDS.BUS_EVENT_MAX_BYTES, ); + assertStringBounded("structured event owner", event.owner, 64); assertStringBounded("structured event kind", event.kind, 64); - assertBounded( - "structured event generation", - event.generation, - Number.MAX_SAFE_INTEGER, - ); + if (event.owner.length === 0 || event.kind.length === 0) { + throw new TracingError( + "InvalidEvent", + "owner and kind must not be empty", + ); + } + if (!Number.isSafeInteger(event.sequence) || event.sequence < 0) { + throw new TracingError( + "InvalidEvent", + "sequence must be a nonnegative safe integer", + ); + } + if (!Number.isSafeInteger(event.generation) || event.generation < 1) { + throw new TracingError( + "InvalidEvent", + "generation must be a positive safe integer", + ); + } + if (!Number.isSafeInteger(event.wallClockMs) || event.wallClockMs < 0) { + throw new TracingError( + "InvalidEvent", + "wallClockMs must be a nonnegative safe integer", + ); + } // Filter enforcement if ( rec.filter?.kinds !== undefined && @@ -566,7 +676,8 @@ export class TracingClient { } /** Phase 2: retention and GC. */ - getRetention(traceId: string): TraceRetentionPolicy { + getRetention(scope: string, traceId: string): TraceRetentionPolicy { + this.requireTrace(scope); const rec = this.traces.get(traceId); if (rec === undefined) throw new TracingError("NotFound", `trace ${traceId} not found`); @@ -579,8 +690,8 @@ export class TracingClient { }; } - gcExpiredTraces(nowMs: number, scope?: string): string[] { - if (scope !== undefined) this.requireTrace(scope); + gcExpiredTraces(nowMs: number, scope: string): string[] { + this.requireTrace(scope); const expired: string[] = []; for (const [id, rec] of this.traces) { if (nowMs - rec.startMs >= rec.retention.maxDurationMs) { @@ -605,17 +716,25 @@ export class TracingClient { // H-DEV-02 (CTX-0032): was `previewEqualsExport(text, text)`, a // self-comparison that always passed; re-derive from the export bytes. assertPreviewMatchesExport(text, preview); - return { preview: text, exportBytes: rec.bytes, spoolMode: "0600" }; + return { preview: text, exportBytes: rec.bytes, spoolMode: "memory" }; } /** For diagnostics: remaining traces count. Bounded. */ - traceCount(): number { + traceCount(scope: string): number { + this.requireTrace(scope); return this.traces.size; } - listTraces(): string[] { + listTraces(scope: string): string[] { + this.requireTrace(scope); return [...this.traces.keys()]; } + + clearSessionState(): void { + this.traces.clear(); + this.nextTrace = 1; + this.globalSequence = 0; + } } /** diff --git a/src/transport.ts b/src/transport.ts index abc7172..36f05da 100644 --- a/src/transport.ts +++ b/src/transport.ts @@ -1,16 +1,16 @@ /** - * IPC transport for DevTools phase 2 (live runtime, bounded, headless-testable). + * Bounded IPC transport fixture for DevTools phase 2. * - * This module provides the real IPC socket/pipe peer-creds integration against - * the live Bitty runtime without requiring unsafe or a live socket in tests. - * It reuses the framing and budget vocabulary from `bitty-ipc` and the + * This module verifies caller-supplied peer and endpoint values in an + * injectable in-memory transport. The Linux endpoint-attested OS socket adapter lives + * in `ipc-socket.ts`; no live socket is opened by this fixture. It reuses the + * framing and budget vocabulary from `bitty-ipc` and the * devtools-rfc: length-prefixed frames bounded at 256 KiB (IPC) and logical * devtools frames at 1 MiB, chunked at 256 KiB (RC-10), rate limits RC-9 * (100 req/s, 2x burst, 16 concurrent connections). * - * The transport is headless by default (in-memory VecDeque stub) and accepts - * an injectable socket factory for live integration. No TCP listener is - * created. All queues are bounded and fail-closed; producers never block. + * The transport is an in-memory VecDeque fixture with no TCP listener. All + * queues are bounded and fail-closed; producers never block. */ import { BOUNDS, assertBounded, assertStringBounded } from "./bounds.js"; @@ -431,7 +431,7 @@ export class StdioTransportStub { } // --------------------------------------------------------------------------- -// IpcTransport (phase 2): live-runtime-capable, peer-creds verified, rate-limited +// IpcTransport (phase 2): bounded fixture transport with peer-value checks // --------------------------------------------------------------------------- export type IpcTransportConfig = { @@ -444,11 +444,8 @@ export type IpcTransportConfig = { peer?: PeerCredentials | null; capacity?: number; /** - * Windows named-pipe peer identity (CTX-0043). When both SIDs are present - * the transport verifies the pipe peer instead of the Unix endpoint checks - * (Unix mode/owner checks are meaningless on a named pipe). Absent on - * Unix; injected headlessly in tests since the pipe path cannot execute - * on Linux (Windows-CI item). + * Headless test seam for caller-supplied named-pipe identity values. It + * does not open a Windows pipe or establish a live peer identity. */ windowsPeerSid?: bigint | number; windowsRuntimeSid?: bigint | number; @@ -530,7 +527,6 @@ export class IpcTransport { throw new TransportError("TransportClosed", "stdio transport is closed"); } if (this.isWindowsPipe()) { - // CTX-0043: named-pipe peers carry SIDs, not Unix modes/owners. this.checkWindowsPipe( this.config.windowsPeerSid as bigint | number, this.config.windowsRuntimeSid as bigint | number, @@ -586,7 +582,6 @@ export class IpcTransport { verifyPeerForPrivilegedAction(): void { if (this.isWindowsPipe()) { - // CTX-0043: every privileged action re-verifies the pipe peer SID. this.checkWindowsPipe( this.config.windowsPeerSid as bigint | number, this.config.windowsRuntimeSid as bigint | number, @@ -651,10 +646,9 @@ export class IpcTransport { * it asynchronously. Response ids must match the request id, and the * envelope is validated by `decodeResponse` before it is returned. * - * L2 (recorded, not fixed here): this reads exactly one inbound frame and - * does not reassemble RC-10 256 KiB continuation frames, and the transport - * is still the in-memory `StdioTransportStub` rather than a live socket - * reader. Those remain tracked follow-ups outside PR #45's scope. + * This headless seam consumes exactly one inbound frame and does not + * reassemble RC-10 continuation frames. OS socket readers use the separate + * live socket path and their own bounded response contract. */ request(req: IpcRequest, nowMs: number): IpcResponse { this.sendRequest(req, nowMs); @@ -665,7 +659,14 @@ export class IpcTransport { `no response for id ${req.id} (${req.method})`, ); } - const raw = new TextDecoder().decode(frame.payload); + let raw: string; + try { + raw = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode( + frame.payload, + ); + } catch { + throw new TransportError("InvalidFrame", "response is not valid UTF-8"); + } const response: IpcResponse = decodeResponse(raw); if (response.id !== req.id) { throw new TransportError( diff --git a/tests/auth.test.ts b/tests/auth.test.ts index 96c1b9e..526e9dc 100644 --- a/tests/auth.test.ts +++ b/tests/auth.test.ts @@ -15,7 +15,7 @@ import { SOCKET_MODE, } from "../src/auth.js"; -describe("auth peer-creds (phase 2, live runtime)", () => { +describe("headless auth policy and endpoint selection", () => { test("peer uid equality", () => { const peer = peerCredentials(1000, 1000, 42); expect(() => verifyPeerUid(peer, 1000)).not.toThrow(); @@ -53,7 +53,7 @@ describe("auth peer-creds (phase 2, live runtime)", () => { expect(() => verifyWindowsPipe(123n, 999n)).toThrow("pipe peer sid"); }); - test("resolve socket path precedence BITTY_SOCKET advisory", () => { + test("resolve socket path precedence uses BITTY_SOCKET as a bounded selector", () => { const p1 = resolveSocketPath({ runtimeUid: 1000, bittySocket: "/tmp/custom.sock", @@ -160,7 +160,7 @@ describe("auth peer-creds (phase 2, live runtime)", () => { check("child token expired"); }); - test("BITTY_SOCKET without peer cred still fails (advisory only)", () => { + test("BITTY_SOCKET selection does not provide peer credentials", () => { const peer = peerCredentials(2000, 2000, 99); const runtimeUid = 1000; expect(() => verifyPeerUid(peer, runtimeUid)).toThrow("peer uid"); diff --git a/tests/automation.test.ts b/tests/automation.test.ts index da721c9..253fc46 100644 --- a/tests/automation.test.ts +++ b/tests/automation.test.ts @@ -473,7 +473,7 @@ describe("AutomationClient captureFrame", () => { explicitOptIn: true, }); expect(frame.format).toBe("pixels"); - expect(frame.masked).toBe(true); + expect((frame as { masked: boolean }).masked).toBe(true); }); test("rejects a pixels envelope carrying a tiny non-image sentinel", () => { diff --git a/tests/cli.test.ts b/tests/cli.test.ts index 541dd08..2b2f8a2 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -10,6 +10,7 @@ import { WATCH_CEILING_MS, WATCH_FLOOR_MS, WATCH_JITTER_FRACTION, + WATCH_MAX_FAILED_ATTEMPTS, WATCH_MAX_TICKS, exitCodeForError, parseCliArgs, @@ -21,7 +22,7 @@ import { import type { CliRuntime } from "../src/cli.js"; import { DIR_MODE, SOCKET_MODE, peerCredentials } from "../src/auth.js"; import { IpcTransport, TransportError } from "../src/transport.js"; -import type { IpcRequest } from "../src/transport.js"; +import type { IpcRequest, IpcResponse } from "../src/transport.js"; import { EXIT_CONFIG, EXIT_GENERIC, @@ -79,6 +80,28 @@ function makeTransport(): RecordingTransport { }); } +class AlternatingRateLimitTransport extends RecordingTransport { + attempts = 0; + + override request(req: IpcRequest, _nowMs: number): IpcResponse { + this.attempts += 1; + if (this.attempts % 2 === 0) { + return { + jsonrpc: "2.0", + id: req.id, + error: { category: "budget", code: "RateLimited", message: "fixture" }, + version: "1.0", + }; + } + return { + jsonrpc: "2.0", + id: req.id, + result: { plugins: [pluginPayload()] }, + version: "1.0", + }; + } +} + function responsePayload(id: number, result: unknown): Uint8Array { return new TextEncoder().encode( JSON.stringify({ jsonrpc: "2.0", id, result, version: "1.0" }), @@ -308,7 +331,7 @@ describe("runCli dispatch over an injected transport", () => { const harness = makeHarness(transport); expect(runCli(["inspect", "--plugins"], harness.deps)).toBe(EXIT_OK); const output = harness.out.join(""); - expect(output).toContain(`${"x".repeat(MAX_CELL_CHARS)}...`); + expect(output).toContain(`${"x".repeat(MAX_CELL_CHARS - 3)}...`); expect(output).not.toContain(huge); }); @@ -324,7 +347,7 @@ describe("runCli dispatch over an injected transport", () => { ); const parsed = JSON.parse(harness.out.join("")) as Array<{ id: string }>; expect(parsed[0]!.id.endsWith("...")).toBe(true); - expect(parsed[0]!.id.length).toBe(MAX_CELL_CHARS + 3); + expect(parsed[0]!.id.length).toBe(MAX_CELL_CHARS); expect(harness.out.join("")).not.toContain(huge); }); @@ -781,12 +804,12 @@ describe("wire-trace flag contract N1-N15", () => { expect(fetched.chunk).not.toContain("example-secret-value"); expect(fetched.preview).not.toContain("example-secret-value"); const stopped = c.stopTrace(s.traceId); - expect(stopped.spoolMode).toBe("0600"); + expect(stopped.spoolMode).toBe("memory"); expect(stopped.previews.join("")).not.toContain("example-secret-value"); const s2 = c.startTrace({ maxBytes: 1024 }); c.appendToTrace(s2.traceId, "hello"); const preview = c.exportTracePreview(s2.traceId); - expect(preview.spoolMode).toBe("0600"); + expect(preview.spoolMode).toBe("memory"); let tampered: string | null = null; try { const { assertPreviewMatchesExport } = await import("../src/tracing.js"); @@ -862,7 +885,7 @@ describe("wire-trace flag contract N1-N15", () => { EXIT_RUNTIME, ); expect(h.out).toEqual([]); - expect(h.err.join("")).toContain("no connected Bitty instance"); + expect(h.err.join("")).toContain("inspect-only"); }); test("N14 rate and frame shedding fail closed with counted drops", () => { @@ -1479,6 +1502,28 @@ describe("inspect --watch mode per accepted design A4 (CTX-0072)", () => { expect(detached).toBe(1); }); + test("W9 total rate-limit budget survives alternating successful frames", async () => { + const { uid, gid, pid } = makeHarness().deps.runtime; + const transport = new AlternatingRateLimitTransport({ + runtimeUid: uid, + socketPath: `/run/user/${uid}/bitty/default.sock`, + peer: peerCredentials(uid, gid, pid), + capacity: 256, + }); + const harness = makeHarness(transport); + const code = await runCliWatch( + ["inspect", "--plugins", "--watch", "--max-ticks", "1000"], + { + runtime: harness.deps.runtime, + transport, + watch: { random01: () => 0.5, sleep: async () => {} }, + }, + ); + expect(code).toBe(EXIT_OK); + expect(transport.attempts).toBeLessThan(WATCH_MAX_FAILED_ATTEMPTS * 3); + expect(harness.out.length).toBeGreaterThan(0); + }); + test("W9 per-tick bounds apply and no frames are retained across ticks", async () => { const huge = "x".repeat(MAX_CELL_CHARS + 50); const transport = makeTransport(); @@ -1499,7 +1544,7 @@ describe("inspect --watch mode per accepted design A4 (CTX-0072)", () => { ); expect(code).toBe(EXIT_OK); expect(harness.out.length).toBe(2); - expect(harness.out[0]).toContain(`${"x".repeat(MAX_CELL_CHARS)}...`); + expect(harness.out[0]).toContain(`${"x".repeat(MAX_CELL_CHARS - 3)}...`); expect(harness.out[0]).not.toContain(huge); expect(harness.out[1]).toContain("plugin-b"); expect(harness.out[1]).not.toContain("x".repeat(10)); diff --git a/tests/client.test.ts b/tests/client.test.ts index 4203971..6512820 100644 --- a/tests/client.test.ts +++ b/tests/client.test.ts @@ -3,6 +3,7 @@ import { DevtoolsClient } from "../src/client.js"; import { IpcTransport } from "../src/transport.js"; import { peerCredentials } from "../src/auth.js"; import { createScratchLoopback } from "./helpers/fake-live-socket.js"; +import { isLiveSocketSupported } from "../src/ipc-socket.js"; describe("DevtoolsClient integration", () => { test("connect + scope lifecycle", () => { @@ -17,6 +18,9 @@ describe("DevtoolsClient integration", () => { c.grantScope("debug.control"); expect(c.currentScope()).toContain("debug.control"); c.revokeScope("debug.inspect"); + expect(c.currentScope()).toEqual(["debug.trace", "debug.control"]); + c.revokeScope("debug.trace"); + c.revokeScope("debug.control"); expect(c.currentScope()).toEqual([]); }); @@ -134,7 +138,80 @@ describe("DevtoolsClient inspection live IPC wiring", () => { maxSubscriptionsPerPanel: 32, }, }); - expect(c.listPlugins()).toEqual([]); + }); + + describe("CTX-0080 session and live boundaries", () => { + test("independent scopes do not widen one another", () => { + const c = new DevtoolsClient(); + c.connect(); + c.grantScope("debug.trace"); + expect(() => c.listPlugins()).toThrow("debug.inspect scope required"); + c.grantScope("debug.control"); + c.revokeScope("debug.trace"); + expect(() => c.startTrace({})).toThrow("debug.trace scope required"); + + expect(() => c.startTrace({})).toThrow("debug.trace scope required"); + }); + + test("disconnect clears panel, trace, and control session state", () => { + const c = new DevtoolsClient(); + c.connect(); + c.grantScope("debug.trace"); + const trace = c.startTrace({}); + c.grantScope("debug.control"); + c.suspendHandler(1 as never, "handler", "test", "tester"); + c.disconnect(); + c.connect(); + c.grantScope("debug.inspect"); + expect(c.getPanelSnapshot()).toBeNull(); + c.grantScope("debug.trace"); + expect(() => c.fetchTraceChunk(trace.traceId, 0)).toThrow("not found"); + c.grantScope("debug.control"); + expect(c.listAuditLog()).toEqual([]); + }); + + test("live socket identity is explicit and trace/control are unavailable", async () => { + if (!isLiveSocketSupported()) return; + const responsePayload = new TextEncoder().encode( + JSON.stringify({ + jsonrpc: "2.0", + id: 1, + result: { plugins: [] }, + version: "1.0", + }), + ); + const loopback = createScratchLoopback({ + prefix: "bitty-devtools-client-ctx0080", + responsePayload, + timeoutMs: 1000, + }); + try { + const c = new DevtoolsClient(); + await c.connectLiveSocket( + loopback.runtimeUid, + peerCredentials(loopback.runtimeUid + 1, loopback.runtimeUid, 1), + undefined, + undefined, + loopback.socketPath, + ); + expect(c.currentScope()).toEqual([]); + c.grantScope("debug.inspect"); + expect(() => c.grantScope("debug.trace")).toThrow("inspect-only"); + await expect( + c.requestLive( + { id: 2, method: "bitty.debug/startTrace", version: "1.0" }, + 0, + ), + ).rejects.toMatchObject({ + name: "ProtocolError", + error: { code: "UnknownMethod" }, + }); + + c.disconnect(); + } finally { + loopback.stop(); + } + }); }); test("failed live connect fails closed and never falls back to mock", () => { @@ -234,6 +311,20 @@ describe("DevtoolsClient inspection live IPC wiring", () => { expect(session.connected).toBe(true); expect(c.isIpcConnected()).toBe(true); c.grantScope("debug.inspect"); + await expect( + c.requestLive( + { id: 99, method: "bitty.debug/listPlugins", version: "2.0" }, + 0, + ), + ).rejects.toMatchObject({ + name: "ProtocolError", + error: { code: "UnsupportedVersion" }, + }); + expect(() => c.listPlugins()).toThrow("unavailable synchronously"); + const typedPlugins = await c.listPluginsLive(); + + expect(typedPlugins[0]?.id).toBe("panel-live"); + expect(c.getLiveIdentity()?.authenticated).toBe(false); const response = await c.requestLive( { id: 1, diff --git a/tests/compat-matrix.test.ts b/tests/compat-matrix.test.ts index 67e26b4..f0f55a5 100644 --- a/tests/compat-matrix.test.ts +++ b/tests/compat-matrix.test.ts @@ -9,8 +9,8 @@ import { describe("compat-matrix 14x4", () => { test("matrix is 14 rows ordered", () => { expect(MATRIX.length).toBe(14); - expect(MATRIX[0].surface).toBe("shell"); - expect(MATRIX[MATRIX.length - 1].surface).toBe("DPI"); + expect(MATRIX[0]!.surface).toBe("shell"); + expect(MATRIX[MATRIX.length - 1]!.surface).toBe("DPI"); }); test("reference terms are 4", () => { @@ -75,7 +75,7 @@ describe("compat-matrix 14x4", () => { for (let i = 0; i < expected.length; i++) { const row = MATRIX[i]; const want = expected[i]; - expect(row?.surface).toBe(want?.[0]); + expect(row?.surface as string | undefined).toBe(want?.[0]); expect(row?.category).toBe(want?.[1]); expect(row?.corpusRel).toBe(want?.[2]); } @@ -91,11 +91,43 @@ describe("compat-matrix 14x4", () => { expect(`${generateMatrixJson()}\n`).toBe(golden); }); - test("parse bounded rejects oversize", () => { + test("parse bounded rejects unsafe entry values and ordering", () => { + const document = JSON.parse(generateMatrixJson()) as { + entries: Array>; + }; + const mutations: Array> = [ + { corpusRel: "../escape.bin" }, + { corpusRel: "/absolute.bin" }, + { generation: 0 }, + { width: 0 }, + { stateHash: "not-a-hash" }, + ]; + for (const mutation of mutations) { + const candidate = structuredClone(document) as typeof document; + Object.assign(candidate.entries[0]!, mutation); + expect(() => parseMatrixJsonBounded(JSON.stringify(candidate))).toThrow(); + } + const reordered = structuredClone(document) as typeof document; + const first = reordered.entries[0]!; + reordered.entries[0] = reordered.entries[1]!; + reordered.entries[1] = first; + expect(() => parseMatrixJsonBounded(JSON.stringify(reordered))).toThrow(); + }); + + test("parse bounded rejects oversize and closed-schema violations", () => { const j = generateMatrixJson(); expect(parseMatrixJsonBounded(j).version).toBe(1); expect(() => parseMatrixJsonBounded("a".repeat(20 * 1024))).toThrow( "16 KiB", ); + const document = JSON.parse(j) as Record; + expect(() => + parseMatrixJsonBounded(JSON.stringify({ ...document, extra: true })), + ).toThrow("not allowed"); + const entries = document["entries"] as Array>; + entries[0]!["extra"] = true; + expect(() => parseMatrixJsonBounded(JSON.stringify(document))).toThrow( + "not allowed", + ); }); }); diff --git a/tests/control.test.ts b/tests/control.test.ts index 8d77808..58ea7d3 100644 --- a/tests/control.test.ts +++ b/tests/control.test.ts @@ -29,7 +29,7 @@ describe("control (debug.control, audited, no bypass)", () => { ); expect(receipt.audited.caller).toBe("tester"); expect(receipt.audited.action).toBe("suspendHandler"); - expect(receipt.generation).toBe(2); + expect(Number(receipt.generation)).toBe(2); }); test("resume cannot bypass budget gate (typed receipt)", () => { @@ -37,7 +37,7 @@ describe("control (debug.control, audited, no bypass)", () => { c.connect(); c.grantScope("debug.control"); const r = c.resumePlugin(panelId(1), 1 as never); - expect(r.newGeneration).toBe(2); + expect(Number(r.newGeneration)).toBe(2); }); test("disposeGeneration reclaims bounded", () => { diff --git a/tests/framer-fuzz-smoke.test.ts b/tests/framer-fuzz-smoke.test.ts index d012281..4624513 100644 --- a/tests/framer-fuzz-smoke.test.ts +++ b/tests/framer-fuzz-smoke.test.ts @@ -352,7 +352,7 @@ describe("T1 framer pushBytes", () => { const frames = framer.pushBytes(wire.slice(at)); expect(frames.length).toBe(1); expect(toHex(frames[0]!.payload)).toBe( - (vec.expect["framesHex"] as string[])[0], + String((vec.expect["framesHex"] as string[] | undefined)?.[0] ?? ""), ); } const emojiPayload = loadSeed("emoji.bin"); @@ -417,9 +417,9 @@ describe("T2 encode decode round-trip", () => { test("oracle fixed frames match encoder output", () => { const oracle = loadOracle(); const helloWire = encodeFrame(loadSeed("hello.bin")); - expect(toHex(helloWire)).toBe(vectorById(oracle, "V03").wireHex); + expect(toHex(helloWire)).toBe(vectorById(oracle, "V03").wireHex!); expect(toHex(encodeFrame(loadSeed("empty.bin")))).toBe( - vectorById(oracle, "V02").inputHex, + vectorById(oracle, "V02").inputHex!, ); }); }); @@ -430,8 +430,8 @@ describe("T3 chunkText boundary parity", () => { for (const id of ["C01", "C02", "C03"]) { const vec = vectorById(oracle, id); const chunks = chunkText(vec.input!, vec.limitBytes!); - expect(chunks).toEqual(vec.expect["chunks"]); - expect(chunks.join("")).toBe(vec.input); + expect(chunks).toEqual(vec.expect["chunks"] as string[]); + expect(chunks.join("")).toBe(vec.input!); const encoder = new TextEncoder(); for (const chunk of chunks) { expect(encoder.encode(chunk).length).toBeLessThanOrEqual( diff --git a/tests/helpers/fake-live-socket.ts b/tests/helpers/fake-live-socket.ts index a753c01..f33f0b3 100644 --- a/tests/helpers/fake-live-socket.ts +++ b/tests/helpers/fake-live-socket.ts @@ -1,13 +1,19 @@ import { spyOn } from "bun:test"; import * as fs from "node:fs"; import { connectLiveSocket } from "../../src/ipc-socket.js"; -import type { LiveSocketConnection } from "../../src/ipc-socket.js"; +import type { + LiveSocketConnection, + SymlinkProbe, +} from "../../src/ipc-socket.js"; export const MEMORY_SOCKET_PATH = "/memory/bitty/fixture.sock"; export const MEMORY_TIMEOUT_MS = 100; export const MEMORY_RUNTIME_UID = 1000; export const MEMORY_DIR_MODE = 0o700; export const MEMORY_SOCK_MODE = 0o600; +const memoryLstat: SymlinkProbe = () => ({ + isSymbolicLink: () => false, +}); export const SCRATCH_TIMEOUT_MS = 1000; export const SCRATCH_SOCKET_LEAF = "loopback.sock"; export const SCRATCH_TIMEOUT_CEILING_MS = 5000; @@ -116,6 +122,7 @@ export async function withMemoryConnection( socketPath: MEMORY_SOCKET_PATH, runtimeUid: MEMORY_RUNTIME_UID, timeoutMs: MEMORY_TIMEOUT_MS, + lstatSync: memoryLstat, }); try { await run( diff --git a/tests/inspection.test.ts b/tests/inspection.test.ts index dbb76a2..1f69cba 100644 --- a/tests/inspection.test.ts +++ b/tests/inspection.test.ts @@ -371,6 +371,58 @@ describe("inspection over real IPC (connected path)", () => { } }); + test("standard inspection results reject extra fields", () => { + const cases: Array<[string, unknown]> = [ + [ + "bitty.debug/listPlugins", + { plugins: [pluginPayload({ injected: true })] }, + ], + ["bitty.debug/getPlugin", pluginPayload({ injected: true })], + [ + "bitty.debug/listSubscriptions", + [subscriptionPayload({ injected: true })], + ], + ["bitty.debug/getBudgets", budgetPayload({ injected: true })], + ["bitty.debug/getQueueSnapshot", queuePayload({ injected: true })], + ["bitty.debug/listHandles", [handlePayload({ injected: true })]], + ["bitty.debug/getSnapshot", semanticSnapshotPayload({ injected: true })], + ]; + for (const [method, result] of cases) { + const client = new InspectionClient( + new MethodTransport({ [method]: result }), + ); + let failed = false; + try { + switch (method) { + case "bitty.debug/listPlugins": + client.listPlugins("debug.inspect"); + break; + case "bitty.debug/getPlugin": + client.getPlugin("debug.inspect", "plugin-a"); + break; + case "bitty.debug/listSubscriptions": + client.listSubscriptions("debug.inspect", "plugin-a"); + break; + case "bitty.debug/getBudgets": + client.getBudgets("debug.inspect", "plugin-a", generation(1)); + break; + case "bitty.debug/getQueueSnapshot": + client.getQueueSnapshot("debug.inspect", "plugin-a"); + break; + case "bitty.debug/listHandles": + client.listHandles("debug.inspect", "plugin-a"); + break; + case "bitty.debug/getSnapshot": + client.getSnapshotForTerminal("debug.inspect", "term-1", ""); + break; + } + } catch (error) { + failed = error instanceof InspectionError; + } + expect(failed).toBe(true); + } + }); + test("getSnapshotForTerminal dispatches semantic getSnapshot and redacts", () => { const transport = new RecordingTransport(semanticSnapshotPayload()); const client = new InspectionClient(transport); diff --git a/tests/ipc-socket.test.ts b/tests/ipc-socket.test.ts index c79ec31..ed033e8 100644 --- a/tests/ipc-socket.test.ts +++ b/tests/ipc-socket.test.ts @@ -240,6 +240,36 @@ describe("in-memory physical stream frames (#97)", () => { } }); +describe("CTX-0080 live request ownership", () => { + test("correlates concurrent responses by request id", async () => { + await withMemoryConnection(async (connection, receive) => { + const first = connection.requestResponse(firstPayload, 0, 1); + const second = connection.requestResponse(secondPayload, 1, 2); + receive(secondFrame); + receive(firstFrame); + expect(await second).toEqual(secondPayload); + expect(await first).toEqual(firstPayload); + }); + }); + + test("abort settles the request and closes the connection", async () => { + await withMemoryConnection(async (connection) => { + const controller = new AbortController(); + const pending = connection.requestResponse( + firstPayload, + 0, + 1, + controller.signal, + ); + controller.abort(); + await expect(pending).rejects.toMatchObject({ + code: "TransportClosed", + }); + expect(connection.isOpen()).toBe(false); + }); + }); +}); + describe("live Unix IPC socket (CTX-0036)", () => { test("connect -> request -> response round trip over a loopback socket", async () => { if (!isLiveSocketSupported()) return; @@ -286,6 +316,28 @@ describe("live Unix IPC socket (CTX-0036)", () => { } }); + test("rejects relative or oversized live paths before endpoint access", async () => { + if (!isLiveSocketSupported()) return; + await expect( + attestLiveSocketEndpoint({ + socketPath: "relative.sock", + runtimeUid: 1000, + }), + ).rejects.toThrow("absolute"); + await expect( + connectLiveSocket({ + socketPath: "/run/user/1000/bitty/../other.sock", + runtimeUid: 1000, + }), + ).rejects.toThrow("absolute"); + await expect( + connectLiveSocket({ + socketPath: `/${"a".repeat(256)}`, + runtimeUid: 1000, + }), + ).rejects.toThrow("bounded"); + }); + test("symlink at the socket path refuses to attest", async () => { if (!isLiveSocketSupported()) return; const proc = globalThis.process as unknown as { diff --git a/tests/panel-runtime.test.ts b/tests/panel-runtime.test.ts index ea64444..7fd1bcc 100644 --- a/tests/panel-runtime.test.ts +++ b/tests/panel-runtime.test.ts @@ -13,8 +13,8 @@ describe("panel-runtime re-use", () => { test("PanelId distinct from ViewId (branded, no From)", () => { const pid = panelId(1); const vid = viewId(1); - expect(pid).toBe(1); - expect(vid).toBe(1); + expect(Number(pid)).toBe(1); + expect(Number(vid)).toBe(1); // Branded types share runtime value but are distinct at type level expect(pid !== (vid as unknown as typeof pid)).toBe(false); // same numeric, type-level distinct }); @@ -33,13 +33,15 @@ describe("panel-runtime re-use", () => { }); test("EventTopic grammar bounded 64", () => { - expect(parseEventTopic("example.git:branch-changed")).toBe( + expect(parseEventTopic("example.git:branch-changed") as string).toBe( "example.git:branch-changed", ); expect(() => parseEventTopic("badtopic")).toThrow("invalid topic"); expect(() => parseEventTopic("Owner.name:topic")).toThrow("invalid topic"); expect(() => parseEventTopic("bitty.foo:bar")).toThrow("forbidden"); - expect(parseEventTopic("bitty.panel:mounted")).toBe("bitty.panel:mounted"); + expect(parseEventTopic("bitty.panel:mounted") as string).toBe( + "bitty.panel:mounted", + ); }); test("BoundedPayload rejects oversize", () => { diff --git a/tests/platform-contract.test.ts b/tests/platform-contract.test.ts new file mode 100644 index 0000000..30031c0 --- /dev/null +++ b/tests/platform-contract.test.ts @@ -0,0 +1,163 @@ +import { describe, expect, test } from "bun:test"; +import { readdirSync, readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { + attestLiveSocketEndpoint, + connectLiveSocket, + isLiveSocketSupported, + liveSocketPlatform, +} from "../src/ipc-socket.js"; + +const repositoryRoot = resolve(import.meta.dir, ".."); +const repositoryFile = (path: string): string => + readFileSync(resolve(repositoryRoot, path), "utf8"); + +// Job names are the status contexts a workflow reports. In this repository's +// workflows a job key sits at two-space indentation and its keys, including +// name, at four, so a four-space name is a job name and never a step name. +const workflowJobNames = (): string[] => { + const directory = resolve(repositoryRoot, ".github/workflows"); + return readdirSync(directory) + .filter((entry) => entry.endsWith(".yml") || entry.endsWith(".yaml")) + .sort() + .flatMap((entry) => { + const names: string[] = []; + let inJobs = false; + for (const line of repositoryFile(`.github/workflows/${entry}`).split( + "\n", + )) { + if (/^jobs:\s*$/u.test(line)) { + inJobs = true; + continue; + } + if (inJobs && /^\S/u.test(line)) inJobs = false; + const match = inJobs ? line.match(/^ {4}name: (.+)$/u) : null; + if (match?.[1]) + names.push(match[1].trim().replace(/^["']|["']$/gu, "")); + } + return names; + }); +}; + +describe("offline platform contract", () => { + test("advertises only the implemented live adapter", () => { + const platform = process.platform; + expect(liveSocketPlatform()).toBe( + platform === "linux" ? "linux" : "unsupported", + ); + expect(isLiveSocketSupported()).toBe(platform === "linux"); + }); + + test("non-Linux entry points fail before endpoint access", async () => { + if (process.platform === "linux") return; + await expect( + attestLiveSocketEndpoint({ + socketPath: "/synthetic.sock", + runtimeUid: 1000, + }), + ).rejects.toThrow("unsupported"); + await expect( + connectLiveSocket({ socketPath: "/synthetic.sock", runtimeUid: 1000 }), + ).rejects.toThrow("unsupported"); + }); + + test("CodeQL activates the checked-in configuration for every language", () => { + const workflow = repositoryFile(".github/workflows/codeql.yml"); + const config = repositoryFile(".github/codeql/codeql-config.yml"); + expect(workflow).toContain("config-file: .github/codeql/codeql-config.yml"); + expect(workflow).toContain( + 'category: "/language:javascript-typescript,actions"', + ); + expect(workflow).toContain('category: "/language:rust"'); + expect(workflow).toContain("languages: javascript-typescript, actions"); + expect(workflow).toContain("languages: rust"); + expect(workflow.match(/build-mode: none/gu)).toHaveLength(2); + expect(config).toContain("uses: security-and-quality"); + expect(config).toContain("paths-ignore:"); + }); + + test("every required status context is emitted by a workflow job name", () => { + const required = repositoryFile(".github/required-status-checks.txt") + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0 && !line.startsWith("#")); + expect(required.length).toBeGreaterThan(0); + const emitted = workflowJobNames(); + for (const context of required) { + expect(emitted).toContain(context); + } + }); + + test("the required combined CodeQL context keeps its exact job name", () => { + // Branch protection on main requires this context character for character; + // a per-language matrix would rename it and block every pull request. + const workflow = repositoryFile(".github/workflows/codeql.yml"); + expect(workflow).toContain( + "name: Analyze (javascript-typescript, actions)", + ); + expect(workflow).not.toContain("matrix.language"); + expect(workflowJobNames()).toContain( + "Analyze (javascript-typescript, actions)", + ); + }); + + test("toolchain, lockfile, CI, and changelog metadata agree", () => { + const packageMetadata = repositoryFile("package.json"); + const lockfile = repositoryFile("bun.lock"); + const changelog = repositoryFile("CHANGELOG.md"); + const workflow = repositoryFile(".github/workflows/ci.yml"); + const hooks = repositoryFile("lefthook.yml"); + const justfile = repositoryFile("justfile"); + const typeConfig = repositoryFile("tsconfig.check.json"); + const declaredCarryctx = ( + JSON.parse(packageMetadata) as { + devDependencies: { carryctx: string }; + } + ).devDependencies.carryctx; + expect(packageMetadata).toContain('"packageManager": "bun@1.4.2"'); + expect(lockfile).toContain('"@types/bun": "1.4.2"'); + expect(lockfile).toContain('"bun-types": "1.4.2"'); + expect(lockfile).toContain(`"carryctx": "${declaredCarryctx}"`); + expect(workflow.match(/bun-version: 1\.4\.2/gu)).toHaveLength(3); + expect(workflow.match(/bun install --frozen-lockfile/gu)).toHaveLength(2); + expect(hooks).toContain("run: just check"); + expect(hooks).toContain("same full quality gate as CI"); + expect(justfile).toContain("bun test --max-concurrency=1"); + expect(changelog).toContain("`bun.lock` is synchronized"); + expect(changelog).not.toContain("`bun.lock` is unchanged"); + expect(changelog).not.toContain("CI still installs Bun `1.4.0`"); + expect(typeConfig).toContain('"exclude": ["tests/campaign.test.ts"]'); + }); + + test("protocol ownership is split at the CTX-0080 boundary", () => { + const boundary = repositoryFile("src/protocol-boundary.ts"); + expect(boundary).toContain("CTX-0079 owns src/protocol.ts"); + expect(boundary).toContain("CTX-0080"); + expect(boundary).toContain("validateLiveRequest"); + }); + + test("platform metadata does not claim connected peer authentication", () => { + const files = [ + "README.md", + "CHANGELOG.md", + "crates/devtools-client/Cargo.toml", + "src/auth.ts", + "src/cli.ts", + "src/client.ts", + "src/ipc-socket.ts", + "src/transport.ts", + "crates/devtools-client/src/auth.rs", + "crates/devtools-client/src/ipc_socket.rs", + ].map(repositoryFile); + const text = files.join("\n"); + expect(text).not.toContain("live IPC with peer-creds"); + expect(text).not.toContain("real IPC socket/pipe peer-creds"); + expect(text).not.toContain("verified Linux OS socket"); + expect(text).not.toContain( + "--socket Explicit Bitty IPC socket path (advisory)", + ); + expect(text).not.toContain("BITTY_RUNTIME_UID"); + expect(text).toContain("endpoint-attested"); + expect(text).toContain("authenticated: false"); + }); +}); diff --git a/tests/protocol-boundary.test.ts b/tests/protocol-boundary.test.ts new file mode 100644 index 0000000..cedcbf2 --- /dev/null +++ b/tests/protocol-boundary.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, test } from "bun:test"; +import { ProtocolErrorImpl } from "../src/protocol.js"; +import { + isValidLiveMethodForScope, + validateLiveRequest, +} from "../src/protocol-boundary.js"; + +function expectProtocolCode(action: () => void, code: string): void { + try { + action(); + throw new Error("expected protocol admission to fail"); + } catch (error) { + expect(error).toBeInstanceOf(ProtocolErrorImpl); + expect((error as ProtocolErrorImpl).error.code).toBe(code); + } +} + +describe("CTX-0080 live protocol admission", () => { + test("rejects unsupported protocol versions with a typed error", () => { + expectProtocolCode( + () => + validateLiveRequest({ + id: 1, + method: "bitty.debug/listPlugins", + version: "2.0", + }), + "UnsupportedVersion", + ); + }); + + test("rejects unregistered methods and scope widening", () => { + expectProtocolCode( + () => + validateLiveRequest({ + id: 1, + method: "bitty.debug/notRegistered", + version: "1.0", + }), + "UnknownMethod", + ); + expectProtocolCode( + () => + validateLiveRequest({ + id: 1, + method: "bitty.debug/startTrace", + version: "1.0", + }), + "UnknownMethod", + ); + }); + + test("admits only the registered inspect method set", () => { + expect( + isValidLiveMethodForScope("bitty.debug/listPlugins", "debug.inspect"), + ).toBe(true); + expect( + isValidLiveMethodForScope("bitty.debug/getFocus", "debug.trace"), + ).toBe(false); + expect( + isValidLiveMethodForScope("bitty.debug/captureFrame", "debug.trace"), + ).toBe(true); + expect( + isValidLiveMethodForScope("bitty.debug/synthesizeInput", "debug.control"), + ).toBe(true); + }); +}); diff --git a/tests/tracing.test.ts b/tests/tracing.test.ts index 9899cdc..cce2c40 100644 --- a/tests/tracing.test.ts +++ b/tests/tracing.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test"; +import * as publicApi from "../src/index.js"; import { DevtoolsClient } from "../src/client.js"; import { redactPreview } from "../src/redaction.js"; import { @@ -36,6 +37,10 @@ function snap(): PanelRuntimeSnapshot { } describe("tracing (debug.trace, opt-in, bounded)", () => { + test("low-level tracing client is not part of the public package surface", () => { + expect(Object.hasOwn(publicApi, "TracingClient")).toBe(false); + }); + test("inspect cannot start trace", () => { const c = new DevtoolsClient(); c.connect(); @@ -58,6 +63,18 @@ describe("tracing (debug.trace, opt-in, bounded)", () => { expect(stop.previews[0]).toBe("hello"); }); + test("low-level tracing accessors require trace scope", () => { + const tracing = new TracingClient(); + const start = tracing.startTrace("debug.trace", {}); + expect(() => + tracing.appendToTrace("debug.control", start.traceId, "x"), + ).toThrow("debug.trace scope required"); + expect(() => tracing.listTraces("debug.control")).toThrow( + "debug.trace scope required", + ); + tracing.stopTrace("debug.trace", start.traceId); + }); + test("streamEvents bounded 32/8 KiB and DropOldest", () => { const c = new DevtoolsClient(); c.connect(); @@ -73,6 +90,57 @@ describe("tracing (debug.trace, opt-in, bounded)", () => { expect(batch.dropCount).toBe(0); }); + test("streamEvents admits records against the requested byte budget", () => { + const c = new DevtoolsClient(); + c.connect(); + c.grantScope("debug.trace"); + const first = { + owner: "panel-1", + kind: "one", + payload: JSON.stringify({ count: 1 }), + }; + const firstBytes = new TextEncoder().encode(JSON.stringify([first])).length; + const batch = c.streamEvents(["one", "two"], { + maxEvents: 2, + maxBytes: firstBytes, + }); + expect(batch.records).toHaveLength(1); + expect(batch.dropCount).toBe(1); + expect(new TextEncoder().encode(JSON.stringify(batch.records)).length).toBe( + firstBytes, + ); + }); + + test("filtered streams use the same requested byte budget", () => { + const c = new DevtoolsClient(); + c.connect(); + c.grantScope("debug.trace"); + const record = { + owner: "panel-1", + kind: "one", + payload: JSON.stringify({ count: 1 }), + }; + const maxBytes = new TextEncoder().encode(JSON.stringify([record])).length; + const batch = c.streamFilteredEvents( + { kinds: ["one", "two"] }, + { maxEvents: 2, maxBytes }, + ); + expect(batch.records).toHaveLength(1); + expect(batch.dropCount).toBe(1); + }); + + test("filtered stream kind cardinality matches Rust", () => { + const c = new DevtoolsClient(); + c.connect(); + c.grantScope("debug.trace"); + expect(() => + c.streamFilteredEvents( + { kinds: Array.from({ length: 33 }, (_, index) => `event.${index}`) }, + { maxEvents: 32, maxBytes: 8192 }, + ), + ).toThrow("filter.kinds"); + }); + test("trace duration and bytes bounded", () => { const c = new DevtoolsClient(); c.connect(); @@ -114,7 +182,7 @@ describe("tracing (debug.trace, opt-in, bounded)", () => { const start = c.startTrace(scope, { maxBytes: 1024 * 1024 }); const records: string[] = []; const chunks: string[] = []; - for (let i = 0; i < 100; i++) { + for (let i = 0; i < 80; i++) { const payload = `${i}:` + "abcdef".repeat(900 + (i % 5) * 100); const event = { sequence: i, @@ -135,8 +203,8 @@ describe("tracing (debug.trace, opt-in, bounded)", () => { } else { chunks[last] += record; } - if (structured) c.appendStructuredEvent(start.traceId, event); - else c.appendToTrace(start.traceId, payload); + if (structured) c.appendStructuredEvent(scope, start.traceId, event); + else c.appendToTrace(scope, start.traceId, payload); } const reference = records.join(""); expect(chunks.length).toBeGreaterThan(2); @@ -182,7 +250,7 @@ describe("tracing (debug.trace, opt-in, bounded)", () => { const scope = "debug.trace"; const start = c.startTrace(scope, {}); const source = "aé中🙂\uFEFFz"; - c.appendToTrace(start.traceId, source); + c.appendToTrace(scope, start.traceId, source); const encoder = new TextEncoder(); let offset = 0; let charOffset = 0; @@ -209,14 +277,16 @@ describe("tracing (debug.trace, opt-in, bounded)", () => { const bytes = encoder.encode(expected).length; for (const retentionFirst of [true, false]) { const c = new TracingClient(); + const scope = "debug.trace"; const { traceId } = c.startTrace("debug.trace", { maxBytes: retentionFirst ? bytes * 2 : bytes, retention: { maxBytes: retentionFirst ? bytes : bytes * 2 }, }); - for (const fragment of fragments) c.appendToTrace(traceId, fragment); + for (const fragment of fragments) + c.appendToTrace(scope, traceId, fragment); const before = c.fetchTraceChunk("debug.trace", traceId, 0); expect(before.chunk).toBe(expected); - c.appendToTrace(traceId, "extra"); + c.appendToTrace(scope, traceId, "extra"); expect(c.fetchTraceChunk("debug.trace", traceId, 0)).toEqual(before); let offset = 0; let charOffset = 0; @@ -242,6 +312,7 @@ describe("tracing (debug.trace, opt-in, bounded)", () => { for (const structured of [false, true]) { test(`multilingual ${structured ? "structured" : "raw"} pages preserve retained bytes across chunks`, () => { const c = new TracingClient(); + const scope = "debug.trace"; const { traceId, chunkBytes } = c.startTrace("debug.trace", { maxBytes: 1024 * 1024, }); @@ -267,8 +338,8 @@ describe("tracing (debug.trace, opt-in, bounded)", () => { ) chunks.push(record); else chunks[last] += record; - if (structured) c.appendStructuredEvent(traceId, event); - else c.appendToTrace(traceId, payload); + if (structured) c.appendStructuredEvent(scope, traceId, event); + else c.appendToTrace(scope, traceId, payload); } const reference = records.join(""); const referenceBytes = encoder.encode(reference); @@ -309,10 +380,10 @@ describe("tracing (debug.trace, opt-in, bounded)", () => { const start = c.startTrace(scope, {}); expect(c.fetchTraceChunk(scope, start.traceId, 0).chunk).toBe(""); expect(c.fetchTraceChunk(scope, start.traceId, 0).continuation).toBe(false); - c.appendToTrace(start.traceId, "hello"); + c.appendToTrace(scope, start.traceId, "hello"); const first = c.fetchTraceChunk(scope, start.traceId, 0); expect(first.continuation).toBe(false); - c.appendToTrace(start.traceId, " world"); + c.appendToTrace(scope, start.traceId, " world"); const tail = c.fetchTraceChunk(scope, start.traceId, first.chunk.length); expect(tail.chunk).toBe(" world"); expect(tail.continuation).toBe(false); @@ -359,6 +430,7 @@ describe("tracing (debug.trace, opt-in, bounded)", () => { [48, 24], ]) { const c = new TracingClient(); + const scope = "debug.trace"; const { traceId } = c.startTrace("debug.trace", { maxBytes, retention: { maxBytes: retentionBytes }, @@ -374,7 +446,7 @@ describe("tracing (debug.trace, opt-in, bounded)", () => { } else { drops++; } - c.appendToTrace(traceId, record); + c.appendToTrace(scope, traceId, record); const state = c["traces"].get(traceId)!; expect(state.chunks.join("")).toBe(retained); expect(state.bytes).toBe(new TextEncoder().encode(retained).length); @@ -405,12 +477,13 @@ describe("tracing (debug.trace, opt-in, bounded)", () => { for (const limit of [bytes - 1, bytes, bytes + 1]) { for (const retentionFirst of [true, false]) { const c = new TracingClient(); + const scope = "debug.trace"; const { traceId } = c.startTrace("debug.trace", { maxBytes: retentionFirst ? bytes * 2 : limit, retention: { maxBytes: retentionFirst ? limit : bytes * 2 }, }); const before = structuredClone(c["traces"].get(traceId)!); - c.appendStructuredEvent(traceId, event); + c.appendStructuredEvent(scope, traceId, event); const state = c["traces"].get(traceId)!; if (limit < bytes) { expect({ ...state, drops: before.drops }).toEqual(before); @@ -420,7 +493,7 @@ describe("tracing (debug.trace, opt-in, bounded)", () => { expect(state.bytes).toBe(bytes); expect(state.events).toEqual([event]); const accepted = structuredClone(state); - c.appendStructuredEvent(traceId, event); + c.appendStructuredEvent(scope, traceId, event); expect({ ...state, drops: accepted.drops }).toEqual(accepted); expect(state.drops).toBe(1); } @@ -430,6 +503,7 @@ describe("tracing (debug.trace, opt-in, bounded)", () => { test("retained redaction is measured and detached from caller events", () => { const c = new TracingClient(); + const scope = "debug.trace"; const { traceId } = c.startTrace("debug.trace", { maxBytes: 512 }); const event: StructuredTraceEvent = { sequence: 1, @@ -439,7 +513,7 @@ describe("tracing (debug.trace, opt-in, bounded)", () => { generation: 1, wallClockMs: 10, }; - c.appendStructuredEvent(traceId, event); + c.appendStructuredEvent(scope, traceId, event); const retained = { ...event, payload: "[REDACTED]" }; event.payload = "changed after append"; const state = c["traces"].get(traceId)!; @@ -449,7 +523,7 @@ describe("tracing (debug.trace, opt-in, bounded)", () => { new TextEncoder().encode(JSON.stringify(retained)).length, ); const raw = c.startTrace("debug.trace", { maxBytes: 10 }); - c.appendToTrace(raw.traceId, "password=example"); + c.appendToTrace(scope, raw.traceId, "password=example"); expect(c["traces"].get(raw.traceId)!.chunks).toEqual(["[REDACTED]"]); expect(c.stopTrace("debug.trace", raw.traceId).byteCount).toBe(10); }); @@ -457,9 +531,10 @@ describe("tracing (debug.trace, opt-in, bounded)", () => { test("opaque raw records never partially coalesce with event metadata", () => { for (const coalesce of ["budget", "none"] as const) { const c = new TracingClient(); + const scope = "debug.trace"; const { traceId } = c.startTrace("debug.trace", { coalesce }); - c.appendToTrace(traceId, "你好"); - c.appendToTrace(traceId, "trace.record café"); + c.appendToTrace(scope, traceId, "你好"); + c.appendToTrace(scope, traceId, "trace.record café"); const state = c["traces"].get(traceId)!; expect(state.events.map((event) => event.payload)).toEqual([ "你好", @@ -474,10 +549,11 @@ describe("tracing (debug.trace, opt-in, bounded)", () => { test("validation and filtering leave retained state unchanged", () => { const c = new TracingClient(); + const scope = "debug.trace"; const { traceId } = c.startTrace("debug.trace", { filter: { kinds: ["trace.record"] }, }); - c.appendToTrace(traceId, "café"); + c.appendToTrace(scope, traceId, "café"); const before = structuredClone(c["traces"].get(traceId)!); const event: StructuredTraceEvent = { sequence: 2, @@ -487,9 +563,9 @@ describe("tracing (debug.trace, opt-in, bounded)", () => { generation: -1, wallClockMs: 10, }; - expect(() => c.appendStructuredEvent(traceId, event)).toThrow(); + expect(() => c.appendStructuredEvent(scope, traceId, event)).toThrow(); expect(c["traces"].get(traceId)).toEqual(before); - c.appendStructuredEvent(traceId, { + c.appendStructuredEvent(scope, traceId, { ...event, generation: 1, kind: "other", @@ -505,17 +581,18 @@ describe("tracing (debug.trace, opt-in, bounded)", () => { const [lead, trail] = [scalar.slice(0, 1), scalar.slice(1, 2)]; const replacement = "\uFFFD"; const c = new TracingClient(); + const scope = "debug.trace"; const { traceId } = c.startTrace("debug.trace", { maxBytes: 16, retention: { maxBytes: 32 }, coalesce: "budget", }); - c.appendToTrace(traceId, lead); + c.appendToTrace(scope, traceId, lead); let state = c["traces"].get(traceId)!; expect(state.chunks).toEqual([replacement]); expect(state.bytes).toBe(3); expect(state.events.map((event) => event.payload)).toEqual([replacement]); - c.appendToTrace(traceId, trail); + c.appendToTrace(scope, traceId, trail); state = c["traces"].get(traceId)!; expect(state.chunks).toEqual([replacement + replacement]); expect(state.bytes).toBe(6); @@ -526,23 +603,25 @@ describe("tracing (debug.trace, opt-in, bounded)", () => { expect(new TextEncoder().encode(state.chunks.join("")).length).toBe(6); expect(c.stopTrace("debug.trace", traceId).byteCount).toBe(6); const replay = new TracingClient(); + const replayScope = "debug.trace"; const whole = replay.startTrace("debug.trace", { maxBytes: 16, retention: { maxBytes: 16 }, coalesce: "budget", }); - replay.appendToTrace(whole.traceId, scalar); + replay.appendToTrace(replayScope, whole.traceId, scalar); const wholeState = replay["traces"].get(whole.traceId)!; expect(wholeState.bytes).toBe(4); expect(wholeState.chunks).toEqual([scalar]); expect(wholeState.events.map((event) => event.payload)).toEqual([scalar]); const owned = new TracingClient(); + const ownedScope = "debug.trace"; const { traceId: ownedId } = owned.startTrace("debug.trace", { maxBytes: 2, retention: { maxBytes: 4 }, }); const before = structuredClone(owned["traces"].get(ownedId)!); - owned.appendToTrace(ownedId, scalar); + owned.appendToTrace(ownedScope, ownedId, scalar); state = owned["traces"].get(ownedId)!; expect(state.chunks).toEqual([]); expect(state.bytes).toBe(0); @@ -612,7 +691,7 @@ describe("wire-trace negatives N5-N14", () => { test("N8 direct offset rejects negative noninteger and over bytes", () => { const t = new TracingClient(); const s = t.startTrace("debug.trace", { maxBytes: 1024 }); - t.appendToTrace(s.traceId, "hello"); + t.appendToTrace("debug.trace", s.traceId, "hello"); expect(() => t.fetchTraceChunk("debug.trace", s.traceId, -1)).toThrow(); expect(() => t.fetchTraceChunk("debug.trace", s.traceId, 1.5)).toThrow(); expect(() => t.fetchTraceChunk("debug.trace", s.traceId, 6)).toThrow(); @@ -628,7 +707,11 @@ describe("wire-trace negatives N5-N14", () => { test("N9 input default off with redaction on opt in", () => { const t = new TracingClient(); const s = t.startTrace("debug.trace", { maxBytes: 1024 }); - t.appendToTrace(s.traceId, "clipboard=top-secret-value password=hide"); + t.appendToTrace( + "debug.trace", + s.traceId, + "clipboard=top-secret-value password=hide", + ); const page = t.fetchTraceChunk("debug.trace", s.traceId, 0); expect(page.chunk).not.toContain("hide"); expect(page.chunk).toBe("[REDACTED]"); @@ -637,7 +720,7 @@ describe("wire-trace negatives N5-N14", () => { maxBytes: 1024, includeInput: true, }); - t.appendToTrace(s2.traceId, "password=hide-me"); + t.appendToTrace("debug.trace", s2.traceId, "password=hide-me"); const page2 = t.fetchTraceChunk("debug.trace", s2.traceId, 0); expect(page2.chunk).toBe("[REDACTED]"); t.stopTrace("debug.trace", s2.traceId); @@ -648,9 +731,9 @@ describe("wire-trace negatives N5-N14", () => { expect(SOCKET_MODE).toBe(0o600); const t = new TracingClient(); const s = t.startTrace("debug.trace", { maxBytes: 2048 }); - t.appendToTrace(s.traceId, "token=sk-live-abcdefgh12345678"); + t.appendToTrace("debug.trace", s.traceId, "token=sk-live-abcdefgh12345678"); const stopped = t.stopTrace("debug.trace", s.traceId); - expect(stopped.spoolMode).toBe("0600"); + expect(stopped.spoolMode).toBe("memory"); expect(stopped.previews.join("")).not.toContain("sk-live"); expect(() => assertPreviewMatchesExport("hello", "hello-tampered")).toThrow( "preview must equal export", @@ -663,8 +746,8 @@ describe("wire-trace negatives N5-N14", () => { expect("aé中".length).toBe(3); const t = new TracingClient(); const s = t.startTrace("debug.trace", { maxBytes: 1024 }); - t.appendToTrace(s.traceId, "é"); - const state = t.listTraces(); + t.appendToTrace("debug.trace", s.traceId, "é"); + const state = t.listTraces("debug.trace"); expect(state).toContain(s.traceId); expect(() => t.fetchTraceChunk("debug.trace", s.traceId, 1)).toThrow(); const p0 = t.fetchTraceChunk("debug.trace", s.traceId, 0); @@ -682,8 +765,8 @@ describe("wire-trace negatives N5-N14", () => { expect(() => checkConnectionCap(16)).toThrow("shed newest"); const t = new TracingClient(); const s = t.startTrace("debug.trace", { maxBytes: 5 }); - t.appendToTrace(s.traceId, "hello"); - t.appendToTrace(s.traceId, "extra-bytes"); + t.appendToTrace("debug.trace", s.traceId, "hello"); + t.appendToTrace("debug.trace", s.traceId, "extra-bytes"); const stopped = t.stopTrace("debug.trace", s.traceId); expect(stopped.dropCount).toBe(1); expect(stopped.truncated).toBe(true); diff --git a/tests/transport.test.ts b/tests/transport.test.ts index 327a7c5..bc5939d 100644 --- a/tests/transport.test.ts +++ b/tests/transport.test.ts @@ -18,7 +18,7 @@ import { } from "../src/transport.js"; import { peerCredentials } from "../src/auth.js"; -describe("transport framing (phase 2, live runtime, bounded 256 KiB IPC / 1 MiB devtools)", () => { +describe("transport framing (headless fixture, bounded 256 KiB IPC / 1 MiB devtools)", () => { test("frame roundtrip small", () => { const p = new TextEncoder().encode("hello"); const wire = encodeFrame(p); @@ -204,7 +204,7 @@ describe("transport framing (phase 2, live runtime, bounded 256 KiB IPC / 1 MiB expect(new TextDecoder().decode(out[0]!.payload)).toBe("b"); }); - test("ipc transport peer creds verified at connect and per privileged action", () => { + test("headless transport verifies supplied peer values at connect and per privileged action", () => { const peer = peerCredentials(1000, 1000, 1); const t = new IpcTransport({ runtimeUid: 1000, @@ -321,6 +321,23 @@ describe("transport framing (phase 2, live runtime, bounded 256 KiB IPC / 1 MiB expect(() => t.connect()).toThrow("peer uid"); }); + test("ipc transport rejects malformed UTF-8 before response parsing", () => { + const t = new IpcTransport({ + runtimeUid: 1000, + socketPath: "/unused/headless.sock", + peer: peerCredentials(1000, 1000, 1), + }); + t.connect(); + t.injectResponsePayload(new Uint8Array([0xff])); + expect(() => + t.request( + { id: 1, method: "bitty.debug/listPlugins", version: "1.0" }, + 0, + ), + ).toThrow("not valid UTF-8"); + t.disconnect(); + }); + test("ipc transport chunking at 256 KiB for 1 MiB logical frame", () => { const peer = peerCredentials(1000, 1000, 1); const t = new IpcTransport({ diff --git a/tests/validation.test.ts b/tests/validation.test.ts new file mode 100644 index 0000000..a0a2581 --- /dev/null +++ b/tests/validation.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, test } from "bun:test"; +import { + parseCompatMatrixJsonBounded, + validatePanelSnapshot, +} from "../src/client.js"; +import { DevtoolsClient } from "../src/client.js"; +import { generateMatrixJson } from "../src/compat-matrix.js"; + +function snapshot(): Record { + return { + generation: 1, + panels: [], + panelsPerWorkspace: new Map(), + totalPanels: 0, + topics: [], + overlays: [], + config: { + maxPanelsPerWorkspace: 16, + maxPanelsPerWindow: 32, + maxTopicsTotal: 256, + maxSubscriptionsPerPanel: 32, + }, + }; +} + +describe("closed candidate schemas", () => { + test("panel snapshots reject unknown and malformed nested fields", () => { + expect(() => validatePanelSnapshot(snapshot())).not.toThrow(); + expect(() => + validatePanelSnapshot({ ...snapshot(), injected: true }), + ).toThrow("not allowed"); + expect(() => + validatePanelSnapshot({ + ...snapshot(), + config: { ...(snapshot().config as Record), extra: 1 }, + }), + ).toThrow("not allowed"); + expect(() => + validatePanelSnapshot({ + ...snapshot(), + topics: ["not-a-topic"], + }), + ).toThrow("invalid topic"); + }); + + test("panel snapshots enforce per-workspace configuration", () => { + const candidate = snapshot(); + candidate.panels = [ + { id: 1, generation: 1, state: "Mounted", type: "helper" }, + { id: 2, generation: 1, state: "Mounted", type: "helper" }, + ] as never; + candidate.totalPanels = 2; + candidate.panelsPerWorkspace = new Map([[1, 2]]) as never; + (candidate.config as Record)["maxPanelsPerWorkspace"] = 1; + expect(() => validatePanelSnapshot(candidate)).toThrow("configured bound"); + }); + + test("validated panel snapshots are detached on input and output", () => { + const client = new DevtoolsClient(); + client.connect(); + client.grantScope("debug.inspect"); + const input = snapshot(); + client.setPanelSnapshot(input as never); + input.topics = ["not-a-topic"] as never; + const first = client.getPanelSnapshot(); + expect(first?.topics).toEqual([]); + if (first !== null) first.topics = ["not-a-topic"] as never; + expect(client.getPanelSnapshot()?.topics).toEqual([]); + }); + + test("compat matrix parser requires the closed generated shape", () => { + const generated = generateMatrixJson(); + expect(() => parseCompatMatrixJsonBounded(generated)).not.toThrow(); + const document = JSON.parse(generated) as Record; + expect(() => + parseCompatMatrixJsonBounded( + JSON.stringify({ ...document, extra: true }), + ), + ).toThrow("not allowed"); + const entries = document["entries"] as Array>; + entries[0]!["extra"] = true; + expect(() => + parseCompatMatrixJsonBounded(JSON.stringify(document)), + ).toThrow("not allowed"); + delete entries[0]!["extra"]; + entries[0]!["bytesLen"] = 0; + expect(() => + parseCompatMatrixJsonBounded(JSON.stringify(document)), + ).toThrow("empty matrix entry"); + }); +}); diff --git a/tests/workflow-import-budget.test.ts b/tests/workflow-import-budget.test.ts new file mode 100644 index 0000000..6185e18 --- /dev/null +++ b/tests/workflow-import-budget.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +const repositoryRoot = resolve(import.meta.dir, ".."); +const fixtureSuitePath = "tests/workflow-import.test.ts"; +const fixtureSuite = readFileSync( + resolve(repositoryRoot, fixtureSuitePath), + "utf8", +); + +// Bun's built-in per-test deadline. Fixtures in this repository drive real +// child processes, so a test allowed less than the budget it drives reports a +// timeout instead of a result. +const BUN_DEFAULT_TEST_TIMEOUT_MS = 5_000; + +const declaredNumber = (name: string): number => { + const match = fixtureSuite.match( + new RegExp(`^const ${name} = ([0-9_]+);$`, "mu"), + ); + if (!match?.[1]) throw new Error(`Missing declaration: ${name}`); + return Number(match[1].replaceAll("_", "")); +}; + +describe("workflow import test budget", () => { + test("declares the budgets the fixture suite derives from", () => { + expect(declaredNumber("FIXTURE_SPAWN_TIMEOUT_MS")).toBe(10_000); + expect( + declaredNumber("MAX_SEQUENTIAL_FIXTURE_RUNS"), + ).toBeGreaterThanOrEqual(2); + expect(declaredNumber("FIXTURE_TEST_HEADROOM_MS")).toBeGreaterThan(0); + }); + + test("keeps the outer per-test budget above the inner spawn budget", () => { + const inner = declaredNumber("FIXTURE_SPAWN_TIMEOUT_MS"); + const runs = declaredNumber("MAX_SEQUENTIAL_FIXTURE_RUNS"); + const headroom = declaredNumber("FIXTURE_TEST_HEADROOM_MS"); + const outer = runs * inner + headroom; + // The regression this guards: the outer deadline must never be reachable + // before the inner budget a single fixture run may consume, and must cover + // every sequential run one test is allowed to drive. + expect(outer).toBeGreaterThan(inner); + expect(outer).toBeGreaterThanOrEqual(runs * inner); + // Bun's default is shorter than a single inner budget, which is why the + // suite carries an explicit derived deadline instead of the default. + expect(BUN_DEFAULT_TEST_TIMEOUT_MS).toBeLessThan(inner); + // The wrapper must apply the derived budget, not a second literal. + expect(fixtureSuite).toContain( + "test(name, body, FIXTURE_TEST_TIMEOUT_MS);", + ); + expect(fixtureSuite).toMatch( + /^const FIXTURE_TEST_TIMEOUT_MS =\n {2}MAX_SEQUENTIAL_FIXTURE_RUNS \* FIXTURE_SPAWN_TIMEOUT_MS \+\n {2}FIXTURE_TEST_HEADROOM_MS;$/mu, + ); + }); + + test("no fixture spawn carries a budget literal", () => { + const spawnBudgets = [ + ...fixtureSuite.matchAll(/timeout: (\w+)(?![\w])/gu), + ].map((match) => match[1]); + expect(spawnBudgets.length).toBe(7); + for (const name of spawnBudgets) { + expect(name).toBe("FIXTURE_SPAWN_TIMEOUT_MS"); + } + // A reintroduced literal would silently decouple a spawn deadline from the + // derived outer budget. + expect(fixtureSuite).not.toMatch(/timeout: [0-9][0-9_]*[,}]/u); + }); + + test("every test definition in the fixture suite carries the outer budget", () => { + const wrapped = [...fixtureSuite.matchAll(/^\s*fixtureTest\(/gmu)].length; + expect(wrapped).toBeGreaterThan(0); + // The only bare test( definition permitted is the wrapper's own + // delegation; any other would inherit Bun's shorter default deadline. + const bare = [...fixtureSuite.matchAll(/(? match[0].trim(), + ); + expect(bare).toEqual(["test(name, body, FIXTURE_TEST_TIMEOUT_MS);"]); + }); +}); diff --git a/tests/workflow-import.test.ts b/tests/workflow-import.test.ts index 5001461..245ffdf 100644 --- a/tests/workflow-import.test.ts +++ b/tests/workflow-import.test.ts @@ -67,6 +67,31 @@ type FixtureOptions = { dryRun?: boolean; }; +// Inner budget: the hard kill deadline applied to every fixture child process +// (the python fixture builders, the read-only sqlite3 probes, and the +// workflow-import.sh run under test). A fixture that outlives it is killed and +// reported as a hung fixture rather than stalling the suite. +const FIXTURE_SPAWN_TIMEOUT_MS = 10_000; + +// Outer budget: Bun's per-test deadline defaults to 5_000 ms, which is shorter +// than a single inner budget. A correct-but-slow fixture run therefore trips +// the outer deadline first and is misreported as a test timeout, so the outer +// deadline is derived from the inner one instead of left at the default. The +// heaviest test drives two sequential fixture runs (the fresh sidecar-free WAL +// case), so the outer budget covers MAX_SEQUENTIAL_FIXTURE_RUNS inner budgets +// plus fixture setup and assertion headroom. tests/workflow-import-budget +// .test.ts fails if this relationship is ever crossed again. +const MAX_SEQUENTIAL_FIXTURE_RUNS = 2; +const FIXTURE_TEST_HEADROOM_MS = 5_000; +const FIXTURE_TEST_TIMEOUT_MS = + MAX_SEQUENTIAL_FIXTURE_RUNS * FIXTURE_SPAWN_TIMEOUT_MS + + FIXTURE_TEST_HEADROOM_MS; + +// Every test in this file goes through this wrapper so no test can silently +// inherit a deadline shorter than the budget it drives. +const fixtureTest = (name: string, body: () => void) => + test(name, body, FIXTURE_TEST_TIMEOUT_MS); + const script = resolve(import.meta.dir, "../scripts/workflow-import.sh"); const original = "[project]\nname = 'benign-fixture'\n"; const currentSchemaObjects = "74|6E|74|"; @@ -94,7 +119,7 @@ const currentMigrations = [ ]; const currentMigrationSnapshot = currentMigrations .map((row) => { - const [version, name, checksum] = row.split("|"); + const [version = "", name = "", checksum = ""] = row.split("|"); return `${version}|${Buffer.from(name).toString("hex").toUpperCase()}|${checksum.toUpperCase()}`; }) .join("\n"); @@ -221,7 +246,7 @@ os._exit(0) authoritySchemaBase64, JSON.stringify(currentMigrations), ], - { timeout: 10_000 }, + { timeout: FIXTURE_SPAWN_TIMEOUT_MS }, ); if (result.exitCode !== 0) { throw new Error( @@ -290,7 +315,7 @@ connection.commit() os._exit(0) `; const result = Bun.spawnSync([python, "-c", program, path, modes], { - timeout: 10_000, + timeout: FIXTURE_SPAWN_TIMEOUT_MS, }); if (result.exitCode !== 0) { throw new Error( @@ -811,7 +836,7 @@ process.exit(result.exitCode);`, fixtureDatabase, "SELECT updated_at FROM projects;", ], - { timeout: 10_000 }, + { timeout: FIXTURE_SPAWN_TIMEOUT_MS }, ); if (beforeProject.exitCode !== 0) { throw new Error(beforeProject.stderr.toString()); @@ -882,7 +907,7 @@ process.exit(result.exitCode);`, FIXTURE_VERSION_OUTPUT: options.versionOutput ?? realVersionEnvelope(schemaVersion), }, - timeout: 10_000, + timeout: FIXTURE_SPAWN_TIMEOUT_MS, }); const backups = readdirSync(temp) .map((entry) => join(temp, entry, "config.toml.before")) @@ -910,7 +935,7 @@ process.exit(result.exitCode);`, authorityDatabase, "SELECT COUNT(*), COALESCE(SUM(CASE WHEN name LIKE '%fts%' THEN 1 ELSE 0 END), 0) FROM sqlite_master;", ], - { timeout: 10_000 }, + { timeout: FIXTURE_SPAWN_TIMEOUT_MS }, ); if (inventory.exitCode !== 0) { throw new Error(inventory.stderr.toString()); @@ -937,7 +962,7 @@ process.exit(result.exitCode);`, currentDatabase, "SELECT updated_at FROM projects;", ], - { timeout: 10_000 }, + { timeout: FIXTURE_SPAWN_TIMEOUT_MS }, ); if (afterProject.exitCode !== 0) { throw new Error(afterProject.stderr.toString()); @@ -964,7 +989,7 @@ process.exit(result.exitCode);`, path, "SELECT name FROM projects;", ], - { timeout: 10_000 }, + { timeout: FIXTURE_SPAWN_TIMEOUT_MS }, ); return query.exitCode === 0 ? query.stdout.toString().trim() : undefined; }; @@ -1046,7 +1071,7 @@ function expectUnknown(result: ReturnType) { describe("workflow import config preservation with fake commands", () => { for (const stage of ["init", "import", "stats", "success"]) { - test(`restores configuration after ${stage}`, () => { + fixtureTest(`restores configuration after ${stage}`, () => { const result = runFixture(stage, "overwrite"); expect(result.exitCode).toBe(stage === "success" ? 0 : 1); expect(result.config).toBe(original); @@ -1056,52 +1081,71 @@ describe("workflow import config preservation with fake commands", () => { for (const change of ["delete", "directory"]) { for (const stage of ["import", "success"]) { - test(`restores missing config after ${change} and ${stage}`, () => { - const result = runFixture(stage, change); - expect(result.exitCode).toBe(stage === "success" ? 0 : 1); - expect(result.config).toBe(original); - expect(result.backups).toEqual([]); - }); + fixtureTest( + `restores missing config after ${change} and ${stage}`, + () => { + const result = runFixture(stage, change); + expect(result.exitCode).toBe(stage === "success" ? 0 : 1); + expect(result.config).toBe(original); + expect(result.backups).toEqual([]); + }, + ); } } for (const stage of ["import", "success"]) { - test(`retains recovery backup when restoration fails after ${stage}`, () => { - const result = runFixture(stage, "overwrite", true); - expect(result.exitCode).toBe(1); - expect(result.backups).toHaveLength(1); - expect(result.backups[0]?.content).toBe(original); - expect(result.stderr).toContain(result.backups[0]!.path); - }); + fixtureTest( + `retains recovery backup when restoration fails after ${stage}`, + () => { + const result = runFixture(stage, "overwrite", true); + expect(result.exitCode).toBe(1); + expect(result.backups).toHaveLength(1); + expect(result.backups[0]?.content).toBe(original); + expect(result.stderr).toContain(result.backups[0]!.path); + }, + ); } }); describe("workflow import local state classification with fake adapters", () => { - test("accepts the real version envelope shape and classifies an absent database", () => { - const result = runFixture("success", "none", false, { database: "absent" }); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("state=absent"); - expect(result.commands).toMatch(/carryctx init\b/); - expect(result.commands).toMatch(/carryctx import\b/); - }); + fixtureTest( + "accepts the real version envelope shape and classifies an absent database", + () => { + const result = runFixture("success", "none", false, { + database: "absent", + }); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("state=absent"); + expect(result.commands).toMatch(/carryctx init\b/); + expect(result.commands).toMatch(/carryctx import\b/); + }, + ); - test("classifies a current-schema empty database and initializes before import", () => { - const result = runFixture("success", "none", false, { database: "empty" }); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("state=empty"); - expect(result.commands).toMatch(/carryctx init\b/); - expect(result.commands).toMatch(/carryctx import\b/); - }); + fixtureTest( + "classifies a current-schema empty database and initializes before import", + () => { + const result = runFixture("success", "none", false, { + database: "empty", + }); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("state=empty"); + expect(result.commands).toMatch(/carryctx init\b/); + expect(result.commands).toMatch(/carryctx import\b/); + }, + ); - test("treats a project row as non-empty even without data rows", () => { - const result = runFixture("success", "none", false, { - database: "non-empty", - projectRows: 1, - }); - expect(result.exitCode).toBe(1); - expect(result.stderr).toContain("non-empty"); - expectNoImport(result); - }); + fixtureTest( + "treats a project row as non-empty even without data rows", + () => { + const result = runFixture("success", "none", false, { + database: "non-empty", + projectRows: 1, + }); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("non-empty"); + expectNoImport(result); + }, + ); for (const table of [ "operations", @@ -1110,7 +1154,7 @@ describe("workflow import local state classification with fake adapters", () => "tombstones", "snapshot_state", ]) { - test(`blocks rows found only in ${table}`, () => { + fixtureTest(`blocks rows found only in ${table}`, () => { const result = runFixture("success", "none", false, { database: "non-empty", rowTable: table, @@ -1121,7 +1165,7 @@ describe("workflow import local state classification with fake adapters", () => }); } - test("blocks rows in actual disposable FTS shadow tables", () => { + fixtureTest("blocks rows in actual disposable FTS shadow tables", () => { const result = runFixture("success", "none", false, { realFts: true }); expect(result.exitCode).toBe(1); expect(result.stderr).toContain("non-empty"); @@ -1131,7 +1175,7 @@ describe("workflow import local state classification with fake adapters", () => expectNoImport(result); }); - test("rejects schema drift in a real 0.11.6 database", () => { + fixtureTest("rejects schema drift in a real 0.11.6 database", () => { const result = runFixture("success", "none", false, { realSchemaDrift: true, }); @@ -1140,7 +1184,7 @@ describe("workflow import local state classification with fake adapters", () => expectNoImport(result); }); - test("rejects migration drift in a real 0.11.6 database", () => { + fixtureTest("rejects migration drift in a real 0.11.6 database", () => { const result = runFixture("success", "none", false, { realMigrationDrift: true, }); @@ -1149,20 +1193,23 @@ describe("workflow import local state classification with fake adapters", () => expectNoImport(result); }); - test("force-imports a real pending WAL database with valid sidecars", () => { - const result = runFixture("success", "none", false, { - realWal: true, - force: true, - }); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("state=non-empty"); - expect(result.walPendingBytes).toBeGreaterThan(0); - expect(result.shmPendingBytes).toBeGreaterThan(0); - expect(result.projectStatePreserved).toBe(true); - expect(result.commands).toMatch(/carryctx import\b/); - }); + fixtureTest( + "force-imports a real pending WAL database with valid sidecars", + () => { + const result = runFixture("success", "none", false, { + realWal: true, + force: true, + }); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("state=non-empty"); + expect(result.walPendingBytes).toBeGreaterThan(0); + expect(result.shmPendingBytes).toBeGreaterThan(0); + expect(result.projectStatePreserved).toBe(true); + expect(result.commands).toMatch(/carryctx import\b/); + }, + ); - test("force-imports a real pending WAL database without SHM", () => { + fixtureTest("force-imports a real pending WAL database without SHM", () => { const result = runFixture("success", "none", false, { walWithoutShm: true, force: true, @@ -1173,31 +1220,37 @@ describe("workflow import local state classification with fake adapters", () => expect(result.projectStatePreserved).toBe(true); }); - test("accepts fresh sidecar-free WAL mode for dry-run and force import", () => { - const dryRun = runFixture("success", "none", false, { - freshWal: true, - dryRun: true, - versionMutatesTarget: true, - }); - expect(dryRun.exitCode).toBe(0); - expect(dryRun.databaseStable).toBe(true); + fixtureTest( + "accepts fresh sidecar-free WAL mode for dry-run and force import", + () => { + const dryRun = runFixture("success", "none", false, { + freshWal: true, + dryRun: true, + versionMutatesTarget: true, + }); + expect(dryRun.exitCode).toBe(0); + expect(dryRun.databaseStable).toBe(true); - const force = runFixture("success", "none", false, { - freshWal: true, - force: true, - }); - expect(force.exitCode).toBe(0); - expect(force.stdout).toContain("state=non-empty"); - }); + const force = runFixture("success", "none", false, { + freshWal: true, + force: true, + }); + expect(force.exitCode).toBe(0); + expect(force.stdout).toContain("state=non-empty"); + }, + ); - test("blocks a real disposable table name containing a newline", () => { - const result = runFixture("success", "none", false, { - realNewlineTable: true, - }); - expectUnknown(result); - }); + fixtureTest( + "blocks a real disposable table name containing a newline", + () => { + const result = runFixture("success", "none", false, { + realNewlineTable: true, + }); + expectUnknown(result); + }, + ); - test("allows --force for a valid non-empty database", () => { + fixtureTest("allows --force for a valid non-empty database", () => { const result = runFixture("success", "none", false, { database: "non-empty", rowTable: "tombstones", @@ -1209,23 +1262,26 @@ describe("workflow import local state classification with fake adapters", () => expect(result.commands).not.toMatch(/carryctx init\b/); }); - test("allows a dry-run for a valid non-empty database without init", () => { - const result = runFixture("success", "none", false, { - database: "non-empty", - rowTable: "tombstones", - dryRun: true, - }); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("dry-run PASS"); - expect(result.commands).toMatch(/carryctx-dry-run import\b/); - expect(result.commands).not.toMatch(/carryctx init\b/); - }); + fixtureTest( + "allows a dry-run for a valid non-empty database without init", + () => { + const result = runFixture("success", "none", false, { + database: "non-empty", + rowTable: "tombstones", + dryRun: true, + }); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("dry-run PASS"); + expect(result.commands).toMatch(/carryctx-dry-run import\b/); + expect(result.commands).not.toMatch(/carryctx init\b/); + }, + ); for (const [name, options] of [ ["WAL and SHM", { realWal: true }], ["WAL without SHM", { walWithoutShm: true }], ] as const) { - test(`keeps real ${name} state unchanged during dry-run`, () => { + fixtureTest(`keeps real ${name} state unchanged during dry-run`, () => { const result = runFixture("success", "none", false, { ...options, dryRun: true, @@ -1353,7 +1409,7 @@ describe("workflow import local state classification with fake adapters", () => ]; for (const [name, options] of unknownScenarios) { - test(`blocks ${name} before fetch, init, or import`, () => { + fixtureTest(`blocks ${name} before fetch, init, or import`, () => { const result = runFixture("success", "none", false, options); if (name === "symlinked database" || name === "symlinked WAL sidecar") { expect(result.exitCode).toBe(1); @@ -1366,7 +1422,7 @@ describe("workflow import local state classification with fake adapters", () => }); } - test("enforces the portable command timeout", () => { + fixtureTest("enforces the portable command timeout", () => { const result = runFixture("success", "none", false, { database: "non-empty", sqliteMode: "hang", @@ -1375,7 +1431,7 @@ describe("workflow import local state classification with fake adapters", () => expectUnknown(result); }); - test("blocks an existing CarryCtx admission lock", () => { + fixtureTest("blocks an existing CarryCtx admission lock", () => { const result = runFixture("success", "none", false, { database: "non-empty", rowTable: "tombstones", @@ -1387,7 +1443,7 @@ describe("workflow import local state classification with fake adapters", () => expectNoImport(result); }); - test("blocks a database mutation during fetch", () => { + fixtureTest("blocks a database mutation during fetch", () => { const result = runFixture("success", "none", false, { database: "non-empty", rowTable: "tombstones", @@ -1399,8 +1455,24 @@ describe("workflow import local state classification with fake adapters", () => expectNoImport(result, true); }); - test("preserves a writer that commits during import and aborts", () => { - const result = runFixture("success", "none", false, { + fixtureTest( + "preserves a writer that commits during import and aborts", + () => { + const result = runFixture("success", "none", false, { + database: "non-empty", + rowTable: "tombstones", + force: true, + race: "import", + }); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("state changed while staging import"); + expect(result.commands).not.toMatch(/carryctx project restore\b/); + expect(result.databaseContent).toBe("fixtureimport-race"); + }, + ); + + fixtureTest("does not restore over a writer when recovery would fail", () => { + const result = runFixture("success", "none", true, { database: "non-empty", rowTable: "tombstones", force: true, @@ -1409,93 +1481,675 @@ describe("workflow import local state classification with fake adapters", () => expect(result.exitCode).toBe(1); expect(result.stderr).toContain("state changed while staging import"); expect(result.commands).not.toMatch(/carryctx project restore\b/); - expect(result.databaseContent).toBe("fixtureimport-race"); }); - test("does not restore over a writer when recovery would fail", () => { - const result = runFixture("success", "none", true, { - database: "non-empty", - rowTable: "tombstones", + fixtureTest( + "fails closed when import replaces the database path with a symlink", + () => { + const result = runFixture("success", "none", false, { + database: "non-empty", + rowTable: "tombstones", + force: true, + race: "import-path", + }); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain( + "project paths changed while staging import", + ); + expect(result.commands).not.toMatch(/carryctx project restore\b/); + }, + ); + + fixtureTest( + "fails closed when import replaces the CarryCtx directory", + () => { + const result = runFixture("success", "none", false, { + database: "non-empty", + rowTable: "tombstones", + force: true, + race: "carryctx-dir", + }); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain( + "project paths changed while staging import", + ); + expect(result.commands).not.toMatch(/carryctx project restore\b/); + }, + ); + + fixtureTest( + "fails closed on a byte-identical database inode replacement at import", + () => { + const result = runFixture("success", "none", false, { + realWal: true, + force: true, + race: "import-inode", + }); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain( + "project paths changed while staging import", + ); + expect(result.databaseStable).toBe(true); + expect(result.databaseIdentityStable).toBe(false); + expect(result.commands).not.toMatch(/carryctx project restore\b/); + }, + ); + + fixtureTest( + "fails closed when WAL is injected into the hidden guard at handoff", + () => { + const result = runFixture("success", "none", false, { + realWal: true, + force: true, + race: "guard-wal", + }); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain( + "post-handoff committed-state validation failed", + ); + expect(result.activeProjectName).not.toContain("handoff-injected"); + expect(result.guardProjectName).toContain("handoff-injected"); + }, + ); + + fixtureTest( + "fails closed when WAL is injected after the prior handoff validation", + () => { + const result = runFixture("success", "none", false, { + realWal: true, + force: true, + race: "guard-after-validation", + }); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain( + "post-handoff committed-state validation failed", + ); + expect(result.activeProjectName).not.toContain("held-final"); + expect(result.guardProjectName).toContain("held-final"); + }, + ); + + fixtureTest("blocks an SHM inode replacement during fetch", () => { + const result = runFixture("success", "none", false, { + realWal: true, force: true, - race: "import", + race: "fetch-shm-inode", }); expect(result.exitCode).toBe(1); - expect(result.stderr).toContain("state changed while staging import"); - expect(result.commands).not.toMatch(/carryctx project restore\b/); + expect(result.stderr).toContain("changed during fetch"); + expect(result.shmIdentityStable).toBe(false); + expectNoImport(result, true); }); - test("fails closed when import replaces the database path with a symlink", () => { + fixtureTest("blocks a configuration path replacement during fetch", () => { const result = runFixture("success", "none", false, { - database: "non-empty", - rowTable: "tombstones", - force: true, - race: "import-path", + race: "config", }); expect(result.exitCode).toBe(1); - expect(result.stderr).toContain( - "project paths changed while staging import", - ); - expect(result.commands).not.toMatch(/carryctx project restore\b/); + expect(result.stderr).toContain("paths changed during fetch"); + expectNoImport(result, true); }); - test("fails closed when import replaces the CarryCtx directory", () => { + fixtureTest("uses job-control groups when setsid is unavailable", () => { const result = runFixture("success", "none", false, { database: "non-empty", rowTable: "tombstones", force: true, - race: "carryctx-dir", + noSetsid: true, }); - expect(result.exitCode).toBe(1); - expect(result.stderr).toContain( - "project paths changed while staging import", - ); - expect(result.commands).not.toMatch(/carryctx project restore\b/); + expect(result.exitCode).toBe(0); + expect(result.commands).toMatch(/carryctx import\b/); }); - test("fails closed on a byte-identical database inode replacement at import", () => { + fixtureTest("terminates descendants when a probe command times out", () => { const result = runFixture("success", "none", false, { - realWal: true, - force: true, - race: "import-inode", + database: "non-empty", + sqliteMode: "hang-descendant", + gitTimeout: 1, }); - expect(result.exitCode).toBe(1); - expect(result.stderr).toContain( - "project paths changed while staging import", - ); - expect(result.databaseStable).toBe(true); - expect(result.databaseIdentityStable).toBe(false); - expect(result.commands).not.toMatch(/carryctx project restore\b/); + expectUnknown(result); + expect(result.descendantLeak).toBe(false); + expect( + result.tempEntries.filter((entry) => + entry.startsWith("workflow-import."), + ), + ).toEqual([]); }); - test("fails closed when WAL is injected into the hidden guard at handoff", () => { + fixtureTest("does not allow --force to bypass unknown state", () => { const result = runFixture("success", "none", false, { - realWal: true, + database: "non-empty", + sqliteMode: "corrupt", force: true, - race: "guard-wal", }); - expect(result.exitCode).toBe(1); - expect(result.stderr).toContain( - "post-handoff committed-state validation failed", - ); - expect(result.activeProjectName).not.toContain("handoff-injected"); - expect(result.guardProjectName).toContain("handoff-injected"); + expectUnknown(result); }); +}); - test("fails closed when WAL is injected after the prior handoff validation", () => { - const result = runFixture("success", "none", false, { - realWal: true, - force: true, - race: "guard-after-validation", +describe("workflow import path safety", () => { + const pathScenarios: Array<[string, FixtureOptions]> = [ + ["symlinked project path", { projectSymlink: true }], + ["symlinked project path component", { projectParentSymlink: true }], + ["symlinked .carryctx directory", { carryctxSymlink: true }], + ["symlinked config", { configSymlink: true }], + ]; + + for (const [name, options] of pathScenarios) { + fixtureTest(`rejects ${name} before init, restore, or import`, () => { + const result = runFixture("success", "none", false, options); + expect(result.exitCode).toBe(1); + expect(result.config).toBe(original); + expectNoImport(result); }); - expect(result.exitCode).toBe(1); - expect(result.stderr).toContain( - "post-handoff committed-state validation failed", - ); - expect(result.activeProjectName).not.toContain("held-final"); - expect(result.guardProjectName).toContain("held-final"); - }); + } +}); + +describe("workflow import strict version envelope parsing", () => { + const envelope = realVersionEnvelope(18); + const malformed: Array<[string, string]> = [ + ["success false", envelope.replace('"success":true', '"success":false')], + ["unsupported cli", envelope.replace('"cli":"0.11.6"', '"cli":"0.11.5"')], + [ + "fractional db_schema", + envelope.replace('"db_schema":18', '"db_schema":18.0'), + ], + [ + "exponent db_schema", + envelope.replace('"db_schema":18', '"db_schema":1e2'), + ], + [ + "duplicate db_schema", + envelope.replace('"db_schema":18,', '"db_schema":18,"db_schema":18,'), + ], + [ + "unknown envelope field", + envelope.replace( + '"command":"version",', + '"command":"version","extra":true,', + ), + ], + ["missing data path", envelope.replace(',"data":{', ",{")], + ["multiple JSON lines", `${envelope}\n{}`], + ["malformed JSON", "{"], + ]; + + for (const [name, versionOutput] of malformed) { + fixtureTest(`rejects ${name} before sqlite or import`, () => { + const result = runFixture("success", "none", false, { + database: "non-empty", + versionOutput, + }); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("unknown"); + expect(result.commands).not.toMatch(/sqlite3 /); + expectNoImport(result); + }); + } +}); + +describe("workflow import local state classification with fake adapters", () => { + fixtureTest( + "accepts the real version envelope shape and classifies an absent database", + () => { + const result = runFixture("success", "none", false, { + database: "absent", + }); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("state=absent"); + expect(result.commands).toMatch(/carryctx init\b/); + expect(result.commands).toMatch(/carryctx import\b/); + }, + ); + + fixtureTest( + "classifies a current-schema empty database and initializes before import", + () => { + const result = runFixture("success", "none", false, { + database: "empty", + }); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("state=empty"); + expect(result.commands).toMatch(/carryctx init\b/); + expect(result.commands).toMatch(/carryctx import\b/); + }, + ); + + fixtureTest( + "treats a project row as non-empty even without data rows", + () => { + const result = runFixture("success", "none", false, { + database: "non-empty", + projectRows: 1, + }); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("non-empty"); + expectNoImport(result); + }, + ); + + for (const table of [ + "operations", + "sequences", + "worktree_cleanup_requests", + "tombstones", + "snapshot_state", + ]) { + fixtureTest(`blocks rows found only in ${table}`, () => { + const result = runFixture("success", "none", false, { + database: "non-empty", + rowTable: table, + }); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("non-empty"); + expectNoImport(result); + }); + } + + fixtureTest("blocks rows in actual disposable FTS shadow tables", () => { + const result = runFixture("success", "none", false, { realFts: true }); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("non-empty"); + expect(result.authoritySchemaObjectCount).toBe(129); + expect(result.authorityFtsRelatedCount).toBe(36); + expect(result.config).toBe(original); + expectNoImport(result); + }); + + fixtureTest("rejects schema drift in a real 0.11.6 database", () => { + const result = runFixture("success", "none", false, { + realSchemaDrift: true, + }); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("unknown"); + expectNoImport(result); + }); + + fixtureTest("rejects migration drift in a real 0.11.6 database", () => { + const result = runFixture("success", "none", false, { + realMigrationDrift: true, + }); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("unknown"); + expectNoImport(result); + }); + + fixtureTest( + "force-imports a real pending WAL database with valid sidecars", + () => { + const result = runFixture("success", "none", false, { + realWal: true, + force: true, + }); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("state=non-empty"); + expect(result.walPendingBytes).toBeGreaterThan(0); + expect(result.shmPendingBytes).toBeGreaterThan(0); + expect(result.projectStatePreserved).toBe(true); + expect(result.commands).toMatch(/carryctx import\b/); + }, + ); + + fixtureTest("force-imports a real pending WAL database without SHM", () => { + const result = runFixture("success", "none", false, { + walWithoutShm: true, + force: true, + }); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("state=non-empty"); + expect(result.walPendingBytes).toBeGreaterThan(0); + expect(result.projectStatePreserved).toBe(true); + }); + + fixtureTest( + "accepts fresh sidecar-free WAL mode for dry-run and force import", + () => { + const dryRun = runFixture("success", "none", false, { + freshWal: true, + dryRun: true, + versionMutatesTarget: true, + }); + expect(dryRun.exitCode).toBe(0); + expect(dryRun.databaseStable).toBe(true); + + const force = runFixture("success", "none", false, { + freshWal: true, + force: true, + }); + expect(force.exitCode).toBe(0); + expect(force.stdout).toContain("state=non-empty"); + }, + ); + + fixtureTest( + "blocks a real disposable table name containing a newline", + () => { + const result = runFixture("success", "none", false, { + realNewlineTable: true, + }); + expectUnknown(result); + }, + ); + + fixtureTest("allows --force for a valid non-empty database", () => { + const result = runFixture("success", "none", false, { + database: "non-empty", + rowTable: "tombstones", + force: true, + }); + expect(result.exitCode).toBe(0); + expect(result.stderr).toContain("--force"); + expect(result.commands).toMatch(/carryctx import\b/); + expect(result.commands).not.toMatch(/carryctx init\b/); + }); + + fixtureTest( + "allows a dry-run for a valid non-empty database without init", + () => { + const result = runFixture("success", "none", false, { + database: "non-empty", + rowTable: "tombstones", + dryRun: true, + }); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("dry-run PASS"); + expect(result.commands).toMatch(/carryctx-dry-run import\b/); + expect(result.commands).not.toMatch(/carryctx init\b/); + }, + ); + + for (const [name, options] of [ + ["WAL and SHM", { realWal: true }], + ["WAL without SHM", { walWithoutShm: true }], + ] as const) { + fixtureTest(`keeps real ${name} state unchanged during dry-run`, () => { + const result = runFixture("success", "none", false, { + ...options, + dryRun: true, + versionMutatesTarget: true, + }); + expect(result.exitCode).toBe(0); + expect(result.databaseStable).toBe(true); + expect(result.walPendingBytes).toBeGreaterThan(0); + expect(result.walSidecarsStable).toBe(true); + if (name === "WAL and SHM") { + expect(result.shmPendingBytes).toBeGreaterThan(0); + } + expect(result.commands).toMatch( + /carryctx version --json --project .*version-contract/, + ); + expect(result.commands).not.toMatch(/carryctx version --json\n/); + }); + } + + const unknownScenarios: Array<[string, FixtureOptions]> = [ + ["unreadable", { database: "non-empty", sqliteMode: "unreadable" }], + ["locked", { database: "non-empty", sqliteMode: "locked" }], + ["timed out", { database: "non-empty", sqliteMode: "timeout" }], + ["corrupt", { database: "non-empty", sqliteMode: "corrupt" }], + [ + "newer", + { + database: "non-empty", + migrationCount: 19, + migrationMax: 19, + migrationDistinct: 19, + }, + ], + [ + "partial migration history", + { + database: "non-empty", + migrationCount: 17, + migrationMax: 17, + migrationDistinct: 17, + }, + ], + [ + "malformed schema output", + { database: "non-empty", sqliteMode: "malformed" }, + ], + [ + "migration identity drift", + { database: "non-empty", sqliteMode: "migration-drift" }, + ], + [ + "schema object drift", + { database: "non-empty", sqliteMode: "schema-drift" }, + ], + [ + "oversized schema object", + { database: "non-empty", sqliteMode: "schema-line-overflow" }, + ], + [ + "foreign-key violation", + { database: "non-empty", sqliteMode: "foreign-key" }, + ], + [ + "table inventory failure", + { database: "non-empty", sqliteMode: "table-list-error" }, + ], + [ + "missing project table", + { database: "non-empty", sqliteMode: "missing-projects" }, + ], + [ + "missing migration table", + { database: "non-empty", sqliteMode: "missing-schema-migrations" }, + ], + ["row count failure", { database: "non-empty", sqliteMode: "count-error" }], + [ + "authority schema failure", + { database: "non-empty", authorityFailure: true }, + ], + ["malformed project row", { database: "non-empty", projectInvalid: 1 }], + [ + "missing sqlite adapter", + { database: "non-empty", sqliteAvailable: false }, + ], + ["non-file database path", { database: "directory" }], + [ + "unavailable schema contract", + { database: "non-empty", versionFails: true }, + ], + [ + "unavailable schema contract for absent database", + { database: "absent", versionFails: true }, + ], + ["orphan WAL sidecar", { database: "absent", orphanSidecar: true }], + [ + "newline-containing table name", + { database: "non-empty", sqliteMode: "newline-table" }, + ], + [ + "overlong table name", + { database: "non-empty", sqliteMode: "long-table-name" }, + ], + [ + "too many tables", + { database: "non-empty", sqliteMode: "too-many-tables" }, + ], + [ + "duplicate table inventory entry", + { database: "non-empty", sqliteMode: "duplicate-table" }, + ], + [ + "invalid WAL sidecar", + { database: "non-empty", sidecars: "wal", sqliteMode: "wal-invalid" }, + ], + [ + "invalid SHM sidecar", + { database: "non-empty", sidecars: "shm", sqliteMode: "wal-invalid" }, + ], + [ + "invalid WAL and SHM sidecars", + { database: "non-empty", sidecars: "both", sqliteMode: "wal-invalid" }, + ], + ["symlinked database", { database: "non-empty", databaseSymlink: true }], + ["symlinked WAL sidecar", { database: "non-empty", sidecarSymlink: true }], + ]; + + for (const [name, options] of unknownScenarios) { + fixtureTest(`blocks ${name} before fetch, init, or import`, () => { + const result = runFixture("success", "none", false, options); + if (name === "symlinked database" || name === "symlinked WAL sidecar") { + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("symlink"); + expect(result.config).toBe(original); + expectNoImport(result); + } else { + expectUnknown(result); + } + }); + } + + fixtureTest("enforces the portable command timeout", () => { + const result = runFixture("success", "none", false, { + database: "non-empty", + sqliteMode: "hang", + gitTimeout: 1, + }); + expectUnknown(result); + }); + + fixtureTest("blocks an existing CarryCtx admission lock", () => { + const result = runFixture("success", "none", false, { + database: "non-empty", + rowTable: "tombstones", + force: true, + commandLock: true, + }); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("already in progress"); + expectNoImport(result); + }); + + fixtureTest("blocks a database mutation during fetch", () => { + const result = runFixture("success", "none", false, { + database: "non-empty", + rowTable: "tombstones", + force: true, + race: "database", + }); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("changed during fetch"); + expectNoImport(result, true); + }); + + fixtureTest( + "preserves a writer that commits during import and aborts", + () => { + const result = runFixture("success", "none", false, { + database: "non-empty", + rowTable: "tombstones", + force: true, + race: "import", + }); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("state changed while staging import"); + expect(result.commands).not.toMatch(/carryctx project restore\b/); + expect(result.databaseContent).toBe("fixtureimport-race"); + }, + ); + + fixtureTest("does not restore over a writer when recovery would fail", () => { + const result = runFixture("success", "none", true, { + database: "non-empty", + rowTable: "tombstones", + force: true, + race: "import", + }); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("state changed while staging import"); + expect(result.commands).not.toMatch(/carryctx project restore\b/); + }); + + fixtureTest( + "fails closed when import replaces the database path with a symlink", + () => { + const result = runFixture("success", "none", false, { + database: "non-empty", + rowTable: "tombstones", + force: true, + race: "import-path", + }); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain( + "project paths changed while staging import", + ); + expect(result.commands).not.toMatch(/carryctx project restore\b/); + }, + ); + + fixtureTest( + "fails closed when import replaces the CarryCtx directory", + () => { + const result = runFixture("success", "none", false, { + database: "non-empty", + rowTable: "tombstones", + force: true, + race: "carryctx-dir", + }); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain( + "project paths changed while staging import", + ); + expect(result.commands).not.toMatch(/carryctx project restore\b/); + }, + ); + + fixtureTest( + "fails closed on a byte-identical database inode replacement at import", + () => { + const result = runFixture("success", "none", false, { + realWal: true, + force: true, + race: "import-inode", + }); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain( + "project paths changed while staging import", + ); + expect(result.databaseStable).toBe(true); + expect(result.databaseIdentityStable).toBe(false); + expect(result.commands).not.toMatch(/carryctx project restore\b/); + }, + ); + + fixtureTest( + "fails closed when WAL is injected into the hidden guard at handoff", + () => { + const result = runFixture("success", "none", false, { + realWal: true, + force: true, + race: "guard-wal", + }); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain( + "post-handoff committed-state validation failed", + ); + expect(result.activeProjectName).not.toContain("handoff-injected"); + expect(result.guardProjectName).toContain("handoff-injected"); + }, + ); + + fixtureTest( + "fails closed when WAL is injected after the prior handoff validation", + () => { + const result = runFixture("success", "none", false, { + realWal: true, + force: true, + race: "guard-after-validation", + }); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain( + "post-handoff committed-state validation failed", + ); + expect(result.activeProjectName).not.toContain("held-final"); + expect(result.guardProjectName).toContain("held-final"); + }, + ); - test("blocks an SHM inode replacement during fetch", () => { + fixtureTest("blocks an SHM inode replacement during fetch", () => { const result = runFixture("success", "none", false, { realWal: true, force: true, @@ -1507,7 +2161,7 @@ describe("workflow import local state classification with fake adapters", () => expectNoImport(result, true); }); - test("blocks a configuration path replacement during fetch", () => { + fixtureTest("blocks a configuration path replacement during fetch", () => { const result = runFixture("success", "none", false, { race: "config", }); @@ -1516,7 +2170,7 @@ describe("workflow import local state classification with fake adapters", () => expectNoImport(result, true); }); - test("uses job-control groups when setsid is unavailable", () => { + fixtureTest("uses job-control groups when setsid is unavailable", () => { const result = runFixture("success", "none", false, { database: "non-empty", rowTable: "tombstones", @@ -1527,7 +2181,7 @@ describe("workflow import local state classification with fake adapters", () => expect(result.commands).toMatch(/carryctx import\b/); }); - test("terminates descendants when a probe command times out", () => { + fixtureTest("terminates descendants when a probe command times out", () => { const result = runFixture("success", "none", false, { database: "non-empty", sqliteMode: "hang-descendant", @@ -1542,7 +2196,7 @@ describe("workflow import local state classification with fake adapters", () => ).toEqual([]); }); - test("does not allow --force to bypass unknown state", () => { + fixtureTest("does not allow --force to bypass unknown state", () => { const result = runFixture("success", "none", false, { database: "non-empty", sqliteMode: "corrupt", @@ -1561,7 +2215,7 @@ describe("workflow import path safety", () => { ]; for (const [name, options] of pathScenarios) { - test(`rejects ${name} before init, restore, or import`, () => { + fixtureTest(`rejects ${name} before init, restore, or import`, () => { const result = runFixture("success", "none", false, options); expect(result.exitCode).toBe(1); expect(result.config).toBe(original); @@ -1600,7 +2254,7 @@ describe("workflow import strict version envelope parsing", () => { ]; for (const [name, versionOutput] of malformed) { - test(`rejects ${name} before sqlite or import`, () => { + fixtureTest(`rejects ${name} before sqlite or import`, () => { const result = runFixture("success", "none", false, { database: "non-empty", versionOutput, diff --git a/tsconfig.check.json b/tsconfig.check.json new file mode 100644 index 0000000..0389820 --- /dev/null +++ b/tsconfig.check.json @@ -0,0 +1,13 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": ".", + "noEmit": true, + "declaration": false, + "declarationMap": false, + "sourceMap": false, + "types": ["bun", "node"] + }, + "include": ["src/**/*", "bin/**/*", "tests/**/*"], + "exclude": ["tests/campaign.test.ts"] +}