diff --git a/.agents/sessions/audit-phases-p0-p3-2026-10/AUDIT-REPORT.md b/.agents/sessions/audit-phases-p0-p3-2026-10/AUDIT-REPORT.md new file mode 100644 index 0000000000..8dded7a6f9 --- /dev/null +++ b/.agents/sessions/audit-phases-p0-p3-2026-10/AUDIT-REPORT.md @@ -0,0 +1,282 @@ +# Audit Report — audit-phases-p0-p3-2026-10 + +Generated 2026-10-04. Synthesized from 8 shard finding files. 165 raw findings → 162 consolidated (3 merged duplicate facets): **0 CRITICAL, 6 HIGH, 70 MEDIUM, 86 LOW**. + +## Top 10 highest-leverage fixes +1. [HIGH] security — sdk/src/serve/serve.ts:76 — SEC-7 containment unwired: forward projectRoot/allowedAdditionalDirectories through both serve transports, fail loudly when omitted +2. [HIGH] security — common/src/mcp/dns-pinning.ts:271 — non-Bun https path skips pinning (DNS-rebinding TOCTOU): fail closed or share one DNS resolution between validation and dial +3. [HIGH] correctness — sdk/src/services/semgrep-baseline.ts:158 — SIGKILL escalation armed at spawn kills healthy scans >5s: arm escalate inside the deadline callback (pattern exists in diagnostic-delta-runner.ts) +4. [HIGH] performance — sdk/src/tools/transaction-intent-log.ts:625 — O(n²) whole-file read-rewrite+fsync per append: append-only writes; keep read-rewrite only for bounded trim +5. [HIGH] performance — packages/agent-runtime/src/util/run-journal.ts:500 — per-append full-table retention scan (512 MiB cap): maintain a running per-run byte total instead +6. [HIGH] security — sdk/src/env.ts:72 — child-env credential scrub is a fixed 8-key denylist: derive strip set from configured provider apiKeyEnv presets + regression test (consolidates env.ts:60 contract facet and env.test.ts:210 echo-test facet) +7. [MEDIUM] security — sdk/src/serve/event-bridge.ts:500 — tool_call rawInput bypasses NEW-3 credential holdback: apply credential-value substitution over rawInput +8. [MEDIUM] security — agents/context-pruner.ts:1900 — pinned active-work extraction runs on raw model text: sanitize before extraction (durable prompt-injection channel) +9. [MEDIUM] security — scripts/memory-drift-guard.ts:700 — unsigned review-receipt forgeable to suppress staleness findings: bind receipt to reviewer run +10. [MEDIUM] correctness — sdk/src/services/semgrep-baseline.ts:412 — exit-1-with-findings treated as failure: parse SARIF, return ok for findings-found code + +## Cross-cutting findings (span multiple shards) + +- **Cache/registry unboundedness / bounds enforced at read not write** (p1-acp-serve, p0-safety, p2-durability, pr-cq-compaction, indexer-codemap): ACP sessions Map capped at 16 but never evicted → permanent DoS (acp-agent.ts:620); `runningClients`/`listToolsCache` unbounded + poisoned rejected promise (mcp/client.ts:640,700); per-session bridge maps never deleted (bridge.ts:155); `pipeProbeCache` never evicted, deferred user-data-dir cleanups unswept (browser-logs.ts:950); `parsedCacheByRoot.set` bypasses `evictOldestRootCacheIfNeeded` on write paths (metadata-indexer.ts:113); `inFlightDetections` uncapped/undeadline (scip-runner.ts:660); oversize tmp artifacts accumulate forever with default perms (spawn-agent-utils.ts:1505); `refs/openbuff/turns` grows against git gc forever (turn-snapshots.ts:40). +- **Fail-open error swallowing** (p0-safety, p1-acp-serve, p2-durability, pr-cq-compaction, indexer-codemap): `chromeSandboxArgs` fails open to `--no-sandbox` (browser-logs.ts:674); memory-drift-guard `checkStaleness` returns zero findings on any git failure — gate fails open (memory-drift-guard.ts:470); socket 'error' swallowed, close() unlinks a rebound socket (socket-listener.ts:197); watcher-worker crash degrades silently (index-dir-watch.ts:175); SCIP worker-load failure silently degrades every ingest to main-thread 256MiB parse (scip-runner.ts:850); metadata cache-write / hash-read failures swallowed (3d-assets.ts:930, metadata-indexer.ts:262); CDP writeFrame rejections discarded (cdp-pipe-transport.ts:233); turn finally-flush can mask the run error (bridge.ts:660). +- **Spawn-time vs deadline-timer arming / missing hard deadlines** (p3-language, p2-durability, indexer-codemap): semgrep SIGKILL escalate armed at spawn (semgrep-baseline.ts:158); supervisor settle has no absolute deadline race — unkillable child hangs the supervisor forever (process-supervisor.ts:345); SCIP timeout coverage is seam-only, no process-level test (scip-runner.test.ts:241). The correct in-deadline-callback pattern already exists in diagnostic-delta-runner.ts:129-135 — it is applied inconsistently. +- **Unvalidated bytes trusted at trust boundaries** (p1-acp-serve, pr-cq-compaction, indexer-codemap): journal-restored gate snapshot type-asserted, not schema-validated — tampered journal forges "gate passed" on the wire (session-data.ts:770); forgeable unsigned review receipt (memory-drift-guard.ts:700); workspace-writable 3D metadata concepts ingested with shallow validation (metadata-indexer.ts:661); unbounded SCIP symbol strings persisted as query-facing labels (scip-ingest.ts:350); "last non-empty stdout line wins" weakens the supervised one-line contract (process-supervisor.ts:430). Pattern: sibling record types (capabilities, receipts) get validation; gate snapshots, artifacts, and labels do not. +- **Credential-boundary inconsistency — scrub at one site per path, not a chokepoint** (p0-safety, p1-cli, p1-acp-serve, pr-cq-compaction, p2-durability): fixed denylist (env.ts:72) bypassed by caller-env merge-after-scrub (run-terminal-command.ts:640), by git preflight spawning with full env (run-terminal-command.ts:41), by CLI handing full process.env as credentialEnv via the raw-export helper (serve-command.ts:118 ↔ env.ts:87), by tool_call rawInput skipping NEW-3 holdback (event-bridge.ts:500), by plaintext pre-images persisted to shared state (transaction-intent-log.ts:200), by unsanitized command output in cross-session task memory (context-pruner.ts:2700). +- **Implemented-but-unwired plan features** (p1-cli, p1-acp-serve, p2-durability): OutboundQueue (§12.6 backpressure) is dead code — transports write straight to the writable (outbound.ts:700); DA1 image-capability parser has no production call site (terminal-images.ts:219); replay driver is test-only, production resume seam unwired (run-replay-driver.ts:200). Pattern: component exists and is unit-tested, production seam never connected — plan claims overstate shipped behavior. + +## Plan-claim verification + +**VERIFIED HOLDING** (LOW-severity caveats noted): +- X-2a hot paths (perf-baseline parity rows) — caveat: baseline script run-state not reentrancy-safe (measure-perf-guards-baseline.ts:160, LOW) +- PageRank iteration/epsilon clamps — caveat: damping clamp admits exactly 1.0 (pagerank.ts:96, LOW) +- SCIP fail-closed ingestion (verified; adjacent MEDIUMs concern label hygiene/runner hygiene, not the fail-closed contract) +- Tokenizer caps + exact-tokenizer seam — caveat: `systemAndToolsTokens` not included in `messagesTokens` (token-counter.ts:520, LOW) +- Frozen capability manifest v1 — caveat: deserialized manifests don't enforce "every language exactly once" (language-capability-manifest.ts:85, LOW) +- NEW-2 trust default fail-closed — caveat: asymmetric realpath canonicalization of allowlist entries (trusted-roots.ts:8, LOW) +- CDP pipe transport fail-closed — caveat: writeFrame rejections swallowed (cdp-pipe-transport.ts:233, LOW) +- Terminal policy fail-closed (verified) +- P0-T6 paired bootstrap CI soundness — caveat: asymmetric percentile indices (statistics.ts:115, LOW) + +**NOT FULLY HOLDING:** +- P0-T1 env scrub is denylist-not-allowlist (env.ts:72, HIGH); tests mirror the implementation, verifying only its own echo (env.test.ts:210) +- Caller env merged after the scrub can re-inject stripped credential names (run-terminal-command.ts:640) +- DA1 image capability query not wired to production (terminal-images.ts:219) +- P0-T8 Wilcoxon significance gate: normal approximation at arbitrarily small n, no minimum-sample guard (statistics.ts:180) +- GV-15..GV-30 security golden vectors largely pin fixture-declared expectations, not implemented behavior (acp-ext-v1.golden.test.ts:660) +- Pruner knowledge-memory ceiling unenforceable under many distinct reviewers (context-pruner.ts:1480) +- D26 eviction-archive recall delivers 4k-truncated content vs the full-content claim (context-archive.ts:150) + +## De-duplication notes +- No identical finding duplicated across shards at the same file:line; overlap is at pattern level (captured above). +- Merged: env.ts:72 (HIGH) + env.ts:60 (api-contract) + env.test.ts:210 (test-coverage) → one Security HIGH; spawn-agent-utils.ts:1505 security + test-coverage → one entry. +- Correlated pairs kept separate: env.ts:87 raw export ↔ serve-command.ts:118 full-env credentialEnv; zod caret pin (common/package.json:45) ↔ MCP SDK zod-v3 shim (mcp/server.ts:1290); @types-in-runtime-deps (sdk/package.json:30 ↔ common/package.json:18). + +## Findings by domain + +### Security +- [HIGH] sdk/src/env.ts:72 — 8-key denylist passes other provider credentials (GEMINI_API_KEY, apiKeyEnv values) to children — derive strip set from configured presets; add leak regression test. Consolidates env.ts:60 (document denylist contract on the export) and env.test.ts:210 (suite mirrors implementation's delete list; structurally can't catch a real leak). +- [HIGH] sdk/src/serve/serve.ts:76 — SEC-7 projectRoot omitted in both transports; clients operate outside intended root — add to RunServeOptions, fail loudly when omitted. +- [HIGH] common/src/mcp/dns-pinning.ts:271 — Node/undici https skips pin; check-then-connect TOCTOU — fail closed or cache one DNS resolution across validate+dial. +- [MEDIUM] sdk/src/tools/run-terminal-command.ts:41 — git preflight spawns with full unscrubbed env — pass getChildProcessEnv(). +- [MEDIUM] sdk/src/tools/run-terminal-command.ts:640 — caller env merged after scrub re-injects credential names — re-apply denylist after merge. +- [MEDIUM] sdk/src/tools/browser-logs.ts:674 — sandbox probe fails open to --no-sandbox; misses Ubuntu 24.04 AppArmor userns — check apparmor_restrict_unprivileged_userns; fail closed when undeterminable. +- [MEDIUM] sdk/src/services/acp/acp-agent.ts:990 — ext methods serve any sessionId, no per-connection ownership check — validate against per-connection map. +- [MEDIUM] sdk/src/serve/socket-listener.ts:176 — bind→chmod race: socket connectable before 0o600 applied — pre-create with restrictive umask / chmod before accept. +- [MEDIUM] sdk/src/serve/event-bridge.ts:500 — tool_call rawInput bypasses NEW-3 holdback — substitute credential values over rawInput. +- [MEDIUM] sdk/src/mcp/server.ts:915 — MCP read_files echoes live cap.v3 token; issuer runId constant 'mcp' across clients — structured opt-in field or per-client issuer. +- [MEDIUM] cli/src/serve-command.ts:118 — full process env as credentialEnv — pass filtered credential-key allowlist. +- [MEDIUM] cli/src/utils/terminal-notify.ts:20 — OSC 9/777 body/title unsanitized; terminal escape injection — strip control chars. +- [MEDIUM] cli/src/utils/tree-sitter-client.ts:213 — predictable shared /tmp grammar cache; poisonable/symlinkable — per-uid/mkdtemp isolation + ownership check. +- [MEDIUM] cli/src/cli-args.ts:139 — empty --socket selects socket transport with empty path — reject empty values like --socket-token. +- [MEDIUM] sdk/src/tools/transaction-intent-log.ts:200 — plaintext file pre-images (possible secrets) persisted in shared state dir — restrict perms, encrypt or exclude sensitive paths. +- [MEDIUM] sdk/src/services/build-graph.ts:544 — python testCommand interpolates unsanitized directory (command injection) — route through safeCommandToken. +- [MEDIUM] packages/agent-runtime/src/tools/handlers/tool/spawn-agent-utils.ts:1505 — oversize tmp artifacts unbounded, world-readable, predictable path — 0o600 + TTL sweep + sanitizer. (Consolidates test-coverage: no persistence/round-trip tests.) +- [MEDIUM] agents/context-pruner.ts:1900 — pinned-state extraction on raw model text → durable prompt injection/memory poisoning — sanitize before extraction. +- [MEDIUM] scripts/memory-drift-guard.ts:700 — unsigned local review receipt forgeable, suppresses staleness forever — bind to reviewer run; validate schema at load. +- [MEDIUM] cli/src/utils/payload-sanitizer.ts:60 — 'auth'/'passphrase'/'pwd'/'credentialsJson' not redacted — extend CREDENTIAL_WORDS/CARRIER_SUFFIXES + tests. +- [MEDIUM] packages/indexer/src/metadata-indexer.ts:661 — 3D metadata concepts ingested from workspace-writable file, shallow validation — allowlist charset/length or gate on trust. +- [MEDIUM] packages/indexer/src/scip-ingest.ts:350 — unbounded SCIP symbols persisted as query-facing labels — cap length, truncate/hash. +- [LOW] sdk/src/env.ts:87 — getSystemProcessEnv exports raw env — remove or mark unsafe. +- [LOW] common/src/tools/params/tool/find-files-matching-content.ts:40 — cwd allows absolute out-of-project paths — gate on sandbox tier. +- [LOW] common/src/tools/__tests__/read-files-schema.test.ts:138 — unbounded fragment JSON.parse in preprocessor — cap count/size. +- [LOW] cli/src/utils/trusted-roots.ts:8 — asymmetric realpath canonicalization — canonicalize both sides. +- [LOW] cli/src/serve-command.ts:126 — socket path no lstat/symlink pre-check — validate before bind. +- [LOW] cli/src/utils/turn-snapshots.ts:195 — snapshots capture uncommitted (possibly secret) content, pushable via --mirror — bound retention, limit exposure. +- [LOW] sdk/src/dash/server.ts:455 — token in ?token= URL — header-only default. +- [LOW] agents/context-pruner.ts:2700 — command output persisted verbatim to cross-session memory — sanitize persisted memory. +- [LOW] packages/indexer/src/metadata-indexer.ts:1150 — raw manifest/CI commands as query-facing concepts — cap length, normalize. +- [LOW] packages/indexer/src/scip-runner.ts:712 — PATH-resolved scip-* binaries execute with cwd=projectRoot — document trust model / absolute paths / confirm. +- [LOW] packages/indexer/src/index-store.ts:1010 — lock-pid probe correctly conservative (informational; no fix). + +### Correctness +- [HIGH] sdk/src/services/semgrep-baseline.ts:158 — escalate timer armed at spawn kills scans >5s — arm inside deadline callback. +- [MEDIUM] sdk/src/services/semgrep-baseline.ts:412 — exit 1 (findings found) → error, findings dropped — return ok with parsed SARIF. +- [MEDIUM] common/src/tools/compile-tool-definitions.ts:128 — allOf/patternProperties/$defs/prefixItems silently dropped — throw on unrecognized keys. +- [MEDIUM] common/src/tools/compile-tool-definitions.ts:160 — nested additionalProperties:false falsy-checked → Record — branch on `=== false`. +- [MEDIUM] sdk/src/services/acp/acp-agent.ts:640 — concurrent prompt() replaces AbortController; first turn uncancellable — reject/abort prior turn. +- [MEDIUM] common/src/mcp/client.ts:640 — listToolsCache caches rejected promise forever — TTL/invalidate + no-op catch. +- [MEDIUM] sdk/src/services/acp/session-data.ts:770 — journal-restored gate snapshot unvalidated — zod-validate, re-derive phase. +- [MEDIUM] evals/buffbench/statistics.ts:180 — Wilcoxon normal approx at any n≥1 — minimum-n guard or exact test. +- [MEDIUM] sdk/src/tools/transaction-intent-log.ts:500 — lock-break unlink race deletes fresh lock → dual writers — identity-verify before unlink. +- [MEDIUM] sdk/src/tools/transaction-intent-log.ts:430 — trim evicts live sibling's unresolved tx_begin — pin unfinished groups. +- [MEDIUM] packages/agent-runtime/src/util/run-journal.ts:870 — batching defers tool_call completion marker past execution → replay double-execution window — force flush before side-effecting tools. +- [MEDIUM] cli/src/utils/turn-snapshots.ts:40 — snapshot ref chain unbounded, gc-immune, --mirror-publishable — retention policy or alternates store. +- [MEDIUM] sdk/src/services/lsp-multiplexer.ts:875 — workspaceSymbol routes to most-recent server regardless of language — language hint or unavailable. +- [MEDIUM] sdk/src/services/harness-intelligence.ts:80 — discoverNamedFiles unbounded recursion (build-graph F3 hardening not mirrored) — port depth/visited caps. +- [MEDIUM] sdk/src/services/diagnostic-delta.ts:241 — strict matcher default rejects shifted pre-existing errors — tolerant/hybrid default on broker path. +- [MEDIUM] packages/agent-runtime/src/tools/handlers/tool/spawn-agent-utils.ts:2770 — supervised settles collapsed into 'crashed' outcome — structured outcome field. +- [MEDIUM] packages/agent-runtime/src/util/context-archive.ts:150 — eviction archive 4k truncation vs D26 full-content contract; shares 8-snapshot cap with semantic snapshots — raise cap or fix claim + round-trip test. +- [MEDIUM] agents/context-pruner.ts:1480 — ceiling unenforceable with many distinct reviewers — bound excluded fields by total chars. +- [MEDIUM] cli/src/utils/payload-sanitizer.ts:230 — no recursion-depth limit in sanitizeValue; no caps in sanitizeMediaForUiState — maxDepth + caps. +- [MEDIUM] packages/indexer/src/query-data.ts:80 — cap-truncated candidate union reported as df, skewing IDF — return undefined when cap saturated. +- [LOW] common/package.json:8 — exports default './src*.ts' missing '/' — fix to './src/*.ts'. +- [LOW] common/src/protocol/acp-ext-v1.ts:470 — inconsistent empty-value guards; null crashes wire schema — single nonEmptyString helper. +- [LOW] sdk/src/tools/3d-assets.ts:290 — stdout+stderr merged; marker corruption/UTF-8 splits — separate buffers. +- [LOW] sdk/src/tools/3d-assets.ts:340 — OPENBUFF_3D_OPERATIONS unbounded (E2BIG) — cap or pass via file. +- [LOW] evals/buffbench/statistics.ts:115 — asymmetric bootstrap percentile indices — paired indices pinned in test. +- [LOW] sdk/src/tools/cdp-pipe-transport.ts:233 — writeFrame rejections swallowed — reject pending entry. +- [LOW] sdk/src/services/acp/acp-agent.ts:1100 — NdJSON line-byte guard conflates chunk/line boundaries — reset per newline. +- [LOW] cli/src/commands/dash-command.ts:165 — existsSync→openJournal TOCTOU lets dash create the db — read-only open flag. +- [LOW] cli/src/cli-args.ts:128 — --stdio ignored when combined with --socket — error or document precedence. +- [LOW] packages/agent-runtime/src/supervision/process-supervisor.ts:430 — last-non-empty-line-wins weakens one-line contract — reject multi-line stdout. +- [LOW] packages/agent-runtime/src/tools/handlers/tool/spawn-agent-utils.ts:2430 — lastSetOutputError may permanently downgrade recovered child — clear-on-success or attempt-index stamp. +- [LOW] packages/agent-runtime/src/util/context-archive.ts:275 — early-return over-reports snapshotsSearched — report actual index reached. +- [LOW] packages/agent-runtime/src/util/archive-recall-index.ts:150 — one non-tokenizable term disables FTS for whole query — drop only that term; equivalence tests. +- [LOW] packages/agent-runtime/src/util/tool-result-eviction.ts:55 — tombstone detected by prefix; genuine results misclassified — detect full shape. +- [LOW] scripts/memory-drift-guard.ts:705 — receipt shape check lacks schemaVersion/hash pinning — enforce v1 + 64-hex. +- [LOW] sdk/src/services/language-intelligence.ts:124 — toLspPosition line 0 → -1 — clamp ≥0. +- [LOW] sdk/src/services/symbol-enrichment.ts:245 — inlayHint range end one past EOF — length-1. +- [LOW] sdk/src/services/semgrep-baseline.ts:409 — stderr-sniff timeout detection false positives — use structured signal. +- [LOW] sdk/src/services/harness-intelligence.ts:460 — cargo -p uses dir basename; 'unknown run ' }], + gate: null, + } + const html = renderDashHtml(snapshot) + expect(html).toContain('window.__DASH_DATA__') + expect(html).not.toContain('') + expect(html).toContain(''.replace(/` blob + * (so the exported file works fully offline, with no fetch and NO token — a + * static export has no auth because it is a local file) plus one JSON data + * file per endpoint. + * + * SECURITY NOTE: an exported HTML contains whatever the provider returned. + * Providers must never include secrets in their responses; the default + * journal provider surfaces already-redacted wire shapes (receipts/gate), + * so the default pipeline redacts nothing further. + * + * Path safety: the export never writes outside `outDir`. Every target file + * is resolved against `outDir` and the resolved path must keep the resolved + * directory as a prefix — a hostile outDir or filename cannot escape. + */ + +import { mkdir, mkdtemp, stat, writeFile } from 'node:fs/promises' +import path from 'node:path' + +import { DASH_MAX_EVENTS, renderDashHtml } from './server' + +import type { DashDataProvider, DashSnapshot } from './server' + +export type ExportDashStaticParams = { + outDir: string + data: DashDataProvider + /** + * Accepted for signature compatibility with the server; the token is + * NEVER written to disk and the static page never uses it. + */ + token?: string +} + +export type ExportDashStaticResult = + | { ok: true; files: string[] } + | { ok: false; error: string } + +const EXPORT_FILES = [ + 'index.html', + 'runs.json', + 'events.json', + 'receipts.json', + 'gate.json', +] as const + +/** + * Pure containment predicate over already-resolved absolute paths. The + * directory separator is appended only when the resolved outDir does not + * already end with one, so an outDir that resolves to a filesystem root + * ('/' on POSIX, 'C:\\' on Windows) no longer fails every export (the + * separator-appended root was no longer a prefix of anything). Exported for + * direct testing. + */ +export function isInsideOutDir( + resolvedOutDir: string, + resolvedTarget: string, +): boolean { + if (resolvedTarget === resolvedOutDir) { + return true + } + const prefix = resolvedOutDir.endsWith(path.sep) + ? resolvedOutDir + : resolvedOutDir + path.sep + return resolvedTarget.startsWith(prefix) +} + +/** + * Assert a target file stays inside outDir (resolve + prefix check): the + * export NEVER writes outside outDir, whatever a caller passes. + */ +function assertInsideOutDir(outDir: string, target: string): void { + const resolvedTarget = path.resolve(target) + if (!isInsideOutDir(path.resolve(outDir), resolvedTarget)) { + throw new Error( + `openbuff dash export: refusing to write outside outDir (${resolvedTarget})`, + ) + } +} + +function boundedMessage(error: unknown): string { + const message = error instanceof Error ? error.message : String(error) + return message.length <= 300 ? message : `${message.slice(0, 297)}...` +} + +/** + * Export the dashboard as a static site into outDir. `outDir` is created if + * missing; the actual files land in a fresh `dash-` mkdtemp subdirectory + * inside it (so a shared export root never collides). On success the five + * written files are returned with ABSOLUTE paths; on any failure the error + * is returned as `{ ok: false, error }` (bounded message), never thrown. + */ +export async function exportDashStatic( + params: ExportDashStaticParams, +): Promise { + try { + const { outDir, data } = params + if (typeof outDir !== 'string' || outDir.trim().length === 0) { + return { ok: false, error: 'openbuff dash export: outDir is required' } + } + + let dir: string + try { + await mkdir(outDir, { recursive: true }) + const stat_ = await stat(outDir) + if (!stat_.isDirectory()) { + return { + ok: false, + error: `openbuff dash export: outDir is not a directory: ${outDir}`, + } + } + // mkdtemp into outDir keeps every write under it even for a shared root. + dir = await mkdtemp(path.join(path.resolve(outDir), 'dash-')) + } catch (error) { + return { ok: false, error: `openbuff dash export: ${boundedMessage(error)}` } + } + + // Gather the snapshot FIRST: runs, then each run's events (bounded to the + // same cap the live server serves), then receipts and gate. + const runs = await data.listRuns() + const events: DashSnapshot['events'] = {} + for (const run of runs) { + const runEvents = await data.getRunEvents(run.runId) + events[run.runId] = runEvents.slice(-DASH_MAX_EVENTS) + } + const receipts = await data.getReceipts() + const gate = await data.getGateState() + + const snapshot: DashSnapshot = { runs, events, receipts, gate } + + const files: string[] = [] + for (const name of EXPORT_FILES) { + const target = path.join(dir, name) + assertInsideOutDir(outDir, target) + const payload = + name === 'runs.json' + ? runs + : name === 'events.json' + ? events + : name === 'receipts.json' + ? receipts + : gate + // A provider may violate its contract and return undefined for + // receipts/gate; mirror the server-side inliner's undefined→null + // fallback so gate.json/receipts.json never contain the invalid JSON + // text 'undefined'. + const content = + name === 'index.html' + ? renderDashHtml(snapshot) + : JSON.stringify(payload ?? null, null, 2) + '\n' + await writeFile(target, content, 'utf8') + files.push(target) + } + + return { ok: true, files } + } catch (error) { + return { ok: false, error: `openbuff dash export: ${boundedMessage(error)}` } + } +} diff --git a/sdk/src/dash/provider.ts b/sdk/src/dash/provider.ts new file mode 100644 index 0000000000..1ea61b728b --- /dev/null +++ b/sdk/src/dash/provider.ts @@ -0,0 +1,170 @@ +/** + * P2-T7: the default {@link DashDataProvider} backed by a P2-T2 run journal + * (plus optional receipt/gate seams). Deliberately thin and injectable: the + * journal reader is a seam (`JournalReader`), so tests inject hermetic fakes + * and the dashboard never opens sqlite itself. When no reader is wired the + * provider serves EMPTY run/event data (additive-and-optional, matching the + * journal contract) while receipts/gate still surface from their seams. + */ + +import type { JournalRunSummary } from '@codebuff/agent-runtime/util/run-journal' + +import type { JournalReader } from '@codebuff/common/types/contracts/agent-runtime' + +import type { DashDataProvider, DashRunEvent, DashRunSummary } from './server' + +/** payloadSummary is a bounded JSON.stringify of the event payload. */ +export const MAX_PAYLOAD_SUMMARY_LENGTH = 300 + +/** Bounded listRuns: at most the 64 most recent runs are surfaced. */ +export const MAX_LISTED_RUNS = 64 + +/** + * Bounded JSON summary of a journal payload: JSON.stringify, truncated to + * `MAX_PAYLOAD_SUMMARY_LENGTH` chars. A cyclic/unserializable payload falls + * back to a constant marker so the provider can never throw on a + * partially-shaped journal row. + */ +export function summarizePayload(payload: unknown): string { + let text: string + try { + text = JSON.stringify(payload) ?? 'undefined' + } catch { + return '[unserializable payload]' + } + if (text.length <= MAX_PAYLOAD_SUMMARY_LENGTH) return text + return `${text.slice(0, MAX_PAYLOAD_SUMMARY_LENGTH)}...` +} + +/** + * REAL wall-clock timestamp (ISO 8601) for a journal row, from the + * `createdAt` (epoch ms) the reader surfaced — or undefined when the journal + * carries no timestamp. Never a synthetic ordering label: a field named + * createdAt must carry a timestamp or be absent. + */ +function createdAtIsoFor(event: { createdAt?: number }): string | undefined { + return typeof event.createdAt === 'number' + ? new Date(event.createdAt).toISOString() + : undefined +} + +function mapEvent(event: { + seq: number + eventType: string + payload: unknown + createdAt?: number +}): DashRunEvent { + const createdAt = createdAtIsoFor(event) + return { + seq: event.seq, + eventType: event.eventType, + payloadSummary: summarizePayload(event.payload), + ...(createdAt !== undefined ? { createdAt } : {}), + } +} + +export type CreateDashProviderFromJournalParams = { + /** P2-T2 run-journal reader; when absent, runs/events are empty arrays. */ + journalReader?: JournalReader + /** + * Run-id enumeration seam. `JournalReader.events` is per-runId, so + * listing runs needs a source of ids: an explicit injection wins, then + * the reader's optional non-contract `runIds()` extension (the production + * sqlite journal provides it). Without either, listRuns surfaces no runs + * (fail-closed, never guesses ids). + */ + runIds?: () => string[] + /** Redacted receipt wire shapes; defaults to empty. */ + receipts?: () => unknown[] + /** Redacted gate state; defaults to null. */ + gateState?: () => unknown | null +} + +/** + * Build the dashboard provider from the journal/receipt/gate seams. + * + * listRuns is O(runs), not O(events): when the reader exposes the optional + * non-contract `runSummaries` extension (the production sqlite journal + * provides it — one bounded COUNT + MIN(created_at) aggregate per runId), + * summaries come straight from that aggregate and the reader's events are + * never hydrated per poll. Readers without the extension fall back to the + * per-run `events()` scan over the ids exposed through the optional + * `runIds` extension (the production sqlite journal provides it too), + * bounded to `MAX_LISTED_RUNS`; a reader with neither surfaces no runs + * (fail-closed, never guesses ids). Both paths keep the same output shape: + * `eventCount`, plus the run's REAL `startedAt` when the journal carries a + * timestamp for its first event (omitted otherwise). + */ +export function createDashProviderFromJournal( + params: CreateDashProviderFromJournalParams = {}, +): DashDataProvider { + const journalReader = params.journalReader + + const knownRunIds = (): string[] => { + if (params.runIds) return params.runIds().slice(0, MAX_LISTED_RUNS) + if (!journalReader) return [] + const readerWithIds = journalReader as JournalReader & { + runIds?: () => string[] + } + if (typeof readerWithIds.runIds === 'function') { + return readerWithIds.runIds().slice(0, MAX_LISTED_RUNS) + } + return [] + } + + return { + async listRuns(): Promise { + if (!journalReader) return [] + // O(runs) path: the optional runSummaries extension (the production + // sqlite journal provides it) serves one bounded COUNT + MIN(created_at) + // aggregate per runId, so the reader's events are never hydrated here. + const readerWithSummaries = journalReader as JournalReader & { + runSummaries?: () => JournalRunSummary[] + } + if (typeof readerWithSummaries.runSummaries === 'function') { + return readerWithSummaries + .runSummaries() + .slice(0, MAX_LISTED_RUNS) + .map((summary) => { + const startedAt = + typeof summary.firstCreatedAt === 'number' + ? new Date(summary.firstCreatedAt).toISOString() + : undefined + return { + runId: summary.runId, + eventCount: summary.eventCount, + ...(startedAt !== undefined ? { startedAt } : {}), + } + }) + } + // Fallback for readers without the extension: the per-run events() scan + // over the ids runIds() exposes — same output shape, same + // startedAt-omission behavior. + const summaries: DashRunSummary[] = [] + for (const runId of knownRunIds()) { + const events = journalReader.events(runId) + if (events.length === 0) continue + const startedAt = createdAtIsoFor(events[0]!) + summaries.push({ + runId, + eventCount: events.length, + ...(startedAt !== undefined ? { startedAt } : {}), + }) + } + return summaries + }, + + async getRunEvents(runId: string): Promise { + if (!journalReader) return [] + return journalReader.events(runId).map((event) => mapEvent(event)) + }, + + async getReceipts(): Promise { + return params.receipts?.() ?? [] + }, + + async getGateState(): Promise { + return params.gateState?.() ?? null + }, + } +} diff --git a/sdk/src/dash/server.ts b/sdk/src/dash/server.ts new file mode 100644 index 0000000000..a5f3c277ee --- /dev/null +++ b/sdk/src/dash/server.ts @@ -0,0 +1,499 @@ +/** + * P2-T7: `openbuff dash` — a localhost-only HTTP dashboard that replays run + * journals, receipts, and gate timelines. + * + * Security invariants: + * - Bound to 127.0.0.1 ONLY (never 0.0.0.0): the dashboard exposes run + * journals and receipts, so it must never leave the loopback interface. + * - Every route except `/healthz` requires the shared token, presented via + * `Authorization: Bearer ` (primary) OR the legacy `?token=` query + * parameter (kept: the dash URL carries `?token=` and a plain browser + * navigation cannot set headers, so query tokens must remain accepted). + * The comparison is + * constant-time (sha256 digests of BOTH sides, mirroring + * `serve/socket-listener.ts`'s constant-time token style) and a failure + * returns 401 without revealing which side mismatched. + * - The token is never embedded in the served HTML: the page re-uses the + * `?token=` query the browser already has. Static exports (`export.ts`) + * inline the DATA but never the token — an exported HTML file contains + * whatever the provider returned, so providers must not include secrets + * (the default journal provider surfaces already-redacted wire shapes). + * - JSON only, no CORS headers, no cookies: the page is same-origin by + * construction and nothing invites cross-origin reads. + */ + +import { createHash, randomBytes, timingSafeEqual } from 'node:crypto' + +import type { Logger } from '@codebuff/common/types/contracts/logger' + +// The SDK declaration build (sdk/tsconfig.build.json) typechecks this file +// under `types: ["node"]` — WITHOUT bun-types — so the Bun-global `Bun.serve` +// below would fail TS2868 there. The dash server runs only under the Bun +// runtime, which supplies the global at runtime; this module-scope `declare +// const` (never exported) gives the build only the narrow structural surface +// this module actually uses, keeping the declaration build green without +// adding bun-types (which breaks dts-bundle-generator). +declare const Bun: { + serve: (options: { + hostname: string + port: number + fetch: (req: Request) => Response | Promise + error?: (error: unknown) => Response | Promise + }) => { + port: number + stop: (closeActiveConnections?: boolean) => void + } +} + +/** Summary row for GET /api/runs. */ +export type DashRunSummary = { + runId: string + /** + * Wall-clock start timestamp (ISO 8601) of the run's FIRST journaled + * event. Omitted when the journal carries no timestamp for it — a real + * timestamp or nothing, never a synthetic ordering label. + */ + startedAt?: string + eventCount: number +} + +/** Timeline row for GET /api/runs/:runId/events. */ +export type DashRunEvent = { + seq: number + eventType: string + /** + * Wall-clock timestamp (ISO 8601) of the event. Omitted when the journal + * carries no timestamp for it — a real timestamp or nothing, never a + * synthetic ordering label. + */ + createdAt?: string + payloadSummary: string +} + +/** + * Injectable data seam for the dashboard. Deliberately decoupled from the + * journal: tests inject fakes, and CLI wiring decides what backs it. + */ +export type DashDataProvider = { + listRuns(): Promise + getRunEvents(runId: string): Promise + getReceipts(): Promise + getGateState(): Promise +} + +/** Snapshot of everything the dashboard renders (inlined by the static export). */ +export type DashSnapshot = { + runs: DashRunSummary[] + events: Record + receipts: unknown[] + gate: unknown | null +} + +export type StartDashServerParams = { + /** TCP port on 127.0.0.1; 0 (the default) binds a random free port. */ + port?: number + token: string + data: DashDataProvider + logger?: Logger +} + +export type DashServerHandle = { + /** URL including the `?token=` query so it can be opened directly. */ + url: string + close: () => Promise +} + +/** Hard cap on events returned per request (the LATEST 2000 win). */ +export const DASH_MAX_EVENTS = 2000 + +/** runId path segments must be a safe, filename-like token. */ +const RUN_ID_PATTERN = /^[A-Za-z0-9_-]+$/ + +/** Marker replaced with the inlined data blob by {@link renderDashHtml}. */ +const INLINE_BLOB_MARKER = '/*OPENBUFF_DASH_INLINE_BLOB*/' + +/** + * 256-bit CSPRNG hex token, used when the CLI caller passes no --token and no + * OPENBUFF_DASH_TOKEN is set. The CALLER prints it to stderr (never stdout — + * stdout may be piped). + */ +export function generateDashToken(): string { + return randomBytes(32).toString('hex') +} + +/** + * Constant-time token comparison on sha256 digests: both digests are fixed + * 32-byte values, so timingSafeEqual cannot throw on a length mismatch and + * the comparison leaks neither the expected token nor which side mismatched. + */ +function tokenMatches(provided: string, expected: string): boolean { + const providedDigest = createHash('sha256').update(provided).digest() + const expectedDigest = createHash('sha256').update(expected).digest() + return timingSafeEqual(providedDigest, expectedDigest) +} + +/** + * The request's presented token, or null when the Authorization header is + * present but NOT a Bearer scheme. A present-but-unrecognized header must + * NEVER silently downgrade to the legacy `?token=` query channel: it is an + * explicit signal the caller sent the wrong credential shape, so it maps to + * a plain 401 instead. + * + * Otherwise: the `Authorization: Bearer` header when present (the PRIMARY, + * preferred channel), or the `?token=` query parameter (kept LEGACY/less- + * preferred but accepted: the dash URL carries `?token=` and a plain browser + * navigation cannot set headers). No Authorization header at all leaves the + * query channel fully working. An absent token compares as the empty string + * (still constant-time, still a plain 401). + */ +function extractProvidedToken(req: Request, url: URL): string | null { + const auth = req.headers.get('authorization') + if (auth !== null) { + return auth.startsWith('Bearer ') ? auth.slice('Bearer '.length) : null + } + return url.searchParams.get('token') ?? '' +} + +function jsonResponse(body: unknown, status: number): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }) +} + +function boundedErrorMessage(error: unknown): string { + const message = error instanceof Error ? error.message : String(error) + return message.length <= 300 ? message : `${message.slice(0, 297)}...` +} + +/** JSON.stringify that never yields `undefined` (scripts/JSON files need text). */ +function jsonForScript(value: unknown): string { + // `` blob so it works fully + * offline. All dynamic text is rendered via textContent (no innerHTML), so + * provider-supplied strings cannot inject markup. + */ +const DASH_PAGE_HTML = ` + + + + +openbuff dash + + + +
+

openbuff dash

+

run journals · receipts · gate timelines

+
+
+
+

Runs

+
    +
    +
    +
    +

    Event timeline

    + + + +
    seqeventatpayload
    +

    +
    +
    +

    Receipts

    +
    
    +    
    +
    +

    Gate

    +
    
    +    
    +
    +
    + + + + +` + +/** + * Render the dashboard page. With no argument the page fetches the four API + * endpoints (server mode); with a snapshot the data is INLINED and the page + * works offline (static-export mode). The token is NEVER embedded. + */ +export function renderDashHtml(inlineData?: DashSnapshot): string { + const blob = + inlineData === undefined + ? '' + : 'window.__DASH_DATA__ = ' + jsonForScript(inlineData) + ';' + // A function replacer: `$` sequences inside JSON must not be interpreted + // as replacement patterns. + return DASH_PAGE_HTML.replace(INLINE_BLOB_MARKER, () => blob) +} + +/** + * Start the dashboard server bound to 127.0.0.1 ONLY. Resolves with the + * bound URL (including the token query) and a close handle. A bind failure + * (e.g. the port is already in use) surfaces as a clean thrown Error, never + * an unhandled crash; per-request provider failures surface as generic 500s. + */ +export async function startDashServer( + params: StartDashServerParams, +): Promise { + const port = params.port ?? 0 + if (!Number.isInteger(port) || port < 0 || port > 65535) { + throw new Error(`openbuff dash: port must be an integer in [0, 65535], got ${port}`) + } + const { token, data, logger } = params + + // An empty (or whitespace-only) EXPECTED token is an auth bypass: + // tokenMatches compares sha256 digests and sha256('') === sha256(''), so + // every request — including the unauthenticated no-token case — would + // authorize. The CLI rejects an empty --token at parse time for exactly + // this reason; the SDK surface must not be weaker than the CLI, so the + // guard lives here where every consumer is covered. + if (typeof token !== 'string' || token.trim().length === 0) { + throw new Error( + 'openbuff dash: token must be a nonempty string (an empty expected token is an auth bypass)', + ) + } + + const handle: (req: Request, url: URL) => Promise = async ( + req, + url, + ) => { + const pathname = url.pathname + // Liveness probe: intentionally the ONLY unauthenticated route, and it + // returns no data. + if (pathname === '/healthz') { + return jsonResponse({ ok: true }, 200) + } + // A null presented token means a present-but-non-Bearer Authorization + // header: rejected outright instead of silently falling through to the + // legacy query-token channel. + const providedToken = extractProvidedToken(req, url) + if (providedToken === null || !tokenMatches(providedToken, token)) { + return jsonResponse({ error: 'unauthorized' }, 401) + } + if (req.method !== 'GET') { + return jsonResponse({ error: 'method not allowed' }, 405) + } + if (pathname === '/') { + return new Response(renderDashHtml(), { + headers: { 'content-type': 'text/html; charset=utf-8' }, + }) + } + if (pathname === '/api/runs') { + return jsonResponse(await data.listRuns(), 200) + } + if (pathname === '/api/receipts') { + return jsonResponse(await data.getReceipts(), 200) + } + if (pathname === '/api/gate') { + return jsonResponse(await data.getGateState(), 200) + } + const eventsMatch = /^\/api\/runs\/([^/]+)\/events$/.exec(pathname) + if (eventsMatch) { + let runId: string + try { + runId = decodeURIComponent(eventsMatch[1]) + } catch { + return jsonResponse({ error: 'invalid runId' }, 400) + } + if (!RUN_ID_PATTERN.test(runId)) { + return jsonResponse({ error: 'invalid runId' }, 400) + } + const events = await data.getRunEvents(runId) + // Bounded: only the LATEST 2000 events are served. + return jsonResponse(events.slice(-DASH_MAX_EVENTS), 200) + } + return jsonResponse({ error: 'not found' }, 404) + } + + type BunServer = ReturnType + let server: BunServer + try { + server = Bun.serve({ + // Loopback ONLY, by design. + hostname: '127.0.0.1', + port, + async fetch(req) { + return handle(req, new URL(req.url)) + }, + error(error) { + logger?.warn?.( + { error: boundedErrorMessage(error) }, + 'openbuff dash request failed', + ) + return jsonResponse({ error: 'internal error' }, 500) + }, + }) + } catch (error) { + throw new Error( + `openbuff dash: failed to bind 127.0.0.1:${port} (${boundedErrorMessage(error)}); is the port already in use?`, + ) + } + + const url = `http://127.0.0.1:${server.port}/?token=${encodeURIComponent(token)}` + return { + url, + close: async () => { + await server.stop(true) + }, + } +} diff --git a/sdk/src/env.ts b/sdk/src/env.ts index 211b1de75b..c4fbed8df6 100644 --- a/sdk/src/env.ts +++ b/sdk/src/env.ts @@ -12,6 +12,8 @@ import { } from '@codebuff/common/constants/chatgpt-oauth' import { getBaseEnv } from '@codebuff/common/env-process' +import { CREDENTIAL_ENV_KEYS } from './credential-env-keys' + import type { SdkEnv } from './types/env' /** @@ -32,6 +34,9 @@ export const getSdkEnv = (): SdkEnv => ({ OVERRIDE_TARGET: process.env.OVERRIDE_TARGET, OVERRIDE_PLATFORM: process.env.OVERRIDE_PLATFORM, OVERRIDE_ARCH: process.env.OVERRIDE_ARCH, + + // Diagnostic-delta preflight match mode + OPENBUFF_DIAGNOSTIC_DELTA_MODE: process.env.OPENBUFF_DIAGNOSTIC_DELTA_MODE, }) /** @@ -46,10 +51,89 @@ export const getOpenbuffApiKeyFromEnv = (): string | undefined => { * alias so existing imports continue to resolve after the SDK rename. */ export const getCodebuffApiKeyFromEnv = getOpenbuffApiKeyFromEnv +/** + * UNSAFE for child-process environments: returns the RAW `process.env`, + * including every agent credential (API keys, OAuth tokens, BYOK secrets). + * Never hand this to a spawned child process — use {@link getChildProcessEnv} + * (or {@link scrubChildProcessEnv} for merged caller overrides), which strip + * the canonical credential denylist. Kept as an export because existing + * consumers intentionally read the live agent-process environment; new + * callers almost always want one of the scrubbing helpers instead. + */ export const getSystemProcessEnv = (): NodeJS.ProcessEnv => { return process.env } +/** + * Environment for spawned child shell commands (e.g. run_terminal_command). + * + * Returns a shallow copy of process.env with every key in the canonical + * credential denylist {@link CREDENTIAL_ENV_KEYS} removed. The denylist is + * an EXACT-NAME list (no wildcard filtering) maintained in + * ./credential-env-keys; it covers Openbuff's own BYOK/OAuth/API + * credentials, the generic upstream provider API keys, other common + * provider credentials (GEMINI_API_KEY, DEEPSEEK_API_KEY, ...), and the + * built-in preset providers' apiKeyEnv names. Child shell commands + * legitimately need the rest of the environment (PATH, HOME, and the + * user's own project vars), so only those credential-named keys are + * deleted — a child command (or a compromised dependency it invokes) must + * not be able to read any of those secrets. Custom openbuff.json apiKeyEnv + * names beyond the canonical list are NOT stripped here; add such a name + * to CREDENTIAL_ENV_KEYS if it must never reach child processes. Deleting + * an absent key is a harmless no-op, and the copy is a new object so + * process.env itself is never mutated. + */ +export const getChildProcessEnv = (): NodeJS.ProcessEnv => { + return scrubChildProcessEnv(process.env) +} + +/** + * Whether `name` case-insensitively matches an entry in the canonical + * credential denylist {@link CREDENTIAL_ENV_KEYS}. Windows environment + * blocks are case-insensitive, so a credential stored under a + * non-canonical case (e.g. 'openai_api_key') is still what a child shell + * resolves for %OPENAI_API_KEY%. Exported for testing. + */ +const LOWERCASED_CREDENTIAL_ENV_KEYS = new Set( + CREDENTIAL_ENV_KEYS.map((key) => key.toLowerCase()), +) + +export const matchesCredentialEnvKeyIgnoreCase = (name: string): boolean => + LOWERCASED_CREDENTIAL_ENV_KEYS.has(name.toLowerCase()) + +/** + * Applies the same credential strip as {@link getChildProcessEnv} to an + * arbitrary environment object: every key in the canonical denylist + * {@link CREDENTIAL_ENV_KEYS} is removed by exact name (no wildcards; + * deleting an absent key is a no-op) and a NEW object is returned — the + * input, including process.env, is never mutated. On win32 the match is + * additionally case-insensitive, because Windows environment blocks are + * case-insensitive and a credential stored under a non-canonical case + * (e.g. 'openai_api_key') would otherwise survive the copy while the + * child shell still resolves %OPENAI_API_KEY% to it; on POSIX only + * exact-name deletes happen, to avoid surprising case-collisions on + * case-sensitive platforms. Used for merge-after-scrub: when + * caller-supplied env overrides are merged into a child-process env, the + * merged object is passed here so credential-named keys cannot be + * re-injected through the override. + */ +export const scrubChildProcessEnv = ( + env: NodeJS.ProcessEnv, +): NodeJS.ProcessEnv => { + const scrubbed = { ...env } + for (const key of CREDENTIAL_ENV_KEYS) { + delete scrubbed[key] + } + if (process.platform === 'win32') { + for (const key of Object.keys(scrubbed)) { + if (matchesCredentialEnvKeyIgnoreCase(key)) { + delete scrubbed[key] + } + } + } + return scrubbed +} + export const getByokOpenrouterApiKeyFromEnv = (): string | undefined => { return process.env[BYOK_OPENROUTER_ENV_VAR] } diff --git a/sdk/src/impl/agent-runtime.ts b/sdk/src/impl/agent-runtime.ts index 9b60de5cd3..3ebdcf7bf3 100644 --- a/sdk/src/impl/agent-runtime.ts +++ b/sdk/src/impl/agent-runtime.ts @@ -8,12 +8,32 @@ import { success } from '@codebuff/common/util/error' import { promptAiSdk, promptAiSdkStream, promptAiSdkStructured } from './llm' import { resolveModelContextWindow } from './model-provider' +import { + getByokOpenrouterApiKeyFromEnv, + getChatGptOAuthTokenFromEnv, + getOpenbuffApiKeyFromEnv, + getSystemProcessEnv, +} from '../env' + +// The key list comes from a leaf module so this seam can enumerate it +// WITHOUT evaluating the supervisor module: supervised-spawn keeps the +// supervisor (and the receipt-schema imports it pulls) lazy behind its +// dynamic import, so the flag-off hot path never touches them. +import { SUPERVISED_CHILD_RUNTIME_ENV_KEYS } from '@codebuff/agent-runtime/supervision/supervised-child-env-keys' +import { + buildDefaultSpawnSupervised, + isSupervisedSpawnSupported, +} from '@codebuff/agent-runtime/supervision/supervised-spawn' +import type { SupervisedChildEnvSeed } from '@codebuff/agent-runtime/supervision/process-supervisor' + +import type { RunResumeReport } from '@codebuff/agent-runtime/util/run-journal' import type { AgentRuntimeDeps, AgentRuntimeScopedDeps, } from '@codebuff/common/types/contracts/agent-runtime' import type { DatabaseAgentCache } from '@codebuff/common/types/contracts/database' +import type { ConsumeCreditsWithFallbackFn } from '@codebuff/common/types/contracts/billing' import type { ClientEnv } from '@codebuff/common/types/contracts/env' import type { Logger } from '@codebuff/common/types/contracts/logger' import type { TrackEventFn } from '@codebuff/common/types/contracts/analytics' @@ -29,28 +49,150 @@ import type { const databaseAgentCache: DatabaseAgentCache = new Map() +const PROCESS_SUPERVISION_ON = /^(1|true|yes|on)$/i +const PROCESS_SUPERVISION_OFF = /^(0|false|no|off)$/i + +/** + * P2-T8: resolution of the `OPENBUFF_PROCESS_SUPERVISION` env var. + * Supervision is ON by default; the value is trimmed and matched + * case-insensitively against whole tokens: + * + * unset / empty / whitespace-only => true (default on) + * `1` / `true` / `yes` / `on` => true + * `0` / `false` / `no` / `off` => false (opt-out) + * any other value => true (keeps the default) + * + * Matching is anchored, so e.g. `nothing` is not read as `no`. This only + * resolves the env var: when the caller passes no `processSupervision`, + * getAgentRuntimeImpl also requires the supervised child to be launchable + * (see resolveDefaultProcessSupervision), so under Node, inside the + * compiled CLI binary, and on Windows subagents stay in-process. Exported so + * tests can assert the truth table. + */ +export const isProcessSupervisionEnabled = ( + env: Record, +): boolean => { + const raw = env.OPENBUFF_PROCESS_SUPERVISION + if (raw === undefined) { + return true + } + return !PROCESS_SUPERVISION_OFF.test(raw.trim()) +} + +/** + * P2-T8: the default when the caller passes no `processSupervision`. On when + * the env var allows it AND the supervised child can be launched in this + * runtime (isSupervisedSpawnSupported). If the env var explicitly asks for + * supervision (`1`/`true`/`yes`/`on`) but the child cannot be launched, a + * warning is logged and subagents run in-process. + */ +const resolveDefaultProcessSupervision = (logger: Logger): boolean => { + const env = getSystemProcessEnv() + if (!isProcessSupervisionEnabled(env)) { + return false + } + if (isSupervisedSpawnSupported()) { + return true + } + const raw = env.OPENBUFF_PROCESS_SUPERVISION + if (raw !== undefined && PROCESS_SUPERVISION_ON.test(raw.trim())) { + logger.warn( + { OPENBUFF_PROCESS_SUPERVISION: raw }, + 'Process supervision was requested, but the supervised child process cannot be launched in this runtime (it needs Bun with the child entry file on disk, not on Windows); subagents will run in-process.', + ) + } + return false +} + +/** + * P2-T8: the supervised-child env allowlist seed, sourced EXPLICITLY at this + * SDK seam (the only place ambient `process.env` may be read for this + * feature — agent-runtime production files never read ambient env). + * + * PATH/HOME are deliberately NOT seeded here: the default seam invokes the + * child through the absolute `process.execPath`, so no PATH inheritance is + * needed for the Bun runtime itself, and the P2-T8b bridged child runs the + * agent loop over the sandbox RPC socket rather than native tools. A later + * slice that enables native-tool children must seed them explicitly and + * document that choice on `SupervisedChildEnvSeed`. + */ +const buildSupervisedChildEnvSeedFromSystemEnv = + (): SupervisedChildEnvSeed => { + const env = getSystemProcessEnv() + // Runtime pass-through keys (provider-config endpoint override, proxy, + // TMPDIR/locale, ripgrep override) are picked ONE named key at a time + // from the closed SUPERVISED_CHILD_RUNTIME_ENV_KEYS universe — ambient + // env is never forwarded wholesale. + const passthroughEnv: SupervisedChildEnvSeed['passthroughEnv'] = {} + for (const key of SUPERVISED_CHILD_RUNTIME_ENV_KEYS) { + const value = env[key] + if (typeof value === 'string' && value.length > 0) { + passthroughEnv[key] = value + } + } + return { + ...(getOpenbuffApiKeyFromEnv() + ? { openbuffApiKey: getOpenbuffApiKeyFromEnv() } + : {}), + ...(getByokOpenrouterApiKeyFromEnv() + ? { byokOpenrouterApiKey: getByokOpenrouterApiKeyFromEnv() } + : {}), + ...(getChatGptOAuthTokenFromEnv() + ? { chatGptOauthToken: getChatGptOAuthTokenFromEnv() } + : {}), + ...(env.NODE_ENV ? { nodeEnv: env.NODE_ENV } : {}), + ...(Object.keys(passthroughEnv).length > 0 ? { passthroughEnv } : {}), + } + } + +/** + * P2-audit-fix-8: the concrete deps superset this seam returns — the contract + * deps plus the additive-optional production `resumeDriver` + * (sdk/src/services/run-resume-driver.ts). The key is present ONLY when the + * caller wired one, so a run without a journal carries no resumeDriver field + * and stays byte-identical. + */ +export type AgentRuntimeImplDeps = AgentRuntimeDeps & + AgentRuntimeScopedDeps & { + resumeDriver?: (report: RunResumeReport) => Promise + } + export function getAgentRuntimeImpl( params: { logger?: Logger apiKey: string clientEnv?: ClientEnv + /** P2-audit-fix-8: production crash-resume driver (additive-optional). */ + resumeDriver?: (report: RunResumeReport) => Promise } & Pick< - AgentRuntimeScopedDeps, - | 'handleStepsLogChunk' - | 'requestToolCall' - | 'requestMcpToolData' - | 'requestFiles' - | 'requestOptionalFile' - | 'fileSystem' - | 'fileFilter' - | 'sendAction' - | 'sendSubagentChunk' - >, -): AgentRuntimeDeps & AgentRuntimeScopedDeps { + AgentRuntimeDeps, + | 'journalWriter' + | 'journalReader' + | 'processSupervision' + | 'spawnSupervised' + > & + Pick< + AgentRuntimeScopedDeps, + | 'handleStepsLogChunk' + | 'requestToolCall' + | 'requestMcpToolData' + | 'requestFiles' + | 'requestOptionalFile' + | 'fileSystem' + | 'fileFilter' + | 'sendAction' + | 'sendSubagentChunk' + >, +): AgentRuntimeImplDeps { const { logger, apiKey, clientEnv = clientEnvDefault, + journalWriter, + journalReader, + resumeDriver, + processSupervision, + spawnSupervised, handleStepsLogChunk, requestToolCall, requestMcpToolData, @@ -79,10 +221,7 @@ export function getAgentRuntimeImpl( addAgentStep: localAddAgentStep, // Billing - consumeCreditsWithFallback: async () => - success({ - chargedToOrganization: false, - }), + consumeCreditsWithFallback: localConsumeCreditsWithFallback, // LLM promptAiSdkStream, @@ -100,6 +239,95 @@ export function getAgentRuntimeImpl( logger: logger ?? noopLogger, fetch: globalThis.fetch, + // Durable run journal (P2-T2). Additive-optional: the keys are present + // ONLY when a journal is actually wired (conditional spreads), so a run + // without a journal carries no journal fields at all and stays + // byte-identical to today. + ...(journalWriter ? { journalWriter } : {}), + ...(journalReader ? { journalReader } : {}), + + // P2-audit-fix-8: production resume driver. Additive-optional exactly + // like the journal deps above: the key exists ONLY when the caller wired + // one, so the default run's deps carry no resumeDriver field at all. + ...(resumeDriver ? { resumeDriver } : {}), + + // Process supervision (P2-T8). The keys are present ONLY when + // supervision resolves on. An explicit `processSupervision: true` forces + // it on and `false` forces it off. When the caller passes nothing, it is + // ON by default wherever the supervised child can be launched (Bun + // runtime, child entry file on disk, not Windows), off under Node and + // inside the compiled CLI binary, and off when + // `OPENBUFF_PROCESS_SUPERVISION` is `0`/`false`/`no`/`off` (see + // resolveDefaultProcessSupervision). The seam is seeded from this SDK env + // seam (via getSystemProcessEnv) — never from ambient process.env inside + // agent-runtime. When supervision is on WITHOUT an injected seam, the + // default supervisor seam (supervision/supervised-spawn.ts → + // supervision/child-entry.ts) is built here. + ...(processSupervision === true || + (processSupervision === undefined && + resolveDefaultProcessSupervision(logger ?? noopLogger)) + ? { + processSupervision: true, + spawnSupervised: + spawnSupervised ?? + // P2-T8b: the default seam now carries the parent-side RPC handler + // table built from the deps available in THIS scope, so the + // supervised child runs the REAL agent loop over the sandbox Unix + // socket instead of degrading to 'unsupported-deps'. The handler + // table is constructed only on this flag-on branch (the flag-off + // path never evaluates it and stays byte-identical). The parent's + // own apiKey/logger/trackEvent/database stubs are stamped into + // every bridged request; scoped client deps (sendAction, + // requestToolCall, ...) flow through unchanged. + buildDefaultSpawnSupervised( + buildSupervisedChildEnvSeedFromSystemEnv(), + // P2-T8b lazy-load discipline: the handler table is supplied as + // a THUNK, so this composition root never statically imports + // parent-bridge-server.ts (node:net + zod-from-json-schema); + // both the bridge module and the table construction are + // evaluated only on the flag-on spawn path (see + // supervised-spawn.ts ParentBridgeHandlersSource). + async () => { + const { buildSupervisedBridgeHandlers } = await import( + '@codebuff/agent-runtime/supervision/parent-bridge-server' + ) + return buildSupervisedBridgeHandlers({ + promptAiSdkStream, + promptAiSdk, + promptAiSdkStructured, + sendAction, + requestToolCall, + requestFiles, + requestOptionalFile, + requestMcpToolData, + handleStepsLogChunk, + sendSubagentChunk, + trackEvent: trackSdkRuntimeEvent, + fetch: globalThis.fetch, + startAgentRun: localStartAgentRun, + finishAgentRun: localFinishAgentRun, + addAgentStep: localAddAgentStep, + fetchAgentFromDatabase: localFetchAgentFromDatabase, + consumeCreditsWithFallback: localConsumeCreditsWithFallback, + apiKey, + logger: logger ?? noopLogger, + }) + }, + { + // P2-T8c: a BOUNDED restart policy so production callers get + // automatic bounded retries on transport-level child crashes. + // maxAttempts is passed EXPLICITLY (never a default that could + // silently become unbounded); on a deterministic crash the + // loop stops after this many restarts and returns the last + // settled result. This option rides INSIDE the flag-on branch + // only — the flag-off seam never calls buildDefaultSpawnSupervised + // and stays byte-identical. + restart: { maxAttempts: 3 }, + }, + ), + } + : {}), + // Client (WebSocket) handleStepsLogChunk, requestToolCall, @@ -150,6 +378,13 @@ const localStartAgentRun: StartAgentRunFn = async () => const localFinishAgentRun: FinishAgentRunFn = async () => {} +// P2-T8b: hoisted from the inline seam arrow so the supervised-bridge handler +// table references the SAME billing stub the deps object exposes. +const localConsumeCreditsWithFallback: ConsumeCreditsWithFallbackFn = async () => + success({ + chargedToOrganization: false, + }) + const localAddAgentStep: AddAgentStepFn = async () => `local-step-${crypto.randomUUID()}` diff --git a/sdk/src/impl/database.ts b/sdk/src/impl/database.ts index 9f2f808356..26f023ec77 100644 --- a/sdk/src/impl/database.ts +++ b/sdk/src/impl/database.ts @@ -28,6 +28,7 @@ import type { } from '@codebuff/common/types/contracts/database' import type { DynamicAgentTemplate } from '@codebuff/common/types/dynamic-agent-template' import type { ParamsOf } from '@codebuff/common/types/function-params' +import { markAllMCPConfigOrigins } from '@codebuff/common/mcp/client' type CachedUserInfo = Partial< NonNullable>> @@ -322,6 +323,12 @@ export async function fetchAgentFromDatabase( executionSource: 'database' as const, } + // M-2 backstop: mark the FINAL template object's MCP configs 'client' — + // database agents are untrusted, so $VAR references must never expand. + // The zod parse and validateSingleAgent spread create fresh objects whose + // marks would be lost; only this object reaches callers. + markAllMCPConfigOrigins(agentTemplate.mcpServers, 'client') + logger.debug( { publisherId, diff --git a/sdk/src/index.ts b/sdk/src/index.ts index bbcddeb410..195a08351b 100644 --- a/sdk/src/index.ts +++ b/sdk/src/index.ts @@ -13,6 +13,27 @@ export type { export { collectWorkspaceMoves, run } from './run' export { getFilesStructured } from './tools/read-files' export { changeFile, changeFiles } from './tools/change-file' +// The `changeFiles` `intentLog` parameter above is typed with +// TransactionIntentLog, so the type closure and its factory/recovery helpers +// are published beside it: external consumers must be able to name the +// parameter type (and construct/recover a log) without reaching into +// unpublished internals. +export { + createTransactionIntentLog, + createTransactionIntentLogForWorkspace, + recoverAndRevertInterruptedTransactions, + revertPathToPreImage, + transactionIntentLogFileName, +} from './tools/transaction-intent-log' +export type { + BeginTransactionParams, + IntentOutcome, + RecoveredInterruptedTransaction, + RecoveryOutcome, + RevertTransactionOutcome, + TransactionIntentEntry, + TransactionIntentLog, +} from './tools/transaction-intent-log' export { replaceRange } from './tools/replace-range' export { readImages } from './tools/read-image' export { edit3dAsset, inspect3dAsset, render3dPreview } from './tools/3d-assets' @@ -21,6 +42,7 @@ export type { NodeFileSystemOptions } from './tools/node-filesystem' export { diagnosticParsers, parseLanguageDiagnostics, + structuredDiagnosticParsers, } from './tools/language-diagnostics' export type { DiagnosticParser, @@ -28,7 +50,10 @@ export type { LanguageDiagnostic, LanguageDiagnosticPosition, LanguageDiagnosticRange, + LanguageDiagnosticRelatedInfo, LanguageDiagnosticSeverity, + LanguageDiagnosticTextEdit, + LanguageDiagnosticTextEditApplicability, } from './tools/language-diagnostics' export { FilesystemAuthority, @@ -136,6 +161,66 @@ export type { CodebuffToolOutput, } from '@codebuff/common/tools/list' export * from './client' +// P1-T3 backend seam + ACP-remote backend. +export type { ClientBackend } from './client/backend' +export { InProcessBackend, resolveClientBackend } from './client/backend' +export { + AcpRemoteBackend, + acpSessionUpdateToPrintModeEvents, +} from './client/acp-client' +export { createServeBridge } from './serve/bridge' +export type { ServeBridgeClient, ServeBridgeOptions } from './serve/bridge' +export { sanitizeOutbound } from './serve/outbound-filter' +// Published so hosts (the CLI serve command) can build a credential-only +// `credentialEnv` for runServe: the well-known holdback keys plus every +// `apiKeyEnv` name the provider configuration declares. +export { + getConfiguredCredentialEnvKeys, + CREDENTIAL_ENV_KEYS as WELL_KNOWN_CREDENTIAL_ENV_KEYS, +} from './serve/outbound' +export { serveAcpOverSocket } from './serve/socket-listener' +export type { ServeAcpOverSocketOptions } from './serve/socket-listener' +export { runServe } from './serve/serve' +export type { RunServeOptions } from './serve/serve' +// P2-T7: local dashboard over run journals, receipts, and gate timelines — +// a localhost+token HTTP server (127.0.0.1 ONLY, constant-time bearer/query +// token auth) plus a static HTML export that inlines the data (never the +// token). The provider is injectable; createDashProviderFromJournal adapts +// a P2-T2 JournalReader (plus optional receipt/gate seams). +export { + startDashServer, + generateDashToken, + renderDashHtml, + DASH_MAX_EVENTS, +} from './dash/server' +export type { + DashDataProvider, + DashRunEvent, + DashRunSummary, + DashServerHandle, + DashSnapshot, + StartDashServerParams, +} from './dash/server' +export { + createDashProviderFromJournal, + summarizePayload, +} from './dash/provider' +export type { CreateDashProviderFromJournalParams } from './dash/provider' +export { exportDashStatic } from './dash/export' +export type { + ExportDashStaticParams, + ExportDashStaticResult, +} from './dash/export' +export { createMcpServer, runMcp } from './mcp/server' +export type { + CreateMcpServerOptions, + McpIndexManager, + McpServerClient, + McpSessionData, + RunMcpOptions, +} from './mcp/server' +export { AcpSessionData } from './services/acp/session-data' +export type { AcpSessionDataOptions } from './services/acp/session-data' export * from './custom-tool' export * from './native/ripgrep' export * from './run-state' diff --git a/sdk/src/mcp/server.ts b/sdk/src/mcp/server.ts new file mode 100644 index 0000000000..3899b06992 --- /dev/null +++ b/sdk/src/mcp/server.ts @@ -0,0 +1,1565 @@ +import { Server } from '@modelcontextprotocol/sdk/server/index.js' +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' +import { + CallToolRequestSchema, + ErrorCode, + ListToolsRequestSchema, + McpError, +} from '@modelcontextprotocol/sdk/types.js' +import { z } from 'zod' +import { parseFileStructure } from '@codebuff/code-map' +import { MemoryRetrievalRequestSchema } from '@codebuff/common/types/memory-v2' +import { isMandatorySensitiveReadPath } from '@codebuff/common/util/sensitive-paths' + +import { codeSearch } from '../tools/code-search' +import { getFilesStructured } from '../tools/read-files' +import { createNodeFileSystem } from '../tools/node-filesystem' +import { inspectCodebaseStructure } from '../services/audit-intelligence' +import { getHarnessStateDir } from '../credentials' +import { WorkspaceMutationBroker } from '../services/workspace-mutation-broker' +import { createHash } from 'node:crypto' +import path from 'node:path' + +import { sanitizeOutbound } from '../serve/outbound-filter' + +import type { IndexManager } from '@codebuff/indexer' +import type { StructureDiagnostic } from '@codebuff/code-map' +import type { Tool } from '@modelcontextprotocol/sdk/types.js' +import type { CodebuffFileSystem } from '@codebuff/common/types/filesystem' +import type { Logger } from '@codebuff/common/types/contracts/logger' +import type { ProjectId } from '@codebuff/common/types/memory-v2' +import type { MemoryRepositoryV2 } from '../services/memory-v2/types' + +/** + * The `openbuff mcp` server (P1-T4): a fail-closed MCP projection of the + * SDK's EXISTING in-process read/inspect surfaces. Every tool delegates to + * the canonical implementation (the indexer `IndexManager`, the code-map + * tree-sitter outline pass, the policy-guarded file/ripgrep read tools, and + * the audit-intelligence inventory, and the Memory V2 lexical query) — + * this layer validates input, adapts call shapes, and formats bounded + * output; it never reimplements the logic. + * + * Memory search IS exposed read-only: `memory_search` delegates to the + * Memory V2 LEXICAL query surface (`MemoryRepositoryV2.query`) through the + * `sessionData.memory` seam; semantic recall stays unwired (P8-T8 — the + * Bun SQLite kernel's `search()` returns `unsupported`). With no + * repository bound, the tool answers an honest disabled payload. + * + * NOT exposed by default (P1-T4 follow-up `openbuff mcp --mutations`): + * receipt-backed edit tools (change-file/replace-range via the workspace + * mutation broker). Mutation tools must stay opt-in; an `openbuff mcp` + * process opened by an MCP host config is read-only unless the user + * explicitly arms edits. + */ + +/** The CLI prints its own version; the default keeps the wire shape valid. */ +export const MCP_SERVER_NAME = 'openbuff' + +/** Server-side caps on tool arguments, independent of the wire schemas. */ +const MAX_QUERY_CHARS = 4_096 +const MAX_PATTERN_CHARS = 1_024 +const MAX_STRINGS = 32 +const MAX_SCOPE_ENTRIES = 16 +const MAX_SCOPE_CHARS = 256 +const MAX_PATH_CHARS = 1_024 +const MAX_QUERY_LIMIT = 100 +const MAX_SEARCH_RESULTS = 250 +const DEFAULT_QUERY_LIMIT = 20 +const DEFAULT_SEARCH_RESULTS = 30 +const DEFAULT_OUTLINE_DEPTH = 2 +const MAX_OUTLINE_DEPTH = 4 +const MAX_OUTLINE_SYMBOLS = 500 +/** read_files item rendering is capped by path+content budget, not by count. */ +const MAX_READ_FILES = 16 +const MAX_READ_FILES_TEXT_CHARS = 250_000 +/** apply_edits: a bounded batch of full-file writes. */ +const MAX_APPLY_EDITS = 32 +const MAX_APPLY_EDIT_CONTENT_CHARS = 1_000_000 +const DEFAULT_SEARCH_TIMEOUT_SECONDS = 10 +const MAX_SEARCH_TIMEOUT_SECONDS = 60 +const DEFAULT_INDEX_WAIT_MS = 2_000 +const MAX_INDEX_WAIT_MS = 30_000 +/** memory_search: per-category result cap and bounded text projection. */ +const DEFAULT_MEMORY_LIMIT = 10 +const MAX_MEMORY_LIMIT = 25 +const MAX_MEMORY_ARTIFACT_KINDS = 16 +const MAX_MEMORY_FIELD_CHARS = 400 + +const READ_ONLY_ANNOTATIONS = { + readOnlyHint: true, + destructiveHint: false, + openWorldHint: false, +} as const + +const MUTATION_ANNOTATIONS = { + readOnlyHint: false, + destructiveHint: true, + openWorldHint: false, +} as const + +const stringList = (description: string) => ({ + type: 'array', + items: { type: 'string' }, + maxItems: MAX_STRINGS, + description, +}) + +const QUERY_INDEX_INPUT_SCHEMA = { + type: 'object', + properties: { + query: { + type: 'string', + maxLength: MAX_QUERY_CHARS, + description: 'Free-text search query over the codebase metadata index.', + }, + limit: { + type: 'integer', + minimum: 1, + maximum: MAX_QUERY_LIMIT, + description: `Maximum number of file results (default ${DEFAULT_QUERY_LIMIT}, capped at ${MAX_QUERY_LIMIT}).`, + }, + fileTypes: stringList( + 'Restrict to these file extensions (e.g. ["ts", "py"]).', + ), + pathPrefixes: stringList( + 'Restrict to these project-relative path prefixes.', + ), + mode: { + type: 'string', + enum: ['search', 'neighbors', 'path', 'explain', 'commands', 'references'], + description: + 'Query mode: free-text search (default) or a graph traversal mode.', + }, + from: { + type: 'string', + maxLength: MAX_PATH_CHARS, + description: 'Graph traversal start node (neighbors/explain modes).', + }, + to: { + type: 'string', + maxLength: MAX_PATH_CHARS, + description: 'Graph traversal target node (explain mode).', + }, + timeoutMs: { + type: 'integer', + minimum: 0, + maximum: MAX_INDEX_WAIT_MS, + description: `How long to wait for an index build before answering from the empty/stale snapshot (default ${DEFAULT_INDEX_WAIT_MS}ms).`, + }, + }, + required: ['query'], + additionalProperties: false, +} + +const CODE_SEARCH_INPUT_SCHEMA = { + type: 'object', + properties: { + pattern: { + type: 'string', + maxLength: MAX_PATTERN_CHARS, + description: 'ripgrep-compatible regular expression to search for.', + }, + paths: stringList( + 'Optional project-relative files/directories to restrict the search to.', + ), + maxResults: { + type: 'integer', + minimum: 1, + maximum: MAX_SEARCH_RESULTS, + description: `Per-file result cap (default ${DEFAULT_SEARCH_RESULTS}).`, + }, + timeoutSeconds: { + type: 'integer', + minimum: 1, + maximum: MAX_SEARCH_TIMEOUT_SECONDS, + description: `Search timeout in seconds (default ${DEFAULT_SEARCH_TIMEOUT_SECONDS}, capped at ${MAX_SEARCH_TIMEOUT_SECONDS}).`, + }, + }, + required: ['pattern'], + additionalProperties: false, +} + +const READ_FILES_INPUT_SCHEMA = { + type: 'object', + properties: { + paths: stringList( + 'Project-relative file paths to read (policy-guarded; sensitive paths fail closed).', + ), + ranges: { + type: 'array', + maxItems: MAX_STRINGS, + description: + 'Optional bounded line-range reads; range reads mint a cap.v3 read capability on full coverage.', + items: { + type: 'object', + properties: { + path: { type: 'string', maxLength: MAX_PATH_CHARS }, + startLine: { type: 'integer', minimum: 1 }, + endLine: { type: 'integer', minimum: 1 }, + }, + required: ['path'], + additionalProperties: false, + }, + }, + }, + additionalProperties: false, +} + +const FILE_OUTLINE_INPUT_SCHEMA = { + type: 'object', + properties: { + path: { + type: 'string', + maxLength: MAX_PATH_CHARS, + description: + 'Project-relative source file to outline (parsed with the shipped tree-sitter grammars).', + }, + maxDepth: { + type: 'integer', + minimum: 0, + maximum: MAX_OUTLINE_DEPTH, + description: `Maximum symbol nesting depth to include (default ${DEFAULT_OUTLINE_DEPTH}).`, + }, + }, + required: ['path'], + additionalProperties: false, +} + +const CODEBASE_STRUCTURE_INPUT_SCHEMA = { + type: 'object', + properties: { + scope: { + ...stringList( + 'Optional project-relative subtrees to inventory (defaults to the whole project).', + ), + maxItems: MAX_SCOPE_ENTRIES, + }, + }, + additionalProperties: false, +} + +const MEMORY_SEARCH_INPUT_SCHEMA = { + type: 'object', + properties: { + query: { + type: 'string', + maxLength: MAX_QUERY_CHARS, + description: + 'Free-text lexical search query over the Memory V2 store (token-overlap retrieval only).', + }, + limit: { + type: 'integer', + minimum: 1, + maximum: MAX_MEMORY_LIMIT, + description: `Maximum results per category (default ${DEFAULT_MEMORY_LIMIT}, capped at ${MAX_MEMORY_LIMIT}).`, + }, + paths: stringList( + 'Restrict to observations carrying evidence on these project-relative file paths.', + ), + kinds: stringList( + 'Restrict to these artifact kinds: source, test, configuration, documentation, generated, dependency, data, binary, other.', + ), + }, + required: ['query'], + additionalProperties: false, +} + +const APPLY_EDITS_INPUT_SCHEMA = { + type: 'object', + properties: { + edits: { + type: 'array', + minItems: 1, + maxItems: MAX_APPLY_EDITS, + description: `A bounded batch of full-file writes (create or guarded overwrite), applied via the workspace mutation broker.`, + items: { + type: 'object', + properties: { + path: { + type: 'string', + maxLength: MAX_PATH_CHARS, + description: 'Project-relative file path to write.', + }, + content: { + type: 'string', + maxLength: MAX_APPLY_EDIT_CONTENT_CHARS, + description: 'The complete new file content.', + }, + expectedHash: { + type: ['string', 'null'], + maxLength: 128, + description: + 'sha256 hex (optionally "sha256:"-prefixed) of the CURRENT file content, as reported by read_files. REQUIRED to overwrite an existing file; null or absent creates a new file.', + }, + }, + required: ['path', 'content'], + additionalProperties: false, + }, + }, + }, + required: ['edits'], + additionalProperties: false, +} + +/** + * The single tool registry: the tools/list payload and the dispatch map are + * built from these entries, so a tool can never be listed-but-unhandled or + * handled-but-unlisted. All entries are read-only this wave. + */ +const MCP_TOOLS: Tool[] = [ + { + name: 'query_index', + description: + 'Query the Openbuff codebase metadata index (symbols, headings, imports, graph neighbors) with lexical search plus optional semantic blending. Returns ranked file results with the index status.', + inputSchema: QUERY_INDEX_INPUT_SCHEMA, + annotations: { title: 'Query code index', ...READ_ONLY_ANNOTATIONS }, + }, + { + name: 'code_search', + description: + 'Regex search over the project via the bundled ripgrep (cap-guarded: blocked/sensitive paths are filtered; flags pass through a safe allowlist).', + inputSchema: CODE_SEARCH_INPUT_SCHEMA, + annotations: { title: 'Code search', ...READ_ONLY_ANNOTATIONS }, + }, + { + name: 'read_files', + description: + 'Read project files through the Openbuff capability guard (sensitive-path blocklist, host read policy, 10MB ceiling, bounded range reads). Complete reads mint cap.v3 read capabilities for edit tooling; the token itself is redacted from MCP output (SEC-1) and stays usable only in-process.', + inputSchema: READ_FILES_INPUT_SCHEMA, + annotations: { title: 'Read files', ...READ_ONLY_ANNOTATIONS }, + }, + { + name: 'file_outline', + description: + 'Structural outline of one source file (functions, classes, methods with line spans) from the Openbuff tree-sitter code map.', + inputSchema: FILE_OUTLINE_INPUT_SCHEMA, + annotations: { title: 'File outline', ...READ_ONLY_ANNOTATIONS }, + }, + { + name: 'codebase_structure', + description: + 'Audit intelligence: the whole-repo structural inventory (files, languages, subsystems, entry points) with a content-addressed snapshot id.', + inputSchema: CODEBASE_STRUCTURE_INPUT_SCHEMA, + annotations: { title: 'Codebase structure', ...READ_ONLY_ANNOTATIONS }, + }, + { + name: 'memory_search', + description: + 'Lexical search over the Memory V2 store (verified knowledge, reusable discoveries, matched tasks, historical context) via the repository query surface. Retrieval is lexical-only — semantic recall is not available (P8-T8).', + inputSchema: MEMORY_SEARCH_INPUT_SCHEMA, + annotations: { title: 'Memory search', ...READ_ONLY_ANNOTATIONS }, + }, +] + +/** + * The opt-in mutation tool, registered ONLY when the server is started with + * `mutations: true`. Kept as a single entry so the tools/list payload and the + * tools/call dispatch stay consistent from one source (the read-only path + * never sees it: `buildTools(false)` returns `MCP_TOOLS` untouched). + */ +const APPLY_EDITS_TOOL: Tool = { + name: 'apply_edits', + description: + 'Apply a bounded batch of full-file writes (create or guarded overwrite) to the workspace via the receipt-backed mutation broker. Opt-in only (openbuff mcp --mutations).', + inputSchema: APPLY_EDITS_INPUT_SCHEMA, + annotations: { title: 'Apply edits', ...MUTATION_ANNOTATIONS }, +} + +/** + * Builds the listed-tool array from the single registry. With mutations off + * this is exactly `MCP_TOOLS`; with mutations on it appends the opt-in tool. + * The dispatch switch gates on the same source, so a tool is never + * listed-but-unhandled or handled-but-unlisted. + */ +function buildTools(mutations: boolean): Tool[] { + return mutations ? [...MCP_TOOLS, APPLY_EDITS_TOOL] : MCP_TOOLS +} + +export type McpQueryIndexOptions = { + limit?: number + fileTypes?: string[] + pathPrefixes?: string[] + mode?: 'search' | 'neighbors' | 'path' | 'explain' | 'commands' | 'references' + from?: string + to?: string +} + +/** Minimal structural seam over the real `IndexManager` (tests can stub it). */ +export type McpIndexManager = Pick + +/** + * Minimal structural seam over the Memory V2 repository's LEXICAL query + * surface (tests can stub it). The semantic `search()` surface is + * deliberately excluded — the Bun SQLite kernel answers it with + * `unsupported` and it rides P8-T8. + */ +export type McpMemoryRepository = Pick + +export type McpMemorySession = { + /** The repository's lexical query surface. */ + repository: McpMemoryRepository + /** + * The store's project scope. The wiring party supplies it (the CLI + * derives it from the project storage key); the SDK cannot reconstruct + * it from projectRoot alone, and a mismatched scope fails closed at the + * repository's bound-project check. + */ + projectId: ProjectId +} + +export type McpSessionData = { + /** Project root all reads/queries/searches are scoped to. */ + projectRoot: string + /** + * Indexer seam (CLI: `IndexManager.getInstance(projectRoot, config, embedder)`). + * Omitted → query_index answers with an honest disabled payload. + */ + index?: McpIndexManager + /** + * Memory V2 seam. Omitted → memory_search answers with an honest + * disabled payload. + */ + memory?: McpMemorySession +} + +/** + * Structurally-typed host seam (mirrors `ServeBridgeClient`): the CLI binds + * the real `OpenbuffClient` options here; tests inject stubs. Every member + * is optional and defaulted inside `createMcpServer`, so a minimal + * `{ fileSystem }` is a complete client for the read tools. + */ +export type McpServerClient = { + fileSystem?: CodebuffFileSystem + fileFilter?: (filePath: string) => { status: 'blocked' | 'allow-example' | 'allow' } + logger?: Logger +} + +/** + * The public handle to a created MCP server. This is a STRUCTURAL interface + * covering only what the SDK's public surface and its callers use + * (`connect`/`close`/`setRequestHandler`) — NOT the concrete + * `@modelcontextprotocol/sdk` `Server` class. + * + * Why: that class's bundled `.d.ts` references zod-v3-only symbols + * (`objectOutputType`, `objectInputType`, …) that do not exist under this + * workspace's hoisted zod 4.x, so re-exporting the concrete `Server` type from + * the SDK's public declaration surface makes `dts-bundle-generator` fail the CI + * `cd sdk && bun run build` step with `Cannot find symbol for node + * "objectOutputType"` (P1-T4 follow-up). Returning this structural type keeps + * the MCP SDK's zod-typed internals OUT of the public `.d.ts`. + * + * MIGRATION NOTE (type-level breaking change for @openbuff/sdk consumers): + * `createMcpServer` previously returned the concrete MCP SDK `Server`; it now + * returns ONLY this structural surface (`connect`/`close`). Code that used + * the concrete surface on the returned object — `setRequestHandler(...)`, + * the `transport` property, or additional `on*` registrations — will no + * longer typecheck. Migrate by constructing and wiring your own + * `@modelcontextprotocol/sdk` `Server` for those handlers (the + * `setRequestHandler` registrations inside `createMcpServer` show the + * pattern) and keep using this handle for `connect`/`close` only. The + * runtime object is unchanged: this is a type-level narrowing only. + */ +export interface McpServer { + // The transport is typed `unknown` (not the MCP SDK's `Transport`): the SDK + // only ever passes a transport it just constructed (runMcp) or a linked test + // transport, and referencing the SDK's `Transport` would re-introduce its + // zod-typed .d.ts into our public declaration surface. `unknown` keeps the + // concrete `Server` assignable (connect(transport: Transport) accepts it). + connect(transport: unknown): Promise + close(): Promise +} + +export type CreateMcpServerOptions = { + client: McpServerClient + sessionData: McpSessionData + /** Advertised on the MCP initialize handshake; defaults to MCP_SERVER_NAME. */ + serverName?: string + /** Advertised on the MCP initialize handshake. */ + serverVersion?: string + /** + * Opt-in arm for the receipt-backed `apply_edits` edit tool (default + * false). When true the tool is listed and handled; when absent the server + * is read-only and a `tools/call` for `apply_edits` hits the fail-closed + * unknown-tool gate. + */ + mutations?: boolean +} + +export type RunMcpOptions = CreateMcpServerOptions & { + /** Aborting closes the server; stdio's lifetime is otherwise the process. */ + signal?: AbortSignal +} + +type CallToolResult = { + content: Array<{ type: 'text'; text: string }> + isError?: boolean +} + +const textResult = (text: string): CallToolResult => ({ + content: [{ type: 'text', text }], +}) + +const errorResult = (text: string): CallToolResult => ({ + content: [{ type: 'text', text }], + isError: true, +}) + +function invalid(message: string): CallToolResult { + return errorResult(`Invalid input: ${message}`) +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function errorMessage(error: unknown): string { + if (error instanceof Error && error.message.length > 0) return error.message + return typeof error === 'string' && error.length > 0 ? error : 'Unknown error' +} + +/** The Node errno code carried by an fs error, when one is present. */ +function errnoCode(error: unknown): string | undefined { + if ( + typeof error === 'object' && + error !== null && + 'code' in error && + typeof (error as { code?: unknown }).code === 'string' + ) { + return (error as { code: string }).code + } + return undefined +} + +function optionalString( + value: unknown, + name: string, + maxChars: number, +): { ok: true; value: string | undefined } | { ok: false; message: string } { + if (value === undefined) return { ok: true, value: undefined } + if (typeof value !== 'string' || value.length === 0) { + return { ok: false, message: `${name} must be a non-empty string.` } + } + if (value.length > maxChars) { + return { ok: false, message: `${name} exceeds the ${maxChars}-character limit.` } + } + return { ok: true, value } +} + +function optionalStringList( + value: unknown, + name: string, + maxItems: number, + maxChars: number, +): { ok: true; value: string[] | undefined } | { ok: false; message: string } { + if (value === undefined) return { ok: true, value: undefined } + if ( + !Array.isArray(value) || + value.some((entry) => typeof entry !== 'string' || entry.length === 0) + ) { + return { ok: false, message: `${name} must be an array of non-empty strings.` } + } + if (value.length > maxItems) { + return { ok: false, message: `${name} supports at most ${maxItems} entries.` } + } + if (value.some((entry) => entry.length > maxChars)) { + return { ok: false, message: `${name} entries are capped at ${maxChars} characters.` } + } + return { ok: true, value: value as string[] } +} + +function optionalBoundedInt( + value: unknown, + name: string, + min: number, + max: number, + fallback: number | undefined, +): { ok: true; value: number | undefined } | { ok: false; message: string } { + if (value === undefined) return { ok: true, value: fallback } + if (typeof value !== 'number' || !Number.isFinite(value)) { + return { ok: false, message: `${name} must be a finite number.` } + } + const rounded = Math.floor(value) + if (rounded < min) { + return { ok: false, message: `${name} must be at least ${min}.` } + } + return { ok: true, value: Math.min(rounded, max) } +} + +const QUERY_INDEX_MODES = new Set([ + 'search', + 'neighbors', + 'path', + 'explain', + 'commands', + 'references', +]) + +function validateQueryIndexInput( + args: unknown, +): + | { + ok: true + input: { query: string; options: McpQueryIndexOptions; timeoutMs: number } + } + | { ok: false; message: string } { + if (!isRecord(args)) return { ok: false, message: 'arguments must be an object.' } + const query = optionalString(args.query, 'query', MAX_QUERY_CHARS) + if (!query.ok || query.value === undefined) { + return { ok: false, message: query.ok ? 'query is required.' : query.message } + } + const fileTypes = optionalStringList( + args.fileTypes, + 'fileTypes', + MAX_STRINGS, + 32, + ) + if (!fileTypes.ok) return fileTypes + const pathPrefixes = optionalStringList( + args.pathPrefixes, + 'pathPrefixes', + MAX_STRINGS, + MAX_PATH_CHARS, + ) + if (!pathPrefixes.ok) return pathPrefixes + const limit = optionalBoundedInt( + args.limit, + 'limit', + 1, + MAX_QUERY_LIMIT, + undefined, + ) + if (!limit.ok) return limit + const timeoutMs = optionalBoundedInt( + args.timeoutMs, + 'timeoutMs', + 0, + MAX_INDEX_WAIT_MS, + DEFAULT_INDEX_WAIT_MS, + ) + if (!timeoutMs.ok) return timeoutMs + const from = optionalString(args.from, 'from', MAX_PATH_CHARS) + if (!from.ok) return from + const to = optionalString(args.to, 'to', MAX_PATH_CHARS) + if (!to.ok) return to + let mode: McpQueryIndexOptions['mode'] + if (args.mode !== undefined) { + if (typeof args.mode !== 'string' || !QUERY_INDEX_MODES.has(args.mode)) { + return { + ok: false, + message: `mode must be one of: ${[...QUERY_INDEX_MODES].join(', ')}.`, + } + } + mode = args.mode as McpQueryIndexOptions['mode'] + } + return { + ok: true, + input: { + query: query.value, + timeoutMs: timeoutMs.value ?? DEFAULT_INDEX_WAIT_MS, + options: { + ...(limit.value !== undefined ? { limit: limit.value } : {}), + ...(fileTypes.value ? { fileTypes: fileTypes.value } : {}), + ...(pathPrefixes.value ? { pathPrefixes: pathPrefixes.value } : {}), + ...(mode !== undefined ? { mode } : {}), + ...(from.value !== undefined ? { from: from.value } : {}), + ...(to.value !== undefined ? { to: to.value } : {}), + }, + }, + } +} + +async function callQueryIndex( + sessionData: McpSessionData, + args: unknown, +): Promise { + const validated = validateQueryIndexInput(args) + if (!validated.ok) return invalid(validated.message) + const index = sessionData.index + if (!index) { + return textResult( + JSON.stringify( + { + kind: 'query_index_result', + schemaVersion: 1, + results: [], + ready: false, + totalIndexed: 0, + message: + 'No codebase index was configured for this MCP server (indexing disabled in openbuff.json); fall back to code_search or read_files.', + }, + null, + 2, + ), + ) + } + await index.waitUntilReady(validated.input.timeoutMs) + const result = await index.queryBlended( + validated.input.query, + validated.input.options, + ) + // Project to a bounded payload: the per-file symbol/heading lists are + // already capped by the indexer; totals/status/message ride along verbatim. + return textResult( + JSON.stringify( + { + kind: 'query_index_result', + schemaVersion: 1, + results: result.results.map((item) => ({ + path: item.path, + score: item.score, + matchedOn: item.matchedOn, + ...(item.symbols ? { symbols: item.symbols } : {}), + ...(item.headings ? { headings: item.headings } : {}), + ...(item.matchedSnippets + ? { matchedSnippets: item.matchedSnippets } + : {}), + ...(item.explanation ? { explanation: item.explanation } : {}), + })), + ready: result.ready, + totalIndexed: result.totalIndexed, + indexAge: result.indexAge, + status: result.status, + ...(result.snapshot ? { snapshot: result.snapshot } : {}), + }, + null, + 2, + ), + ) +} + +async function callCodeSearch( + client: McpServerClient, + sessionData: McpSessionData, + args: unknown, +): Promise { + if (!isRecord(args)) return invalid('arguments must be an object.') + const pattern = optionalString(args.pattern, 'pattern', MAX_PATTERN_CHARS) + if (!pattern.ok || pattern.value === undefined) { + return invalid(pattern.ok ? 'pattern is required.' : pattern.message) + } + const paths = optionalStringList(args.paths, 'paths', MAX_STRINGS, MAX_PATH_CHARS) + if (!paths.ok) return invalid(paths.message) + const maxResults = optionalBoundedInt( + args.maxResults, + 'maxResults', + 1, + MAX_SEARCH_RESULTS, + DEFAULT_SEARCH_RESULTS, + ) + if (!maxResults.ok) return invalid(maxResults.message) + const timeoutSeconds = optionalBoundedInt( + args.timeoutSeconds, + 'timeoutSeconds', + 1, + MAX_SEARCH_TIMEOUT_SECONDS, + DEFAULT_SEARCH_TIMEOUT_SECONDS, + ) + if (!timeoutSeconds.ok) return invalid(timeoutSeconds.message) + const output = await codeSearch({ + projectPath: sessionData.projectRoot, + pattern: pattern.value, + maxResults: maxResults.value ?? DEFAULT_SEARCH_RESULTS, + globalMaxResults: MAX_SEARCH_RESULTS, + timeoutSeconds: timeoutSeconds.value ?? DEFAULT_SEARCH_TIMEOUT_SECONDS, + ...(paths.value ? { paths: paths.value } : {}), + ...(client.logger ? { logger: client.logger } : {}), + ...(client.fileFilter ? { fileFilter: client.fileFilter } : {}), + }) + const jsonPart = output.find((part) => part.type === 'json') + const value: { errorMessage?: string; stdout?: string } = + jsonPart && jsonPart.type === 'json' && isRecord(jsonPart.value) + ? (jsonPart.value as { errorMessage?: string; stdout?: string }) + : {} + if (typeof value.errorMessage === 'string') { + return errorResult(value.errorMessage) + } + const stdout = typeof value.stdout === 'string' ? value.stdout : '' + return textResult( + stdout.trim().length > 0 + ? stdout + : `No matches for pattern ${JSON.stringify(pattern.value)}.`, + ) +} + +function renderReadFilesItem(item: { + path: string + status: string + selector: string + content?: string + complete?: boolean + startLine?: number + endLine?: number + totalLines?: number + error?: { code: string; message: string } + editAnchor?: { readCapability: string } +}): string { + const header = + item.selector === 'range' + ? `--- ${item.path} lines ${item.startLine ?? '?'}-${item.endLine ?? '?'} of ${item.totalLines ?? '?'} [${item.status}] ---` + : `--- ${item.path} [${item.status}] ---` + if (item.status === 'error') { + return `${header}\nerror(${item.error?.code ?? 'io_error'}): ${item.error?.message ?? 'unknown error'}` + } + const capability = item.editAnchor?.readCapability + // SEC (SEC-1): a minted cap.v3 read capability is a LIVE bearer token for + // this process. The MCP transport is an untrusted-host boundary, so the + // token never appears in tool output: it is redacted with the SAME + // outbound redaction the ACP wire uses (`sanitizeOutbound`), while the + // response still reports honestly that a capability was minted. The + // in-process SDK surfaces keep the raw token; only this MCP projection + // redacts it. + const footer = capability + ? `\n\n[READ_CAPABILITY lines ${item.selector === 'range' ? `${item.startLine}-${item.endLine}` : `1-${item.totalLines ?? '?'}`}: ${sanitizeOutbound(capability)} — capability token redacted over MCP]` + : '' + return `${header}\n${item.content ?? ''}${footer}` +} + +async function callReadFiles( + client: Required> & McpServerClient, + sessionData: McpSessionData, + args: unknown, +): Promise { + if (!isRecord(args)) return invalid('arguments must be an object.') + const paths = optionalStringList(args.paths, 'paths', MAX_READ_FILES, MAX_PATH_CHARS) + if (!paths.ok) return invalid(paths.message) + let ranges: + | { path: string; startLine?: number; endLine?: number }[] + | undefined + if (args.ranges !== undefined) { + if (!Array.isArray(args.ranges) || args.ranges.length > MAX_STRINGS) { + return invalid(`ranges must be an array of at most ${MAX_STRINGS} entries.`) + } + ranges = [] + for (const entry of args.ranges) { + if (!isRecord(entry)) return invalid('ranges entries must be objects.') + const rangePath = optionalString(entry.path, 'ranges[].path', MAX_PATH_CHARS) + if (!rangePath.ok || rangePath.value === undefined) { + return invalid( + rangePath.ok ? 'ranges[].path is required.' : rangePath.message, + ) + } + const startLine = optionalBoundedInt( + entry.startLine, + 'ranges[].startLine', + 1, + Number.MAX_SAFE_INTEGER, + undefined, + ) + if (!startLine.ok) return invalid(startLine.message) + const endLine = optionalBoundedInt( + entry.endLine, + 'ranges[].endLine', + 1, + Number.MAX_SAFE_INTEGER, + undefined, + ) + if (!endLine.ok) return invalid(endLine.message) + if ( + startLine.value !== undefined && + endLine.value !== undefined && + endLine.value < startLine.value + ) { + return invalid('ranges[].endLine must not precede ranges[].startLine.') + } + ranges.push({ + path: rangePath.value, + ...(startLine.value !== undefined ? { startLine: startLine.value } : {}), + ...(endLine.value !== undefined ? { endLine: endLine.value } : {}), + }) + } + } + if ((paths.value?.length ?? 0) === 0 && (ranges?.length ?? 0) === 0) { + return invalid('at least one path or range is required.') + } + const result = await getFilesStructured({ + filePaths: paths.value ?? [], + ...(ranges ? { ranges } : {}), + cwd: sessionData.projectRoot, + fs: client.fileSystem, + ...(client.fileFilter ? { fileFilter: client.fileFilter } : {}), + // cap.v3 minting: scoped to this project root and MCP server process; the + // process-scoped runId keeps tokens non-transferable across sessions, and + // mutation tooling stays opt-in (P1-T4 follow-up) so minted capabilities + // are inert read attestations by default. + capabilityIssuer: { + projectId: sessionData.projectRoot, + runId: 'mcp', + }, + }) + const blocks: string[] = [] + let usedChars = 0 + let truncatedCount = 0 + for (const item of result.results) { + const block = renderReadFilesItem(item) + if (usedChars + block.length > MAX_READ_FILES_TEXT_CHARS) { + truncatedCount += 1 + continue + } + blocks.push(block) + usedChars += block.length + } + if (truncatedCount > 0) { + blocks.push( + `[${truncatedCount} result(s) omitted to stay within the ${MAX_READ_FILES_TEXT_CHARS}-character MCP response budget; request them with narrower reads.]`, + ) + } + return textResult(blocks.join('\n\n')) +} + +async function callFileOutline( + client: Required> & McpServerClient, + sessionData: McpSessionData, + args: unknown, +): Promise { + if (!isRecord(args)) return invalid('arguments must be an object.') + const pathArg = optionalString(args.path, 'path', MAX_PATH_CHARS) + if (!pathArg.ok || pathArg.value === undefined) { + return invalid(pathArg.ok ? 'path is required.' : pathArg.message) + } + const maxDepth = optionalBoundedInt( + args.maxDepth, + 'maxDepth', + 0, + MAX_OUTLINE_DEPTH, + DEFAULT_OUTLINE_DEPTH, + ) + if (!maxDepth.ok) return invalid(maxDepth.message) + // The file content comes through the SAME cap-guarded read as the + // read_files tool, so outline can never see bytes the guard would block. + const read = await getFilesStructured({ + filePaths: [pathArg.value], + cwd: sessionData.projectRoot, + fs: client.fileSystem, + ...(client.fileFilter ? { fileFilter: client.fileFilter } : {}), + }) + const item = read.results[0] + if (item === undefined) { + return errorResult( + `read failed for ${pathArg.value}: the read returned no result.`, + ) + } + // Union narrowing: `status: 'error'` items carry `error`; the whole-file + // 'file' selector carries `content`/`complete`. Reading those fields off + // the un-narrowed ReadFilesItemV1 union does not typecheck. + if (item.status === 'error') { + return errorResult( + `read failed for ${pathArg.value}: ${item.error.message}`, + ) + } + if (item.selector !== 'file' || typeof item.content !== 'string') { + return errorResult( + `read failed for ${pathArg.value}: the file could not be read.`, + ) + } + if (!item.complete) { + return errorResult( + `The file ${pathArg.value} exceeds the whole-file read limit; outline requires a complete file.`, + ) + } + const diagnostics: StructureDiagnostic[] = [] + const symbols = await parseFileStructure(item.content, pathArg.value, diagnostics) + if (symbols === null) { + return errorResult( + `No tree-sitter grammar is available for ${pathArg.value}; outline unsupported.`, + ) + } + const depthLimit = maxDepth.value ?? DEFAULT_OUTLINE_DEPTH + const visible = symbols.filter((symbol) => symbol.depth <= depthLimit) + const rendered = visible + .slice(0, MAX_OUTLINE_SYMBOLS) + .map((symbol) => { + const indent = ' '.repeat(symbol.depth) + const tags = [symbol.kind, symbol.exported === true ? 'exported' : null] + .filter(Boolean) + .join(', ') + return `${indent}${symbol.name} (${tags}) — lines ${symbol.startLine}-${symbol.endLine}` + }) + const notes: string[] = [] + if (visible.length > MAX_OUTLINE_SYMBOLS) { + notes.push( + `[outline truncated at ${MAX_OUTLINE_SYMBOLS} symbols; lower maxDepth to narrow it.]`, + ) + } + if (symbols.length > visible.length) { + notes.push( + `[${symbols.length - visible.length} deeper symbol(s) hidden by maxDepth=${depthLimit}.]`, + ) + } + for (const diagnostic of diagnostics.slice(0, 8)) { + notes.push(`[parse diagnostic: ${diagnostic.message}]`) + } + const header = `Outline of ${pathArg.value} (${symbols.length} symbol(s), maxDepth=${depthLimit}):` + return textResult( + [header, ...(rendered.length > 0 ? rendered : ['(no top-level symbols)']), ...notes].join( + '\n', + ), + ) +} + +function callCodebaseStructure( + sessionData: McpSessionData, + args: unknown, +): CallToolResult { + if (args !== undefined && !isRecord(args)) { + return invalid('arguments must be an object.') + } + const scope = optionalStringList( + isRecord(args) ? args.scope : undefined, + 'scope', + MAX_SCOPE_ENTRIES, + MAX_SCOPE_CHARS, + ) + if (!scope.ok) return invalid(scope.message) + const inventory = inspectCodebaseStructure( + sessionData.projectRoot, + scope.value, + ) + return textResult(JSON.stringify(inventory, null, 2)) +} + +const MEMORY_ARTIFACT_KINDS: ReadonlySet = new Set([ + 'source', + 'test', + 'configuration', + 'documentation', + 'generated', + 'dependency', + 'data', + 'binary', + 'other', +]) + +/** + * Derives a schema-valid opaque memory id (`:`). A read-only + * MCP search has no persistent turn identity, but MemoryRetrievalRequest + * requires queryId/sessionId — both are derived deterministically from the + * project root and the validated input (identical queries share a queryId; + * the query path only echoes it back, never persists it). + */ +const scopedMemoryId = (scope: 'query' | 'session', seed: string): string => + `${scope}:${createHash('sha256').update(seed).digest('hex').slice(0, 48)}` + +/** Bounds one free-text field in the memory_search projection. */ +const boundedMemoryText = (value: string): string => + value.length > MAX_MEMORY_FIELD_CHARS + ? `${value.slice(0, MAX_MEMORY_FIELD_CHARS)}…[truncated]` + : value + +/** Projects ranking reasons to their compact, bounded form (code + weight). */ +const projectReasons = ( + reasons: ReadonlyArray<{ code: string; contribution: number }>, +) => reasons.map(({ code, contribution }) => ({ code, contribution })) + +function validateMemorySearchInput( + args: unknown, +): + | { + ok: true + input: { query: string; limit: number; paths?: string[]; kinds?: string[] } + } + | { ok: false; message: string } { + if (!isRecord(args)) return { ok: false, message: 'arguments must be an object.' } + const query = optionalString(args.query, 'query', MAX_QUERY_CHARS) + if (!query.ok || query.value === undefined) { + return { ok: false, message: query.ok ? 'query is required.' : query.message } + } + const paths = optionalStringList(args.paths, 'paths', MAX_STRINGS, MAX_PATH_CHARS) + if (!paths.ok) return paths + const kinds = optionalStringList( + args.kinds, + 'kinds', + MAX_MEMORY_ARTIFACT_KINDS, + 32, + ) + if (!kinds.ok) return kinds + if (kinds.value?.some((kind) => !MEMORY_ARTIFACT_KINDS.has(kind))) { + return { + ok: false, + message: `kinds entries must be one of: ${[...MEMORY_ARTIFACT_KINDS].join(', ')}.`, + } + } + const limit = optionalBoundedInt( + args.limit, + 'limit', + 1, + MAX_MEMORY_LIMIT, + DEFAULT_MEMORY_LIMIT, + ) + if (!limit.ok) return limit + return { + ok: true, + input: { + query: query.value, + limit: limit.value ?? DEFAULT_MEMORY_LIMIT, + ...(paths.value ? { paths: paths.value } : {}), + ...(kinds.value ? { kinds: kinds.value } : {}), + }, + } +} + +async function callMemorySearch( + sessionData: McpSessionData, + args: unknown, +): Promise { + const validated = validateMemorySearchInput(args) + if (!validated.ok) return invalid(validated.message) + const memory = sessionData.memory + if (!memory) { + return textResult( + JSON.stringify( + { + kind: 'memory_search_result', + schemaVersion: 1, + retrieval: 'lexical', + ready: false, + totalResults: 0, + results: [], + message: + 'No Memory V2 repository was configured for this MCP server; memory search is disabled.', + }, + null, + 2, + ), + ) + } + const sessionId = scopedMemoryId('session', sessionData.projectRoot) + // Build a minimal valid MemoryRetrievalRequest from the validated tool + // input; the repository re-validates with the same Zod schema before + // querying, and the `.parse` here fails closed on any residual drift. + const request = MemoryRetrievalRequestSchema.parse({ + schemaVersion: 2, + queryId: scopedMemoryId( + 'query', + `${sessionId} ${JSON.stringify(validated.input)}`, + ), + projectId: memory.projectId, + sessionId, + query: validated.input.query, + selectors: (validated.input.paths ?? []).map((path) => ({ + kind: 'file', + path, + })), + artifactKinds: validated.input.kinds ?? [], + includeHistorical: true, + maxResultsPerCategory: validated.input.limit, + }) + // LEXICAL query surface only — never the semantic search() (P8-T8). + const outcome = await memory.repository.query(request) + if (outcome.outcome !== 'result') { + return errorResult( + `Memory search ${outcome.outcome}: ${outcome.error.message}`, + ) + } + const result = outcome.result + // Project the five retrieval categories to a bounded, honestly-labelled + // flat list; long free-text fields are truncated. + const results = [ + ...result.matchedTasks.map((item) => ({ + category: 'matched-task', + taskId: item.taskId, + title: boundedMemoryText(item.title), + status: item.status, + summary: boundedMemoryText(item.summary), + score: item.score, + reasons: projectReasons(item.reasons), + })), + ...result.verifiedKnowledge.map((item) => ({ + category: 'verified-knowledge', + observationId: item.observation.observationId, + taskId: item.observation.taskId, + kind: item.observation.kind, + summary: boundedMemoryText(item.observation.summary), + detail: boundedMemoryText(item.observation.detail), + confidence: item.observation.confidence, + verifiedAt: item.verifiedAt, + score: item.score, + reasons: projectReasons(item.reasons), + })), + ...result.reusableDiscovery.map((item) => ({ + category: 'reusable-discovery', + observationId: item.observation.observationId, + taskId: item.observation.taskId, + kind: item.observation.kind, + summary: boundedMemoryText(item.observation.summary), + reuseGuidance: boundedMemoryText(item.reuseGuidance), + score: item.score, + reasons: projectReasons(item.reasons), + })), + ...result.rereadRequired.map((item) => ({ + category: 'reread-required', + observationId: item.observationId, + reason: item.reason, + detail: boundedMemoryText(item.detail), + score: item.score, + reasons: projectReasons(item.reasons), + })), + ...result.historicalContext.map((item) => ({ + category: 'historical-context', + ...(item.taskId !== undefined ? { taskId: item.taskId } : {}), + summary: boundedMemoryText(item.summary), + score: item.score, + reasons: projectReasons(item.reasons), + })), + ] + return textResult( + JSON.stringify( + { + kind: 'memory_search_result', + schemaVersion: 1, + retrieval: 'lexical', + ready: true, + totalResults: results.length, + results, + degradation: result.degradation, + message: + 'Results are lexical (token-overlap) retrieval only; semantic recall is not available from the Memory V2 store (P8-T8).', + }, + null, + 2, + ), + ) +} + +/** + * The sha256 hex forms the read surfaces mint: bare 64-char hex or the + * `sha256:`-prefixed exact-content hash. Both are accepted so a client can + * echo whichever hash its read produced; anything else is invalid. + */ +function normalizeSha256Hex(value: string): string | null { + const hex = value.startsWith('sha256:') ? value.slice('sha256:'.length) : value + return /^[0-9a-fA-F]{64}$/.test(hex) ? hex.toLowerCase() : null +} + +/** + * Resolves a project-relative apply_edits target with the SAME lexical + * containment the mutation broker applies; returns null when the path + * escapes the project root so the gate can refuse before any write. + */ +function resolveEditsTarget( + projectRoot: string, + relativePath: string, +): string | null { + const absolute = path.resolve(projectRoot, relativePath) + const relative = path.relative(projectRoot, absolute) + if ( + relative === '' || + relative === '..' || + relative.startsWith(`..${path.sep}`) || + path.isAbsolute(relative) + ) { + return null + } + return absolute +} + +async function callApplyEdits( + sessionData: McpSessionData, + getBroker: () => Promise, + fileSystem: CodebuffFileSystem, + args: unknown, +): Promise { + if (!isRecord(args)) return invalid('arguments must be an object.') + const rawEdits = args.edits + if (!Array.isArray(rawEdits) || rawEdits.length === 0) { + return invalid('edits must be a non-empty array.') + } + if (rawEdits.length > MAX_APPLY_EDITS) { + return invalid(`edits supports at most ${MAX_APPLY_EDITS} entries.`) + } + const edits: { + path: string + content: string + expectedHash?: string | null + }[] = [] + for (const entry of rawEdits) { + if (!isRecord(entry)) return invalid('edits entries must be objects.') + const pathArg = optionalString(entry.path, 'edits[].path', MAX_PATH_CHARS) + if (!pathArg.ok || pathArg.value === undefined) { + return invalid(pathArg.ok ? 'edits[].path is required.' : pathArg.message) + } + if (typeof entry.content !== 'string') { + return invalid('edits[].content must be a string.') + } + if (entry.content.length > MAX_APPLY_EDIT_CONTENT_CHARS) { + return invalid( + `edits[].content exceeds the ${MAX_APPLY_EDIT_CONTENT_CHARS}-character limit.`, + ) + } + const rawHash = entry.expectedHash + if ( + rawHash !== undefined && + rawHash !== null && + typeof rawHash !== 'string' + ) { + return invalid('edits[].expectedHash must be a string or null.') + } + edits.push({ + path: pathArg.value, + content: entry.content, + ...(rawHash === undefined ? {} : { expectedHash: rawHash }), + }) + } + // AUDIT HIGH (blind-overwrite gate): every edit is gated on a verified + // snapshot of the CURRENT file BEFORE anything is written. A sensitive + // path, an escaping path, a missing/mismatched expectedHash on an + // existing file, or a non-null expectedHash on a create ALL reject the + // whole batch as a structured McpError — fail closed, no partial writes. + // The broker's compare-and-swap re-verifies the hash atomically at commit + // time; this gate adds the presence requirement, the sensitive-path + // refusal, and the structured rejections the broker cannot express. + const planned: { + path: string + content: string + expectedHash: string | null + }[] = [] + for (const edit of edits) { + if (isMandatorySensitiveReadPath(edit.path)) { + throw new McpError( + ErrorCode.InvalidParams, + `Refusing apply_edits write to sensitive path '${edit.path}': credential and key material are never writable through this tool.`, + ) + } + const absolute = resolveEditsTarget(sessionData.projectRoot, edit.path) + if (absolute === null) { + throw new McpError( + ErrorCode.InvalidParams, + `apply_edits path '${edit.path}' resolves outside the project root.`, + ) + } + // Existence snapshot FIRST: the gate decides create-vs-overwrite from + // the CURRENT file state. A read failure other than ENOENT is a real + // tool error, never a silently-permissive write. + let current: string | Buffer | undefined + try { + current = await fileSystem.readFile(absolute) + } catch (error) { + if (errnoCode(error) !== 'ENOENT') throw error + } + if (current === undefined) { + // Create-only path: the file does not exist, so expectedHash MUST be + // null/absent (a non-null hash claims knowledge of content that + // cannot exist — a stale client view). + if (edit.expectedHash != null) { + throw new McpError( + ErrorCode.InvalidParams, + `expectedHash must be null (or omitted) to create the new file '${edit.path}': the file does not exist.`, + ) + } + planned.push({ + path: edit.path, + content: edit.content, + expectedHash: null, + }) + continue + } + // Overwrite path: expectedHash is REQUIRED and must equal the hash of + // the current content — a missing/null/mismatched hash is a structured + // invalid-params rejection (fail closed, no partial writes). + if (edit.expectedHash == null) { + throw new McpError( + ErrorCode.InvalidParams, + `expectedHash is required to overwrite the existing file '${edit.path}': pass the sha256 hex of the current content (a full read_files call reports it).`, + ) + } + const expectedHex = normalizeSha256Hex(edit.expectedHash) + const actualHex = createHash('sha256').update(current).digest('hex') + if (expectedHex === null || expectedHex !== actualHex) { + throw new McpError( + ErrorCode.InvalidParams, + `expectedHash does not match the current content of '${edit.path}': the file changed since it was read; re-read and retry.`, + ) + } + planned.push({ ...edit, expectedHash: `sha256:${actualHex}` }) + continue + } + // A broker-construction failure (lock/state-dir unavailable, identity + // unresolvable) surfaces as a tool error, never a crashed server. + let broker: WorkspaceMutationBroker + try { + broker = await getBroker() + } catch (error) { + return errorResult( + `Workspace mutation broker unavailable: ${errorMessage(error)}`, + ) + } + const results: { path: string; applied: boolean; actualHash?: string }[] = [] + for (const edit of planned) { + // v1 is a full-file write: `null` is a create (the broker's create path + // is exclusive), a hash is a guarded overwrite re-verified atomically. + const commit = await broker.conditionalCommit( + edit.path, + edit.content, + edit.expectedHash, + ) + results.push({ + path: edit.path, + applied: commit.applied, + // `actualHash` exists only on the `applied: false` union variant (the + // hash that blocked the write); narrow before reading it. + ...(commit.applied === false && commit.actualHash != null + ? { actualHash: commit.actualHash } + : {}), + }) + } + return textResult(JSON.stringify(results, null, 2)) +} + +/** + * A zod-4-native `tools/call` request schema. The MCP SDK's bundled + * `CallToolRequestSchema` is zod-v3-shaped: its `params.arguments` is a + * one-argument `z.record(z.unknown())`, which this workspace's hoisted zod 4 + * cannot parse — any non-empty arguments object throws + * `undefined is not an object (evaluating 'def.valueType._zod')` inside the + * SDK's protocol layer and surfaces as a -32603 response. Registering this + * equivalent schema keeps the wire contract (method + name + optional + * arguments/_meta) while parsing under zod 4; per-tool input validation still + * happens in the handlers below. + */ +const CALL_TOOL_REQUEST_SCHEMA_V4 = z.object({ + method: z.literal('tools/call'), + params: z.object({ + name: z.string(), + arguments: z.record(z.string(), z.unknown()).optional(), + _meta: z.unknown().optional(), + }), +}) + +/** + * Builds the MCP server: a raw `Server` with the tools capability declared + * and `ListToolsRequestSchema`/`CallToolRequestSchema` handlers registered + * explicitly, so the dispatch contract stays visible at this layer. + * + * Fail-closed posture: an unknown tool name throws `McpError(MethodNotFound)` + * (a structured JSON-RPC error, never a process crash), and every handler + * exception is converted to a tool-level `{ isError: true }` result. + * + * Public return type: the structural `McpServer` (connect/close only), NOT + * the concrete MCP SDK `Server` — see the migration note on that interface + * before relying on the concrete Server surface. + */ +export function createMcpServer(options: CreateMcpServerOptions): McpServer { + const { sessionData } = options + const client: McpServerClient = options.client + const mutations = options.mutations === true + const fs = client.fileSystem ?? createNodeFileSystem() + const tools = buildTools(mutations) + const toolNames = new Set(tools.map((tool) => tool.name)) + + // The broker is constructed once per server and memoized: a per-call + // `WorkspaceMutationBroker.create` would re-acquire the workspace lock for + // every edit. It is built LAZILY (on the first apply_edits call) so an + // armed server that never mutates never touches the broker state dir. + let brokerPromise: Promise | undefined + const getMutationBroker = (): Promise => { + brokerPromise ??= WorkspaceMutationBroker.create({ + cwd: sessionData.projectRoot, + // The broker requires its state dir OUTSIDE the workspace (it rejects a + // state dir inside the project root), so it lives under the harness + // state dir rather than the project — the same convention run.ts uses. + stateDir: path.join(getHarnessStateDir(), 'mutation-broker'), + }) + return brokerPromise + } + + const server = new Server( + { + name: options.serverName ?? MCP_SERVER_NAME, + version: options.serverVersion ?? '0.0.0', + }, + { capabilities: { tools: {} } }, + ) + + server.setRequestHandler(ListToolsRequestSchema, () => ({ tools })) + + server.setRequestHandler( + // Cast: the SDK's parameter type is its bundled zod-v3-shaped schema; + // ours is the zod-4-native equivalent with the identical wire shape (see + // CALL_TOOL_REQUEST_SCHEMA_V4 above). + CALL_TOOL_REQUEST_SCHEMA_V4 as unknown as typeof CallToolRequestSchema, + async (request) => { + const { name, arguments: args } = request.params + // Fail closed BEFORE any handler logic: unknown names get a structured + // MCP error, never a dispatched call. The gate is built from the SAME + // tools array that tools/list served, so a listed tool is always handled. + if (!toolNames.has(name)) { + throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`) + } + try { + switch (name) { + case 'query_index': + return await callQueryIndex(sessionData, args) + case 'code_search': + return await callCodeSearch(client, sessionData, args) + case 'read_files': + return await callReadFiles( + { ...client, fileSystem: fs }, + sessionData, + args, + ) + case 'file_outline': + return await callFileOutline( + { ...client, fileSystem: fs }, + sessionData, + args, + ) + case 'codebase_structure': + return callCodebaseStructure(sessionData, args) + case 'memory_search': + return await callMemorySearch(sessionData, args) + case 'apply_edits': + return await callApplyEdits( + sessionData, + getMutationBroker, + fs, + args, + ) + default: + // Unreachable given the toolNames gate; kept so a registry edit + // that forgets a case still fails closed. + throw new McpError( + ErrorCode.MethodNotFound, + `Unknown tool: ${name}`, + ) + } + } catch (error) { + // A structured McpError raised BY a handler (the apply_edits + // overwrite/sensitive-path gate) IS the response: rethrow it so the + // client receives the JSON-RPC invalid-params error instead of an + // isError tool result that would flatten the structured code. + if (error instanceof McpError) throw error + // Any other handler failure is a TOOL error: the MCP client gets + // isError:true content, never a crashed server or a dropped + // JSON-RPC response. + return errorResult(`Tool ${name} failed: ${errorMessage(error)}`) + } + }, + ) + + return server +} + +/** + * Connects the MCP server to a `StdioServerTransport` and returns `{ close }` + * (a no-op for stdio — the transport's lifetime IS the process). stdout is + * the MCP wire: this layer writes nothing human-readable anywhere; the CLI + * owns the stderr banner. + */ +export async function runMcp( + options: RunMcpOptions, +): Promise<{ close: () => Promise }> { + const { signal, ...serverOptions } = options + const server = createMcpServer(serverOptions) + const transport = new StdioServerTransport() + await server.connect(transport) + const close = async () => { + await server.close() + } + if (signal) { + if (signal.aborted) { + await close() + } else { + signal.addEventListener('abort', () => void close(), { once: true }) + } + } + return { close } +} diff --git a/sdk/src/run-state.ts b/sdk/src/run-state.ts index e8842dc18d..6b929449a4 100644 --- a/sdk/src/run-state.ts +++ b/sdk/src/run-state.ts @@ -156,6 +156,17 @@ type ProjectIndexInput = { const MAX_DISCOVERED_PROJECT_READ_BYTES = 1_000_000 +/** + * Session-boot parse budget: computeProjectIndex runs before the first LLM + * call, so its tree-sitter pass must stay bounded regardless of repository + * size. The indexer's own build keeps the generous library defaults; this + * budget bounds only the synchronous-feeling cold-start pass. 64MB covers + * every repository the file-tree discovery (gitignore-aware) can yield in + * practice without truncating, while capping pathological workspaces. + * Exported for testing. + */ +export const SESSION_STATE_MAX_TOTAL_PARSE_BYTES = 64_000_000 + async function computeProjectIndex(params: ProjectIndexInput): Promise<{ fileTree: FileTreeNode[] fileTokenScores: Record @@ -167,9 +178,25 @@ async function computeProjectIndex(params: ProjectIndexInput): Promise<{ if (filePaths.length > 0) { try { - const tokenData = await getFileTokenScores(cwd, filePaths, readFile) + const tokenData = await getFileTokenScores( + cwd, + filePaths, + readFile, + undefined, + { maxTotalBytes: SESSION_STATE_MAX_TOTAL_PARSE_BYTES }, + ) fileTokenScores = tokenData.tokenScores tokenCallers = tokenData.tokenCallers + if (tokenData.coverage?.truncated) { + logger?.debug?.( + { + parsedFiles: tokenData.coverage.parsedFiles, + skippedFiles: tokenData.coverage.skippedFiles, + maxTotalBytes: SESSION_STATE_MAX_TOTAL_PARSE_BYTES, + }, + 'Session-state parse budget truncated token scoring', + ) + } } catch (error) { // If token scoring fails, continue with empty scores logger?.debug?.( diff --git a/sdk/src/run.ts b/sdk/src/run.ts index cdee797e5e..7b8d459610 100644 --- a/sdk/src/run.ts +++ b/sdk/src/run.ts @@ -9,9 +9,12 @@ import { import { MAX_AGENT_STEPS_DEFAULT } from '@codebuff/common/constants/agents' import { getMCPClient, + getMCPClientCacheKey, listMCPTools, callMCPTool, + resolveMCPConfigOrigin, } from '@codebuff/common/mcp/client' +import type { MCPConfig } from '@codebuff/common/types/mcp' import { toolNames } from '@codebuff/common/tools/constants' import { fileMutationResultV1Schema, @@ -47,11 +50,20 @@ import { import { WorkspaceJournalService } from './services/workspace-journal' import { WorkspaceMutationBroker } from './services/workspace-mutation-broker' import { LocalHarnessStore } from './services/local-harness-store' +import { + captureDiagnostics, + supportedDiagnosticFiles, +} from './services/diagnostic-delta' +import { + createDiagnosticCommandRunner, + createDiagnosticDeltaHook, +} from './services/diagnostic-delta-runner' import { MemoryV2Coordinator } from './services/memory-v2/coordinator' import type { MemoryV2ClientConfig } from './services/memory-v2/types' import { HarnessApprovalService, evaluateHarnessActionPolicy, + hashCommand, } from './services/harness-enforcement' import type { HarnessApprovalMode, @@ -88,18 +100,35 @@ import { runTargetedValidation } from './tools/run-targeted-validation' import { inspectEnvironment } from './tools/inspect-environment' import { getAffectedTests } from './tools/get-affected-tests' import { getBuildTargets } from './tools/get-build-targets' +import { findReferences } from './tools/find-references' +import { goToDefinition } from './tools/go-to-definition' +import { hoverType } from './tools/hover-type' +import { workspaceSymbol } from './tools/workspace-symbol' +import { + createLanguageIntelligence, + type LanguageIntelligenceService, +} from './services/language-intelligence' import { evaluateAuditCoverageTool, inspectCodebaseStructureTool, inspectFeatureCompletenessTool, } from './tools/audit-intelligence' import { gitBranch } from './tools/git-branch' -import { runFileChangeHooks } from './tools/file-change-hooks' +import { + isDiagnosticPreflightEnabled, + runFileChangeHooks, + type DiagnosticDeltaHook, +} from './tools/file-change-hooks' import { findFileMutationResult, writeAuditFindings, } from './tools/write-audit-findings' import { createNodeFileSystem } from './tools/node-filesystem' +import { + createTransactionIntentLogForWorkspace, + recoverAndRevertInterruptedTransactions, + type TransactionIntentLog, +} from './tools/transaction-intent-log' import type { FilesystemAuthorityPolicy } from './tools/filesystem-authority' import type { CustomToolDefinition } from './custom-tool' @@ -129,12 +158,67 @@ import type { JobOwner } from '@codebuff/common/util/job-registry' import type { ReadCapabilityIssuer } from '@codebuff/common/util/content-hash' import type { Source } from '@codebuff/common/types/source' import type { CodebuffSpawn } from '@codebuff/common/types/spawn' +import type { LanguageDiagnostic } from './tools/language-diagnostics' import { listJobs } from './tools/list-jobs' import type { ListJobsViewRow } from '@codebuff/common/util/list-jobs-view' import { fingerprintListJobsRows } from '@codebuff/common/util/list-jobs-view' import { getSystemProcessEnv } from './env' import { spawn as nodeSpawn } from 'node:child_process' +import type { + JournalReader, + JournalWriter, +} from '@codebuff/common/types/contracts/agent-runtime' +import { createRunResumeDriver } from './services/run-resume-driver' + +/** + * Audit fix (D): tool calls whose receipt can contain file actions — the same + * set that reaches handleToolCall's post-commit mutation block (the + * change-file tools plus the compact-receipt write tool). Mirrors the + * toolName list the override receipt guard uses there. + */ +const DIAGNOSTIC_BASELINE_TOOL_NAMES: ReadonlySet = new Set([ + 'write_file', + 'str_replace', + 'create_plan', + 'edit_transaction', + 'replace_range', + 'write_audit_findings', +]) + +/** + * Audit fix (D): bounded file list for the pre-edit baseline capture — the + * paths a mutating tool call targets, derived from its input WITHOUT any + * schema change. Each tool input carries at most a few paths, so the list is + * bounded by construction; supportedDiagnosticFiles filters it further before + * capture, and an empty output means no capture runs at all. + */ +function diagnosticBaselineFiles(toolName: string, input: unknown): string[] { + const record = (input ?? {}) as Record + switch (toolName) { + case 'write_file': + case 'str_replace': + case 'create_plan': + case 'replace_range': { + const file = typeof record.path === 'string' ? record.path : undefined + return file ? [file] : [] + } + case 'edit_transaction': { + const edits = Array.isArray(record.edits) ? record.edits : [] + const files = new Set() + for (const edit of edits) { + if (!edit || typeof edit !== 'object') continue + const editPath = (edit as { path?: unknown }).path + if (typeof editPath === 'string' && editPath !== '') { + files.add(editPath) + } + } + return [...files] + } + default: + return [] + } +} /** * Stable trusted background-job ownership seed for THIS client session. @@ -227,6 +311,17 @@ export type OpenbuffClientOptions = { projectFiles?: Record knowledgeFiles?: Record agentDefinitions?: AgentDefinition[] + /** + * Host-supplied MCP servers to attach to the agent being run. They are + * merged (by object REFERENCE, never cloned) into the resolved agent + * template's `mcpServers` after the session state is built, so the run + * loop's getMCPToolData picks them up. Callers that need these treated as + * untrusted (e.g. the serve bridge, for client-advertised servers) mark them + * with markAllMCPConfigOrigins BEFORE passing them here; run() never + * re-marks or clones them, so the identity-keyed origin marks survive to + * resolveMCPConfigOrigin. + */ + mcpServers?: Record maxAgentSteps?: number env?: Record /** Harness control-plane state root. Defaults to the Openbuff config directory. */ @@ -299,6 +394,29 @@ export type OpenbuffClientOptions = { * unref'd so it won't keep a host process alive on its own; it still fires * while the event loop is busy with the active run. */ runTimeoutMs?: number + + /** + * P1-T3 backend seam. Optional pluggable execution backend for + * `OpenbuffClient.run()`. When omitted, the in-process backend (today's + * `run()`) is used and behavior is byte-identical to before. Supplying a + * backend (e.g. `AcpRemoteBackend`) routes the run through it instead, so a + * host can attach to a live `openbuff serve` session. Additive-optional. + */ + backend?: import('./client/backend').ClientBackend + + /** P2-T2: append-only run-journal writer (see createRunJournal in + * @codebuff/agent-runtime/util/run-journal). Threaded into the agent + * runtime deps so the run loop journals tool_call/tool_result/step + * boundaries for crash-safe resume. Additive-optional: a run without it + * is byte-identical to today (no journaling occurs). */ + journalWriter?: JournalWriter + + /** P2-T2: run-journal reader threaded into the agent runtime deps so the + * existing guarded block in runAgentStep classifies/replays interrupted + * prior work, and so the §4c replay short-circuit reuses journaled tool + * results (keyed deterministically by toolName+input per P2-T2-DESIGN §5, + * never by the freshly-minted toolCallId). Additive-optional. */ + journalReader?: JournalReader } export type FilesystemMutationEvent = { @@ -565,6 +683,13 @@ type RunExecutionOptions = RunOptions & } type RunReturnType = RunState +/** + * P2-T5: guards the once-per-process startup recovery of interrupted + * multi-file transactions, keyed by state dir + intent-log file so two + * concurrent projects in one process each recover exactly once. + */ +const transactionRecoveryPerformed = new Set() + export async function run(options: RunExecutionOptions): Promise { const { signal } = options @@ -591,6 +716,7 @@ async function runOnce({ projectFiles, knowledgeFiles, agentDefinitions, + mcpServers, maxAgentSteps = MAX_AGENT_STEPS_DEFAULT, env, harnessStateDir, @@ -614,6 +740,8 @@ async function runOnce({ spawnSource, logger, memoryV2, + journalWriter, + journalReader, agent, prompt, @@ -752,6 +880,35 @@ async function runOnce({ } else { fs = createNodeFileSystem() } + + // P2-T5: startup recovery of interrupted multi-file transactions. The + // intent log is colocated with the harness state dir (outside the project + // tree it guards) and scoped to this workspace. Best-effort and + // once-per-process: failures are logged and never crash startup. + let intentLog: TransactionIntentLog | undefined + if (cwd) { + intentLog = createTransactionIntentLogForWorkspace({ + stateDir: resolvedHarnessStateDir, + cwd, + }) + const recoveryKey = `${resolvedHarnessStateDir}\u0000${intentLog.filePath}` + if (!transactionRecoveryPerformed.has(recoveryKey)) { + transactionRecoveryPerformed.add(recoveryKey) + try { + await recoverAndRevertInterruptedTransactions({ + intentLog, + cwd, + fs, + logger, + }) + } catch (error) { + logger?.warn( + { error }, + 'Transaction intent recovery failed; startup continues', + ) + } + } + } let spawn: CodebuffSpawn if (spawnSource) { const spawnSourceValue = await spawnSource @@ -759,6 +916,17 @@ async function runOnce({ } else { spawn = nodeSpawn as CodebuffSpawn } + // Audit fix (B): production diagnostic-delta preflight wiring. The runner + // executes diagnostic commands as argv arrays over this run's child-process + // seam (never a shell), and the injector is fail-open. The + // OPENBUFF_DIAGNOSTIC_PREFLIGHT flag check lives inside runFileChangeHooks, + // so passing the injector unconditionally keeps flag-off behavior + // byte-identical. + const diagnosticDeltaHook = createDiagnosticDeltaHook({ spawn }) + // Audit fix (D): the pre-edit baseline capture below runs through the SAME + // argv-array child-process seam (its own stateless adapter instance — the + // runner holds no state), bounded exactly like the hook's own captures. + const diagnosticBaselineRunner = createDiagnosticCommandRunner({ spawn }) const preparedContent = wrapContentForUserMessage(content) // Per-run client session id (also the trusted process-job owner session). @@ -814,6 +982,26 @@ async function runOnce({ workspaceMoves: persistedWorkspaceMoves, }) } + // Attach host-supplied MCP servers to the RESOLVED agent template so the run + // loop's getMCPToolData reads them. This runs AFTER sessionState is fully + // built (post initialSessionState / applyOverridesToSessionState, both of + // which JSON-clone), so merging the SAME MCPConfig object references here is + // never cloned again. Object identity MUST be preserved: origin marks are + // recorded in the mcpConfigOrigins WeakMap keyed by config object identity, + // so cloning would strip the caller's 'client' marks and + // getMCPToolData / resolveMCPConfigOrigin would no longer see them. run() + // never re-marks or re-origins them — it only merges references. Scoped to + // the single resolved agentId's template; if that template is missing this + // no-ops safely. + if (mcpServers && Object.keys(mcpServers).length > 0) { + const targetTemplate = sessionState.fileContext.agentTemplates?.[agentId] + if (targetTemplate) { + targetTemplate.mcpServers = { + ...targetTemplate.mcpServers, + ...mcpServers, + } + } + } // Snapshot the hydrated task memory so post-run persistence can merge the // final memory into it. The runtime replaces this property rather than // mutating it in place, so holding the reference is sufficient. @@ -850,6 +1038,17 @@ async function runOnce({ const approvalService = new HarnessApprovalService( new LocalHarnessStore(resolvedHarnessStateDir), ) + // P3-T2 (LI-01): lazily-built language-intelligence service shared across the + // four LSP read tools. Only constructed when a language-intelligence client + // tool actually fires, so a run that never uses them never spawns a server. + let languageIntelligenceService: LanguageIntelligenceService | undefined + const getLanguageIntelligence = (): LanguageIntelligenceService => { + languageIntelligenceService ??= createLanguageIntelligence({ + cwd: requireCwd(cwd, 'language intelligence'), + spawn, + }) + return languageIntelligenceService + } if (workspaceJournal) { try { const persistedWorkspace = workspaceJournal.read() @@ -1061,9 +1260,29 @@ async function runOnce({ // means no git_status observation has been emitted yet this turn, so the // first call always returns the full observation. let lastGitStatusFingerprint: string | null = null + // Per-run set of already-approved client-origin MCP tool keys (P1-T2). + // Declared in this run closure (not module scope) so every run() call starts + // with a fresh approval scope and concurrent runs never share it. The FIRST + // client-origin call of each (server-config, tool) pair is gated behind the + // host approver; subsequent calls of the same tool this run run freely. + const approvedClientMcpTools = new Set() + // P2-audit-fix-8: production crash-resume driver. Built exactly when a + // journal reader is wired (the same condition under which the runtime's + // loop-entry block builds the resume report) and threaded additively into + // the agent-runtime deps so loopAgentSteps' existing not-clean branch can + // ACT on the report: re-drive in-flight children (best-effort; recorded) + // and re-issue interrupted background intents with the durable respawnOf + // marker. Fail-open by contract; a run with no journalReader gets no + // resumeDriver field at all and stays byte-identical. + const resumeDriver = journalReader + ? createRunResumeDriver({ journalWriter, journalReader, logger }) + : undefined const agentRuntimeImpl = getAgentRuntimeImpl({ logger, apiKey, + journalWriter, + journalReader, + ...(resumeDriver ? { resumeDriver } : {}), handleStepsLogChunk: () => { // Does nothing for now }, @@ -1087,6 +1306,53 @@ async function runOnce({ if (cloneMatch?.[1]) ownedLibrarianCloneDirs.add(cloneMatch[1]) } } + // Audit fix (D): pre-edit baseline capture for the diagnostic-delta + // preflight. For tool calls whose receipt can contain file actions (the + // same set that reaches the post-commit mutation block), capture the + // PRE-EDIT diagnostic state ONCE so the hook can compute a real + // before/after delta. Bounded and fail-open: only when the + // OPENBUFF_DIAGNOSTIC_PREFLIGHT flag is on, only one diagnostic-command + // pass per mutating tool call, and any capture error degrades to no + // baseline (today's exact hook behavior). The baseline is captured into + // this tool call's closure and consumed by the per-call hook wrapper + // below, so it can never leak across tool calls. + const isMutatingToolCall = DIAGNOSTIC_BASELINE_TOOL_NAMES.has(toolName) + let diagnosticBaseline: LanguageDiagnostic[] | undefined + if ( + isMutatingToolCall && + cwd && + env && + isDiagnosticPreflightEnabled(env) + ) { + try { + const baselineFiles = supportedDiagnosticFiles( + diagnosticBaselineFiles(toolName, input), + ) + if (baselineFiles.length > 0) { + diagnosticBaseline = await captureDiagnostics({ + files: baselineFiles, + cwd, + runCommand: diagnosticBaselineRunner, + env, + signal: runSignal, + }) + } + } catch { + // Fail-open: no baseline → the hook keeps today's no-baseline path. + diagnosticBaseline = undefined + } + } + // Per-call hook wrapper: only a mutating tool call with a captured + // baseline supplies it (and the hook then drops skipSecondCapture so + // the after-capture runs for real); every other call reuses the shared + // hook unchanged, keeping no-baseline behavior byte-identical. + const diagnosticDeltaForCall: DiagnosticDeltaHook = diagnosticBaseline + ? (hookParams) => + createDiagnosticDeltaHook({ + spawn, + getBaseline: () => diagnosticBaseline, + })(hookParams) + : diagnosticDeltaHook const trustedCallId = callId ?? crypto.randomUUID() const handled = await handleToolCall({ action: { @@ -1101,6 +1367,8 @@ async function runOnce({ onFilesChanged, onFilesystemMutation, verifyExternalMutation, + getLanguageIntelligence, + diagnosticDelta: diagnosticDeltaForCall, customToolDefinitions: customToolDefinitions ? Object.fromEntries( customToolDefinitions.map((def) => [def.toolName, def]), @@ -1110,6 +1378,7 @@ async function runOnce({ fs, fileFilter, filesystemPolicy, + intentLog, trustedJobOwner, logger, capabilityIssuer: cwd @@ -1125,6 +1394,7 @@ async function runOnce({ approvalReceiptIds, approvalMode, requestApproval, + approvedClientMcpTools, approvalService, harnessWorkspaceIdentity: workspaceJournal ? { @@ -1468,6 +1738,12 @@ async function runOnce({ // browses opened this run are still owned next turn until stopped here. clientSessionId: trustedJobOwner.clientSessionId, }) + // Dispose warm language servers; no-op when no language-intelligence tool ran. + try { + await languageIntelligenceService?.dispose() + } catch { + // best-effort + } const cleanupLibrarianClone = (cloneDir: string) => { try { rmSync(cloneDir, { recursive: true, force: true }) @@ -1746,6 +2022,7 @@ export async function handleToolCall({ fs, fileFilter, filesystemPolicy, + intentLog, trustedJobOwner, capabilityIssuer, logger, @@ -1754,6 +2031,7 @@ export async function handleToolCall({ approvalReceiptIds, approvalMode, requestApproval, + approvedClientMcpTools, approvalService, harnessWorkspaceIdentity, getWorkspaceState, @@ -1762,6 +2040,8 @@ export async function handleToolCall({ onFilesChanged, onFilesystemMutation, verifyExternalMutation, + getLanguageIntelligence, + diagnosticDelta, signal, }: { action: ServerAction<'tool-call-request'> @@ -1771,6 +2051,8 @@ export async function handleToolCall({ fs: CodebuffFileSystem fileFilter?: FileFilter filesystemPolicy?: FilesystemAuthorityPolicy + /** P2-T5: durable transaction-intent log for multi-file commit loops. */ + intentLog?: TransactionIntentLog /** Trusted owner injected into every process-job op; never model-derived. */ trustedJobOwner: JobOwner capabilityIssuer?: ReadCapabilityIssuer @@ -1780,6 +2062,10 @@ export async function handleToolCall({ approvalReceiptIds: string[] approvalMode: HarnessApprovalMode requestApproval?: OpenbuffClientOptions['requestApproval'] + /** Per-run set of already-approved client-origin MCP tool keys (P1-T2). + * Optional so existing direct callers/tests keep working; when absent, the + * gate still enforces approval but with a fresh local set for this call. */ + approvedClientMcpTools?: Set approvalService: HarnessApprovalService harnessWorkspaceIdentity?: { repositoryId: string @@ -1796,12 +2082,37 @@ export async function handleToolCall({ onFilesChanged?: OpenbuffClientOptions['onFilesChanged'] onFilesystemMutation?: OpenbuffClientOptions['onFilesystemMutation'] verifyExternalMutation?: OpenbuffClientOptions['verifyExternalMutation'] + /** + * P3-T2 (LI-01): lazily builds/returns this run's shared language-intelligence + * service. Optional so existing direct `handleToolCall` callers keep working; + * when absent the four LSP read tools fall back to a spawner-less service that + * degrades each query into a structured result instead of throwing. + */ + getLanguageIntelligence?: () => LanguageIntelligenceService + /** Audit fix (B): fail-open diagnostic-delta preflight injector threaded + * into run_file_change_hooks (and forwarded to run_targeted_validation). + * Optional so existing direct callers/tests keep working; absent means the + * preflight seam stays off. The OPENBUFF_DIAGNOSTIC_PREFLIGHT flag check + * lives inside runFileChangeHooks. */ + diagnosticDelta?: DiagnosticDeltaHook signal?: AbortSignal }): Promise<{ output: ToolResultOutput[] canonicalReceipt?: CommitReceiptV1 }> { const toolName = action.toolName + // P3-T2 (LI-01): resolve the shared language-intelligence service. runOnce + // injects a getter closing over this run's spawn/cwd; direct handleToolCall + // callers (tests) fall back to a spawner-less service whose queries degrade + // into a structured errorMessage result rather than throwing. + let fallbackLanguageIntelligence: LanguageIntelligenceService | undefined + const resolveLanguageIntelligence = (): LanguageIntelligenceService => { + if (getLanguageIntelligence) return getLanguageIntelligence() + fallbackLanguageIntelligence ??= createLanguageIntelligence({ + cwd: requireCwd(cwd, 'language intelligence'), + }) + return fallbackLanguageIntelligence + } const input = typeof action.input === 'string' ? parseJsonBounded(action.input) @@ -1815,6 +2126,63 @@ export async function handleToolCall({ // Handle MCP tool calls when mcpConfig is present if (action.mcpConfig) { + // P1-T2: gate the FIRST call of each tool from a `client`-origin MCP + // config behind the host approval callback. Trusted origins + // (`user`/`project`, and any explicitly trusted mark) proceed unchanged + // with no approval. `resolveMCPConfigOrigin` fails closed to `client` for + // unmarked configs, so an unmarked config is gated too — matching every + // other origin consumer. + if (resolveMCPConfigOrigin(action.mcpConfig) === 'client') { + // Stable per-(server-config, tool) key. `getMCPClientCacheKey` is the + // same identity `getMCPClient` computes downstream, so computing it here + // first is safe. If it can ever throw for a malformed config, fall back + // to a JSON key so a key failure never bypasses the gate. + let cacheKey: string + try { + cacheKey = getMCPClientCacheKey(action.mcpConfig, { origin: 'client' }) + } catch { + cacheKey = JSON.stringify(action.mcpConfig) + } + const approvalKey = `${cacheKey}\u0000${toolName}` + // When no per-run set is threaded in, use a fresh local set so behavior + // is defined (the gate still enforces approval for this call). + const approvedTools = approvedClientMcpTools ?? new Set() + if (!approvedTools.has(approvalKey)) { + if (requestApproval) { + // NUL-free, human-readable approval target derived from the config. + const serverLabel = + action.mcpConfig.type === 'stdio' + ? action.mcpConfig.command + : action.mcpConfig.url + const approved = await requestApproval({ + action: 'mcp-tool', + target: `${serverLabel} ${toolName}`, + commandHash: hashCommand(approvalKey), + reason: `MCP tool '${toolName}' comes from a client-supplied (untrusted) MCP server and is running for the first time this run.`, + risk: 'high', + }) + if (!approved) { + // Denied: do NOT call the tool. Return the same error-output shape + // the branch uses for a thrown MCP error so callers parse it + // identically to a failed call. + return { + output: [ + { + type: 'json', + value: { + errorMessage: `Client MCP tool '${toolName}' was denied approval.`, + }, + }, + ], + } + } + } + // Approved by the host, or fail-open when no host approver exists + // (headless/CI). Record the key so subsequent calls of this same tool + // this run run freely. + approvedTools.add(approvalKey) + } + } try { const mcpClientId = await getMCPClient(action.mcpConfig) const result = await callMCPTool( @@ -2031,6 +2399,7 @@ export async function handleToolCall({ signal, fileFilter, filesystemPolicy, + intentLog, capabilityIssuer, callId: action.requestId, logger, @@ -2132,6 +2501,7 @@ export async function handleToolCall({ approvalId, action: classified.action, target: classified.target, + commandHash: classified.commandHash, snapshotId, }) const approvedDecision = evaluateHarnessActionPolicy({ @@ -2167,7 +2537,11 @@ export async function handleToolCall({ runId: rootRunId, snapshotId, }, - { action: classified.action, target: classified.target }, + { + action: classified.action, + target: classified.target, + commandHash: classified.commandHash, + }, ) const receipt = approvalService.consume({ ...harnessWorkspaceIdentity, @@ -2176,6 +2550,7 @@ export async function handleToolCall({ approvalId: grant.id, action: classified.action, target: classified.target, + commandHash: classified.commandHash, }) const approvedDecision = evaluateHarnessActionPolicy({ ...classified, @@ -2340,6 +2715,7 @@ export async function handleToolCall({ env, signal, fileSystem: fs, + diagnosticDelta, }) } else if (toolName === 'check_job') { // The trusted owner overrides any model-supplied owner in the input. @@ -2415,6 +2791,7 @@ export async function handleToolCall({ env, signal, fileSystem: fs, + diagnosticDelta, workspaceState: getWorkspaceState(), }) } else if (toolName === 'inspect_environment') { @@ -2424,6 +2801,26 @@ export async function handleToolCall({ requireCwd(cwd, 'get_affected_tests'), (input as { files: string[] }).files, ) + } else if (toolName === 'go_to_definition') { + result = await goToDefinition( + resolveLanguageIntelligence(), + input as { path: string; line: number; character: number }, + ) + } else if (toolName === 'find_references') { + result = await findReferences( + resolveLanguageIntelligence(), + input as { path: string; line: number; character: number }, + ) + } else if (toolName === 'hover_type') { + result = await hoverType( + resolveLanguageIntelligence(), + input as { path: string; line: number; character: number }, + ) + } else if (toolName === 'workspace_symbol') { + result = await workspaceSymbol( + resolveLanguageIntelligence(), + input as { query: string }, + ) } else if (toolName === 'get_build_targets') { result = getBuildTargets( requireCwd(cwd, 'get_build_targets'), @@ -2535,6 +2932,33 @@ export async function handleToolCall({ ? advanceWorkspaceJournal(workspaceChange) : advanceWorkspaceState(getWorkspaceState(), workspaceChange) setWorkspaceState(workspaceState) + // Audit fix (C): push the committed files into any already-running + // language server so post-edit LSP answers are not stale. Guarded and + // fail-open: sync can never fail the run that just committed the write. + // Only successfully-written project-relative paths are synced; a move + // syncs its destination path. + const syncedPaths = changedActions + .filter((changed) => changed.action !== 'delete') + .map((changed) => + changed.action === 'move' ? changed.destinationPath : changed.path, + ) + .filter((changedPath): changedPath is string => Boolean(changedPath)) + // Audit fix: deleted paths are forwarded separately as a didClose so a + // warm language server drops its stale open-document state for files + // that no longer exist (they are excluded from syncedPaths above). + const closedPaths = changedActions + .filter((changed) => changed.action === 'delete') + .map((changed) => changed.path) + .filter((changedPath): changedPath is string => Boolean(changedPath)) + if (syncedPaths.length > 0 || closedPaths.length > 0) { + try { + await getLanguageIntelligence?.().syncMutatedFiles(syncedPaths, { + closedPaths, + }) + } catch (error) { + logger?.warn({ error }, 'Language-server document sync failed') + } + } // Only a mutation that was actually part of `result` can be enriched in // place. Tools whose declared output is a compact receipt reach this block // through `compactReceiptMutation`; their receipt schema declares no diff --git a/sdk/src/serve/__tests__/event-bridge.test.ts b/sdk/src/serve/__tests__/event-bridge.test.ts new file mode 100644 index 0000000000..afa81bd4f5 --- /dev/null +++ b/sdk/src/serve/__tests__/event-bridge.test.ts @@ -0,0 +1,1000 @@ +import { describe, expect, test } from 'bun:test' + +import type { PrintModeEvent } from '@codebuff/common/types/print-mode' + +import { + printModeErrorToRpcError, + printModeToSessionUpdates, + UnhandledPrintModeVariantError, +} from '../event-bridge' +import type { EventBridgeContext } from '../event-bridge' + +function makeCtx(overrides?: Partial): EventBridgeContext { + return { + sessionId: 's1', + runId: 'run-1', + eventsMode: 'acp', + projectRoot: '/proj', + toolKind: (toolName) => (toolName === 'write_file' ? 'edit' : 'other'), + ...overrides, + } +} + +describe('printModeToSessionUpdates', () => { + test('text maps to agent_message_chunk with messageId = ctx.runId', () => { + const updates = printModeToSessionUpdates( + { type: 'text', text: 'hello world' }, + makeCtx(), + ) + expect(updates).toEqual([ + { + sessionUpdate: 'agent_message_chunk', + messageId: 'run-1', + content: { type: 'text', text: 'hello world' }, + }, + ]) + }) + + test('subagent text is dropped in acp mode and carries _meta.agentId in full mode', () => { + // The real `text` variant carries `agentId` only (no parentAgentId), so + // only agentId appears in _meta. + const event: PrintModeEvent = { + type: 'text', + text: 'child text', + agentId: 'agent-2', + } + expect(printModeToSessionUpdates(event, makeCtx())).toEqual([]) + expect( + printModeToSessionUpdates(event, makeCtx({ eventsMode: 'full' })), + ).toEqual([ + { + sessionUpdate: 'agent_message_chunk', + messageId: 'run-1', + content: { type: 'text', text: 'child text' }, + _meta: { 'openbuff.dev': { agentId: 'agent-2' } }, + }, + ]) + }) + + test('reasoning_delta maps root-only in acp mode and everywhere in full mode', () => { + const root: PrintModeEvent = { + type: 'reasoning_delta', + text: 'thinking', + ancestorRunIds: [], + runId: 'r1', + } + expect(printModeToSessionUpdates(root, makeCtx())).toEqual([ + { + sessionUpdate: 'agent_thought_chunk', + messageId: 'run-1', + content: { type: 'text', text: 'thinking' }, + }, + ]) + + const nested: PrintModeEvent = { + type: 'reasoning_delta', + text: 'child thinking', + ancestorRunIds: ['r1'], + runId: 'r2', + } + expect(printModeToSessionUpdates(nested, makeCtx())).toEqual([]) + expect( + printModeToSessionUpdates(nested, makeCtx({ eventsMode: 'full' })), + ).toEqual([ + { + sessionUpdate: 'agent_thought_chunk', + messageId: 'run-1', + content: { type: 'text', text: 'child thinking' }, + }, + ]) + }) + + test('tool_call maps kind via ctx.toolKind and makes path params absolute', () => { + const updates = printModeToSessionUpdates( + { + type: 'tool_call', + toolCallId: 't1', + toolName: 'write_file', + input: { path: 'src/a.ts', content: 'x' }, + }, + makeCtx(), + ) + expect(updates).toEqual([ + { + sessionUpdate: 'tool_call', + toolCallId: 't1', + title: 'write_file', + kind: 'edit', + status: 'pending', + locations: [{ path: '/proj/src/a.ts' }], + // NEW-6: rawInput rides every tool card; src/a.ts is not a sensitive + // path, so the content-bearing field passes through unredacted. + rawInput: { content: 'x', path: 'src/a.ts' }, + }, + ]) + }) + + test('tool_call without path params omits locations', () => { + const updates = printModeToSessionUpdates( + { + type: 'tool_call', + toolCallId: 't2', + toolName: 'code_search', + input: { query: 'x' }, + }, + makeCtx(), + ) + expect(updates).toEqual([ + { + sessionUpdate: 'tool_call', + toolCallId: 't2', + title: 'code_search', + kind: 'other', + status: 'pending', + // NEW-6: rawInput rides every tool card. + rawInput: { query: 'x' }, + }, + ]) + }) + + test('NEW-3: tool_call rawInput redacts configured credential values', () => { + const secret = 'sk-live-credential-value-9876543210' + const updates = printModeToSessionUpdates( + { + type: 'tool_call', + toolCallId: 't3', + toolName: 'run_terminal_command', + input: { + command: `curl -H "Authorization: Bearer ${secret}" https://example.com`, + }, + }, + makeCtx({ credentialValues: [secret] }), + ) + const rawInput = (updates[0] as { rawInput: Record }) + .rawInput + expect(rawInput.command).toBe( + 'curl -H "Authorization: Bearer [REDACTED_SECRET]" https://example.com', + ) + expect(JSON.stringify(rawInput)).not.toContain(secret) + }) + + test('NEW-3: credential redaction reaches nested rawInput fields', () => { + const secret = 'sk-deep-credential-value-42' + const updates = printModeToSessionUpdates( + { + type: 'tool_call', + toolCallId: 't4', + toolName: 'edit_transaction', + input: { + path: 'src/config.ts', + edits: [{ oldString: `const key = '${secret}'`, newString: 'x' }], + }, + }, + makeCtx({ credentialValues: [secret] }), + ) + const rawInput = (updates[0] as { rawInput: Record }) + .rawInput + const edits = rawInput.edits as Array> + expect(edits[0]!.oldString).toBe("const key = '[REDACTED_SECRET]'") + }) + + test('NEW-3 composes with NEW-6: non-content fields of a sensitive-path card still redact credentials', () => { + const secret = 'sk-compose-credential-value-777' + const updates = printModeToSessionUpdates( + { + type: 'tool_call', + toolCallId: 't5', + toolName: 'write_file', + input: { + path: '.env', + content: `TOKEN=${secret}`, + note: `rotate ${secret} soon`, + }, + }, + makeCtx({ credentialValues: [secret] }), + ) + const rawInput = (updates[0] as { rawInput: Record }) + .rawInput + // NEW-6: content-bearing fields of a sensitive path become [sensitive]. + expect(rawInput.content).toBe('[sensitive]') + // NEW-3: every other string field still has the credential value replaced. + expect(rawInput.note).toBe('rotate [REDACTED_SECRET] soon') + }) + + test('NEW-6: a sensitive-path replace_range tool_call redacts the top-level newContent field', () => { + const updates = printModeToSessionUpdates( + { + type: 'tool_call', + toolCallId: 't-env', + toolName: 'replace_range', + input: { + path: '.env', + newContent: 'OPENROUTER_API_KEY=sk-live-abc123\n', + oldString: 'OPENROUTER_API_KEY=sk-live-old\n', + }, + }, + makeCtx(), + ) + expect(updates).toEqual([ + { + sessionUpdate: 'tool_call', + toolCallId: 't-env', + title: 'replace_range', + kind: 'other', + status: 'pending', + locations: [{ path: '/proj/.env' }], + // NEW-6/GV-25: `newContent` is a content-bearing rawInput field, so a + // card touching a sensitive path must never leak the replacement text. + rawInput: { + path: '.env', + newContent: '[sensitive]', + oldString: '[sensitive]', + }, + }, + ]) + }) + + test('NEW-6: a non-sensitive-path replace_range keeps newContent unredacted', () => { + const updates = printModeToSessionUpdates( + { + type: 'tool_call', + toolCallId: 't-src', + toolName: 'replace_range', + input: { + path: 'src/a.ts', + newContent: 'export const answer = 42\n', + oldString: 'export const answer = 41\n', + }, + }, + makeCtx(), + ) + expect(updates).toEqual([ + { + sessionUpdate: 'tool_call', + toolCallId: 't-src', + title: 'replace_range', + kind: 'other', + status: 'pending', + locations: [{ path: '/proj/src/a.ts' }], + rawInput: { + path: 'src/a.ts', + newContent: 'export const answer = 42\n', + oldString: 'export const answer = 41\n', + }, + }, + ]) + }) + + test('NEW-6: a sensitive-path edit_transaction redacts nested structured-edit operation text', () => { + const updates = printModeToSessionUpdates( + { + type: 'tool_call', + toolCallId: 't-structured', + toolName: 'edit_transaction', + input: { + path: '.env', + edits: [ + { + id: 'insert-secret', + type: 'structured', + path: '.env', + operation: { + kind: 'insert_text', + position: { line: 1, column: 1 }, + text: 'OPENROUTER_API_KEY=sk-live-secret', + }, + }, + { + id: 'add-import', + type: 'structured', + path: '.env', + operation: { + kind: 'insert_import', + importStatement: 'import { apiKey } from "./secrets"', + }, + }, + ], + }, + }, + makeCtx(), + ) + expect(updates).toEqual([ + { + sessionUpdate: 'tool_call', + toolCallId: 't-structured', + title: 'edit_transaction', + kind: 'other', + status: 'pending', + locations: [{ path: '/proj/.env' }], + // NEW-6/GV-25: the nested content positions of structured edits + // (`operation.text` / `operation.importStatement`) are file content a + // flat field list cannot see, so the element rule is deep: every + // string at every depth inside an edit element is redacted while + // numeric/structural fields (position) survive. + rawInput: { + path: '.env', + edits: [ + { + id: '[sensitive]', + type: '[sensitive]', + path: '[sensitive]', + operation: { + kind: '[sensitive]', + position: { line: 1, column: 1 }, + text: '[sensitive]', + }, + }, + { + id: '[sensitive]', + type: '[sensitive]', + path: '[sensitive]', + operation: { + kind: '[sensitive]', + importStatement: '[sensitive]', + }, + }, + ], + }, + }, + ]) + }) + + test('NEW-6: a sensitive-path edit_transaction redacts occurrence-targeted replace_range edits', () => { + const updates = printModeToSessionUpdates( + { + type: 'tool_call', + toolCallId: 't-occurrence', + toolName: 'edit_transaction', + input: { + path: 'secrets.keys', + edits: [ + { + type: 'replace_range', + path: 'secrets.keys', + readCapability: 'cap.v3.1.436.AAAA.BBBB', + occurrence: { match: 'private key material line' }, + newContent: 'replacement private key material', + }, + ], + }, + }, + makeCtx(), + ) + expect(updates).toEqual([ + { + sessionUpdate: 'tool_call', + toolCallId: 't-occurrence', + title: 'edit_transaction', + kind: 'other', + status: 'pending', + locations: [{ path: '/proj/secrets.keys' }], + // NEW-6/GV-25: `occurrence.match` names the exact file text being + // replaced, and the edit's readCapability is a cap.v3 token — both + // must never cross the wire for a sensitive-path card. + rawInput: { + path: 'secrets.keys', + edits: [ + { + type: '[sensitive]', + path: '[sensitive]', + readCapability: '[sensitive]', + occurrence: { match: '[sensitive]' }, + newContent: '[sensitive]', + }, + ], + }, + }, + ]) + }) + + test('NEW-6: a non-sensitive-path edit_transaction keeps nested edit content unredacted', () => { + const input = { + path: 'src/a.ts', + edits: [ + { + type: 'structured', + path: 'src/a.ts', + operation: { + kind: 'insert_text', + position: { line: 3, column: 1 }, + text: 'export const answer = 42', + }, + }, + ], + } + const updates = printModeToSessionUpdates( + { + type: 'tool_call', + toolCallId: 't-src-structured', + toolName: 'edit_transaction', + input, + }, + makeCtx(), + ) + expect((updates[0] as { rawInput: unknown }).rawInput).toEqual(input) + }) + + test('tool_start maps to tool_call_update in_progress', () => { + expect( + printModeToSessionUpdates( + { type: 'tool_start', toolCallId: 't1' }, + makeCtx(), + ), + ).toEqual([ + { + sessionUpdate: 'tool_call_update', + toolCallId: 't1', + status: 'in_progress', + }, + ]) + }) + + test('tool_result maps to completed with json content and never rawOutput', () => { + const updates = printModeToSessionUpdates( + { + type: 'tool_result', + toolCallId: 't1', + toolName: 'read_files', + output: [{ type: 'json', value: { files: ['a.ts'] } }], + }, + makeCtx(), + ) + expect(updates).toEqual([ + { + sessionUpdate: 'tool_call_update', + toolCallId: 't1', + status: 'completed', + content: [{ type: 'text', text: '{"files":["a.ts"]}' }], + }, + ]) + }) + + test('media-only tool_result output emits no content and never rawOutput', () => { + const updates = printModeToSessionUpdates( + { + type: 'tool_result', + toolCallId: 't3', + toolName: 'read_image', + output: [{ type: 'media', data: 'AAAA', mediaType: 'image/png' }], + }, + makeCtx(), + ) + expect(updates).toEqual([ + { + sessionUpdate: 'tool_call_update', + toolCallId: 't3', + status: 'completed', + }, + ]) + }) + + test('tool_result with an errorMessage in its output maps to failed', () => { + const updates = printModeToSessionUpdates( + { + type: 'tool_result', + toolCallId: 't1', + toolName: 'run_terminal_command', + output: [{ type: 'json', value: { errorMessage: 'boom' } }], + }, + makeCtx(), + ) + expect(updates).toEqual([ + { + sessionUpdate: 'tool_call_update', + toolCallId: 't1', + status: 'failed', + content: [{ type: 'text', text: '{"errorMessage":"boom"}' }], + }, + ]) + }) + + test('a mutation outcome outside applied maps tool_result to failed', () => { + const updates = printModeToSessionUpdates( + { + type: 'tool_result', + toolCallId: 't1', + toolName: 'edit_transaction', + output: [ + { + type: 'json', + value: { + kind: 'file_mutation_result', + outcome: 'partial', + actions: [], + }, + }, + ], + }, + makeCtx(), + ) + expect(updates).toHaveLength(1) + expect(updates[0]).toMatchObject({ + sessionUpdate: 'tool_call_update', + toolCallId: 't1', + status: 'failed', + }) + }) + + test('an applied mutation with afterContent adds diff content blocks', () => { + const mutationValue = { + kind: 'file_mutation_result', + outcome: 'applied', + actions: [ + { outcome: 'applied', path: 'src/a.ts', afterContent: 'new text' }, + ], + } + const updates = printModeToSessionUpdates( + { + type: 'tool_result', + toolCallId: 't1', + toolName: 'write_file', + output: [{ type: 'json', value: mutationValue }], + }, + makeCtx(), + ) + expect(updates).toEqual([ + { + sessionUpdate: 'tool_call_update', + toolCallId: 't1', + status: 'completed', + content: [ + { type: 'text', text: JSON.stringify(mutationValue) }, + { type: 'diff', path: 'src/a.ts', newText: 'new text' }, + ], + }, + ]) + }) + + test('oversized tool_result content is capped with a truncation marker', () => { + const updates = printModeToSessionUpdates( + { + type: 'tool_result', + toolCallId: 't1', + toolName: 'read_files', + output: [ + { type: 'json', value: { blob: 'x'.repeat(80 * 1024) } }, + ], + }, + makeCtx(), + ) + expect(updates).toHaveLength(1) + const payload = updates[0] as { + content?: Array<{ type: string; text: string }> + } + const text = payload.content?.[0]?.text ?? '' + expect(text.endsWith('\n[truncated]')).toBe(true) + expect(new TextEncoder().encode(text).byteLength).toBeLessThanOrEqual( + 64 * 1024 + Buffer.byteLength('\n[truncated]') + 2, + ) + }) + + test('write_todos result maps to plan entries with the first incomplete in_progress', () => { + const updates = printModeToSessionUpdates( + { + type: 'tool_result', + toolCallId: 't5', + toolName: 'write_todos', + output: [ + { + type: 'json', + value: { + currentTodos: [ + { task: 'done task', completed: true }, + { task: 'active task', completed: false }, + { task: 'later task', completed: false }, + ], + }, + }, + ], + }, + makeCtx(), + ) + expect(updates).toEqual([ + { + sessionUpdate: 'plan', + entries: [ + { content: 'done task', priority: 'medium', status: 'completed' }, + { content: 'active task', priority: 'medium', status: 'in_progress' }, + { content: 'later task', priority: 'medium', status: 'pending' }, + ], + }, + ]) + }) + + test('a fully completed todo list yields no in_progress entry', () => { + const updates = printModeToSessionUpdates( + { + type: 'tool_result', + toolCallId: 't5', + toolName: 'write_todos', + output: [ + { + type: 'json', + value: { currentTodos: [{ task: 'only', completed: true }] }, + }, + ], + }, + makeCtx(), + ) + expect(updates).toEqual([ + { + sessionUpdate: 'plan', + entries: [{ content: 'only', priority: 'medium', status: 'completed' }], + }, + ]) + }) + + test('subagent_start/finish map onto the parent spawn toolCallId with _meta.agent', () => { + const start: PrintModeEvent = { + type: 'subagent_start', + agentId: 'agent-2', + agentType: 'reviewer', + displayName: 'Reviewer', + onlyChild: false, + spawnToolCallId: 'spawn-1', + } + const agentMeta = { + 'openbuff.dev': { + agent: { + agentId: 'agent-2', + agentType: 'reviewer', + displayName: 'Reviewer', + }, + }, + } + expect(printModeToSessionUpdates(start, makeCtx())).toEqual([ + { + sessionUpdate: 'tool_call', + toolCallId: 'spawn-1', + title: 'Reviewer', + kind: 'other', + status: 'pending', + _meta: agentMeta, + }, + ]) + + const finish: PrintModeEvent = { + type: 'subagent_finish', + agentId: 'agent-2', + agentType: 'reviewer', + displayName: 'Reviewer', + onlyChild: false, + spawnToolCallId: 'spawn-1', + } + expect(printModeToSessionUpdates(finish, makeCtx())).toEqual([ + { + sessionUpdate: 'tool_call_update', + toolCallId: 'spawn-1', + status: 'completed', + _meta: agentMeta, + }, + ]) + + const failedFinish: PrintModeEvent = { ...finish, error: 'blew up' } + expect(printModeToSessionUpdates(failedFinish, makeCtx())).toEqual([ + { + sessionUpdate: 'tool_call_update', + toolCallId: 'spawn-1', + status: 'failed', + _meta: agentMeta, + }, + ]) + }) + + test('subagent events without a spawn toolCallId emit nothing', () => { + const event: PrintModeEvent = { + type: 'subagent_start', + agentId: 'agent-2', + agentType: 'reviewer', + displayName: 'Reviewer', + onlyChild: true, + } + expect( + printModeToSessionUpdates(event, makeCtx({ eventsMode: 'full' })), + ).toEqual([]) + }) + + test('context_window maps used/size and finish maps cost only', () => { + expect( + printModeToSessionUpdates( + { type: 'context_window', used: 100, max: 200 }, + makeCtx(), + ), + ).toEqual([{ sessionUpdate: 'usage_update', used: 100, size: 200 }]) + expect( + printModeToSessionUpdates( + { type: 'finish', totalCost: 0.25 }, + makeCtx(), + ), + ).toEqual([ + { + sessionUpdate: 'usage_update', + cost: { amount: 0.25, currency: 'USD' }, + }, + ]) + }) + + test('autoRecovering errors are not emitted; terminal errors map via printModeErrorToRpcError', () => { + const autoRecovering: PrintModeEvent = { + type: 'error', + message: 'detailed retry context', + autoRecovering: true, + } + expect(printModeToSessionUpdates(autoRecovering, makeCtx())).toEqual([]) + + const terminal: PrintModeEvent = { + type: 'error', + message: 'detailed failure', + userMessage: 'calm summary', + } + expect(printModeToSessionUpdates(terminal, makeCtx())).toEqual([]) + expect(printModeErrorToRpcError(terminal)).toEqual({ + code: -32603, + data: { 'openbuff.dev': { message: 'calm summary' } }, + }) + expect( + printModeErrorToRpcError({ type: 'error', message: 'detailed failure' }), + ).toEqual({ + code: -32603, + data: { 'openbuff.dev': { message: 'detailed failure' } }, + }) + }) + + test('telemetry variants emit the _openbuff.dev/event ext notification only in full mode', () => { + const events: PrintModeEvent[] = [ + { + type: 'job_update', + jobId: 'j1', + kind: 'process', + state: 'running', + sequence: 0, + }, + { type: 'provider_status', status: 'retrying' }, + { type: 'phase', phase: 'planning' }, + { + type: 'context_request_trim', + messageBudgetTokens: 1000, + beforeTokens: 2000, + afterTokens: 1500, + beforeMessages: 20, + afterMessages: 15, + }, + { + type: 'context_compaction_status', + state: 'started', + runId: 'r1', + ancestorRunIds: [], + }, + { + type: 'memory_reuse', + receipt: { + schemaVersion: 1, + turnId: 'turn-1', + skip: 1, + narrow: 0, + full: 0, + recordsServed: 2, + gapsRemaining: 0, + recordedDecisions: 1, + conceptExpanded: 0, + }, + }, + { type: 'download', version: '1.2.3', status: 'complete' }, + ] + for (const event of events) { + expect(printModeToSessionUpdates(event, makeCtx())).toEqual([]) + expect( + printModeToSessionUpdates(event, makeCtx({ eventsMode: 'full' })), + ).toEqual([ + { method: '_openbuff.dev/event', params: { sessionId: 's1', event } }, + ]) + } + }) + + test('GV-16: an internal tool_result emits nothing even with events=full', () => { + const event: PrintModeEvent = { + type: 'tool_result', + toolCallId: 't9', + toolName: 'read_files', + output: [], + parentAgentId: 'agent-1', + } + expect( + printModeToSessionUpdates(event, makeCtx({ eventsMode: 'full' })), + ).toEqual([]) + }) + + test("the unmapped 'start' variant emits nothing", () => { + expect( + printModeToSessionUpdates( + { type: 'start', messageHistoryLength: 3 }, + makeCtx(), + ), + ).toEqual([]) + }) + + test('an unknown variant throws UnhandledPrintModeVariantError naming it', () => { + const event = { type: 'mystery' } as unknown as PrintModeEvent + expect(() => printModeToSessionUpdates(event, makeCtx())).toThrow( + UnhandledPrintModeVariantError, + ) + expect(() => printModeToSessionUpdates(event, makeCtx())).toThrow( + /'mystery'/, + ) + }) + + test('EV-1: a sensitive-path mutation redacts content fields from the raw-JSON text block', () => { + const mutationValue = { + kind: 'file_mutation_result', + outcome: 'applied', + actions: [ + { + outcome: 'applied', + path: '.env', + afterContent: 'OPENROUTER_API_KEY=sk-live-echo-secret\n', + patch: '--- a/.env\n+++ b/.env\n@@ -1 +1 @@\n', + content: 'raw post-edit content', + beforeContent: 'raw pre-edit content', + }, + ], + } + const updates = printModeToSessionUpdates( + { + type: 'tool_result', + toolCallId: 't-ev1', + toolName: 'write_file', + output: [{ type: 'json', value: mutationValue }], + }, + makeCtx(), + ) + const payload = updates[0] as { + content?: Array<{ type: string; text?: string }> + } + const textBlock = payload.content?.find((block) => block.type === 'text') + expect(typeof textBlock?.text).toBe('string') + // Each text block line stays JSON.parse-able for downstream consumers. + const parsed = JSON.parse(textBlock!.text!) as { + actions: Array> + } + expect(parsed.actions[0]!.path).toBe('.env') + expect(parsed.actions[0]!.afterContent).toBe('[sensitive]') + expect(parsed.actions[0]!.patch).toBe('[sensitive]') + expect(parsed.actions[0]!.content).toBe('[sensitive]') + expect(parsed.actions[0]!.beforeContent).toBe('[sensitive]') + expect(textBlock!.text!).not.toContain('sk-live-echo-secret') + // The separate diff block is still the sensitive placeholder. + expect(payload.content).toContainEqual({ + type: 'text', + text: '[sensitive file changed: .env]', + }) + }) + + test('EV-1: non-sensitive actions in a mixed mutation keep their afterContent verbatim', () => { + const mutationValue = { + kind: 'file_mutation_result', + outcome: 'applied', + actions: [ + { + outcome: 'applied', + path: '.env', + afterContent: 'SECRET_ENV_CONTENT', + }, + { + outcome: 'applied', + path: 'src/a.ts', + afterContent: 'export const answer = 42', + }, + ], + } + const updates = printModeToSessionUpdates( + { + type: 'tool_result', + toolCallId: 't-ev1b', + toolName: 'edit_transaction', + output: [{ type: 'json', value: mutationValue }], + }, + makeCtx(), + ) + const payload = updates[0] as { + content?: Array<{ + type: string + text?: string + path?: string + newText?: string + }> + } + const textBlock = payload.content?.find((block) => block.type === 'text') + const parsed = JSON.parse(textBlock!.text!) as { + actions: Array> + } + expect(parsed.actions[0]!.afterContent).toBe('[sensitive]') + expect(parsed.actions[1]!.afterContent).toBe('export const answer = 42') + // The non-sensitive action still emits its diff block. + expect(payload.content).toContainEqual({ + type: 'diff', + path: 'src/a.ts', + newText: 'export const answer = 42', + }) + }) + + test('EV-2: credential redaction past the walk depth cap is fail-closed', () => { + const secret = 'sk-capped-credential-value-31337' + let deep: unknown = { leaf: `token=${secret}` } + for (let i = 0; i < 64; i += 1) deep = { nested: deep } + const updates = printModeToSessionUpdates( + { + type: 'tool_call', + toolCallId: 't-ev2', + toolName: 'code_search', + input: { query: 'x', payload: deep }, + }, + makeCtx({ credentialValues: [secret] }), + ) + const rawInput = (updates[0] as { rawInput: Record }) + .rawInput + const serialized = JSON.stringify(rawInput) + // The capped tail must NOT pass through unredacted. + expect(serialized).not.toContain(secret) + expect(serialized).toContain('[REDACTED_SECRET]') + }) + + test('EV-2: subtrees shallower than the walk depth cap keep their structure', () => { + const secret = 'sk-shallow-credential-value-8' + let deep: unknown = { leaf: `token=${secret}` } + for (let i = 0; i < 20; i += 1) deep = { nested: deep } + const updates = printModeToSessionUpdates( + { + type: 'tool_call', + toolCallId: 't-ev2b', + toolName: 'code_search', + input: { query: 'x', payload: deep }, + }, + makeCtx({ credentialValues: [secret] }), + ) + const rawInput = (updates[0] as { rawInput: Record }) + .rawInput + let node = rawInput.payload as Record + for (let i = 0; i < 20; i += 1) { + expect(Array.isArray(node)).toBe(false) + node = node.nested as Record + } + expect(node.leaf).toBe('token=[REDACTED_SECRET]') + }) + + test('EV-3: pathologically nested sensitive-path rawInput is depth-bounded, not stack-bound', () => { + const secret = 'sk-deep-sensitive-echo-999' + let deepElement: Record = { + tail: `OPENROUTER_API_KEY=${secret}`, + } + for (let i = 0; i < 50_000; i += 1) deepElement = { nested: deepElement } + const updates = printModeToSessionUpdates( + { + type: 'tool_call', + toolCallId: 't-ev3', + toolName: 'edit_transaction', + input: { + path: '.env', + edits: [deepElement], + }, + }, + makeCtx(), + ) + const rawInput = (updates[0] as { rawInput: Record }) + .rawInput + // Walk the nested chain iteratively: the capped tail collapses to the + // sensitive marker within the depth bound instead of recursing to the + // 50_000-deep leaf. + let node: unknown = (rawInput.edits as unknown[])[0] + let found = false + for (let i = 0; i <= 50 && !found; i += 1) { + if (typeof node === 'string') { + expect(node).toBe('[sensitive]') + found = true + break + } + node = (node as Record).nested + } + expect(found).toBe(true) + expect(JSON.stringify(rawInput)).not.toContain('sk-deep-sensitive-echo') + }) +}) diff --git a/sdk/src/serve/__tests__/outbound.test.ts b/sdk/src/serve/__tests__/outbound.test.ts new file mode 100644 index 0000000000..55adec6866 --- /dev/null +++ b/sdk/src/serve/__tests__/outbound.test.ts @@ -0,0 +1,263 @@ +import { describe, expect, test } from 'bun:test' + +import { createProviderPresetConfig } from '../../provider-config' + +import { + MAX_HELD_WINDOW_CHARS, + OutboundHoldback, + collectCredentialValues, + getConfiguredCredentialEnvKeys, + holdbackSizeFor, + sanitizeOutboundStream, +} from '../outbound' + +/** + * Regression coverage for the §12.8 NEW-3/NEW-4 streaming guards: + * - GV-27 (RF-11): no configured credential or cap.v3 token is ever SPLIT + * across emitted frames — neither on the progressive path, the + * MAX_HELD_WINDOW_CHARS overflow path, nor across a flush boundary — and + * configured credentials are redacted by VALUE even without a + * provider-secret shape. + * - RF-12: flush routing is session-scoped — flushSession/flushIdleSession + * never release another session's held text. + * - GV-28: the ndJsonStream chokepoint sanitizes every serialized frame. + */ + +const CREDENTIAL = 'QQUNIQQMARKERVALUEQQ' +/** Interior fragments that would betray a split credential. */ +const CREDENTIAL_FRAGMENTS = ['QQUNIQQ', 'MARKERVALUEQQ'] + +describe('OutboundHoldback no-split invariant (GV-27 / RF-11)', () => { + test('a credential straddling chunk boundaries is never emitted raw', () => { + const hb = new OutboundHoldback() + const pieces: string[] = [] + pieces.push(...hb.push('s', 'm', 'before ', [CREDENTIAL], 0)) + pieces.push(...hb.push('s', 'm', CREDENTIAL, [CREDENTIAL], 1)) + pieces.push(...hb.push('s', 'm', ' after', [CREDENTIAL], 2)) + pieces.push(...hb.flush('s', 'm', 3)) + const all = pieces.join('') + expect(all).not.toContain(CREDENTIAL) + for (const fragment of CREDENTIAL_FRAGMENTS) { + expect(all).not.toContain(fragment) + } + expect(all).toContain('before ') + expect(all).toContain(' after') + }) + + test('a configured credential is redacted by VALUE even without a provider-secret shape', () => { + const hb = new OutboundHoldback() + const pieces = hb.push( + 's', + 'm', + 'x '.repeat(200) + CREDENTIAL, + [CREDENTIAL], + 0, + ) + pieces.push(...hb.flush('s', 'm', 1)) + const all = pieces.join('') + expect(all).toContain('[REDACTED_SECRET]') + expect(all).not.toContain(CREDENTIAL) + }) + + test('the MAX_HELD_WINDOW_CHARS overflow path never splits a credential', () => { + const hb = new OutboundHoldback() + // Position the credential so the naive overflow cut (held.length - + // holdback) would land INSIDE it: without the safe-cut walk the first + // frame would carry a prefixless fragment the redaction regexes cannot + // match (the exact overflow split the reviewer finding describes). + const filler = 'h'.repeat(65335) + const tail = 't'.repeat(243) + const pieces = hb.push('s', 'm', filler + CREDENTIAL + tail, [CREDENTIAL], 0) + expect(pieces.length).toBeGreaterThan(0) + const all = pieces.join('') + expect(all).not.toContain(CREDENTIAL) + for (const fragment of CREDENTIAL_FRAGMENTS) { + expect(all).not.toContain(fragment) + } + // The remainder flushes whole and clean. + const rest = hb.flush('s', 'm', 1).join('') + expect(rest).not.toContain(CREDENTIAL) + expect(all + rest).toContain(tail) + }) + + test('the no-safe-cut overflow path discards the blocked run so held stays bounded', () => { + const hb = new OutboundHoldback() + // A cap.v3 token run that never closes spans the whole window: every cut + // is blocked, so the overflow branch cannot emit. Successive pushes must + // NOT grow the held window without bound (the old behavior kept every + // byte and rescanned the whole window on every push) — the blocked run is + // discarded instead, so nothing of it ever crosses the wire. + expect( + hb.push('s', 'm', 'cap.v3.' + 'B'.repeat(MAX_HELD_WINDOW_CHARS), [], 0), + ).toEqual([]) + // The window reset: a following ordinary chunk streams again instead of + // piling onto an unbounded held backlog. + const resumed = hb.push('s', 'm', 'x'.repeat(1000), [], 1) + expect(resumed.length).toBeGreaterThan(0) + // The remainder is the ordinary holdback tail, not the runaway run. + const rest = hb.flush('s', 'm', 2).join('') + expect(rest.length).toBeLessThanOrEqual(1000) + // The unterminated anchor never crossed any frame. + expect(resumed.join('') + rest).not.toContain('cap.v3.') + }) + + test('a cap.v3 token straddling the cut is held whole and redacted in one frame', () => { + const hb = new OutboundHoldback() + const token = 'cap.v3.1.2.' + 'B'.repeat(50) + const pieces: string[] = [] + pieces.push( + ...hb.push('s', 'm', 'f'.repeat(100) + token.slice(0, 30), [], 0), + ) + pieces.push(...hb.push('s', 'm', token.slice(30) + 'g'.repeat(200), [], 1)) + pieces.push(...hb.flush('s', 'm', 2)) + const all = pieces.join('') + expect(all).toContain('[REDACTED_CAPABILITY]') + expect(all).not.toMatch(/cap\.v3\.[A-Za-z0-9._-]{4,}/) + }) +}) + +describe('OutboundHoldback session-scoped flush routing (RF-12)', () => { + test('flushSession releases only the owning session and leaves others held', () => { + const hb = new OutboundHoldback() + hb.push('sA', 'm1', 'alpha-text', [], 0) + hb.push('sB', 'm1', 'beta-text', [], 0) + expect(hb.flushSession('sA', 1).join('')).toBe('alpha-text') + // sB is untouched: its window is still held and flushes on its own turn. + expect(hb.flush('sB', 'm1', 2).join('')).toBe('beta-text') + }) + + test('flushIdleSession releases only the owning session idle windows', () => { + const hb = new OutboundHoldback() + hb.push('sA', 'm1', 'idle-a', [], 0) + hb.push('sB', 'm1', 'fresh-b', [], 500) + expect(hb.flushIdleSession('sA', 1000, 250).join('')).toBe('idle-a') + expect(hb.flush('sB', 'm1', 1001).join('')).toBe('fresh-b') + }) +}) + +describe('sanitizeOutboundStream chokepoint (GV-28)', () => { + test('redacts a cap.v3 token inside a serialized frame', async () => { + const chunks: Uint8Array[] = [] + const target = new WritableStream({ + write(chunk) { + chunks.push(chunk) + }, + }) + const wrapped = sanitizeOutboundStream(target) + const writer = wrapped.getWriter() + await writer.write( + new TextEncoder().encode('{"x":"cap.v3.1.2.AAAA.BBBB"}\n'), + ) + await writer.close() + const out = new TextDecoder().decode(concatChunks(chunks)) + expect(out).toContain('[REDACTED_CAPABILITY]') + expect(out).not.toContain('cap.v3.') + }) + + test('forwards frames without redactable text byte-identically', async () => { + const chunks: Uint8Array[] = [] + const target = new WritableStream({ + write(chunk) { + chunks.push(chunk) + }, + }) + const wrapped = sanitizeOutboundStream(target) + const writer = wrapped.getWriter() + const frame = new TextEncoder().encode('{"ok":true}\n') + await writer.write(frame) + await writer.close() + expect(chunks).toEqual([frame]) + }) +}) + +function concatChunks(chunks: Uint8Array[]): Uint8Array { + const total = chunks.reduce((sum, chunk) => sum + chunk.length, 0) + const out = new Uint8Array(total) + let offset = 0 + for (const chunk of chunks) { + out.set(chunk, offset) + offset += chunk.length + } + return out +} + +describe('collectCredentialValues (NEW-3 host seam)', () => { + test('emits KEY=value pairs and bare values for the well-known credential env keys', () => { + // An explicit configured-key list keeps this test hermetic (no ambient + // provider-config read); the configured-surface path is covered below. + expect( + collectCredentialValues( + { + OPENROUTER_API_KEY: 'sk-or-1', + ANTHROPIC_API_KEY: 'sk-ant-1', + OPENAI_API_KEY: 'sk-oai-1', + }, + [], + ), + ).toEqual([ + 'OPENROUTER_API_KEY=sk-or-1', + 'sk-or-1', + 'ANTHROPIC_API_KEY=sk-ant-1', + 'sk-ant-1', + 'OPENAI_API_KEY=sk-oai-1', + 'sk-oai-1', + ]) + }) + + test('skips absent and empty values, and ignores non-credential keys', () => { + expect(collectCredentialValues({}, [])).toEqual([]) + expect( + collectCredentialValues( + { + OPENROUTER_API_KEY: undefined, + ANTHROPIC_API_KEY: '', + OPENAI_API_KEY: 'sk-only', + SOME_OTHER_SECRET: 'not-a-configured-credential', + }, + [], + ), + ).toEqual(['OPENAI_API_KEY=sk-only', 'sk-only']) + }) + + test('collects values for configured provider-config apiKeyEnv keys (NEW-3)', () => { + // The provider configuration surface (providers.json apiKeyEnv, built-in + // presets) declares credential env keys beyond the well-known three; the + // no-split invariant must cover those too. + expect( + collectCredentialValues({ MY_PROVIDER_KEY: 'sk-custom-value-1' }, [ + 'MY_PROVIDER_KEY', + ]), + ).toEqual(['MY_PROVIDER_KEY=sk-custom-value-1', 'sk-custom-value-1']) + // A configured key already covered by the well-known list is not doubled. + expect( + collectCredentialValues({ OPENAI_API_KEY: 'sk-1' }, ['OPENAI_API_KEY']), + ).toEqual(['OPENAI_API_KEY=sk-1', 'sk-1']) + }) + + test('getConfiguredCredentialEnvKeys reads the built-in preset provider surface', () => { + const presetConfig = createProviderPresetConfig('opencode-go') + expect( + getConfiguredCredentialEnvKeys({}, { + config: presetConfig, + sourceFilePaths: [], + }), + ).toContain('OPENCODE_GO_API_KEY') + + // A preset whose providers declare no apiKeyEnv yields no configured keys. + const ollamaConfig = createProviderPresetConfig('ollama') + expect( + getConfiguredCredentialEnvKeys({}, { + config: ollamaConfig, + sourceFilePaths: [], + }), + ).toEqual([]) + }) +}) + +describe('holdbackSizeFor (NEW-3 window sizing)', () => { + test('uses the 256-char floor with no credentials and widens to the longest value', () => { + expect(holdbackSizeFor([])).toBe(256) + expect(holdbackSizeFor(['sk-a'])).toBe(256) + expect(holdbackSizeFor(['x'.repeat(300)])).toBe(300) + }) +}) diff --git a/sdk/src/serve/bridge.ts b/sdk/src/serve/bridge.ts new file mode 100644 index 0000000000..21a3310e51 --- /dev/null +++ b/sdk/src/serve/bridge.ts @@ -0,0 +1,1155 @@ +import { RequestError } from '@agentclientprotocol/sdk' +import { markAllMCPConfigOrigins } from '@codebuff/common/mcp/client' +import { toolKind } from '@codebuff/common/protocol/acp-ext-v1' + +import { + OPENBUFF_CAPABILITIES_CHANGED_NOTIFICATION, + OPENBUFF_GATE_STATE_NOTIFICATION, + defaultCapabilityMapV1, +} from '../services/acp/ext-methods' +import { OutboundHoldback } from './outbound' + +import type { + AcpPromptHandler, + AcpReverseRequests, +} from '../services/acp/acp-agent' +import type { AcpSessionData } from '../services/acp/session-data' +import type { HarnessApprovalRequest } from '../services/harness-enforcement' +import type { + FilesystemMutationEvent, + OpenbuffClientOptions, + RunOptions, +} from '../run' +import type { RunState } from '../run-state' +import type { + CreateElicitationRequest, + ElicitationPropertySchema, + McpServer, + RequestPermissionRequest, +} from '@agentclientprotocol/sdk' +import type { MCPConfig } from '@codebuff/common/types/mcp' +import type { ToolResultOutput } from '@codebuff/common/types/messages/content-part' +import type { Logger } from '@codebuff/common/types/contracts/logger' +import type { PrintModeEvent } from '@codebuff/common/types/print-mode' +import { + printModeErrorToRpcError, + printModeToSessionUpdates, +} from './event-bridge' +import type { PrintModeRpcError } from './event-bridge' + +/** + * The structural slice of `OpenbuffClient` the bridge needs: just `.run`. The + * production `openbuff serve` CLI (a LATER wave) passes a real + * `OpenbuffClient`; unit tests pass a fake whose `.run` invokes the supplied + * `handleEvent`/`onFilesystemMutation` and resolves a `RunState`. Typed as the + * exact production run signature so a real client is assignable without a cast. + */ +export type ServeBridgeClient = { + run: (runOptions: RunOptions & OpenbuffClientOptions) => Promise +} + +export type ServeBridgeOptions = { + /** Structurally-typed run seam (see {@link ServeBridgeClient}). */ + client: ServeBridgeClient + /** + * The LIVE per-session store the ACP extension methods + * (`openbuff/getReceipts`, `openbuff/gateState`) read. The bridge records + * confirmed receipts and published gate-state here as the turn streams. + */ + sessionData: AcpSessionData + /** + * Which Openbuff agent each prompt turn runs. Defaults to `'base'`. + */ + agentId?: string + /** + * Lets the host mark which MCP servers the client advertised as untrusted + * ('client' origin) at ingest, so any such server the host later attaches to + * a run is already treated as untrusted by the run loop's per-tool approval + * gate and SSRF guard. Everything reaches the bridge via options — this layer + * never reads `process.env`. The bridge marks and forwards the servers here; + * the core RunOptions has no mcpServers param, so attaching them is the + * host's job. + */ + markClientMcpServers?: (mcpServers: Record) => void + /** + * ACP event richness (P1-T2b, §4.2). `'acp'` (the default) keeps subagent + * text, nested-run reasoning, and telemetry variants off the wire; `'full'` + * enables subagent text and the `_openbuff.dev/event` ext notifications. + */ + eventsMode?: 'acp' | 'full' + /** + * Seam for every non-text payload the §4.2 event bridge emits (tool_call, + * tool_call_update, plan, usage_update, agent_thought_chunk) plus + * `{ method, params }` `_openbuff.dev/event` ext notifications. When + * absent, non-text payloads are dropped and the bridge keeps the Wave-1 + * text-only behavior. Emitting to the real ACP connection is a later wave; + * this callback is the transport-free seam. + */ + onSessionUpdate?: (payload: unknown) => void | Promise + /** + * Absolute project root `tool_call` locations are resolved against. When + * omitted, location paths stay relative instead of being silently resolved + * against the process cwd. + */ + projectRoot?: string + /** + * NEW-3 (§12.8): the configured credential VALUES the streaming holdback + * must never split across chunk frames. Injected by the host (the CLI + * collects them from its configured env keys) — this layer NEVER reads + * `process.env`. Empty by default; the holdback still retains its 256-char + * floor so cap.v3 tokens cannot straddle frames. + */ + credentialValues?: string[] + /** Optional structured logger forwarded by the host; unused in Wave-1 core. */ + logger?: Logger + /** + * Client-side reverse-request seam (P1-T2 §5/§4.2), bound by the host to + * the owning ACP connection. Required to bridge the core `requestApproval` + * callback to `session/request_permission`; without it approvals fail + * closed to `false` (a high-impact action is denied rather than silently + * approved). Also used for the `elicitation.form` ask_user path. + */ + reverseRequests?: AcpReverseRequests + /** + * Approval timeout (§12.4 `serve.approvalTimeoutMs`), defaulting to 30 min. + * On expiry the approval resolves `false` and `$/cancel_request` is sent + * for the outstanding request. The ask_user elicitation waits honor the + * same timeout so a prompt never blocks forever. + */ + approvalTimeoutMs?: number + /** + * The negotiated client capabilities snapshot (§4.2). When + * `elicitation.form` is set, the core `ask_user` tool is mapped to an + * `elicitation/create` reverse-request; otherwise the `events` extension + * (when enabled) carries `_openbuff.dev/ask_user`; otherwise the tool + * returns a skipped result. + */ + clientCapabilities?: { elicitation?: { form?: unknown } } + /** + * Whether the `events` Openbuff extension was negotiated (§3.2). Gates the + * `_openbuff.dev/ask_user` fallback: extension requests are sent only for + * enabled extensions, so a false/absent value falls through to the skipped + * result. + */ + eventsExtensionEnabled?: boolean + /** + * Reverse-request seam for one client-bound JSON-RPC request/response + * pair, used to carry `_openbuff.dev/ask_user` (a method the typed + * {@link AcpReverseRequests} surface does not declare). Distinct from + * `onSessionUpdate`, which is notification-shaped (fire-and-forget) and + * cannot yield an answer. Returning a rejected promise maps to `skipped`. + */ + onReverseRequest?: (method: string, params: unknown) => Promise + /** + * Reverse-request cancellation seam (§12.4): sends `$/cancel_request` for + * an outstanding request id. Fired on approval timeout so the client can + * tear down the pending permission UI. + */ + onCancelRequest?: (requestId: string) => void | Promise +} + +/** §12.8 NEW-3: the idle flush window (ms), driven by one unref'd timer. */ +const IDLE_FLUSH_MS = 250 +/** §6.1 rate limit: at most one capabilities_changed push per 250 ms. */ +const CAPABILITIES_PUSH_MIN_INTERVAL_MS = 250 +/** §12.4 `serve.approvalTimeoutMs` default: 30 minutes. */ +const DEFAULT_APPROVAL_TIMEOUT_MS = 30 * 60 * 1000 + +/** + * The honest P1 serve capability map (§6.1) is built per session by + * `defaultCapabilityMapV1` (in ../services/acp/ext-methods): it is derived + * from the serving process's REAL posture — in particular `journal.resume`/ + * `journal.replay` come from whether the backing store actually mirrors to a + * durable journal, so a host using a purely in-memory `AcpSessionData` never + * advertises resume/replay that `session/load` cannot honor. + */ + +/** + * The seam the `openbuff serve` CLI (LATER wave) instantiates: it binds the + * ACP `AcpPromptHandler` to the real `OpenbuffClient.run()` loop and feeds the + * live `AcpSessionData` store the read-only ACP extension methods serve. + * + * Security posture (P1-T1-DESIGN §12.1 SEC-1): + * - By default (no `onSessionUpdate`), the bridge NEVER forwards + * `tool_call`/`tool_result` events or raw tool output to the client — those + * are structurally DROPPED. Only model-visible assistant text + * (`PrintModeEvent` of type `'text'`) is forwarded, and when unsure whether + * an event carries such text the bridge DROPS it. + * - When the host supplies `onSessionUpdate` (P1-T2b), the §4.2-mapped tool + * cards, plan entries, usage updates, and `_openbuff.dev/event` ext + * notifications flow through that seam; `rawOutput` is still never emitted + * and tool results cross only as capped `content` text. + * - Every forwarded chunk passes through {@link sanitizeOutbound} before it + * crosses the ACP wire, so a cap.v3 token or provider secret that slipped + * into assistant text is redacted as a last line of defense. + * - Confirmed filesystem mutations and published `` blocks are + * recorded into the injected LIVE store, which is exactly what the ACP + * extension methods project back to the client. + * + * The bridge is transport-agnostic and free of process spawning: it only wires + * callbacks. The CLI wave supplies the real client and the stdio transport. + */ +export function createServeBridge(options: ServeBridgeOptions): { + promptHandler: AcpPromptHandler + sessionData: AcpSessionData +} { + const { client, sessionData } = options + const agentId = options.agentId ?? 'base' + const eventsMode = options.eventsMode ?? 'acp' + const projectRoot = options.projectRoot ?? '' + const credentialValues = options.credentialValues ?? [] + // NEW-3 (§12.8): per-(sessionId, messageId) streaming holdbacks. The two + // single-sink instances keep flush routing trivial: message pieces route to + // `input.update`, thought pieces to `onSessionUpdate`. + const messageHoldback = new OutboundHoldback() + const thoughtHoldback = new OutboundHoldback() + /** Last-notified ext-v1 gate-state projection JSON per session. */ + const lastGateStateJson = new Map() + /** Last-notified capability map JSON per session. */ + const lastCapabilitiesJson = new Map() + /** Rate-limit timestamp for the capabilities_changed push, per session. */ + const lastCapabilitiesPushAt = new Map() + + /** + * Warn-level containment for best-effort flushes: a rejected update seam + * (e.g. a client hang-up rejecting the wire) must never surface as an + * unhandled rejection and crash the serve process. Contained and logged; + * never re-thrown. + */ + const warnFlushFailure = (source: string, error: unknown): void => { + options.logger?.warn( + { + source, + error: error instanceof Error ? error.message : String(error), + }, + 'best-effort holdback flush failed; continuing', + ) + } + + /** Awaits one emit, containing (warn-level) any rejection it produces. */ + const tryEmit = async ( + source: string, + emit: () => unknown, + ): Promise => { + try { + await emit() + } catch (error) { + warnFlushFailure(source, error) + } + } + + /** + * §6.1: pushes `_openbuff.dev/capabilities_changed` when the session's map + * changed since the last push, rate-limited to one push per 250 ms and + * always sending the full map. A no-op without `onSessionUpdate`. + */ + const maybeNotifyCapabilitiesChanged = async ( + sessionId: string, + ): Promise => { + if (!options.onSessionUpdate) return + const capabilities = sessionData.getCapabilities(sessionId) + if (capabilities === undefined) return + const json = JSON.stringify(capabilities) + if (lastCapabilitiesJson.get(sessionId) === json) return + const now = Date.now() + const last = lastCapabilitiesPushAt.get(sessionId) + if (last !== undefined && now - last < CAPABILITIES_PUSH_MIN_INTERVAL_MS) { + // Rate-limited: keep the previous notified JSON so the next call can + // retry the push after the window. + return + } + lastCapabilitiesPushAt.set(sessionId, now) + lastCapabilitiesJson.set(sessionId, json) + await options.onSessionUpdate({ + method: OPENBUFF_CAPABILITIES_CHANGED_NOTIFICATION, + params: { capabilities }, + }) + } + + /** + * §6.4: pushes `_openbuff.dev/gate_state {state}` whenever the projected + * ext-v1 gate state differs from the last push for the session. A no-op + * without `onSessionUpdate`. + */ + const maybeNotifyGateState = async (sessionId: string): Promise => { + if (!options.onSessionUpdate) return + const state = sessionData.getGateStateV1(sessionId) + const json = JSON.stringify(state) + if (lastGateStateJson.get(sessionId) === json) return + lastGateStateJson.set(sessionId, json) + await options.onSessionUpdate({ + method: OPENBUFF_GATE_STATE_NOTIFICATION, + params: { state }, + }) + } + + const promptHandler: AcpPromptHandler = async (input) => { + /** + * §5/§4.2 production wiring: ONE bridge instance serves every ACP + * connection, so the reverse-request seams are selected PER TURN — the + * transports bind them to the OWNING connection (see + * `reverseRequestsFromConnection` in ../services/acp/acp-agent) and + * supply them on the prompt-handler input; the static options remain the + * fallback for hosts that wire a single connection directly. + */ + const reverseRequests = input.reverseRequests ?? options.reverseRequests + const clientCapabilities = + input.clientCapabilities ?? options.clientCapabilities + const eventsExtensionEnabled = + input.eventsExtensionEnabled ?? options.eventsExtensionEnabled + const onReverseRequest = input.onReverseRequest ?? options.onReverseRequest + const onCancelRequest = input.onCancelRequest ?? options.onCancelRequest + + /** + * NEW-3 (§12.8) flush helpers, scoped to this prompt turn AND this + * session: one bridge instance serves every socket connection, so held + * windows are keyed by (sessionId, messageId) and every flush releases + * ONLY the OWNING session's windows through this turn's sinks — a + * concurrent turn for a different session must never have its held text + * released here (reviewer finding shared-holdback-cross-turn-flush / + * RF-12). + * - flushHoldbacks releases this session's held windows, routing message + * pieces to `input.update` and thought pieces to `onSessionUpdate` + * (flushed thought pieces are emitted as fresh `agent_thought_chunk` + * payloads). + * - ensureIdleFlusher lazily starts ONE unref'd interval per turn that + * releases THIS session's windows idle for IDLE_FLUSH_MS; + * stopIdleFlusher clears it in the turn's finally so no per-chunk + * timer ever exists. + */ + const flushHoldbacks = async (): Promise => { + const now = Date.now() + for (const piece of messageHoldback.flushSession(input.sessionId, now)) { + await input.update(piece) + } + for (const piece of thoughtHoldback.flushSession(input.sessionId, now)) { + await options.onSessionUpdate?.({ + sessionUpdate: 'agent_thought_chunk', + content: { type: 'text', text: piece }, + }) + } + } + let idleTimer: ReturnType | undefined + const ensureIdleFlusher = (): void => { + if (idleTimer !== undefined) return + idleTimer = setInterval(() => { + const now = Date.now() + for (const piece of messageHoldback.flushIdleSession( + input.sessionId, + now, + IDLE_FLUSH_MS, + )) { + // Fire-and-forget WITH containment: a rejected update (client + // hang-up) must never surface as an unhandled rejection and crash + // the serve process from an idle timer tick. + void tryEmit('idle-flush update', () => input.update(piece)) + } + for (const piece of thoughtHoldback.flushIdleSession( + input.sessionId, + now, + IDLE_FLUSH_MS, + )) { + void tryEmit('idle-flush onSessionUpdate', () => + options.onSessionUpdate?.({ + sessionUpdate: 'agent_thought_chunk', + content: { type: 'text', text: piece }, + }), + ) + } + }, IDLE_FLUSH_MS) + // A flush timer must never keep the process alive on its own. + idleTimer.unref?.() + } + const stopIdleFlusher = (): void => { + if (idleTimer === undefined) return + clearInterval(idleTimer) + idleTimer = undefined + } + + /** + * §12.4: pending approvals/elicitations for THIS turn, keyed by the + * reverse-request id. The map drives three fail-closed paths: a + * duplicate/unknown-id response is a no-op (GV-24, the entry is already + * gone), session/cancel or owner disconnect resolves every entry `false` + * (GV-23), and the timeout resolves `false` after sending + * `$/cancel_request`. The owning-connection close cannot be observed from + * inside a turn, so it is modeled exactly as the cancel path it triggers + * (§4.1: a client closing mid-turn MUST answer pending permission + * requests cancelled). + */ + const pendingReverseRequests = new Map< + string, + { + resolve: (value: boolean) => void + settled: boolean + timeout: ReturnType + } + >() + /** + * Settles one pending reverse-request EXACTLY once (GV-24): the first + * resolution wins, the entry is dropped, and its timer is cleared, so a + * duplicate or unknown-id response for the same id finds no entry and is + * ignored. + */ + const settleReverseRequest = (requestId: string, value: boolean): void => { + const entry = pendingReverseRequests.get(requestId) + if (entry === undefined || entry.settled) return + entry.settled = true + pendingReverseRequests.delete(requestId) + clearTimeout(entry.timeout) + entry.resolve(value) + } + /** + * GV-23: resolves every still-pending approval/elicitation `false`. Wired + * to the turn's abort signal (session/cancel and owner-disconnect both + * abort it), and re-checked before any `true` resolution so a response + * that arrives after the abort can never approve. + */ + const cancelAllPendingReverseRequests = (): void => { + for (const requestId of [...pendingReverseRequests.keys()]) { + settleReverseRequest(requestId, false) + } + } + input.signal.addEventListener('abort', cancelAllPendingReverseRequests) + /** + * Terminal-error slot: the FIRST terminal `error` PrintModeEvent is + * recorded (via `handleEvent`) and thrown after the run settles so the + * prompt handler rejects with the -32603 `PrintModeRpcError` shape §4.2 + * prescribes for `session/prompt`. Later terminal errors keep the first + * (the one that actually ended the run). + */ + let terminalError: PrintModeRpcError | undefined + let reverseRequestCounter = 0 + + /** + * Maps every event through the §4.2 event bridge and forwards the + * resulting payloads. `agent_message_chunk` text flows through the + * message holdback (pieces are already sanitized by the holdback's emit + * path) and out via `update`; `agent_thought_chunk` text flows through + * the thought holdback and out via `onSessionUpdate`. Every other payload + * goes to the optional `onSessionUpdate` seam — dropped when the host did + * not supply it, which reproduces the Wave-1 text-only behavior. A + * `tool_call`/`tool_call_update` payload is a §12.8 flush trigger: held + * text is released BEFORE the tool card so the wire order stays + * text → tool. The raw (unsanitized) text feeds gate-state parsing by + * design — redacting the block would break the parser — while the + * holdback's emit path and the NEW-4 chokepoint still redact the + * outbound copy. + */ + const handleEvent = async (event: PrintModeEvent): Promise => { + // Terminal-error surfacing (§4.2): an `error` event WITHOUT + // `autoRecovering: true` is terminal. It is never emitted as a session + // update; instead it is recorded so the turn rejects the + // `session/prompt` call with the -32603 shape + // `printModeErrorToRpcError` produces. The FIRST terminal error wins. + if (event.type === 'error' && event.autoRecovering !== true) { + if (terminalError === undefined) { + terminalError = printModeErrorToRpcError(event) + } + } + const payloads = printModeToSessionUpdates(event, { + sessionId: input.sessionId, + // The run id is not carried on the prompt-handler input yet; the CLI + // wave supplies it, so `messageId` is omitted for now. + runId: undefined, + eventsMode, + projectRoot, + // NEW-3 (§12.8): the configured credential values back the holdback; + // the event bridge applies the same value substitution to tool_call + // rawInput, which the streaming holdback does not cover. + credentialValues, + toolKind, + }) + for (const payload of payloads) { + if (!isPlainRecord(payload)) { + await options.onSessionUpdate?.(payload) + continue + } + const isMessageChunk = payload.sessionUpdate === 'agent_message_chunk' + const isThoughtChunk = payload.sessionUpdate === 'agent_thought_chunk' + if ( + !isMessageChunk && + !isThoughtChunk && + (payload.sessionUpdate === 'tool_call' || + payload.sessionUpdate === 'tool_call_update') + ) { + // Flush trigger (§12.8): release held text before the tool card. + await flushHoldbacks() + } + if (isMessageChunk) { + const text = extractForwardableText(payload) + if (text === undefined) { + await options.onSessionUpdate?.(payload) + continue + } + // Record any published gate-state block from the model-visible text; + // the store's parser is a no-op when the chunk carries no block. + sessionData.updateGateStateFromBlock(input.sessionId, text) + await maybeNotifyGateState(input.sessionId) + ensureIdleFlusher() + for (const piece of messageHoldback.push( + input.sessionId, + messageIdKey(payload), + text, + credentialValues, + Date.now(), + )) { + await input.update(piece) + } + continue + } + if (isThoughtChunk) { + const thought = extractThoughtText(payload) + if (thought === undefined) { + await options.onSessionUpdate?.(payload) + continue + } + ensureIdleFlusher() + for (const piece of thoughtHoldback.push( + input.sessionId, + messageIdKey(payload), + thought, + credentialValues, + Date.now(), + )) { + await options.onSessionUpdate?.({ + ...payload, + content: { type: 'text', text: piece }, + }) + } + continue + } + await options.onSessionUpdate?.(payload) + } + } + + // Ingest trust boundary (P1-T2 SEC): mark every client-advertised MCP + // server 'client' origin BEFORE the run so any such server that reaches the + // run loop is already untrusted for the per-tool approval gate and SSRF + // guard. Marking is idempotent (an existing 'client' mark is never + // upgraded), so re-marking across turns is safe. + // + // The bridge now ATTACHES these client-advertised servers to the run via + // client.run({ mcpServers }) at 'client' origin (previously they were only + // handed to the host via markClientMcpServers). The SAME marked record is + // both attached to the run and passed to the optional markClientMcpServers + // observability hook, so the WeakMap origin marks — keyed by config object + // identity — survive to the run loop's getMCPToolData / + // resolveMCPConfigOrigin. When the client advertised no servers, nothing is + // attached and that path is unchanged. + let clientMcpServers: Record | undefined + if (Array.isArray(input.mcpServers) && input.mcpServers.length > 0) { + clientMcpServers = acpMcpServersToConfigRecord(input.mcpServers) + markAllMCPConfigOrigins(clientMcpServers, 'client') + options.markClientMcpServers?.(clientMcpServers) + } + + /** + * §5/§12.4: bridges one core `requestApproval` host callback onto + * `session/request_permission`. Fail-closed on every axis: no + * reverse-request seam → `false`; a rejected/transport-lost promise → + * `false`; a non-`allow_once` or cancelled outcome → `false` (GV-22); + * session/cancel or owner disconnect → `false` AND the run aborts + * `cancelled` (GV-23); the timeout resolves `false` after sending + * `$/cancel_request`. A `true` resolution additionally requires the turn + * signal to still be live (the request is still pending on the owning + * connection). + */ + const requestApproval = async ( + request: HarnessApprovalRequest, + ): Promise => { + if (!reverseRequests) return false + const requestId = `approval-${reverseRequestCounter++}` + // GV-21 display sanitization is applied inside + // `buildApprovalPermissionRequest` BEFORE anything is emitted, so no + // control/bidi/zero-width char ever reaches the permission UI. + const params = buildApprovalPermissionRequest(input.sessionId, request) + const outcomePromise = reverseRequests.requestPermission(params) + const timeoutMs = options.approvalTimeoutMs ?? DEFAULT_APPROVAL_TIMEOUT_MS + return await new Promise((resolve) => { + let settled = false + const timeout = setTimeout(() => { + // §12.4 timeout: resolve false and ask the client to cancel the + // outstanding request. + settleReverseRequest(requestId, false) + void onCancelRequest?.(requestId) + }, timeoutMs) + timeout.unref?.() + pendingReverseRequests.set(requestId, { + resolve: (value) => { + if (settled) return + settled = true + resolve(value) + }, + settled: false, + timeout, + }) + outcomePromise.then( + (response) => { + // GV-22/GV-24: resolve true ONLY for a selected `allow_once` + // outcome on a request that is still pending (not + // cancelled/disconnected). The ACP SDK does not check `optionId` + // against the offered options, so this layer must. A late or + // duplicate response finds no entry and is ignored by + // `settleReverseRequest`. + const allow = + response !== undefined && + response !== null && + response.outcome !== undefined && + response.outcome.outcome === 'selected' && + (response.outcome as { optionId?: string }).optionId === + 'allow_once' && + pendingReverseRequests.has(requestId) && + !input.signal.aborted + settleReverseRequest(requestId, allow) + }, + () => { + // A rejected promise (transport loss / connection close) fails + // closed. + settleReverseRequest(requestId, false) + }, + ) + }) + } + + /** + * §4.2: bridges the core `ask_user` tool onto the client. The mapping is + * capability-driven: `elicitation.form` → `elicitation/create`; else the + * `events` extension → `_openbuff.dev/ask_user`; else a skipped result. + * Cancel is honored — an abort (session/cancel or disconnect) resolves + * `skipped` so the tool never blocks the turn forever, and the approval + * timeout bounds the wait the same way. + */ + const handleAskUser = async (input0: { + questions: Array<{ + question: string + header?: string + options: Array<{ label: string; description?: string }> + multiSelect: boolean + }> + }): Promise => { + const skippedResult: ToolResultOutput[] = [ + { type: 'json', value: { skipped: true } }, + ] + const timeoutMs = options.approvalTimeoutMs ?? DEFAULT_APPROVAL_TIMEOUT_MS + const elicitationForm = + clientCapabilities?.elicitation?.form !== undefined + + // Await one reverse-request payload, honoring cancel + the approval + // timeout. Resolves the payload on success, `undefined` when the wait + // is cancelled, times out, or the request fails (→ skipped result). + const awaitReverseAnswer = ( + method: 'elicitation' | 'ask_user', + invoke: () => Promise, + ): Promise => { + const requestId = `${method}-${reverseRequestCounter++}` + let settled = false + let resolveWait: (value: unknown) => void = () => {} + const wait = new Promise((resolve) => { + resolveWait = resolve + }) + const timeout = setTimeout(() => { + settled = true + pendingReverseRequests.delete(requestId) + resolveWait(undefined) + void onCancelRequest?.(requestId) + }, timeoutMs) + timeout.unref?.() + pendingReverseRequests.set(requestId, { + resolve: () => { + // A cancel resolves the wait as skipped. + if (settled) return + settled = true + clearTimeout(timeout) + resolveWait(undefined) + }, + settled: false, + timeout, + }) + void Promise.resolve() + .then(invoke) + .then( + (payload) => { + if (settled || input.signal.aborted) { + settleReverseRequest(requestId, false) + return + } + settled = true + pendingReverseRequests.delete(requestId) + clearTimeout(timeout) + resolveWait(payload) + }, + () => { + settleReverseRequest(requestId, false) + }, + ) + return wait + } + + // The elicitation/form path needs an elicitation reverse-request seam. + // `AcpReverseRequests` (owned by acp-agent.ts) does not declare one, so + // the bridge drives `elicitation/create` through the generic + // `onReverseRequest` channel when the client negotiated form support. + if (elicitationForm && onReverseRequest !== undefined) { + const response = (await awaitReverseAnswer('elicitation', () => + onReverseRequest( + 'elicitation/create', + buildAskUserElicitation(input.sessionId, input0), + ), + )) as { action?: string; content?: Record } | null + if ( + response === null || + response === undefined || + response.action !== 'accept' || + !isPlainRecord(response.content) + ) { + return skippedResult + } + return [ + { + type: 'json', + value: { + answers: elicitationContentToAskUserAnswers( + input0.questions, + response.content, + ), + }, + }, + ] + } + + if (eventsExtensionEnabled && onReverseRequest !== undefined) { + const response = await awaitReverseAnswer('ask_user', () => + onReverseRequest('_openbuff.dev/ask_user', { + sessionId: input.sessionId, + questions: sanitizeAskUserQuestions(input0.questions), + }), + ) + if (!isPlainRecord(response) || response.skipped === true) { + return skippedResult + } + if (Array.isArray(response.answers)) { + return [{ type: 'json', value: { answers: response.answers } }] + } + return skippedResult + } + + return skippedResult + } + + // §6.1: a session with no stored capability map gets the honest P1 serve + // default BEFORE the first turn, so `_openbuff.dev/capabilities/get` + // never fails for an open session and the change push has a baseline. + // The journal flags are derived from the store's real posture, not + // hardcoded (a purely in-memory store advertises no resume/replay). + if (sessionData.getCapabilities(input.sessionId) === undefined) { + sessionData.setCapabilities( + input.sessionId, + defaultCapabilityMapV1({ journalAvailable: sessionData.hasJournal() }), + ) + } + await maybeNotifyCapabilitiesChanged(input.sessionId) + + try { + await client.run({ + agent: agentId, + prompt: input.promptText, + handleEvent, + onFilesystemMutation: (event: FilesystemMutationEvent) => + sessionData.recordReceiptFromMutationEvent(input.sessionId, event), + signal: input.signal, + // §5: bridge the core approval callback to session/request_permission. + // Without a reverseRequests seam the callback resolves `false`, so a + // high-impact action is denied rather than silently approved. + requestApproval, + // §4.2: bridge the ask_user tool onto elicitation / the events ext / + // a skipped result, honoring cancel. + overrideTools: { ask_user: handleAskUser }, + ...(clientMcpServers ? { mcpServers: clientMcpServers } : {}), + }) + } finally { + // §12.8 NEW-3 flush contract: turn end AND cancel release everything + // held; the idle timer is stopped so no per-turn timer outlives the turn. + // The final flush is best-effort: a rejected emit (e.g. a client + // hang-up) is contained at warn level and NEVER masks the run error or + // throws out of a finally — and a CANCELLED turn never flushes at all + // (its held windows are drained without emitting, so nothing crosses a + // dead sink while memory stays bounded). A normally-settled turn still + // attempts every held frame, so the last frame is never dropped. + stopIdleFlusher() + if (input.signal.aborted) { + void messageHoldback.flushSession(input.sessionId) + void thoughtHoldback.flushSession(input.sessionId) + } else { + try { + await flushHoldbacks() + } catch (error) { + warnFlushFailure('turn-end flush', error) + } + } + // Turn teardown: detach the cancel listener and resolve every still- + // pending approval/elicitation `false` so no timer or promise outlives + // the turn. + input.signal.removeEventListener('abort', cancelAllPendingReverseRequests) + cancelAllPendingReverseRequests() + } + + // §4.2 terminal-error surfacing: a terminal error answers `session/prompt` + // with the -32603 error (data["openbuff.dev"].message = userMessage ?? + // message), NOT a normal stopReason. The payload is thrown as a REAL + // RequestError instance — never a bare PrintModeRpcError object — because + // the ACP wire layer maps a thrown RequestError onto the JSON-RPC error + // response directly, while many JSON-RPC implementations wrap or discard + // plain-object (non-Error) throws, which would silently turn the + // terminal error into a generic failure without the openbuff.dev data. + if (terminalError !== undefined) { + throw new RequestError( + terminalError.code, + terminalError.data['openbuff.dev'].message, + terminalError.data, + ) + } + + // A run that ended via the abort signal maps to 'cancelled'; a naturally + // settled run is 'end_turn'. + return { stopReason: input.signal.aborted ? 'cancelled' : 'end_turn' } + } + + return { promptHandler, sessionData } +} + +/** + * Extracts the model-visible assistant text from a mapped `agent_message_chunk` + * payload, or `undefined` when the payload carries none. Conservative by + * design: only the text content of an `agent_message_chunk` is forwardable + * through the streaming `update` seam; every other payload is handed to the + * optional `onSessionUpdate` seam instead. + */ +function extractForwardableText(payload: unknown): string | undefined { + if (!isPlainRecord(payload)) return undefined + if (payload.sessionUpdate !== 'agent_message_chunk') return undefined + const content = payload.content + if (!isPlainRecord(content)) return undefined + if (content.type !== 'text' || typeof content.text !== 'string') { + return undefined + } + return content.text +} + +/** Type guard for the plain-object payloads the event bridge emits. */ +function isPlainRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +/** The holdback window key for a chunk payload: its messageId, else 'default'. */ +function messageIdKey(payload: Record): string { + return typeof payload.messageId === 'string' ? payload.messageId : 'default' +} + +/** Extracts the text of an `agent_thought_chunk` payload, else undefined. */ +function extractThoughtText( + payload: Record, +): string | undefined { + const content = payload.content + if (!isPlainRecord(content)) return undefined + if (content.type !== 'text' || typeof content.text !== 'string') { + return undefined + } + return content.text +} + +/** + * Folds an ACP `{ name, value }[]` list (stdio `env` / remote `headers`) into + * the `Record` shape the core {@link MCPConfig} uses. Later + * entries win on a duplicate name. + */ +function acpEntriesToRecord( + entries: Array<{ name: string; value: string }>, +): Record { + // Null-prototype for the same reason as acpMcpServersToConfigRecord: an + // attacker-controlled env/header name must never reach an inherited setter. + const record: Record = Object.create(null) + for (const { name, value } of entries) { + record[name] = value + } + return record +} + +/** + * Converts client-advertised ACP `McpServer[]` into the core + * `Record` keyed by each server's `name`. Only the + * transports the core {@link MCPConfig} union actually supports are emitted: + * - `stdio` (the ACP variant with no `type` discriminant) → core stdio config, + * with `env` folded into a record; + * - `http` / `sse` → core remote config, with `headers` folded into a record + * (`params` is empty — ACP advertises none); + * - `acp` → SKIPPED: the core union has no ACP-transport equivalent. + * + * Pure and synchronous so it can run inline before the async `client.run`. + * Unknown/empty inputs simply produce no entries. + */ +function acpMcpServersToConfigRecord( + servers: McpServer[], +): Record { + // Null-prototype so an attacker-controlled server name like '__proto__', + // 'constructor', or 'prototype' becomes a plain own key instead of hitting + // an inherited setter (which would silently drop the entry from + // Object.values/entries and thus from the client-origin marking pass). + const record: Record = Object.create(null) + for (const server of servers) { + if ('type' in server) { + // http | sse | acp — the variants carrying the ACP `type` discriminant. + if (server.type === 'http' || server.type === 'sse') { + record[server.name] = { + type: server.type, + url: server.url, + params: {}, + headers: acpEntriesToRecord(server.headers), + } + } + // 'acp': skipped — no core equivalent transport. + continue + } + // McpServerStdio is the only ACP variant without a `type` discriminant. + record[server.name] = { + type: 'stdio', + command: server.command, + args: [...server.args], + env: acpEntriesToRecord(server.env), + } + } + return record +} + +// --------------------------------------------------------------------------- +// §5/§12.4 approval + §4.2 ask_user helpers (module-level, pure) +// --------------------------------------------------------------------------- + +/** + * §12.4 display-sanitization character set: C0/C1 controls, ESC, U+2028/2029, + * bidi controls (U+202A–U+202E, U+2066–U+2069), and zero-width chars + * (U+200B–U+200D, U+FEFF). Each is replaced with U+FFFD (GV-21). Bounded + * character-class matches only, so this stays linear on adversarial input. + */ +const DISPLAY_UNSAFE_RE = + /[\u0000-\u001F\u007F-\u009F\u2028\u2029\u202A-\u202E\u2066-\u2069\u200B-\u200D\uFEFF]/g + +/** §12.4: `title` display cap, in characters. */ +const APPROVAL_TITLE_MAX_CHARS = 200 +/** §12.4: `command` cap on the approval `_meta`, in UTF-8 bytes. */ +const APPROVAL_COMMAND_MAX_BYTES = 4 * 1024 + +/** + * Replaces every display-unsafe character (C0/C1, ESC, U+2028/2029, bidi, + * zero-width) with U+FFFD (GV-21). Pure and never throws. + */ +function sanitizeDisplayText(value: string): string { + if (value.length === 0) return value + return value.replace(DISPLAY_UNSAFE_RE, '\uFFFD') +} + +/** Caps a string at `maxChars` characters (GV-21 `title` cap). */ +function capChars(value: string, maxChars: number): string { + return value.length > maxChars ? value.slice(0, maxChars) : value +} + +/** + * §12.4 command binding: truncates `command` at 4 KiB UTF-8, appending a + * truncation marker, so the approval `_meta` carries the normalized command + * without unbounded growth. + */ +function capCommandForApproval(command: string): string { + const encoder = new TextEncoder() + if (encoder.encode(command).byteLength <= APPROVAL_COMMAND_MAX_BYTES) { + return command + } + let truncated = command + while ( + truncated.length > 0 && + encoder.encode(truncated).byteLength > APPROVAL_COMMAND_MAX_BYTES + ) { + truncated = truncated.slice(0, Math.ceil(truncated.length / 2)) + } + return `${truncated}\n[truncated]` +} + +/** + * Builds the §5 `session/request_permission` params for one + * `HarnessApprovalRequest`. Every display field (`title`, `target`, `branch`, + * `command`) passes through GV-21 sanitization BEFORE emission; `title` is + * capped at 200 chars and `command` at 4 KiB. The `_meta["openbuff.dev"] + * .approval` carries the (sanitized, capped) `command` plus the verbatim + * `commandHash` binding (§12.4). Only the one-shot `allow_once` / `reject_once` + * options are offered — there is no `allow_always` (GV-22). + */ +function buildApprovalPermissionRequest( + sessionId: string, + request: HarnessApprovalRequest, +): RequestPermissionRequest { + const action = sanitizeDisplayText(String(request.action)) + const target = sanitizeDisplayText(String(request.target)) + const title = capChars(`Approve ${action}: ${target}`, APPROVAL_TITLE_MAX_CHARS) + // The core HarnessApprovalRequest binds the command via commandHash (the + // raw command is not carried across the contract); the human-readable + // display derives from the classified target. + const command = sanitizeDisplayText( + capCommandForApproval(String(request.target)), + ) + const approval: Record = { + action, + target, + risk: request.risk, + reason: sanitizeDisplayText(String(request.reason)), + command, + commandHash: request.commandHash, + } + if (request.branch !== undefined) { + approval.branch = sanitizeDisplayText(String(request.branch)) + } + return { + sessionId, + toolCall: { + toolCallId: `approval-${request.commandHash}`, + title, + kind: 'execute', + status: 'pending', + }, + options: [ + { optionId: 'allow_once', name: 'Allow once', kind: 'allow_once' }, + { optionId: 'reject_once', name: 'Deny', kind: 'reject_once' }, + ], + _meta: { 'openbuff.dev': { approval } }, + } +} + +/** One ask_user question as the bridge needs it (subset of the tool schema). */ +type AskUserQuestion = { + question: string + header?: string + options: Array<{ label: string; description?: string }> + multiSelect: boolean +} + +/** + * §4.2/GV-21: sanitizes the ask_user question list before it crosses the wire. + * Question text, headers, option labels, and descriptions all pass through the + * display sanitizer; structure and `multiSelect` are preserved. + */ +function sanitizeAskUserQuestions( + questions: AskUserQuestion[], +): AskUserQuestion[] { + return questions.map((question) => ({ + question: sanitizeDisplayText(question.question), + ...(question.header !== undefined + ? { header: sanitizeDisplayText(question.header) } + : {}), + options: question.options.map((option) => ({ + label: sanitizeDisplayText(option.label), + ...(option.description !== undefined + ? { description: sanitizeDisplayText(option.description) } + : {}), + })), + multiSelect: question.multiSelect, + })) +} + +/** + * Builds the `elicitation/create` form-mode request for one ask_user call + * (§4.2). Each question becomes one property keyed `q`: a titled + * single-select (`oneOf`) string, or a multi-select (`array`) whose items are + * the titled options. Question text is the property title; option labels are + * the enum consts. All text passes through GV-21 sanitization. + */ +function buildAskUserElicitation( + sessionId: string, + input: { questions: AskUserQuestion[] }, +): CreateElicitationRequest { + const questions = sanitizeAskUserQuestions(input.questions) + const properties: Record = {} + const required: string[] = [] + for (let index = 0; index < questions.length; index++) { + const question = questions[index] + const key = `q${index}` + required.push(key) + const title = question.header ?? question.question + if (question.multiSelect) { + properties[key] = { + type: 'array', + title, + description: question.question, + items: { + anyOf: question.options.map((option) => ({ + const: option.label, + title: option.label, + ...(option.description !== undefined + ? { description: option.description } + : {}), + })), + }, + } + } else { + properties[key] = { + type: 'string', + title, + description: question.question, + oneOf: question.options.map((option) => ({ + const: option.label, + title: option.label, + ...(option.description !== undefined + ? { description: option.description } + : {}), + })), + } + } + } + return { + mode: 'form', + sessionId, + message: + questions.length === 1 + ? questions[0].question + : `Answer ${questions.length} questions`, + requestedSchema: { + type: 'object', + title: 'Questions', + properties, + required, + }, + } +} + +/** One ask_user answer: a JSON-safe projection of an elicitation response. */ +type AskUserAnswer = { + questionIndex: number + selectedOption?: string + selectedOptions?: string[] +} + +/** + * Maps an `elicitation/create` accepted `content` object back onto the + * ask_user `answers` result shape. Single-select questions read a string at + * `q`; multi-select read a string array. A non-matching value yields + * no answer for that question (the tool schema allows partial answers). + */ +function elicitationContentToAskUserAnswers( + questions: AskUserQuestion[], + content: Record, +): AskUserAnswer[] { + const answers: AskUserAnswer[] = [] + for (let index = 0; index < questions.length; index++) { + const value = content[`q${index}`] + if (questions[index].multiSelect) { + if (Array.isArray(value)) { + answers.push({ + questionIndex: index, + selectedOptions: value.filter((entry) => typeof entry === 'string'), + }) + } + continue + } + if (typeof value === 'string') { + answers.push({ questionIndex: index, selectedOption: value }) + } + } + return answers +} diff --git a/sdk/src/serve/event-bridge.ts b/sdk/src/serve/event-bridge.ts new file mode 100644 index 0000000000..df91aa413c --- /dev/null +++ b/sdk/src/serve/event-bridge.ts @@ -0,0 +1,864 @@ +import path from 'path' + +import type { ToolResultOutput } from '@codebuff/common/types/messages/content-part' +import type { PrintModeEvent } from '@codebuff/common/types/print-mode' +import type { + CommitReceiptV1, + FileMutationActionV1, + FileMutationResultV1, +} from '@codebuff/common/tools/results/filesystem' +import { toWireMutation } from '@codebuff/common/protocol/acp-ext-v1' +import { getConfirmedAppliedActionsV1, isFileMutationResultV1 } from '@codebuff/common/tools/results/filesystem' + +/** + * Pure, transport-free ACP event bridge (P1-T2b, NORMATIVE contract §4.2 of + * `.agents/sessions/polyglot-roadmap-v2/P1-T1-DESIGN.md`). Maps a + * `PrintModeEvent` from the core run loop onto the `session/update` payload + * shapes the ACP client consumes. The function performs NO I/O: it owns no + * connection, spawns no process, and reads no environment — the host supplies + * everything ambient through {@link EventBridgeContext}. + * + * Security posture inherited from §12.1 (SEC-1/SEC-6): + * - `rawOutput` is NEVER attached to any emitted payload. + * - Tool results are surfaced only as bounded `content` text (capped at 64 KiB + * with a truncation marker); final `sanitizeOutbound` redaction stays at the + * transport chokepoint. + * - Auto-recovering errors are never emitted; terminal errors are mapped by + * {@link printModeErrorToRpcError} instead of a session update. + */ +export type EventBridgeContext = { + sessionId: string + /** Run id stamped onto `agent_message_chunk`/`agent_thought_chunk` `messageId`. */ + runId: string | undefined + /** + * `'acp'` (default) keeps subagent text and telemetry variants off the wire; + * `'full'` enables them per §4.2. + */ + eventsMode: 'acp' | 'full' + /** Root `tool_call` locations are resolved against. */ + projectRoot: string + /** + * The active tool call id, stamped onto the receipt envelope's `toolCallId` + * (§6.2). Only meaningful while a `tool_result` is being mapped. + */ + toolCallId?: string + /** + * §4.6 tool `kind` mapping, supplied by the host so this module stays + * decoupled from the parallel stream's module layout. + */ + toolKind: ( + toolName: string, + mutation?: { actions: Array<{ action: string }> }, + ) => string + /** + * NEW-3 (§12.8): the configured credential VALUES the streaming holdback + * redacts from chunk text. The holdback does NOT cover `tool_call` + * rawInput, so the same value substitution is applied here over every + * rawInput string field before emission. Omitted/empty → no credential + * redaction (matching the holdback's default posture). + */ + credentialValues?: readonly string[] +} + +/** + * Thrown by the exhaustiveness default branch when a `PrintModeEvent` variant + * has no §4.2 mapping. Typed so callers can distinguish contract drift from a + * mapping bug. + */ +export class UnhandledPrintModeVariantError extends Error { + readonly variant: string + constructor(variant: string) { + super( + `printModeToSessionUpdates has no §4.2 mapping for PrintModeEvent variant '${variant}'`, + ) + this.name = 'UnhandledPrintModeVariantError' + this.variant = variant + } +} + +/** JSON-RPC error shape §4.2 prescribes for a terminal error on `session/prompt`. */ +export type PrintModeRpcError = { + code: number + data: { 'openbuff.dev': { message: string } } +} + +/** + * Maps a terminal `error` event (one WITHOUT `autoRecovering: true`) onto the + * JSON-RPC error §4.2 prescribes for `session/prompt`: -32603 with + * `data["openbuff.dev"].message = userMessage ?? message`. Auto-recovering + * errors are never emitted and never reach this function. + */ +export function printModeErrorToRpcError( + event: Extract, +): PrintModeRpcError { + return { + code: -32603, + data: { + 'openbuff.dev': { message: event.userMessage ?? event.message }, + }, + } +} + +/** The `_openbuff.dev/event` ext notification method (§4.2, events=`full`). */ +const EXT_EVENT_METHOD = '_openbuff.dev/event' + +/** + * Input keys treated as filesystem paths when building `tool_call` + * `locations`. Values are made absolute against `ctx.projectRoot`. + */ +const PATH_PARAM_KEYS = [ + 'path', + 'paths', + 'filePath', + 'filePaths', + 'targetPath', + 'destinationPath', +] as const + +/** §12.1: tool results cross as sanitized text capped at 64 KiB. */ +const TOOL_RESULT_CONTENT_MAX_BYTES = 64 * 1024 + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +/** + * Builds `tool_call` `locations` from the event's path-shaped input params, + * made absolute against `projectRoot` and de-duplicated. + */ +function collectLocations( + input: Record, + projectRoot: string, +): Array<{ path: string }> { + const locations: Array<{ path: string }> = [] + const seen = new Set() + for (const key of PATH_PARAM_KEYS) { + const value = input[key] + if (value === undefined) continue + for (const candidate of Array.isArray(value) ? value : [value]) { + if (typeof candidate !== 'string' || candidate.length === 0) continue + // An empty projectRoot (the host has not supplied a root yet) keeps the + // path relative instead of silently resolving against process.cwd(). + const absolute = + projectRoot === '' ? candidate : path.resolve(projectRoot, candidate) + if (seen.has(absolute)) continue + seen.add(absolute) + locations.push({ path: absolute }) + } + } + return locations +} + +/** + * NEW-6 (§12.8, GV-25): paths whose basename marks credential material. + * Case-insensitive on the basename: `.env`/`.env.*`, `*.pem`, `*.key`, + * `id_rsa`/`id_rsa.*`, `id_ed25519`/`id_ed25519.*`, `.git-credentials`, + * `.netrc`, `credentials`, and `secrets.*`. + */ +export function isSensitivePath(p: string): boolean { + const basename = p.split(/[\\/]/).pop() ?? p + const lower = basename.toLowerCase() + if (lower === '.env' || lower.startsWith('.env.')) return true + if (lower.endsWith('.pem') || lower.endsWith('.key')) return true + if (lower === 'id_rsa' || lower.startsWith('id_rsa.')) return true + if (lower === 'id_ed25519' || lower.startsWith('id_ed25519.')) return true + if ( + lower === '.git-credentials' || + lower === '.netrc' || + // Package-registry auth files carry tokens; a card touching one must not + // leak the registry credentials it writes. + lower === '.npmrc' || + lower === '.pypirc' || + lower === 'credentials' + ) { + return true + } + return lower.startsWith('secrets.') +} + +/** The placeholder NEW-6 substitutes into content-bearing rawInput fields. */ +const SENSITIVE_PLACEHOLDER = '[sensitive]' + +/** Top-level rawInput string fields redacted for a sensitive path. */ +const RAW_INPUT_CONTENT_KEYS = [ + 'content', + 'newString', + 'oldString', + 'diff', + // `replace_range` carries its replacement text in `newContent`; a card + // touching a sensitive path must never leak it (NEW-6/GV-25). + 'newContent', +] + +/** rawInput list fields whose element string values are redacted at every depth. */ +const RAW_INPUT_LIST_KEYS = ['replacements', 'edits'] + +/** + * NEW-6 element-level redaction: EVERY string value at EVERY depth inside a + * `replacements`/`edits` element becomes "[sensitive]". Edit elements carry + * file content in nested positions a flat field list cannot cover — a + * `structured` edit's `operation.text`/`importStatement`, a + * `replace_range` edit's `occurrence.match`, or any future nested + * content-bearing field — so the element rule is shape-recursive, not + * field-listed. Arrays and plain objects are rebuilt; everything else passes + * through. + */ +function redactAllStringsDeep(value: unknown, depth: number): unknown { + if (typeof value === 'string') { + return SENSITIVE_PLACEHOLDER + } + if (depth >= REDACT_WALK_MAX_DEPTH) { + // Same fail-closed shape as redactCredentialValuesDeep: a capped subtree + // is serialized rather than passed through, and redact-all-strings of + // that serialized form is the marker itself. + return SENSITIVE_PLACEHOLDER + } + if (Array.isArray(value)) { + return value.map((element) => redactAllStringsDeep(element, depth + 1)) + } + if (isRecord(value)) { + const copy: Record = {} + for (const [field, fieldValue] of Object.entries(value)) { + copy[field] = redactAllStringsDeep(fieldValue, depth + 1) + } + return copy + } + return value +} + +/** + * Builds the NEW-6 redacted rawInput copy: top-level content-bearing string + * fields become "[sensitive]", and EVERY string value of every + * `replacements`/`edits` element becomes "[sensitive]" at EVERY nesting + * depth (covering nested `content`/`newString`/`oldString`/`newContent` + * inside edit elements, plus the nested content positions of structured and + * occurrence-targeted edits). All other top-level fields — paths, ids, + * flags — pass through untouched. + */ +function redactSensitiveRawInput( + input: Record, +): Record { + const redacted: Record = { ...input } + for (const key of RAW_INPUT_CONTENT_KEYS) { + if (typeof redacted[key] === 'string') { + redacted[key] = SENSITIVE_PLACEHOLDER + } + } + for (const key of RAW_INPUT_LIST_KEYS) { + const list = redacted[key] + if (!Array.isArray(list)) continue + redacted[key] = list.map((element) => + isRecord(element) ? redactAllStringsDeep(element, 0) : element, + ) + } + return redacted +} + +/** The NEW-3 substitution literal, identical to OutboundHoldback's emit path. */ +const CREDENTIAL_VALUE_REDACTED = '[REDACTED_SECRET]' + +/** + * Recursion bound shared by the deep-redaction walks (credential-value and + * sensitive-string) over a hostile nested rawInput. At the cap a walk does + * NOT pass the remaining subtree through: it is serialized and the + * serialized text is redacted instead (fail-closed). + */ +const REDACT_WALK_MAX_DEPTH = 32 + +/** Applies the credential-value substitution to one serialized text blob. */ +function redactCredentialValuesInString( + text: string, + credentials: readonly string[], +): string { + let redacted = text + for (const credential of credentials) { + if (credential.length === 0) continue + redacted = redacted.split(credential).join(CREDENTIAL_VALUE_REDACTED) + } + return redacted +} + +/** + * NEW-3 (§12.8): substitutes every configured credential VALUE found in any + * string at any depth of a rawInput payload with "[REDACTED_SECRET]" + * (mirroring OutboundHoldback's emit path, which only covers streaming chunk + * text — rawInput bypassed the holdback entirely). Depth-bounded so a + * hostile deep payload cannot overflow the stack; at the depth cap the + * remaining subtree is serialized and the serialized text is redacted rather + * than passed through unredacted (EV-2, fail-closed). + */ +function redactCredentialValuesDeep( + value: unknown, + credentials: readonly string[], + depth: number, +): unknown { + if (typeof value === 'string') { + return redactCredentialValuesInString(value, credentials) + } + if (depth >= REDACT_WALK_MAX_DEPTH) { + const serialized = JSON.stringify(value) + return typeof serialized === 'string' + ? redactCredentialValuesInString(serialized, credentials) + : value + } + if (Array.isArray(value)) { + return value.map((element) => + redactCredentialValuesDeep(element, credentials, depth + 1), + ) + } + if (isRecord(value)) { + const copy: Record = {} + for (const [field, fieldValue] of Object.entries(value)) { + copy[field] = redactCredentialValuesDeep( + fieldValue, + credentials, + depth + 1, + ) + } + return copy + } + return value +} + +/** Whether any collected tool_call location points at a sensitive path. */ +function anyLocationSensitive(locations: Array<{ path: string }>): boolean { + return locations.some((location) => isSensitivePath(location.path)) +} + +type MutationActionSummary = { + outcome: string + path: string + afterContent: string | undefined +} + +type MutationResultSummary = { + outcome: string + actions: MutationActionSummary[] +} + +/** + * Structurally narrows an output JSON value onto the slice of + * `FileMutationResultV1` the diff/`failed` mapping needs. Deliberately loose + * (it does NOT require the full result schema) so an existing tool_result + * that carries only outcome/path/afterContent still drives the tool card's + * status and diff content. Receipt attachment uses the strictly-validated + * {@link asConfirmedMutation} instead. + */ +function asMutationResult(value: unknown): MutationResultSummary | undefined { + if (!isRecord(value) || value.kind !== 'file_mutation_result') return undefined + if (typeof value.outcome !== 'string' || !Array.isArray(value.actions)) { + return undefined + } + const actions: MutationActionSummary[] = [] + for (const element of value.actions) { + if (!isRecord(element)) continue + if ( + typeof element.outcome !== 'string' || + typeof element.path !== 'string' + ) { + continue + } + actions.push({ + outcome: element.outcome, + path: element.path, + afterContent: + typeof element.afterContent === 'string' + ? element.afterContent + : undefined, + }) + } + return { outcome: value.outcome, actions } +} + +/** + * Strictly narrows an output JSON value onto a schema-clean + * `FileMutationResultV1`, or `undefined`. Only a fully-validated mutation is + * projected onto a §6.2 receipt envelope (cap.v3 authority is at stake). + */ +function asConfirmedMutation(value: unknown): FileMutationResultV1 | undefined { + return isFileMutationResultV1(value) ? value : undefined +} + +/** NEW-6/GV-25: the literal substituted for every redacted hash (§12.1). */ +const REDACTED_HASH = 'sha256:redacted' + +/** The NEW-6 placeholder text block substituted for a sensitive-path diff. */ +function sensitiveDiffPlaceholder(path: string): Record { + // The text is constant-shape (no file content), so it leaks nothing beyond + // the already-disclosed fact that this path changed. + return { type: 'text', text: `[sensitive file changed: ${path}]` } +} + +/** + * NEW-6 (§12.1, GV-25): the content block for one mutation action. A + * non-sensitive action with after-text yields a `diff` block; a sensitive-path + * action yields the `[sensitive file changed]` text placeholder instead of any + * diff content (the post-edit content must never reach the wire for a + * sensitive path). + */ +function mutationActionBlock( + action: MutationActionSummary, +): Record | undefined { + if (isSensitivePath(action.path)) { + return sensitiveDiffPlaceholder(action.path) + } + if (action.afterContent !== undefined) { + return { type: 'diff', path: action.path, newText: action.afterContent } + } + return undefined +} + +/** Content-bearing mutation-result fields redacted for a sensitive-path action. */ +const MUTATION_RESULT_CONTENT_KEYS = [ + 'afterContent', + 'beforeContent', + 'patch', + 'content', +] as const + +/** + * EV-1: returns `values` with the content-bearing fields of every + * sensitive-path mutation action replaced with "[sensitive]" (the same + * placeholder the diff block's convention uses) so the raw-JSON + * `tool_result` text block never echoes post-edit file content for a + * sensitive path. Values without a sensitive-path mutation pass through + * unchanged (the same array when nothing is sensitive), and every serialized + * line stays JSON.parse-able. + */ +function redactSensitiveMutationValues(values: unknown[]): unknown[] { + const sensitivePaths = new Set() + for (const value of values) { + const parsed = asMutationResult(value) + if (parsed === undefined) continue + for (const action of parsed.actions) { + if (isSensitivePath(action.path)) sensitivePaths.add(action.path) + } + } + if (sensitivePaths.size === 0) return values + return values.map((value) => { + if ( + !isRecord(value) || + value.kind !== 'file_mutation_result' || + !Array.isArray(value.actions) + ) { + return value + } + return { + ...value, + actions: value.actions.map((action) => { + if (!isRecord(action) || !sensitivePaths.has(String(action.path))) + return action + const redacted = { ...action } + for (const key of MUTATION_RESULT_CONTENT_KEYS) { + if (typeof redacted[key] === 'string') { + redacted[key] = SENSITIVE_PLACEHOLDER + } + } + return redacted + }), + } + }) +} + +/** + * Builds the redacted §6.2 receipt envelope for a confirmed mutation, with + * NEW-6 hash redaction applied to the mutation projection and its kept-verbatim + * `authorityReceipt.finalHashes`. Mirrors `toWireReceipt` (the envelope + * `toWireMutation` projection already drops `afterContent`/`patch`/`editAnchor` + * and pins `freshCapabilities: []`), then rewrites the hashes of every + * sensitive-path action to `"sha256:redacted"`. + */ +function buildReceiptEnvelope( + mutation: FileMutationResultV1, + ctx: EventBridgeContext, +): Record { + const wire = toWireMutation(mutation) + const sensitivePaths = new Set( + mutation.actions + .filter((action: FileMutationActionV1) => isSensitivePath(action.path)) + .map((action: FileMutationActionV1) => action.path), + ) + if (sensitivePaths.size > 0) { + for (const action of wire.actions) { + if (!sensitivePaths.has(action.path)) continue + action.beforeHash = action.beforeHash === null ? null : REDACTED_HASH + action.afterHash = action.afterHash === null ? null : REDACTED_HASH + } + const receipt = wire.authorityReceipt + if (receipt !== undefined) { + wire.authorityReceipt = { + ...receipt, + actions: receipt.actions.map((action: CommitReceiptV1['actions'][number]) => + sensitivePaths.has(action.path) + ? { + ...action, + beforeHash: action.beforeHash === null ? null : REDACTED_HASH, + afterHash: action.afterHash === null ? null : REDACTED_HASH, + } + : action, + ), + finalHashes: Object.fromEntries( + Object.entries(receipt.finalHashes).map(([path, hash]) => [ + path, + sensitivePaths.has(path) && hash !== null ? REDACTED_HASH : hash, + ]), + ), + } satisfies CommitReceiptV1 + } + } + const envelope: Record = { + kind: 'openbuff.receipt_envelope', + version: 1, + sessionId: ctx.sessionId, + toolCallId: ctx.toolCallId, + laneId: 'main', + mutation: wire, + } + return envelope +} + +/** The JSON values of a tool_result output (media parts are never emitted). */ +function jsonValuesOf(output: ToolResultOutput[]): unknown[] { + const values: unknown[] = [] + for (const part of output) { + // `media` parts carry raw output bytes; they are never emitted (§12.1). + if (part.type === 'json') values.push(part.value) + } + return values +} + +/** Caps serialized tool-result text at `maxBytes` UTF-8 with a marker (§12.1). */ +function capUtf8(text: string, maxBytes: number): string { + const encoder = new TextEncoder() + if (encoder.encode(text).byteLength <= maxBytes) return text + let truncated = text + while ( + truncated.length > 0 && + encoder.encode(truncated).byteLength > maxBytes + ) { + truncated = truncated.slice(0, Math.ceil(truncated.length / 2)) + } + return `${truncated}\n[truncated]` +} + +type PlanEntry = { + content: string + priority: 'medium' + status: 'pending' | 'in_progress' | 'completed' +} + +/** + * Builds §4.2 `plan` entries from a `write_todos` result's `currentTodos` + * (`{task, completed}[]`): each todo becomes + * `{content: task, priority: 'medium', status: completed ? 'completed' : 'pending'}` + * and the FIRST incomplete todo becomes `in_progress`. Returns `undefined` + * when the output carries no todo list at all. + */ +function planEntriesFromWriteTodos( + values: unknown[], +): PlanEntry[] | undefined { + for (const value of values) { + if (!isRecord(value) || !Array.isArray(value.currentTodos)) continue + const entries: PlanEntry[] = [] + let markedFirstIncomplete = false + for (const todo of value.currentTodos) { + if (!isRecord(todo) || typeof todo.task !== 'string') continue + if (todo.completed === true) { + entries.push({ content: todo.task, priority: 'medium', status: 'completed' }) + continue + } + if (!markedFirstIncomplete) { + markedFirstIncomplete = true + entries.push({ content: todo.task, priority: 'medium', status: 'in_progress' }) + continue + } + entries.push({ content: todo.task, priority: 'medium', status: 'pending' }) + } + return entries + } + return undefined +} + +/** + * Maps one `PrintModeEvent` onto zero or more ACP `session/update` payloads + * per §4.2. Payload shapes are the ACP `SessionUpdate` union members (a + * `sessionUpdate` discriminant) plus the `_openbuff.dev/event` ext + * notification, emitted as `{ method, params }` so the host can dispatch it + * as a notification rather than a session update. + * + * Variants with no §4.2 row (`start`) emit nothing; an UNKNOWN variant throws + * {@link UnhandledPrintModeVariantError} naming it. + */ +export function printModeToSessionUpdates( + event: PrintModeEvent, + ctx: EventBridgeContext, +): Array { + switch (event.type) { + case 'text': { + // Subagent text is forwarded only when events=`full`. The real `text` + // variant carries `agentId` but no `parentAgentId`, so only `agentId` + // is forwarded in `_meta` rather than invented. + const isSubagent = event.agentId !== undefined + if (isSubagent && ctx.eventsMode !== 'full') return [] + const payload: Record = { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: event.text }, + } + if (ctx.runId !== undefined) payload.messageId = ctx.runId + if (isSubagent) { + payload._meta = { 'openbuff.dev': { agentId: event.agentId } } + } + return [payload] + } + case 'reasoning_delta': { + // `ancestorRunIds` is empty exactly for the root run, so root-only means + // an empty lineage unless events=`full`. + if (event.ancestorRunIds.length > 0 && ctx.eventsMode !== 'full') { + return [] + } + const payload: Record = { + sessionUpdate: 'agent_thought_chunk', + content: { type: 'text', text: event.text }, + } + if (ctx.runId !== undefined) payload.messageId = ctx.runId + return [payload] + } + case 'tool_call': { + const locations = collectLocations(event.input, ctx.projectRoot) + const payload: Record = { + sessionUpdate: 'tool_call', + toolCallId: event.toolCallId, + // The tool registry's display label is not reachable from a pure, + // transport-free function, so the tool name stands in as the title. + title: event.toolName, + kind: ctx.toolKind(event.toolName), + status: 'pending', + } + if (locations.length > 0) payload.locations = locations + // NEW-6 (§12.8): rawInput rides every tool card. When any collected + // location path is sensitive, the content-bearing input fields are + // replaced with "[sensitive]" (GV-25) before emission; the §12.1 + // transport chokepoint stays the last line of defense for the rest. + // NEW-6 (§12.8): rawInput rides every tool card. When any collected + // location path is sensitive, the content-bearing input fields are + // replaced with "[sensitive]" (GV-25) before emission; the §12.1 + // transport chokepoint stays the last line of defense for the rest. + // NEW-3 (§12.8): the streaming holdback only covers chunk text, so the + // credential-value substitution is applied to rawInput here too — any + // configured credential value embedded in the tool input is replaced + // with "[REDACTED_SECRET]" before the card crosses the wire. + const rawInput = anyLocationSensitive(locations) + ? redactSensitiveRawInput(event.input) + : event.input + const credentials = ctx.credentialValues ?? [] + payload.rawInput = credentials.some((value) => value.length > 0) + ? redactCredentialValuesDeep(rawInput, credentials, 0) + : rawInput + return [payload] + } + case 'tool_start': { + return [ + { + sessionUpdate: 'tool_call_update', + toolCallId: event.toolCallId, + status: 'in_progress', + }, + ] + } + case 'tool_result': { + // Internal (subagent-forwarded) tool results are never surfaced, even + // with events=`full` — the parent's spawn tool card carries the + // subagent UI instead (GV-16). + if (event.parentAgentId !== undefined) return [] + + const values = jsonValuesOf(event.output) + const planEntries = + event.toolName === 'write_todos' + ? planEntriesFromWriteTodos(values) + : undefined + if (planEntries !== undefined) { + return [{ sessionUpdate: 'plan', entries: planEntries }] + } + + let failed = false + let mutation: MutationResultSummary | undefined + // The strictly-validated confirmed mutation that earns a §6.2 receipt + // envelope (undefined unless a schema-clean mutation has at least one + // confirmed applied action). + let confirmedMutation: FileMutationResultV1 | undefined + for (const value of values) { + if (!isRecord(value)) continue + const errorMessage = value.errorMessage + if (typeof errorMessage === 'string' && errorMessage.length > 0) { + failed = true + } + // A native tool-result error carries `error.message`, not + // `errorMessage`, but is by definition a failed result. + if (value.kind === 'native_tool_result_error') failed = true + const parsed = asMutationResult(value) + if (parsed !== undefined) { + // §4.2: a mutation outcome outside {applied} fails the tool card. + if (parsed.outcome !== 'applied') failed = true + mutation = parsed + } + const confirmed = asConfirmedMutation(value) + if ( + confirmed !== undefined && + getConfirmedAppliedActionsV1(confirmed).length > 0 + ) { + confirmedMutation = confirmed + } + } + + const content: Array = [] + // EV-1: the raw-JSON text block must not echo full post-edit file + // content for a sensitive-path action — the separate diff block already + // substitutes "[sensitive file changed]", so the content-bearing fields + // of any sensitive-path action are replaced with "[sensitive]" before + // serialization (each line stays JSON.parse-able). + const text = capUtf8( + redactSensitiveMutationValues(values) + .map((value) => JSON.stringify(value)) + .join('\n'), + TOOL_RESULT_CONTENT_MAX_BYTES, + ) + if (text.length > 0) content.push({ type: 'text', text }) + if (mutation !== undefined) { + for (const action of mutation.actions) { + // Mutations add `diff` content when after-text is available + // (`afterContent` present, §4.2). The tool_result variant carries + // no before-text, so the block omits `oldText` rather than + // inventing it. NEW-6 (§12.1, GV-25): a sensitive-path action emits + // a `[sensitive file changed]` text placeholder instead of any + // `diff` content, and its hashes are redacted on the receipt. + const block = mutationActionBlock(action) + if (block !== undefined) content.push(block) + } + } + + const payload: Record = { + sessionUpdate: 'tool_call_update', + toolCallId: event.toolCallId, + status: failed ? 'failed' : 'completed', + } + if (content.length > 0) payload.content = content + // §4.2/§6.2: a confirmed mutation attaches the redacted receipt + // envelope to `_meta["openbuff.dev"].receipt` (cap.v3 tokens never + // leave the core — toWireMutation drops afterContent/patch/editAnchor + // and pins freshCapabilities to []). + if (confirmedMutation !== undefined) { + payload._meta = { + 'openbuff.dev': { + receipt: buildReceiptEnvelope(confirmedMutation, { + ...ctx, + toolCallId: event.toolCallId, + }), + }, + } + } + // `rawOutput` is deliberately never attached (§12.1). + return [payload] + } + case 'subagent_start': { + // Without the parent's spawn toolCallId there is no card to attach to, + // so nothing is emitted rather than inventing an id. + if (event.spawnToolCallId === undefined) return [] + return [ + { + sessionUpdate: 'tool_call', + toolCallId: event.spawnToolCallId, + title: event.displayName, + kind: ctx.toolKind('spawn_agents'), + status: 'pending', + _meta: { + 'openbuff.dev': { + agent: { + agentId: event.agentId, + agentType: event.agentType, + displayName: event.displayName, + }, + }, + }, + }, + ] + } + case 'subagent_finish': { + if (event.spawnToolCallId === undefined) return [] + return [ + { + sessionUpdate: 'tool_call_update', + toolCallId: event.spawnToolCallId, + status: event.error !== undefined ? 'failed' : 'completed', + _meta: { + 'openbuff.dev': { + agent: { + agentId: event.agentId, + agentType: event.agentType, + displayName: event.displayName, + }, + }, + }, + }, + ] + } + case 'context_window': { + // The variant carries `used`/`max` (and optional compaction telemetry + // this mapping does not surface), so only used/size are emitted. + return [ + { + sessionUpdate: 'usage_update', + used: event.used, + size: event.max, + }, + ] + } + case 'finish': { + // `totalCost` is required on the variant; cost is local estimation only. + return [ + { + sessionUpdate: 'usage_update', + cost: { amount: event.totalCost, currency: 'USD' }, + }, + ] + } + case 'error': { + // Auto-recovering errors are never emitted (§4.2); terminal errors are + // mapped to the `session/prompt` JSON-RPC error by + // printModeErrorToRpcError, not to a session update. + return [] + } + case 'start': { + // No §4.2 row maps `start` onto any client-visible message. + return [] + } + case 'job_update': + case 'provider_status': + case 'phase': + case 'context_compaction': + case 'context_compaction_status': + case 'context_compaction_progress': + case 'context_request_trim': + case 'memory_reuse': + case 'download': { + // §4.2: telemetry variants reach the client ONLY as the + // `_openbuff.dev/event` ext notification when events=`full`, and never + // as `tool_call`/`tool_result`. + if (ctx.eventsMode !== 'full') return [] + return [ + { + method: EXT_EVENT_METHOD, + params: { sessionId: ctx.sessionId, event }, + }, + ] + } + default: { + const unhandled = event as { type?: unknown } + const unhandledType = unhandled.type + throw new UnhandledPrintModeVariantError( + typeof unhandledType === 'string' + ? unhandledType + : String(unhandledType), + ) + } + } +} diff --git a/sdk/src/serve/outbound-filter.ts b/sdk/src/serve/outbound-filter.ts new file mode 100644 index 0000000000..72c0cb7e4b --- /dev/null +++ b/sdk/src/serve/outbound-filter.ts @@ -0,0 +1,52 @@ +/** + * Last-line outbound redactor for text about to cross the ACP wire. + * + * Mirrors P1-T1-DESIGN §12.1 SEC-1 ("no cap.v3 token / secret value leaves the + * process"). This is DEFENSE-IN-DEPTH, not the only guard: the serve bridge + * already structurally DROPS `tool_call`/`tool_result` events and forwards only + * model-visible assistant text, so a secret-bearing tool result never reaches + * this filter in the first place. `sanitizeOutbound` is the second layer that + * catches a secret or capability token that slipped into free-form assistant + * text anyway. + * + * Every pattern is deliberately bounded (character classes with a single `+` + * or a fixed `{20,}` lower bound, no nested quantifiers over overlapping + * classes) so matching stays linear and cannot catastrophically backtrack on + * adversarial input. The function is pure, never throws, and returns the input + * unchanged when nothing matches. + */ + +/** + * Active read/edit capability tokens are `cap.v3.` followed by a run of + * base64url / `.` / `-` characters. Bounded single-`+` run, so it is linear. + */ +const CAP_V3_TOKEN_RE = /cap\.v3\.[A-Za-z0-9._-]+/g + +/** + * `KEY=value` provider-secret env pairs. The key prefix is preserved and only + * the value (`\S+`, a bounded non-whitespace run) is redacted, so an operator + * still sees WHICH variable leaked without seeing its value. + */ +const ENV_KEY_PAIR_RE = + /(OPENROUTER_API_KEY|ANTHROPIC_API_KEY|OPENAI_API_KEY)=\S+/g + +/** + * Bare provider secrets by value shape. Ordered most-specific first so + * `sk-or-v1-...` and `sk-ant-...` win over the generic `sk-<20+>` fallback. + * Each alternative is a single bounded run over one character class. + */ +const PROVIDER_SECRET_RE = + /sk-or-v1-[A-Za-z0-9._-]+|sk-ant-[A-Za-z0-9._-]+|sk-[A-Za-z0-9]{20,}/g + +/** + * Redacts capability tokens and provider secrets from outbound text. Env-pair + * redaction runs before the bare-secret pass so the `KEY=` prefix is kept even + * when the value itself is a recognizable `sk-` secret. + */ +export function sanitizeOutbound(text: string): string { + if (text.length === 0) return text + return text + .replace(CAP_V3_TOKEN_RE, '[REDACTED_CAPABILITY]') + .replace(ENV_KEY_PAIR_RE, '$1=[REDACTED_SECRET]') + .replace(PROVIDER_SECRET_RE, '[REDACTED_SECRET]') +} diff --git a/sdk/src/serve/outbound.ts b/sdk/src/serve/outbound.ts new file mode 100644 index 0000000000..8635bc5d99 --- /dev/null +++ b/sdk/src/serve/outbound.ts @@ -0,0 +1,829 @@ +/** + * Outbound streaming guards for the ACP wire (P1-T1-DESIGN §12.8 NEW-3/NEW-4). + * + * NEW-3 (streaming holdback): a per-`(sessionId, messageId)` holdback buffer + * for `agent_message_chunk`/`agent_thought_chunk` text. It retains the + * trailing `max(longest configured credential length, 256)` RAW characters + * un-emitted, runs `sanitizeOutbound` over every emitted piece, and pulls the + * cut back ahead of any occurrence that would otherwise straddle the cut — so + * no single frame, and no per-messageId concatenation of frames, ever + * contains a configured credential value or a `cap.v3` token (GV-27). + * + * The host flushes on the next tool_call, at turn end, and on cancel via + * {@link OutboundHoldback.flush}/{@link OutboundHoldback.flushAll}, and + * drives the 250 ms idle flush via {@link OutboundHoldback.flushIdleSession} + * (from a timer it owns). Flushing emits everything held INCLUDING a tail-anchored + * partial (documented trade-off: a message whose final characters are the + * literal prefix of a secret — e.g. prose ending in `cap.v3.` — is emitted + * at turn end; progressive pushes never emit such a prefix). + * + * NEW-4 (chokepoint): {@link sanitizeOutboundStream} wraps the + * `WritableStream` handed to `ndJsonStream` so EVERY serialized frame — + * including SDK-generated JSON-RPC errors, agent→client requests, and + * `$/cancel_request` — passes through {@link sanitizeOutbound} before it + * hits the wire. Handler-level sanitization is an optimization only, never + * the guarantee (GV-28). + * + * §12.6 (outbound backpressure): {@link OutboundQueue} is a per-connection + * byte-capped (32 MiB) FIFO for serialized outbound frames. On overflow it + * first coalesces consecutive `agent_message_chunk`/`tool_call_update` + * frames that share an id (chunk text concatenates; a later update wins); + * when coalescing cannot bring the queue back under the cap it aborts the + * session's run with `stopReason: 'cancelled'` so memory stays bounded + * instead of letting a stalled reader grow the queue without limit. + * + * WIRING STATUS (§12.6): the queue is wired into the SOCKET transport — + * `createOutboundQueueWritable` in `./socket-listener` interposes a + * per-connection {@link OutboundQueue} between the sanitize chokepoint and + * the authenticated socket, so every outbound frame is enqueued and drained + * respecting the writable's backpressure (writes pause while + * `writable.write()` reports a full buffer and resume on 'drain'); an + * overflow-abort destroys the connection, which the existing + * owner-disconnect path turns into an in-flight-turn abort (§12.6 'run + * cancelled' at the transport level). stdio remains UNWIRED deliberately: + * it is a process-lifetime transport whose single peer is the host process + * reading stdout, so there is no per-connection backpressure boundary. + */ + +import { loadProviderConfigSync } from '../provider-config' +import { sanitizeOutbound } from './outbound-filter' + +import type { LoadedProviderConfig } from '../provider-config' + +/** + * Well-known credential-bearing env keys the holdback protects out of the + * box (§12.1 env-pair rule). Exported on the package surface (as + * WELL_KNOWN_CREDENTIAL_ENV_KEYS) so hosts can build a credential-only + * `credentialEnv` that keeps this exact holdback coverage. The provider + * configuration surface (providers.json `apiKeyEnv`, built-in presets) + * declares more; see {@link getConfiguredCredentialEnvKeys}. + */ +export const CREDENTIAL_ENV_KEYS = [ + 'OPENROUTER_API_KEY', + 'ANTHROPIC_API_KEY', + 'OPENAI_API_KEY', +] as const + +/** Floor on the holdback window (§12.8 NEW-3). */ +const MIN_HOLDBACK_CHARS = 256 + +/** + * Absolute cap on a single held window so memory stays bounded. Exported so + * the bounded-overflow regression test can size its fixtures; production + * callers never read it. + */ +export const MAX_HELD_WINDOW_CHARS = 64 * 1024 + +/** + * Collects the credential env keys the provider configuration surface + * declares (`apiKeyEnv` on openai-compatible / anthropic-compatible + * providers — the built-in presets and any custom providers.json entry). + * These are exactly the providers model routing can select, so their env + * values are exactly the configured credentials that can reach assistant + * text. The configuration is loaded through the SAME `env` the caller + * supplied (no `process.env` access here); pass `loadedConfig` explicitly + * to keep the call hermetic. + */ +export function getConfiguredCredentialEnvKeys( + env: Record, + loadedConfig?: LoadedProviderConfig, +): string[] { + const config = loadedConfig ?? loadProviderConfigSync({ env }) + const providers = config.config.providers ?? {} + const keys = new Set() + for (const provider of Object.values(providers)) { + if ( + (provider.type === 'openai-compatible' || + provider.type === 'anthropic-compatible') && + provider.apiKeyEnv + ) { + keys.add(provider.apiKeyEnv) + } + } + return Array.from(keys).sort() +} + +/** + * Resolves {@link getConfiguredCredentialEnvKeys} for the no-injection path, + * degrading to the well-known keys alone when the provider configuration + * cannot be loaded (e.g. an invalid OPENBUFF_PROVIDER_CONFIG override): a + * failed config load must never break serving, and the no-split invariant + * still covers every credential whose key is discoverable. + */ +function safeConfiguredCredentialEnvKeys( + env: Record, +): string[] { + try { + return getConfiguredCredentialEnvKeys(env) + } catch { + return [] + } +} + +/** + * Collects the configured credential VALUES the holdback must never split: + * for every credential-bearing env key present (and non-empty) in `env` — + * the well-known provider keys plus every key the provider configuration + * declares (providers.json `apiKeyEnv`, built-in presets) — the env-pair + * text `KEY=` plus the bare value itself. Values come ONLY from the + * caller-supplied `env`; the configured key NAMES default to the provider + * configuration surface unless `configuredCredentialEnvKeys` is supplied + * explicitly (which keeps the call hermetic). Pure when the key list is + * supplied; the default key resolution performs the provider config read the + * host's own model routing performs anyway, and only trusted config surfaces + * (project / global / explicit override) contribute key names. + */ +export function collectCredentialValues( + env: Record, + configuredCredentialEnvKeys?: readonly string[], +): string[] { + const keys = [ + ...CREDENTIAL_ENV_KEYS, + ...(configuredCredentialEnvKeys ?? safeConfiguredCredentialEnvKeys(env)), + ] + const values: string[] = [] + const seen = new Set() + for (const key of keys) { + if (seen.has(key)) continue + seen.add(key) + const value = env[key] + if (typeof value === 'string' && value.length > 0) { + values.push(`${key}=${value}`, value) + } + } + return values +} + +/** + * The holdback window size for a set of configured credentials (§12.8 + * NEW-3): `max(longest configured credential length, 256)`. + */ +export function holdbackSizeFor(credentials: string[]): number { + let longest = 0 + for (const value of credentials) { + if (value.length > longest) longest = value.length + } + return Math.max(longest, MIN_HOLDBACK_CHARS) +} + +/** A held streaming window for one `(sessionId, messageId)` pair. */ +type HoldbackWindow = { + /** Raw text still held (the trailing window; unsanitized). */ + held: string + /** Immutable credential list the window was built with. */ + credentials: string[] + /** Immutable holdback size for `credentials`. */ + holdback: number + /** Host-supplied monotonic `now` of the last push/flush touch. */ + lastActivity: number +} + +/** + * Per-`(sessionId, messageId)` streaming holdback (§12.8 NEW-3, GV-27). + * One instance serves the whole agent process. + * + * `push` retains the trailing holdback window raw and emits only the prefix + * that can never straddle a protected value; the tail stays held until a + * flush or a later push releases it. `flush`/`flushAll`/`flushIdle` emit + * what remains. The no-split invariant holds regardless of + * `sanitizeOutbound`'s length-changing replacements, because every emitted + * piece is a contiguous RAW slice whose boundaries were verified before the + * redaction pass. + */ +export class OutboundHoldback { + private readonly windows = new Map() + + /** + * Pushes one streaming chunk for `(sessionId, messageId)`. Returns the + * pieces safe to emit NOW, in order (possibly empty). The first push for a + * key freezes the credential list for that window. + */ + push( + sessionId: string, + messageId: string, + chunk: string, + credentials: string[], + now: number, + ): string[] { + const key = `${sessionId}\u0000${messageId}` + let window = this.windows.get(key) + if (window === undefined) { + window = { + held: '', + credentials, + holdback: holdbackSizeFor(credentials), + lastActivity: now, + } + this.windows.set(key, window) + } + window.lastActivity = now + window.held += chunk + + // Bound pathological non-flushing windows: force the oldest text out as + // if a flush had arrived — but through the SAME safe-cut check as a + // progressive push, so a configured credential or cap.v3 token straddling + // the overflow cut is never split across two frames (GV-27 no-split + // invariant holds on the overflow path too). If no safe cut exists the + // blocked run is discarded (see below) so the window stays bounded. + if (window.held.length > MAX_HELD_WINDOW_CHARS) { + const overflowCut = window.held.length - window.holdback + const safe = this.pullCutBack(window, overflowCut) + if (safe === undefined) { + // Fail closed AND bounded: `undefined` means a protected value + // anchored at index 0 blocks every cut (e.g. a cap.v3 token run or a + // credential spanning the whole window). Keeping the bytes would let + // `held` grow without bound as later chunks append — and make every + // push rescan the whole window — so the blocked run is DISCARDED + // instead. Nothing of it ever crossed the wire, so no protected value + // is split across frames (GV-27) and no partial is emitted. + window.held = '' + return [] + } + const overflow = window.held.slice(0, safe) + window.held = window.held.slice(safe) + return this.emitSlice(window, overflow) + } + + if (window.held.length <= window.holdback) { + // Everything is inside the holdback window: emit nothing yet. + return [] + } + + const cut = window.held.length - window.holdback + const safe = this.pullCutBack(window, cut) + if (safe === undefined) { + // No safe cut exists yet: everything stays held. + return [] + } + const emitted = window.held.slice(0, safe) + window.held = window.held.slice(safe) + return this.emitSlice(window, emitted) + } + + /** + * Flushes one window (turn end / cancel / tool_call for its message). + * Returns the sanitized remainder (possibly empty) and clears the window. + * A tail-anchored partial prefix is emitted here (documented trade-off). + */ + flush(sessionId: string, messageId: string, now?: number): string[] { + const key = `${sessionId}\u0000${messageId}` + const window = this.windows.get(key) + if (window === undefined) return [] + if (now !== undefined) window.lastActivity = now + this.windows.delete(key) + if (window.held.length === 0) return [] + return this.emitSlice(window, window.held) + } + + /** + * Flushes every window OWNED BY one session (turn end / cancel), in + * per-key insertion order. Session-scoped by design: a concurrent turn for + * a DIFFERENT session sharing this holdback instance (one bridge instance + * serves every socket connection) must never have its held text released + * through this turn's sink — cross-session routing is the reviewer finding + * this method exists to close. + */ + flushSession(sessionId: string, now?: number): string[] { + const prefix = `${sessionId}\u0000` + const pieces: string[] = [] + for (const [key, window] of [...this.windows]) { + if (!key.startsWith(prefix)) continue + if (now !== undefined) window.lastActivity = now + this.windows.delete(key) + if (window.held.length > 0) { + pieces.push(...this.emitSlice(window, window.held)) + } + void key + } + return pieces + } + + /** + * Flushes every held window regardless of session. RESERVED for whole- + * process teardown only — never call this from a per-turn path. + */ + flushAll(now?: number): string[] { + const pieces: string[] = [] + for (const [key, window] of [...this.windows]) { + if (now !== undefined) window.lastActivity = now + this.windows.delete(key) + if (window.held.length > 0) { + pieces.push(...this.emitSlice(window, window.held)) + } + void key + } + return pieces + } + + /** + * Flushes windows OWNED BY one session idle for at least `idleMs` + * (§12.8: 250 ms idle flush). Session-scoped for the same reason as + * {@link flushSession}: the host's per-turn idle timer must only release + * the OWNING session's windows, never another session's held text. + */ + flushIdleSession( + sessionId: string, + now: number, + idleMs: number, + ): string[] { + const prefix = `${sessionId}\u0000` + const pieces: string[] = [] + for (const [key, window] of [...this.windows]) { + if (!key.startsWith(prefix)) continue + if (now - window.lastActivity >= idleMs) { + this.windows.delete(key) + if (window.held.length > 0) { + pieces.push(...this.emitSlice(window, window.held)) + } + } + void key + } + return pieces + } + + /** + * Pulls the intended cut position back to the latest position where the + * emitted prefix `held.slice(0, safe)` contains no PARTIAL protected + * value: every full occurrence either ends at or before `safe` or starts + * at or after `safe`, and no tail-anchored partial prefix (which may + * complete in a future chunk) is emitted. Returns the safe cut, or + * `undefined` when the whole window must stay held. + * + * Algorithm (bounded, deterministic — GV-27 / RF-11): + * 1. Collect the boundaries of every full occurrence in the held window: + * each configured credential and every full cap.v3 token (anchor plus + * its trailing token-run characters). + * 2. Compute the tail-hold ceiling: the earliest start of a tail-anchored + * PARTIAL prefix. For credentials a full occurrence at the tail is + * complete (redactable as a whole), so only proper prefixes count; for + * the cap.v3 anchor even a full anchor match at the tail may complete + * in the next chunk, so it holds too. + * 3. Walk candidates downward from `min(cut, tailHold)`: while some full + * occurrence straddles the candidate, jump the candidate to the largest + * straddling occurrence's START (strictly decreasing, so this + * terminates, and the result is the LARGEST valid cut at or below the + * ceiling). A candidate of 0 means no safe cut exists. + */ + private pullCutBack( + window: HoldbackWindow, + cut: number, + ): number | undefined { + const held = window.held + type Occurrence = { start: number; end: number } + const occurrences: Occurrence[] = [] + for (const value of window.credentials) { + if (value.length === 0) continue + let searchFrom = 0 + for (;;) { + const idx = held.indexOf(value, searchFrom) + if (idx === -1) break + searchFrom = idx + 1 + occurrences.push({ start: idx, end: idx + value.length }) + } + } + // cap.v3 token family (a value shape, not a literal): full tokens are + // the anchor plus its trailing token-run characters. + const TOKEN_ANCHOR = 'cap.v3.' + const isTokenRunChar = (ch: string): boolean => /[A-Za-z0-9._-]/.test(ch) + let tokenSearchFrom = 0 + for (;;) { + const idx = held.indexOf(TOKEN_ANCHOR, tokenSearchFrom) + if (idx === -1) break + tokenSearchFrom = idx + 1 + let end = idx + TOKEN_ANCHOR.length + for (;;) { + const ch = held[end] + if (ch === undefined || !isTokenRunChar(ch)) break + end += 1 + } + occurrences.push({ start: idx, end }) + } + + // Tail-hold ceiling: the earliest start among tail-anchored PARTIAL + // prefixes. The longest matching prefix gives the earliest start, so the + // scan stops at the first (longest) match per value. + let tailHold: number | undefined + const considerTail = (value: string, includeFull: boolean): void => { + const maxPrefix = includeFull + ? Math.min(value.length, held.length) + : Math.min(value.length - 1, held.length) + for (let prefixLength = maxPrefix; prefixLength >= 1; prefixLength -= 1) { + if (held.endsWith(value.slice(0, prefixLength))) { + const start = held.length - prefixLength + if (tailHold === undefined || start < tailHold) tailHold = start + return + } + } + } + for (const value of window.credentials) { + if (value.length > 0) considerTail(value, false) + } + considerTail('cap.v3.1.', true) + considerTail('cap.v3.', true) + + let candidate = tailHold !== undefined ? Math.min(cut, tailHold) : cut + if (candidate <= 0) return undefined + for (;;) { + let blocked: Occurrence | undefined + for (const occurrence of occurrences) { + if ( + occurrence.start < candidate && + occurrence.end > candidate && + (blocked === undefined || occurrence.start > blocked.start) + ) { + blocked = occurrence + } + } + if (blocked === undefined) break + candidate = blocked.start + if (candidate <= 0) return undefined + } + return candidate + } + + /** + * Sanitizes one contiguous RAW slice and returns it as a single piece. + * Slices are chosen before this call so a protected value can never be + * split across two pieces; the redaction itself stays whole per slice. + */ + private emitSlice(window: HoldbackWindow, text: string): string[] { + if (text.length === 0) return [] + // Configured credentials are redacted by VALUE, not only by shape: a + // value with no recognizable provider-secret shape must never cross the + // wire either. Value-redaction cannot be defeated by a split because + // every slice boundary was verified (pullCutBack) to never cut inside a + // credential, so any slice containing part of a credential contains the + // whole credential (GV-27 / RF-11). + let sanitized = text + for (const value of window.credentials) { + if (value.length === 0) continue + sanitized = sanitized.split(value).join('[REDACTED_SECRET]') + } + return [sanitizeOutbound(sanitized)] + } +} + +/** + * NEW-4 chokepoint (§12.8, GV-28): wraps the WritableStream handed to + * `ndJsonStream` so every serialized frame passes through + * {@link sanitizeOutbound} before the wire. A chunk that contains no + * redactable text is forwarded byte-identical; a redacted chunk is re-encoded + * as UTF-8 through the wrapped target. SDK-generated JSON-RPC errors, + * agent→client requests, and `$/cancel_request` all cross this wrapper. + */ +export function sanitizeOutboundStream( + target: WritableStream, +): WritableStream { + const decoder = new TextDecoder('utf8', { fatal: false }) + const encoder = new TextEncoder() + return new WritableStream({ + async write(chunk) { + const frame = decoder.decode(chunk, { stream: true }) + const sanitized = sanitizeOutbound(frame) + if (sanitized === frame) { + // No redaction applies: forward the original bytes unchanged. + await writeThrough(target, chunk) + return + } + await writeThrough(target, encoder.encode(sanitized)) + }, + async close() { + // Flush the streaming decoder's tail before closing: a frame whose final + // bytes are an incomplete UTF-8 sequence is held by `stream: true` + // decoding and must still be sanitized and written before the wire ends. + const tail = decoder.decode() + if (tail.length > 0) { + await writeThrough(target, encoder.encode(sanitizeOutbound(tail))) + } + return closeThrough(target) + }, + abort(reason) { + return abortThrough(target, reason) + }, + }) +} + +async function writeThrough( + target: WritableStream, + chunk: Uint8Array, +): Promise { + const writer = target.getWriter() + try { + await writer.write(chunk) + } finally { + // Release WITHOUT closing: the writer is a per-call borrow, and the + // target stream itself stays open for the next frame. + writer.releaseLock() + } +} + +async function closeThrough(target: WritableStream): Promise { + const writer = target.getWriter() + try { + await writer.close() + } catch { + // The target may already be closed by its owner; closing is best-effort. + } finally { + writer.releaseLock() + } +} + +async function abortThrough( + target: WritableStream, + reason: unknown, +): Promise { + const writer = target.getWriter() + try { + await writer.abort(reason) + } catch { + // Best-effort: the target may already be errored/closed. + } finally { + writer.releaseLock() + } +} + +// --------------------------------------------------------------------------- +// §12.6 outbound backpressure (per-connection byte-capped queue) +// --------------------------------------------------------------------------- + +/** §12.6: a connection's outbound queue is capped at 32 MiB. */ +export const OUTBOUND_QUEUE_MAX_BYTES = 32 * 1024 * 1024 + +/** `sessionUpdate` kinds whose frames coalesce by concatenating `content.text`. */ +const CHUNK_TEXT_KINDS = new Set(['agent_message_chunk', 'agent_thought_chunk']) + +function isPlainRecordFrame(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +/** Returns the record actually holding the session/update payload fields. */ +function frameContainer(frame: Record): Record { + if (typeof frame.sessionUpdate === 'string') return frame + const params = frame.params + if (isPlainRecordFrame(params)) { + if (isPlainRecordFrame(params.update)) return params.update + return params + } + return frame +} + +/** Replaces the payload-holding container of `frame` with `merged`. */ +function replaceContainer( + frame: Record, + merged: Record, +): void { + if (typeof frame.sessionUpdate === 'string') { + for (const key of Object.keys(frame)) delete frame[key] + Object.assign(frame, merged) + return + } + const params = frame.params + if (isPlainRecordFrame(params)) { + if (isPlainRecordFrame(params.update)) { + params.update = merged + } else { + frame.params = merged + } + return + } + for (const key of Object.keys(frame)) delete frame[key] + Object.assign(frame, merged) +} + +/** + * The stable identity tying consecutive coalesceable frames together: + * `messageId` for chunk frames, `toolCallId` for `tool_call_update`. Returns + * `undefined` for a non-coalesceable frame. + */ +function coalesceIdOf(frame: Record): string | undefined { + const container = frameContainer(frame) + const kind = container.sessionUpdate + if (typeof kind === 'string' && CHUNK_TEXT_KINDS.has(kind)) { + const id = container.messageId + return typeof id === 'string' && id.length > 0 ? id : undefined + } + if (kind === 'tool_call_update') { + const id = container.toolCallId + return typeof id === 'string' && id.length > 0 ? id : undefined + } + return undefined +} + +/** + * Merges `incoming` into the queued `existing` frame in place. Two chunk + * frames for the same `messageId` concatenate their `content.text`. Two + * `tool_call_update` frames for the same `toolCallId` keep the LATER frame's + * fields (a status transition supersedes the earlier one) while concatenating + * any `content` arrays so no emitted block is lost. Returns whether a merge + * happened; the two frames must share a `sessionUpdate` kind and an id. + */ +function coalesceFrames( + existing: Record, + incoming: Record, +): boolean { + const existingId = coalesceIdOf(existing) + const incomingId = coalesceIdOf(incoming) + if (existingId === undefined || existingId !== incomingId) return false + const existingContainer = frameContainer(existing) + const incomingContainer = frameContainer(incoming) + const kind = existingContainer.sessionUpdate + if (kind !== incomingContainer.sessionUpdate) return false + + if (typeof kind === 'string' && CHUNK_TEXT_KINDS.has(kind)) { + const existingContent = existingContainer.content + const incomingContent = incomingContainer.content + if ( + isPlainRecordFrame(existingContent) && + isPlainRecordFrame(incomingContent) && + typeof existingContent.text === 'string' && + typeof incomingContent.text === 'string' + ) { + existingContent.text = + (existingContent.text as string) + (incomingContent.text as string) + return true + } + return false + } + + // tool_call_update: the later frame's scalar fields supersede the earlier + // one; content arrays concatenate so no emitted block is lost. + const merged: Record = { ...existingContainer } + for (const [key, value] of Object.entries(incomingContainer)) { + if (key === 'content') continue + merged[key] = value + } + const existingContent = existingContainer.content + const incomingContent = incomingContainer.content + if (Array.isArray(existingContent) || Array.isArray(incomingContent)) { + merged.content = [ + ...(Array.isArray(existingContent) ? existingContent : []), + ...(Array.isArray(incomingContent) ? incomingContent : []), + ] + } + replaceContainer(existing, merged) + return true +} + +/** The run-abort decision returned by an overflow that coalescing cannot fix. */ +export type OutboundQueueAbort = { + stopReason: 'cancelled' + sessionId: string | undefined +} + +export type OutboundEnqueueResult = + | { status: 'queued' } + | { status: 'coalesced' } + | { status: 'abort'; abort: OutboundQueueAbort } + +type QueueEntry = { + frame: Record + bytes: number + sessionId: string | undefined +} + +/** + * §12.6 outbound backpressure queue (per connection). Frames are enqueued as + * already-sanitized serializable objects; the queue tracks their serialized + * UTF-8 byte size against {@link OUTBOUND_QUEUE_MAX_BYTES}. + * + * WIRED (§12.6): the SOCKET transport routes every outbound NDJSON frame + * through one instance of this queue per connection + * (`createOutboundQueueWritable` in `./socket-listener`): frames enqueue, + * coalesce, and drain to the socket respecting the writable's backpressure, + * and the `abort` enqueue decision destroys the connection (fail closed; + * the existing owner-disconnect path aborts the in-flight turn and cancels + * pending reverse requests, realizing §12.6's 'run cancelled' semantics). + * stdio is NOT wired — it is a process-lifetime transport whose single peer + * is the host process reading stdout, so there is no per-connection + * backpressure boundary to protect. + * Overflow handling is two-stage, in the order §12.6 prescribes: + * 1. Coalesce: walk the queue and merge consecutive + * `agent_message_chunk`/`tool_call_update` frames that share an id. This + * shrinks the queue without dropping any text or terminal status. + * 2. Abort: if the queue is STILL over the cap after coalescing, the run + * producing the flood is aborted with `stopReason: 'cancelled'` and the + * frame is dropped, so a stalled reader can never grow memory without + * bound. The abort decision is returned for the host to act on (the queue + * itself owns no run lifecycle). + */ +export class OutboundQueue { + private readonly maxBytes: number + private readonly entries: QueueEntry[] = [] + private totalBytes = 0 + + constructor(maxBytes: number = OUTBOUND_QUEUE_MAX_BYTES) { + this.maxBytes = maxBytes + } + + /** Current queued byte total (serialized UTF-8). */ + get byteLength(): number { + return this.totalBytes + } + + /** Number of queued frames. */ + get size(): number { + return this.entries.length + } + + /** Extracts the sessionId a frame belongs to, when one is present. */ + private sessionIdOf(frame: Record): string | undefined { + if (typeof frame.sessionId === 'string') return frame.sessionId + const params = frame.params + if (isPlainRecordFrame(params) && typeof params.sessionId === 'string') { + return params.sessionId + } + return undefined + } + + private byteSizeOf(frame: Record): number { + return new TextEncoder().encode(JSON.stringify(frame)).byteLength + } + + /** + * Enqueues one frame. Coalesces into the immediately-preceding queued frame + * when both are coalesceable and share an id. On overflow, coalesces the + * whole queue; when that is not enough, returns the abort decision instead + * of queueing (the run is cancelled and the frame dropped — fail closed). + */ + enqueue(frame: Record): OutboundEnqueueResult { + const sessionId = this.sessionIdOf(frame) + + // Fast path: merge into the tail when it is the same coalesceable stream. + const tail = this.entries[this.entries.length - 1] + if (tail !== undefined && coalesceFrames(tail.frame, frame)) { + const resized = this.byteSizeOf(tail.frame) + this.totalBytes += resized - tail.bytes + tail.bytes = resized + if (this.totalBytes <= this.maxBytes) return { status: 'coalesced' } + } else { + const bytes = this.byteSizeOf(frame) + this.entries.push({ frame, bytes, sessionId }) + this.totalBytes += bytes + if (this.totalBytes <= this.maxBytes) return { status: 'queued' } + } + + // Overflow: coalesce every consecutive mergeable run, then re-measure. + this.coalesceAll() + if (this.totalBytes <= this.maxBytes) { + return { status: 'coalesced' } + } + + // Still over the cap: abort the run rather than grow without bound. + this.removeLast() + return { + status: 'abort', + abort: { stopReason: 'cancelled', sessionId }, + } + } + + /** Removes and returns the oldest queued frame, or `undefined` when empty. */ + dequeue(): Record | undefined { + const entry = this.entries.shift() + if (entry === undefined) return undefined + this.totalBytes -= entry.bytes + return entry.frame + } + + /** Drains every queued frame in FIFO order. */ + drain(): Array> { + const out: Array> = [] + let frame = this.dequeue() + while (frame !== undefined) { + out.push(frame) + frame = this.dequeue() + } + return out + } + + /** Clears the queue (teardown / after an abort). */ + clear(): void { + this.entries.length = 0 + this.totalBytes = 0 + } + + private removeLast(): void { + const entry = this.entries.pop() + if (entry !== undefined) this.totalBytes -= entry.bytes + } + + /** Merges every run of consecutive coalesceable frames sharing an id. */ + private coalesceAll(): void { + if (this.entries.length < 2) return + const merged: QueueEntry[] = [] + let total = 0 + for (const entry of this.entries) { + const tail = merged[merged.length - 1] + if (tail !== undefined && coalesceFrames(tail.frame, entry.frame)) { + const resized = this.byteSizeOf(tail.frame) + total += resized - tail.bytes + tail.bytes = resized + continue + } + merged.push(entry) + total += entry.bytes + } + this.entries.length = 0 + this.entries.push(...merged) + this.totalBytes = total + } +} diff --git a/sdk/src/serve/serve.ts b/sdk/src/serve/serve.ts new file mode 100644 index 0000000000..b70e4adfdb --- /dev/null +++ b/sdk/src/serve/serve.ts @@ -0,0 +1,157 @@ +import { createServeBridge } from './bridge' +import { serveAcpOverSocket } from './socket-listener' +import { collectCredentialValues } from './outbound' +import { serveAcpOverStdio } from '../services/acp/acp-agent' + +import type { ServeBridgeClient } from './bridge' +import type { AcpSessionData } from '../services/acp/session-data' +import type { Logger } from '@codebuff/common/types/contracts/logger' + +/** + * Programmatic entry the `openbuff serve` CLI invokes. It wires the Wave-1 + * serve bridge (`createServeBridge`) to the chosen transport: + * - `stdio` (default): serves ACP over the process stdio streams; the + * transport's lifetime IS the process, so `close` is a no-op. + * - `socket`: serves ACP over a SEC-4-authenticated unix domain socket and + * returns that listener's real `close`. + * + * The CLI owns the pieces this layer never constructs: the real + * `OpenbuffClient`, a journal-backed `AcpSessionData`, and the socket token. + * They arrive through options — this layer never reads `process.env`. + */ +export type RunServeOptions = { + /** Structurally-typed run seam bound to the real OpenbuffClient by the CLI. */ + client: ServeBridgeClient + /** Live per-session store backing the read-only ACP extension methods. */ + sessionData: AcpSessionData + /** Which Openbuff agent each prompt turn runs in the bridge. Defaults to 'base'. */ + agentId?: string + /** + * NEW-3 (§12.8): the environment the host runs with, consulted ONLY for + * the configured credential env keys the streaming holdback must never + * split. Collected with `collectCredentialValues` and handed to the serve + * bridge as `credentialValues`; omitted → no value-level holdback (the + * 256-char cap.v3 floor still applies). This layer never reads + * `process.env` itself — the host passes the environment in. + */ + credentialEnv?: Record + /** Transport selection: stdio (default) or a unix domain socket. NO TCP. */ + transport: + | { kind: 'stdio' } + | { kind: 'socket'; socketPath: string; token: string } + /** Optional structured logger forwarded into the bridge. */ + logger?: Logger + /** Aborts the socket transport (ignored by stdio, whose lifetime is the process). */ + signal?: AbortSignal + /** + * SEC-7 (§12.5) containment anchor: the project root `session/new` / + * `session/load` cwd values must be absolute and inside (after symlink + * dereference), enforced by `createAcpAgent` on BOTH transports. When + * supplied, it is bound once at serve start and also threaded into the + * serve bridge so `tool_call` locations resolve against it. When omitted, + * runServe logs a warning through the injected logger and defaults the + * containment root to `process.cwd()` — never silently permissive. + */ + projectRoot?: string + /** + * SEC-7 allowlist for client-supplied `additionalDirectories` entries + * (`--add-dir`), resolved to absolute form by the host. An entry not in + * this list is rejected (-32602), never silently admitted. + */ + allowedAdditionalDirectories?: string[] + /** + * GV-18 opt-in (additive, default false): when set, the SOCKET transport + * accepts client-supplied `mcpServers` in `session/new` like stdio does. + * Unset (the default) keeps client MCP servers DISABLED on the socket + * transport — a session/new carrying mcpServers is rejected -32602 with + * data['openbuff.dev'].code = 'client_mcp_disabled' before any process is + * spawned. stdio is unaffected: it keeps today's permissive behavior. + */ + allowClientMcp?: boolean +} + +/** + * Builds the serve bridge and starts the selected transport. + * + * @returns `{ close }` — a no-op for stdio (process-scoped), or the socket + * listener's idempotent close for socket mode. + */ +export function runServe(options: RunServeOptions): { + close: () => Promise +} { + const { client, sessionData, transport, logger, signal, agentId } = options + // SEC-7 (audit HIGH #1): the containment decision is made EXACTLY ONCE, + // here at serve start — never per connection (the socket transport resolves + // paths late, so a per-connection decision could drift). An explicitly + // supplied projectRoot binds the boundary; an omitted one fails LOUDLY (a + // warning through the injected logger) and defaults to process.cwd() + // instead of silently degrading to the pre-SEC-7 permissive posture. + const containmentRoot = resolveServeContainmentRoot({ + projectRoot: options.projectRoot, + logger, + }) + const { promptHandler } = createServeBridge({ + client, + sessionData, + logger, + agentId, + // When the host supplied a project root, the bridge also resolves + // `tool_call` locations against it; omitted keeps locations relative + // (the bridge's own default), so only the containment boundary changes. + projectRoot: options.projectRoot, + // NEW-3 (§12.8): collect the configured credential VALUES from the + // host-supplied environment so the streaming holdback can enforce the + // no-split invariant for real credentials (an empty environment yields + // the bridge's default empty list). + credentialValues: collectCredentialValues(options.credentialEnv ?? {}), + }) + // Both transports spread these into AcpAgentOptions, where createAcpAgent + // enforces SEC-7 cwd containment and the additionalDirectories allowlist + // on session/new and session/load. + const containmentOptions = { + projectRoot: containmentRoot, + allowedAdditionalDirectories: options.allowedAdditionalDirectories, + } + + if (transport.kind === 'stdio') { + // serveAcpOverStdio applies resolveAcpServeOptions internally (confirmed in + // acp-agent.ts), so the journal-backed session/load restore is wired. + // stdio keeps the PERMISSIVE GV-18 posture: allowClientMcpServers is left + // unset so client-supplied mcpServers are still accepted (the default). + serveAcpOverStdio({ promptHandler, sessionData, ...containmentOptions }) + return { close: async () => {} } + } + + // GV-18: the socket transport REFUSES client-supplied mcpServers unless + // the host explicitly opted in with allowClientMcp. + return serveAcpOverSocket({ + promptHandler, + sessionData, + socketPath: transport.socketPath, + token: transport.token, + signal, + ...containmentOptions, + allowClientMcpServers: options.allowClientMcp === true, + }) +} + +/** + * Resolves the SEC-7 containment root for one {@link runServe} call — the + * decision is made exactly once, at serve start. An explicit `projectRoot` + * binds the boundary to that root; an omitted one logs a warning through the + * injected logger and defaults to `process.cwd()`, so containment is never + * silently permissive. + */ +function resolveServeContainmentRoot(options: { + projectRoot?: string + logger?: Logger +}): string { + if (options.projectRoot !== undefined) return options.projectRoot + // No raw host paths in the payload: this warning may be shared, and the + // codebase convention keeps raw paths out of shared logs. + options.logger?.warn( + {}, + 'runServe: no projectRoot was supplied; defaulting the SEC-7 containment root to process.cwd(). Pass projectRoot explicitly to bind containment to the served project.', + ) + return process.cwd() +} diff --git a/sdk/src/serve/socket-listener.ts b/sdk/src/serve/socket-listener.ts new file mode 100644 index 0000000000..74eb287b5a --- /dev/null +++ b/sdk/src/serve/socket-listener.ts @@ -0,0 +1,511 @@ +import { timingSafeEqual } from 'node:crypto' +import { + chmodSync, + closeSync, + lstatSync, + openSync, + statSync, + unlinkSync, +} from 'node:fs' +import { createServer } from 'node:net' +import type { Socket } from 'node:net' +import { dirname } from 'node:path' +import { Readable } from 'node:stream' + +import { AgentSideConnection, ndJsonStream } from '@agentclientprotocol/sdk' + +import { + createAcpAgent, + resolveAcpServeOptions, +} from '../services/acp/acp-agent' +import type { AcpAgentOptions } from '../services/acp/acp-agent' +import { + OutboundQueue, + OUTBOUND_QUEUE_MAX_BYTES, + sanitizeOutboundStream, +} from './outbound' + +/** + * Options for {@link serveAcpOverSocket}. Everything the ACP agent needs + * (`promptHandler`, `sessionData`, `loadHandler`, ...) rides through the + * `Omit` spread — the per-connection + * `connection` is minted inside this transport. `socketPath`/`token` are the + * unix-socket transport surface; the callbacks are observability seams the + * CLI/tests bind. + */ +export type ServeAcpOverSocketOptions = Omit & { + /** Absolute path of the unix domain socket to bind. NEVER a TCP address. */ + socketPath: string + /** + * Shared secret every client must present as its first line (see SEC-4 + * below). Compared in constant time; never logged. + */ + token: string + /** Fired once the socket is bound and chmod'd 0o600. */ + onListening?: (info: { socketPath: string }) => void + /** + * Fired when a connection is rejected. Receives a reason STRING only + * ('timeout' | 'bad-token' | 'auth-line-too-long') — never the token. + */ + onAuthFailure?: (reason: string) => void + /** Closes the server when aborted. */ + signal?: AbortSignal + /** + * Grace window (ms) for a connection to send its auth line before it is + * destroyed. Defaults to 5000; tests inject a short value. + */ + authTimeoutMs?: number +} + +/** + * Hard cap on the buffered auth line before the newline arrives. A client that + * never sends a newline is already bounded in TIME by the auth timeout; this + * bounds it in SPACE so a same-uid peer cannot force unbounded buffer growth + * inside the grace window. + */ +const MAX_AUTH_LINE_BYTES = 64 * 1024 + +/** + * Serves the Wave-1 ACP agent over a unix domain socket, honoring + * P1-T1-DESIGN §SEC-4 socket auth. UNIX-SOCKET ONLY — there is no TCP listener + * anywhere in this module by design. + * + * SEC-4 layered auth model: + * 1. OS-credential layer. The parent directory is asserted safe + * ({@link assertSocketDirSafe}): it must exist, be a real directory (not a + * symlink), be owned by the current euid, and carry no group/other write + * bits. The socket itself is chmod'd `0o600` synchronously immediately + * after the bind, BEFORE any accept callback can run (no bind→chmod + * connectability window). Together the + * owner-only directory + `0o600` socket mean ONLY the same uid can even + * `connect()` — this is the SO_PEERCRED-equivalent guard, since node does + * not expose the peer uid directly. + * 2. Token layer. Because a DIFFERENT process running as the SAME uid could + * still connect, every connection must present a shared secret as its + * first line before any ACP framing. The token defends against that + * same-uid peer. + * + * Stale-socket handling takes an exclusive `${socketPath}.lock` first + * (`openSync(..., 'wx')`); only while holding the lock is a pre-existing + * SOCKET at `socketPath` unlinked (a non-socket path is refused). The lock is + * released/unlinked on close, and also if binding fails, so a crash cannot + * strand it. + * + * Auth-line/stream boundary contract (documented so the client wave matches): + * the client sends exactly one JSON line + * `{"type":"openbuff.serve.auth","token":""}\n` and then IMMEDIATELY + * begins ACP ndJson framing on the SAME socket. The server buffers bytes until + * the first `\n`, validates the token in constant time, unshifts any bytes + * that arrived after the newline back onto the socket, and only then hands the + * socket to `ndJsonStream` + a per-connection `AgentSideConnection` exactly + * like `serveAcpOverStdio`. No ack byte is sent — a valid token is silently + * followed by ACP framing. + * + * @returns `{ close }` — closes the server, unlinks the socket + lock, and is + * idempotent. Closes automatically if `options.signal` aborts. + */ +export function serveAcpOverSocket(options: ServeAcpOverSocketOptions): { + close: () => Promise +} { + const { + socketPath, + token, + onListening, + onAuthFailure, + signal, + authTimeoutMs = 5000, + ...agentOptions + } = options + + // SEC-4 step 1: refuse an unsafe parent directory (and an unsupported + // platform) BEFORE binding or touching the lock. + assertSocketDirSafe(socketPath) + + const lockPath = `${socketPath}.lock` + // SEC-4 step 2: take an exclusive lock. 'wx' fails if the lock already + // exists — another server may own this socket, so refuse rather than race. + let lockFd: number + try { + lockFd = openSync(lockPath, 'wx') + } catch { + throw new Error( + `refusing to serve: lock file already exists (another server may own it): ${lockPath}`, + ) + } + + let lockReleased = false + const releaseLock = (): void => { + if (lockReleased) return + lockReleased = true + try { + closeSync(lockFd) + } catch {} + try { + unlinkSync(lockPath) + } catch {} + } + + try { + // SEC-4 step 3: only while HOLDING the lock, remove a stale socket. A path + // that exists but is NOT a socket is refused (never blindly unlinked). + const existing = lstatSync(socketPath, { throwIfNoEntry: false }) + if (existing) { + if (!existing.isSocket()) { + throw new Error( + `refusing to serve: path exists and is not a socket: ${socketPath}`, + ) + } + unlinkSync(socketPath) + } + + // Auto-derive the session/load restore handler once, then reuse it for + // every connection's agent (mirrors serveAcpOverStdio). + const resolvedOptions = resolveAcpServeOptions(agentOptions) + + const server = createServer((socket) => { + handleConnection(socket, token, authTimeoutMs, resolvedOptions, onAuthFailure) + }) + + // SEC-4 step 4: bind, then chmod 0o600 so ONLY the owner uid can connect. + // The chmod runs SYNCHRONOUSLY right after listen() returns, before any + // event-loop turn: node binds a unix socket inside the listen() call + // (uv_pipe_bind creates the socket inode), while the 'listening' and + // connection callbacks only run on a later turn. chmod'ing in the + // listening callback left a bind→chmod window in which the inode existed + // with the process-default mode and a same-uid peer could connect() + // before the mode was tightened. A failed bind leaves no inode (ENOENT): + // the real bind error then surfaces through the server 'error' handler + // below; any OTHER chmod failure is fail-loud. + server.listen(socketPath, () => { + onListening?.({ socketPath }) + }) + try { + chmodSync(socketPath, 0o600) + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { + throw err + } + } + + let closed = false + const onAbort = (): void => { + void close() + } + const close = async (): Promise => { + if (closed) return + closed = true + if (signal) signal.removeEventListener('abort', onAbort) + await new Promise((resolve) => server.close(() => resolve())) + try { + unlinkSync(socketPath) + } catch {} + releaseLock() + } + + // A late bind error (e.g. the socket path became unusable) must not strand + // the lock: release it so a retry is not blocked by our own stale lock. + server.on('error', () => { + releaseLock() + }) + + if (signal) { + if (signal.aborted) void close() + else signal.addEventListener('abort', onAbort) + } + + return { close } + } catch (err) { + // A failed stale-socket check / bind setup must not leave a stale lock. + releaseLock() + throw err + } +} + +/** + * SEC-4 OS-credential precondition. The socket's PARENT directory must exist, + * be a real directory (not a symlink), be owned by the current euid, and carry + * no group/other write bits — so no other user can drop a socket or hijack the + * path. `process.geteuid`/`getuid` are absent on Windows; without them the + * OS-credential layer cannot be enforced, so unix-socket serving is refused + * LOUDLY rather than silently degrading. + */ +export function assertSocketDirSafe(socketPath: string): void { + if ( + typeof process.geteuid !== 'function' || + typeof process.getuid !== 'function' + ) { + throw new Error('unix socket serving is not supported on this platform') + } + const dir = dirname(socketPath) + const linkStat = lstatSync(dir, { throwIfNoEntry: false }) + if (!linkStat) { + throw new Error(`serve socket parent directory does not exist: ${dir}`) + } + if (linkStat.isSymbolicLink()) { + throw new Error(`serve socket parent directory must not be a symlink: ${dir}`) + } + const dirStat = statSync(dir) + if (!dirStat.isDirectory()) { + throw new Error(`serve socket parent is not a directory: ${dir}`) + } + if (dirStat.uid !== process.geteuid()) { + throw new Error( + `serve socket parent directory must be owned by the current user: ${dir}`, + ) + } + if ((dirStat.mode & 0o022) !== 0) { + throw new Error( + `serve socket parent directory must not be group/other-writable: ${dir}`, + ) + } +} + +/** + * Per-connection SEC-4 token gate + ACP handoff. Buffers bytes until the first + * newline, validates the auth line, then hands the socket to a per-connection + * `AgentSideConnection` (tying each session to the connection that created + * it). A connection that does not authenticate within `authTimeoutMs`, sends a + * bad token, or overflows the auth-line buffer is destroyed and reported via + * `onAuthFailure` — with a reason STRING only, never the token. + */ +function handleConnection( + socket: Socket, + token: string, + authTimeoutMs: number, + resolvedOptions: Omit, + onAuthFailure?: (reason: string) => void, +): void { + let buffer = Buffer.alloc(0) + let settled = false + + const timer = setTimeout(() => { + if (settled) return + settled = true + socket.off('data', onData) + onAuthFailure?.('timeout') + socket.destroy() + }, authTimeoutMs) + // Never let the auth-timeout timer keep the event loop / process alive. + timer.unref() + + const onData = (chunk: Buffer): void => { + if (settled) return + buffer = Buffer.concat([buffer, chunk]) + const newlineIndex = buffer.indexOf(0x0a) + if (newlineIndex === -1) { + if (buffer.length > MAX_AUTH_LINE_BYTES) { + settled = true + clearTimeout(timer) + socket.off('data', onData) + onAuthFailure?.('auth-line-too-long') + socket.destroy() + } + return + } + settled = true + clearTimeout(timer) + // Pause BEFORE detaching the listener so no post-newline bytes are lost in + // the window before ndJsonStream starts reading. + socket.pause() + socket.off('data', onData) + + const line = buffer.subarray(0, newlineIndex).toString('utf8') + const rest = buffer.subarray(newlineIndex + 1) + + if (!verifyAuthLine(line, token)) { + onAuthFailure?.('bad-token') + socket.destroy() + return + } + + // Valid token: re-feed any bytes that arrived after the auth line, then + // hand the socket to ACP ndJson framing exactly like serveAcpOverStdio. + // The node<->web stream conversion cast pattern is copied verbatim from + // serveAcpOverStdio: the SDK Stream type is defined over web streams. + if (rest.length > 0) { + socket.unshift(rest) + } + // NEW-4 chokepoint (§12.8): every serialized frame — including + // SDK-generated JSON-RPC errors and agent→client requests — crosses + // sanitizeOutbound before the wire; clean frames stay byte-identical. + // §12.6 (OutboundQueue wiring): the sanitized frames then flow through + // a per-connection queue that coalesces and drains respecting the + // socket's backpressure; an overflow abort destroys the connection + // (fail closed), which cancels the in-flight turn via the normal + // owner-disconnect path. + const stream = ndJsonStream( + sanitizeOutboundStream(createOutboundQueueWritable(socket)), + Readable.toWeb(socket) as unknown as ReadableStream, + ) + // A fresh AgentSideConnection per socket: the session map inside + // createAcpAgent is per-connection, so a session is tied to the connection + // that created it. + void new AgentSideConnection( + (conn) => createAcpAgent({ ...resolvedOptions, connection: conn }), + stream, + ) + } + + socket.on('data', onData) +} + +/** + * Validates a single auth line against the expected token. The line must be + * `{"type":"openbuff.serve.auth","token":""}`. `timingSafeEqual` throws + * on a length mismatch, so byte-lengths are pre-checked and fail closed + * without calling it; otherwise the compare is constant time. + */ +function verifyAuthLine(line: string, expectedToken: string): boolean { + let parsed: unknown + try { + parsed = JSON.parse(line) + } catch { + return false + } + if ( + typeof parsed !== 'object' || + parsed === null || + (parsed as Record).type !== 'openbuff.serve.auth' + ) { + return false + } + const provided = (parsed as Record).token + if (typeof provided !== 'string') return false + const providedBuf = Buffer.from(provided, 'utf8') + const expectedBuf = Buffer.from(expectedToken, 'utf8') + // Fail closed on length mismatch before timingSafeEqual (which throws). + if (providedBuf.length !== expectedBuf.length) return false + return timingSafeEqual(providedBuf, expectedBuf) +} + +function isJsonObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +/** + * The node writable the queued outbound path writes to. Structurally + * satisfied by a net `Socket` (and by test fakes), so the §12.6 wiring stays + * testable without real sockets. + */ +export type OutboundQueueWritableTarget = { + /** Returns false when the writable's buffer is full (backpressure). */ + write: (chunk: Uint8Array) => boolean + once: (event: 'drain', listener: () => void) => unknown + destroy: () => void + end?: () => unknown +} + +/** + * §12.6 outbound backpressure wiring (per connection): wraps the socket the + * authenticated connection writes to so every outbound NDJSON frame is + * enqueued into a per-connection {@link OutboundQueue} (which coalesces + * consecutive chunk/update frames) and drained respecting the writable's + * backpressure — writes continue while `write()` reports buffered capacity + * and PAUSE the moment it returns false, resuming on 'drain'. A client that + * keeps up therefore takes the immediate-write fast path; the queue only + * grows while the client is genuinely stalled. When the queue's overflow + * decision is `abort` (coalescing could not bring it back under its byte + * cap) the connection is DESTROYED — fail closed. The existing + * owner-disconnect path already aborts the in-flight turn and cancels + * pending reverse requests on a destroyed socket, realizing §12.6's 'run + * cancelled' semantics at the transport level. + * + * Frames are re-serialized as `JSON.stringify(frame) + '\n'` after the + * round-trip through the queue; the sanitize chokepoint runs BEFORE this + * wrapper (see handleConnection), so no unsanitized text can reach the wire. + * + * stdio stays UNWIRED deliberately: it is a process-lifetime transport whose + * single peer is the host process reading our stdout, so there is no + * per-connection backpressure boundary to protect. + */ +export function createOutboundQueueWritable( + target: OutboundQueueWritableTarget, + options?: { maxBytes?: number }, +): WritableStream { + const queue = new OutboundQueue(options?.maxBytes ?? OUTBOUND_QUEUE_MAX_BYTES) + const decoder = new TextDecoder('utf8', { fatal: false }) + const encoder = new TextEncoder() + let lineBuffer = '' + let stalled = false + let aborted = false + + const flushQueue = (): void => { + if (stalled || aborted) return + for (;;) { + const frame = queue.dequeue() + if (frame === undefined) return + if (!target.write(encoder.encode(`${JSON.stringify(frame)}\n`))) { + // Backpressure: the socket's buffer is full (the frame just handed + // to write() is already buffered by the socket). Pause the drain and + // resume on 'drain'; frames enqueued meanwhile stay queued. + stalled = true + target.once('drain', () => { + stalled = false + flushQueue() + }) + return + } + } + } + + /** + * Fails closed: clears the queue, destroys the connection, errors the + * stream. Declared `never` — it ALWAYS throws, so the compiler knows calls + * terminate control flow and downstream narrowing survives. + */ + // Variable-level `(reason: string) => never` annotation: TS's + // never-return call analysis (which makes the guards below terminate + // control flow so `parsed` narrows to Record) only + // applies when the const CARRIES an explicit never-typed signature, not + // merely an inline arrow return type. + const failClosed: (reason: string) => never = (reason) => { + aborted = true + queue.clear() + target.destroy() + throw new Error(reason) + } + + return new WritableStream({ + write(chunk) { + if (aborted) return + lineBuffer += decoder.decode(chunk, { stream: true }) + for (;;) { + const newlineIndex = lineBuffer.indexOf('\n') + if (newlineIndex === -1) break + const line = lineBuffer.slice(0, newlineIndex).trim() + lineBuffer = lineBuffer.slice(newlineIndex + 1) + if (line.length === 0) continue + let parsed: unknown + try { + parsed = JSON.parse(line) + } catch { + failClosed( + 'outbound frame was not valid JSON; closing connection (fail closed).', + ) + } + if (!isJsonObject(parsed)) { + failClosed( + 'outbound frame was not a JSON object; closing connection (fail closed).', + ) + } + const result = queue.enqueue(parsed) + if (result.status === 'abort') { + failClosed( + 'outbound queue overflow could not be coalesced; closing connection.', + ) + } + } + flushQueue() + }, + close() { + lineBuffer = '' + queue.clear() + target.end?.() + }, + abort() { + aborted = true + queue.clear() + target.destroy() + }, + }) +} diff --git a/sdk/src/services/acp/__tests__/ext-methods.test.ts b/sdk/src/services/acp/__tests__/ext-methods.test.ts new file mode 100644 index 0000000000..433b1cab79 --- /dev/null +++ b/sdk/src/services/acp/__tests__/ext-methods.test.ts @@ -0,0 +1,798 @@ +/** + * Direct coverage for the ext-v1 ACP extension dispatcher (reviewer + * requirement RF-10) and the journal capabilities-replay validation (RF-13). + * + * RF-10: every known `_openbuff.dev/*` method serves from the LIVE + * `AcpSessionData` store with schema-validated params (missing/invalid + * params → -32602), fail-closed errors for unknown methods (-32601) and for + * sessions/store states with nothing to serve, the §6.2 receipt_not_found + * error (-32002 with the `openbuff.dev` data key), the §6.3 single-main-lane + * projection, and the §6.4 gate-state projection — and every served RESULT + * is re-parsed against the published ext-v1 schema so the wire contract + * (not just the TS type) is verified end to end. + * + * RF-13: `restoreFromJournal` validates replayed `capabilities` lines + * against the published `capabilityMapV1Schema` before accepting them — but + * the map is then RE-DERIVED from the serving store's real posture, never + * served from the replayed bytes: a tampered journal line (the journal lives + * under the project root, which serve mode treats as untrusted) can never + * advertise an overstated capability map to clients, even a schema-valid one. + * Receipt-envelope lines are replayed only when already redaction-clean + * (GV-07), and `_openbuff.dev/receipts/get` re-validates the full + * `receiptEnvelopeV1Schema` on every serve path including restored sessions. + */ + +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { afterEach, describe, expect, test } from 'bun:test' + +import type { RequestError } from '@agentclientprotocol/sdk' + +import { + capabilityMapV1Schema, + gateStateV1Schema, + laneV1Schema, + receiptEnvelopeV1Schema, +} from '@codebuff/common/protocol/acp-ext-v1' +import type { CapabilityMapV1 } from '@codebuff/common/protocol/acp-ext-v1' +import { + fileMutationResultV1Schema, + type FileMutationResultV1, +} from '@codebuff/common/tools/results/filesystem' +import { + getExactContentHash, + getContentHash, +} from '@codebuff/common/util/content-hash' + +import { createAcpAgent } from '../acp-agent' +import { + compileExtV1JsonSchemas, + OPENBUFF_EXT_METHODS, +} from '../ext-methods' +import { AcpSessionData } from '../session-data' + +/** The honest P1 serve capability map (same shape the bridge stores). */ +const VALID_CAPABILITY_MAP: CapabilityMapV1 = { + kind: 'openbuff.capabilities', + version: 1, + generation: 1, + sandbox: { tier: 'lexical', enforced: false, network: 'unrestricted' }, + index: { state: 'absent' }, + lsp: [], + sidecars: [], + lanes: { supported: false }, + journal: { resume: true, replay: true }, + gate: { enabled: true }, +} + +const HASH_A = `sha256:${'1'.repeat(64)}` +const HASH_B = `sha256:${'2'.repeat(64)}` + +/** + * A confirmed-mutation event in the exact shape the run loop emits; the + * store builds the redacted wire mutation from it (so the receipt fixture + * cannot drift from the projection the GV-06 golden vector pins). + */ +function makeMutationEvent(receiptId: string) { + return { + toolName: 'write_file', + callId: 'call_7', + operationId: 'op_1', + receiptId, + workspaceRevision: 1, + workspaceSnapshotId: 'ws-1', + actions: [ + { + action: 'update' as const, + path: 'src/a.ts', + beforeHash: HASH_A, + afterHash: HASH_B, + }, + ], + } +} + +function makeAgent(sessionData: AcpSessionData) { + return createAcpAgent({ + promptHandler: async () => ({ stopReason: 'end_turn' }), + connection: { sessionUpdate: async () => {} }, + sessionData, + loadHandler: async () => {}, + }) +} + +/** + * An agent whose connection OWNS `sessionId`: `extMethod`'s + * `assertOwnedSession` gate only forwards sessions this connection created + * (`newSession`) or restored (`loadSession`) to the ext-v1 dispatcher. + * `loadSession` registers the caller-supplied id and §6.1-seeds a baseline + * capability map when the store holds none for it yet. + */ +async function ownedAgent(sessionData: AcpSessionData, sessionId: string) { + const agent = makeAgent(sessionData) + await agent.loadSession({ cwd: '/tmp/acp-ext', mcpServers: [], sessionId }) + return agent +} + +/** + * A real, fully-applied mutation carrying the content-bearing fields the + * §6.2 wire projection must drop — the payload a caller hands the store's + * PUBLIC `recordReceipt` path (the path the wire-schema-drift finding named: + * the store previously kept the internal shape via spread, so any field + * outside the strict wire allowlist collapsed the serve to + * receipt_not_found). + */ +function makeAppliedMutation(receiptId: string): FileMutationResultV1 { + const operationId = 'op_recorded' + const afterContent = 'export const answer = 42\n' + const afterHash = getExactContentHash(afterContent) + return { + kind: 'file_mutation_result', + version: 1, + operationId, + outcome: 'applied', + actions: [ + { + actionId: 'act-1', + index: 0, + action: 'create', + path: 'src/new.ts', + outcome: 'applied', + beforeHash: null, + afterHash, + afterContent, + editAnchor: { + startLine: 1, + endLine: afterContent.split('\n').length, + contentHash: getContentHash(afterContent), + readCapability: 'cap.v3.1.2.AAAA.BBBB', + }, + }, + ], + authorityTier: 'conditional_commit', + receiptId, + workspaceRevision: 1, + workspaceSnapshotId: 'ws-1', + authorityReceipt: { + kind: 'commit_receipt', + version: 1, + receiptId, + operationId, + callId: 'call_7', + authorityTier: 'conditional_commit', + status: 'committed', + actions: [ + { + actionId: 'act-1', + index: 0, + action: 'create', + path: 'src/new.ts', + status: 'committed', + beforeHash: null, + afterHash, + }, + ], + finalHashes: { 'src/new.ts': afterHash }, + workspaceRevision: 1, + workspaceSnapshotId: 'ws-1', + }, + errors: [], + freshCapabilities: [ + { + kind: 'whole_file', + version: 1, + token: 'cap.v3.1.2.AAAA.BBBB', + snapshot: { + kind: 'file_snapshot', + version: 1, + canonicalPath: 'src/new.ts', + contentHash: getContentHash(afterContent), + sizeBytes: new TextEncoder().encode(afterContent).byteLength, + encoding: 'utf8', + readGeneration: 0, + }, + }, + ], + } +} + +/** + * Settles a call into `{ code, data }` or fails when it resolves. The + * parameter is `unknown` on purpose: `extMethod` returns + * `MaybePromise>`, and `await` accepts both shapes. + */ +async function errorOf( + promise: unknown, +): Promise<{ code: number; data: unknown }> { + try { + await promise + } catch (failure) { + const err = failure as RequestError & { data?: unknown } + return { code: err.code, data: (failure as unknown as { data?: unknown }).data } + } + throw new Error('expected the ext-method call to reject') +} + +const tmpDirs: string[] = [] +function makeJournalDir(): string { + const dir = mkdtempSync(join(tmpdir(), 'acp-ext-methods-')) + tmpDirs.push(dir) + return dir +} + +afterEach(() => { + while (tmpDirs.length > 0) { + const dir = tmpDirs.pop() + if (dir !== undefined) rmSync(dir, { recursive: true, force: true }) + } +}) + +describe('ext-v1 dispatcher serves the live store (RF-10)', () => { + test('capabilities/get returns the stored map, which re-parses against the published schema', async () => { + const sessionData = new AcpSessionData() + sessionData.setCapabilities('s1', VALID_CAPABILITY_MAP) + const agent = await ownedAgent(sessionData, 's1') + const result = await agent.extMethod('_openbuff.dev/capabilities/get', { + sessionId: 's1', + }) + expect(capabilityMapV1Schema.parse(result)).toEqual(VALID_CAPABILITY_MAP) + }) + + // §6.1 seeds a baseline capability map for every session this connection + // registers (newSession/loadSession), so "registered + no stored map" is + // unreachable and -32601 can never fire here. The reachable fail-closed for + // a session this connection does not own is `assertOwnedSession`'s -32602. + test('capabilities/get fails closed with -32602 for a session this connection does not own', async () => { + const sessionData = new AcpSessionData() + const failure = await errorOf( + makeAgent(sessionData).extMethod('_openbuff.dev/capabilities/get', { + sessionId: 's-none', + }), + ) + expect(failure.code).toBe(-32602) + }) + + test('params are schema-validated: missing sessionId and extra keys are both -32602', async () => { + const sessionData = new AcpSessionData() + const agent = makeAgent(sessionData) + const missing = await errorOf( + agent.extMethod('_openbuff.dev/capabilities/get', {}), + ) + expect(missing.code).toBe(-32602) + const extra = await errorOf( + agent.extMethod('_openbuff.dev/capabilities/get', { + sessionId: 's1', + extra: true, + }), + ) + expect(extra.code).toBe(-32602) + }) + + test('receipts/get returns a redacted envelope that re-parses against the published schema', async () => { + const sessionData = new AcpSessionData() + sessionData.recordReceiptFromMutationEvent( + 's1', + makeMutationEvent('rcpt_1'), + 'call_7', + ) + const agent = await ownedAgent(sessionData, 's1') + const result = await agent.extMethod('_openbuff.dev/receipts/get', { + sessionId: 's1', + receiptId: 'rcpt_1', + }) + const receipt = receiptEnvelopeV1Schema.parse( + (result as { receipt: unknown }).receipt, + ) + // The redaction invariant holds on the served wire shape: no fresh + // capability and no content-bearing action fields ever leave the core. + expect(receipt.mutation.freshCapabilities).toEqual([]) + for (const action of receipt.mutation.actions) { + expect(action).not.toHaveProperty('afterContent') + expect(action).not.toHaveProperty('patch') + expect(action).not.toHaveProperty('editAnchor') + } + }) + + test('receipts/get: an unknown receipt id is -32002 with the openbuff.dev data key', async () => { + const sessionData = new AcpSessionData() + const agent = await ownedAgent(sessionData, 's1') + const failure = await errorOf( + agent.extMethod('_openbuff.dev/receipts/get', { + sessionId: 's1', + receiptId: 'nope', + }), + ) + expect(failure.code).toBe(-32002) + const data = failure.data as Record> + expect(data['openbuff.dev']).toEqual({ + code: 'receipt_not_found', + receiptId: 'nope', + }) + }) + + test('receipts/get: a tool-less envelope has no wire representation and is not fetchable', async () => { + const sessionData = new AcpSessionData() + // No toolCallId: the published receiptEnvelopeV1Schema requires one, so + // the store must not serve this envelope by id. + sessionData.recordReceiptFromMutationEvent( + 's1', + makeMutationEvent('rcpt_toolless'), + ) + const agent = await ownedAgent(sessionData, 's1') + const failure = await errorOf( + agent.extMethod('_openbuff.dev/receipts/get', { + sessionId: 's1', + receiptId: 'rcpt_toolless', + }), + ) + expect(failure.code).toBe(-32002) + }) + + test('lanes/list serves the single main lane; lanes/create and lanes/land are capability_disabled (GV-10)', async () => { + const sessionData = new AcpSessionData() + sessionData.recordReceiptFromMutationEvent( + 's1', + makeMutationEvent('rcpt_lane'), + ) + const agent = await ownedAgent(sessionData, 's1') + const result = (await agent.extMethod('_openbuff.dev/lanes/list', { + sessionId: 's1', + })) as { lanes: unknown[] } + expect(result.lanes).toHaveLength(1) + const lane = laneV1Schema.parse(result.lanes[0]) + expect(lane.laneId).toBe('main') + expect(lane.status).toBe('active') + expect(lane.baseRevision).toBe(1) + + for (const method of [ + '_openbuff.dev/lanes/create', + '_openbuff.dev/lanes/land', + ] as const) { + const failure = await errorOf(agent.extMethod(method, { sessionId: 's1' })) + expect(failure.code).toBe(-32601) + const data = failure.data as Record> + expect(data['openbuff.dev']).toEqual({ + code: 'capability_disabled', + capability: 'lanes', + }) + } + }) + + test('gate_state/get returns a state that re-parses against the published schema; idle before any block', async () => { + const sessionData = new AcpSessionData() + const agent = await ownedAgent(sessionData, 's1') + const result = await agent.extMethod('_openbuff.dev/gate_state/get', { + sessionId: 's1', + }) + const state = gateStateV1Schema.parse(result) + expect(state.status).toBe('idle') + }) + + test('an unknown _openbuff.dev/* method fails closed with -32601', async () => { + const sessionData = new AcpSessionData() + const failure = await errorOf( + makeAgent(sessionData).extMethod('_openbuff.dev/future/verb', { + sessionId: 's1', + }), + ) + expect(failure.code).toBe(-32601) + }) + + test('receipts/get serves a receipt recorded via the public recordReceipt path', async () => { + const sessionData = new AcpSessionData() + const mutation = makeAppliedMutation('rcpt_recorded') + expect(fileMutationResultV1Schema.safeParse(mutation).success).toBe(true) + sessionData.recordReceipt('s1', mutation, 'call_7') + + const agent = await ownedAgent(sessionData, 's1') + const result = await agent.extMethod('_openbuff.dev/receipts/get', { + sessionId: 's1', + receiptId: 'rcpt_recorded', + }) + // The recorded envelope is built with the §6.2 wire projection, so it + // conforms to the published ext-v1 schema and IS served — not collapsed + // to receipt_not_found by a strict re-validation the stored shape cannot + // pass. + const receipt = receiptEnvelopeV1Schema.parse( + (result as { receipt: unknown }).receipt, + ) + expect(receipt.mutation.receiptId).toBe('rcpt_recorded') + expect(receipt.mutation.freshCapabilities).toEqual([]) + for (const action of receipt.mutation.actions) { + expect(action).not.toHaveProperty('afterContent') + expect(action).not.toHaveProperty('patch') + expect(action).not.toHaveProperty('editAnchor') + } + }) + + test('receipts/get serves an envelope whose internal mutation carried a field outside the strict wire allowlist', async () => { + const sessionData = new AcpSessionData() + // The internal FileMutationResultV1 contract is non-strict, so a runtime + // mutation can carry fields the strict wire schema has no slot for; the + // §6.2 projection rebuilds the mutation field-by-field, so such an + // envelope is still stored wire-clean and served. + const driftMutation = { + ...makeAppliedMutation('rcpt_drift'), + futureField: 'not-in-the-wire-allowlist', + } as unknown as FileMutationResultV1 + sessionData.recordReceipt('s1', driftMutation, 'call_7') + + const agent = await ownedAgent(sessionData, 's1') + const result = await agent.extMethod('_openbuff.dev/receipts/get', { + sessionId: 's1', + receiptId: 'rcpt_drift', + }) + const receipt = receiptEnvelopeV1Schema.parse( + (result as { receipt: unknown }).receipt, + ) + expect( + (receipt.mutation as Record).futureField, + ).toBeUndefined() + }) + + test('gate_state/get reports a skipped gate as skipped with its reason, not as failed', async () => { + const sessionData = new AcpSessionData() + sessionData.updateGateStateFromBlock( + 's1', + `${JSON.stringify({ + gate: 'reviewer', + status: 'skipped', + details: 'no reviewable diff in the pending set', + })}`, + ) + const agent = await ownedAgent(sessionData, 's1') + const result = await agent.extMethod('_openbuff.dev/gate_state/get', { + sessionId: 's1', + }) + const state = gateStateV1Schema.parse(result) + expect(state.status).toBe('skipped') + expect(state.skipReason).toBe('no reviewable diff in the pending set') + }) + + test('without the live store, every ext-v1 method fails closed with -32601', async () => { + const agent = createAcpAgent({ + promptHandler: async () => ({ stopReason: 'end_turn' }), + connection: { sessionUpdate: async () => {} }, + }) + const failure = await errorOf( + agent.extMethod('_openbuff.dev/capabilities/get', { sessionId: 's1' }), + ) + expect(failure.code).toBe(-32601) + }) +}) + +describe('§6.1 baseline capability map is available before the first prompt', () => { + test('newSession seeds the baseline so capabilities/get works between session/new and the first prompt', async () => { + const sessionData = new AcpSessionData() + const agent = makeAgent(sessionData) + const { sessionId } = await agent.newSession({ + cwd: '/tmp/openbuff-preprompt', + mcpServers: [], + }) + + // No prompt has run yet: the map must already be served, never -32601. + const result = await agent.extMethod('_openbuff.dev/capabilities/get', { + sessionId, + }) + const map = capabilityMapV1Schema.parse(result) + // A purely in-memory store advertises NO journal resume/replay: the + // flags are derived from the store's real posture, not hardcoded. + expect(map.journal).toEqual({ resume: false, replay: false }) + expect(map.sandbox).toEqual({ + tier: 'lexical', + enforced: false, + network: 'unrestricted', + }) + }) + + test('loadSession seeds the baseline for a restored session whose journal carried no capabilities line', async () => { + const dir = makeJournalDir() + writeFileSync( + join(dir, 's-restored.jsonl'), + `${JSON.stringify({ + kind: 'gate_state', + snapshot: { + gate: 'validation', + status: 'running', + details: '', + phase: 'validating', + }, + })}\n`, + 'utf8', + ) + const sessionData = new AcpSessionData({ journalDir: dir }) + const agent = createAcpAgent({ + promptHandler: async () => ({ stopReason: 'end_turn' }), + connection: { sessionUpdate: async () => {} }, + sessionData, + loadHandler: async () => {}, + }) + await agent.loadSession({ + cwd: '/tmp/openbuff-restored', + mcpServers: [], + sessionId: 's-restored', + }) + + const result = await agent.extMethod('_openbuff.dev/capabilities/get', { + sessionId: 's-restored', + }) + const map = capabilityMapV1Schema.parse(result) + // A journal-backed store honestly advertises resume/replay. + expect(map.journal).toEqual({ resume: true, replay: true }) + }) +}) + +describe('compileExtV1JsonSchemas emits params AND result per method (RF-3)', () => { + test('every ext-v1 method artifact carries both params and result', () => { + const artifacts = compileExtV1JsonSchemas() + for (const method of OPENBUFF_EXT_METHODS) { + const artifact = artifacts[method] as Record | undefined + expect(artifact).toBeDefined() + expect(artifact?.params).toBeDefined() + expect(artifact?.result).toBeDefined() + } + }) + + test('the receipts/get result artifact names the receipt property', () => { + const artifacts = compileExtV1JsonSchemas() + const artifact = artifacts['_openbuff.dev/receipts/get'] as { + result: unknown + } + expect(JSON.stringify(artifact.result)).toContain('"receipt"') + }) +}) + +describe('journal capabilities replay is schema-validated (RF-13)', () => { + test('a valid capabilities line restores the stored map', async () => { + const dir = makeJournalDir() + writeFileSync( + join(dir, 's-journal.jsonl'), + `${JSON.stringify({ kind: 'capabilities', capabilities: VALID_CAPABILITY_MAP })}\n`, + 'utf8', + ) + const sessionData = new AcpSessionData({ journalDir: dir }) + await expect(sessionData.restoreFromJournal('s-journal')).resolves.toBe(true) + expect(sessionData.getCapabilities('s-journal')).toEqual(VALID_CAPABILITY_MAP) + }) + + test('a tampered capabilities line (unknown key) is skipped fail-closed', async () => { + const dir = makeJournalDir() + const tampered = { + ...VALID_CAPABILITY_MAP, + injected: 'overstated', + } as unknown as CapabilityMapV1 + writeFileSync( + join(dir, 's-journal.jsonl'), + [ + JSON.stringify({ kind: 'capabilities', capabilities: VALID_CAPABILITY_MAP }), + JSON.stringify({ kind: 'capabilities', capabilities: tampered }), + ].join('\n') + '\n', + 'utf8', + ) + const sessionData = new AcpSessionData({ journalDir: dir }) + await sessionData.restoreFromJournal('s-journal') + const stored = sessionData.getCapabilities('s-journal') + expect(stored).toEqual(VALID_CAPABILITY_MAP) + expect((stored as unknown as Record).injected).toBeUndefined() + }) + + test('a journal with only tampered capability lines leaves no stored map', async () => { + const dir = makeJournalDir() + const tampered = { + ...VALID_CAPABILITY_MAP, + version: 2, + } as unknown as CapabilityMapV1 + writeFileSync( + join(dir, 's-journal.jsonl'), + `${JSON.stringify({ kind: 'capabilities', capabilities: tampered })}\n`, + 'utf8', + ) + const sessionData = new AcpSessionData({ journalDir: dir }) + await sessionData.restoreFromJournal('s-journal') + expect(sessionData.getCapabilities('s-journal')).toBeUndefined() + }) + + test('a schema-valid but OVERSTATED replayed map is re-derived, never served verbatim', async () => { + const dir = makeJournalDir() + const overstated = { + ...VALID_CAPABILITY_MAP, + generation: 99, + sandbox: { + tier: 'landlock+seccomp', + enforced: true, + network: 'none', + }, + lanes: { supported: true }, + } as unknown as CapabilityMapV1 + // The tampered line is itself schema-valid: only the honest re-derivation + // defends the wire against this shape of lie. + expect(capabilityMapV1Schema.safeParse(overstated).success).toBe(true) + writeFileSync( + join(dir, 's-journal.jsonl'), + `${JSON.stringify({ kind: 'capabilities', capabilities: overstated })}\n`, + 'utf8', + ) + const sessionData = new AcpSessionData({ journalDir: dir }) + await sessionData.restoreFromJournal('s-journal') + + // Register the queried id AFTER the restore so the replayed (re-derived) + // map is what the dispatcher serves. + const agent = await ownedAgent(sessionData, 's-journal') + const result = await agent.extMethod('_openbuff.dev/capabilities/get', { + sessionId: 's-journal', + }) + const served = capabilityMapV1Schema.parse(result) + // The served map is the honest P1 serve default derived from THIS + // store's posture — not the overstated replayed bytes. + expect(served).toEqual({ + ...VALID_CAPABILITY_MAP, + journal: { resume: true, replay: true }, + }) + expect(served.sandbox.enforced).toBe(false) + expect(served.lanes.supported).toBe(false) + }) + + test('a receipt_envelope line carrying content-bearing fields is skipped fail-closed (GV-07)', async () => { + const dir = makeJournalDir() + const envelope = { + kind: 'openbuff.receipt_envelope', + version: 1, + sessionId: 's-tamper', + toolCallId: 'call_7', + laneId: 'main', + mutation: { + kind: 'file_mutation_result', + version: 1, + operationId: 'op_1', + outcome: 'applied', + actions: [ + { + actionId: 'op_1:0', + index: 0, + action: 'update', + path: 'src/a.ts', + outcome: 'applied', + beforeHash: HASH_A, + afterHash: HASH_B, + // The leak the GV-07 replay gate must refuse. + afterContent: 'SECRET FILE CONTENT', + }, + ], + authorityTier: 'conditional_commit', + receiptId: 'rcpt_tampered', + workspaceRevision: 1, + workspaceSnapshotId: 'ws-1', + errors: [], + freshCapabilities: [], + }, + } + writeFileSync( + join(dir, 's-tamper.jsonl'), + `${JSON.stringify({ kind: 'receipt_envelope', envelope })}\n`, + 'utf8', + ) + const sessionData = new AcpSessionData({ journalDir: dir }) + await expect(sessionData.restoreFromJournal('s-tamper')).resolves.toBe(true) + // The tampered envelope never entered the store... + expect(sessionData.getReceipts('s-tamper').receipts).toEqual([]) + // ...so it is not addressable by id on the wire either. Register the + // queried id after the restore so the replayed store state is what the + // dispatcher sees. + const agent = await ownedAgent(sessionData, 's-tamper') + const failure = await errorOf( + agent.extMethod('_openbuff.dev/receipts/get', { + sessionId: 's-tamper', + receiptId: 'rcpt_tampered', + }), + ) + expect(failure.code).toBe(-32002) + }) + + test('a receipt_envelope line carrying freshCapabilities is skipped fail-closed (GV-07)', async () => { + const dir = makeJournalDir() + const envelope = { + kind: 'openbuff.receipt_envelope', + version: 1, + sessionId: 's-caps', + toolCallId: 'call_7', + laneId: 'main', + mutation: { + kind: 'file_mutation_result', + version: 1, + operationId: 'op_1', + outcome: 'applied', + actions: [ + { + actionId: 'op_1:0', + index: 0, + action: 'update', + path: 'src/a.ts', + outcome: 'applied', + beforeHash: HASH_A, + afterHash: HASH_B, + }, + ], + authorityTier: 'conditional_commit', + receiptId: 'rcpt_caps', + workspaceRevision: 1, + workspaceSnapshotId: 'ws-1', + errors: [], + // A replayed cap.v3 token must never survive replay. + freshCapabilities: [ + { kind: 'whole_file', version: 1, token: 'cap.v3.1.2.LEAKED' }, + ], + }, + } + writeFileSync( + join(dir, 's-caps.jsonl'), + `${JSON.stringify({ kind: 'receipt_envelope', envelope })}\n`, + 'utf8', + ) + const sessionData = new AcpSessionData({ journalDir: dir }) + await expect(sessionData.restoreFromJournal('s-caps')).resolves.toBe(true) + expect(sessionData.getReceipts('s-caps').receipts).toEqual([]) + }) + + test('a redaction-clean receipt_envelope line replays and serves schema-valid (RF-12)', async () => { + const dir = makeJournalDir() + const envelope = { + kind: 'openbuff.receipt_envelope', + version: 1, + sessionId: 's-clean', + toolCallId: 'call_7', + laneId: 'main', + mutation: { + kind: 'file_mutation_result', + version: 1, + operationId: 'op_1', + outcome: 'applied', + actions: [ + { + actionId: 'op_1:0', + index: 0, + action: 'update', + path: 'src/a.ts', + outcome: 'applied', + beforeHash: HASH_A, + afterHash: HASH_B, + }, + ], + authorityTier: 'conditional_commit', + receiptId: 'rcpt_clean', + workspaceRevision: 1, + workspaceSnapshotId: 'ws-1', + errors: [], + freshCapabilities: [], + }, + } + writeFileSync( + join(dir, 's-clean.jsonl'), + `${JSON.stringify({ kind: 'receipt_envelope', envelope })}\n`, + 'utf8', + ) + const sessionData = new AcpSessionData({ journalDir: dir }) + await expect(sessionData.restoreFromJournal('s-clean')).resolves.toBe(true) + + // The journal-restored envelope IS addressable by id, and the served + // wire shape re-parses against the redaction-enforcing ext-v1 schema. + // Register the queried id after the restore so the replayed envelope is + // what the dispatcher serves. + const agent = await ownedAgent(sessionData, 's-clean') + const result = await agent.extMethod('_openbuff.dev/receipts/get', { + sessionId: 's-clean', + receiptId: 'rcpt_clean', + }) + const receipt = receiptEnvelopeV1Schema.parse( + (result as { receipt: unknown }).receipt, + ) + expect(receipt.mutation.receiptId).toBe('rcpt_clean') + expect(receipt.mutation.freshCapabilities).toEqual([]) + for (const action of receipt.mutation.actions) { + expect(action).not.toHaveProperty('afterContent') + expect(action).not.toHaveProperty('patch') + expect(action).not.toHaveProperty('editAnchor') + } + }) +}) diff --git a/sdk/src/services/acp/acp-agent.ts b/sdk/src/services/acp/acp-agent.ts new file mode 100644 index 0000000000..b268f52dab --- /dev/null +++ b/sdk/src/services/acp/acp-agent.ts @@ -0,0 +1,1425 @@ +import { randomUUID } from 'node:crypto' +import { Readable, Writable } from 'node:stream' + +import { + AgentSideConnection, + PROTOCOL_VERSION, + RequestError, + ndJsonStream, +} from '@agentclientprotocol/sdk' +import type { + Agent, + AgentCapabilities, + AuthMethod, + CancelNotification, + CreateTerminalRequest, + CreateTerminalResponse, + InitializeRequest, + InitializeResponse, + LoadSessionRequest, + LoadSessionResponse, + McpServer, + NewSessionRequest, + NewSessionResponse, + PromptRequest, + PromptResponse, + ReadTextFileRequest, + ReadTextFileResponse, + RequestPermissionRequest, + RequestPermissionResponse, + SessionNotification, + TerminalHandle, + WriteTextFileRequest, + WriteTextFileResponse, +} from '@agentclientprotocol/sdk' + +import { + OPENBUFF_ACP_EXT_VERSION, + OPENBUFF_ACP_NS, + receiptEnvelopeV1Schema, +} from '@codebuff/common/protocol/acp-ext-v1' +import type { CapabilityMapV1 } from '@codebuff/common/protocol/acp-ext-v1' +import { resolveProjectPath } from '@codebuff/common/util/project-path-containment' + +import { sanitizeOutbound } from '../../serve/outbound-filter' +import { sanitizeOutboundStream } from '../../serve/outbound' +import { + OPENBUFF_EXT_METHOD_PREFIX, + OPENBUFF_EXT_METHODS, + OPENBUFF_SUPPORTED_EXTENSIONS, + defaultCapabilityMapV1, + openbuffExtMethodSchemas, +} from './ext-methods' +import type { OpenbuffExtMethod } from './ext-methods' +import { ACP_EXTENSION_METHODS, acpExtensionSchemas } from './extensions' +import type { AcpExtensionMethod } from './extensions' +import type { AcpSessionData } from './session-data' + +/** §12.6 limits, in bytes. */ +const MAX_INBOUND_FRAME_BYTES = 16 * 1024 * 1024 +const MAX_PROMPT_TOTAL_BYTES = 8 * 1024 * 1024 +const MAX_IMAGE_BYTES = 5 * 1024 * 1024 +const MAX_LIVE_SESSIONS = 16 + +/** + * The resolved shape of one prompt turn: the handler reports its own terminal + * state so a cancelled turn (via the abort signal) and a naturally finished + * turn are both explicit. + */ +export type AcpPromptTurnResult = { stopReason: 'end_turn' | 'cancelled' } + +/** + * The injectable seam P1-T2 binds to the real core run: the ACP skeleton owns + * the protocol surface while the handler owns turn execution and streaming. + * + * The returned promise models BOTH §4.2 turn exits: it RESOLVES with + * {@link AcpPromptTurnResult} for a finished turn, and it may REJECT with a + * JSON-RPC `RequestError` when the turn ends in a terminal error — the serve + * bridge throws the -32603 `PrintModeRpcError` shape after the handler body + * completes (as a REAL `RequestError` instance, so the ACP wire layer maps it + * directly onto the `session/prompt` error response). Consumers must not + * assume the promise always resolves: type-asserting the resolved shape or + * wrapping the handler with a non-throwing default silently converts the + * terminal-error contract into a generic failure. + */ +export type AcpPromptHandler = (input: { + sessionId: string + promptText: string + update: (chunkText: string) => Promise + signal: AbortSignal + /** + * The client-advertised MCP servers for this session (from + * newSession/loadSession), forwarded so the P1-T2 bridge can mark them + * untrusted ('client' origin) at ingest before any run attaches them. + */ + mcpServers?: McpServer[] + /** + * Optional client-side reverse-request seam (P1-T2 binds it to the + * connection): lets a turn request permission, read/write files in the + * client's workspace, or create a terminal mid-prompt. + */ + reverseRequests?: AcpReverseRequests + /** + * §4.2: the client capabilities snapshot negotiated at initialize, + * projected onto the slice the serve bridge consults (`elicitation.form` + * gates the ask_user → elicitation/create mapping). Omitted when the + * client advertised no elicitation capability. + */ + clientCapabilities?: { elicitation?: { form?: unknown } } + /** + * §3.2: whether the `events` Openbuff extension was negotiated. Omitted + * when it was not, so the bridge's ext-gated ask_user path stays closed. + */ + eventsExtensionEnabled?: boolean + /** + * §4.2: the generic reverse-request channel bound to the owning + * connection, carrying `elicitation/create` and `_openbuff.dev/ask_user`. + */ + onReverseRequest?: (method: string, params: unknown) => Promise + /** + * §12.4: sends `$/cancel_request` for an outstanding reverse-request id on + * the owning connection, so the client can tear down pending UI on timeout. + */ + onCancelRequest?: (requestId: string) => void | Promise +}) => Promise + +/** + * The slice of the ACP connection the agent needs: pushing `session/update` + * notifications to the client. Structurally satisfied by the SDK's + * `AgentSideConnection` and trivially fakeable in tests, so prompt streaming + * stays testable without a transport. + */ +export type AcpSessionUpdateSink = { + sessionUpdate(params: SessionNotification): Promise +} + +/** + * The per-connection surface the serve transports hand to + * {@link createAcpAgent}: at minimum the session-update sink; the real wire + * connections (the SDK's `AgentSideConnection`) additionally expose the + * client-method reverse-request surface, from which the agent derives the + * §5/§4.2 bridge seams when the host injected no explicit + * `reverseRequests`. Every extra is optional so test fakes only need + * `sessionUpdate`. + */ +export type AcpAgentConnection = AcpSessionUpdateSink & { + requestPermission?( + params: RequestPermissionRequest, + ): Promise + readTextFile?(params: ReadTextFileRequest): Promise + writeTextFile?(params: WriteTextFileRequest): Promise + /** The SDK returns a `TerminalHandle`; the agent adapts it onto the plain + * `CreateTerminalResponse` shape `AcpReverseRequests` models. */ + createTerminal?(params: CreateTerminalRequest): Promise + /** Generic client-method request channel (elicitation/create, ext methods). */ + request?(method: string, params?: unknown): Promise + /** Generic client-method notification channel (`$/cancel_request`). */ + notify?(method: string, params?: unknown): Promise +} + +/** + * The client-side reverse-request surface a prompt turn may need. The P1-T2 + * bridge binds these to the SDK's `AgentSideConnection` methods so a prompt + * handler can request permission, touch the client filesystem, or spawn a + * terminal while the turn is in flight. Optional everywhere so tests can + * omit it. + */ +export type AcpReverseRequests = { + requestPermission( + params: RequestPermissionRequest, + ): Promise + readTextFile(params: ReadTextFileRequest): Promise + writeTextFile(params: WriteTextFileRequest): Promise + createTerminal( + params: CreateTerminalRequest, + ): Promise +} + +/** + * One already-persisted chat-history message replayed to the client on + * `session/load` (§4.1). The shape is deliberately minimal and structural so + * the CLI's chat-history store (and tests) can feed it without the protocol + * layer importing the CLI's concrete ChatMessage type. Reasoning content is + * dropped at projection time and the tool `rawInput`/`rawOutput` are never + * emitted (GV-26). + */ +export type AcpReplayMessage = { + variant: 'user' | 'agent' | 'tool' + text?: string + toolCallId?: string + toolName?: string + paths?: string[] +} + +export type AcpAgentOptions = { + /** Required by design: there is no default prompt handler to hide behind. */ + promptHandler: AcpPromptHandler + /** Where streamed agent message chunks are delivered (the client side). + * The real transports pass the SDK `AgentSideConnection`, whose extra + * client-method surface (see {@link AcpAgentConnection}) is what binds the + * §5 approval / §4.2 ask_user seams on the `openbuff serve` path. */ + connection: AcpAgentConnection + /** Human-readable name for P1-T2 logging; consumed by the serve bridge. */ + clientNameForLogging?: string + /** + * Optional resume path for `session/load`: restores external state for a + * previously created session id. Absent means `session/load` fails closed + * with a JSON-RPC method-not-found error. + */ + loadHandler?: (input: { sessionId: string }) => Promise + /** + * Optional chat-history replay seam for `session/load` (§4.1): returns the + * persisted conversation as ordered replay messages. The agent replays them + * as `user_message_chunk`/`agent_message_chunk`/`tool_call` (final status) + * session/update notifications BEFORE the `session/load` result. Absent + * means no history is replayed. The protocol layer sanitizes every replayed + * text and drops reasoning/tool raw IO, so no receipts, capabilities, or + * `cap.v3.` tokens cross the wire on replay (GV-26). + */ + historyLoader?: (input: { + sessionId: string + }) => Promise + /** + * Optional reverse-request seam forwarded into the prompt handler's input; + * the P1-T2 bridge binds it to AgentSideConnection's requestPermission, + * readTextFile, writeTextFile, and createTerminal. + */ + reverseRequests?: AcpReverseRequests + /** + * Optional dispatcher for Openbuff ACP extension methods (see + * ACP_EXTENSION_METHODS in ./extensions). Absent means every extension + * request fails with a JSON-RPC method-not-found error. + */ + extensionHandler?: (input: { + method: string + params: unknown + }) => Promise + /** + * Optional bounded per-session live-data store backing the read-only + * Openbuff extension methods ('openbuff/getReceipts' and + * 'openbuff/gateState') when no extensionHandler is injected. The P1-T2 + * run loop records real receipts and published gate-state blocks here. + * An explicitly injected extensionHandler still wins (back-compat), and + * methods with no store fallback (askUser) still fail closed. + */ + sessionData?: AcpSessionData + /** + * SEC-7 (§12.5) containment anchor: the serve process's project root. When + * set, `newSession`/`loadSession` validate that `cwd` is absolute and equal + * to or inside this root (after symlink dereference) and reject + * `additionalDirectories` entries that are not in + * `allowedAdditionalDirectories`. When unset, cwd validation is skipped + * entirely (preserving the pre-P1-T2 permissive behavior for in-process + * embeddings that have no fixed root). + */ + projectRoot?: string + /** + * The directories a client may legitimately add via `additionalDirectories` + * — the union of `openbuff.json` `serve.additionalDirectories` and + * `--add-dir`, resolved to absolute form by the host. An entry not in this + * list is rejected (-32602), never silently admitted. + */ + allowedAdditionalDirectories?: string[] + /** + * GV-18 (§12.3 transport gating): whether client-supplied `mcpServers` + * are accepted at `session/new`. Unset/true is PERMISSIVE (in-process + * embeddings and the stdio transport keep today's behavior); the socket + * transport passes `false` so a same-uid peer cannot make the serve + * process spawn client-directed MCP server processes. When `false` and + * the request carries any `mcpServers` entry, `newSession` fails closed + * with -32602 and `data['openbuff.dev'].code = 'client_mcp_disabled'` + * BEFORE any session record is created or process spawned (GV-18). + */ + allowClientMcpServers?: boolean +} + +/** Per-session state kept for later phases (P1-T2 core-run wiring). */ +type AcpSessionState = { + /** The VALIDATED project-root cwd recorded at session/new or session/load. */ + cwd: string + mcpServers: McpServer[] + abortController: AbortController | null +} + +/** + * The concrete agent surface returned by createAcpAgent: the SDK Agent + * interface with the optional methods this skeleton actually implements + * (session/load and legacy extMethod) pinned as required, so callers and + * tests can invoke them directly while staying assignable to `Agent`. + */ +export type AcpAgent = Agent & { + loadSession(params: LoadSessionRequest): Promise + extMethod( + method: string, + params: Record, + ): Promise> +} + +function isPlainRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +/** The negotiated Openbuff extension set for one connection (§3.2). */ +type NegotiatedExt = { extVersion: number; extensions: string[] } + +/** + * Reads `clientCapabilities._meta["openbuff.dev"]` and computes the negotiated + * extension set. Returns undefined for a plain ACP client (no `_meta`) so the + * no-ext-echo rule holds: the agent then emits pure ACP and never sends an + * extension notification. Unknown extensions are dropped (intersection with + * the supported set); `extVersion` is min(client, agent) — independent of the + * ACP protocolVersion. + */ +function negotiateOpenbuffExt( + params: InitializeRequest, +): NegotiatedExt | undefined { + const meta = params.clientCapabilities?._meta + if (!isPlainRecord(meta)) return undefined + const openbuff = meta[OPENBUFF_ACP_NS] + if (!isPlainRecord(openbuff)) return undefined + const requested = Array.isArray(openbuff.extensions) + ? openbuff.extensions.filter( + (entry): entry is string => typeof entry === 'string', + ) + : [] + const supported = new Set(OPENBUFF_SUPPORTED_EXTENSIONS) + const extensions = requested.filter((entry) => supported.has(entry)) + const clientVersion = + typeof openbuff.extVersion === 'number' ? openbuff.extVersion : 0 + const extVersion = Math.min(clientVersion, OPENBUFF_ACP_EXT_VERSION) + return { extVersion, extensions } +} + +/** + * SEC-7 (§12.5): percent-decodes a client-supplied path/URI exactly once and + * rejects the residual-encoding and authority escape shapes. Returns the + * decoded path, or null when the value is rejected (never silently + * normalized). `cwd`/`additionalDirectories` arrive as plain absolute paths; + * a `file://` URI is unwrapped to its path, rejecting any authority that is + * not empty or `localhost`. + */ +function decodeContainedPathInput(input: string): string | null { + let value = input + if (value.startsWith('file://')) { + const rest = value.slice('file://'.length) + const slashIndex = rest.indexOf('/') + const authority = slashIndex === -1 ? rest : rest.slice(0, slashIndex) + if (authority !== '' && authority !== 'localhost') { + return null + } + value = slashIndex === -1 ? '' : rest.slice(slashIndex) + } + let decoded: string + try { + decoded = decodeURIComponent(value) + } catch { + // A malformed percent-escape is not a usable path: reject, never guess. + return null + } + // Residual encoded traversal/separator after ONE decode = double-encoding + // attack (the first decode yields `%2e`/`%2f`/`%5c`, which a second decode + // would turn into `.`/`/`/`\`). Reject rather than decode again. + if (/%2e|%2f|%5c/i.test(decoded)) { + return null + } + return decoded +} + +/** + * Builds the §12.5 containment validator bound to one project root. Returns + * null when the input must be rejected (-32602): a path that fails + * percent-decode/authority, is not absolute, contains traversal segments, or + * escapes the root after symlink dereference. `resolveProjectPath` does the + * root-relative resolution, lexical root check, and realpath symlink check; + * this wrapper adds the SEC-7-specific absolute + reject (never normalize) + * rules on top. + */ +function resolveContainedProjectDir( + projectRoot: string, + input: string, +): string | null { + const decoded = decodeContainedPathInput(input) + if (decoded === null) return null + // SEC-7 requires an absolute path; `resolveProjectPath` would resolve a + // relative input against the root and silently admit it, which is exactly + // the normalization the contract forbids. + if (isWindowsAbsolutePath(decoded) || decoded.startsWith('/')) { + const contained = resolveProjectPath(projectRoot, decoded) + if (contained === null || contained.scope !== 'project') return null + return contained.fullPath + } + return null +} + +function isWindowsAbsolutePath(value: string): boolean { + return /^[A-Za-z]:[\\/]/.test(value) || value.startsWith('\\\\') +} + +/** Builds the -32602 `limit_exceeded` error body. */ +function makeLimitExceeded(message: string): RequestError { + return RequestError.invalidParams( + { [OPENBUFF_ACP_NS]: { code: 'limit_exceeded' } }, + message, + ) +} + +/** + * GV-18/GV-20 (§12.3): the -32602 error bodies the golden vectors pin. Built + * with the RAW RequestError constructor because the SDK's `invalidParams` + * static prepends "Invalid params: " to the message — the fixtures require + * the message field EXACTLY ('Client-supplied MCP servers are disabled on + * this transport' / 'MCP server URL host is a private/loopback address'). + */ +function makeMcpGateError( + code: 'client_mcp_disabled' | 'mcp_url_blocked', + message: string, +): RequestError { + return new RequestError(-32602, message, { + [OPENBUFF_ACP_NS]: { code }, + }) +} + +/** + * GV-20 (§12.3 SSRF): the literal private/loopback/link-local IPv4 ranges a + * client-supplied MCP URL host is refused for (CIDR network + mask). + */ +const PRIVATE_IPV4_RANGES: ReadonlyArray<{ network: number; mask: number }> = [ + { network: 0x7f000000, mask: 0xff000000 }, // 127.0.0.0/8 (loopback) + { network: 0x0a000000, mask: 0xff000000 }, // 10.0.0.0/8 (private) + { network: 0xac100000, mask: 0xfff00000 }, // 172.16.0.0/12 (private) + { network: 0xc0a80000, mask: 0xffff0000 }, // 192.168.0.0/16 (private) + { network: 0xa9fe0000, mask: 0xffff0000 }, // 169.254.0.0/16 (link-local) +] + +/** Parses a dotted-quad IPv4 literal into its 32-bit value, else null. */ +function parseIpv4Literal(host: string): number | null { + const parts = host.split('.') + if (parts.length !== 4) return null + let value = 0 + for (const part of parts) { + if (!/^\d{1,3}$/.test(part)) return null + const octet = Number(part) + if (octet > 255) return null + value = (value << 8) | octet + } + return value >>> 0 +} + +/** + * Expands an IPv6 literal (no brackets, no zone id) to its eight 16-bit + * groups, handling one `::` compression. Returns null for anything that is + * not a plain hex-group IPv6 literal (including IPv4-embedded forms, which + * stay the connect-time DNS pinning layer's job). + */ +function expandIpv6Groups(host: string): number[] | null { + const doubleColon = host.indexOf('::') + let parts: string[] + if (doubleColon !== -1) { + if (host.indexOf('::', doubleColon + 1) !== -1) return null + const head = doubleColon > 0 ? host.slice(0, doubleColon).split(':') : [] + const tail = + doubleColon + 2 < host.length + ? host.slice(doubleColon + 2).split(':') + : [] + const fill = 8 - head.length - tail.length + if (fill < 1) return null + parts = [ + ...head, + ...Array(fill).fill('0'), + ...tail, + ] + } else { + parts = host.split(':') + if (parts.length !== 8) return null + } + const groups: number[] = [] + for (const part of parts) { + if (part.includes('.')) return null + if (!/^[0-9a-fA-F]{1,4}$/.test(part)) return null + groups.push(parseInt(part, 16)) + } + return groups +} + +/** + * GV-20: true for a URL hostname that is the literal `localhost` or a + * literal private/loopback/link-local IP address. Non-literal hostnames + * (names that are neither dotted-quad IPv4 nor IPv6 literals) pass through — + * the connect-time DNS pinning (`common/src/mcp/dns-pinning.ts`) stays the + * second layer. + */ +function isPrivateOrLoopbackHost(hostname: string): boolean { + // A zone id (`fe80::1%eth0`) never reaches this gate through WHATWG URL + // parsing, but stripping keeps the predicate honest if a raw host arrives. + const host = hostname.toLowerCase().replace(/%[0-9a-z]+$/, '') + const unbracketed = + host.startsWith('[') && host.endsWith(']') ? host.slice(1, -1) : host + if (unbracketed === 'localhost') return true + const ipv4 = parseIpv4Literal(unbracketed) + if (ipv4 !== null) { + // `>>> 0` is REQUIRED on the whole comparison: `&` coerces to signed + // int32, so for hosts >= 2^31 (172.16/12, 192.168/16, 169.254/16) the + // masked left side goes negative while `range.network` stays a positive + // double and the ranges would never match. Do not drop the unsigned + // conversion in a refactor. + return PRIVATE_IPV4_RANGES.some( + (range) => ((ipv4 & range.mask) >>> 0) === range.network, + ) + } + const groups = expandIpv6Groups(unbracketed) + if (groups === null) return false + // ::1 (loopback). + if (groups.slice(0, 7).every((group) => group === 0) && groups[7] === 1) { + return true + } + // fe80::/10 (link-local) and fc00::/7 (unique local). + if ((groups[0]! & 0xffc0) === 0xfe80) return true + if ((groups[0]! & 0xfe00) === 0xfc00) return true + return false +} + +/** + * §12.6: enforces the prompt-total (8 MiB) and per-decoded-image (5 MiB) + * limits over the already-parsed content blocks. `prompt` is text-only for + * the handler, but the byte accounting covers every block so a large image or + * embedded resource cannot smuggle an oversized turn past the wire limit. + * A breach throws -32602 `limit_exceeded` before any handler runs. + */ +function enforcePromptLimits(prompt: PromptRequest['prompt']): void { + let total = 0 + for (const block of prompt) { + if (block.type === 'text') { + total += Buffer.byteLength(block.text, 'utf8') + } else if (block.type === 'image') { + // The wire carries base64; the limit is on the DECODED size (~3/4). + const decodedBytes = Math.floor(block.data.length * 0.75) + if (decodedBytes > MAX_IMAGE_BYTES) { + throw makeLimitExceeded( + `ACP prompt image exceeds the ${MAX_IMAGE_BYTES}-byte per-image limit.`, + ) + } + total += decodedBytes + } else if (block.type === 'audio') { + total += Buffer.byteLength(block.data, 'utf8') + } else if (block.type === 'resource') { + const resource = block.resource + if ('text' in resource && typeof resource.text === 'string') { + total += Buffer.byteLength(resource.text, 'utf8') + } else if ('blob' in resource && typeof resource.blob === 'string') { + total += Math.floor(resource.blob.length * 0.75) + } + } else { + // resource_link and any future block: count a bounded proxy. + total += Buffer.byteLength(JSON.stringify(block), 'utf8') + } + } + if (total > MAX_PROMPT_TOTAL_BYTES) { + throw makeLimitExceeded( + `ACP prompt exceeds the ${MAX_PROMPT_TOTAL_BYTES}-byte total limit.`, + ) + } +} + +/** + * Builds the replay `session/update` notifications for one persisted history + * message (§4.1). Reasoning is dropped; tool calls are emitted with a final + * status and NO `rawInput`/`rawOutput`/receipts/capabilities (GV-26). Every + * replayed text passes through `sanitizeOutbound` so a stored `cap.v3.` token + * or credential can never reach the wire on replay. + */ +function replayMessagesToUpdates( + sessionId: string, + messages: AcpReplayMessage[], +): SessionNotification[] { + const updates: SessionNotification[] = [] + for (const message of messages) { + if (message.variant === 'user' || message.variant === 'agent') { + const text = message.text + if (typeof text !== 'string' || text.length === 0) continue + updates.push({ + sessionId, + update: { + sessionUpdate: + message.variant === 'user' + ? 'user_message_chunk' + : 'agent_message_chunk', + content: { type: 'text', text: sanitizeOutbound(text) }, + }, + }) + continue + } + if (message.variant === 'tool') { + const toolCallId = message.toolCallId + if (typeof toolCallId !== 'string' || toolCallId.length === 0) continue + const title = + typeof message.toolName === 'string' && message.toolName.length > 0 + ? message.toolName + : 'tool' + const locations = (message.paths ?? []) + .filter((path): path is string => typeof path === 'string') + .map((path) => ({ path: sanitizeOutbound(path) })) + // Final status, and deliberately NO rawInput/rawOutput/_meta: replayed + // tool calls carry no receipts or capabilities (§4.1, GV-26). + updates.push({ + sessionId, + update: { + sessionUpdate: 'tool_call', + toolCallId: sanitizeOutbound(toolCallId), + title: sanitizeOutbound(title), + status: 'completed', + ...(locations.length > 0 ? { locations } : {}), + }, + }) + } + } + return updates +} + +/** + * Derives the §5 `AcpReverseRequests` seam from the per-connection surface + * when the host injected none. The SDK `AgentSideConnection` the serve + * transports pass implements the full client-method surface; `createTerminal` + * is adapted from the SDK's `TerminalHandle` onto the plain + * `CreateTerminalResponse` shape `AcpReverseRequests` models. Returns + * `undefined` for a bare session-update sink (test fakes, in-process + * embeddings) so the bridge's fail-closed behavior is unchanged there. + */ +function reverseRequestsFromConnection( + connection: AcpAgentConnection, +): AcpReverseRequests | undefined { + const { requestPermission, readTextFile, writeTextFile, createTerminal } = + connection + if ( + typeof requestPermission !== 'function' || + typeof readTextFile !== 'function' || + typeof writeTextFile !== 'function' || + typeof createTerminal !== 'function' + ) { + return undefined + } + return { + requestPermission: (params) => requestPermission.call(connection, params), + readTextFile: (params) => readTextFile.call(connection, params), + writeTextFile: async (params) => { + await writeTextFile.call(connection, params) + }, + createTerminal: async (params) => { + const handle = await createTerminal.call(connection, params) + return { terminalId: handle.id } + }, + } +} + +/** + * Builds the agent-side ACP v1 skeleton: + * initialize/newSession/loadSession/prompt/cancel over a private session + * map, plus the Openbuff extension surface (extMethod) and the + * reverse-request seam for prompt turns. The prompt handler and update sink + * are injected so the real core run (P1-T2) stays out of the protocol layer. + */ +export function createAcpAgent(options: AcpAgentOptions): AcpAgent { + const sessions = new Map() + // §3.2: the negotiated extension set is per-connection, established at + // initialize and consulted to gate every extension notification. + let negotiatedExt: NegotiatedExt | undefined + // §4.2: the client capabilities snapshot projected for the serve bridge, + // captured at initialize (always before the first prompt of the session). + let clientCapabilities: { elicitation?: { form?: unknown } } | undefined + // §5/§4.2 production wiring: the per-connection reverse-request seams. + // An explicitly injected seam wins (back-compat); otherwise it is derived + // from the connection's own client-method surface when it has one. + const { connection } = options + const reverseRequests = + options.reverseRequests ?? reverseRequestsFromConnection(connection) + const { request: connectionRequest, notify: connectionNotify } = connection + const onReverseRequest = + typeof connectionRequest === 'function' + ? (method: string, params: unknown): Promise => + connectionRequest.call(connection, method, params) + : undefined + const onCancelRequest = + typeof connectionNotify === 'function' + ? (requestId: string): Promise => + connectionNotify.call(connection, '$/cancel_request', { requestId }) + : undefined + + /** SEC-7 containment helpers bound to the injected project root. */ + const resolveSessionCwd = (input: string): string | null => + options.projectRoot === undefined + ? input + : resolveContainedProjectDir(options.projectRoot, input) + + const rejectUncontainedCwd = (cwd: string): string => { + const resolved = resolveSessionCwd(cwd) + if (resolved === null) { + throw RequestError.invalidParams( + { cwd }, + `ACP session cwd must be absolute and inside the server project root.`, + ) + } + return resolved + } + + const rejectUnallowedAdditionalDirectories = ( + additionalDirectories: string[] | undefined, + ): void => { + if (options.projectRoot === undefined) return + if (!additionalDirectories || additionalDirectories.length === 0) return + const allowed = new Set(options.allowedAdditionalDirectories ?? []) + for (const directory of additionalDirectories) { + const decoded = decodeContainedPathInput(directory) + // Allowlist membership is the whole gate (SEC-7): an additional + // directory legitimately lives OUTSIDE the project root (--add-dir), so + // project containment is not demanded here — only the one-shot + // percent-decode/authority check above plus membership in the + // host-resolved allowlist. + if (decoded === null || !allowed.has(decoded)) { + throw RequestError.invalidParams( + { additionalDirectories }, + `ACP additionalDirectories entry '${directory}' is not in the server's allowed additional directories.`, + ) + } + } + } + + /** + * §12.6 LRU session tracking: `sessions` is a plain Map whose insertion + * order IS the recency order. Re-inserting a session id on every access + * (delete + set) moves it to the most-recently-used end, so the FIRST key + * is always the least-recently-used session. + */ + const touchSession = (sessionId: string): AcpSessionState | undefined => { + const state = sessions.get(sessionId) + if (state === undefined) return undefined + sessions.delete(sessionId) + sessions.set(sessionId, state) + return state + } + + /** + * §12.6 live-session cap with LRU eviction: the cap bounds per-connection + * memory, so inserting beyond 16 evicts the least-recently-used session + * (the first Map key) instead of rejecting the insert. Eviction is + * best-effort cleanup consistent with the existing teardown: the victim's + * in-flight AbortController — if any — is aborted, exactly what `cancel` + * does, and the session record is dropped. + * + * An ACTIVE session (one with a prompt turn in flight) is never chosen as + * the victim: recency is touched on every access (prompt/cancel/load), + * and eviction additionally skips any session whose AbortController is + * still live. When EVERY live session has a turn in flight there is + * nothing safe to evict and the insert fails closed with the same + * limit_exceeded error the cap has always produced. + */ + const insertSessionBounded = ( + sessionId: string, + state: AcpSessionState, + ): void => { + if (!sessions.has(sessionId) && sessions.size >= MAX_LIVE_SESSIONS) { + let victimId: string | undefined + for (const candidateId of sessions.keys()) { + if (sessions.get(candidateId)?.abortController === null) { + victimId = candidateId + break + } + } + if (victimId === undefined) { + throw makeLimitExceeded( + `ACP server holds at most ${MAX_LIVE_SESSIONS} live sessions and every one has a turn in flight.`, + ) + } + // Best-effort teardown of the evicted session, consistent with what + // `cancel` does for an in-flight turn. + sessions.get(victimId)?.abortController?.abort() + sessions.delete(victimId) + } + // Re-inserting (delete + set) refreshes LRU recency for re-loads. + sessions.delete(sessionId) + sessions.set(sessionId, state) + } + + // SEC: ext methods carry a client-supplied sessionId, so the store-serving + // paths must only answer for sessions THIS connection created + // (newSession) or restored (loadSession). The private `sessions` map is + // per-connection by construction and IS the per-connection session-id + // registry the ext dispatcher checks against. + const assertOwnedSession = (sessionId: string): void => { + if (!sessions.has(sessionId)) { + throw RequestError.invalidParams( + { sessionId }, + `Unknown ACP session id '${sessionId}' for this connection.`, + ) + } + } + const sessionIdOf = (data: unknown): string => { + const sessionId = (data as { sessionId?: unknown } | null)?.sessionId + return typeof sessionId === 'string' ? sessionId : '' + } + + return { + initialize(params: InitializeRequest): InitializeResponse { + negotiatedExt = negotiateOpenbuffExt(params) + // §4.2: project the negotiated client capabilities onto the slice the + // serve bridge consults. `form: null` means "not advertised" per the + // ACP schema, so it is treated exactly like an omitted form capability. + const elicitationForm = params.clientCapabilities?.elicitation?.form + clientCapabilities = + elicitationForm != null + ? { elicitation: { form: elicitationForm } } + : undefined + // §3.3: the honest P1 capability advertisement. `loadSession` is true + // (session/load is implemented below). `promptCapabilities` claims ONLY + // what the prompt path actually consumes: `prompt()` forwards just the + // `type === 'text'` blocks to the handler, so image and embedded-context + // (resource) blocks are silently dropped — advertising `image: true` or + // `embeddedContext: true` would promise input the wire path discards, + // and a compliant client sending that content would lose it without any + // error. `sessionCapabilities` is deliberately NOT advertised: + // this agent implements neither session/list nor session/close, and a + // compliant client that saw them advertised would call methods the wire + // layer rejects with -32601. The `chatgpt-oauth` auth method is + // advertised ONLY when the client can run a terminal + // (`clientCapabilities.auth.terminal`). + const agentCapabilities: AgentCapabilities = { + loadSession: true, + promptCapabilities: { + image: false, + audio: false, + embeddedContext: false, + }, + mcpCapabilities: { http: true, sse: true }, + } + if (negotiatedExt !== undefined) { + // §3.2: respond with the ext intersection and the live capability map. + const capabilities: CapabilityMapV1 = defaultCapabilityMapV1({ + journalAvailable: options.sessionData?.hasJournal() ?? false, + }) + agentCapabilities._meta = { + [OPENBUFF_ACP_NS]: { + extVersion: negotiatedExt.extVersion, + extensions: negotiatedExt.extensions, + capabilities, + }, + } + } + const authMethods: AuthMethod[] = [] + if (params.clientCapabilities?.auth?.terminal === true) { + authMethods.push({ + id: 'chatgpt-oauth', + name: 'Sign in with ChatGPT', + type: 'terminal', + args: ['login', 'chatgpt'], + }) + } + return { + protocolVersion: PROTOCOL_VERSION, + agentCapabilities, + authMethods, + } + }, + + async loadSession( + params: LoadSessionRequest, + ): Promise { + if (!options.loadHandler) { + // session/load is advertised, but no resume path was injected: fail + // closed with a JSON-RPC method-not-found error rather than silently + // half-loading the session. + throw RequestError.methodNotFound('session/load') + } + // SEC-7 (§12.5 Reload): the client-supplied cwd must resolve inside the + // project root AND match the session's recorded project root — a loaded + // session must never silently re-point to a different root. + const resolvedCwd = rejectUncontainedCwd(params.cwd) + const existing = sessions.get(params.sessionId) + if (existing !== undefined && existing.cwd !== resolvedCwd) { + throw RequestError.invalidParams( + { cwd: params.cwd }, + `ACP session/load cwd does not match the session's recorded project root.`, + ) + } + // Register in the same private map newSession uses so subsequent + // prompt/cancel calls work against the loaded session id. The bounded + // insert also refreshes the §12.6 LRU recency of the reloaded session, + // and a still-live in-flight turn keeps its controller so the + // concurrent-prompt guard in `prompt` stays honest. + insertSessionBounded(params.sessionId, { + cwd: resolvedCwd, + mcpServers: params.mcpServers, + abortController: existing?.abortController ?? null, + }) + await options.loadHandler({ sessionId: params.sessionId }) + // §4.1 (GV-26): replay the persisted chat history as sanitized + // user/agent message chunks and final-status tool calls — reasoning + // dropped, no receipts/capabilities — BEFORE the load result. + if (options.historyLoader) { + const history = await options.historyLoader({ + sessionId: params.sessionId, + }) + for (const update of replayMessagesToUpdates(params.sessionId, history)) { + await options.connection.sessionUpdate(update) + } + } + // §6.1: a restored session also never answers capabilities/get with + // -32601. Journal replay stores an honest re-derived map only when the + // journal carried a capabilities line, so a restored session whose + // journal predates the §6.1 store still gets the baseline here. + const sessionData = options.sessionData + if ( + sessionData && + sessionData.getCapabilities(params.sessionId) === undefined + ) { + sessionData.setCapabilities( + params.sessionId, + defaultCapabilityMapV1({ + journalAvailable: sessionData.hasJournal(), + }), + ) + } + // LoadSessionResponse's fields (modes/configOptions/_meta) are all + // optional per the SDK declarations; this skeleton restores no mode + // state, so the exact empty response shape is the honest reply. + return {} + }, + + // Async so a containment/limit rejection is a rejected promise, never a + // synchronous throw: callers (and the wire layer) await this method and + // expect the JSON-RPC error to surface through the awaited result. The + // SDK's Agent interface allows MaybePromise. + async newSession( + params: NewSessionRequest, + ): Promise { + // SEC-7 (§12.5): validate cwd containment and the additionalDirectories + // allowlist BEFORE any session record is created. + const resolvedCwd = rejectUncontainedCwd(params.cwd) + rejectUnallowedAdditionalDirectories(params.additionalDirectories) + // GV-18 (§12.3 transport gating): a transport that disabled client MCP + // refuses any request carrying mcpServers BEFORE any session record is + // created or process spawned (the GV-18 fixture expects exactly + // -32602 + 'client_mcp_disabled' + zero spawned processes). + if ( + options.allowClientMcpServers === false && + params.mcpServers.length > 0 + ) { + throw makeMcpGateError( + 'client_mcp_disabled', + 'Client-supplied MCP servers are disabled on this transport', + ) + } + // GV-20 (§12.3 SSRF): every client-supplied MCP server entry carrying + // an http/sse URL has its literal host checked BEFORE any connection + // is attempted. Literal private/loopback/link-local hosts are refused; + // non-literal hostnames pass through (connect-time DNS pinning in + // common/src/mcp/dns-pinning.ts stays the second layer). + for (const server of params.mcpServers) { + if (!('url' in server) || typeof server.url !== 'string') continue + let hostname: string + try { + hostname = new URL(server.url).hostname + } catch { + // An unparseable URL carries no literal host to reject here; the + // connect-time DNS pinning stays the second layer. + continue + } + if (isPrivateOrLoopbackHost(hostname)) { + throw makeMcpGateError( + 'mcp_url_blocked', + 'MCP server URL host is a private/loopback address', + ) + } + } + const sessionId = randomUUID() + insertSessionBounded(sessionId, { + cwd: resolvedCwd, + mcpServers: params.mcpServers, + abortController: null, + }) + // §6.1: seed the honest baseline capability map at session creation so + // `_openbuff.dev/capabilities/get` never fails for an open session — + // not even between session/new and the first prompt. The journal flags + // reflect the injected store's real posture (a purely in-memory store + // advertises no resume/replay), and the map is always derived locally. + const sessionData = options.sessionData + if ( + sessionData && + sessionData.getCapabilities(sessionId) === undefined + ) { + sessionData.setCapabilities( + sessionId, + defaultCapabilityMapV1({ + journalAvailable: sessionData.hasJournal(), + }), + ) + } + return { sessionId } + }, + + async prompt(params: PromptRequest): Promise { + // Touch LRU recency first: an actively-prompted session is the + // most-recently-used one and can never be the §12.6 eviction victim. + const session = touchSession(params.sessionId) + if (!session) { + // A real JSON-RPC invalid-params error (not a bare Error) so clients + // see a protocol-appropriate failure for an unknown session id. + throw RequestError.invalidParams( + { sessionId: params.sessionId }, + `Unknown ACP session id '${params.sessionId}'.`, + ) + } + // One prompt turn per session at a time: the previous behavior silently + // REPLACED the in-flight AbortController here, which left the first + // turn uncancellable (its cancel signal was dropped on the floor). + // Fail closed instead: a concurrent prompt on the same session is + // rejected and may be retried once the in-flight turn settles. The + // wire shape is pinned BYTE-FOR-BYTE by the GV-14 golden fixture: + // JSON-RPC -32600, message 'A prompt is already running for this + // session', data { 'openbuff.dev': { code: 'prompt_in_flight' } }. + if (session.abortController !== null) { + throw new RequestError( + -32600, + 'A prompt is already running for this session', + { [OPENBUFF_ACP_NS]: { code: 'prompt_in_flight' } }, + ) + } + // §12.6: prompt-total and per-image limits, enforced before any handler + // runs so an oversized turn never reaches the core. + enforcePromptLimits(params.prompt) + // One AbortController per prompt turn: `cancel` aborts the turn in + // flight, and the controller is released when the turn settles so a + // subsequent prompt is accepted again. + const abortController = new AbortController() + session.abortController = abortController + // The baseline contract here only needs text: concatenate the + // `type === 'text'` blocks and let the injected handler decide what to + // do with richer blocks (images, resources) in later phases. + const promptText = params.prompt + .flatMap((block) => (block.type === 'text' ? [block.text] : [])) + .join('') + try { + const result = await options.promptHandler({ + sessionId: params.sessionId, + promptText, + mcpServers: session.mcpServers, + reverseRequests, + clientCapabilities, + eventsExtensionEnabled: negotiatedExt?.extensions.includes('events') + ? true + : undefined, + onReverseRequest, + onCancelRequest, + update: async (chunkText) => { + await options.connection.sessionUpdate({ + sessionId: params.sessionId, + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: chunkText }, + }, + }) + }, + signal: abortController.signal, + }) + return { stopReason: result.stopReason } + } finally { + // The turn settled (finished, errored, or was cancelled): release the + // controller so the session accepts its next prompt. Only this + // turn's own controller is cleared. + if (session.abortController === abortController) { + session.abortController = null + } + } + }, + + cancel(params: CancelNotification): void { + // Notifications carry no response, so an unknown session id is a no-op + // here rather than an error surface. Recency is touched on cancel too, + // so an actively-driven session never looks stale to §12.6 LRU + // eviction. + touchSession(params.sessionId)?.abortController?.abort() + }, + + async extMethod( + method: string, + params: Record, + ): Promise> { + // Ext-v1 namespaced methods (§6) dispatch first: the legacy `openbuff/*` + // names below never overlap the `_openbuff.dev/` prefix. Params are + // validated with the ext-v1 schemas, an injected extensionHandler keeps + // winning (back-compat), and otherwise the live store serves the method. + if (method.startsWith(OPENBUFF_EXT_METHOD_PREFIX)) { + const known = OPENBUFF_EXT_METHODS.find( + (candidate) => candidate === method, + ) + if (!known) { + // Unknown ext-v1 methods follow the ACP rule for unknown extension + // methods: -32601. + throw RequestError.methodNotFound(method) + } + const { params: paramsSchema } = openbuffExtMethodSchemas[known] + const parsed = paramsSchema.safeParse(params) + if (!parsed.success) { + throw RequestError.invalidParams( + params, + `Invalid params for ACP extension '${method}': ${parsed.error.issues + .map((issue) => `${issue.path.join('.')}: ${issue.message}`) + .join('; ')}`, + ) + } + if (options.extensionHandler) { + const result = await options.extensionHandler({ + method, + params: parsed.data, + }) + if ( + result === null || + typeof result !== 'object' || + Array.isArray(result) + ) { + throw RequestError.internalError( + { method }, + `ACP extension handler for '${method}' returned a non-object response.`, + ) + } + return result as Record + } + // The live store serves per-session data, so the requested sessionId + // must belong to THIS connection (an injected extensionHandler above + // is a bridge/test seam and stays ungated). + if (options.sessionData) { + assertOwnedSession(sessionIdOf(parsed.data)) + } + return dispatchExtV1Method( + known, + parsed.data as Record, + options.sessionData, + ) + } + if (!ACP_EXTENSION_METHODS.includes(method as AcpExtensionMethod)) { + // Unknown extension methods fail closed as JSON-RPC method-not-found. + throw RequestError.methodNotFound(method) + } + const { params: paramsSchema } = + acpExtensionSchemas[method as AcpExtensionMethod] + const parsed = paramsSchema.safeParse(params) + if (!parsed.success) { + throw RequestError.invalidParams( + params, + `Invalid params for ACP extension '${method}': ${parsed.error.issues + .map((issue) => `${issue.path.join('.')}: ${issue.message}`) + .join('; ')}`, + ) + } + // An explicitly injected handler wins for every method (back-compat + // with the static-data seam tests rely on). + if (options.extensionHandler) { + const result = await options.extensionHandler({ + method, + params: parsed.data, + }) + if ( + result === null || + typeof result !== 'object' || + Array.isArray(result) + ) { + // The Agent.extMethod contract returns a JSON object response; a + // non-object handler result would corrupt the wire, so fail closed. + throw RequestError.internalError( + { method }, + `ACP extension handler for '${method}' returned a non-object response.`, + ) + } + return result as Record + } + // Without an injected handler, the two read-only extension methods + // read the live per-session store. The params schema above already + // guaranteed sessionId (and the optional limit), so the narrowing + // below is schema-backed. + if (options.sessionData && method === 'openbuff/getReceipts') { + const { sessionId, limit } = parsed.data as { + sessionId: string + limit?: number + } + assertOwnedSession(sessionId) + return options.sessionData.getReceipts(sessionId, limit) + } + if (options.sessionData && method === 'openbuff/gateState') { + const { sessionId } = parsed.data as { sessionId: string } + assertOwnedSession(sessionId) + return options.sessionData.getGateState(sessionId) + } + // 'openbuff/askUser' (and any other method with no store fallback) + // has nothing to answer it: fail closed as method-not-found. + throw RequestError.methodNotFound(method) + }, + + // The Agent interface requires authenticate even though this skeleton + // advertises no auth methods; a client that calls it anyway gets a clear + // protocol error instead of a silent success. + authenticate(): never { + throw RequestError.invalidParams( + {}, + 'ACP authentication is not supported by this agent.', + ) + }, + } +} + +/** + * Dispatches one KNOWN ext-v1 `_openbuff.dev/*` method (§6) against the live + * store. Wire shapes pinned by the golden vectors: `lanes/create` and + * `lanes/land` are -32601 with `data["openbuff.dev"] = {code: + * 'capability_disabled', capability: 'lanes'}` while lanes are unsupported + * (GV-10), and an unknown receipt id is -32002 with `code: + * 'receipt_not_found'` (§6.2). + */ +async function dispatchExtV1Method( + method: OpenbuffExtMethod, + params: Record, + sessionData: AcpSessionData | undefined, +): Promise> { + if (!sessionData) { + // Without the live store there is nothing to serve ext-v1 methods from: + // fail closed (mirrors the legacy askUser path). + throw RequestError.methodNotFound(method) + } + const data = params as { sessionId?: unknown; receiptId?: unknown } + const sessionId = typeof data.sessionId === 'string' ? data.sessionId : '' + switch (method) { + case '_openbuff.dev/capabilities/get': { + const capabilities = sessionData.getCapabilities(sessionId) + if (capabilities === undefined) { + // The ext surface was never negotiated/stored for this session. + throw RequestError.methodNotFound(method) + } + return capabilities + } + case '_openbuff.dev/receipts/get': { + const receiptId = typeof data.receiptId === 'string' ? data.receiptId : '' + const envelope = sessionData.getReceipt(sessionId, receiptId) + if (envelope === undefined) { + throw new RequestError( + -32002, + `Unknown receipt id '${receiptId}'.`, + { 'openbuff.dev': { code: 'receipt_not_found', receiptId } }, + ) + } + // GV-07: the redaction-enforcing ext-v1 wire contract is validated on + // EVERY serve path — including journal-restored sessions — so a tampered + // journal (or any future internal regression) can never publish + // content-bearing mutation fields (`afterContent`/`patch`/`editAnchor`) + // or fresh cap.v3 tokens across the wire. Envelopes the store records + // are built with the §6.2 wire projection (toWireMutation), so every + // legitimately recorded receipt conforms to this strict shape and is + // served; only a non-conforming envelope has no wire representation and + // is unaddressable (receipt_not_found). + const parsedEnvelope = receiptEnvelopeV1Schema.safeParse(envelope) + if (!parsedEnvelope.success) { + throw new RequestError( + -32002, + `Unknown receipt id '${receiptId}'.`, + { 'openbuff.dev': { code: 'receipt_not_found', receiptId } }, + ) + } + return { + receipt: parsedEnvelope.data, + } as Record + } + case '_openbuff.dev/lanes/list': + return { + lanes: [sessionData.getMainLane(sessionId)], + } + case '_openbuff.dev/lanes/create': + case '_openbuff.dev/lanes/land': + throw new RequestError(-32601, 'Method not found', { + 'openbuff.dev': { code: 'capability_disabled', capability: 'lanes' }, + }) + case '_openbuff.dev/gate_state/get': + return sessionData.getGateStateV1(sessionId) + } +} + +/** + * Derives the serve-time agent options, auto-wiring `session/load` restore + * from an injected journal-backed store. When `sessionData` is present and no + * `loadHandler` was supplied, the returned options carry a derived + * loadHandler that replays the durable journal + * (`AcpSessionData.restoreFromJournal`) so a restored session recovers its + * receipt history and last gate-state snapshot. A bridge that wants no + * restore simply omits `sessionData` or supplies its own `loadHandler` (a + * caller-provided loadHandler always wins). The input object is never + * mutated: a new object is built only in the derive branch, otherwise the + * input is returned unchanged. + */ +export function resolveAcpServeOptions( + options: Omit, +): Omit { + if (!options.sessionData || options.loadHandler) { + return options + } + // Capture the store in a local const so the derived handler closes over the + // value observed here, not a later-cleared field on `options`. + const sessionData = options.sessionData + return { + ...options, + loadHandler: async ({ sessionId }) => { + // restoreFromJournal is best-effort and returns a boolean we ignore: + // loadSession's own success is independent of whether a journal existed. + await sessionData.restoreFromJournal(sessionId) + }, + } +} + +/** + * Serves the agent over line-delimited JSON on stdio. Only call from a real + * CLI entry (P1-T2 owns `openbuff serve`); it stays importable so the wiring + * remains reviewable while the Agent object above stays testable. + * + * §12.6 inbound limit: the installed `@agentclientprotocol/sdk` (1.5.0) + * `ndJsonStream` has NO `maxMessageBytes` option — its `LineBuffer` accumulates + * a single newline-delimited line without any byte cap (the design doc's 32 + * MiB default describes a newer SDK). The 16 MiB cap is therefore enforced at + * the ONLY seam this layer owns: the input `ReadableStream` is byte-counted + * per NDJSON line and the connection's readable is errored (closing the + * connection) when a line exceeds 16 MiB, before the SDK ever parses it. This + * is the tighten-16-MiB behavior the contract requires, implemented at the + * correct layer; the installed SDK exposes no `maxMessageBytes` knob, so the + * guard is the only enforcement point. On a breach the outbound side is ended + * too (see `limitNdJsonLineBytes`), so a peer observes the connection close + * instead of hanging on a pending request. + */ +export function serveAcpOverStdio( + options: Omit, +): void { + // Auto-derive the session/load restore handler from an injected journal- + // backed store once, then reuse it for every connection's agent. + const resolvedOptions = resolveAcpServeOptions(options) + // ndJsonStream(output, input): the agent's stdout is the output wire and its + // stdin is the input wire. Node/Bun streams are converted to their web + // counterparts because the SDK's Stream type is defined over web streams; + // the cast bridges the node-vs-DOM WritableStream typing duality. + // NEW-4 chokepoint (§12.8): every serialized frame — including + // SDK-generated JSON-RPC errors and agent→client requests — passes through + // sanitizeOutbound before the wire. Clean frames stay byte-identical. + const stream = ndJsonStream( + sanitizeOutboundStream( + Writable.toWeb(process.stdout) as unknown as WritableStream, + ), + limitNdJsonLineBytes( + Readable.toWeb(process.stdin) as unknown as ReadableStream, + MAX_INBOUND_FRAME_BYTES, + // §12.6: an oversized inbound frame closes the connection in BOTH + // directions — ending stdout makes the peer observe the close instead + // of hanging on a pending request forever. + () => process.stdout.end(), + ), + ) + // AgentSideConnection is marked @deprecated in favor of the `agent()` app + // builder, but it is the canonical direct wire surface and stable for this + // skeleton. The connection lives for the lifetime of the process; the CLI + // entry keeps running until the underlying stream closes. + void new AgentSideConnection( + (conn) => createAcpAgent({ ...resolvedOptions, connection: conn }), + stream, + ) +} + +/** + * §12.6 inbound guard: wraps the NDJSON byte stream so a single line longer + * than `maxBytes` errors the readable (which the SDK surfaces as a connection + * close) instead of letting the SDK's uncapped `LineBuffer` accumulate it. + * The limit applies per LINE: the byte counter accumulates non-newline bytes + * and resets at every newline, so one chunk carrying several sub-limit lines + * can never close a legitimate connection by chunk+line accumulation. On a + * breach the underlying input is cancelled (no further bytes are read) and + * `onLimitExceeded` fires BEFORE the readable errors, so the host can tear + * down the outbound direction too and a peer with a request in flight + * observes the connection close instead of hanging forever. Exported so the + * wire-limit test pins the exact guard `serveAcpOverStdio` installs. + */ +export function limitNdJsonLineBytes( + input: ReadableStream, + maxBytes: number, + onLimitExceeded?: () => void, +): ReadableStream { + const reader = input.getReader() + let currentLineBytes = 0 + return new ReadableStream({ + async pull(controller) { + for (;;) { + const { value, done } = await reader.read() + if (done) { + controller.close() + return + } + if (!value) continue + // The limit applies per LINE, not per chunk: the counter accumulates + // non-newline bytes and resets at EVERY newline, so a chunk that + // carries several lines is accounted line-by-line and legitimate + // sub-limit lines can never trip the guard by accumulation. + for (const byte of value) { + if (byte === 0x0a) { + currentLineBytes = 0 + continue + } + currentLineBytes += 1 + if (currentLineBytes > maxBytes) { + // Tear down BOTH directions: the readable error closes the agent + // side, and the callback lets the host end the outbound side. + onLimitExceeded?.() + void reader.cancel().catch(() => {}) + controller.error( + new Error( + `ACP inbound NDJSON line exceeds the ${maxBytes}-byte limit; closing connection.`, + ), + ) + return + } + } + controller.enqueue(value) + return + } + }, + cancel(reason) { + return reader.cancel(reason) + }, + }) +} diff --git a/sdk/src/services/acp/ext-methods.ts b/sdk/src/services/acp/ext-methods.ts new file mode 100644 index 0000000000..b5c4a1bccc --- /dev/null +++ b/sdk/src/services/acp/ext-methods.ts @@ -0,0 +1,157 @@ +import z from 'zod/v4' + +import { + capabilityMapV1Schema, + gateStateV1Schema, + laneV1Schema, + receiptEnvelopeV1Schema, +} from '@codebuff/common/protocol/acp-ext-v1' +import type { + CapabilityMapV1, + GateStateV1, + LaneV1, + ReceiptEnvelopeV1, +} from '@codebuff/common/protocol/acp-ext-v1' + +/** + * The ext-v1 ACP extension surface (P1-T1-DESIGN §6): methods are named + * `_openbuff.dev//` and notifications `_openbuff.dev/`, + * with `_meta` payloads under the single key `"openbuff.dev"`. These are the + * v1 namespaced methods (the legacy `openbuff/*` names in ./extensions stay + * for back-compat and are served by the same dispatcher). + */ +export const OPENBUFF_EXT_METHOD_PREFIX = '_openbuff.dev/' + +/** + * The Openbuff extension names the agent can negotiate (§3.2). The agent + * responds to `initialize` with the INTERSECTION of the client's requested + * list and this set, and emits extension notifications only for enabled + * extensions. `lanes` is negotiable now (its shape is frozen) even though + * create/land stay refused until P6. + */ +export const OPENBUFF_SUPPORTED_EXTENSIONS = [ + 'capabilities', + 'receipts', + 'lanes', + 'gate', + 'events', +] as const + +export type OpenbuffSupportedExtension = + (typeof OPENBUFF_SUPPORTED_EXTENSIONS)[number] + +export const OPENBUFF_EXT_METHODS = [ + '_openbuff.dev/capabilities/get', + '_openbuff.dev/receipts/get', + '_openbuff.dev/lanes/list', + '_openbuff.dev/lanes/create', + '_openbuff.dev/lanes/land', + '_openbuff.dev/gate_state/get', +] as const + +export type OpenbuffExtMethod = (typeof OPENBUFF_EXT_METHODS)[number] + +/** The `_openbuff.dev/capabilities_changed` push notification (§6.1). */ +export const OPENBUFF_CAPABILITIES_CHANGED_NOTIFICATION = + '_openbuff.dev/capabilities_changed' + +/** The `_openbuff.dev/gate_state` push notification (§6.4). */ +export const OPENBUFF_GATE_STATE_NOTIFICATION = '_openbuff.dev/gate_state' + +/** + * All session-scoped ext methods carry the ACP session id (§6) — including + * `_openbuff.dev/capabilities/get`: its store lookup is per-session, so an + * empty params object would pin the dispatcher to sessionId '' and make the + * method unreachable for every real session. + */ +const sessionIdParams = z.object({ sessionId: z.string().min(1) }).strict() + +/** `_openbuff.dev/receipts/get` names the receipt's mutation receiptId. */ +const receiptsGetParams = z + .object({ sessionId: z.string().min(1), receiptId: z.string().min(1) }) + .strict() + +/** + * Result contracts (§6): the published wire shapes the dispatcher serves, + * pinned by the common protocol schemas so the compiled JSON-Schema artifact + * can validate ext-v1 RESULTS and not just params. + */ +const capabilitiesGetResult = capabilityMapV1Schema +const receiptsGetResult = z.object({ receipt: receiptEnvelopeV1Schema }).strict() +const lanesListResult = z.object({ lanes: z.tuple([laneV1Schema]) }).strict() +// `lanes/create` and `lanes/land` ALWAYS fail with -32601 capability_disabled +// while lanes are unsupported (GV-10): they never return a result, so the +// honest result contract is `never`. +const lanesUnsupportedResult = z.never() +const gateStateGetResult = gateStateV1Schema + +export const openbuffExtMethodSchemas: Record< + OpenbuffExtMethod, + { params: z.ZodType; result: z.ZodType } +> = { + '_openbuff.dev/capabilities/get': { + params: sessionIdParams, + result: capabilitiesGetResult, + }, + '_openbuff.dev/receipts/get': { + params: receiptsGetParams, + result: receiptsGetResult, + }, + '_openbuff.dev/lanes/list': { + params: sessionIdParams, + result: lanesListResult, + }, + '_openbuff.dev/lanes/create': { + params: sessionIdParams, + result: lanesUnsupportedResult, + }, + '_openbuff.dev/lanes/land': { + params: sessionIdParams, + result: lanesUnsupportedResult, + }, + '_openbuff.dev/gate_state/get': { + params: sessionIdParams, + result: gateStateGetResult, + }, +} + +/** Result shapes (type-level contracts served by the dispatcher). */ +export type CapabilitiesGetResult = CapabilityMapV1 +export type ReceiptsGetResult = { receipt: ReceiptEnvelopeV1 } +export type LanesListResult = { lanes: [LaneV1] } +export type GateStateGetResult = GateStateV1 + +/** + * The honest P1 serve-mode capability map (§6.1) now lives beside the schema + * it populates: `defaultCapabilityMapV1` in + * `@codebuff/common/protocol/acp-ext-v1` is the single canonical builder, so + * every advertising surface (SDK serve mode and the CLI's status-bar chip / + * doctor rows) derives from one source and can never drift on a value change + * (e.g. a sandbox-tier or gate-default update). Re-exported here for + * back-compat with existing internal imports; `journalAvailable` still + * reflects whether the backing store actually mirrors to a durable journal, + * and the map is always derived locally from the serving process's real + * posture — a journal-replayed map is untrusted input and is never served + * verbatim. + */ +export { defaultCapabilityMapV1 } from '@codebuff/common/protocol/acp-ext-v1' + +/** + * Compiles the JSON Schema artifact for every ext-v1 method, keyed by method + * name in OPENBUFF_EXT_METHODS iteration order. Mirrors + * compileAcpExtensionJsonSchemas in ./extensions exactly — `{ params, result }` + * per method — so the X-1 golden-vector pipeline can consume ext-v1 contracts + * the same way it consumes the legacy extension contracts, and the published + * artifact can validate ext-v1 results. + */ +export function compileExtV1JsonSchemas(): Record { + const artifacts: Record = {} + for (const method of OPENBUFF_EXT_METHODS) { + const { params, result } = openbuffExtMethodSchemas[method] + artifacts[method] = { + params: z.toJSONSchema(params, { io: 'input' }), + result: z.toJSONSchema(result, { io: 'input' }), + } + } + return artifacts +} diff --git a/sdk/src/services/acp/extensions.ts b/sdk/src/services/acp/extensions.ts new file mode 100644 index 0000000000..42a3ec0383 --- /dev/null +++ b/sdk/src/services/acp/extensions.ts @@ -0,0 +1,89 @@ +import z from 'zod/v4' + +/** + * Namespaced Openbuff ACP extension method contracts (roadmap decision D9). + * Each entry is a JSON-RPC extension method a client can invoke on the + * Openbuff agent; params and result are pinned here as Zod schemas so the + * wire contract, runtime validation, and the X-1 JSON Schema pipeline all + * share one source of truth. + */ +export const ACP_EXTENSION_METHODS = [ + 'openbuff/getReceipts', + 'openbuff/askUser', + 'openbuff/gateState', +] as const + +export type AcpExtensionMethod = (typeof ACP_EXTENSION_METHODS)[number] + +const getReceiptsParams = z.object({ + sessionId: z.string(), + limit: z.number().optional(), +}) + +/** Mirrors the editor agent's mutationReceipts receipt shape. */ +const getReceiptsResult = z.object({ + receipts: z.array( + z.object({ + operationId: z.string(), + receiptId: z.string(), + paths: z.array(z.string()), + actionIds: z.array(z.string()), + }), + ), +}) + +const askUserParams = z.object({ + sessionId: z.string(), + question: z.string(), + choices: z.array(z.string()), +}) + +const askUserResult = z.object({ + answer: z.string(), +}) + +const gateStateParams = z.object({ + sessionId: z.string(), +}) + +const gateStateResult = z.object({ + phase: z.string(), + currentTask: z.string().nullable(), +}) + +export const acpExtensionSchemas: Record< + AcpExtensionMethod, + { params: z.ZodType; result: z.ZodType } +> = { + 'openbuff/getReceipts': { + params: getReceiptsParams, + result: getReceiptsResult, + }, + 'openbuff/askUser': { + params: askUserParams, + result: askUserResult, + }, + 'openbuff/gateState': { + params: gateStateParams, + result: gateStateResult, + }, +} + +/** + * Compiles the JSON Schema artifact for every Openbuff extension method, + * keyed by method name in ACP_EXTENSION_METHODS iteration order. Mirrors + * compileToolJsonSchemas in common/src/tools/compile-tool-definitions.ts so + * the X-1 golden-vector pipeline can consume extension contracts the same + * way it consumes tool contracts. + */ +export function compileAcpExtensionJsonSchemas(): Record { + const artifacts: Record = {} + for (const method of ACP_EXTENSION_METHODS) { + const { params, result } = acpExtensionSchemas[method] + artifacts[method] = { + params: z.toJSONSchema(params, { io: 'input' }), + result: z.toJSONSchema(result, { io: 'input' }), + } + } + return artifacts +} diff --git a/sdk/src/services/acp/session-data.ts b/sdk/src/services/acp/session-data.ts new file mode 100644 index 0000000000..22939d9d0e --- /dev/null +++ b/sdk/src/services/acp/session-data.ts @@ -0,0 +1,949 @@ +import { appendFile, mkdir, readFile, writeFile } from 'node:fs/promises' + +import { + capabilityMapV1Schema, + toWireMutation as toWireMutationV1, + type CapabilityMapV1, + type GateStateV1, + type LaneV1, + type WireFileMutationResultV1, +} from '@codebuff/common/protocol/acp-ext-v1' +import type { + FileMutationActionV1, + FileMutationResultV1, +} from '@codebuff/common/tools/results/filesystem' + +import { defaultCapabilityMapV1 } from './ext-methods' + +/** + * Structural mirror of the run loop's `FilesystemMutationEvent` + * (sdk/src/run.ts). Declared locally rather than imported to avoid an sdk + * run.ts → session-data.ts import cycle: run.ts pulls in the entire tool + * surface, so even a type-only import risks a cycle, and the shape here is + * tiny and stable. This is the run-loop's confirmed-mutation channel + * (workspaceRevision/snapshotId correlated); it carries only paths/hashes/ids + * and already excludes `afterContent`/`patch`/`editAnchor`, so recording it is + * redaction-preserving by construction. + */ +type FilesystemMutationEventLike = { + toolName: string + callId: string + operationId: string + receiptId?: string + workspaceRevision: number + workspaceSnapshotId: string + actions: Array<{ + action: 'create' | 'update' | 'delete' | 'move' + path: string + destinationPath?: string + beforeHash: string | null + afterHash: string | null + }> +} + +/** + * Bounded per-session live data backing the read-only Openbuff ACP extension + * methods (`openbuff/getReceipts`, `openbuff/gateState`). The P1-T2 run loop + * records real `FileMutationResultV1` receipts and published gate-state + * blocks here; `AcpAgent` serves projections of this store when no static + * `extensionHandler` is injected. + * + * Bounded by design: at most `MAX_RECEIPTS_PER_SESSION` receipts per session + * (newest wins) and exactly one gate-state snapshot per session (the latest + * published block). + */ + +/** Receipts retained per session; recording beyond the cap drops the oldest. */ +const MAX_RECEIPTS_PER_SESSION = 256 +/** Default and maximum `limit` served by `openbuff/getReceipts`. */ +const DEFAULT_RECEIPT_LIMIT = 50 +const MAX_RECEIPT_LIMIT = 256 + +/** + * Constructor options for `AcpSessionData`. Everything is optional so the + * no-journal construction stays byte-identical to the pure in-memory store + * (the env-architecture checks forbid reading `process.env` in this layer, + * so the journal directory is always injected by the caller). + */ +export type AcpSessionDataOptions = { + /** + * When set, receipts and gate-state snapshots are mirrored to an + * append-only JSONL journal (`/.jsonl`) so a + * session restored via `session/load` can replay its history across + * process restarts. Omitted → no filesystem access at all. + */ + journalDir?: string +} + +/** + * One JSONL journal record. Receipts persist the ALREADY-REDACTED envelope + * returned by `toWireReceipt` (redaction is normative: what hits disk is + * exactly what the wire would see); gate snapshots persist the parsed + * `AcpGateStateSnapshot`, never the raw block text. + */ +type JournalLine = + | { kind: 'receipt_envelope'; envelope: AcpWireReceiptEnvelope } + | { kind: 'gate_state'; snapshot: AcpGateStateSnapshot } + | { kind: 'capabilities'; capabilities: CapabilityMapV1 } + +/** + * The redacted receipt envelope wire shape (P1-T2-DESIGN §6.2): the mutation + * is projected to its identifying/hashing surface. Content-bearing fields + * (`afterContent`, `patch`, `editAnchor`) and fresh `cap.v3` capability + * tokens never leave the core — redaction here is normative, not cosmetic. + * `authorityReceipt` is kept verbatim: it already holds only ids, hashes, + * tiers, and statuses. + */ +export type AcpWireReceiptEnvelope = { + kind: 'openbuff.receipt_envelope' + version: 1 + sessionId: string + toolCallId?: string + laneId: 'main' + mutation: WireFileMutationResultV1 +} + +/** Projected receipt row served by `openbuff/getReceipts`. */ +export type AcpWireReceipt = { + operationId: string + receiptId: string + paths: string[] + actionIds: string[] +} + +export type AcpReceiptsResult = { receipts: AcpWireReceipt[] } + +/** + * Phases the published gate-state block projects onto. `idle` is the + * no-snapshot answer; the rest come from the block payload. + */ +export type AcpGatePhase = + | 'idle' + | 'validating' + | 'reviewing' + | 'blocked' + | 'skipped' + | 'final_response_allowed' + +export type AcpGateStateResult = { + phase: AcpGatePhase + currentTask: string | null +} + +/** + * The latest parsed `` payload for a session. Producer: + * `formatGateStateBlock` in agents/base2/base2.ts, which emits + * `{...}` with `gate`/`status`/`details` always + * present and `repairRound`/`maxRepairRounds`/`advisories`/`workflow` + * optional and additive. `phase` is this store's projection of the payload. + */ +export type AcpGateStateSnapshot = { + gate: string + status: string + details: string + repairRound?: number + maxRepairRounds?: number + advisories?: string[] + workflow?: { + completedCount: number + totalCount: number + nextWorkflowAction: string + } + phase: Exclude +} + +/** + * Non-greedy block extraction, mirroring the CLI's producer-consumer + * contract: the producer escapes `` inside reviewer-authored text cannot terminate + * a block early and the non-greedy match is safe. Last match wins. + */ +const GATE_STATE_BLOCK_RE = /([\s\S]*?)<\/gate-state>/g + +const KNOWN_GATES = new Set(['validation', 'reviewer', 'validation/reviewer']) + +function isPlainRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +/** + * Allowlist gate for interpolating a session id into a journal path. The + * ACP client fully controls the id via session/load (loadSession accepts + * any string), so only single-segment filename-safe ids may ever reach the + * filesystem: alphanumerics plus `.`, `_`, `~`, `-` — never a path + * separator, never the traversal tokens `.`/`..`, bounded to one NAME_MAX + * component. Ids issued by session/new are UUIDs and always pass. + */ +const JOURNAL_SAFE_SESSION_ID_RE = /^[A-Za-z0-9._~-]+$/ + +function isJournalSafeSessionId(sessionId: string): boolean { + return ( + sessionId.length <= 255 && + sessionId !== '.' && + sessionId !== '..' && + JOURNAL_SAFE_SESSION_ID_RE.test(sessionId) + ) +} + +/** + * GV-07 replay gate for journal-restored receipt envelopes: the journal lives + * under the project root, which serve mode treats as untrusted, so a replayed + * envelope is accepted only when it is ALREADY redaction-clean — every action + * must lack the content-bearing fields (`afterContent`, `patch`, + * `editAnchor`) and `freshCapabilities` must be exactly empty. The ext-v1 + * dispatcher re-validates the full `receiptEnvelopeV1Schema` again at serve + * time; this gate keeps content-bearing journal bytes out of the store in the + * first place. + */ +function isRedactionCleanWireEnvelope( + value: Record, +): value is AcpWireReceiptEnvelope { + if ( + value.kind !== 'openbuff.receipt_envelope' || + value.version !== 1 || + typeof value.sessionId !== 'string' || + value.sessionId.length === 0 || + value.laneId !== 'main' + ) { + return false + } + if (value.toolCallId !== undefined && typeof value.toolCallId !== 'string') { + return false + } + const mutation = value.mutation + if ( + !isPlainRecord(mutation) || + mutation.kind !== 'file_mutation_result' || + mutation.version !== 1 || + !Array.isArray(mutation.actions) + ) { + return false + } + for (const action of mutation.actions) { + if (!isPlainRecord(action)) return false + if ('afterContent' in action || 'patch' in action || 'editAnchor' in action) { + return false + } + } + const freshCapabilities = mutation.freshCapabilities + if (!Array.isArray(freshCapabilities) || freshCapabilities.length !== 0) { + return false + } + return true +} + +/** + * Builds the wire mutation for a receipt envelope: the published §6.2 + * projection drops the content-bearing fields (`afterContent`, `patch`, + * `editAnchor`) from every action, pins `freshCapabilities` to the empty + * tuple, and keeps `authorityReceipt` verbatim (it holds only ids, hashes, + * tiers, and statuses). + */ +export function toWireMutation( + mutation: FileMutationResultV1, +): WireFileMutationResultV1 { + // The published §6.2 wire projection (in @codebuff/common) is the single + // source of truth: it REBUILDS the mutation field-by-field against the + // strict wire allowlist instead of spreading the internal shape, so no + // field outside wireFileMutationResultV1Schema survives into the stored + // envelope. Every envelope the store records — including one recorded + // through the public recordReceipt path carrying content-bearing fields or + // fresh capabilities — therefore parses against the ext-v1 receipt + // contract at serve time instead of collapsing to receipt_not_found. + return toWireMutationV1(mutation) +} + +/** Builds the redacted receipt envelope for one recorded mutation. */ +export function toWireReceipt( + mutation: FileMutationResultV1, + sessionId: string, + toolCallId?: string, +): AcpWireReceiptEnvelope { + const envelope: AcpWireReceiptEnvelope = { + kind: 'openbuff.receipt_envelope', + version: 1, + sessionId, + laneId: 'main', + mutation: toWireMutation(mutation), + } + if (toolCallId !== undefined) { + envelope.toolCallId = toolCallId + } + return envelope +} + +function projectGatePhase( + gate: string, + status: string, +): Exclude { + if (status === 'passed') { + return 'final_response_allowed' + } + if (status === 'skipped') { + // A skip is its own recorded status (the ext-v1 gate contract carries + // 'skipped' plus skipReason), distinct from a failure. + return 'skipped' + } + if (status === 'failed') { + return 'blocked' + } + // Non-terminal (or unrecognized) status: the gate alone decides, and the + // combined validation/reviewer gate is a reviewer-side wait. + return gate === 'validation' ? 'validating' : 'reviewing' +} + +function parseWorkflowProgress(value: unknown): AcpGateStateSnapshot['workflow'] { + if (!isPlainRecord(value)) return undefined + const { completedCount, totalCount, nextWorkflowAction } = value + if ( + typeof completedCount !== 'number' || + !Number.isFinite(completedCount) || + typeof totalCount !== 'number' || + !Number.isFinite(totalCount) || + typeof nextWorkflowAction !== 'string' + ) { + return undefined + } + return { completedCount, totalCount, nextWorkflowAction } +} + +/** + * The non-idle phases `parseGateStateBlock` can project a snapshot onto. + * `idle` is the no-snapshot answer and is never stored, so a restored + * snapshot claiming it is malformed by construction. + */ +const STORED_GATE_PHASES: ReadonlySet = new Set([ + 'validating', + 'reviewing', + 'blocked', + 'skipped', + 'final_response_allowed', +]) + +/** + * Structural validation of a journal-restored gate-state snapshot (§12.6 + * journal trust boundary). The journal lives under the project root, which + * serve mode treats as untrusted, so the historically type-asserted replay + * must not let tampered bytes forge a gate state — e.g. a hand-written + * `phase: 'final_response_allowed'` (surfaced as 'passed' on the wire). + * There is no exported Zod schema for the internal `AcpGateStateSnapshot` + * projection, so this is the structural equivalent of what + * `parseGateStateBlock` accepts: known gate, non-empty status, string + * details, a stored (non-idle) phase, and shape-valid optional fields. + * Everything `parseGateStateBlock` produces passes; anything else — an + * unknown gate name, an invented phase, a malformed `workflow` — is + * rejected so the caller treats the snapshot as ABSENT. + */ +function isGateStateSnapshot(value: unknown): value is AcpGateStateSnapshot { + if (!isPlainRecord(value)) return false + if (typeof value.gate !== 'string' || !KNOWN_GATES.has(value.gate)) { + return false + } + if (typeof value.status !== 'string' || value.status.length === 0) { + return false + } + if (typeof value.details !== 'string') return false + if ( + typeof value.phase !== 'string' || + !STORED_GATE_PHASES.has(value.phase) + ) { + return false + } + for (const key of ['repairRound', 'maxRepairRounds'] as const) { + const round = value[key] + if (round === undefined) continue + if (typeof round !== 'number' || !Number.isFinite(round)) return false + } + if (value.advisories !== undefined) { + if ( + !Array.isArray(value.advisories) || + !value.advisories.every((advisory) => typeof advisory === 'string') + ) { + return false + } + } + if (value.workflow !== undefined) { + // Reuses the producer-side validator: an object that fails it is a + // shape no real snapshot could have carried. + if (parseWorkflowProgress(value.workflow) === undefined) return false + } + return true +} + +/** + * Extracts and leniently validates the last `` block in `text`. + * Plain-object checks only (the strict CLI parser lives in cli/ and is not + * importable from sdk/): unknown or malformed payloads return null so the + * caller keeps its previous snapshot instead of throwing. + */ +export function parseGateStateBlock( + blockText: string, +): AcpGateStateSnapshot | null { + const matches = [...blockText.matchAll(GATE_STATE_BLOCK_RE)] + const last = matches.at(-1) + if (!last) return null + + let payload: unknown + try { + payload = JSON.parse(last[1].trim()) + } catch { + return null + } + if (!isPlainRecord(payload)) return null + + const { gate, status } = payload + if (typeof gate !== 'string' || !KNOWN_GATES.has(gate)) return null + if (typeof status !== 'string' || status.length === 0) return null + + const snapshot: AcpGateStateSnapshot = { + gate, + status, + details: typeof payload.details === 'string' ? payload.details : '', + phase: projectGatePhase(gate, status), + } + if ( + typeof payload.repairRound === 'number' && + Number.isFinite(payload.repairRound) + ) { + snapshot.repairRound = payload.repairRound + } + if ( + typeof payload.maxRepairRounds === 'number' && + Number.isFinite(payload.maxRepairRounds) + ) { + snapshot.maxRepairRounds = payload.maxRepairRounds + } + if ( + Array.isArray(payload.advisories) && + payload.advisories.every((advisory) => typeof advisory === 'string') + ) { + snapshot.advisories = payload.advisories + } + const workflow = parseWorkflowProgress(payload.workflow) + if (workflow) { + snapshot.workflow = workflow + } + return snapshot +} + +function projectEnvelopeReceipt(envelope: AcpWireReceiptEnvelope): AcpWireReceipt { + const paths: string[] = [] + const actionIds: string[] = [] + for (const action of envelope.mutation.actions) { + if (!paths.includes(action.path)) { + paths.push(action.path) + } + if ( + action.destinationPath !== undefined && + !paths.includes(action.destinationPath) + ) { + paths.push(action.destinationPath) + } + if (!actionIds.includes(action.actionId)) { + actionIds.push(action.actionId) + } + } + return { + operationId: envelope.mutation.operationId, + // Envelopes recorded before a receiptId existed still surface on the + // wire; the extension contract requires a string, so fall back to ''. + receiptId: envelope.mutation.receiptId ?? '', + paths, + actionIds, + } +} + +/** + * Projects the latest gate snapshot onto the ext-v1 GateStateV1 (§6.4). The + * published `` block carries no file lists, so `pendingFiles` and + * `passedFiles` are honest empties, and every optional field is omitted + * unless the snapshot actually recorded it. + */ +function projectGateStateV1( + snapshot: AcpGateStateSnapshot | undefined, + sessionId: string, +): GateStateV1 { + let status: GateStateV1['status'] + switch (snapshot?.phase) { + case 'final_response_allowed': + status = 'passed' + break + case 'skipped': + // The ext-v1 contract defines 'skipped' (plus an optional skipReason) as + // its own status: a deliberately skipped gate must not carry failure + // semantics to ext-v1 consumers. + status = 'skipped' + break + case 'blocked': + status = 'failed' + break + case 'validating': + status = 'validating' + break + case 'reviewing': + status = 'reviewing' + break + default: + status = 'idle' + } + return { + kind: 'openbuff.gate_state', + version: 1, + sessionId, + status, + pendingFiles: [], + passedFiles: [], + // The producer's `details` line IS the published skip explanation (the + // gate-state block emitter records the skip reason as `details`), so a + // skipped snapshot surfaces it as the contract's optional skipReason; + // nothing is invented when the producer recorded none. + ...(snapshot?.phase === 'skipped' && snapshot.details.length > 0 + ? { skipReason: snapshot.details } + : {}), + } +} + +/** + * Bounded live-data store for ACP sessions. One instance serves the whole + * agent process; sessions are keyed by ACP session id. When constructed + * with a `journalDir`, every receipt/gate-state mutation is mirrored to a + * durable per-session JSONL journal (replayed via `restoreFromJournal`); + * without one the store stays purely in-memory with zero filesystem access. + */ +export class AcpSessionData { + /** Newest-last; capped at MAX_RECEIPTS_PER_SESSION by recordReceipt. */ + private readonly receiptsBySession = new Map() + /** Latest gate-state snapshot per session (one per session, replace-on-update). */ + private readonly gateStateBySession = new Map() + /** Latest ext-v1 capability map per session (one per session, replace-on-set). */ + private readonly capabilitiesBySession = new Map() + /** Latest confirmed workspace revision per session (drives the main lane). */ + private readonly workspaceRevisionBySession = new Map() + /** Injected journal directory; undefined keeps the store purely in-memory. */ + private readonly journalDir?: string + /** Directories already mkdir'd (lazy, recursive, once per journal dir). */ + private readonly journalDirsCreated = new Set() + /** + * Serializes all journal writes for this instance. Independent + * fire-and-forget writes could otherwise race the bounded rewrite (a late + * append could resurrect a dropped receipt or land after a truncating + * rewrite), so every write is chained behind the previous one. + */ + private journalQueue: Promise = Promise.resolve() + + constructor(options?: AcpSessionDataOptions) { + this.journalDir = options?.journalDir + } + + /** Journal IO is best-effort: failures are swallowed, never rethrown. */ + private queueJournalWrite(write: () => Promise): void { + this.journalQueue = this.journalQueue.then(write).catch(() => {}) + } + + /** Lazily creates the journal directory (recursive mkdir, once per dir). */ + private async ensureJournalDir(dir: string): Promise { + if (this.journalDirsCreated.has(dir)) return + await mkdir(dir, { recursive: true }) + this.journalDirsCreated.add(dir) + } + + /** + * Resolves the session's journal path, or undefined when journaling is + * disabled OR the session id is unsafe to interpolate into a path. Every + * journal read/write goes through this gate so a client-controlled + * sessionId (`session/load` accepts any id) can never escape `journalDir` + * into an append/create write or read of `.jsonl` files elsewhere. Fails + * closed: the in-memory store keeps working, only the journal IO is + * skipped. + */ + private journalFilePathFor(sessionId: string): string | undefined { + const dir = this.journalDir + if (dir === undefined || !isJournalSafeSessionId(sessionId)) { + return undefined + } + return `${dir}/${sessionId}.jsonl` + } + + /** Appends one complete JSON line (atomic enough for a single process). */ + private appendJournalLine(sessionId: string, line: JournalLine): void { + const dir = this.journalDir + const filePath = this.journalFilePathFor(sessionId) + if (dir === undefined || filePath === undefined) return + const encoded = `${JSON.stringify(line)}\n` + this.queueJournalWrite(async () => { + await this.ensureJournalDir(dir) + await appendFile(filePath, encoded, 'utf8') + }) + } + + /** + * Rewrites the session's journal from current in-memory state (newest + * MAX_RECEIPTS_PER_SESSION envelopes + latest snapshot), truncating the + * SAME file so it cannot grow without bound once receipts start dropping. + */ + private rewriteJournal(sessionId: string): void { + const dir = this.journalDir + const filePath = this.journalFilePathFor(sessionId) + if (dir === undefined || filePath === undefined) return + const receipts = this.receiptsBySession.get(sessionId) ?? [] + const snapshot = this.gateStateBySession.get(sessionId) + const lines = receipts.map((envelope) => { + const line: JournalLine = { kind: 'receipt_envelope', envelope } + return JSON.stringify(line) + }) + if (snapshot) { + const line: JournalLine = { kind: 'gate_state', snapshot } + lines.push(JSON.stringify(line)) + } + const capabilities = this.capabilitiesBySession.get(sessionId) + if (capabilities) { + const line: JournalLine = { kind: 'capabilities', capabilities } + lines.push(JSON.stringify(line)) + } + const contents = lines.length > 0 ? `${lines.join('\n')}\n` : '' + this.queueJournalWrite(async () => { + await this.ensureJournalDir(dir) + await writeFile(filePath, contents, 'utf8') + }) + } + + /** + * Records a redacted receipt envelope for a real file mutation. Unknown + * session ids are fine — the store creates the bucket lazily. When a + * journalDir is configured, the already-redacted envelope is persisted: + * appended as one line, or (when this record dropped an older receipt in + * memory) the whole journal is rewritten from the bounded in-memory state + * so the file cannot grow without bound. + */ + recordReceipt( + sessionId: string, + mutation: FileMutationResultV1, + toolCallId?: string, + ): void { + this.trackWorkspaceRevision(sessionId, mutation.workspaceRevision) + this.pushEnvelope(sessionId, toWireReceipt(mutation, sessionId, toolCallId)) + } + + /** + * Records a redacted receipt directly from the run loop's + * `FilesystemMutationEvent` — its confirmed-mutation channel + * (workspaceRevision/snapshotId correlated). That event already carries no + * content-bearing fields (`afterContent`/`patch`/`editAnchor` are excluded by + * construction) — only paths, hashes, and ids — so projecting it into an + * envelope is redaction-preserving with nothing content-bearing to drop. The + * event's actions have no `actionId`, so a stable synthetic id + * (`${operationId}:${index}`) is used, and `freshCapabilities` is `[]` since + * no cap.v3 token ever rides this channel. + */ + recordReceiptFromMutationEvent( + sessionId: string, + event: FilesystemMutationEventLike, + toolCallId?: string, + ): void { + const actions: FileMutationActionV1[] = event.actions.map( + (action, index) => ({ + actionId: `${event.operationId}:${index}`, + index, + action: action.action, + path: action.path, + ...(action.destinationPath !== undefined + ? { destinationPath: action.destinationPath } + : {}), + outcome: 'applied' as const, + beforeHash: action.beforeHash, + afterHash: action.afterHash, + }), + ) + const mutation: FileMutationResultV1 = { + kind: 'file_mutation_result', + version: 1, + operationId: event.operationId, + outcome: 'applied', + actions, + authorityTier: 'conditional_commit', + ...(event.receiptId !== undefined ? { receiptId: event.receiptId } : {}), + workspaceRevision: event.workspaceRevision, + workspaceSnapshotId: event.workspaceSnapshotId, + errors: [], + freshCapabilities: [], + } + const envelope: AcpWireReceiptEnvelope = { + kind: 'openbuff.receipt_envelope', + version: 1, + sessionId, + laneId: 'main', + // The envelope carries the §6.2 wire projection (freshCapabilities is + // the empty tuple the wire schema pins), so the typed store shape and + // the published ext-v1 contract cannot drift. + mutation: toWireMutation(mutation), + } + if (toolCallId !== undefined) { + envelope.toolCallId = toolCallId + } + this.trackWorkspaceRevision(sessionId, event.workspaceRevision) + this.pushEnvelope(sessionId, envelope) + } + + /** + * Shared receipt-storage tail for `recordReceipt` and + * `recordReceiptFromMutationEvent`: appends the already-redacted envelope to + * the bounded per-session buffer (newest wins, capped at + * MAX_RECEIPTS_PER_SESSION) and mirrors it to the journal — a plain append, + * or a full rewrite from the bounded in-memory state whenever this push + * dropped an older receipt so the file cannot grow without bound. + */ + private pushEnvelope( + sessionId: string, + envelope: AcpWireReceiptEnvelope, + ): void { + let receipts = this.receiptsBySession.get(sessionId) + if (!receipts) { + receipts = [] + this.receiptsBySession.set(sessionId, receipts) + } + receipts.push(envelope) + if (receipts.length > MAX_RECEIPTS_PER_SESSION) { + receipts.splice(0, receipts.length - MAX_RECEIPTS_PER_SESSION) + this.rewriteJournal(sessionId) + return + } + this.appendJournalLine(sessionId, { + kind: 'receipt_envelope', + envelope, + }) + } + + /** + * Parses a published `` block and replaces this session's + * snapshot. A malformed or missing block keeps the previous snapshot + * (fail closed, no throw). + * + * Note: the published block does NOT carry the current-task pointer — + * `formatGateStateBlock` emits only gate/status/details/repairRound/ + * maxRepairRounds/advisories/workflow — so the projection honestly + * reports `currentTask: null` rather than guessing. + */ + updateGateStateFromBlock(sessionId: string, blockText: string): void { + const snapshot = parseGateStateBlock(blockText) + if (snapshot) { + this.gateStateBySession.set(sessionId, snapshot) + // Persist the parsed snapshot, never the raw block text. + this.appendJournalLine(sessionId, { kind: 'gate_state', snapshot }) + } + } + + /** + * Tracks the latest confirmed workspace revision for a session (the main + * lane's `baseRevision`, §6.3). Monotonic: a lower revision never rewinds + * the recorded value. + */ + private trackWorkspaceRevision( + sessionId: string, + revision: number | undefined, + ): void { + if ( + typeof revision !== 'number' || + !Number.isFinite(revision) || + revision < 0 + ) { + return + } + const current = this.workspaceRevisionBySession.get(sessionId) + if (current === undefined || revision > current) { + this.workspaceRevisionBySession.set(sessionId, revision) + } + } + + /** + * Stores the session's ext-v1 capability map (§6.1, replace-on-set) and + * journals it so a restored session keeps its advertised snapshot. + */ + setCapabilities(sessionId: string, capabilities: CapabilityMapV1): void { + this.capabilitiesBySession.set(sessionId, capabilities) + this.appendJournalLine(sessionId, { kind: 'capabilities', capabilities }) + } + + /** Returns the session's ext-v1 capability map, or undefined when never set. */ + getCapabilities(sessionId: string): CapabilityMapV1 | undefined { + return this.capabilitiesBySession.get(sessionId) + } + + /** + * Whether this store mirrors to a durable journal directory. Drives the + * §6.1 capability map's honest `journal` flags: a purely in-memory store + * must not advertise resume/replay that `session/load` cannot honor. + */ + hasJournal(): boolean { + return this.journalDir !== undefined + } + + /** + * Fetches one full redacted receipt envelope by its mutation receiptId + * (§6.2). Envelopes recorded without a receiptId are NOT fetchable by id — + * they only surface in the `openbuff/getReceipts` list projection. The same + * applies to envelopes recorded without a `toolCallId`: the published + * ext-v1 result contract (`receiptEnvelopeV1Schema`) requires one, so a + * tool-less envelope has no wire representation and is not addressable by + * `_openbuff.dev/receipts/get`. + */ + getReceipt( + sessionId: string, + receiptId: string, + ): AcpWireReceiptEnvelope | undefined { + const receipts = this.receiptsBySession.get(sessionId) ?? [] + return receipts.find( + (envelope) => + envelope.mutation.receiptId === receiptId && + envelope.toolCallId !== undefined, + ) + } + + /** + * The single P1 lane (§6.3): `main`, active, baseRevision = the latest + * confirmed workspace revision (0 before any mutation is confirmed). + */ + getMainLane(sessionId: string): LaneV1 { + return { + kind: 'openbuff.lane', + version: 1, + laneId: 'main', + status: 'active', + baseRevision: this.workspaceRevisionBySession.get(sessionId) ?? 0, + agentIds: [], + } + } + + /** + * Projects the session's latest gate snapshot onto the ext-v1 GateStateV1 + * (§6.4). A session with no published block reports `idle`. + */ + getGateStateV1(sessionId: string): GateStateV1 { + return projectGateStateV1(this.gateStateBySession.get(sessionId), sessionId) + } + + /** + * Replays `/.jsonl` into the in-memory maps so a + * `session/load`-restored session recovers its receipt history and last + * gate-state snapshot. Receipts replay newest-last capped at + * MAX_RECEIPTS_PER_SESSION; the snapshot is the last valid `gate_state` + * line. Malformed lines are skipped fail-closed, and a `gate_state` + * snapshot that fails structural validation is treated as ABSENT (no + * gate state) with a warn diagnostic; any read failure + * degrades to "no journal" — this never throws. Receipt envelopes are + * replayed only when already redaction-clean (GV-07), and capability + * lines re-derive the honest baseline map instead of serving the + * replayed bytes verbatim. + * + * Returns whether a journal file existed for the session. + */ + async restoreFromJournal(sessionId: string): Promise { + // The same gate guards the READ side: replaying a traversal id must + // not read outside the journal directory either. + const filePath = this.journalFilePathFor(sessionId) + if (filePath === undefined) return false + let text: string + try { + text = await readFile(filePath, 'utf8') + } catch { + return false + } + const receipts: AcpWireReceiptEnvelope[] = [] + let snapshot: AcpGateStateSnapshot | undefined + for (const line of text.split('\n')) { + const trimmed = line.trim() + if (trimmed.length === 0) continue + let parsed: unknown + try { + parsed = JSON.parse(trimmed) + } catch { + continue + } + if (!isPlainRecord(parsed)) continue + // The journal lives under the project root, which serve mode treats + // as untrusted. Receipt envelopes are accepted only when proven + // redaction-clean (the gate above); gate-state snapshots are + // validated structurally before they are restored, and an invalid + // snapshot is treated as ABSENT (no gate state) with a warn + // diagnostic; capability lines only validate the SHAPE — the stored + // map is re-derived from this store's real posture, never served from + // the replayed bytes (a schema-valid line from a tampered journal must + // not become a security advertisement). + if ( + parsed.kind === 'receipt_envelope' && + isPlainRecord(parsed.envelope) + ) { + if (!isRedactionCleanWireEnvelope(parsed.envelope)) continue + receipts.push(parsed.envelope) + if (receipts.length > MAX_RECEIPTS_PER_SESSION) { + receipts.splice(0, receipts.length - MAX_RECEIPTS_PER_SESSION) + } + } else if ( + parsed.kind === 'gate_state' && + isPlainRecord(parsed.snapshot) + ) { + // The type assertion here used to be the whole gate: a tampered + // journal could forge any phase — including 'final_response_allowed' + // (surfaced as 'passed' on the wire). Validate the restored + // snapshot against the gate-state shape and treat an invalid + // snapshot as ABSENT (no gate state) with a warn diagnostic. + if (isGateStateSnapshot(parsed.snapshot)) { + snapshot = parsed.snapshot + } else { + console.warn( + `[acp-session-data] journal gate_state snapshot for session '${sessionId}' failed validation; treating it as absent (no gate state).`, + ) + } + } else if (parsed.kind === 'capabilities') { + const replayed = capabilityMapV1Schema.safeParse(parsed.capabilities) + if (replayed.success) { + // Honest re-derivation: journal replay exists so a restored session + // HAS a capability map (§6.1) — its journal flags come from THIS + // store, and every other field is the honest P1 serve default. The + // replayed values are validated but never trusted. + this.capabilitiesBySession.set( + sessionId, + defaultCapabilityMapV1({ journalAvailable: this.hasJournal() }), + ) + } + } + } + if (receipts.length > 0) { + this.receiptsBySession.set(sessionId, receipts) + } + if (snapshot) { + this.gateStateBySession.set(sessionId, snapshot) + } + return true + } + + /** + * Projects the session's receipts to the `openbuff/getReceipts` result + * shape, newest first. `limit` defaults to 50 and is capped at 256. + */ + getReceipts(sessionId: string, limit?: number): AcpReceiptsResult { + const requested = + typeof limit === 'number' && Number.isFinite(limit) + ? Math.floor(limit) + : DEFAULT_RECEIPT_LIMIT + const boundedLimit = Math.min(Math.max(requested, 0), MAX_RECEIPT_LIMIT) + const receipts = this.receiptsBySession.get(sessionId) ?? [] + return { + receipts: receipts + .slice() + .reverse() + .slice(0, boundedLimit) + .map(projectEnvelopeReceipt), + } + } + + /** Projects the session's gate snapshot to the `openbuff/gateState` shape. */ + getGateState(sessionId: string): AcpGateStateResult { + const snapshot = this.gateStateBySession.get(sessionId) + return { + phase: snapshot?.phase ?? 'idle', + // The published block carries no current-task pointer (see + // updateGateStateFromBlock), so this stays null by construction. + currentTask: null, + } + } +} diff --git a/sdk/src/services/build-graph.ts b/sdk/src/services/build-graph.ts new file mode 100644 index 0000000000..9593fddd87 --- /dev/null +++ b/sdk/src/services/build-graph.ts @@ -0,0 +1,732 @@ +/** + * Build graph (P3-T8): maps source files to their owning build targets so + * edits can run the owning target's tests/builds. + * + * Supported toolchains (resolveOwningTargets): cargo metadata (Rust), go list + * (Go), package.json workspaces (JS/TS), pom.xml/gradle static files (JVM), + * .csproj (dotnet), CMakeLists.txt (C/C++), and pyproject.toml/setup.py + * (Python). + * + * Deferred: Bazel, nx, real MSBuild, the Gradle Tooling API, and the CMake + * File API are not implemented — those ecosystems resolve from static + * manifest files only. + */ +import fs from 'node:fs' +import path from 'node:path' +import { spawnSync } from 'node:child_process' + +import { resolveProjectPath } from '@codebuff/common/util/project-path-containment' + +export type BuildGraphConfidence = 'confirmed' | 'inferred' | 'unknown' + +export type BuildGraphTargetKind = + | 'package' + | 'crate' + | 'module' + | 'project' + | 'target' + +export type BuildGraphTarget = { + name: string + kind: BuildGraphTargetKind + root: string + testCommand?: string + buildCommand?: string +} + +export type OwningTargetResolution = { + file: string + ecosystem: string + targets: BuildGraphTarget[] + confidence: BuildGraphConfidence +} + +/** + * Command-runner seam for ecosystem build tools (`cargo metadata`, `go list`). + * Tests inject a fake; the default spawns the tool with a bounded timeout, + * mirroring `toolVersion` in harness-intelligence.ts. + * + * The default runner is intentionally synchronous (`spawnSync`), like the + * semgrep-baseline runner seam — do not convert to async. It now sits on the + * live `get_build_targets` tool path, so the event-loop cost is bounded and + * amortized by contract: + * - each spawn is capped (`runnerTimeoutMs` timeout, `runnerMaxBufferBytes` + * maxBuffer), so a runaway tool cannot stall the event loop indefinitely; + * - the per-cwd target index is cached for `buildGraphCacheTtlMs`, so the + * ecosystem probes run once per TTL window per cwd, not per tool call; + * - each ecosystem resolver is isolated (an absent/failing/slow tool + * contributes nothing) and resolution is capped at `maxFilesPerCall` files; + * - the cargo/go probes are gated on a discovered Cargo.toml/go.mod, so repos + * that do not use an ecosystem never pay that ecosystem's spawn at all. + * A mixed sync/async seam would be worse than either; if the blocking cost + * ever matters in practice, move the whole resolution off the event loop + * (e.g. a worker) rather than making the seam half-async. + */ +export type BuildGraphRunner = ( + argv: string[], + cwd: string, +) => { exitCode: number; stdout: string; stderr: string } + +/** Per-call cap on resolved files; callers surface truncation honestly via `truncated`. */ +export const maxFilesPerCall = 500 +const runnerTimeoutMs = 10_000 +// `cargo metadata` output is large even with --no-deps; bound it generously +// but finitely so a runaway tool cannot exhaust memory. +const runnerMaxBufferBytes = 16 * 1024 * 1024 + +const defaultBuildGraphRunner: BuildGraphRunner = (argv, cwd) => { + if (argv.length === 0) { + return { exitCode: 1, stdout: '', stderr: 'empty argv' } + } + const result = spawnSync(argv[0], argv.slice(1), { + cwd, + encoding: 'utf8', + timeout: runnerTimeoutMs, + maxBuffer: runnerMaxBufferBytes, + stdio: ['ignore', 'pipe', 'pipe'], + }) + return { + exitCode: typeof result.status === 'number' ? result.status : 1, + stdout: result.stdout ?? '', + stderr: result.error + ? `${result.stderr ?? ''}${result.error.message}` + : (result.stderr ?? ''), + } +} + +type IndexedTarget = BuildGraphTarget & { + ecosystem: string + confidence: BuildGraphConfidence +} + +const ignoredDiscoveryDirectories = new Set([ + '.git', + '.hg', + '.svn', + 'node_modules', + 'target', + 'dist', + 'build', + 'out', + '.next', + '.turbo', + '.cache', + '.venv', + 'venv', + '__pycache__', +]) + +const discoveredFileNames = new Set([ + 'package.json', + 'Cargo.toml', + 'go.mod', + 'pom.xml', + 'build.gradle', + 'build.gradle.kts', + 'CMakeLists.txt', + 'pyproject.toml', + 'setup.py', + 'bun.lock', + 'bun.lockb', + 'pnpm-lock.yaml', + 'yarn.lock', + 'package-lock.json', +]) + +/** + * Mirrors `discoverNamedFiles` in harness-intelligence.ts, collecting only the + * manifests/lockfiles the build-graph resolvers key on. + */ +function discoverBuildGraphFiles(root: string): string[] { + // Iterative walk with hard caps on depth and visited directories: an + // unbounded recursion can stack-overflow or stall the main thread on a + // pathological/deep tree (security review F3). Symlinks are skipped, so + // hard-linked loops are the remaining risk, bounded by the visit cap. + const MAX_WALK_DEPTH = 12 + const MAX_VISITED_DIRS = 2_000 + const found: string[] = [] + const stack: Array<{ directory: string; depth: number }> = [ + { directory: root, depth: 0 }, + ] + let visited = 0 + while (stack.length > 0) { + const current = stack.pop() + if (!current) break + if (visited++ >= MAX_VISITED_DIRS) break + if (current.depth > MAX_WALK_DEPTH) continue + let entries: fs.Dirent[] + try { + entries = fs.readdirSync(current.directory, { withFileTypes: true }) + } catch { + continue + } + for (const entry of entries) { + if (entry.isSymbolicLink()) continue + const absolute = path.join(current.directory, entry.name) + if (entry.isDirectory()) { + if (!ignoredDiscoveryDirectories.has(entry.name)) { + stack.push({ directory: absolute, depth: current.depth + 1 }) + } + } else if ( + discoveredFileNames.has(entry.name) || + entry.name.endsWith('.csproj') + ) { + found.push(path.relative(root, absolute).replace(/\\/g, '/')) + } + } + } + return found.sort() +} + +function directoryName(root: string, directory: string): string { + return directory === '.' ? path.basename(root) : path.posix.basename(directory) +} + +/** + * Whitelist for identifiers interpolated into testCommand/buildCommand + * strings (cargo package names, go import paths, module dirs, manifest + * paths). Security review F4: these values come from dependency manifests / + * tool output and are attacker-controlled; a future consumer that runs a + * command string through a shell must never see a shell metacharacter. These + * strings must only ever be executed as argv arrays, never via a shell. + * The colon is allowed for Gradle project paths (`:libs:ui:test`); it is not + * a shell command separator (`;`, `&`, `|` are the separators to exclude). + */ +const SAFE_COMMAND_TOKEN = /^[A-Za-z0-9._~/:-]+$/ +function safeCommandToken(value: string): string | undefined { + return SAFE_COMMAND_TOKEN.test(value) ? value : undefined +} + +/** + * Project-relative, POSIX-separator form of an absolute tool-reported + * directory, or undefined when it escapes the project root. + */ +function relativeDirWithin( + root: string, + absoluteDir: string, +): string | undefined { + const relative = path.relative(root, absoluteDir).replace(/\\/g, '/') + if (relative === '') return '.' + if ( + relative === '..' || + relative.startsWith('../') || + path.isAbsolute(relative) + ) { + return undefined + } + return relative +} + +/** + * Rust: `cargo metadata --no-deps --format-version 1`. Parses only the needed + * fields and tolerates missing ones; an absent/failing cargo is an honest + * skip (no filesystem guessing at crate names). + */ +function resolveCargoTargets( + root: string, + runner: BuildGraphRunner, +): IndexedTarget[] { + const output = runner( + ['cargo', 'metadata', '--no-deps', '--format-version', '1'], + root, + ) + if (output.exitCode !== 0) return [] + let packages: unknown + try { + packages = (JSON.parse(output.stdout) as { packages?: unknown }).packages + } catch { + return [] + } + if (!Array.isArray(packages)) return [] + const targets: IndexedTarget[] = [] + for (const pkg of packages) { + if (typeof pkg !== 'object' || pkg === null) continue + const { name, manifest_path } = pkg as { + name?: unknown + manifest_path?: unknown + } + if (typeof name !== 'string' || name.length === 0) continue + if (typeof manifest_path !== 'string' || manifest_path.length === 0) + continue + const directory = relativeDirWithin(root, path.dirname(manifest_path)) + if (directory === undefined) continue + const safeName = safeCommandToken(name) + targets.push({ + ecosystem: 'rust', + confidence: 'confirmed', + name, + kind: 'crate', + root: directory, + ...(safeName + ? { + testCommand: `cargo test -p ${safeName}`, + buildCommand: `cargo build -p ${safeName}`, + } + : {}), + }) + } + return targets +} + +/** `go list -json ./...` emits a stream of concatenated JSON objects, not an array. */ +function parseJsonObjectStream(text: string): unknown[] { + const objects: unknown[] = [] + let depth = 0 + let start = -1 + let inString = false + let escaped = false + for (let index = 0; index < text.length; index++) { + const char = text[index] + if (inString) { + if (escaped) escaped = false + else if (char === '\\') escaped = true + else if (char === '"') inString = false + continue + } + if (char === '"') { + inString = true + } else if (char === '{') { + if (depth === 0) start = index + depth++ + } else if (char === '}') { + depth-- + if (depth === 0 && start >= 0) { + try { + objects.push(JSON.parse(text.slice(start, index + 1))) + } catch { + /* skip the malformed object, keep the rest of the stream */ + } + start = -1 + } + } + } + return objects +} + +/** Go: `go list -json ./...` (ImportPath, Dir) -> owning package. */ +function resolveGoTargets( + root: string, + runner: BuildGraphRunner, +): IndexedTarget[] { + const output = runner(['go', 'list', '-json', './...'], root) + if (output.exitCode !== 0) return [] + const targets: IndexedTarget[] = [] + for (const object of parseJsonObjectStream(output.stdout)) { + if (typeof object !== 'object' || object === null) continue + const { ImportPath, Dir } = object as { + ImportPath?: unknown + Dir?: unknown + } + if (typeof ImportPath !== 'string' || ImportPath.length === 0) continue + if (typeof Dir !== 'string' || Dir.length === 0) continue + const directory = relativeDirWithin(root, Dir) + if (directory === undefined) continue + const safeImport = safeCommandToken(ImportPath) + targets.push({ + ecosystem: 'go', + confidence: 'confirmed', + name: ImportPath, + kind: 'package', + root: directory, + ...(safeImport + ? { + testCommand: `go test ${safeImport}`, + buildCommand: `go build ${safeImport}`, + } + : {}), + }) + } + return targets +} + +const jsManagerLockfiles = [ + ['bun.lock', 'bun'], + ['bun.lockb', 'bun'], + ['pnpm-lock.yaml', 'pnpm'], + ['yarn.lock', 'yarn'], + ['package-lock.json', 'npm'], +] as const + +/** Mirrors the `closest` ancestor walk in harness-intelligence.ts `inferWorkspace`. */ +function closestDiscoveredFile( + files: Set, + directory: string, + names: readonly string[], +): string | undefined { + let current = directory + while (true) { + for (const name of names) { + const candidate = current === '.' ? name : `${current}/${name}` + if (files.has(candidate)) return candidate + } + if (current === '.') return undefined + const parent = path.posix.dirname(current) + current = parent === current ? '.' : parent + } +} + +/** + * JS/TS: package.json workspace discovery, mirroring the workspace inference + * pattern in harness-intelligence.ts (nearest ancestor lockfile decides the + * manager and confirms it; `packageManager` field confirms; otherwise npm is + * inferred). Commands come from the owning package's scripts. + */ +function resolveJavaScriptTargets( + root: string, + discovered: string[], +): IndexedTarget[] { + const discoveredSet = new Set(discovered) + const targets: IndexedTarget[] = [] + for (const manifest of discovered) { + if (path.posix.basename(manifest) !== 'package.json') continue + const directory = path.posix.dirname(manifest) + let manager: string | undefined + let confidence: BuildGraphConfidence = 'inferred' + for (const [lockfile, lockfileManager] of jsManagerLockfiles) { + if (closestDiscoveredFile(discoveredSet, directory, [lockfile])) { + manager = lockfileManager + confidence = 'confirmed' + break + } + } + let name = directoryName(root, directory) + let testCommand: string | undefined + let buildCommand: string | undefined + try { + const parsed = JSON.parse( + fs.readFileSync(path.join(root, manifest), 'utf8'), + ) as { + name?: string + packageManager?: string + scripts?: Record + } + if (parsed.name) name = parsed.name + if (!manager && parsed.packageManager) { + manager = parsed.packageManager.split('@')[0] + confidence = 'confirmed' + } + // BG-1: `packageManager` is attacker-controlled manifest content, and + // the manager is interpolated as the `${manager} run