From d9f759b7bc967b3837158c5eb7b9d712ddec4da0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 25 Aug 2026 20:07:53 +0000 Subject: [PATCH 01/32] chore(lockfile): refresh pnpm-lock.yaml (#12178) Add the missing server importer for @paperclipai/paperclip-runner. --- pnpm-lock.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 96dfe0a2cba..edad42bee0b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -893,6 +893,9 @@ importers: specifier: ^4.4.3 version: 4.4.3 devDependencies: + '@paperclipai/paperclip-runner': + specifier: workspace:* + version: link:../packages/paperclip-runner '@types/express': specifier: ^5.0.0 version: 5.0.6 From 1fc4591327fdb7dfaddfc8c28342360bb1346949 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 25 Aug 2026 13:39:59 -0700 Subject: [PATCH 02/32] build(deps): bump commander from 13.1.0 to 15.0.0 (#11877) Bumps [commander](https://github.com/tj/commander.js) from 13.1.0 to 15.0.0.
Release notes

Sourced from commander's releases.

v15.0.0

Commander 15 is ESM only. This is expected to be seamless for ESM consumers, but some CommonJS consumers may hit issues with tooling requiring configuration for ESM-only dependencies. See Migration Tips below.

The release of Commander 15 moves Commander 14 into maintenance. Commander 14 will get security updates for 12 months (to May 2027). For more info see Release Policy.

Added

Fixed

Changed

Deleted

Migration Tips

Commander 15 is ESM only, but this does not mean you need to migrate to ESM to use it. Importing ESM from CommonJS is supported by Node.js, and Bun, and Deno. Hopefully it Just Works for you! However, you may be using a different runtime or some other part of your setup that may not yet natively support importing ESM from CommonJS, such as your testing framework or bundler.

If you have problems using Commander 15 in your environment, one option is stay on Commander 14 for now. Commander 14 will get security updates until May 2027 and things will hopefully improve for your setup in the meantime.

v15.0.0-0

Commander 15 is ESM only. This is expected to be seamless for ESM consumers, but some CommonJS consumers may hit issues with tooling requiring configuration for ESM-only dependencies. See Migration Tips below.

The release of Commander 15 in May 2026 will move Commander 14 into maintenance. Commander 14 will get security updates for 12 months (to May 2027). For more info see Release Policy.

Added

Fixed

... (truncated)

Changelog

Sourced from commander's changelog.

[15.0.0] (2026-05-29)

Commander 15 is ESM only. This is expected to be seamless for ESM consumers, but some CommonJS consumers may hit issues with tooling requiring configuration for ESM-only dependencies. See Migration Tips below.

The release of Commander 15 moves Commander 14 into maintenance. Commander 14 will get security updates for 12 months (to May 2027). For more info see Release Policy.

Added

Fixed

Changed

Deleted

Migration Tips

Commander 15 is ESM only, but this does not mean you need to migrate to ESM to use it. Importing ESM from CommonJS is supported by Node.js, and Bun, and Deno. Hopefully it Just Works for you! However, you may be using a different runtime or some other part of your setup that may not yet natively support importing ESM from CommonJS, such as your testing framework or bundler.

If you have problems using Commander 15 in your environment, one option is stay on Commander 14 for now. Commander 14 will get security updates until May 2027 and things will hopefully improve for your setup in the meantime.

[15.0.0-0] (2026-02-22)

(Released as 15.0.0)

[14.0.3] (2026-01-31)

Added

Changes

... (truncated)

Commits

Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- cli/package.json | 2 +- pnpm-lock.yaml | 10 ++-------- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/cli/package.json b/cli/package.json index 4113ed4d45e..47e34f1a593 100644 --- a/cli/package.json +++ b/cli/package.json @@ -54,7 +54,7 @@ "@paperclipai/hermes-paperclip-adapter": "workspace:*", "drizzle-orm": "0.45.2", "dotenv": "^17.4.2", - "commander": "^13.1.0", + "commander": "^15.0.0", "embedded-postgres": "^18.1.0-beta.16", "picocolors": "^1.1.1" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index edad42bee0b..b27eccf9ad3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -98,8 +98,8 @@ importers: specifier: workspace:* version: link:../packages/shared commander: - specifier: ^13.1.0 - version: 13.1.0 + specifier: ^15.0.0 + version: 15.0.0 dotenv: specifier: ^17.4.2 version: 17.4.2 @@ -5486,10 +5486,6 @@ packages: comma-separated-tokens@2.0.3: resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} - commander@13.1.0: - resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==} - engines: {node: '>=18'} - commander@15.0.0: resolution: {integrity: sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==} engines: {node: '>=22.12.0'} @@ -12893,8 +12889,6 @@ snapshots: comma-separated-tokens@2.0.3: {} - commander@13.1.0: {} - commander@15.0.0: {} commander@7.2.0: {} From 3e28d64a72849b4ba6e716144a051f32a5fde994 Mon Sep 17 00:00:00 2001 From: Nicky Leach Date: Tue, 25 Aug 2026 13:42:13 -0700 Subject: [PATCH 03/32] fix(plugin-worker-manager): queue and replay pre-bind login pseudo-terminal frames (#12173) ## Thinking Path > - Paperclip routes plugin worker messages to agent sessions. > - The login pseudo-terminal route opens after the host receives the open reply. > - `readline` can deliver later frames from the same pipe read before that reply continuation runs. > - The host dropped early output and exit frames. > - The fix queues valid early frames, preserves arrival order, and replays them after the route opens. > - The route uses bounded memory and closes fail-closed when a bound breaks. > - The final tests also pin child issue ordering so the serialized suite remains deterministic. ## Linked Issues or Issue Description Fixes #12122 ## What Changed - Add a bounded queue for login pseudo-terminal output and exit frames during route opening. - Validate session ids, chunk types, and per-chunk limits before queue insertion. - Bound the queue by 10,000 frames and 8 MiB of characters. - Charge retained worker session identifiers against the character bound. - Preserve arrival order and stop replay after the first valid exit. - Drop repeated exits without changing the first exit position or code. - Bound the repeat-exit lookup and clear queued state on all terminal paths. - Add regression tests and fixture support for coalesced frames, ordering, limits, cleanup, and log safety. - Pin issue numbers in the child-wake test so its expected child order remains deterministic. ## Verification - Build the plugin SDK with `pnpm --filter @paperclipai/plugin-sdk build`. - Run `npx vitest run server/src/__tests__/plugin-worker-manager.test.ts` from the repository root. - Run `npx vitest run server/src/__tests__/issues-service.test.ts` from the repository root. - The focused plugin worker suite passes 66 of 66 tests at the prior reviewed head. - The issue service file passes 120 of 120 tests in two isolated runs at the current head. - Confirm that GitHub Actions passes all required checks. - Confirm that Greptile reports 5/5 with no unresolved review threads. - Storybook visual regression remains skipped because the PR has no `storybook-visual` label. ## Risks - The queue adds bounded memory use while the login pseudo-terminal route opens. - A queue limit breach closes the route and prevents unbounded buffering. - A hostile worker can fail only its own login route when it breaches a bound. - The first valid exit closes the route, so later records do not reach the session. - The child-wake test now uses distinct issue numbers to match the service sort contract. ## Model Used OpenAI Codex, GPT-5, extended reasoning, tool use, and code review support. The runtime does not expose a separate context-window value. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used with version and capability details - [x] I have checked ROADMAP.md and confirmed that this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have linked the existing public issue with `Fixes: #12122` - [x] I have not referenced internal Paperclip issues or links - [x] My branch name describes the change and contains no internal ticket id - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation where needed - [x] I have considered and documented risks above - [x] All required Paperclip CI gates are green - [x] Greptile is 5/5 with no unresolved review threads - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- .../fixtures/plugin-worker-login-pty.cjs | 140 ++++- server/src/__tests__/issues-service.test.ts | 8 + .../__tests__/plugin-worker-manager.test.ts | 543 ++++++++++++++++++ server/src/services/plugin-worker-manager.ts | 159 ++++- 4 files changed, 819 insertions(+), 31 deletions(-) diff --git a/server/src/__tests__/fixtures/plugin-worker-login-pty.cjs b/server/src/__tests__/fixtures/plugin-worker-login-pty.cjs index 24ecd95e7d5..27d9f1c1e55 100644 --- a/server/src/__tests__/fixtures/plugin-worker-login-pty.cjs +++ b/server/src/__tests__/fixtures/plugin-worker-login-pty.cjs @@ -6,22 +6,103 @@ // The manager allowlists `command` to the fixed `CLAUDE_SETUP_TOKEN_COMMAND`. The // test encodes a JSON directive in the forwarded `providerLeaseId`, so one fixture // serves every route-gate case: -// - `mode`: "normal" | "malformed-open" | "no-open-reply" | "duplicate-open-reply" +// - `mode`: "normal" | "malformed-open" | "no-open-reply" | "duplicate-open-reply" | +// "exit-before-open-reply" // - `workerSessionId`: the worker session id the open reply returns (default "ws-1") // - `outputs`: an array of `{ chunk, sid? }`. The fixture emits each as an output // notification after the open reply. `sid` defaults to the real worker session // id; a test sets a wrong `sid` to prove the host drops a mismatched // notification. // - `exitCode`: when set, the fixture emits an exit notification after the outputs. +// - `extraExits`: an array of `{ exitCode, sid? }`. The fixture emits each as a +// further exit notification, after the main `exitCode` exit. `sid` defaults +// to the real worker session id; a test sets a wrong `sid` to script a worker +// that sends a valid exit, then a mismatched exit, before the bind. +// - `outputsAfterExit`: an array of `{ chunk, sid? }`, same shape as `outputs`. +// The fixture emits these after the exit notification, so a test proves the +// host drops output that arrives behind a queued exit. +// - `sequence`: an array of `{ type: "output", chunk, sid? }` or +// `{ type: "exit", exitCode, sid? }` entries. When set, the fixture emits +// exactly this sequence, in order, instead of the fixed +// outputs/exitCode/extraExits/outputsAfterExit composition. A test uses +// this to script an arrival order the fixed composition cannot express, +// for example a mismatched exit that arrives before a valid one. // - `closeMode`: "ack" | "bad-ack" | "no-ack" (default "ack"). It controls the // close reply, so a test proves the host retires the worker on an unconfirmed // close. +// - `batchWithOpenReply`: when true, the fixture writes the open reply and the +// scripted outputs and exit in one stdout write. The host then reads the open +// reply and the notifications in one batch, so a test proves the host queues +// and replays a record that arrives before the route binds. +// - `mode: "exit-before-open-reply"`: the fixture emits the scripted outputs, +// then exits with no open reply, so a test proves the host clears the +// pre-bind queue on a worker exit during the open window. const readline = require("node:readline"); function send(message) { process.stdout.write(`${JSON.stringify(message)}\n`); } +// Serialize one array of `{ chunk, sid? }` entries as newline-delimited output +// notification lines. A test sets a wrong `sid` on one entry to force a +// mismatch. +function outputLines(entries, workerSessionId) { + let lines = ""; + for (const entry of entries) { + lines += `${JSON.stringify({ + jsonrpc: "2.0", + method: "loginPty.output", + params: { + workerSessionId: entry.sid ?? workerSessionId, + chunk: entry.chunk, + }, + })}\n`; + } + return lines; +} + +// Serialize one exit notification line for the given worker session id. +function exitLine(workerSessionId, exitCode) { + return `${JSON.stringify({ + jsonrpc: "2.0", + method: "loginPty.exit", + params: { workerSessionId, exitCode }, + })}\n`; +} + +// Serialize the scripted output and exit notifications as newline-delimited +// lines. The `sequence` directive, when set, emits exactly that order. The +// fixed composition otherwise emits, in order: the pre-exit outputs, then +// the main exit, then every extra exit, then the post-exit outputs. The +// batch mode writes these together with the open reply in one stdout write. +function scriptedOutputLines(directive, workerSessionId) { + if (Array.isArray(directive.sequence)) { + let lines = ""; + for (const entry of directive.sequence) { + if (entry.type === "output") { + lines += outputLines([{ chunk: entry.chunk, sid: entry.sid }], workerSessionId); + } else if (entry.type === "exit") { + lines += exitLine(entry.sid ?? workerSessionId, entry.exitCode); + } + } + return lines; + } + const outputs = Array.isArray(directive.outputs) ? directive.outputs : []; + const outputsAfterExit = Array.isArray(directive.outputsAfterExit) + ? directive.outputsAfterExit + : []; + const extraExits = Array.isArray(directive.extraExits) ? directive.extraExits : []; + let lines = outputLines(outputs, workerSessionId); + if (typeof directive.exitCode === "number") { + lines += exitLine(workerSessionId, directive.exitCode); + } + for (const exit of extraExits) { + lines += exitLine(exit.sid ?? workerSessionId, exit.exitCode); + } + lines += outputLines(outputsAfterExit, workerSessionId); + return lines; +} + // The registered terminals, keyed by the host route id. Each entry records the // bound worker session id and the close directive. const routes = new Map(); @@ -71,41 +152,48 @@ rl.on("line", (line) => { // Never reply, so the host open call times out. return; } - if (mode === "malformed-open") { - // Reply with no worker session id, so the host terminalizes the route. - send({ jsonrpc: "2.0", id: message.id, result: {} }); + if (mode === "exit-before-open-reply") { + // Emit the scripted pre-bind outputs, then exit with no open reply. The + // route never binds, so a test proves the worker-exit path clears the + // pre-bind queue. + process.stdout.write(scriptedOutputLines(directive, workerSessionId)); + process.exit(1); return; } - const reply = () => - send({ jsonrpc: "2.0", id: message.id, result: { workerSessionId } }); - reply(); + // A malformed reply carries no worker session id, so the host cannot bind + // and terminalizes the route. + const isMalformedOpen = mode === "malformed-open"; + const openReplyLine = `${JSON.stringify({ + jsonrpc: "2.0", + id: message.id, + result: isMalformedOpen ? {} : { workerSessionId }, + })}\n`; + + if (directive.batchWithOpenReply === true) { + // Write the open reply and the scripted outputs and exit in one stdout + // write. The host reads them in one batch, so an output or an exit + // notification arrives before the route binds — even a malformed reply + // that never binds. The host must queue and, on a bind, replay it; on a + // malformed reply, it must clear the queue instead. + process.stdout.write(openReplyLine + scriptedOutputLines(directive, workerSessionId)); + return; + } + + process.stdout.write(openReplyLine); if (mode === "duplicate-open-reply") { // Send a second open reply for the same request id. The host drops it. - reply(); + process.stdout.write(openReplyLine); + } + if (isMalformedOpen) { + // No scripted output follows a non-batched malformed reply. + return; } // Emit the scripted output and the exit after the open reply, so the host // binds the route first. setImmediate(() => { - const outputs = Array.isArray(directive.outputs) ? directive.outputs : []; - for (const entry of outputs) { - send({ - jsonrpc: "2.0", - method: "loginPty.output", - params: { - workerSessionId: entry.sid ?? workerSessionId, - chunk: entry.chunk, - }, - }); - } - if (typeof directive.exitCode === "number") { - send({ - jsonrpc: "2.0", - method: "loginPty.exit", - params: { workerSessionId, exitCode: directive.exitCode }, - }); - } + process.stdout.write(scriptedOutputLines(directive, workerSessionId)); }); return; } diff --git a/server/src/__tests__/issues-service.test.ts b/server/src/__tests__/issues-service.test.ts index ee0632e6732..f2b3bbb347e 100644 --- a/server/src/__tests__/issues-service.test.ts +++ b/server/src/__tests__/issues-service.test.ts @@ -4590,6 +4590,13 @@ describeEmbeddedPostgres("issueService blockers and dependency wake readiness", title: "Child A", status: "done", priority: "medium", + // Give the children distinct, ordered issue numbers. The service + // sorts direct children by issueNumber, then createdAt. A batched + // insert gives every row in the statement the same defaultNow() + // createdAt, so without a distinct issueNumber the two children + // tie on both sort keys and the database is free to return them + // in either order. + issueNumber: 1, }, { id: childB, @@ -4598,6 +4605,7 @@ describeEmbeddedPostgres("issueService blockers and dependency wake readiness", title: "Child B", status: "blocked", priority: "medium", + issueNumber: 2, }, ]); diff --git a/server/src/__tests__/plugin-worker-manager.test.ts b/server/src/__tests__/plugin-worker-manager.test.ts index e34ad8d86d3..6dba39b3712 100644 --- a/server/src/__tests__/plugin-worker-manager.test.ts +++ b/server/src/__tests__/plugin-worker-manager.test.ts @@ -9,6 +9,24 @@ import { type HostServices, type HostToWorkerMethods, } from "@paperclipai/plugin-sdk"; + +// Mock the shared logger, so a test reads the exact calls the manager makes +// when it logs a route event. The child logger returns the same mock object, +// so `log.warn`/`log.error` inside the manager are this mock's `warn`/`error`. +vi.mock("../middleware/logger.js", () => { + const mockLogger: Record = { + trace: vi.fn(), + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + fatal: vi.fn(), + child: vi.fn(() => mockLogger), + }; + return { logger: mockLogger, httpLogger: vi.fn() }; +}); + +import { logger } from "../middleware/logger.js"; import { appendStderrExcerpt, createPluginWorkerHandle, @@ -1345,3 +1363,528 @@ describe("plugin worker manager setup-token pty route gate", () => { } }); }); + +// --------------------------------------------------------------------------- +// Login pseudo-terminal pre-bind queue (GH #12122 / PAP-5132) +// --------------------------------------------------------------------------- +// The host reads the worker pipe and `readline` dispatches every line of one +// chunk synchronously. The route only becomes `open` inside the `await` +// continuation of the `loginPtyOpen` reply, which runs as a microtask after +// the whole synchronous line loop. So a worker that batches an output or an +// exit notification with the open reply floods the host before the bind. The +// tests below prove the host queues these pre-bind records and replays them +// through the live router right after the bind, instead of dropping them. +// The host holds every pre-bind record — output and exit alike — in one +// arrival-ordered queue, and it replays each record through the live router +// in that exact order. A replayed exit that carries the bound worker session +// identifier settles the route, so a record that arrived behind it — a real +// worker process cannot emit output after it exits, so this only matters for +// a forged or a queued record — replays into a route that already settled +// and is dropped. + +describe("plugin worker manager login pseudo-terminal pre-bind queue", () => { + it("queues and replays a coalesced output notification that arrives before the bind", async () => { + const handle = makeLoginPtyHandle(); + try { + await handle.start(); + // The fixture writes the open reply and the output notification in one + // stdout write, so the host reads both before the route binds. + const session = await handle.openLoginPtySession( + ptyOpenInput({ + batchWithOpenReply: true, + workerSessionId: "ws-A", + outputs: [{ chunk: "batched-output" }], + }), + ); + const chunks: string[] = []; + session.onData((chunk) => chunks.push(chunk)); + // The bind already replayed the queued record into `buffered`, so + // `onData` drains it synchronously with no wait. + expect(chunks).toEqual(["batched-output"]); + await session.close(); + } finally { + await handle.stop().catch(() => undefined); + } + }); + + it("delivers a coalesced output before the coalesced exit settles the wait", async () => { + const handle = makeLoginPtyHandle(); + try { + await handle.start(); + const session = await handle.openLoginPtySession( + ptyOpenInput({ + batchWithOpenReply: true, + workerSessionId: "ws-A", + outputs: [{ chunk: "batched-output" }], + exitCode: 0, + }), + ); + const chunks: string[] = []; + session.onData((chunk) => chunks.push(chunk)); + // Both the output and the exit arrived before the bind and queued in + // order. The replay preserves that order, so the output reaches the + // listener before the wait settles. + await expect(session.wait()).resolves.toEqual({ exitCode: 0 }); + expect(chunks).toEqual(["batched-output"]); + await session.close(); + } finally { + await handle.stop().catch(() => undefined); + } + }); + + it("settles the wait with a valid pre-bind exit that a later mismatched exit cannot displace", async () => { + const handle = makeLoginPtyHandle(); + try { + await handle.start(); + // The worker batches a valid exit for the real worker session id, then a + // second exit for a forged worker session id, both before the open + // reply. The bind still verifies the real session id, so the held valid + // exit settles the wait, and the mismatched exit that arrived after it + // never displaces it. + const session = await handle.openLoginPtySession( + ptyOpenInput({ + batchWithOpenReply: true, + workerSessionId: "ws-A", + exitCode: 0, + extraExits: [{ exitCode: 1, sid: "ws-EVIL" }], + }), + ); + await expect(session.wait()).resolves.toEqual({ exitCode: 0 }); + await session.close(); + } finally { + await handle.stop().catch(() => undefined); + } + }); + + it("delivers a pre-bind output, then the exit, and drops output that arrives behind the exit", async () => { + const handle = makeLoginPtyHandle(); + try { + await handle.start(); + // The worker batches an output, then a valid exit, then a further + // output, all before the open reply. The host replays the three held + // records in this exact arrival order. The exit settles the route + // right after the first output, so the record behind the exit finds a + // route that already settled and never reaches the listener. + const session = await handle.openLoginPtySession( + ptyOpenInput({ + batchWithOpenReply: true, + workerSessionId: "ws-A", + outputs: [{ chunk: "before-exit" }], + exitCode: 0, + outputsAfterExit: [{ chunk: "after-exit" }], + }), + ); + const chunks: string[] = []; + session.onData((chunk) => chunks.push(chunk)); + await expect(session.wait()).resolves.toEqual({ exitCode: 0 }); + expect(chunks).toEqual(["before-exit"]); + await session.close(); + } finally { + await handle.stop().catch(() => undefined); + } + }); + + it("drops output that arrives behind a valid pre-bind exit", async () => { + const handle = makeLoginPtyHandle(); + try { + await handle.start(); + const session = await handle.openLoginPtySession( + ptyOpenInput({ + batchWithOpenReply: true, + workerSessionId: "ws-A", + exitCode: 0, + // The worker batched this output behind the exit. The replay sends + // the exit first, in arrival order, which settles the route. The + // output record behind it then finds a route that is no longer + // `open`, the same drop the live path applies to output a real + // worker process could never emit after its own exit. + outputsAfterExit: [{ chunk: "late-output" }], + }), + ); + const chunks: string[] = []; + session.onData((chunk) => chunks.push(chunk)); + await expect(session.wait()).resolves.toEqual({ exitCode: 0 }); + expect(chunks).toEqual([]); + await session.close(); + } finally { + await handle.stop().catch(() => undefined); + } + }); + + it("settles the wait with the valid exit code and drops output behind it, even past the total-chars bound", async () => { + const handle = makeLoginPtyHandle({ + loginPtyLimits: { maxTotalChars: 10, maxPreBindFrames: 1000, maxPreBindChars: 1000 }, + }); + try { + await handle.start(); + // The worker batches a valid exit with the open reply, then an output + // record that would push the cumulative delivered total past the + // 10-character bound. The replay sends the exit first, in arrival + // order. The exit settles the route, so the replay drops the output + // record behind it before the total-chars check ever runs — the + // bound violation the check exists to catch never reaches it, because + // a real worker process could never emit that output in the first + // place. + const session = await handle.openLoginPtySession( + ptyOpenInput({ + batchWithOpenReply: true, + workerSessionId: "ws-A", + exitCode: 0, + outputsAfterExit: [{ chunk: "aaaaaaaaaaaa" }], + }), + ); + const chunks: string[] = []; + session.onData((chunk) => chunks.push(chunk)); + await expect(session.wait()).resolves.toEqual({ exitCode: 0 }); + expect(chunks).toEqual([]); + await session.close(); + } finally { + await handle.stop().catch(() => undefined); + } + }); + + it("delivers output that arrived behind a mismatched pre-bind exit and settles with the later valid exit", async () => { + const handle = makeLoginPtyHandle(); + try { + await handle.start(); + // The worker batches a mismatched exit, then a genuine output, then + // the valid exit, all before the open reply. The mismatched exit + // arrives first, but the exact-match gate fails it, so it changes no + // state and the replay continues. The output that follows it still + // reaches the listener, and the valid exit that follows the output + // settles the wait. + const session = await handle.openLoginPtySession( + ptyOpenInput({ + batchWithOpenReply: true, + workerSessionId: "ws-A", + sequence: [ + { type: "exit", exitCode: 1, sid: "ws-EVIL" }, + { type: "output", chunk: "genuine-output" }, + { type: "exit", exitCode: 0 }, + ], + }), + ); + const chunks: string[] = []; + session.onData((chunk) => chunks.push(chunk)); + await expect(session.wait()).resolves.toEqual({ exitCode: 0 }); + expect(chunks).toEqual(["genuine-output"]); + await session.close(); + } finally { + await handle.stop().catch(() => undefined); + } + }); + + it("drops a forged pre-bind worker session id sent before the valid bind", async () => { + const handle = makeLoginPtyHandle(); + try { + await handle.start(); + const session = await handle.openLoginPtySession( + ptyOpenInput({ + batchWithOpenReply: true, + workerSessionId: "ws-A", + outputs: [ + { chunk: "forged", sid: "ws-EVIL" }, + { chunk: "good" }, + ], + }), + ); + const chunks: string[] = []; + session.onData((chunk) => chunks.push(chunk)); + // Both records queued before the bind, since the host cannot yet check + // a worker session id against a route that has not bound one. The + // replay applies the exact-match gate against the real bind ("ws-A"), + // so the forged record never reaches the listener. + expect(chunks).toEqual(["good"]); + await session.close(); + } finally { + await handle.stop().catch(() => undefined); + } + }); + + it("terminalizes the route when batched pre-bind records pass the frame-count bound", async () => { + const handle = makeLoginPtyHandle({ + loginPtyLimits: { maxPreBindFrames: 2 }, + }); + try { + await handle.start(); + // The worker batches three output notifications with the open reply, so + // all three arrive, and queue, before the bind. The third record passes + // the frame-count bound, so the host terminalizes the route before the + // bind can complete, and the open call itself fails. + await expect( + handle.openLoginPtySession( + ptyOpenInput({ + batchWithOpenReply: true, + outputs: [{ chunk: "a" }, { chunk: "b" }, { chunk: "c" }], + }), + ), + ).rejects.toThrow("LOGIN_PTY_OPEN_FAILED"); + } finally { + await handle.stop().catch(() => undefined); + } + }); + + it("terminalizes the route when batched pre-bind output passes the character bound", async () => { + const handle = makeLoginPtyHandle({ + loginPtyLimits: { maxPreBindChars: 10 }, + }); + try { + await handle.start(); + await expect( + handle.openLoginPtySession( + ptyOpenInput({ + batchWithOpenReply: true, + // 5 + 5 = 10 admits; the third record brings the queued total to + // 15, past the 10-character bound, so the host terminalizes the + // route before the bind can complete. + outputs: [{ chunk: "aaaaa" }, { chunk: "bbbbb" }, { chunk: "ccccc" }], + }), + ), + ).rejects.toThrow("LOGIN_PTY_OPEN_FAILED"); + } finally { + await handle.stop().catch(() => undefined); + } + }); + + it("charges the retained worker session id characters on the pre-bind output path", async () => { + const handle = makeLoginPtyHandle({ + loginPtyLimits: { maxPreBindChars: 10 }, + }); + try { + await handle.start(); + // A 6-character worker session id and one 5-character chunk charge 11 + // characters against the 10-character bound on the very first record, + // even though the chunk alone is under the bound. Without the identifier + // charge, this single record would pass. + await expect( + handle.openLoginPtySession( + ptyOpenInput({ + batchWithOpenReply: true, + workerSessionId: "ABCDEF", + outputs: [{ chunk: "aaaaa" }], + }), + ), + ).rejects.toThrow("LOGIN_PTY_OPEN_FAILED"); + } finally { + await handle.stop().catch(() => undefined); + } + }); + + it("terminalizes the route when batched pre-bind exit notifications with a large worker session id pass the character bound", async () => { + const handle = makeLoginPtyHandle({ + loginPtyLimits: { maxPreBindChars: 10 }, + }); + try { + await handle.start(); + await expect( + handle.openLoginPtySession( + ptyOpenInput({ + batchWithOpenReply: true, + // An exit record carries no chunk, but it still retains the worker + // session id. This 5-character id makes 5 + 5 = 10 admit the first + // two exits; the third brings the queued total to 15, past the + // 10-character bound, so the host terminalizes the route before + // the bind can complete. + workerSessionId: "AAAAA", + sequence: [ + { type: "exit", exitCode: 1 }, + { type: "exit", exitCode: 2 }, + { type: "exit", exitCode: 3 }, + ], + }), + ), + ).rejects.toThrow("LOGIN_PTY_OPEN_FAILED"); + } finally { + await handle.stop().catch(() => undefined); + } + }); + + it("settles with the first exit code against a repeated pre-bind exit for the same session, behind a filled output queue", async () => { + const handle = makeLoginPtyHandle({ + loginPtyLimits: { maxPreBindFrames: 50 }, + }); + try { + await handle.start(); + // Fill the pre-bind queue with 40 output records, then repeat the exit + // for the same worker session id after more output arrives. The first + // exit settles the route during the replay, so the repeat exit and the + // output around it never reach the listener. + const fillerOutputs = Array.from({ length: 40 }, (_, index) => ({ + type: "output" as const, + chunk: `filler-${index}`, + })); + const session = await handle.openLoginPtySession( + ptyOpenInput({ + batchWithOpenReply: true, + workerSessionId: "ws-A", + sequence: [ + ...fillerOutputs, + { type: "exit", exitCode: 1 }, + { type: "output", chunk: "between-exits" }, + { type: "exit", exitCode: 2 }, + { type: "output", chunk: "after-repeat" }, + ], + }), + ); + const chunks: string[] = []; + session.onData((chunk) => chunks.push(chunk)); + // The wait settles with the FIRST exit code, and the exit drops every + // output that arrived behind it, so neither "between-exits" nor + // "after-repeat" reaches the listener. + await expect(session.wait()).resolves.toEqual({ exitCode: 1 }); + expect(chunks).toEqual(fillerOutputs.map((entry) => entry.chunk)); + await session.close(); + } finally { + await handle.stop().catch(() => undefined); + } + }); + + it("settles with the first exit code against N repeated pre-bind exits for the same session", async () => { + const handle = makeLoginPtyHandle(); + try { + await handle.start(); + // The worker sends five repeat exits for the same session before the + // bind. The first exit settles the route during the replay, so every + // repeat drops there and the wait still settles with the first code. + const session = await handle.openLoginPtySession( + ptyOpenInput({ + batchWithOpenReply: true, + workerSessionId: "ws-A", + exitCode: 1, + extraExits: [ + { exitCode: 2 }, + { exitCode: 3 }, + { exitCode: 4 }, + { exitCode: 5 }, + ], + }), + ); + await expect(session.wait()).resolves.toEqual({ exitCode: 1 }); + await session.close(); + } finally { + await handle.stop().catch(() => undefined); + } + }); + + it("clears the pre-bind queue on a malformed open reply", async () => { + const handle = makeLoginPtyHandle(); + try { + await handle.start(); + // The malformed reply carries no worker session id, so the host cannot + // bind. It terminalizes the route and clears the queued output before + // it ever reaches a listener. + await expect( + handle.openLoginPtySession( + ptyOpenInput({ + mode: "malformed-open", + batchWithOpenReply: true, + outputs: [{ chunk: "leaked" }], + }), + ), + ).rejects.toThrow("LOGIN_PTY_OPEN_FAILED"); + // A later open on the same worker starts a fresh route and receives + // only its own scripted output, never the cleared queue. + const session = await handle.openLoginPtySession( + ptyOpenInput({ mode: "normal", outputs: [{ chunk: "fresh" }] }), + ); + const chunks: string[] = []; + session.onData((chunk) => chunks.push(chunk)); + await vi.waitFor(() => expect(chunks).toContain("fresh")); + expect(chunks).not.toContain("leaked"); + await session.close(); + } finally { + await handle.stop().catch(() => undefined); + } + }); + + it("clears the pre-bind queue when the worker exits during the open window", async () => { + const handle = makeLoginPtyHandle({ autoRestart: false }); + try { + await handle.start(); + // The fixture emits one output notification, then exits before it ever + // sends the open reply. The route never binds. The worker-exit path + // must clear the queued output along with the route. + await expect( + handle.openLoginPtySession( + ptyOpenInput({ + mode: "exit-before-open-reply", + outputs: [{ chunk: "queued-before-exit" }], + }), + ), + ).rejects.toThrow(); + } finally { + await handle.stop().catch(() => undefined); + } + }); + + it("terminalizes at replay when the queued output would pass the cumulative total-chars bound", async () => { + const handle = makeLoginPtyHandle({ + loginPtyLimits: { maxTotalChars: 10, maxPreBindFrames: 1000, maxPreBindChars: 1000 }, + }); + try { + await handle.start(); + // The generous pre-bind bounds admit all three records at intake, so + // the bind completes and the replay runs. The replay sends each record + // through the same live router an open route uses, so the cumulative + // `maxTotalChars` gate still applies: the third record would bring the + // delivered total to 15, past the 10-character bound, so the replay + // terminalizes the route partway through. The directive carries no exit + // notification, so only a mid-replay terminalize can settle the wait; + // without the cumulative gate applying during replay, this call would + // hang. + const session = await handle.openLoginPtySession( + ptyOpenInput({ + batchWithOpenReply: true, + outputs: [{ chunk: "aaaaa" }, { chunk: "bbbbb" }, { chunk: "ccccc" }], + }), + ); + const chunks: string[] = []; + session.onData((chunk) => chunks.push(chunk)); + await expect(session.wait()).resolves.toEqual({ exitCode: null }); + // The terminalize clears `route.buffered` by design (a terminalized + // login route settles with a null exit code, so buffered data has no + // consumer), so even the two records the replay delivered before the + // bound tripped never reach the listener. + expect(chunks).toEqual([]); + } finally { + await handle.stop().catch(() => undefined); + } + }); + + it("never logs the raw chunk content, including on the pre-bind overflow path", async () => { + const handle = makeLoginPtyHandle({ + loginPtyLimits: { maxPreBindFrames: 1 }, + }); + const secretMarker = "super-secret-login-code-must-never-reach-a-log-line"; + vi.mocked(logger.warn).mockClear(); + vi.mocked(logger.error).mockClear(); + vi.mocked(logger.info).mockClear(); + vi.mocked(logger.debug).mockClear(); + try { + await handle.start(); + // The first record admits, then the second passes the frame-count + // bound and terminalizes the route. The overflow path logs a fixed + // warning that must never carry the chunk text. + await expect( + handle.openLoginPtySession( + ptyOpenInput({ + batchWithOpenReply: true, + outputs: [{ chunk: secretMarker }, { chunk: "overflow" }], + }), + ), + ).rejects.toThrow("LOGIN_PTY_OPEN_FAILED"); + const loggedText = [ + ...vi.mocked(logger.warn).mock.calls, + ...vi.mocked(logger.error).mock.calls, + ...vi.mocked(logger.info).mock.calls, + ...vi.mocked(logger.debug).mock.calls, + ] + .flat() + .map((arg) => JSON.stringify(arg)) + .join("\n"); + expect(loggedText).not.toContain(secretMarker); + } finally { + await handle.stop().catch(() => undefined); + } + }); +}); diff --git a/server/src/services/plugin-worker-manager.ts b/server/src/services/plugin-worker-manager.ts index 43b7c820b40..0b533a3385c 100644 --- a/server/src/services/plugin-worker-manager.ts +++ b/server/src/services/plugin-worker-manager.ts @@ -169,6 +169,10 @@ const MAX_EXECUTE_LOG_TOTAL_CHARS = 128 * 1024 * 1024; const MAX_LOGIN_PTY_CHUNK_CHARS = 1_000_000; /** Maximum cumulative output characters for one login pseudo-terminal route. */ const MAX_LOGIN_PTY_TOTAL_CHARS = 8 * 1024 * 1024; +/** Default maximum output and exit records the host queues before the bind. */ +const MAX_LOGIN_PTY_PRE_BIND_FRAMES = 10_000; +/** Default maximum cumulative output characters the host queues before the bind. */ +const MAX_LOGIN_PTY_PRE_BIND_CHARS = 8 * 1024 * 1024; /** The default open timeout for one login pseudo-terminal route, in milliseconds. */ const LOGIN_PTY_OPEN_TIMEOUT_MS = 30_000; /** The default close timeout for one login pseudo-terminal route, in milliseconds. */ @@ -472,6 +476,10 @@ export interface WorkerStartOptions { maxChunkChars?: number; /** Max cumulative output characters for one login pseudo-terminal route. */ maxTotalChars?: number; + /** Max number of pre-bind output and exit records the host queues before the bind. */ + maxPreBindFrames?: number; + /** Max cumulative output characters the host queues before the bind. */ + maxPreBindChars?: number; /** The open timeout for one login pseudo-terminal route, in milliseconds. */ openTimeoutMs?: number; /** The close timeout for one login pseudo-terminal route, in milliseconds. */ @@ -909,6 +917,10 @@ export function createPluginWorkerHandle( options.loginPtyLimits?.maxChunkChars ?? MAX_LOGIN_PTY_CHUNK_CHARS; const maxLoginPtyTotalChars = options.loginPtyLimits?.maxTotalChars ?? MAX_LOGIN_PTY_TOTAL_CHARS; + const maxLoginPtyPreBindFrames = + options.loginPtyLimits?.maxPreBindFrames ?? MAX_LOGIN_PTY_PRE_BIND_FRAMES; + const maxLoginPtyPreBindChars = + options.loginPtyLimits?.maxPreBindChars ?? MAX_LOGIN_PTY_PRE_BIND_CHARS; const loginPtyOpenTimeoutMs = options.loginPtyLimits?.openTimeoutMs ?? LOGIN_PTY_OPEN_TIMEOUT_MS; const loginPtyCloseTimeoutMs = @@ -1389,6 +1401,23 @@ export function createPluginWorkerHandle( } type LoginPtyRouteState = RouteState; + + // One pre-bind login pseudo-terminal record: an output chunk or an exit, + // normalized to a narrow scalar shape (never the raw notification object). + interface LoginPtyPreBindOutputRecord { + kind: "output"; + workerSessionId: string; + chunk: string; + } + + interface LoginPtyPreBindExitRecord { + kind: "exit"; + workerSessionId: string; + exitCode: number | null; + } + + type LoginPtyPreBindRecord = LoginPtyPreBindOutputRecord | LoginPtyPreBindExitRecord; + interface LoginPtyRoute { hostRouteId: string; state: LoginPtyRouteState; @@ -1398,6 +1427,12 @@ export function createPluginWorkerHandle( deliveredChars: number; terminalized: boolean; settleWait: (value: { exitCode: number | null }) => void; + // Every output and exit record that arrived before the bind, in arrival order. + preBind: LoginPtyPreBindRecord[]; + // The cumulative characters `preBind` holds. Each record charges its + // `workerSessionId` characters, plus the `chunk` characters for an output + // record. + preBindChars: number; } // At most one active credential pseudo-terminal per worker. A non-null route // blocks a second open until the manager confirms the first route's close. @@ -1430,6 +1465,9 @@ export function createPluginWorkerHandle( route.state = "closed"; route.listener = null; route.buffered = []; + // A terminalized route never replays a queued pre-bind record. + route.preBind = []; + route.preBindChars = 0; // A terminalized route reports a null exit code, which the runner treats as a // failure. settleRouteWait(route, { exitCode: null }); @@ -1448,11 +1486,17 @@ export function createPluginWorkerHandle( // Route one login pseudo-terminal output notification to the per-session // listener. Deliver only while the route is `open` and the notification carries - // the exact bound worker session identifier and valid bounded bytes. Drop an - // unknown, late, malformed, or mismatched notification. Never log the raw bytes. + // the exact bound worker session identifier and valid bounded bytes. Queue the + // notification while the route is still `opening`. Drop an unknown, late, + // malformed, or mismatched notification. Never log the raw bytes. function routeLoginPtyOutput(notification: JsonRpcNotification): void { const route = loginPtyRoute; - if (!route || route.state !== "open") return; + if (!route) return; + if (route.state === "opening") { + queuePreBindLoginPtyOutput(route, notification); + return; + } + if (route.state !== "open") return; const params = isRecord(notification.params) ? notification.params : {}; const workerSessionId = readNonEmptyString(params.workerSessionId); if (!workerSessionId || workerSessionId !== route.workerSessionId) return; @@ -1476,17 +1520,116 @@ export function createPluginWorkerHandle( // Route one login pseudo-terminal exit notification to the login wait. Resolve // only while the route is `open` and the notification carries the exact bound - // worker session identifier. + // worker session identifier. Queue the notification while the route is still + // `opening`. A resolved exit moves the state off `open`, so a later record — + // live or replayed — finds a closed route and drops there. function routeLoginPtyExit(notification: JsonRpcNotification): void { const route = loginPtyRoute; - if (!route || route.state !== "open") return; + if (!route) return; + if (route.state === "opening") { + queuePreBindLoginPtyExit(route, notification); + return; + } + if (route.state !== "open") return; const params = isRecord(notification.params) ? notification.params : {}; const workerSessionId = readNonEmptyString(params.workerSessionId); if (!workerSessionId || workerSessionId !== route.workerSessionId) return; const exitCode = typeof params.exitCode === "number" ? params.exitCode : null; + route.state = "closed"; settleRouteWait(route, { exitCode }); } + // Queue one login pseudo-terminal output notification that arrived before the + // bind, in arrival order. Terminalize the route fail-closed on a frame-count + // or character-bound breach. Never log the raw chunk. + function queuePreBindLoginPtyOutput( + route: LoginPtyRoute, + notification: JsonRpcNotification, + ): void { + const params = isRecord(notification.params) ? notification.params : {}; + const workerSessionId = readNonEmptyString(params.workerSessionId); + const chunk = params.chunk; + if ( + !workerSessionId || + typeof chunk !== "string" || + chunk.length === 0 || + chunk.length > maxLoginPtyChunkChars + ) { + return; + } + // Charge the record's full retained size: the worker session identifier + // plus the chunk. A record that retains only the identifier still counts. + const recordChars = workerSessionId.length + chunk.length; + if ( + route.preBind.length + 1 > maxLoginPtyPreBindFrames || + route.preBindChars + recordChars > maxLoginPtyPreBindChars + ) { + log.warn( + { pluginId }, + "login pseudo-terminal pre-bind queue exceeded a bound; terminalizing route", + ); + void terminalizeLoginPtyRoute(route); + return; + } + route.preBind.push({ kind: "output", workerSessionId, chunk }); + route.preBindChars += recordChars; + } + + // Queue one login pseudo-terminal exit notification that arrived before the + // bind, in the same queue the output records use. An exit record carries no + // chunk, but it still retains a worker session identifier, so it charges + // against the character bound too. + function queuePreBindLoginPtyExit( + route: LoginPtyRoute, + notification: JsonRpcNotification, + ): void { + const params = isRecord(notification.params) ? notification.params : {}; + const workerSessionId = readNonEmptyString(params.workerSessionId); + if (!workerSessionId) return; + const exitCode = typeof params.exitCode === "number" ? params.exitCode : null; + const recordChars = workerSessionId.length; + if ( + route.preBind.length + 1 > maxLoginPtyPreBindFrames || + route.preBindChars + recordChars > maxLoginPtyPreBindChars + ) { + log.warn( + { pluginId }, + "login pseudo-terminal pre-bind queue exceeded a bound; terminalizing route", + ); + void terminalizeLoginPtyRoute(route); + return; + } + route.preBind.push({ kind: "exit", workerSessionId, exitCode }); + route.preBindChars += recordChars; + } + + // Replay the records a route held before it bound, in arrival order, + // through the same live router the open route uses (`routeLoginPtyOutput`, + // `routeLoginPtyExit`), so a forged worker session identifier still fails + // the exact-match gate and a replayed exit still closes the route and + // drops every record behind it. + function replayPreBindLoginPtyRecords(route: LoginPtyRoute): void { + const held = route.preBind; + route.preBind = []; + route.preBindChars = 0; + for (const record of held) { + if (route.terminalized) return; + if (record.kind === "output") { + routeLoginPtyOutput({ + jsonrpc: "2.0", + method: LOGIN_PTY_OUTPUT_NOTIFICATION, + params: { workerSessionId: record.workerSessionId, chunk: record.chunk }, + }); + } else { + routeLoginPtyExit({ + jsonrpc: "2.0", + method: LOGIN_PTY_EXIT_NOTIFICATION, + params: { workerSessionId: record.workerSessionId, exitCode: record.exitCode }, + }); + } + } + } + // Close the one route on a worker exit. The worker is gone, so the manager // resolves the login wait with the fixed non-secret exit and clears the route // one time. The pending pseudo-terminal calls reject through `rejectAllPending`. @@ -1498,6 +1641,8 @@ export function createPluginWorkerHandle( route.state = "closed"; route.listener = null; route.buffered = []; + route.preBind = []; + route.preBindChars = 0; settleRouteWait(route, { exitCode: null }); } @@ -1538,6 +1683,8 @@ export function createPluginWorkerHandle( deliveredChars: 0, terminalized: false, settleWait, + preBind: [], + preBindChars: 0, }; loginPtyRoute = route; @@ -1574,6 +1721,8 @@ export function createPluginWorkerHandle( // Bind the worker session identifier one time and move the route to `open`. route.workerSessionId = workerSessionId; route.state = "open"; + // Replay every record the route queued before the bind, in arrival order. + replayPreBindLoginPtyRecords(route); return { onData(listener: (chunk: string) => void): void { From d2b9765cc81b6963e5742daae19d1892a05905c1 Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Tue, 25 Aug 2026 13:51:35 -0700 Subject: [PATCH 04/32] feat(ui): offer enabling a skill for agents at install time (#12136) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The company skill library lets operators install skills, and each agent has its own enabled-skill set > - Installing a skill only writes the library row; no agent receives the skill, and the UI says "Skill installed" with no attach step > - Operators install a skill, ask an agent to use it, and the agent truthfully reports the skill as not available — the install felt broken > - This pull request adds an "Enable for agents" step to the install dialog and enables the skill for the selected agents right after install > - The benefit is that "install" defaults to a state where agents can actually use the skill, and the toast is honest when they cannot ## Linked Issues or Issue Description **What existing behavior does this improve?** Installing a skill from the catalog in the company Skills page. **Subsystem affected** Web UI — company skills catalog install flow (`ui/src/pages/CompanySkills.tsx`). **Current behavior** Install writes a `company_skills` row and shows a "Skill installed" toast. No agent is enabled for the skill. Agents resolve their skills from their own desired-skill set, so they report the skill as not installed. The operator has to find the separate "Add to agent" control to make the install effective. **Proposed behavior** The install dialog shows an "Enable for agents" section for fresh installs. It pre-selects every agent whose adapter supports skills. After install, the page enables the skill for each selected agent (skills sync with mode `add`). The success toast reports how many agents received the skill, and warns when the skill is in the library with no agents enabled. **Breaking changes** None. Updates and replacements of an existing skill do not show the new section and behave as before. ## What Changed - `InstallPreviewDialog` gains an "Enable for agents" section (fresh installs only) built on the existing `AgentMultiSelect`, with agents whose adapter lacks skills support disabled. - New exported helper `defaultInstallAgentSelection` pre-selects every skills-capable, non-required agent. - The install mutation enables the skill for each selected agent via `agentsApi.syncSkills(..., "add")` before invalidating queries, and reports per-agent failures in a warning toast without failing the install. - Toast copy now distinguishes "enabled for N agents" from "in the library but not enabled for any agent yet". ## Verification - `cd ui && npx vitest run src/pages/CompanySkills.test.tsx` — 23 tests pass, including three new ones: default-selection helper, confirm payload carries the pre-selected agents, update/replace path skips the section. - `cd ui && pnpm run typecheck` — clean. - Manual: install a catalog skill with two agents in the company; both are pre-selected; after install the skill page lists both under "Used by agents". ## Risks - Low risk. Enablement uses the existing per-agent skills sync route with mode `add`, so concurrent edits to an agent's desired set are not overwritten. A per-agent sync failure surfaces as a warning toast and never fails the install itself. ## Model Used - Claude Fable 5 (`claude-fable-5`, Anthropic) with extended thinking and tool use, via Claude Code. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --- ui/src/pages/CompanySkills.test.tsx | 162 +++++++++++++++++++++++++++- ui/src/pages/CompanySkills.tsx | 107 ++++++++++++++++-- 2 files changed, 261 insertions(+), 8 deletions(-) diff --git a/ui/src/pages/CompanySkills.test.tsx b/ui/src/pages/CompanySkills.test.tsx index ef381ae1bcb..ff565fdf9a7 100644 --- a/ui/src/pages/CompanySkills.test.tsx +++ b/ui/src/pages/CompanySkills.test.tsx @@ -3,11 +3,13 @@ import type { ComponentProps, ReactNode } from "react"; import { flushSync } from "react-dom"; import { createRoot, type Root } from "react-dom/client"; -import type { CompanySkillDetail, CompanySkillVersion, FolderListResult } from "@paperclipai/shared"; +import type { CatalogSkill, CompanySkillDetail, CompanySkillVersion, FolderListResult } from "@paperclipai/shared"; import { afterEach, describe, expect, it, vi } from "vitest"; import { DiscoveryGrid, + InstallPreviewDialog, SkillDetailPage, + defaultInstallAgentSelection, getSkillVersionDiffSelection, resolveDiscoveryTab, withDiscoveryTab, @@ -837,3 +839,161 @@ describe("SkillDetailPage settings", () => { expect((node.querySelector('[role="dialog"] input') as HTMLInputElement).value).toBe("memory"); }); }); + +describe("install-time agent enablement", () => { + const agentOptions = [ + { id: "agent-ceo", name: "CEO", adapterType: "claude_local", supportsSkills: true, required: false, icon: null, paused: false }, + { id: "agent-designer", name: "Designer", adapterType: "claude_local", supportsSkills: true, required: false, icon: null, paused: true }, + { id: "agent-gateway", name: "Gateway", adapterType: "openclaw_gateway", supportsSkills: false, required: false, icon: null, paused: false }, + { id: "agent-builtin", name: "Summarizer", adapterType: "claude_local", supportsSkills: true, required: true, icon: null, paused: false }, + ]; + + function makeCatalogSkill(): CatalogSkill { + return { + id: "catalog-1", + key: "paperclipai/bundled/product/wireframe", + kind: "bundled", + category: "product", + slug: "wireframe", + name: "wireframe", + description: "Draw wireframes.", + path: "catalog/bundled/product/wireframe", + entrypoint: "SKILL.md", + trustLevel: "markdown_only", + compatibility: "compatible", + defaultInstall: false, + recommendedForRoles: [], + requires: [], + tags: [], + files: [{ path: "SKILL.md", kind: "skill", sizeBytes: 128, sha256: "abc" }], + contentHash: "sha256:abc", + }; + } + + it("defaults to every skills-capable, non-required agent", () => { + expect(defaultInstallAgentSelection(agentOptions)).toEqual(new Set(["agent-ceo", "agent-designer"])); + }); + + it("passes the default agent selection through onConfirm for fresh installs", async () => { + const onConfirm = vi.fn(); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + root?.render( + , + ); + }); + // The dialog seeds its slug/agent state in passive effects; give them a + // macrotask to flush before interacting. + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + const node = container as ParentNode; + expect(node.textContent).toContain("Enable for agents"); + + await click(buttonsNamed(node, "Install skill")[0] as HTMLButtonElement); + + expect(onConfirm).toHaveBeenCalledWith({ + slug: "wireframe", + force: false, + agentIds: expect.arrayContaining(["agent-ceo", "agent-designer"]), + }); + expect(onConfirm.mock.calls[0][0].agentIds).toHaveLength(2); + }); + + it("keeps tracking the agent default when agents load after the dialog opens", async () => { + const onConfirm = vi.fn(); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + const renderDialog = (agents: typeof agentOptions) => + root?.render( + , + ); + + // Dialog opens before the agents query resolves: nothing to select yet. + await act(async () => renderDialog([])); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + // The agents arrive later; the untouched selection must pick them up. + await act(async () => renderDialog(agentOptions)); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + await click(buttonsNamed(container as ParentNode, "Install skill")[0] as HTMLButtonElement); + + expect(onConfirm.mock.calls[0][0].agentIds).toHaveLength(2); + }); + + it("skips agent enablement for updates and replacements", async () => { + const onConfirm = vi.fn(); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + root?.render( + , + ); + }); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + const node = container as ParentNode; + expect(node.textContent).not.toContain("Enable for agents"); + + await click(buttonsNamed(node, "Install update")[0] as HTMLButtonElement); + + expect(onConfirm).toHaveBeenCalledWith({ slug: "wireframe", force: false, agentIds: [] }); + }); +}); diff --git a/ui/src/pages/CompanySkills.tsx b/ui/src/pages/CompanySkills.tsx index 5ef66a91973..0f9e67ddb74 100644 --- a/ui/src/pages/CompanySkills.tsx +++ b/ui/src/pages/CompanySkills.tsx @@ -2018,7 +2018,17 @@ function CatalogDetailPane({ ); } -function InstallPreviewDialog({ +// Installing only adds a skill to the company library; an agent can use it only +// once it is also enabled for that agent. Pre-select every agent that can +// receive the skill so "install" defaults to a state where the skill is +// actually usable, instead of a library row no agent has. +export function defaultInstallAgentSelection( + agents: Array>, +): Set { + return new Set(agents.filter((agent) => agent.supportsSkills && !agent.required).map((agent) => agent.id)); +} + +export function InstallPreviewDialog({ open, onOpenChange, skill, @@ -2028,6 +2038,7 @@ function InstallPreviewDialog({ defaultSlug, defaultForce, defaultAction, + agents, isPending, error, onConfirm, @@ -2041,13 +2052,21 @@ function InstallPreviewDialog({ defaultSlug: string | null; defaultForce: boolean; defaultAction: "install" | "update" | "replace"; + agents: AttachAgentOption[]; isPending: boolean; error: string | null; - onConfirm: (input: { slug: string | null; force: boolean }) => void; + onConfirm: (input: { slug: string | null; force: boolean; agentIds: string[] }) => void; }) { const [slug, setSlug] = useState(""); const [force, setForce] = useState(false); const [advancedOpen, setAdvancedOpen] = useState(false); + const [selectedAgentIds, setSelectedAgentIds] = useState>(new Set()); + // Whether the user changed the agent selection this open. Until then the + // selection keeps tracking the default: the agents query may resolve after + // the dialog opens, and a one-shot seed would freeze an empty selection and + // install the skill for nobody. + const [selectionTouched, setSelectionTouched] = useState(false); + const wasOpenRef = useRef(false); useEffect(() => { if (!open) return; @@ -2056,6 +2075,18 @@ function InstallPreviewDialog({ setAdvancedOpen(defaultAction === "replace" || defaultForce); }, [open, defaultSlug, defaultForce, defaultAction]); + useEffect(() => { + if (open && !wasOpenRef.current) setSelectionTouched(false); + wasOpenRef.current = open; + }, [open]); + + // Track the default selection while the dialog is open and untouched; the + // user's first change takes over and background refetches never clobber it. + useEffect(() => { + if (!open || selectionTouched) return; + setSelectedAgentIds(defaultAction === "install" ? defaultInstallAgentSelection(agents) : new Set()); + }, [open, selectionTouched, defaultAction, agents]); + if (!skill) return null; let confirmLabel = "Install skill"; @@ -2140,6 +2171,33 @@ function InstallPreviewDialog({ ) : null} + {defaultAction === "install" ? ( +
+
Enable for agents
+

+ Installing adds the skill to the company library. Agents can only use it once it is enabled for them. +

+ { + setSelectionTouched(true); + setSelectedAgentIds(next); + }} + showSelectionPreview={false} + emptyMessage="No agents in this company support skills yet." + isAgentDisabled={(agent) => { + const option = agent as AttachAgentOption; + return option.required || !option.supportsSkills; + }} + getDescription={(agent) => { + const option = agent as AttachAgentOption; + return `${option.adapterType}${option.required ? " · required" : ""}${!option.supportsSkills ? " · skills not supported" : ""}`; + }} + /> +
+ ) : null} + + ) : undefined} + > + New runs will not start until the agent is resumed. {pauseDetail} + ); } @@ -4493,6 +4523,8 @@ export function IssueChatThread({ assigneeUserId = null, onResumeFromBacklog, resumeFromBacklogPending = false, + onResumeAssignee, + resumeAssigneePending = false, externalReferences, linkCaseReferences = false, }: IssueChatThreadProps) { @@ -5241,9 +5273,22 @@ export function IssueChatThread({ : null } /> - + - ) : null} + ) : ( + // Read-only viewers still need to see why nothing is running. +
+ +
+ )} {footer ?
{footer}
: null}
{showComposer ? ( diff --git a/ui/src/components/NewIssueDialog.test.tsx b/ui/src/components/NewIssueDialog.test.tsx index 1d1474351e1..2a6c778a7a3 100644 --- a/ui/src/components/NewIssueDialog.test.tsx +++ b/ui/src/components/NewIssueDialog.test.tsx @@ -548,6 +548,33 @@ describe("NewIssueDialog", () => { act(() => root.unmount()); }); + it("warns when the selected assignee is a paused imported agent", async () => { + dialogState.newIssueDefaults = { + title: "Compare onboarding flows", + assigneeAgentId: "agent-1", + }; + mockAgentsApi.list.mockResolvedValue([ + { + id: "agent-1", + name: "CEO", + status: "paused", + pauseReason: "import", + adapterType: "claude_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }, + ]); + + const { root } = renderDialog(container); + await waitForAssertion(() => { + expect(container.querySelector('[data-testid="new-issue-paused-assignee-note"]')).not.toBeNull(); + }); + expect(container.textContent).toContain("arrived paused from a company import"); + + act(() => root.unmount()); + }); + it("restores the planning mode from dialog defaults", async () => { dialogState.newIssueDefaults = { title: "Planned from defaults", diff --git a/ui/src/components/NewIssueDialog.tsx b/ui/src/components/NewIssueDialog.tsx index f5652ee7841..a382b7c13a9 100644 --- a/ui/src/components/NewIssueDialog.tsx +++ b/ui/src/components/NewIssueDialog.tsx @@ -61,6 +61,7 @@ import { Paperclip, FileText, Flag, + PauseCircle, Loader2, ListTree, X, @@ -76,6 +77,7 @@ import { issueStatusText, issueStatusTextDefault, priorityColor, priorityColorDe import { SHOW_TASK_PRIORITY_UI } from "../lib/ui-flags"; import { MarkdownEditor, type MarkdownEditorRef, type MentionOption } from "./MarkdownEditor"; import { AgentIcon } from "./AgentIconPicker"; +import { InlineBanner } from "./InlineBanner"; import { InlineEntitySelector, type InlineEntityOption } from "./InlineEntitySelector"; import { getTrustPreset } from "../lib/trust-policy-ui"; import { ReusableExecutionWorkspaceSelect } from "./ReusableExecutionWorkspaceSelect"; @@ -586,7 +588,11 @@ export function NewIssueDialog() { const selectedAssigneeAgentId = selectedAssignee.assigneeAgentId; const selectedAssigneeUserId = selectedAssignee.assigneeUserId; - const assigneeAdapterType = (agents ?? []).find((agent) => agent.id === selectedAssigneeAgentId)?.adapterType ?? null; + const selectedAssigneeAgent = useMemo( + () => (agents ?? []).find((agent) => agent.id === selectedAssigneeAgentId) ?? null, + [agents, selectedAssigneeAgentId], + ); + const assigneeAdapterType = selectedAssigneeAgent?.adapterType ?? null; const supportsAssigneeOverrides = Boolean( assigneeAdapterType && ISSUE_OVERRIDE_ADAPTER_TYPES.has(assigneeAdapterType), ); @@ -2331,6 +2337,15 @@ export function NewIssueDialog() {
) : null} + {selectedAssigneeAgent?.status === "paused" ? ( +
+ + {selectedAssigneeAgent.name} is paused and will not start work on this task until it is resumed + {selectedAssigneeAgent.pauseReason === "import" ? " — it arrived paused from a company import" : ""}. You can resume it from the task page after creating the task. + +
+ ) : null} + {currentAssigneeLowTrust ? (
{ + if (!currentAssigneeValue?.startsWith("agent:")) return null; + const assigneeAgentId = currentAssigneeValue.slice("agent:".length); + return agentMap?.get(assigneeAgentId) ?? null; + }, [agentMap, currentAssigneeValue]); + // Mobile (PAP-360): the app shell scrolls the DOCUMENT (Layout's main is // overflow-visible with auto height), so the desktop bounded h-dvh chain // collapses the absolute-inset transcript viewport to 0px. Render the thread @@ -715,6 +723,15 @@ export function TaskChatThread(props: TaskChatThreadProps) { /> )}
+ {assignedAgentForNotice?.status === "paused" ? ( +
+ +
+ ) : null} {showComposer ? (
Promise | void; resumeFromBacklogPending?: boolean; + onResumeAssignee?: () => Promise | void; + resumeAssigneePending?: boolean; externalReferences?: MarkdownExternalReferenceMap; linkCaseReferences?: boolean; }; @@ -1126,6 +1128,8 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({ assigneeUserId, onResumeFromBacklog, resumeFromBacklogPending, + onResumeAssignee, + resumeAssigneePending, externalReferences, linkCaseReferences, }: IssueDetailChatTabProps) { @@ -1401,6 +1405,8 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({ assigneeUserId={assigneeUserId} onResumeFromBacklog={onResumeFromBacklog} resumeFromBacklogPending={resumeFromBacklogPending} + onResumeAssignee={onResumeAssignee} + resumeAssigneePending={resumeAssigneePending} footer={footer} externalReferences={externalReferences} linkCaseReferences={linkCaseReferences} @@ -4063,6 +4069,46 @@ export function IssueDetail() { const handleResumeFromBacklog = useCallback(async () => { await updateIssue.mutateAsync({ status: "todo" }); }, [updateIssue.mutateAsync]); + // Resume a paused assignee agent straight from the thread notice: a paused + // assignee silently drops every assignment wake, so the fix belongs next to + // the explanation. + const issueAssigneeAgentIdForResume = issue?.assigneeAgentId ?? null; + const issueCompanyIdForResume = issue?.companyId ?? null; + const issueStatusForResume = issue?.status ?? null; + const issueIdForResume = issue?.id ?? null; + const resumeAssigneeAgent = useMutation({ + mutationFn: async () => { + if (!issueAssigneeAgentIdForResume) throw new Error("No assignee agent"); + await agentsApi.resume(issueAssigneeAgentIdForResume, issueCompanyIdForResume ?? undefined); + // The pause silently dropped this issue's assignment wake, so resuming + // alone would leave the task idle until some other trigger fires. + // Re-issue the wake for executable statuses; best-effort — the agent is + // resumed either way and the next comment or timer also wakes it. + if (issueIdForResume && (issueStatusForResume === "todo" || issueStatusForResume === "in_progress")) { + try { + await agentsApi.wakeup( + issueAssigneeAgentIdForResume, + { + source: "assignment", + reason: "Assignee resumed from the task page", + payload: { issueId: issueIdForResume, mutation: "assignee_resumed" }, + }, + issueCompanyIdForResume ?? undefined, + ); + } catch { + // Non-fatal: the resume succeeded; the wake retries on the next trigger. + } + } + }, + onSuccess: async () => { + if (issueCompanyIdForResume) { + await queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(issueCompanyIdForResume) }); + } + }, + }); + const handleResumeAssignee = useCallback(async () => { + await resumeAssigneeAgent.mutateAsync(); + }, [resumeAssigneeAgent.mutateAsync]); const activeRecoveryActionId = issue?.activeRecoveryAction?.id; const handleResolveRecoveryAction = useCallback( (outcome: import("../components/IssueRecoveryActionCard").RecoveryResolveOutcome) => { @@ -5365,6 +5411,8 @@ export function IssueDetail() { resumeFromBacklogPending={ updateIssue.isPending && updateIssue.variables?.status === "todo" } + onResumeAssignee={issue.assigneeAgentId ? handleResumeAssignee : undefined} + resumeAssigneePending={resumeAssigneeAgent.isPending} externalReferences={externalObjectsState.isEnabled ? externalObjectsState.markdownReferences : undefined} linkCaseReferences={casesChipsEnabled} /> From 868e210a95fd9aa8755730430377e8d431e0e313 Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Tue, 25 Aug 2026 13:51:46 -0700 Subject: [PATCH 07/32] feat(ui): post-import landing CTAs on every outcome branch (#12143) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Company import ends on one of two success screens: the full outcome, or a soft-success panel when the job's in-memory result expired before it could be read > - The soft-success panel named no company and offered no way in, and the full outcome never said that paused agents stay resumable after leaving the page > - Users on the soft-success path concluded the import vanished and ran it again, producing duplicate companies > - This pull request gives every success branch a named landing with a direct CTA into the new company and a pointer to the paused-agents banner > - The benefit is that a finished import always lands the user somewhere actionable ## Linked Issues or Issue Description **What existing behavior does this improve?** The outcome screens of the company import page. **Subsystem affected** Web UI — company import (`ui/src/pages/CompanyImport.tsx`). **Current behavior** The expired-job branch renders two sentences ("the company has been added — open it to view it") with no company name and no link. The full-outcome screen shows the activation checklist but does not say the checklist's resume actions remain available on the dashboard, so users treat the page as their only chance. **Proposed behavior** The expired branch keeps the landed company's name and dashboard path when readable, renders an "Open company dashboard" button, and notes that imported agents arrive paused and can be resumed from the dashboard banner. When the company is unreadable it gives explicit switcher guidance instead. The full-outcome screen states that paused items stay resumable from the dashboard. **Breaking changes** None. Pure UI copy/state additions to an existing page. ## What Changed - The `expired` import outcome now carries `companyName` and `dashboardPath`, captured from the already-fetched company in `onSuccess`. - The expired panel renders the company name, a dashboard CTA (`data-testid="import-expired-open-company"`), the paused-agents pointer, and a switcher fallback. - The full-outcome screen adds a line noting the dashboard offers the same resume actions as the activation checklist. ## Verification - `cd ui && npx vitest run src/pages/CompanyImport.test.tsx` — 24 tests pass; the soft-success test now asserts the name, pointer, and CTA, and a new test covers the unreadable-company fallback. - `cd ui && pnpm run typecheck` — clean. ## Risks - Low risk. The dashboard pointer references the paused-agents banner shipping in #12142; until that merges the sentence still points at the dashboard, where paused agents are already visible in the metric card. ## Model Used - Claude Fable 5 (`claude-fable-5`, Anthropic) with extended thinking and tool use, via Claude Code. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --- ui/src/pages/CompanyImport.test.tsx | 23 +++++++++++- ui/src/pages/CompanyImport.tsx | 56 +++++++++++++++++++++++++---- 2 files changed, 71 insertions(+), 8 deletions(-) diff --git a/ui/src/pages/CompanyImport.test.tsx b/ui/src/pages/CompanyImport.test.tsx index 3a70ea3a4d0..272a924705f 100644 --- a/ui/src/pages/CompanyImport.test.tsx +++ b/ui/src/pages/CompanyImport.test.tsx @@ -922,7 +922,14 @@ describe("CompanyImport", () => { // Success-leaning panel, not the failure panel. expect(container.textContent).toContain("Import completed"); - expect(container.textContent).toContain("open it to view it"); + // The readable company gives the panel a name and a direct CTA into the + // new company's dashboard, plus the paused-agents pointer. + expect(container.textContent).toContain("Imported Test"); + // The default import submits with pauseAutomations checked, so the + // paused pointer must show; it is gated off when the user unchecks it. + expect(container.textContent).toContain("Imported agents arrived paused"); + const openCompany = container.querySelector('[data-testid="import-expired-open-company"]'); + expect(openCompany).not.toBeNull(); expect(container.textContent).not.toContain("Import failed"); expect(mockPushToast).toHaveBeenCalledWith(expect.objectContaining({ tone: "success" })); // The company list is refreshed so the new company appears in the switcher. @@ -934,6 +941,20 @@ describe("CompanyImport", () => { } }); + it("falls back to switcher guidance when the expired job's company is unreadable", async () => { + mockCompaniesApi.getImportJob.mockResolvedValue({ + job: { id: "job-1", status: "succeeded", result: { companyId: "company-2" } }, + }); + mockCompaniesApi.get.mockRejectedValue(new Error("forbidden")); + + await renderPageAndImport(); + + expect(container.textContent).toContain("Import completed"); + expect(container.textContent).toContain("select it from the company switcher"); + // No readable company, so no dashboard CTA — the switcher guidance stands in. + expect(container.querySelector('[data-testid="import-expired-open-company"]')).toBeNull(); + }); + it("surfaces a first-poll 404 as an error because the job never existed", async () => { // A 404 on the very first poll — before the client ever saw the job // running — means the id never existed. That stays a hard error. diff --git a/ui/src/pages/CompanyImport.tsx b/ui/src/pages/CompanyImport.tsx index 6104c9244a4..4e86104af05 100644 --- a/ui/src/pages/CompanyImport.tsx +++ b/ui/src/pages/CompanyImport.tsx @@ -899,7 +899,12 @@ export function CompanyImport() { dashboardPath: string; pausedAutomations: boolean; } - | { kind: "expired" } + | { + kind: "expired"; + companyName: string | null; + dashboardPath: string | null; + pausedAutomations: boolean; + } | null >(null); const [activationChecked, setActivationChecked] = useState>(new Set()); @@ -1243,18 +1248,28 @@ export function CompanyImport() { if (outcome.status === "completed-expired") { // The import finished and wrote all its data, but the job's result // expired (or was never retained) before we could read it. This is a - // success, not a failure: surface it gently and let the refreshed - // switcher carry the user into the new company. + // success, not a failure: keep the landed company's identity so the + // outcome screen can take the user straight there instead of leaving + // them to hunt through the switcher. + let expiredCompanyName: string | null = null; + let expiredDashboardPath: string | null = null; if (outcome.companyId) { try { const importedCompany = await companiesApi.get(outcome.companyId); setSelectedCompanyId(importedCompany.id); + expiredCompanyName = importedCompany.name; + expiredDashboardPath = `/${importedCompany.issuePrefix}/dashboard`; } catch { // The company id may be unreadable (permissions, race); the // refreshed company list still surfaces the import. } } - setImportOutcome({ kind: "expired" }); + setImportOutcome({ + kind: "expired", + companyName: expiredCompanyName, + dashboardPath: expiredDashboardPath, + pausedAutomations: submittedPauseAutomations, + }); pushToast({ tone: "success", title: "Import completed", @@ -1619,16 +1634,37 @@ export function CompanyImport() { // Soft success: the import finished and wrote all its data, but the job's // in-memory result expired before we could read it. Never a failure — the // company list has been refreshed, so the imported company is available - // from the switcher. + // from the switcher, and when we could read the company we take the user + // straight to it. return (

Import completed

- The import finished and your company is ready. Its detailed summary is no - longer available, but the company has been added — open it to view it. + {importOutcome.companyName + ? <>The import finished and {importOutcome.companyName} is ready. Its detailed summary is no longer available. + : "The import finished and your company is ready. Its detailed summary is no longer available, but the company has been added — select it from the company switcher to view it."}

+ {importOutcome.pausedAutomations ? ( +

+ Imported agents arrived paused — resume them from the company's Agents page so assigned tasks can start. +

+ ) : null}
+ {importOutcome.dashboardPath ? ( +
+ +
+ ) : null}
); } @@ -1730,6 +1766,12 @@ export function CompanyImport() {
)} + {importOutcome.pausedAutomations ? ( +

+ Anything left paused here stays visible on the company's Agents and Routines pages, which offer the same resume actions — nothing is lost if you leave this page. +

+ ) : null} + {/* Force a fresh dashboard load so newly imported agents are immediately visible. */}
+ } + > + Agents from a company import arrive paused as a safety default. Resume them so assigned tasks can start. + + ) : pausedBanner?.kind === "all-paused" ? ( + + Review agents + + } + > + Resume at least one agent to let assigned tasks start. + + ) : null} + {hasNoAgents && (
From 6880213de5da2cd93e517732231fdf0eeff4f236 Mon Sep 17 00:00:00 2001 From: Nicky Leach Date: Tue, 25 Aug 2026 14:22:47 -0700 Subject: [PATCH 14/32] fix(adapter-utils): honor .gitignore for referenced-project staging (#12184) ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Sandbox adapters stage project files before an agent starts. > - Referenced projects ignored Git-ignored paths and copied large local directories. > - This behavior increased staging time and disk use, and it differed from anchor workspaces. > - This pull request resolves Git-ignored paths once and shares that result across all referenced-project consumers. > - The benefit is smaller, faster, and consistent project staging. ## Linked Issues or Issue Description No public GitHub issue exists for this bug. **What happened?** Referenced-project staging copied Git-ignored paths, except for a fixed list of heavy directory names. A large repository therefore used much more time and disk space than the same repository in an anchor workspace. **Expected behavior** Referenced-project staging should exclude the same Git-ignored paths that the workspace staging path excludes. **Steps to reproduce** 1. Create a referenced project with a large Git-ignored directory. 2. Start a sandbox or SSH run that stages the referenced project. 3. Observe that the ignored directory enters the staged content. **Paperclip version or commit** Commit `9964b034bbff24e700c8eccf5a8b1fc3daa44bf2`. **Deployment mode** Built from source. ## What Changed - Resolve each referenced project's Git-ignored paths once before staging. - Carry the resolved paths as a required field on `SandboxAdditionalSource`. - Reuse the resolved paths in sandbox staging, SSH staging, and content-signature code. - Harden the read-only Git helper with a bounded process, a reduced environment, and disabled system and global configuration. - Fail closed on Git errors, timeouts, and invalid path relations. - Escape tar glob metacharacters in ignore-derived exclude entries. - Add and update unit tests for the resolver and its three consumers. ## Verification - `pnpm vitest run --config packages/adapter-utils/vitest.config.ts` passes 266 tests locally. - `pnpm exec tsc --noEmit -p packages/adapter-utils/tsconfig.json` passes locally. - CI must pass on this pull request. - Greptile must report 5/5 with no unresolved comments before merge. ## Risks - A Git error or timeout now prevents staging for the affected referenced project. - The resolver uses a bounded read-only Git process and fails closed by design. - The change stays inside `packages/adapter-utils` and does not change the database schema. ## Model Used Claude Sonnet 5 (Anthropic) assisted the implementation with code execution and tool use. The exact context window and reasoning mode are not recorded. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes: #` / `Refs: #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- .../src/acpx-engine/execute.test.ts | 93 ++++++++ .../adapter-utils/src/acpx-engine/execute.ts | 90 +++++-- .../src/command-managed-runtime.test.ts | 6 +- .../adapter-utils/src/execution-target.ts | 4 + .../src/git-workspace-sync.test.ts | 108 +++++++++ .../adapter-utils/src/git-workspace-sync.ts | 151 +++++++++++- .../src/remote-managed-runtime.test.ts | 89 ++++++- .../src/remote-managed-runtime.ts | 25 +- .../src/sandbox-file-sync.test.ts | 12 +- .../src/sandbox-managed-runtime.test.ts | 220 +++++++++++++++++- .../src/sandbox-managed-runtime.ts | 148 +++++++++++- .../workspace-git-operation-scheduler.ts | 5 + 12 files changed, 902 insertions(+), 49 deletions(-) diff --git a/packages/adapter-utils/src/acpx-engine/execute.test.ts b/packages/adapter-utils/src/acpx-engine/execute.test.ts index 8e0d712b097..1aa79fd366a 100644 --- a/packages/adapter-utils/src/acpx-engine/execute.test.ts +++ b/packages/adapter-utils/src/acpx-engine/execute.test.ts @@ -31,11 +31,13 @@ import { findAncestorBin, geminiVersionSupportsNativeAcpFlag, parseGeminiVersionParts, + referencedSourceContentSignature, rewriteGeminiAcpFlagForVersion, summarizeAcpxTurnUsage, type AcpxEngineExecutorOptions, } from "./execute.js"; import { runChildProcess } from "../server-utils.js"; +import { setExpensiveWorkspaceGitExecutor } from "../git-workspace-sync.js"; import { getActiveStepContext, runWithRuntimeParent, @@ -1417,6 +1419,50 @@ describe("shared ACPX engine runtime behavior", () => { expect(fp(repointed)).not.toBe(fp(two)); }); + it("routes every referenced project's Git-ignore scan through the registered scheduler, not an unbounded direct spawn", async () => { + const root = await makeTempRoot(); + const stateDir = path.join(root, "state"); + const cwd = path.join(root, "workspace"); + const baseConfig = { agentCommand: "node ./fake-acp.js", stateDir }; + // The paths need not exist: the scheduler hook intercepts the scan before + // any real `git` process (or even a directory-existence check) runs. + const projectPaths = ["/host/project-a", "/host/project-b", "/host/project-c"]; + + const scannedPaths: string[] = []; + setExpensiveWorkspaceGitExecutor(async (input) => { + scannedPaths.push(input.localDir); + const error = new Error("fatal: not a git repository (or any of the parent directories): .git"); + throw Object.assign(error, { stdout: "", stderr: error.message }); + }); + + try { + await runExecutor(baseConfig, { + context: { + taskId: "issue-1", + wakeReason: "issue_assigned", + paperclipWorkspace: { + cwd, + realization: { + additional: projectPaths.map((localPath, index) => ({ + path: localPath, + projectId: String.fromCharCode(97 + index), + })), + }, + }, + }, + }); + } finally { + setExpensiveWorkspaceGitExecutor(null); + } + + // Every referenced project's scan reached the registered scheduler hook. + // The `Promise.all` fan-out over projects can no longer bypass it by + // spawning `git` directly, so a host process that bounds concurrent scans + // there bounds referenced-project scans too, however many projects a run + // configures. + expect(scannedPaths.sort()).toEqual([...projectPaths].sort()); + }); + it("busts the session fingerprint when referenced-project files change at the same host path", async () => { const root = await makeTempRoot(); const stateDir = path.join(root, "state"); @@ -1513,6 +1559,53 @@ describe("shared ACPX engine runtime behavior", () => { expect(fp(after)).not.toBe(fp(before)); }); + describe("referencedSourceContentSignature", () => { + it("returns a stable marker without walking the tree when the ignore resolution failed", async () => { + const root = await makeTempRoot(); + // A directory that does not exist: a walk would throw ENOENT. The + // `failed` resolution must short-circuit before any `fs.readdir` call. + const localPath = path.join(root, "does-not-exist"); + + const signature = await referencedSourceContentSignature(localPath, { kind: "failed", reason: "git status timed out" }); + + expect(signature).toBe("unreadable:git status timed out"); + }); + + it("skips a Git-ignored file (exact match) so its content never affects the signature", async () => { + const root = await makeTempRoot(); + const localPath = path.join(root, "project"); + await fs.mkdir(localPath, { recursive: true }); + await fs.writeFile(path.join(localPath, "kept.txt"), "kept\n", "utf8"); + await fs.writeFile(path.join(localPath, "secret.env"), "TOKEN=1\n", "utf8"); + + const resolution = { kind: "git" as const, ignoredPaths: ["secret.env"] }; + const before = await referencedSourceContentSignature(localPath, resolution); + await fs.writeFile(path.join(localPath, "secret.env"), "TOKEN=2\n", "utf8"); + const afterIgnoredEdit = await referencedSourceContentSignature(localPath, resolution); + await fs.writeFile(path.join(localPath, "kept.txt"), "kept, changed\n", "utf8"); + const afterKeptEdit = await referencedSourceContentSignature(localPath, resolution); + + // Editing the ignored file never busts the signature; editing the kept file does. + expect(afterIgnoredEdit).toBe(before); + expect(afterKeptEdit).not.toBe(before); + }); + + it("skips every file under a Git-ignored directory (prefix match)", async () => { + const root = await makeTempRoot(); + const localPath = path.join(root, "project"); + await fs.mkdir(path.join(localPath, "build", "nested"), { recursive: true }); + await fs.writeFile(path.join(localPath, "kept.txt"), "kept\n", "utf8"); + await fs.writeFile(path.join(localPath, "build", "nested", "artifact.js"), "v1\n", "utf8"); + + const resolution = { kind: "git" as const, ignoredPaths: ["build"] }; + const before = await referencedSourceContentSignature(localPath, resolution); + await fs.writeFile(path.join(localPath, "build", "nested", "artifact.js"), "v2\n", "utf8"); + const after = await referencedSourceContentSignature(localPath, resolution); + + expect(after).toBe(before); + }); + }); + it("shapes ACPX session env for remote execution identities", async () => { const root = await makeTempRoot(); const localCwd = path.join(root, "local"); diff --git a/packages/adapter-utils/src/acpx-engine/execute.ts b/packages/adapter-utils/src/acpx-engine/execute.ts index 37c15eb9aff..f62c6c6a1db 100644 --- a/packages/adapter-utils/src/acpx-engine/execute.ts +++ b/packages/adapter-utils/src/acpx-engine/execute.ts @@ -22,6 +22,7 @@ import { prepareAdapterExecutionTargetRuntime, readAdapterExecutionTarget, resolveAdapterExecutionTargetTimeout, + resolveReferencedSourceIgnore, runAdapterExecutionTargetShellCommand, startAdapterExecutionTargetPaperclipBridge, startAdapterExecutionTargetProcessSessionBridge, @@ -31,6 +32,7 @@ import { type AdapterExecutionTargetTimeoutResolution, type AdapterManagedRuntimeAsset, type PreparedAdapterExecutionTargetRuntime, + type ReferencedSourceIgnoreResolution, type SandboxAdditionalSource, } from "@paperclipai/adapter-utils/execution-target"; import type { DuplexLossReason } from "../duplex-observability.js"; @@ -519,9 +521,14 @@ export function finalizeLaunchEnvironment( } // Directory names the staging path never ships for a referenced project (heavy -// build/cache output and git history). The content signature skips them so it -// reflects only the staged tree and never reads their bytes. Keep this set equal -// to the staging excludes in the sandbox and remote runtimes. +// build/cache output and git history), applied regardless of the project's +// ignore resolution. The content signature skips them so it reflects only the +// staged tree and never reads their bytes. Keep this set equal to the fixed +// excludes the sandbox and SSH runtimes always apply. A project's OWN resolved +// Git-ignored paths (see `resolveReferencedSourceIgnore`) are matched +// separately, by relative path, inside `referencedSourceContentSignature` — that +// is the real invariant now: the signature and both staging lanes must consume +// the SAME one resolution per project, not just this fixed name list. const REFERENCED_SOURCE_SIGNATURE_SKIP_DIRS = new Set([ "node_modules", "vendor", @@ -551,12 +558,30 @@ const REFERENCED_SOURCE_SIGNATURE_SKIP_DIRS = new Set([ * re-checkout that restores the same size and timestamp. The byte hash busts on any * content change, so the fingerprint busts and the next launch stages the current * tree. The walk skips the heavy build, cache, and git directories the staging path - * never ships, and records a symlink by its target text without following it. On a - * read error the function returns a stable marker, so the fingerprint does not churn - * while staging surfaces the real error. The walk runs only when the run carries - * referenced projects (the multi-project sync path). + * never ships, plus the project's own resolved Git-ignored paths, and records a + * symlink by its target text without following it. On a read error the function + * returns a stable marker, so the fingerprint does not churn while staging + * surfaces the real error. The walk runs only when the run carries referenced + * projects (the multi-project sync path). + * + * `ignoreResolution` is the ONE resolution `resolveReferencedSourceIgnore` + * computed for this project — the same one the sandbox lane and the SSH lane + * consume. A `failed` resolution skips the walk entirely and returns a stable + * marker instead, because a failed project is not staged and its bytes are not + * read anywhere. */ -async function referencedSourceContentSignature(localPath: string): Promise { +export async function referencedSourceContentSignature( + localPath: string, + ignoreResolution: ReferencedSourceIgnoreResolution, +): Promise { + if (ignoreResolution.kind === "failed") { + return `unreadable:${ignoreResolution.reason}`; + } + const isIgnoredByGitResolution = (relativePath: string): boolean => + ignoreResolution.kind === "git" && + ignoreResolution.ignoredPaths.some( + (entry) => relativePath === entry || relativePath.startsWith(`${entry}/`), + ); const hash = createHash("sha256"); const walk = async (relative: string): Promise => { const current = relative ? path.join(localPath, relative) : localPath; @@ -564,6 +589,9 @@ async function referencedSourceContentSignature(localPath: string): Promise (left.name < right.name ? -1 : left.name > right.name ? 1 : 0)); for (const dirent of dirents) { const next = relative ? path.posix.join(relative, dirent.name) : dirent.name; + if (isIgnoredByGitResolution(next)) { + continue; + } if (dirent.isDirectory()) { if (REFERENCED_SOURCE_SIGNATURE_SKIP_DIRS.has(dirent.name)) { continue; @@ -1545,32 +1573,56 @@ async function buildRuntime(input: { const additionalSourceRecords = ( Array.isArray(realizationContext.additional) ? realizationContext.additional : [] ).map((entry) => parseObject(entry)); - const additionalSources: SandboxAdditionalSource[] = additionalSourceRecords - .map((entry) => ({ localPath: asString(entry.path, ""), projectId: asString(entry.projectId, "") })) + const additionalSourceCandidates = additionalSourceRecords + .map((entry) => ({ + localPath: asString(entry.path, ""), + projectId: asString(entry.projectId, ""), + projectWorkspaceId: asString(entry.projectWorkspaceId, ""), + repoUrl: asString(entry.repoUrl, ""), + repoRef: asString(entry.repoRef, ""), + })) .filter((entry) => entry.localPath.length > 0 && entry.projectId.length > 0); + // Resolve each referenced project's Git-ignored paths ONCE, here, before any + // staging site runs. The sandbox lane, the SSH lane, and the content signature + // below all consume this SAME resolution per project, so they can never apply + // a different exclusion set to the same project. See + // `resolveReferencedSourceIgnore` for the fail-closed rules. + const additionalSourcesWithIgnore = await Promise.all( + additionalSourceCandidates.map(async (entry) => ({ + ...entry, + ignoreResolution: await resolveReferencedSourceIgnore(entry.localPath), + })), + ); + const additionalSources: SandboxAdditionalSource[] = additionalSourcesWithIgnore.map((entry) => ({ + localPath: entry.localPath, + projectId: entry.projectId, + ignoreResolution: entry.ignoreResolution, + })); // Stable identity of the referenced-project set for the session fingerprint. // The staged-runtime cache reuses already-staged referenced-project trees on a // compatible resume, so the fingerprint must change when the set OR a project's // pinned checkout changes. Without this, a resume reuses a stale staged tree. // Fold in each project's id, host path, workspace id, and pinned ref; sort by // projectId so the identity depends on the set, not the record order. - const additionalSourcesIdentityBase = additionalSourceRecords + const additionalSourcesIdentityBase = additionalSourcesWithIgnore .map((entry) => ({ - projectId: asString(entry.projectId, ""), - localPath: asString(entry.path, ""), - projectWorkspaceId: asString(entry.projectWorkspaceId, ""), - repoUrl: asString(entry.repoUrl, ""), - repoRef: asString(entry.repoRef, ""), + projectId: entry.projectId, + localPath: entry.localPath, + projectWorkspaceId: entry.projectWorkspaceId, + repoUrl: entry.repoUrl, + repoRef: entry.repoRef, + ignoreResolution: entry.ignoreResolution, })) - .filter((entry) => entry.localPath.length > 0 && entry.projectId.length > 0) .sort((a, b) => (a.projectId < b.projectId ? -1 : a.projectId > b.projectId ? 1 : 0)); // Metadata alone does not change on a content-only checkout change (same host // path and pinned ref, new file bytes). Fold in each tree's content signature so // a file add, remove, or edit busts the fingerprint and the resume re-stages. + // The signature reads the same `ignoreResolution` the staging sites above use, + // so it never disagrees with what was actually shipped. const additionalSourcesIdentity = await Promise.all( - additionalSourcesIdentityBase.map(async (entry) => ({ + additionalSourcesIdentityBase.map(async ({ ignoreResolution, ...entry }) => ({ ...entry, - contentSignature: await referencedSourceContentSignature(entry.localPath), + contentSignature: await referencedSourceContentSignature(entry.localPath, ignoreResolution), })), ); // Referenced-project workspace hints exposed to the agent through PAPERCLIP_WORKSPACES_JSON. The diff --git a/packages/adapter-utils/src/command-managed-runtime.test.ts b/packages/adapter-utils/src/command-managed-runtime.test.ts index cf240453678..f601ce4bce6 100644 --- a/packages/adapter-utils/src/command-managed-runtime.test.ts +++ b/packages/adapter-utils/src/command-managed-runtime.test.ts @@ -356,9 +356,9 @@ describe("command managed runtime", () => { adapterKey: "claude", workspaceLocalDir: localWorkspaceDir, additionalSources: [ - { localPath: goodOne, projectId: "one" }, - { localPath: path.join(rootDir, "missing"), projectId: "broken" }, - { localPath: goodTwo, projectId: "two" }, + { localPath: goodOne, projectId: "one", ignoreResolution: { kind: "other" } }, + { localPath: path.join(rootDir, "missing"), projectId: "broken", ignoreResolution: { kind: "other" } }, + { localPath: goodTwo, projectId: "two", ignoreResolution: { kind: "other" } }, ], }); diff --git a/packages/adapter-utils/src/execution-target.ts b/packages/adapter-utils/src/execution-target.ts index 28423e1a4e4..123cf72d3e4 100644 --- a/packages/adapter-utils/src/execution-target.ts +++ b/packages/adapter-utils/src/execution-target.ts @@ -20,8 +20,12 @@ import type { AdditionalSourceStagingFailure, SandboxAdditionalSource, } from "./sandbox-managed-runtime.js"; +export { + resolveReferencedSourceIgnore, +} from "./sandbox-managed-runtime.js"; export type { AdditionalSourceStagingFailure, + ReferencedSourceIgnoreResolution, SandboxAdditionalSource, } from "./sandbox-managed-runtime.js"; import { diff --git a/packages/adapter-utils/src/git-workspace-sync.test.ts b/packages/adapter-utils/src/git-workspace-sync.test.ts index e7f1a6b9a50..035d3203eb2 100644 --- a/packages/adapter-utils/src/git-workspace-sync.test.ts +++ b/packages/adapter-utils/src/git-workspace-sync.test.ts @@ -14,6 +14,7 @@ import { integrateImportedGitHead, isMissingGitPrerequisiteError, readGitWorkspaceSnapshot, + readReferencedSourceGitIgnoredPaths, runLocalGit, sanitizeGitRemoteUrl, setExpensiveWorkspaceGitExecutor, @@ -534,6 +535,113 @@ describe("git workspace sync", () => { .rejects.toThrow(/Failed to merge concurrent remote git histories/); expect(await git(repo, ["rev-parse", "HEAD"])).toBe(currentHead); }); + + describe("readReferencedSourceGitIgnoredPaths", () => { + it("returns null for a directory that is not a Git work tree", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-referenced-nogit-")); + cleanupDirs.push(rootDir); + const plainDir = path.join(rootDir, "plain"); + await mkdir(plainDir, { recursive: true }); + await writeFile(path.join(plainDir, "file.txt"), "body\n", "utf8"); + + await expect(readReferencedSourceGitIgnoredPaths(plainDir)).resolves.toBeNull(); + }); + + it("reads the repository top level and the ignored paths of a Git work tree", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-referenced-git-")); + cleanupDirs.push(rootDir); + const repo = await createRepo(rootDir); + await writeFile(path.join(repo, ".gitignore"), "secret.env\nbuild/\n", "utf8"); + await writeFile(path.join(repo, "secret.env"), "TOKEN=abc\n", "utf8"); + await mkdir(path.join(repo, "build"), { recursive: true }); + await writeFile(path.join(repo, "build", "out.js"), "artifact\n", "utf8"); + + const scan = await readReferencedSourceGitIgnoredPaths(repo); + expect(scan?.toplevel).toBe(await git(repo, ["rev-parse", "--show-toplevel"])); + expect(scan?.ignoredPaths).toEqual(["build", "secret.env"]); + }); + + it("preserves trailing whitespace in an ignored path entry", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-referenced-trailing-ws-")); + cleanupDirs.push(rootDir); + const repo = await createRepo(rootDir); + // A wildcard pattern avoids the separate rule that git trims an + // unescaped trailing space in a .gitignore pattern itself; the trailing + // space under test lives in the matched FILE name, not the pattern. + const paddedName = "secret.env "; + await writeFile(path.join(repo, ".gitignore"), "secret.env*\n", "utf8"); + await writeFile(path.join(repo, paddedName), "TOKEN=abc\n", "utf8"); + + const scan = await readReferencedSourceGitIgnoredPaths(repo); + expect(scan?.ignoredPaths).toEqual([paddedName]); + }); + + it("routes both scan commands through the registered scheduler instead of spawning git directly", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-referenced-scheduler-")); + cleanupDirs.push(rootDir); + const repo = await createRepo(rootDir); + await writeFile(path.join(repo, ".gitignore"), "build/\n", "utf8"); + await mkdir(path.join(repo, "build"), { recursive: true }); + await writeFile(path.join(repo, "build", "out.js"), "artifact\n", "utf8"); + + const operations: string[] = []; + setExpensiveWorkspaceGitExecutor(async (input) => { + operations.push(input.operation); + return await runLocalGit(input.localDir, [...input.args], { + timeout: input.timeout, + maxBuffer: input.maxBuffer, + env: input.env, + }); + }); + + const scan = await readReferencedSourceGitIgnoredPaths(repo); + + expect(scan?.ignoredPaths).toEqual(["build"]); + // Both the toplevel probe and the ignored-paths read go through the SAME + // process-wide admission seam the anchor workspace's expensive reads + // use. A host process that bounds concurrent scans there also bounds + // referenced-project scans, so a run with many referenced projects + // cannot spawn one unbounded Git process per project. + expect(operations.sort()).toEqual(["referenced_source.ignored_files", "referenced_source.toplevel"]); + }); + + it("carries the hardened arguments and does not inherit a poisoned GIT_CONFIG_GLOBAL", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-referenced-hardened-env-")); + cleanupDirs.push(rootDir); + const repo = await createRepo(rootDir); + const badGlobalConfig = path.join(rootDir, "bad-global-gitconfig"); + await writeFile(badGlobalConfig, "this is not valid git config syntax [[[\n", "utf8"); + + const priorGlobal = process.env.GIT_CONFIG_GLOBAL; + process.env.GIT_CONFIG_GLOBAL = badGlobalConfig; + try { + // A plain invocation inherits the poisoned global config and fails to parse it. + await expect(execFile("git", ["-C", repo, "status", "--porcelain"])).rejects.toThrow(); + // The hardened helper does not inherit GIT_CONFIG_GLOBAL from this process's + // environment, so it succeeds regardless. + await expect(readReferencedSourceGitIgnoredPaths(repo)).resolves.toMatchObject({ ignoredPaths: [] }); + } finally { + if (priorGlobal === undefined) delete process.env.GIT_CONFIG_GLOBAL; + else process.env.GIT_CONFIG_GLOBAL = priorGlobal; + } + }); + + it("neutralizes a repository-local core.fsmonitor hook", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-referenced-fsmonitor-")); + cleanupDirs.push(rootDir); + const repo = await createRepo(rootDir); + const markerPath = path.join(rootDir, "pwned.txt"); + // A malicious repository-local config: a non-boolean `core.fsmonitor` value + // is a hook COMMAND Git runs on every status-like read. `--no-optional-locks` + // alone does not stop this; only the command-line `-c core.fsmonitor=false` + // override does, because command-line config wins over repository config. + await git(repo, ["config", "core.fsmonitor", `sh -c 'touch ${markerPath}; printf 1'`]); + + await readReferencedSourceGitIgnoredPaths(repo); + + await expect(stat(markerPath)).rejects.toThrow(); + }); + }); }); describe("sanitizeGitRemoteUrl", () => { diff --git a/packages/adapter-utils/src/git-workspace-sync.ts b/packages/adapter-utils/src/git-workspace-sync.ts index 6cae5e47247..b8a354c02cf 100644 --- a/packages/adapter-utils/src/git-workspace-sync.ts +++ b/packages/adapter-utils/src/git-workspace-sync.ts @@ -23,6 +23,15 @@ export interface ExpensiveWorkspaceGitInput { operation: string; timeout: number; maxBuffer: number; + /** + * Optional environment override for the invocation. Absent for the anchor + * workspace's own full-tree walks (they inherit the process environment, a + * directory this process already controls). A referenced-project scan sets + * this to its hardened environment (see {@link buildHardenedGitEnv}), so a + * host executor that honors it still runs the read hardened even though it + * dispatches through the same seam as the anchor's reads. + */ + env?: NodeJS.ProcessEnv; } export type ExpensiveWorkspaceGitExecutor = ( @@ -69,6 +78,7 @@ export async function runLocalGit( options: { timeout?: number; maxBuffer?: number; + env?: NodeJS.ProcessEnv; } = {}, ): Promise { return await new Promise((resolve, reject) => { @@ -78,6 +88,7 @@ export async function runLocalGit( { timeout: options.timeout ?? 15_000, maxBuffer: options.maxBuffer ?? 1024 * 128, + env: options.env ?? process.env, }, (error, stdout, stderr) => { if (error) { @@ -97,7 +108,7 @@ async function runExpensiveWorkspaceGit( localDir: string, args: string[], operation: string, - options: { timeout: number; maxBuffer: number }, + options: { timeout: number; maxBuffer: number; env?: NodeJS.ProcessEnv }, ): Promise { if (expensiveWorkspaceGitExecutor) { return await expensiveWorkspaceGitExecutor({ @@ -106,6 +117,7 @@ async function runExpensiveWorkspaceGit( operation, timeout: options.timeout, maxBuffer: options.maxBuffer, + env: options.env, }); } return await runLocalGit(localDir, args, options); @@ -168,6 +180,143 @@ export async function readGitWorkspaceSnapshot(localDir: string): Promise { + return await runExpensiveWorkspaceGit( + localDir, + ["-c", "core.fsmonitor=false", "--no-optional-locks", ...args], + operation, + { timeout: options.timeout, maxBuffer: options.maxBuffer, env: buildHardenedGitEnv() }, + ); +} + +/** + * True when a failed `git` invocation failed specifically because `localDir` + * is not inside a Git work tree — Git's own "not a git repository" fatal + * error. Distinguishes the expected non-Git case from a real failure (a + * timeout, a permissions error, a corrupt repository), which must still + * surface as a failure and never look like "no Git tree here". + */ +function isNotAGitRepositoryError(error: unknown): boolean { + const stderr = error && typeof error === "object" && "stderr" in error ? String((error as { stderr: unknown }).stderr) : ""; + const message = error instanceof Error ? error.message : String(error); + return /not a git repository/i.test(stderr) || /not a git repository/i.test(message); +} + +/** + * Read the Git-ignored paths of a referenced-project host directory, for the + * staging path to exclude them (see `resolveReferencedSourceIgnore` in + * `sandbox-managed-runtime.ts`). Every command runs through + * {@link runHardenedReadOnlyGit}, because the directory is a host checkout the + * staging code does not control, unlike the anchor workspace. + * + * Returns `null` when `localDir` is not a Git work tree — the caller keeps + * today's fixed excludes for that case. Throws on any other Git error, a + * timeout, or malformed output, so the caller can fail closed and skip + * staging that one project instead of shipping it unfiltered. + */ +export async function readReferencedSourceGitIgnoredPaths( + localDir: string, +): Promise { + let toplevel: string; + try { + const toplevelResult = await runHardenedReadOnlyGit( + localDir, + ["rev-parse", "--show-toplevel"], + "referenced_source.toplevel", + { timeout: 15_000, maxBuffer: 64 * 1024 }, + ); + toplevel = toplevelResult.stdout.trim(); + } catch (error) { + if (isNotAGitRepositoryError(error)) { + return null; + } + throw error; + } + if (!toplevel) { + throw new Error(`git rev-parse --show-toplevel returned an empty path for ${localDir}`); + } + + const ignoredResult = await runHardenedReadOnlyGit( + localDir, + ["status", "--ignored", "--porcelain=v1", "-z", "--untracked-files=normal"], + "referenced_source.ignored_files", + { timeout: 60_000, maxBuffer: 16 * 1024 * 1024 }, + ); + // Do not trim each entry: `git status -z` already delimits entries with a + // NUL byte, so a leading or trailing space in an entry is part of the path + // itself, not padding to remove. A length check finds the one genuinely + // empty entry `-z` appends after the last NUL, without eating a real path's + // own leading or trailing whitespace. + const ignoredPaths = ignoredResult.stdout + .split("\0") + .filter((entry) => entry.length > 0) + .filter((entry) => entry.startsWith("!! ")) + .map((entry) => entry.slice(3).replace(/\/+$/, "")) + .filter((entry) => entry.length > 0) + .sort((left, right) => left.localeCompare(right)); + + return { toplevel, ignoredPaths }; +} + // scp-like ssh remote (`user@host:path`). The syntax has no password slot, so // it cannot embed a secret. Conservative shape: exactly one `@`, no colon in // the user segment (a colon there could smuggle credential-looking material), diff --git a/packages/adapter-utils/src/remote-managed-runtime.test.ts b/packages/adapter-utils/src/remote-managed-runtime.test.ts index 330b1cfcd63..35f41348303 100644 --- a/packages/adapter-utils/src/remote-managed-runtime.test.ts +++ b/packages/adapter-utils/src/remote-managed-runtime.test.ts @@ -125,9 +125,9 @@ describe("remote managed runtime", () => { workspaceRemoteDir: "/app", syncWorkspace: false, additionalSources: [ - { localPath: firstDir, projectId: "first" }, - { localPath: brokenDir, projectId: "broken" }, - { localPath: secondDir, projectId: "second" }, + { localPath: firstDir, projectId: "first", ignoreResolution: { kind: "other" } }, + { localPath: brokenDir, projectId: "broken", ignoreResolution: { kind: "other" } }, + { localPath: secondDir, projectId: "second", ignoreResolution: { kind: "other" } }, ], }); @@ -171,8 +171,8 @@ describe("remote managed runtime", () => { workspaceRemoteDir: "/app", syncWorkspace: false, additionalSources: [ - { localPath: "relative/referenced", projectId: "relative" }, - { localPath: healthyDir, projectId: "healthy" }, + { localPath: "relative/referenced", projectId: "relative", ignoreResolution: { kind: "other" } }, + { localPath: healthyDir, projectId: "healthy", ignoreResolution: { kind: "other" } }, ], }); @@ -183,4 +183,83 @@ describe("remote managed runtime", () => { localDir: "relative/referenced", })); }); + + it("passes a project's resolved Git-ignored paths to the SSH exclude list, escaped", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-remote-runtime-ignore-")); + cleanupDirs.push(rootDir); + const workspaceDir = path.join(rootDir, "workspace"); + const projectDir = path.join(rootDir, "referenced-project"); + await mkdir(workspaceDir, { recursive: true }); + + await prepareRemoteManagedRuntime({ + spec: { + host: "127.0.0.1", + port: 2222, + username: "fixture", + remoteWorkspacePath: "/app", + remoteCwd: "/app", + privateKey: "PRIVATE KEY", + knownHosts: "KNOWN HOSTS", + strictHostKeyChecking: true, + }, + runId: "run-ignore", + adapterKey: "codex", + workspaceLocalDir: workspaceDir, + workspaceRemoteDir: "/app", + syncWorkspace: false, + additionalSources: [ + { + localPath: projectDir, + projectId: "proj", + ignoreResolution: { kind: "git", ignoredPaths: ["secret.env", "build", "weird[1].txt"] }, + }, + ], + }); + + const call = syncDirectoryToSsh.mock.calls.find((entry) => entry[0].localDir === projectDir); + expect(call).toBeDefined(); + const exclude = (call![0] as { exclude?: string[] }).exclude ?? []; + // The resolved ignored paths ride the exclude list, glob-escaped, on top of + // the fixed heavy-directory excludes the SSH lane already applies. + expect(exclude).toContain("secret.env"); + expect(exclude).toContain("build"); + expect(exclude).toContain("weird\\[1].txt"); + expect(exclude).toContain("node_modules"); + }); + + it("skips a project whose ignore resolution failed without ever calling syncDirectoryToSsh for it", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-remote-runtime-failed-")); + cleanupDirs.push(rootDir); + const workspaceDir = path.join(rootDir, "workspace"); + const healthyDir = path.join(rootDir, "referenced-healthy"); + const failedDir = path.join(rootDir, "referenced-failed"); + await mkdir(workspaceDir, { recursive: true }); + + const prepared = await prepareRemoteManagedRuntime({ + spec: { + host: "127.0.0.1", + port: 2222, + username: "fixture", + remoteWorkspacePath: "/app", + remoteCwd: "/app", + privateKey: "PRIVATE KEY", + knownHosts: "KNOWN HOSTS", + strictHostKeyChecking: true, + }, + runId: "run-failed", + adapterKey: "codex", + workspaceLocalDir: workspaceDir, + workspaceRemoteDir: "/app", + syncWorkspace: false, + additionalSources: [ + { localPath: healthyDir, projectId: "healthy", ignoreResolution: { kind: "other" } }, + { localPath: failedDir, projectId: "failed", ignoreResolution: { kind: "failed", reason: "git status timed out" } }, + ], + }); + + // Fail closed: the failed project never reaches the transfer at all — no + // bytes are sent for it — while the healthy project still stages. + expect(Object.keys(prepared.additionalSourceDirs)).toEqual(["healthy"]); + expect(syncDirectoryToSsh).not.toHaveBeenCalledWith(expect.objectContaining({ localDir: failedDir })); + }); }); diff --git a/packages/adapter-utils/src/remote-managed-runtime.ts b/packages/adapter-utils/src/remote-managed-runtime.ts index 0902e17a9cc..ff394dc7fb3 100644 --- a/packages/adapter-utils/src/remote-managed-runtime.ts +++ b/packages/adapter-utils/src/remote-managed-runtime.ts @@ -7,13 +7,19 @@ import { restoreWorkspaceFromSshExecution, syncDirectoryToSsh, } from "./ssh.js"; -import type { - SandboxAdditionalSource, - SandboxManagedRuntimeAssetRestoreContext, +import { + mergeExcludes, + referencedSourceIgnoreExcludeEntries, + type SandboxAdditionalSource, + type SandboxManagedRuntimeAssetRestoreContext, } from "./sandbox-managed-runtime.js"; import { captureDirectorySnapshot } from "./workspace-restore-merge.js"; import type { RuntimeProgressSink } from "./runtime-progress.js"; +// The fixed heavy-directory excludes every referenced project drops, +// regardless of its ignore resolution. A `git`-resolved project additionally +// drops its own resolved ignored paths (see `referencedSourceIgnoreExcludeEntries` +// and the per-project merge below); an `other` project keeps only this set. const REMOTE_ADDITIONAL_SOURCE_HEAVY_DIR_EXCLUDES = [ "node_modules", "vendor", @@ -179,7 +185,7 @@ export async function prepareRemoteManagedRuntime(input: { // the other projects continue (no workspace restore, unlike an asset failure). const additionalSourceDirs: Record = {}; for (const source of input.additionalSources ?? []) { - const { localPath, projectId } = source; + const { localPath, projectId, ignoreResolution } = source; try { if (!path.posix.isAbsolute(localPath)) { throw new Error(`additional source localPath is not an absolute path: ${localPath}`); @@ -192,12 +198,21 @@ export async function prepareRemoteManagedRuntime(input: { ) { throw new Error(`additional source projectId is not a simple path segment: ${projectId}`); } + // Fail closed: a project whose ignore resolution failed is not staged at + // all — the existing per-project skip-and-warn path below handles it. + if (ignoreResolution.kind === "failed") { + throw new Error(`referenced project ignore resolution failed: ${ignoreResolution.reason}`); + } const remoteDir = path.posix.join(runtimeRootDir, `project-${projectId}`); + const exclude = mergeExcludes( + REMOTE_ADDITIONAL_SOURCE_HEAVY_DIR_EXCLUDES, + referencedSourceIgnoreExcludeEntries(ignoreResolution), + ); await syncDirectoryToSsh({ spec: input.spec, localDir: localPath, remoteDir, - exclude: REMOTE_ADDITIONAL_SOURCE_HEAVY_DIR_EXCLUDES, + exclude, onProgress: input.onProgress, progressLabel: `project-${projectId}`, }); diff --git a/packages/adapter-utils/src/sandbox-file-sync.test.ts b/packages/adapter-utils/src/sandbox-file-sync.test.ts index aeb642a645f..7deadcda4cb 100644 --- a/packages/adapter-utils/src/sandbox-file-sync.test.ts +++ b/packages/adapter-utils/src/sandbox-file-sync.test.ts @@ -257,7 +257,11 @@ describe("sandbox native file sync", () => { adapterKey: "test-adapter", client, workspaceLocalDir: localWorkspaceDir, - additionalSources: projects.map((project) => ({ localPath: project.localDir, projectId: project.projectId })), + additionalSources: projects.map((project) => ({ + localPath: project.localDir, + projectId: project.projectId, + ignoreResolution: { kind: "other" }, + })), }); // Each project lands in its OWN `project-` directory under the @@ -320,9 +324,9 @@ describe("sandbox native file sync", () => { client, workspaceLocalDir: localWorkspaceDir, additionalSources: [ - { localPath: goodDir, projectId: "good-a" }, - { localPath: path.join(rootDir, "does-not-exist"), projectId: "broken" }, - { localPath: goodDir, projectId: "good-b" }, + { localPath: goodDir, projectId: "good-a", ignoreResolution: { kind: "other" } }, + { localPath: path.join(rootDir, "does-not-exist"), projectId: "broken", ignoreResolution: { kind: "other" } }, + { localPath: goodDir, projectId: "good-b", ignoreResolution: { kind: "other" } }, ], }); diff --git a/packages/adapter-utils/src/sandbox-managed-runtime.test.ts b/packages/adapter-utils/src/sandbox-managed-runtime.test.ts index 8e60c71fb05..47d744e42f7 100644 --- a/packages/adapter-utils/src/sandbox-managed-runtime.test.ts +++ b/packages/adapter-utils/src/sandbox-managed-runtime.test.ts @@ -9,8 +9,12 @@ import { resetLocalGitIndexToHead } from "./git-workspace-sync.js"; import { assertSyncOperationsConfined, + escapeTarExcludeLiteral, mirrorDirectory, prepareSandboxManagedRuntime, + resolveReferencedSourceIgnore, + type PreparedSandboxManagedRuntime, + type ReferencedSourceIgnoreResolution, type SandboxManagedRuntimeAsset, type SandboxManagedRuntimeClient, type SandboxSyncOperation, @@ -160,6 +164,64 @@ async function git(cwd: string, args: string[]): Promise { return stdout.trim(); } +// A minimal committed Git repository, for the referenced-project ignore tests. +async function initGitRepo(repoDir: string): Promise { + await mkdir(repoDir, { recursive: true }); + await git(repoDir, ["init", "-q"]); + await git(repoDir, ["config", "user.name", "Paperclip Test"]); + await git(repoDir, ["config", "user.email", "test@paperclip.dev"]); + await writeFile(path.join(repoDir, "README.md"), "root\n", "utf8"); + await git(repoDir, ["add", "README.md"]); + await git(repoDir, ["commit", "-qm", "base"]); +} + +// A `CommandManagedRuntimeRunner` that runs real shell commands on the host +// filesystem (host FS stands in for the sandbox FS), exposing no native +// `syncIn` — staging rides the real base64/tar fallback, the same transport a +// provider without native sync uses. Unlike the in-file fake clients, this +// fallback DOES build a real tarball via `createTarballFromDirectory` for a +// `directory`-kind mapping, so it is the one that genuinely applies `exclude`. +function makeInlineSpawnRunner(): CommandManagedRuntimeRunner { + return { + execute: (input) => + new Promise((resolve) => { + const startedAt = new Date().toISOString(); + const command = + input.command === "sh" ? "/bin/sh" : input.command === "bash" ? "/bin/bash" : input.command; + const child = spawn(command, input.args ?? [], { cwd: input.cwd, env: { ...process.env, ...input.env } }); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk) => { stdout += chunk.toString("utf8"); }); + child.stderr.on("data", (chunk) => { stderr += chunk.toString("utf8"); }); + child.on("error", () => resolve({ exitCode: 127, signal: null, timedOut: false, stdout, stderr, pid: null, startedAt })); + child.on("close", (code) => resolve({ exitCode: code ?? 0, signal: null, timedOut: false, stdout, stderr, pid: child.pid ?? null, startedAt })); + if (input.stdin != null) child.stdin.write(input.stdin); + child.stdin.end(); + }), + }; +} + +// Stage ONE referenced project end-to-end (real tar fallback, real `exclude` +// filtering) through the full `prepareCommandManagedRuntime` seam, for the +// referenced-project ignore regression tests. +async function stageOneReferencedProject( + referencedDir: string, + ignoreResolution: ReferencedSourceIgnoreResolution, +): Promise { + const rootDir = path.dirname(referencedDir); + const localWorkspaceDir = path.join(rootDir, "local-workspace"); + const remoteWorkspaceDir = path.join(rootDir, "remote-workspace"); + await mkdir(localWorkspaceDir, { recursive: true }); + await writeFile(path.join(localWorkspaceDir, "README.md"), "anchor\n", "utf8"); + return await prepareCommandManagedRuntime({ + runner: makeInlineSpawnRunner(), + spec: { remoteCwd: remoteWorkspaceDir, timeoutMs: 30_000 }, + adapterKey: "test-adapter", + workspaceLocalDir: localWorkspaceDir, + additionalSources: [{ localPath: referencedDir, projectId: "proj", ignoreResolution }], + }); +} + async function listTarMembers(rootDir: string, name: string, bytes: Buffer): Promise { const tarPath = path.join(rootDir, name); await writeFile(tarPath, bytes); @@ -2087,7 +2149,7 @@ describe("sandbox managed runtime", () => { adapterKey: "test-adapter", client, workspaceLocalDir: localWorkspaceDir, - additionalSources: [{ localPath: referencedDir, projectId: "proj-first" }], + additionalSources: [{ localPath: referencedDir, projectId: "proj-first", ignoreResolution: { kind: "other" } }], }); const referencedMapping = captured @@ -2169,9 +2231,9 @@ describe("sandbox managed runtime", () => { adapterKey: "test-adapter", workspaceLocalDir: localWorkspaceDir, additionalSources: [ - { localPath: first, projectId: "proj-first" }, - { localPath: path.join(rootDir, "referenced-missing"), projectId: "proj-missing" }, - { localPath: second, projectId: "proj-second" }, + { localPath: first, projectId: "proj-first", ignoreResolution: { kind: "other" } }, + { localPath: path.join(rootDir, "referenced-missing"), projectId: "proj-missing", ignoreResolution: { kind: "other" } }, + { localPath: second, projectId: "proj-second", ignoreResolution: { kind: "other" } }, ], }); @@ -2213,6 +2275,150 @@ describe("sandbox managed runtime", () => { } }); + describe("resolveReferencedSourceIgnore", () => { + it("re-relativizes root-relative ignored paths to a nested localPath", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-ignore-nested-")); + cleanupDirs.push(rootDir); + const repo = path.join(rootDir, "repo"); + await initGitRepo(repo); + // An ignored entry OUTSIDE the referenced project's localPath, and two + // ignored entries inside it (one top-level, one nested). + await writeFile(path.join(repo, ".gitignore"), "outside-secret.env\npackages/app/secret.env\npackages/app/build/\n", "utf8"); + await writeFile(path.join(repo, "outside-secret.env"), "outside\n", "utf8"); + const localPath = path.join(repo, "packages", "app"); + // Commit a tracked file under `localPath` first. Otherwise the whole + // `packages/` directory is untracked, and `git status` collapses it to + // one `packages/` line instead of reporting entries inside it + // individually — the fixture needs the individual entries. + await mkdir(localPath, { recursive: true }); + await writeFile(path.join(localPath, "index.ts"), "export {};\n", "utf8"); + await git(repo, ["add", "packages/app/index.ts"]); + await git(repo, ["commit", "-qm", "add app"]); + await mkdir(path.join(localPath, "build"), { recursive: true }); + await writeFile(path.join(localPath, "secret.env"), "TOKEN=abc\n", "utf8"); + await writeFile(path.join(localPath, "build", "out.js"), "artifact\n", "utf8"); + + const resolution = await resolveReferencedSourceIgnore(localPath); + + // Only the entries under `localPath` apply, re-relativized to it — the + // sibling `outside-secret.env` never appears, and the prefix + // `packages/app/` is stripped. + expect(resolution).toEqual({ kind: "git", ignoredPaths: ["build", "secret.env"] }); + }); + + it("keeps today's fixed excludes for a non-Git source", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-ignore-nongit-")); + cleanupDirs.push(rootDir); + const plainDir = path.join(rootDir, "plain-project"); + await mkdir(plainDir, { recursive: true }); + await writeFile(path.join(plainDir, "file.txt"), "body\n", "utf8"); + + await expect(resolveReferencedSourceIgnore(plainDir)).resolves.toEqual({ kind: "other" }); + }); + + it("fails closed on a real Git error instead of returning an unfiltered result", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-ignore-fail-")); + cleanupDirs.push(rootDir); + const repo = path.join(rootDir, "repo"); + await initGitRepo(repo); + // Corrupt the index so `git rev-parse --show-toplevel` still succeeds but + // `git status --ignored` fails with a real error (not "not a git repository"). + await writeFile(path.join(repo, ".git", "index"), "not a valid index\n", "utf8"); + + const resolution = await resolveReferencedSourceIgnore(repo); + + expect(resolution.kind).toBe("failed"); + expect((resolution as { reason: string }).reason.length).toBeGreaterThan(0); + }); + }); + + it("escapes glob metacharacters so a literal ignored path never over-excludes a sibling", async () => { + // A bare tar `--exclude` pattern treats `*`/`?`/`[` as globs. Without + // escaping, an ignored file named `secret[1].txt` would exclude the + // unrelated sibling `secret1.txt` too (both match the glob `secret[1].txt`, + // whose `[1]` is a one-character class matching the literal digit `1`). + expect(escapeTarExcludeLiteral("secret[1].txt")).toBe("secret\\[1].txt"); + expect(escapeTarExcludeLiteral("wildcard*name")).toBe("wildcard\\*name"); + expect(escapeTarExcludeLiteral("question?mark")).toBe("question\\?mark"); + + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-ignore-glob-")); + cleanupDirs.push(rootDir); + const referencedDir = path.join(rootDir, "referenced-project"); + await initGitRepo(referencedDir); + // The gitignore pattern itself escapes `[` and `]` (gitignore patterns are + // globs too), so it ignores ONLY the literal file `secret[1].txt`. + await writeFile(path.join(referencedDir, ".gitignore"), "secret\\[1\\].txt\n", "utf8"); + await writeFile(path.join(referencedDir, "secret[1].txt"), "ignored\n", "utf8"); + // A sibling that would ALSO match the UNESCAPED tar exclude glob + // `secret[1].txt` (its `[1]` is a one-character class matching `1`), if the + // staging path failed to escape the ignored entry before passing it to tar. + await writeFile(path.join(referencedDir, "secret1.txt"), "must stay\n", "utf8"); + + const ignoreResolution = await resolveReferencedSourceIgnore(referencedDir); + expect(ignoreResolution).toEqual({ kind: "git", ignoredPaths: ["secret[1].txt"] }); + + const prepared = await stageOneReferencedProject(referencedDir, ignoreResolution); + + const stagedDir = prepared.additionalSourceDirs["proj"]!; + await expect(readFile(path.join(stagedDir, "secret[1].txt"), "utf8")).rejects.toMatchObject({ code: "ENOENT" }); + await expect(readFile(path.join(stagedDir, "secret1.txt"), "utf8")).resolves.toBe("must stay\n"); + }); + + it("never ships a Git-ignored secret in a referenced project's staged tree", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-ignore-secret-")); + cleanupDirs.push(rootDir); + const referencedDir = path.join(rootDir, "referenced-project"); + await initGitRepo(referencedDir); + await writeFile(path.join(referencedDir, ".gitignore"), "secret.env\n", "utf8"); + await writeFile(path.join(referencedDir, "secret.env"), "TOKEN=abc\n", "utf8"); + await writeFile(path.join(referencedDir, "tracked.md"), "kept\n", "utf8"); + + const ignoreResolution = await resolveReferencedSourceIgnore(referencedDir); + expect(ignoreResolution).toEqual({ kind: "git", ignoredPaths: ["secret.env"] }); + + const prepared = await stageOneReferencedProject(referencedDir, ignoreResolution); + + const stagedDir = prepared.additionalSourceDirs["proj"]!; + await expect(readFile(path.join(stagedDir, "secret.env"), "utf8")).rejects.toMatchObject({ code: "ENOENT" }); + await expect(readFile(path.join(stagedDir, "tracked.md"), "utf8")).resolves.toBe("kept\n"); + }); + + it("stages no bytes for a project whose ignore resolution failed, and stages the rest", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-ignore-failed-skip-")); + cleanupDirs.push(rootDir); + const localWorkspaceDir = path.join(rootDir, "local-workspace"); + const remoteWorkspaceDir = path.join(rootDir, "remote-workspace"); + const healthyDir = path.join(rootDir, "referenced-healthy"); + const failedDir = path.join(rootDir, "referenced-failed"); + await mkdir(localWorkspaceDir, { recursive: true }); + await mkdir(healthyDir, { recursive: true }); + await mkdir(failedDir, { recursive: true }); + await writeFile(path.join(localWorkspaceDir, "README.md"), "anchor\n", "utf8"); + await writeFile(path.join(healthyDir, "notes.md"), "healthy\n", "utf8"); + await writeFile(path.join(failedDir, "should-never-ship.txt"), "must not stage\n", "utf8"); + + const prepared = await prepareCommandManagedRuntime({ + runner: makeInlineSpawnRunner(), + spec: { remoteCwd: remoteWorkspaceDir, timeoutMs: 30_000 }, + adapterKey: "test-adapter", + workspaceLocalDir: localWorkspaceDir, + additionalSources: [ + { localPath: healthyDir, projectId: "healthy", ignoreResolution: { kind: "other" } }, + { localPath: failedDir, projectId: "failed", ignoreResolution: { kind: "failed", reason: "boom: git status timed out" } }, + ], + }); + + // The failed project is not staged at all (fail closed) — no bytes reach the + // sandbox for it — and is recorded as a first-class failure. The healthy + // project stages normally. + expect(Object.keys(prepared.additionalSourceDirs)).toEqual(["healthy"]); + expect(prepared.additionalSourceFailures.map((failure) => failure.projectId)).toEqual(["failed"]); + expect(prepared.additionalSourceFailures[0]!.error).toContain("boom: git status timed out"); + const runtimeRootDir = path.posix.join(remoteWorkspaceDir, ".paperclip-runtime", "test-adapter"); + await expect(readFile(path.join(runtimeRootDir, "project-failed", "should-never-ship.txt"), "utf8")).rejects + .toMatchObject({ code: "ENOENT" }); + }); + it("builds the workspace tarball inside one host pack span for a usual workspace sync", async () => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-pack-span-")); cleanupDirs.push(rootDir); @@ -2655,8 +2861,8 @@ describe("sandbox managed runtime inbound coordinator", () => { syncWorkspace: false, workspaceLocalDir: workspaceDir, additionalSources: [ - { localPath: dirOf("good"), projectId: "good" }, - { localPath: dirOf("bad"), projectId: "bad" }, + { localPath: dirOf("good"), projectId: "good", ignoreResolution: { kind: "other" } }, + { localPath: dirOf("bad"), projectId: "bad", ignoreResolution: { kind: "other" } }, ], }); @@ -2748,7 +2954,7 @@ describe("sandbox managed runtime inbound coordinator", () => { client, workspaceLocalDir: workspaceDir, assets: [{ key: "home", localDir: dirOf("home") }], - additionalSources: [{ localPath: dirOf("proj"), projectId: "proj-1" }], + additionalSources: [{ localPath: dirOf("proj"), projectId: "proj-1", ignoreResolution: { kind: "other" } }], runtimeSpan, }); diff --git a/packages/adapter-utils/src/sandbox-managed-runtime.ts b/packages/adapter-utils/src/sandbox-managed-runtime.ts index 26196a474f0..bfa21b7065e 100644 --- a/packages/adapter-utils/src/sandbox-managed-runtime.ts +++ b/packages/adapter-utils/src/sandbox-managed-runtime.ts @@ -14,6 +14,7 @@ import { GIT_ARCHIVE_EXCLUDES, integrateImportedGitHead, readGitWorkspaceSnapshot, + readReferencedSourceGitIgnoredPaths, resetLocalGitIndexToHead, withShallowGitWorkspaceClone, } from "./git-workspace-sync.js"; @@ -135,6 +136,27 @@ export interface SandboxManagedRuntimeAsset { restore?: (ctx: SandboxManagedRuntimeAssetRestoreContext) => Promise; } +/** + * How a referenced project's Git-ignored paths were resolved, computed once + * per project by `resolveReferencedSourceIgnore` before staging starts. The + * sandbox lane, the SSH lane, and the content-signature walk each consume + * this ONE resolution, so the three sites never drift apart. + * + * - `git`: `localPath` is a Git work tree. `ignoredPaths` are its ignored + * entries, already re-relativized to `localPath` (see + * `resolveReferencedSourceIgnore`). + * - `other`: `localPath` is not a Git work tree. The staging path keeps + * today's fixed heavy-directory excludes. + * - `failed`: the Git read failed, timed out, or returned output the + * resolver could not parse or safely re-relativize. The project is NOT + * staged (fail closed) — every site records it as a per-project failure + * instead of shipping it unfiltered. + */ +export type ReferencedSourceIgnoreResolution = + | { kind: "git"; ignoredPaths: string[] } + | { kind: "other" } + | { kind: "failed"; reason: string }; + /** * A referenced (additional) project to stage into the run sandbox as a plain, * read-only tree. `localPath` is the host checkout directory. Upstream code @@ -145,10 +167,113 @@ export interface SandboxManagedRuntimeAsset { * Additional sources are plain trees only. They never carry the anchor * workspace's git-history, overlay, or `.paperclip-runtime` preservation * semantics — those stay anchor-only. + * + * `ignoreResolution` is required so every construction site must supply it + * explicitly — a caller cannot default to the unfiltered legacy behavior by + * omission. Resolve it once per project with `resolveReferencedSourceIgnore`. */ export interface SandboxAdditionalSource { localPath: string; projectId: string; + ignoreResolution: ReferencedSourceIgnoreResolution; +} + +/** + * Escape tar `--exclude` glob metacharacters (`*`, `?`, `[`) in a literal + * path, so a Git-ignored path that happens to contain one of them is matched + * literally instead of as a pattern. Without this, a repository-controlled + * path containing e.g. `*` could exclude unrelated sibling files that + * happen to match the resulting glob. GNU tar and bsdtar both honor a + * backslash as a `fnmatch` escape character, so this is not command + * injection — `createTarballFromDirectory` and the SSH tar equivalent both + * pass `--exclude` values as argument-vector entries, never through a shell. + */ +export function escapeTarExcludeLiteral(entry: string): string { + return entry.replace(/\\/g, "\\\\").replace(/([*?[])/g, "\\$1"); +} + +/** + * The tar `--exclude` entries a referenced project's resolved ignore set + * contributes, on top of the fixed heavy-directory excludes every site + * already applies. Empty for `other` (today's fixed excludes are enough) + * and for `failed` (the project is not staged at all, so no exclude list + * matters). + */ +export function referencedSourceIgnoreExcludeEntries(resolution: ReferencedSourceIgnoreResolution): string[] { + return resolution.kind === "git" ? resolution.ignoredPaths.map(escapeTarExcludeLiteral) : []; +} + +/** + * Compute the relative position of `localPath` under a Git `toplevel` + * directory, as a POSIX path with no leading or trailing slash. Returns `""` + * when `localPath` IS the toplevel. Returns `null` when the relation is not a + * plain descendant — `localPath` escapes upward from `toplevel`, resolves to + * an absolute/rooted result (a different filesystem root), or the two paths + * are otherwise not comparable. The caller treats `null` as a resolution + * failure (fail closed), never as "nothing to exclude". + */ +function relativizeUnderGitToplevel(input: { toplevel: string; localPath: string }): string | null { + const toplevel = path.resolve(input.toplevel); + const localPath = path.resolve(input.localPath); + const relative = path.relative(toplevel, localPath); + if (relative === "") return ""; + if (path.isAbsolute(relative)) return null; + if (relative === ".." || relative.startsWith(`..${path.sep}`)) return null; + return relative.split(path.sep).join("/"); +} + +/** + * Re-relativize root-relative ignored paths (as `git status --ignored` + * reports them, from the repository toplevel) to `offset`, the position of + * the referenced project's `localPath` under that toplevel. Keeps only the + * entries that are `offset` itself or a descendant of it — an ignored path + * elsewhere in the repository does not apply to this project's staged tree — + * and strips the `offset` prefix so the result matches the tar member + * namespace, which is `localPath`-relative. + */ +function reRelativizeIgnoredPathsToLocalPath(input: { ignoredPaths: string[]; offset: string }): string[] { + if (input.offset === "") { + return [...input.ignoredPaths]; + } + const prefix = `${input.offset}/`; + return input.ignoredPaths + .filter((entry) => entry.startsWith(prefix)) + .map((entry) => entry.slice(prefix.length)) + .filter(Boolean); +} + +/** + * Resolve a referenced project's Git-ignored paths ONCE, before any staging + * site runs. Called once per project (see `execute.ts`); the sandbox lane, + * the SSH lane, and the content-signature walk all consume this one result, + * so they can never apply a different exclusion set to the same project. + * + * Fails closed: a Git read error, a timeout, malformed output, or a `localPath` + * that is not a plain descendant of its own Git toplevel all return `failed`, + * never an empty ignore list — an empty list means "resolved, nothing extra to + * exclude", which is a different claim than "the resolution did not run". + */ +export async function resolveReferencedSourceIgnore(localPath: string): Promise { + let scan: Awaited>; + try { + scan = await readReferencedSourceGitIgnoredPaths(localPath); + } catch (error) { + return { kind: "failed", reason: error instanceof Error ? error.message : String(error) }; + } + if (!scan) { + return { kind: "other" }; + } + const offset = relativizeUnderGitToplevel({ toplevel: scan.toplevel, localPath }); + if (offset === null) { + return { + kind: "failed", + reason: `referenced project path is not a descendant of its own Git top level: ${localPath} under ${scan.toplevel}`, + }; + } + return { + kind: "git", + ignoredPaths: reRelativizeIgnoredPathsToLocalPath({ ignoredPaths: scan.ignoredPaths, offset }), + }; } /** @@ -681,7 +806,7 @@ async function emitRuntimeStatus( await Promise.resolve(sink({ phase, message })).catch(() => undefined); } -function mergeExcludes(...groups: Array): string[] { +export function mergeExcludes(...groups: Array): string[] { return [...new Set(groups.flatMap((group) => group ?? []))]; } @@ -850,8 +975,11 @@ export async function prepareSandboxManagedRuntime(input: { const additionalSourceFailures: AdditionalSourceStagingFailure[] = []; // Additional projects stage as plain trees. Drop the heavy build/cache dirs a // reference tree does not need, and `.git` — additional sources never carry - // git-history semantics (anchor-only). - const additionalSourceExclude = mergeExcludes(SANDBOX_WORKSPACE_HEAVY_DIR_EXCLUDES, [".git"]); + // git-history semantics (anchor-only). Each project also drops its OWN + // resolved Git-ignored paths (or keeps this fixed set as-is for a non-Git + // source) — see `resolveReferencedSourceIgnore` and the per-project merge + // below. + const additionalSourceBaseExclude = mergeExcludes(SANDBOX_WORKSPACE_HEAVY_DIR_EXCLUDES, [".git"]); // Every delegated post-upload command (extract/wipe/remove-deleted/asset merge) // must run under the run-specific timeout (`spec.timeoutMs`), not the provider @@ -1122,7 +1250,7 @@ export async function prepareSandboxManagedRuntime(input: { inboundTaskIsRequired.push(false); inboundTasks.push(() => runStepSpan(`stage.project.${source.projectId}`, async () => { - const { localPath, projectId } = source; + const { localPath, projectId, ignoreResolution } = source; const label = `project-${projectId}`; try { if (!path.posix.isAbsolute(localPath)) { @@ -1136,14 +1264,24 @@ export async function prepareSandboxManagedRuntime(input: { ) { throw new Error(`additional source projectId is not a simple path segment: ${projectId}`); } + // Fail closed: a project whose ignore resolution failed is not staged + // at all. Shipping it with only the fixed heavy-directory excludes + // would defeat the resolution's purpose. + if (ignoreResolution.kind === "failed") { + throw new Error(`referenced project ignore resolution failed: ${ignoreResolution.reason}`); + } const remoteProjectDir = path.posix.join(runtimeRootDir, label); + const exclude = mergeExcludes( + additionalSourceBaseExclude, + referencedSourceIgnoreExcludeEntries(ignoreResolution), + ); await emitRuntimeStatus(input.onRuntimeProgress, "config_sync", "Syncing referenced project to environment"); await stageConfinedSyncIn({ files: [{ sourcePath: localPath, targetPath: remoteProjectDir, kind: "directory", - exclude: additionalSourceExclude, + exclude, access: "ro", }], sourceRoots: [localPath], diff --git a/server/src/services/workspace-git-operation-scheduler.ts b/server/src/services/workspace-git-operation-scheduler.ts index 5819094ec57..bed4c39bcf9 100644 --- a/server/src/services/workspace-git-operation-scheduler.ts +++ b/server/src/services/workspace-git-operation-scheduler.ts @@ -882,6 +882,11 @@ setExpensiveWorkspaceGitExecutor(async (input) => { timeoutMs: input.timeout, maxStdoutBytes: input.maxBuffer, maxStderrBytes: input.maxBuffer, + // Absent for the anchor workspace's own reads (they inherit the process + // environment, a directory this process already controls). A + // referenced-project scan sets this to its hardened environment, so the + // hardening survives the hop through this shared scheduler. + env: input.env, }); return { stdout: result.stdout, stderr: result.stderr }; }); From d866ff374e816730b60960d021fa98b20e80e5d3 Mon Sep 17 00:00:00 2001 From: Nicky Leach Date: Tue, 25 Aug 2026 14:24:13 -0700 Subject: [PATCH 15/32] fix(adapter-utils): report real transferred bytes for project sync, git-history export, and workspace restore (#12180) ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Agent adapters move files between the host and sandbox during a run > - The sync transport reports transferred bytes, but some progress lines discard this value > - Discarded byte totals make large transfers display as `0.0 MB` > - This pull request passes the transport total to the affected progress lines > - The benefit is accurate transfer progress without changing file movement or confinement checks ## Linked Issues or Issue Description **What happened?** Three file-sync progress lines displayed `0.0 MB` when the transport moved data. The affected paths cover referenced-project staging, native git-history export, and native workspace restore. **Expected behavior** Each progress line should display the bytes that the sync transport transfers. A provider that reports zero bytes should preserve the known host-side value for inbound workspace sync. **Steps to reproduce** 1. Run a sandbox task that stages a referenced project. 2. Run a task that uses native git-history export or native workspace restore. 3. Inspect the file-sync progress lines during each transfer. **Paperclip version or commit** Commit `8062612baa20036a1defce8bbd683c038ba187d5`. **Deployment mode** Built from source with the adapter-utils Vitest suite. ## What Changed - Add a helper that sums valid `bytesTransferred` values from a `SandboxSyncResult`. - Use the transport total for referenced-project staging, native git-history export, and native workspace restore. - Preserve the caller count when referenced-project staging reports zero bytes. - Add tests for non-zero progress and the zero-byte fallback. ## Verification - Run `npx vitest run packages/adapter-utils/src/sandbox-managed-runtime.test.ts` from the repository root. - Run the TypeScript check for `packages/adapter-utils`. - Confirm that the new tests cover referenced-project staging, native workspace restore, native git-history export, and the zero-byte fallback. ## Risks This change affects progress reporting only. It does not change transferred files, transfer order, provider behavior, or confinement checks. ## Model Used OpenAI Codex, GPT-5, with tool use and code execution. The model reviewed and routed the author-provided change. The implementing engineer authored the code. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes: #` / `Refs: #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I have addressed all Greptile and reviewer comments before requesting merge Co-authored-by: Paperclip --- .../src/sandbox-managed-runtime.test.ts | 194 +++++++++++++++++- .../src/sandbox-managed-runtime.ts | 41 +++- 2 files changed, 216 insertions(+), 19 deletions(-) diff --git a/packages/adapter-utils/src/sandbox-managed-runtime.test.ts b/packages/adapter-utils/src/sandbox-managed-runtime.test.ts index 47d744e42f7..9eb425002d7 100644 --- a/packages/adapter-utils/src/sandbox-managed-runtime.test.ts +++ b/packages/adapter-utils/src/sandbox-managed-runtime.test.ts @@ -1,5 +1,6 @@ +import { randomBytes } from "node:crypto"; import { promises as fsPromises } from "node:fs"; -import { lstat, mkdir, mkdtemp, readFile, readdir, rm, symlink, writeFile } from "node:fs/promises"; +import { lstat, mkdir, mkdtemp, readFile, readdir, rm, stat, symlink, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { execFile as execFileCallback, spawn } from "node:child_process"; @@ -40,6 +41,23 @@ function toArrayBuffer(bytes: Buffer): ArrayBuffer { return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer; } +// Sum the file sizes under a directory, recursively. Test-only: it stands in +// for a real provider's own byte count on a `kind: "directory"` mapping, so a +// fake `syncIn`/`syncOut` can report a real, non-zero `bytesTransferred`. +async function directoryByteSize(dir: string): Promise { + let total = 0; + const entries = await readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + const entryPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + total += await directoryByteSize(entryPath); + } else if (entry.isFile()) { + total += (await stat(entryPath)).size; + } + } + return total; +} + // Give a bare fake client a `syncIn` that reproduces the non-native base64-tar // FALLBACK (place each file mapping via `writeFile`, then run the operation's // ordered `postUploadCommands` fail-fast via `run`) — byte-for-byte the prior @@ -111,7 +129,9 @@ function attachNativeRecordingSyncIn( // A capturing `syncIn` that records every operation for assertion and // materializes file AND directory mappings. A directory mapping (a referenced // project) uses `mirrorDirectory`, so a test can assert the advisory `access` -// intent on directory mappings as well as file mappings. +// intent on directory mappings as well as file mappings. It reports a real +// `bytesTransferred` for a directory mapping too, from a post-mirror byte +// count, so a test can assert on the emitted progress line. function attachCapturingSyncIn( client: SandboxManagedRuntimeClient, captured: SandboxSyncOperation[], @@ -126,6 +146,7 @@ function attachCapturingSyncIn( await mkdir(path.posix.dirname(mapping.targetPath), { recursive: true }); if (mapping.kind === "directory") { await mirrorDirectory(mapping.sourcePath, mapping.targetPath); + bytesTransferred += await directoryByteSize(mapping.targetPath); } else { const bytes = await readFile(mapping.sourcePath); await writeFile(mapping.targetPath, bytes); @@ -502,6 +523,54 @@ describe("sandbox managed runtime", () => { expect(runtimeStatuses.at(-1)).toBe("finalize:Finalizing workspace"); }); + it("falls back to the host-known byte count when the provider reports 0 for inbound workspace sync", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-sandbox-zero-report-")); + cleanupDirs.push(rootDir); + const localWorkspaceDir = path.join(rootDir, "local-workspace"); + const remoteWorkspaceDir = path.join(rootDir, "remote-workspace"); + await mkdir(localWorkspaceDir, { recursive: true }); + // Sizeable content, well above the 0.1 MB rounding step, so a fallback bug + // that stays at 0 is distinguishable from a small transfer that would + // still round down to "0.0 MB". + await writeFile(path.join(localWorkspaceDir, "large.bin"), Buffer.alloc(300 * 1024, "a")); + + const client = makeFilesystemClient(); + const realSyncIn = client.syncIn!; + // Simulate a provider that under-reports: it moves the real bytes but + // returns 0 in `bytesTransferred`, exactly like a buggy or minimal plugin. + client.syncIn = async (operations) => { + const result = await realSyncIn(operations); + return { + operations: result.operations.map((operation) => ({ ...operation, bytesTransferred: 0 })), + }; + }; + + const lines: string[] = []; + await prepareSandboxManagedRuntime({ + spec: { + transport: "sandbox", + provider: "test", + sandboxId: "sandbox-1", + remoteCwd: remoteWorkspaceDir, + timeoutMs: 30_000, + apiKey: null, + }, + adapterKey: "test-adapter", + client, + workspaceLocalDir: localWorkspaceDir, + onProgress: (line) => { lines.push(line); }, + }); + + await expect(readFile(path.join(remoteWorkspaceDir, "large.bin"))).resolves.toHaveLength(300 * 1024); + + const workspaceLines = lines.filter((line) => line.includes("Syncing workspace to environment")); + expect(workspaceLines.length).toBeGreaterThan(0); + // Even though the provider reported 0, the line still shows the real, + // host-known workspace size, from the caller-supplied fallback. + expect(workspaceLines.some((line) => /\(\d+\.\d\/\d+\.\d MB\)/.test(line))).toBe(true); + expect(workspaceLines.some((line) => line.includes("(0.0/0.0 MB)"))).toBe(false); + }); + it.each(["workspace", "git-workspace"])( "rejects an asset key that collides with the reserved %s archive name", async (reservedKey) => { @@ -2166,6 +2235,70 @@ describe("sandbox managed runtime", () => { } }); + it("reports the real transferred bytes for a referenced project's inbound staging", async () => { + const flagKey = "PAPERCLIP_MULTI_PROJECT_WORKSPACE_SYNC"; + const priorFlag = process.env[flagKey]; + process.env[flagKey] = "1"; + try { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-project-bytes-")); + cleanupDirs.push(rootDir); + const localWorkspaceDir = path.join(rootDir, "local-workspace"); + const remoteWorkspaceDir = path.join(rootDir, "remote-workspace"); + const referencedDir = path.join(rootDir, "referenced-project"); + await mkdir(localWorkspaceDir, { recursive: true }); + await mkdir(referencedDir, { recursive: true }); + await writeFile(path.join(localWorkspaceDir, "README.md"), "anchor\n", "utf8"); + // A referenced project has no host tarball to `fs.stat`, so its progress + // line depends entirely on the transport's own `bytesTransferred`. Give it + // real, sizeable content (well above the 0.1 MB rounding step), so a bug + // that keeps the line at 0 stays distinguishable from a correctly-reported + // small transfer that would still round down to "0.0 MB". + await writeFile(path.join(referencedDir, "notes.md"), Buffer.alloc(300 * 1024, "a")); + + const client: SandboxManagedRuntimeClient = { + makeDir: async (remotePath) => { await mkdir(remotePath, { recursive: true }); }, + writeFile: async (remotePath, bytes) => { + await mkdir(path.dirname(remotePath), { recursive: true }); + await writeFile(remotePath, Buffer.from(bytes)); + }, + readFile: async (remotePath) => await readFile(remotePath), + listFiles: async () => [], + remove: async (remotePath) => { await rm(remotePath, { recursive: true, force: true }); }, + run: async (command) => { await execFile("sh", ["-c", command], { maxBuffer: 32 * 1024 * 1024 }); }, + }; + attachCapturingSyncIn(client, []); + + const lines: string[] = []; + await prepareSandboxManagedRuntime({ + spec: { + transport: "sandbox", + provider: "test", + sandboxId: "sandbox-1", + remoteCwd: remoteWorkspaceDir, + timeoutMs: 30_000, + apiKey: null, + }, + adapterKey: "test-adapter", + client, + workspaceLocalDir: localWorkspaceDir, + additionalSources: [{ localPath: referencedDir, projectId: "proj-first" }], + onProgress: (line) => { lines.push(line); }, + }); + + const projectLines = lines.filter((line) => line.includes("Syncing project-proj-first to environment")); + expect(projectLines.length).toBeGreaterThan(0); + // The transfer landed real bytes, so the terminal line carries the actual + // byte total and a 100% completion, not the "0.0 MB" that a discarded + // sync result would show. + expect(projectLines.some((line) => line.includes("100%"))).toBe(true); + expect(projectLines.every((line) => /\(\d+\.\d\/\d+\.\d MB\)/.test(line))).toBe(true); + expect(projectLines.some((line) => line.includes("(0.0/0.0 MB)"))).toBe(false); + } finally { + if (priorFlag === undefined) delete process.env[flagKey]; + else process.env[flagKey] = priorFlag; + } + }); + it("keeps the sandbox runtime core free of Codex-specific string literals", async () => { const coreSource = await readFile(new URL("./sandbox-managed-runtime.ts", import.meta.url), "utf8"); // The seam must be generic: no adapter (Codex) knowledge may live in the core. @@ -3482,24 +3615,31 @@ describe("sandbox git-bundle export transport", () => { attachFallbackSyncIn(client); if (native) { client.syncOut = async (operations) => { + const resultOperations: SandboxSyncResult["operations"] = []; for (const operation of operations) { capture.syncOutOperations.push(operation); + // Report a real `bytesTransferred`, from a post-copy byte count, so a + // test can assert on the emitted progress line — the same way a real + // provider reports the bytes it actually moved. + let bytesTransferred = 0; for (const mapping of operation.files) { if (mapping.kind === "directory") { await copyDirectoryWithExclude(mapping.sourcePath, mapping.targetPath, mapping.exclude); + bytesTransferred += await directoryByteSize(mapping.targetPath); } else { await mkdir(path.dirname(mapping.targetPath), { recursive: true }); - await writeFile(mapping.targetPath, await readFile(mapping.sourcePath)); + const bytes = await readFile(mapping.sourcePath); + await writeFile(mapping.targetPath, bytes); + bytesTransferred += bytes.byteLength; } } - } - return { - operations: operations.map((operation) => ({ + resultOperations.push({ operationId: operation.operationId, filesTransferred: operation.files.length, - bytesTransferred: 0, - })), - }; + bytesTransferred, + }); + } + return { operations: resultOperations }; }; } return client; @@ -3664,4 +3804,40 @@ describe("sandbox git-bundle export transport", () => { // sandbox head commit. await expect(git(localWorkspaceDir, ["cat-file", "-e", `${sandboxHead}^{commit}`])).resolves.toBe(""); }); + + it("reports the real transferred bytes for the native git-history export and workspace restore", async () => { + const capture: TransportCapture = { syncOutOperations: [], readFilePaths: [] }; + const { localWorkspaceDir, remoteWorkspaceDir } = await setupGitBackedWorkspace("paperclip-restore-bytes-"); + const client = makeTransportClient(true, capture); + const prepared = await prepareSandboxManagedRuntime({ + spec: gitSpec(remoteWorkspaceDir), + adapterKey: "test-adapter", + client, + workspaceLocalDir: localWorkspaceDir, + }); + + // Add a sizeable, incompressible tracked file in the sandbox, so both the + // packed git-history bundle and the restored working tree carry real, + // non-trivial bytes well above the 0.1 MB rounding step. Random bytes, + // not a repeated byte, so git's own pack compression cannot shrink the + // bundle back down to a trivial size. + await writeFile(path.join(remoteWorkspaceDir, "large.bin"), randomBytes(300 * 1024)); + await commitInSandbox(remoteWorkspaceDir); + + const lines: string[] = []; + await prepared.restoreWorkspace((line) => { lines.push(line); }); + + const exportLines = lines.filter((line) => line.includes("Exporting git history from environment")); + const restoreLines = lines.filter((line) => line.includes("Restoring workspace from environment")); + + // Both terminal lines carry the real transferred byte total, not the + // "0.0 MB" a discarded native `syncOut` result would leave behind. + expect(exportLines.length).toBeGreaterThan(0); + expect(exportLines.some((line) => /\(\d+\.\d\/\d+\.\d MB\)/.test(line))).toBe(true); + expect(exportLines.some((line) => line.includes("(0.0/0.0 MB)"))).toBe(false); + + expect(restoreLines.length).toBeGreaterThan(0); + expect(restoreLines.some((line) => /\(\d+\.\d\/\d+\.\d MB\)/.test(line))).toBe(true); + expect(restoreLines.some((line) => line.includes("(0.0/0.0 MB)"))).toBe(false); + }); }); diff --git a/packages/adapter-utils/src/sandbox-managed-runtime.ts b/packages/adapter-utils/src/sandbox-managed-runtime.ts index bfa21b7065e..c409533af08 100644 --- a/packages/adapter-utils/src/sandbox-managed-runtime.ts +++ b/packages/adapter-utils/src/sandbox-managed-runtime.ts @@ -774,6 +774,16 @@ function toBuffer(bytes: Buffer | Uint8Array | ArrayBuffer): Buffer { return Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength); } +// Sum `bytesTransferred` over a `SandboxSyncResult`. The result crosses the +// plugin boundary, so a provider can return anything: guard each value and +// treat a missing, non-finite, or negative number as 0. +function sumSyncResultBytes(result: SandboxSyncResult): number { + return result.operations.reduce((total, operation) => { + const bytes = operation.bytesTransferred; + return Number.isFinite(bytes) && bytes > 0 ? total + bytes : total; + }, 0); +} + function tarExcludeFlags(exclude: string[] | undefined): string { return ["._*", ...(exclude ?? [])].map((entry) => `--exclude ${shellQuote(entry)}`).join(" "); } @@ -1039,8 +1049,15 @@ export async function prepareSandboxManagedRuntime(input: { params.progressLabel, { sink: input.onRuntimeProgress, phase: params.statusPhase }, ); - await syncIn(operations); - await upload.finish(params.progressBytes, params.progressBytes); + const syncResult = await syncIn(operations); + // Prefer the transport's own byte total. It is the real count for a + // provider that has no host tarball to stat (a referenced project rides + // a `directory` mapping). Fall back to the caller-supplied count when + // the transport reports 0, so a provider that under-reports still + // shows the host-known workspace total. + const transferredBytes = sumSyncResultBytes(syncResult); + const reportedBytes = transferredBytes > 0 ? transferredBytes : params.progressBytes; + await upload.finish(reportedBytes, reportedBytes); }; // Build the ordered inbound operation task list. Each task stages one inbound @@ -1413,9 +1430,10 @@ export async function prepareSandboxManagedRuntime(input: { // one `kind: "file"` mapping. The host does not buffer the full // bundle in RAM and does not move the bytes through the base64 // read loop. The git import step below reads the bundle from - // `localBundlePath`. The provider transfer reports no byte counts, - // so the "Exporting git history" progress degrades to - // start-and-finish only (mirrors the workspace restore below). + // `localBundlePath`. The provider transfer reports its own byte + // total, so the "Exporting git history" progress still degrades to + // start-and-finish (mirrors the workspace restore below), but the + // finish line carries the real transferred byte count. const operations: SandboxSyncOperation[] = [{ operationId: nextSyncOperationId(), files: [{ @@ -1428,8 +1446,9 @@ export async function prepareSandboxManagedRuntime(input: { sourceRoots: [runtimeRootDir], targetRoots: [tempDir], }); - await input.client.syncOut!(operations); - await gitExport.finish(0, 0); + const syncResult = await input.client.syncOut!(operations); + const transferredBytes = sumSyncResultBytes(syncResult); + await gitExport.finish(transferredBytes, transferredBytes); } else { const bundleBytes = await input.client.readFile(remoteGitBundle, gitExport.options); const bundleBuffer = toBuffer(bundleBytes); @@ -1468,7 +1487,8 @@ export async function prepareSandboxManagedRuntime(input: { // a fresh host directory. It is a clean destroy-then-replace into a // temp dir the orchestrator just created, so it maps exactly to a // generic directory file mapping; the host-side baseline merge below is - // unchanged. + // unchanged. The provider transfer reports its own byte total, so the + // finish line carries the real transferred byte count. const operations: SandboxSyncOperation[] = [{ operationId: nextSyncOperationId(), files: [{ @@ -1490,8 +1510,9 @@ export async function prepareSandboxManagedRuntime(input: { "workspace", { sink: input.onRuntimeProgress, phase: "restore" }, ); - await input.client.syncOut!(operations); - await workspaceRestore.finish(0, 0); + const syncResult = await input.client.syncOut!(operations); + const transferredBytes = sumSyncResultBytes(syncResult); + await workspaceRestore.finish(transferredBytes, transferredBytes); } else { const remoteWorkspaceTar = path.posix.join(runtimeRootDir, "workspace-download.tar"); await input.client.run( From 822e0aed93504aca95b062b718661e04a95764a0 Mon Sep 17 00:00:00 2001 From: Nicky Leach Date: Tue, 25 Aug 2026 14:32:14 -0700 Subject: [PATCH 16/32] fix(adapter-utils): move the workspace-restore merge lock to an instance-scoped root and surface restore failures on the run (#12187) ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Agent adapters restore sandbox work into project workspaces after a run > - The restore lock used the target workspace parent, which can reject writes > - The teardown then hid restore errors, so a run could report success with lost work > - This pull request moves the lock into an instance-scoped root and reports safe restore failure codes > - The benefit is reliable restore coordination and visible failure evidence without changing run success semantics ## Linked Issues or Issue Description Refs: #10914 ## What Changed - Move the workspace-restore merge lock into a private, instance-scoped root. - Derive the lock key from the canonical target path with SHA-256. - Resolve the lock root from the caller environment and reject unsafe root types. - Classify restore failures with three allowlisted codes. - Add the failure code to run result JSON without exposing a host path or process identifier. - Keep restore failure fail-open for the run exit code and run status. ## Verification - Run `npx vitest run packages/adapter-utils/src/workspace-restore-merge.test.ts`. - Run `npx vitest run packages/adapter-utils/src/acpx-engine/run-fault-matrix.test.ts`. - Run the four Codex credential suites. - Confirm the branch includes the current `master` commit and no manual lockfile edit. - Confirm all pull request checks and the Greptile review reach a terminal green state. ## Risks - The lock path changes for workspace restore and removes the sibling-directory fallback. - A misconfigured or inaccessible instance home can still stop lock setup. - Restore remains fail-open, so callers must inspect the result evidence when a restore fails. ## Model Used OpenAI GPT-5. The model used tool calls and code execution to validate and route an author-provided change. The implementing engineer authored the code. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes: #` / `Refs: #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- .../src/acpx-engine/execute.test.ts | 4 + .../adapter-utils/src/acpx-engine/execute.ts | 78 ++- .../src/acpx-engine/run-contracts.ts | 10 +- .../src/acpx-engine/run-fault-matrix.test.ts | 76 ++- .../src/acpx-engine/run-site-sandbox.test.ts | 8 +- .../src/acpx-engine/run-site-sandbox.ts | 7 +- .../settlement-characterization.test.ts | 2 + .../src/workspace-restore-merge.test.ts | 458 +++++++++++++++++- .../src/workspace-restore-merge.ts | 157 +++++- .../claude-local/src/server/acp.test.ts | 86 ++++ .../adapters/claude-local/src/server/acp.ts | 15 +- .../codex-local/src/server/acp.test.ts | 95 ++++ .../adapters/codex-local/src/server/acp.ts | 16 +- .../src/server/adapter-auth-promotion.ts | 3 +- .../src/server/codex-auth-cache.test.ts | 21 +- .../src/server/codex-auth-cache.ts | 16 +- .../src/server/codex-auth-copyback.ts | 72 +-- .../src/server/codex-auth-seed-write.ts | 58 ++- .../src/server/device-login-export.ts | 1 + .../codex-local/src/server/execute.ts | 3 + .../gemini-local/src/server/acp.test.ts | 84 ++++ .../adapters/gemini-local/src/server/acp.ts | 15 +- 22 files changed, 1168 insertions(+), 117 deletions(-) diff --git a/packages/adapter-utils/src/acpx-engine/execute.test.ts b/packages/adapter-utils/src/acpx-engine/execute.test.ts index 1aa79fd366a..9ba7ea4dde0 100644 --- a/packages/adapter-utils/src/acpx-engine/execute.test.ts +++ b/packages/adapter-utils/src/acpx-engine/execute.test.ts @@ -3019,6 +3019,7 @@ describe("ACPX engine remote managed-home seam (PR 2: per-adapter home seed)", ( stagedRuntime, teardown: async () => { teardownCalls += 1; + return { ok: true }; }, }; }, @@ -3362,6 +3363,7 @@ describe("ACPX engine remote session-lifecycle re-staging (PR 3: stage once / re stagedRuntime, teardown: async () => { teardownCalls += 1; + return { ok: true }; }, disposeStaged: async () => { disposeCalls += 1; @@ -3406,6 +3408,7 @@ describe("ACPX engine remote session-lifecycle re-staging (PR 3: stage once / re stagedRuntime: await input.stage([]), teardown: async () => { teardownCalls += 1; + return { ok: true }; }, disposeStaged: async () => { disposeCalls += 1; @@ -4675,6 +4678,7 @@ describe("ACPX engine sandbox-start spans (opt-in root + child parenting)", () = teardownExecFired = true; issueSandboxExecFromStore(traceContext); } + return { ok: true }; }, }; }, diff --git a/packages/adapter-utils/src/acpx-engine/execute.ts b/packages/adapter-utils/src/acpx-engine/execute.ts index f62c6c6a1db..4442e7f642b 100644 --- a/packages/adapter-utils/src/acpx-engine/execute.ts +++ b/packages/adapter-utils/src/acpx-engine/execute.ts @@ -37,6 +37,11 @@ import { } from "@paperclipai/adapter-utils/execution-target"; import type { DuplexLossReason } from "../duplex-observability.js"; import { DUPLEX_CHANNEL_LOST_ERROR_CODE } from "../bridge-transport-contract.js"; +import type { WorkspaceRestoreFailureCode, WorkspaceRestoreOutcome } from "../workspace-restore-merge.js"; +import { + classifyWorkspaceRestoreFailure, + describeWorkspaceRestoreFailure, +} from "../workspace-restore-merge.js"; import { DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE, applyPaperclipWorkspaceEnv, @@ -218,7 +223,7 @@ export interface StagedRuntimeCacheEntry { * in-sandbox credential, not a stale snapshot. It never removes the staged * in-sandbox home, so re-running it on each reuse can't invalidate this entry. */ - teardown: (() => Promise) | null; + teardown: (() => Promise) | null; /** * The seam's one-time host-side staged-resource cleanup (e.g. remove the * staged home temp dir), or null. Fired ONLY when this entry is dropped — @@ -315,7 +320,7 @@ export interface AcpxRemoteManagedHomeResult { * cached staged runtime across resumes never destroys resources a later run * still needs. */ - teardown?: () => Promise; + teardown?: () => Promise; /** * One-time cleanup of host-side staged resources (e.g. the curated staged * home temp dir). Split out from {@link teardown} so it fires ONLY when the @@ -420,7 +425,7 @@ interface AcpxPreparedRuntime { // exit path by the settlement `syncBack` step; it never removes staged temp, so // it is safe on every compatible resume. Null for local runs, the runner-less // fallback, and adapters with no seam. - remoteManagedHomeTeardown: (() => Promise) | null; + remoteManagedHomeTeardown: (() => Promise) | null; // One-time host-side staged-resource cleanup from the seam (remove staged temp // dirs). Fired ONLY when the staged runtime is dropped (failed/cancelled/timed // -out turn, incompatible re-stage, idle eviction), not on a clean turn that @@ -2013,7 +2018,7 @@ async function buildRuntime(input: { remoteExecutionIdentity, }); let stagedRuntime: PreparedAdapterExecutionTargetRuntime | null = null; - let remoteManagedHomeTeardown: (() => Promise) | null = null; + let remoteManagedHomeTeardown: (() => Promise) | null = null; let remoteStagingDispose: (() => Promise) | null = null; let remoteStagingEnvDelta: Record | null = null; let sessionStagingLeaseRelease: (() => void) | null = null; @@ -2364,14 +2369,22 @@ async function stopRunTransport(prepared: AcpxPreparedRuntime): Promise { // dirs. The seam logs and swallows its own failures — an unclean-teardown // copy-back miss is the accepted, loud `refresh_token_reused` residual on the // next host Codex use, never silent HOST-credential corruption — so a teardown -// fault never masks or fails the run result here. -async function syncBackManagedHome(prepared: AcpxPreparedRuntime): Promise { - if (prepared.remoteManagedHomeTeardown) { - await prepared.remoteManagedHomeTeardown().catch(() => {}); +// fault never masks or fails the run result here. It still returns the restore +// outcome, so the caller can record a failure on the run record; the run's exit +// code and status stay exactly what the turn produced. +// The per-session staging lease does NOT release here. The settlement releases +// it last, in its own `finally`, so a same-session second run cannot re-stage +// until this run fully settles and the caller observes the result. +async function syncBackManagedHome(prepared: AcpxPreparedRuntime): Promise { + if (!prepared.remoteManagedHomeTeardown) { + return { ok: true }; } - // The per-session staging lease does NOT release here. The settlement releases - // it last, in its own `finally`, so a same-session second run cannot re-stage - // until this run fully settles and the caller observes the result. + // The teardown closure already catches and logs its own error (fail-soft); + // this `.catch` is defense in depth for the case where it rejects anyway, so + // a teardown fault can never propagate out of settlement. + return await prepared + .remoteManagedHomeTeardown() + .catch((): WorkspaceRestoreOutcome => ({ ok: false, code: "restore_failed" })); } /** How the settlement `endSession` step releases the runtime a run acquired. */ @@ -3442,8 +3455,29 @@ export function createAcpxEngineExecutor(deps: AcpxEngineExecutorOptions = {}) { let referencedProjectStagingFailuresField: | { referencedProjectStagingFailures: Array<{ projectId: string; error: string }> } | Record = {}; + // Set only when the settlement sync-back reports a failed workspace + // restore. `reproduceResult` merges this into the returned result's + // `resultJson`, so a clean run adds no new key. The run's exit code and + // status stay exactly what the turn produced — this is a signal, not an + // outcome change. + let workspaceRestoreFailureField: + | { workspaceRestoreFailure: WorkspaceRestoreFailureCode } + | Record = {}; + // The one settlement step name whose error can be the same workspace- + // restore failure the adapter teardown closure already classifies (a + // caught error from `syncBackManagedHome`). The closure already + // sanitizes its own `onLog` line; this is a second, independent layer, + // so a defect in that closure (or a future call site that forgets to + // sanitize) still cannot put a raw `Error.message` — and the host path + // or process id it can carry — on the run log. + const SYNC_BACK_SETTLEMENT_STEP = "settlement-sync_back"; const recordTeardownError = async (step: string, teardownErr: unknown) => { - const reason = teardownErr instanceof Error ? teardownErr.message : String(teardownErr); + const reason = + step === SYNC_BACK_SETTLEMENT_STEP + ? describeWorkspaceRestoreFailure(classifyWorkspaceRestoreFailure(teardownErr)) + : teardownErr instanceof Error + ? teardownErr.message + : String(teardownErr); await ctx .onLog("stderr", `[paperclip] ACPX teardown step "${step}" failed: ${reason}\n`) .catch(() => {}); @@ -4445,7 +4479,10 @@ export function createAcpxEngineExecutor(deps: AcpxEngineExecutorOptions = {}) { // per-task restore spans parent to `sandbox.syncBack`. syncBack: () => timedPhase("sync_back", async () => { await runRuntimeSpan("sandbox.syncBack", async () => { - await syncBackManagedHome(prepared); + const restoreOutcome = await syncBackManagedHome(prepared); + if (!restoreOutcome.ok) { + workspaceRestoreFailureField = { workspaceRestoreFailure: restoreOutcome.code }; + } }); }), // The staging lease releases as the run's final act, AFTER the coordinator @@ -4490,7 +4527,20 @@ export function createAcpxEngineExecutor(deps: AcpxEngineExecutorOptions = {}) { if (!capturedResult) { throw new Error("run coordinator reproduced a result before the run recorded one"); } - return capturedResult; + // The sync-back settlement step runs before this reproduces the result + // (settlement precedes reproduction), so a failed workspace restore is + // already recorded by the time we get here. Merge it into `resultJson` + // only on a failure — a clean restore adds no new key. + if (!("workspaceRestoreFailure" in workspaceRestoreFailureField)) { + return capturedResult; + } + return { + ...capturedResult, + resultJson: { + ...(capturedResult.resultJson ?? {}), + ...workspaceRestoreFailureField, + }, + }; }, }; return await runAttempt(plan); diff --git a/packages/adapter-utils/src/acpx-engine/run-contracts.ts b/packages/adapter-utils/src/acpx-engine/run-contracts.ts index afc56f448a7..173cb5db9aa 100644 --- a/packages/adapter-utils/src/acpx-engine/run-contracts.ts +++ b/packages/adapter-utils/src/acpx-engine/run-contracts.ts @@ -18,6 +18,7 @@ import type { AdapterExecutionTargetProcessSessionBridgeHandle, PreparedAdapterExecutionTargetRuntime, } from "@paperclipai/adapter-utils/execution-target"; +import type { WorkspaceRestoreOutcome } from "../workspace-restore-merge.js"; // --------------------------------------------------------------------------- // Resource identity @@ -92,8 +93,13 @@ export interface StagedRuntimeResource { * `AcpxPreparedRuntime.remoteManagedHomeTeardown` and `remoteStagingEnvDelta`. */ export interface ManagedHomeResource { - /** Per-run copy-back hook. Runs the auth copy-back on every exit path. */ - readonly teardown: () => Promise; + /** + * Per-run copy-back hook. Runs the auth copy-back and the workspace restore + * on every exit path. Resolves to the workspace-restore outcome — never + * rejects — so the run coordinator can surface a failed restore on the run + * record without changing the run's exit code or status. + */ + readonly teardown: () => Promise; /** The env keys the seam mutated on this run. */ readonly envDelta: Record; } diff --git a/packages/adapter-utils/src/acpx-engine/run-fault-matrix.test.ts b/packages/adapter-utils/src/acpx-engine/run-fault-matrix.test.ts index eccb29c3666..e8e7c990a97 100644 --- a/packages/adapter-utils/src/acpx-engine/run-fault-matrix.test.ts +++ b/packages/adapter-utils/src/acpx-engine/run-fault-matrix.test.ts @@ -8,6 +8,7 @@ import { startAdapterExecutionTargetProcessSessionBridge, } from "@paperclipai/adapter-utils/execution-target"; import { runChildProcess } from "../server-utils.js"; +import { classifyWorkspaceRestoreFailure } from "../workspace-restore-merge.js"; // The composed fault matrix. // @@ -213,7 +214,22 @@ function managedHomeSeed(options: { teardownRejects?: boolean } = {}): AcpxEngin ? async () => { throw new Error("copy-back boom"); } - : async () => {}, + : async () => ({ ok: true as const }), + }; + }; +} + +// Same seam, but the teardown catches its own restore error and classifies it +// with the real classifier — the way the three real adapter teardown closures +// do — instead of letting it reject the promise. +function managedHomeSeedWithClassifiedTeardownFailure( + error: NodeJS.ErrnoException, +): AcpxEngineExecutorOptions["prepareRemoteManagedHome"] { + return async (input) => { + const stagedRuntime = await input.stage([]); + return { + stagedRuntime, + teardown: async () => ({ ok: false as const, code: classifyWorkspaceRestoreFailure(error) }), }; }; } @@ -689,7 +705,8 @@ describe("composed ACPX run fault matrix", () => { }); // Case 14 — a sync-back copy-back that rejects during settlement never changes - // the result or the report. + // the run's exit code or status, and it surfaces on the result as one + // allowlisted `workspaceRestoreFailure` code — never the raw error message. it("case_14_sync_back_failure_does_not_change_result", async () => { const { stateDir, localCwd, executionTarget } = await setupRemoteSandbox(); stubBridges(); @@ -710,12 +727,67 @@ describe("composed ACPX run fault matrix", () => { expect(result.exitCode).toBe(0); expect(result.resultJson?.status).toBe("completed"); + expect(result.resultJson?.workspaceRestoreFailure).toBe("restore_failed"); + expect(JSON.stringify(result.resultJson)).not.toContain("copy-back boom"); assertDispositionReport(capture.last(), { acquired: SANDBOX_WITH_MANAGED_HOME, transferred: "staged_runtime", }); }); + // Case 14b — a clean sync-back adds no `workspaceRestoreFailure` key at all. + it("case_14b_clean_sync_back_adds_no_workspace_restore_failure_key", async () => { + const { stateDir, localCwd, executionTarget } = await setupRemoteSandbox(); + stubBridges(); + const execute = createAcpxEngineExecutor({ + warmHandles: new Map(), + stagedRuntimes: new Map(), + stagingLocks: new Map(), + createRuntime: () => completedRuntime(), + prepareRemoteManagedHome: managedHomeSeed(), + }); + + const result = await execute({ + runId: "fault-sync-back-clean", + ...remoteArgs(stateDir, localCwd, executionTarget), + } as never); + + expect(result.exitCode).toBe(0); + expect(result.resultJson).not.toHaveProperty("workspaceRestoreFailure"); + }); + + // Case 14c — an EACCES teardown error (the reported lock-mkdir bug) reaches + // the run result as the allowlisted restore_permission_denied code, with no + // filesystem path and no process id in resultJson, and the run's exit code + // and status stay exactly what the turn produced. + it("case_14c_eacces_teardown_reaches_result_as_restore_permission_denied", async () => { + const { stateDir, localCwd, executionTarget } = await setupRemoteSandbox(); + stubBridges(); + const eaccesError: NodeJS.ErrnoException = new Error( + `EACCES: permission denied, mkdir '/srv/telemetry-backend.paperclip-restore.lock' (pid ${process.pid})`, + ); + eaccesError.code = "EACCES"; + const execute = createAcpxEngineExecutor({ + warmHandles: new Map(), + stagedRuntimes: new Map(), + stagingLocks: new Map(), + createRuntime: () => completedRuntime(), + prepareRemoteManagedHome: managedHomeSeedWithClassifiedTeardownFailure(eaccesError), + }); + + const result = await execute({ + runId: "fault-sync-back-eacces", + ...remoteArgs(stateDir, localCwd, executionTarget), + } as never); + + expect(result.exitCode).toBe(0); + expect(result.resultJson?.status).toBe("completed"); + expect(result.resultJson?.workspaceRestoreFailure).toBe("restore_permission_denied"); + const serializedResult = JSON.stringify(result.resultJson); + expect(serializedResult).not.toContain("/srv/telemetry-backend"); + expect(serializedResult).not.toContain(String(process.pid)); + }); + // Case 15 — a concurrent double settlement is a structural error: the second // claim of the ledger throws a LedgerStateError. it("case_15_concurrent_double_settlement_throws_ledger_state_error", async () => { diff --git a/packages/adapter-utils/src/acpx-engine/run-site-sandbox.test.ts b/packages/adapter-utils/src/acpx-engine/run-site-sandbox.test.ts index 426a3342761..ebbab2cb607 100644 --- a/packages/adapter-utils/src/acpx-engine/run-site-sandbox.test.ts +++ b/packages/adapter-utils/src/acpx-engine/run-site-sandbox.test.ts @@ -78,7 +78,11 @@ function makeSite(overrides: Partial = {}) { const stagedRuntime = await stage([]); // Repoint a managed-home env var, so the site captures the delta. env.CODEX_HOME = "/remote/home"; - return { stagedRuntime, teardown: async () => {}, dispose: async () => {} } satisfies ManagedHomeSeamResult; + return { + stagedRuntime, + teardown: async () => ({ ok: true }), + dispose: async () => {}, + } satisfies ManagedHomeSeamResult; }, disposeFreshStagedRuntime: async () => {}, measureStageStep: (run) => run(), @@ -246,7 +250,7 @@ describe("sandbox run site", () => { { stagedRuntime: cachedRuntime, envDelta: { CODEX_HOME: "/remote/home" }, - teardown: async () => {}, + teardown: async () => ({ ok: true }), dispose: async () => {}, lastUsedAt: 500, }, diff --git a/packages/adapter-utils/src/acpx-engine/run-site-sandbox.ts b/packages/adapter-utils/src/acpx-engine/run-site-sandbox.ts index 4f85022607d..73f6a86fe54 100644 --- a/packages/adapter-utils/src/acpx-engine/run-site-sandbox.ts +++ b/packages/adapter-utils/src/acpx-engine/run-site-sandbox.ts @@ -41,6 +41,7 @@ import type { SitePlan, } from "./run-contracts.js"; import { createSessionReuseStore } from "./session-reuse-store.js"; +import type { WorkspaceRestoreOutcome } from "../workspace-restore-merge.js"; /** * One staged in-sandbox runtime the store keeps for the next compatible resume. @@ -52,7 +53,7 @@ import { createSessionReuseStore } from "./session-reuse-store.js"; export interface StagedRuntimeStoreEntry { stagedRuntime: PreparedAdapterExecutionTargetRuntime; envDelta: Record; - teardown: (() => Promise) | null; + teardown: (() => Promise) | null; dispose: (() => Promise) | null; lastUsedAt: number; } @@ -63,7 +64,7 @@ export interface StagedRuntimeStoreEntry { */ export interface StagedWorkspace { readonly stagedRuntime: PreparedAdapterExecutionTargetRuntime; - readonly teardown: (() => Promise) | null; + readonly teardown: (() => Promise) | null; readonly dispose: (() => Promise) | null; readonly envDelta: Record; /** True when the run reused an already-staged runtime rather than staging fresh. */ @@ -79,7 +80,7 @@ export type StageWorkspace = (assets: AdapterManagedRuntimeAsset[]) => Promise

Promise) | null; + readonly teardown: (() => Promise) | null; readonly dispose: (() => Promise) | null; } diff --git a/packages/adapter-utils/src/acpx-engine/settlement-characterization.test.ts b/packages/adapter-utils/src/acpx-engine/settlement-characterization.test.ts index d546a956ded..593fc644d31 100644 --- a/packages/adapter-utils/src/acpx-engine/settlement-characterization.test.ts +++ b/packages/adapter-utils/src/acpx-engine/settlement-characterization.test.ts @@ -334,6 +334,7 @@ describe("ACP settlement — Layer A: engine teardown orchestration", () => { // The lease is still held while the sync-back runs; it releases only // afterward, in the cleanupRemoteBridges finally. leaseSizeDuringSyncBack = stagingLocks.size; + return { ok: true }; }, }; }, @@ -1046,6 +1047,7 @@ describe("ACP settlement — Layer C: per-adapter sync-back teardown fires once stagedRuntime, teardown: async () => { teardownCalls += 1; + return { ok: true }; }, }; }, diff --git a/packages/adapter-utils/src/workspace-restore-merge.test.ts b/packages/adapter-utils/src/workspace-restore-merge.test.ts index ff0a5a126d7..d3a06c08bc4 100644 --- a/packages/adapter-utils/src/workspace-restore-merge.test.ts +++ b/packages/adapter-utils/src/workspace-restore-merge.test.ts @@ -1,10 +1,20 @@ -import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { createHash } from "node:crypto"; +import { promises as fsPromises } from "node:fs"; +import { chmod, lstat, mkdir, mkdtemp, readFile, readdir, realpath, rm, stat, symlink, writeFile } from "node:fs/promises"; import net from "node:net"; import os from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; -import { captureDirectorySnapshot, mergeDirectoryWithBaseline } from "./workspace-restore-merge.js"; +import { resolvePaperclipInstanceRootForAdapter } from "./server-utils.js"; +import { + captureDirectorySnapshot, + classifyWorkspaceRestoreFailure, + describeWorkspaceRestoreFailure, + mergeDirectoryWithBaseline, + withDirectoryMergeLock, + WORKSPACE_RESTORE_LOCK_TIMEOUT_CODE, +} from "./workspace-restore-merge.js"; describe("workspace restore merge", () => { const cleanupDirs: string[] = []; @@ -81,4 +91,446 @@ describe("workspace restore merge", () => { await new Promise((resolve) => server.close(() => resolve())); } }); + + describe("classifyWorkspaceRestoreFailure", () => { + it("maps an EACCES error to restore_permission_denied", () => { + const error: NodeJS.ErrnoException = new Error("permission denied"); + error.code = "EACCES"; + expect(classifyWorkspaceRestoreFailure(error)).toBe("restore_permission_denied"); + }); + + it("maps an EPERM error to restore_permission_denied", () => { + const error: NodeJS.ErrnoException = new Error("operation not permitted"); + error.code = "EPERM"; + expect(classifyWorkspaceRestoreFailure(error)).toBe("restore_permission_denied"); + }); + + it("maps the lock-timeout code to restore_lock_timeout", () => { + const error: NodeJS.ErrnoException = new Error("Timed out waiting for workspace restore lock at /some/path"); + error.code = WORKSPACE_RESTORE_LOCK_TIMEOUT_CODE; + expect(classifyWorkspaceRestoreFailure(error)).toBe("restore_lock_timeout"); + }); + + it("maps an unrecognized error, a string, and null to the default restore_failed code", () => { + expect(classifyWorkspaceRestoreFailure(new Error("some other failure"))).toBe("restore_failed"); + expect(classifyWorkspaceRestoreFailure("a plain string")).toBe("restore_failed"); + expect(classifyWorkspaceRestoreFailure(null)).toBe("restore_failed"); + }); + }); + + describe("describeWorkspaceRestoreFailure", () => { + it("returns one fixed diagnostic line per allowlisted code, and no other text", () => { + expect(describeWorkspaceRestoreFailure("restore_permission_denied")).toBe( + "the restore could not write to the workspace (permission denied)", + ); + expect(describeWorkspaceRestoreFailure("restore_lock_timeout")).toBe( + "the restore timed out waiting for the workspace merge lock", + ); + expect(describeWorkspaceRestoreFailure("restore_failed")).toBe("the restore failed"); + }); + + it("never reflects a sentinel host path or process id, however the caught error is classified", () => { + const sentinelPath = "/srv/telemetry-backend"; + const sentinelPid = String(process.pid); + const error: NodeJS.ErrnoException = new Error( + `EACCES: permission denied, mkdir '${sentinelPath}.paperclip-restore.lock' (pid ${sentinelPid})`, + ); + error.code = "EACCES"; + + const line = describeWorkspaceRestoreFailure(classifyWorkspaceRestoreFailure(error)); + + expect(line).not.toContain(sentinelPath); + expect(line).not.toContain(sentinelPid); + expect(line).not.toContain(error.message); + }); + }); + + describe("instance-scoped directory merge lock", () => { + // Points PAPERCLIP_HOME (and, where noted, PAPERCLIP_INSTANCE_ID) at a + // temporary directory so the lock root never touches the real Paperclip + // instance, then restores the previous values. Mirrors the save-and-restore + // pattern in acpx-engine/execute.test.ts. + let previousHome: string | undefined; + let previousInstanceId: string | undefined; + + function useTempPaperclipHome(homeDir: string, instanceId: string): void { + previousHome = process.env.PAPERCLIP_HOME; + previousInstanceId = process.env.PAPERCLIP_INSTANCE_ID; + process.env.PAPERCLIP_HOME = homeDir; + process.env.PAPERCLIP_INSTANCE_ID = instanceId; + } + + afterEach(() => { + if (previousHome === undefined) delete process.env.PAPERCLIP_HOME; + else process.env.PAPERCLIP_HOME = previousHome; + if (previousInstanceId === undefined) delete process.env.PAPERCLIP_INSTANCE_ID; + else process.env.PAPERCLIP_INSTANCE_ID = previousInstanceId; + previousHome = undefined; + previousInstanceId = undefined; + }); + + it.skipIf(process.platform === "win32")( + "restores successfully when the parent directory of the target is not writable", + async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-restore-merge-")); + cleanupDirs.push(rootDir); + useTempPaperclipHome(path.join(rootDir, "paperclip-home"), "test-instance"); + + // The old lock sat beside the target, so it needed mkdir rights in the + // target's parent. The new lock root lives under PAPERCLIP_HOME instead, + // so a read-only parent must no longer block a restore. + const readOnlyParent = path.join(rootDir, "read-only-parent"); + const targetDir = path.join(readOnlyParent, "target"); + const sourceDir = path.join(rootDir, "source"); + await mkdir(targetDir, { recursive: true }); + await mkdir(sourceDir, { recursive: true }); + + const baseline = await captureDirectorySnapshot(targetDir, { exclude: [] }); + await writeFile(path.join(sourceDir, "new-file.md"), "new content\n", "utf8"); + + await chmod(readOnlyParent, 0o500); + try { + await mergeDirectoryWithBaseline({ baseline, sourceDir, targetDir }); + } finally { + // Restore write access so the outer afterEach can remove rootDir. + await chmod(readOnlyParent, 0o700).catch(() => undefined); + } + + await expect(readFile(path.join(targetDir, "new-file.md"), "utf8")).resolves.toBe("new content\n"); + }, + ); + + it.skipIf(process.platform === "win32")( + "acquires the same lock for two alias paths that resolve to one canonical target", + async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-restore-merge-")); + cleanupDirs.push(rootDir); + const paperclipHome = path.join(rootDir, "paperclip-home"); + useTempPaperclipHome(paperclipHome, "test-instance"); + + const targetDir = path.join(rootDir, "target"); + const aliasDir = path.join(rootDir, "target-alias"); + await mkdir(targetDir, { recursive: true }); + await symlink(targetDir, aliasDir); + + const lockRootDir = path.join(paperclipHome, "instances", "test-instance", "locks", "directory-merge"); + + let lockNameViaTarget = ""; + await withDirectoryMergeLock(targetDir, async () => { + const entries = await readdir(lockRootDir); + lockNameViaTarget = entries[0] ?? ""; + }); + + let lockNameViaAlias = ""; + await withDirectoryMergeLock(aliasDir, async () => { + const entries = await readdir(lockRootDir); + lockNameViaAlias = entries[0] ?? ""; + }); + + expect(lockNameViaTarget).not.toBe(""); + expect(lockNameViaAlias).toBe(lockNameViaTarget); + }, + ); + + it("rejects a lock root that already exists as a symlink", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-restore-merge-")); + cleanupDirs.push(rootDir); + const paperclipHome = path.join(rootDir, "paperclip-home"); + useTempPaperclipHome(paperclipHome, "test-instance"); + + const locksDir = path.join(paperclipHome, "instances", "test-instance", "locks"); + const decoyDir = path.join(rootDir, "decoy"); + await mkdir(locksDir, { recursive: true }); + await mkdir(decoyDir, { recursive: true }); + await symlink(decoyDir, path.join(locksDir, "directory-merge")); + + const targetDir = path.join(rootDir, "target"); + await mkdir(targetDir, { recursive: true }); + + await expect(withDirectoryMergeLock(targetDir, async () => undefined)).rejects.toThrow( + /not a plain directory/, + ); + }); + + it("rejects a lock root that already exists as a non-directory", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-restore-merge-")); + cleanupDirs.push(rootDir); + const paperclipHome = path.join(rootDir, "paperclip-home"); + useTempPaperclipHome(paperclipHome, "test-instance"); + + const locksDir = path.join(paperclipHome, "instances", "test-instance", "locks"); + await mkdir(locksDir, { recursive: true }); + await writeFile(path.join(locksDir, "directory-merge"), "not a directory\n", "utf8"); + + const targetDir = path.join(rootDir, "target"); + await mkdir(targetDir, { recursive: true }); + + await expect(withDirectoryMergeLock(targetDir, async () => undefined)).rejects.toThrow( + /not a plain directory/, + ); + }); + + it("closes the create/validate TOCTOU window: rejects a lock root a racing writer swapped for a symlink during creation", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-restore-merge-")); + cleanupDirs.push(rootDir); + const paperclipHome = path.join(rootDir, "paperclip-home"); + useTempPaperclipHome(paperclipHome, "test-instance"); + + const targetDir = path.join(rootDir, "target"); + await mkdir(targetDir, { recursive: true }); + const decoyDir = path.join(rootDir, "decoy"); + await mkdir(decoyDir, { recursive: true }); + // Pre-create the lock root's parent, so the mock below only has to + // reproduce what `fs.mkdir({ recursive: true })` does to the leaf path. + await mkdir(path.join(paperclipHome, "instances", "test-instance", "locks"), { recursive: true }); + + // Real `fs.mkdir({ recursive: true })` does not fail on a leaf that + // already exists as a symlink to a real directory. This stub reproduces + // exactly that: it plants a symlink to the attacker-controlled decoy + // directory in the window between the resolver's own "does the root + // exist yet" check and its own `mkdir` call, then resolves the way a + // real `mkdir` would (silently) — proving the resolver must validate + // what `mkdir` actually left behind, not trust that the call resolved. + const mkdirSpy = vi.spyOn(fsPromises, "mkdir").mockImplementationOnce(async (dirPath) => { + await symlink(decoyDir, dirPath as string); + return undefined; + }); + + try { + await expect(withDirectoryMergeLock(targetDir, async () => undefined)).rejects.toThrow( + /not a plain directory/, + ); + } finally { + mkdirSpy.mockRestore(); + } + }); + + it("creates the lock root at mode 0o700 and removes the lock directory after release", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-restore-merge-")); + cleanupDirs.push(rootDir); + const paperclipHome = path.join(rootDir, "paperclip-home"); + useTempPaperclipHome(paperclipHome, "test-instance"); + + const targetDir = path.join(rootDir, "target"); + await mkdir(targetDir, { recursive: true }); + + const lockRootDir = path.join(paperclipHome, "instances", "test-instance", "locks", "directory-merge"); + let entriesDuringLock: string[] = []; + await withDirectoryMergeLock(targetDir, async () => { + entriesDuringLock = await readdir(lockRootDir); + }); + + expect((await stat(lockRootDir)).mode & 0o777).toBe(0o700); + expect(entriesDuringLock).toHaveLength(1); + await expect(readdir(lockRootDir)).resolves.toHaveLength(0); + }); + + it("classifies the real lock-timeout error by its stable code, never by the message text", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-restore-merge-")); + cleanupDirs.push(rootDir); + const paperclipHome = path.join(rootDir, "paperclip-home"); + useTempPaperclipHome(paperclipHome, "test-instance"); + + const targetDir = path.join(rootDir, "target"); + await mkdir(targetDir, { recursive: true }); + + // Pre-create the lock directory a live process holds, so `isLockStale` + // never reports it stale and the retry loop can only leave through the + // deadline check. The owner pid is this test process, which stays alive. + const canonicalTargetDir = await realpath(targetDir); + const lockKey = createHash("sha256").update(canonicalTargetDir).digest("hex"); + const lockRootDir = path.join(paperclipHome, "instances", "test-instance", "locks", "directory-merge"); + const heldLockDir = path.join(lockRootDir, `${lockKey}.lock`); + await mkdir(heldLockDir, { recursive: true }); + await writeFile( + path.join(heldLockDir, "owner.json"), + `${JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() })}\n`, + "utf8", + ); + + // Reach the real deadline without a real 30-second wait: the first + // `Date.now()` call computes the deadline (unchanged), and every call + // after reports a time far past it, so the retry loop's own deadline + // check — not a mocked message or a shortened constant — throws. + const realNow = Date.now(); + const dateNowSpy = vi + .spyOn(Date, "now") + .mockImplementationOnce(() => realNow) + .mockImplementation(() => Number.MAX_SAFE_INTEGER); + let caughtError: NodeJS.ErrnoException | undefined; + try { + await withDirectoryMergeLock(targetDir, async () => undefined); + } catch (error) { + caughtError = error as NodeJS.ErrnoException; + } finally { + dateNowSpy.mockRestore(); + } + + expect(caughtError).toBeInstanceOf(Error); + expect(caughtError?.code).toBe(WORKSPACE_RESTORE_LOCK_TIMEOUT_CODE); + // The classifier reads only `code`; prove the message text carries no + // trace of the classified outcome, so a message-text match could not + // have produced this result. + expect(caughtError?.message).not.toContain("restore_lock_timeout"); + expect(classifyWorkspaceRestoreFailure(caughtError)).toBe("restore_lock_timeout"); + }); + + it.skipIf(process.platform === "win32")( + "serializes two concurrent writers that address one target through different aliases", + async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-restore-merge-")); + cleanupDirs.push(rootDir); + useTempPaperclipHome(path.join(rootDir, "paperclip-home"), "test-instance"); + + const targetDir = path.join(rootDir, "target"); + const aliasDir = path.join(rootDir, "target-alias"); + await mkdir(targetDir, { recursive: true }); + await symlink(targetDir, aliasDir); + + let active = false; + let overlapCount = 0; + let completedCount = 0; + const runWriter = (dir: string) => + withDirectoryMergeLock(dir, async () => { + if (active) overlapCount += 1; + active = true; + await new Promise((resolve) => setTimeout(resolve, 30)); + active = false; + completedCount += 1; + }); + + await Promise.all([runWriter(targetDir), runWriter(aliasDir)]); + + expect(overlapCount).toBe(0); + expect(completedCount).toBe(2); + }, + ); + }); + + describe("caller-provided env for the lock root", () => { + // These tests never touch `process.env`. They prove `withDirectoryMergeLock` + // resolves the lock root from a caller's own `env` object — the shape every + // environment-parameterized Codex credential call site holds — instead of + // always reading `process.env`. + + it("two callers that pass the same env with a temporary PAPERCLIP_HOME take the same lock under that home", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-restore-merge-")); + cleanupDirs.push(rootDir); + const explicitHome = path.join(rootDir, "explicit-home"); + const env: NodeJS.ProcessEnv = { PAPERCLIP_HOME: explicitHome, PAPERCLIP_INSTANCE_ID: "test-instance" }; + + const targetDir = path.join(rootDir, "target"); + await mkdir(targetDir, { recursive: true }); + + const lockRootDir = path.join(explicitHome, "instances", "test-instance", "locks", "directory-merge"); + + let lockNameFirstCaller = ""; + await withDirectoryMergeLock( + targetDir, + async () => { + const entries = await readdir(lockRootDir); + lockNameFirstCaller = entries[0] ?? ""; + }, + env, + ); + + let lockNameSecondCaller = ""; + await withDirectoryMergeLock( + targetDir, + async () => { + const entries = await readdir(lockRootDir); + lockNameSecondCaller = entries[0] ?? ""; + }, + env, + ); + + expect(lockNameFirstCaller).not.toBe(""); + expect(lockNameSecondCaller).toBe(lockNameFirstCaller); + expect(lockRootDir.startsWith(explicitHome + path.sep)).toBe(true); + }); + + it("does not write a lock entry under process.env.PAPERCLIP_HOME when the caller passes its own env", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-restore-merge-")); + cleanupDirs.push(rootDir); + const explicitHome = path.join(rootDir, "explicit-home"); + const env: NodeJS.ProcessEnv = { PAPERCLIP_HOME: explicitHome, PAPERCLIP_INSTANCE_ID: "test-instance" }; + + const targetDir = path.join(rootDir, "target"); + await mkdir(targetDir, { recursive: true }); + const canonicalTargetDir = await realpath(targetDir); + const lockKey = createHash("sha256").update(canonicalTargetDir).digest("hex"); + + // Resolved with no `env` argument, so it reads `process.env` exactly the way + // the real instance root does — unaffected by the explicit `env` above. + const realInstanceRoot = resolvePaperclipInstanceRootForAdapter(); + const realLockPath = path.join(realInstanceRoot, "locks", "directory-merge", `${lockKey}.lock`); + + await withDirectoryMergeLock(targetDir, async () => undefined, env); + + await expect(lstat(realLockPath)).rejects.toThrow(); + + const explicitLockRootDir = path.join(explicitHome, "instances", "test-instance", "locks", "directory-merge"); + await expect(stat(explicitLockRootDir)).resolves.toBeTruthy(); + }); + + it("resolves the lock root under the default instance id when the caller env sets PAPERCLIP_HOME but not PAPERCLIP_INSTANCE_ID, ignoring process.env.PAPERCLIP_INSTANCE_ID", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-restore-merge-")); + cleanupDirs.push(rootDir); + const explicitHome = path.join(rootDir, "explicit-home"); + const env: NodeJS.ProcessEnv = { PAPERCLIP_HOME: explicitHome }; + + const previousInstanceId = process.env.PAPERCLIP_INSTANCE_ID; + process.env.PAPERCLIP_INSTANCE_ID = "wrong-instance"; + try { + const targetDir = path.join(rootDir, "target"); + await mkdir(targetDir, { recursive: true }); + + // The independent, no-caller-env resolution of "PAPERCLIP_HOME set, + // PAPERCLIP_INSTANCE_ID unset" — the expected default instance id. + const expectedInstanceRoot = resolvePaperclipInstanceRootForAdapter({ homeDir: explicitHome, env: {} }); + const expectedLockRootDir = path.join(expectedInstanceRoot, "locks", "directory-merge"); + const wrongInstanceLockRootDir = path.join(explicitHome, "instances", "wrong-instance", "locks", "directory-merge"); + + await withDirectoryMergeLock(targetDir, async () => undefined, env); + + await expect(stat(expectedLockRootDir)).resolves.toBeTruthy(); + await expect(stat(wrongInstanceLockRootDir)).rejects.toThrow(); + } finally { + if (previousInstanceId === undefined) delete process.env.PAPERCLIP_INSTANCE_ID; + else process.env.PAPERCLIP_INSTANCE_ID = previousInstanceId; + } + }); + + it("does not read process.env.PAPERCLIP_HOME when the caller env sets neither variable", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-restore-merge-")); + cleanupDirs.push(rootDir); + const fakeProcessHome = path.join(rootDir, "process-home"); + const fallbackOsHome = path.join(rootDir, "os-home"); + await mkdir(fallbackOsHome, { recursive: true }); + + const previousHome = process.env.PAPERCLIP_HOME; + process.env.PAPERCLIP_HOME = fakeProcessHome; + // Stand in for the real host home directory, so the "no env at all" + // fallback lands under a temp dir instead of the real ~/.paperclip. + const homedirSpy = vi.spyOn(os, "homedir").mockReturnValue(fallbackOsHome); + try { + const targetDir = path.join(rootDir, "target"); + await mkdir(targetDir, { recursive: true }); + + // The independent, no-caller-env resolution of "neither variable set" — + // the expected fallback root under the mocked home directory. + const expectedInstanceRoot = resolvePaperclipInstanceRootForAdapter({ env: {} }); + const expectedLockRootDir = path.join(expectedInstanceRoot, "locks", "directory-merge"); + + await withDirectoryMergeLock(targetDir, async () => undefined, {}); + + await expect(stat(fakeProcessHome)).rejects.toThrow(); + await expect(stat(expectedLockRootDir)).resolves.toBeTruthy(); + } finally { + homedirSpy.mockRestore(); + if (previousHome === undefined) delete process.env.PAPERCLIP_HOME; + else process.env.PAPERCLIP_HOME = previousHome; + } + }); + }); }); diff --git a/packages/adapter-utils/src/workspace-restore-merge.ts b/packages/adapter-utils/src/workspace-restore-merge.ts index 16782973c3e..8d59cd06db4 100644 --- a/packages/adapter-utils/src/workspace-restore-merge.ts +++ b/packages/adapter-utils/src/workspace-restore-merge.ts @@ -3,6 +3,7 @@ import { createReadStream } from "node:fs"; import { constants as fsConstants, promises as fs } from "node:fs"; import path from "node:path"; import { shouldExcludePath } from "./exclude-patterns.js"; +import { resolvePaperclipInstanceRootForAdapter } from "./server-utils.js"; type SnapshotEntry = | { kind: "dir" } @@ -108,6 +109,67 @@ function entriesMatch(left: SnapshotEntry | null | undefined, right: SnapshotEnt const LOCK_STALE_MS = 30_000; +/** + * The stable `code` a lock-timeout error carries, so a caller can identify it + * without matching on the error message text (the message embeds the lock + * directory path). + */ +export const WORKSPACE_RESTORE_LOCK_TIMEOUT_CODE = "ERR_WORKSPACE_RESTORE_LOCK_TIMEOUT"; + +/** + * The closed set of codes a failed workspace restore can carry off the + * sandbox. Every code is safe to store on a run record readable by any + * same-company actor: none embeds a filesystem path, a raw error message, or + * a process id. + */ +export type WorkspaceRestoreFailureCode = + | "restore_permission_denied" + | "restore_lock_timeout" + | "restore_failed"; + +/** + * The outcome of one workspace restore. `ok: true` on a clean restore. `ok: + * false` carries one allowlisted {@link WorkspaceRestoreFailureCode} — never a + * raw error, a path, or a process id. + */ +export type WorkspaceRestoreOutcome = + | { readonly ok: true } + | { readonly ok: false; readonly code: WorkspaceRestoreFailureCode }; + +/** + * Classifies a caught workspace-restore error into one allowlisted code. Maps + * `EACCES` and `EPERM` to a permission failure, the merge-lock timeout + * (matched by {@link WORKSPACE_RESTORE_LOCK_TIMEOUT_CODE}, never by the error + * message text) to a lock-timeout failure, and every other error to a generic + * failure. Never reads or returns `Error.message`, a filesystem path, or a + * process id. + */ +export function classifyWorkspaceRestoreFailure(error: unknown): WorkspaceRestoreFailureCode { + const code = error && typeof error === "object" ? (error as NodeJS.ErrnoException).code : undefined; + if (code === "EACCES" || code === "EPERM") return "restore_permission_denied"; + if (code === WORKSPACE_RESTORE_LOCK_TIMEOUT_CODE) return "restore_lock_timeout"; + return "restore_failed"; +} + +/** + * The fixed, allowlisted line an ACP adapter writes to the run log when a + * workspace restore fails. Every call site must pass this to `onLog` instead + * of the caught error's own message: the caught error can carry a host + * filesystem path or the lock owner's process id, and the run log is + * readable by any same-company actor. Never add the code's raw + * `Error.message` to this text. + */ +export function describeWorkspaceRestoreFailure(code: WorkspaceRestoreFailureCode): string { + switch (code) { + case "restore_permission_denied": + return "the restore could not write to the workspace (permission denied)"; + case "restore_lock_timeout": + return "the restore timed out waiting for the workspace merge lock"; + case "restore_failed": + return "the restore failed"; + } +} + async function isLockStale(lockDir: string): Promise { try { const raw = await fs.readFile(path.join(lockDir, "owner.json"), "utf8"); @@ -160,20 +222,97 @@ async function acquireDirectoryMergeLock(lockDir: string): Promise<() => Promise continue; } if (Date.now() >= deadline) { - throw new Error(`Timed out waiting for workspace restore lock at ${lockDir}`); + const timeoutError: NodeJS.ErrnoException = new Error( + `Timed out waiting for workspace restore lock at ${lockDir}`, + ); + timeoutError.code = WORKSPACE_RESTORE_LOCK_TIMEOUT_CODE; + throw timeoutError; } await new Promise((resolve) => setTimeout(resolve, 50)); } } } +const DIRECTORY_MERGE_LOCK_ROOT_MODE = 0o700; + +function nonEmpty(value: string | undefined): string | null { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + +/** + * Resolves the private, instance-scoped root for every directory-merge lock: + * `/locks/directory-merge`. Every process that can mutate one + * target directory must resolve to the same `PAPERCLIP_HOME` and + * `PAPERCLIP_INSTANCE_ID`. That shared resolution is what keeps mutual + * exclusion true for all five callers of `withDirectoryMergeLock`, including + * the three Codex credential call sites that never touch a workspace. + * + * This never falls back to `os.tmpdir()` and never places the lock beside the + * target directory: both paths funnel through this one instance-scoped root, + * so a read-only target parent (the workspace-restore bug) cannot block a + * lock acquisition. + * + * The root reads `PAPERCLIP_HOME` and `PAPERCLIP_INSTANCE_ID` from `env`, so an + * environment-parameterized caller (a Codex credential call site that builds + * its own `env` object instead of reading `process.env`) resolves its lock + * root under the same instance root as the directory it protects. This never + * reads `process.env` when the caller passes an `env`: every fallback inside + * the resolver also reads from that same `env` object. A caller that omits + * `env` gets `process.env`, which keeps the resolution unchanged for the + * workspace-restore call site. + * + * The root is validated, not trusted: `lstat` rejects a symlink and rejects + * any non-directory before use (fail closed). `fs.mkdir` does not change the + * mode of a directory that already exists, so an existing valid directory + * keeps whatever mode it already has; only a freshly created root gets mode + * `0o700`. + * + * The existence check and the `mkdir` below are two separate calls, so a + * racing writer can plant a symlink at `lockRoot` in between them. `fs.mkdir` + * with `recursive: true` does not fail on a leaf that already exists as a + * symlink to a real directory, so a successful `mkdir` call alone does not + * prove the path is a plain directory. The `lstat` after `mkdir` closes that + * window: it validates what is actually at `lockRoot` (never a `stat`, which + * would follow the symlink) before any caller treats it as the lock root. + */ +async function resolveDirectoryMergeLockRoot(env: NodeJS.ProcessEnv = process.env): Promise { + const instanceRoot = resolvePaperclipInstanceRootForAdapter({ + homeDir: nonEmpty(env.PAPERCLIP_HOME) ?? undefined, + instanceId: nonEmpty(env.PAPERCLIP_INSTANCE_ID) ?? undefined, + env, + }); + const lockRoot = path.join(instanceRoot, "locks", "directory-merge"); + const existing = await fs.lstat(lockRoot).catch((error: NodeJS.ErrnoException) => { + if (error.code === "ENOENT") return null; + throw error; + }); + if (existing) { + if (existing.isSymbolicLink() || !existing.isDirectory()) { + throw new Error(`Directory merge lock root at ${lockRoot} is not a plain directory.`); + } + return lockRoot; + } + await fs.mkdir(lockRoot, { recursive: true, mode: DIRECTORY_MERGE_LOCK_ROOT_MODE }); + const created = await fs.lstat(lockRoot); + if (created.isSymbolicLink() || !created.isDirectory()) { + throw new Error(`Directory merge lock root at ${lockRoot} is not a plain directory.`); + } + return lockRoot; +} + export async function withDirectoryMergeLock( targetDir: string, - fn: () => Promise, + fn: (canonicalTargetDir: string) => Promise, + env: NodeJS.ProcessEnv = process.env, ): Promise { - const releaseLock = await acquireDirectoryMergeLock(`${targetDir}.paperclip-restore.lock`); + // Canonicalize before we hash or lock: a retargeted symlink must not let the + // lock protect one directory while the caller mutates another. + const canonicalTargetDir = await fs.realpath(targetDir); + const lockRoot = await resolveDirectoryMergeLockRoot(env); + const lockKey = createHash("sha256").update(canonicalTargetDir).digest("hex"); + const releaseLock = await acquireDirectoryMergeLock(path.join(lockRoot, `${lockKey}.lock`)); try { - return await fn(); + return await fn(canonicalTargetDir); } finally { await releaseLock(); } @@ -227,16 +366,16 @@ export async function mergeDirectoryWithBaseline(input: { afterApply?: () => Promise; }): Promise { const source = await captureDirectorySnapshot(input.sourceDir, { exclude: input.baseline.exclude }); - await withDirectoryMergeLock(input.targetDir, async () => { + await withDirectoryMergeLock(input.targetDir, async (canonicalTargetDir) => { await input.beforeApply?.(); - const current = await captureDirectorySnapshot(input.targetDir, { exclude: input.baseline.exclude }); + const current = await captureDirectorySnapshot(canonicalTargetDir, { exclude: input.baseline.exclude }); const deletedLeafEntries = [...input.baseline.entries.entries()] .filter(([relative, entry]) => entry.kind !== "dir" && !source.entries.has(relative)) .sort(([left], [right]) => right.length - left.length); for (const [relative, baselineEntry] of deletedLeafEntries) { if (!entriesMatch(current.entries.get(relative), baselineEntry)) continue; - await fs.rm(path.join(input.targetDir, relative), { recursive: true, force: true }).catch(() => undefined); + await fs.rm(path.join(canonicalTargetDir, relative), { recursive: true, force: true }).catch(() => undefined); } const deletedDirs = [...input.baseline.entries.entries()] @@ -244,7 +383,7 @@ export async function mergeDirectoryWithBaseline(input: { .sort(([left], [right]) => right.length - left.length); for (const [relative] of deletedDirs) { - await fs.rmdir(path.join(input.targetDir, relative)).catch(() => undefined); + await fs.rmdir(path.join(canonicalTargetDir, relative)).catch(() => undefined); } const changedSourceEntries = [...source.entries.entries()] @@ -252,7 +391,7 @@ export async function mergeDirectoryWithBaseline(input: { .sort(([left], [right]) => left.localeCompare(right)); for (const [relative, entry] of changedSourceEntries) { - await copySnapshotEntry(input.sourceDir, input.targetDir, relative, entry); + await copySnapshotEntry(input.sourceDir, canonicalTargetDir, relative, entry); } await input.afterApply?.(); diff --git a/packages/adapters/claude-local/src/server/acp.test.ts b/packages/adapters/claude-local/src/server/acp.test.ts index a58fbc66b9c..0406d345a5f 100644 --- a/packages/adapters/claude-local/src/server/acp.test.ts +++ b/packages/adapters/claude-local/src/server/acp.test.ts @@ -703,6 +703,92 @@ describe("claude_local ACP lane", () => { await expect(fs.readFile(path.join(localCwd, "from-sandbox.txt"), "utf8")).resolves.toBe("synced"); }); + it("test_claude_acp_teardown_restore_failure_sanitizes_the_run_log", async () => { + // Security regression for a workspace-restore write failure: the run log + // is readable by any same-company actor, so the teardown must never write + // the caught error's own message there — that message can carry the host + // workspace path. Force a real EACCES by making the workspace read-only, + // and name it with a sentinel marker so any leak is easy to spot. + const root = await makeTempRoot("paperclip-claude-acp-restore-failure-"); + const localCwd = path.join(root, "SENTINEL-HOST-PATH-marker", "worktree"); + const remoteCwd = path.join(root, "remote-workspace"); + await fs.mkdir(localCwd, { recursive: true }); + await fs.mkdir(remoteCwd, { recursive: true }); + await fs.writeFile(path.join(localCwd, "hello.txt"), "hi", "utf8"); + process.env.PAPERCLIP_HOME = path.join(root, "paperclip-home"); + process.env.PAPERCLIP_INSTANCE_ID = "test"; + + // The runtime writes a new file into the in-sandbox workspace during the + // turn, so the teardown's restore has something to copy back — and a new + // file is exactly what a read-only workspace directory rejects. The + // workspace turns read-only only after the turn's own writes (settings + // seeded at startup, the sandbox-authored file) — the teardown restore + // that runs after the turn is the write this test forces to fail. + const runtime = new FakeRuntime({}); + const startTurn = runtime.startTurn.bind(runtime); + runtime.startTurn = (input) => { + const turn = startTurn(input); + const remoteWorkspaceCwd = input.handle.cwd ?? remoteCwd; + return { + ...turn, + result: (async () => { + await fs.writeFile(path.join(remoteWorkspaceCwd, "from-sandbox.txt"), "synced", "utf8"); + await fs.chmod(localCwd, 0o500); + return await turn.result; + })(), + }; + }; + + const execute = createClaudeAcpExecutor({ + createRuntime: (options: FakeRuntimeOptions) => { + Object.assign(runtime.options, options); + return runtime as never; + }, + }); + + const loggedLines: string[] = []; + try { + const result = await execute( + buildContext(localCwd, { + config: { + engine: "acp", + cwd: localCwd, + agentCommand: "node ./fake-acp.js", + stateDir: path.join(root, "state"), + promptTemplate: "Do the assigned work.", + }, + context: { + issueId: "issue-1", + paperclipWorkspace: { cwd: localCwd, source: "project_workspace", workspaceId: "workspace-1" }, + }, + executionTarget: { + kind: "remote", + transport: "sandbox", + providerKey: "fake-plugin", + remoteCwd, + runner: createLocalSandboxRunner(), + } as never, + authToken: "real-run-jwt", + onLog: async (_stream, chunk) => { + loggedLines.push(chunk); + }, + }), + ); + + // Fail-open: the restore miss never changes the run's exit code or + // status, and it surfaces as one allowlisted code — never the raw error. + expect(result.exitCode).toBe(0); + expect(result.resultJson?.workspaceRestoreFailure).toBe("restore_permission_denied"); + const allLogs = loggedLines.join(""); + expect(allLogs).not.toContain("SENTINEL-HOST-PATH-marker"); + expect(allLogs).not.toContain(localCwd); + expect(allLogs).not.toContain("EACCES"); + expect(allLogs).toContain("permission denied"); + } finally { + await fs.chmod(localCwd, 0o700).catch(() => undefined); + } + }); + it("remaps a workspace-relative explicit CLAUDE_CONFIG_DIR onto the in-sandbox workspace path", async () => { const root = await makeTempRoot("paperclip-claude-acp-explicit-inworkspace-"); const localCwd = path.join(root, "worktree"); diff --git a/packages/adapters/claude-local/src/server/acp.ts b/packages/adapters/claude-local/src/server/acp.ts index 721e2c39ab7..d064cfae520 100644 --- a/packages/adapters/claude-local/src/server/acp.ts +++ b/packages/adapters/claude-local/src/server/acp.ts @@ -48,6 +48,10 @@ import { classifyThrownErrorClass, logSandboxProbeDiagnostic, } from "./probe-diagnostics.js"; +import { + classifyWorkspaceRestoreFailure, + describeWorkspaceRestoreFailure, +} from "@paperclipai/adapter-utils/workspace-restore-merge"; import { buildLocalAdapterTestProbeEnv } from "./probe-env.js"; import { detectClaudeLoginRequired, parseClaudeStreamJson } from "./parse.js"; import { buildClaudeProbePermissionArgs } from "./permissions.js"; @@ -219,13 +223,18 @@ async function prepareClaudeRemoteManagedHome( try { await onLog("stdout", "[paperclip] Restoring workspace changes from the sandbox.\n"); await stagedRuntime.restoreWorkspace((line) => onLog("stdout", line)); + return { ok: true }; } catch (err) { + // The run log is readable by any same-company actor, so it must never + // carry the caught error's own message: that message can hold a host + // filesystem path or a process id. Log only the fixed, allowlisted + // diagnostic for the classified code. + const code = classifyWorkspaceRestoreFailure(err); await onLog( "stderr", - `[paperclip] Claude ACP teardown workspace restore failed: ${ - err instanceof Error ? err.message : String(err) - }\n`, + `[paperclip] Claude ACP teardown workspace restore failed: ${describeWorkspaceRestoreFailure(code)}\n`, ); + return { ok: false, code }; } }; const envConfig = parseObject(input.config.env); diff --git a/packages/adapters/codex-local/src/server/acp.test.ts b/packages/adapters/codex-local/src/server/acp.test.ts index 37f547324f1..6ce672ae98f 100644 --- a/packages/adapters/codex-local/src/server/acp.test.ts +++ b/packages/adapters/codex-local/src/server/acp.test.ts @@ -1135,6 +1135,101 @@ describe("codex_local ACP lane", () => { expect(hostAuth.tokens.refresh_token).toBe("ref-sandbox-newer"); }); + it("test_codex_acp_teardown_restore_failure_sanitizes_the_run_log", async () => { + // Security regression for a workspace-restore write failure: the run log + // is readable by any same-company actor, so the teardown must never write + // the caught error's own message there — that message can carry the host + // workspace path. Force a real EACCES by making the workspace read-only, + // and name it with a sentinel marker so any leak is easy to spot. + const runId = "run-restore-failure"; + const root = await makeTempRoot("paperclip-codex-acp-restore-failure-"); + const localCwd = path.join(root, "SENTINEL-HOST-PATH-marker", "worktree"); + const remoteCwd = path.join(root, "remote-workspace"); + const sourceHome = path.join(root, "codex-home"); + const sharedHostHome = path.join(root, "shared-codex-home"); + await fs.mkdir(localCwd, { recursive: true }); + await fs.mkdir(remoteCwd, { recursive: true }); + await fs.mkdir(sourceHome, { recursive: true }); + await fs.mkdir(sharedHostHome, { recursive: true }); + await fs.writeFile(path.join(localCwd, "hello.txt"), "hi", "utf8"); + process.env.CODEX_HOME = sharedHostHome; + + // The runtime writes a new file into the in-sandbox workspace during the + // turn, so the teardown's restore has something to copy back — and a new + // file is exactly what a read-only workspace directory rejects. The + // workspace turns read-only only after the turn's own writes, so the + // teardown restore that runs after the turn is the write this forces to + // fail. + const runtime = new FakeRuntime({}); + const startTurn = runtime.startTurn.bind(runtime); + runtime.startTurn = (input) => { + const turn = startTurn(input); + const remoteWorkspaceCwd = input.handle.cwd ?? remoteCwd; + return { + ...turn, + result: (async () => { + await fs.writeFile(path.join(remoteWorkspaceCwd, "from-sandbox.txt"), "synced", "utf8"); + await fs.chmod(localCwd, 0o500); + return await turn.result; + })(), + }; + }; + + const stagedRuntimes = new Map(); + const execute = createCodexAcpExecutor({ + createRuntime: (options: FakeRuntimeOptions) => { + Object.assign(runtime.options, options); + return runtime as never; + }, + stagedRuntimes, + stagingLocks: new Map(), + }); + + const loggedLines: string[] = []; + try { + const result = await execute( + buildContext(localCwd, { + runId, + config: { + engine: "acp", + cwd: localCwd, + agentCommand: "node ./fake-acp.js", + stateDir: path.join(root, "state"), + env: { CODEX_HOME: sourceHome }, + promptTemplate: "Do the assigned work.", + }, + context: { + issueId: "issue-1", + paperclipWorkspace: { cwd: localCwd, source: "project_workspace", workspaceId: "workspace-1" }, + }, + executionTarget: { + kind: "remote", + transport: "sandbox", + providerKey: "fake-plugin", + remoteCwd, + runner: createLocalSandboxRunner(), + } as never, + authToken: "real-run-jwt", + onLog: async (_stream, chunk) => { + loggedLines.push(chunk); + }, + }), + ); + + // Fail-open: the restore miss never changes the run's exit code or + // status, and it surfaces as one allowlisted code — never the raw error. + expect(result.exitCode).toBe(0); + expect(result.resultJson?.workspaceRestoreFailure).toBe("restore_permission_denied"); + const allLogs = loggedLines.join(""); + expect(allLogs).not.toContain("SENTINEL-HOST-PATH-marker"); + expect(allLogs).not.toContain(localCwd); + expect(allLogs).not.toContain("EACCES"); + expect(allLogs).toContain("permission denied"); + } finally { + await fs.chmod(localCwd, 0o700).catch(() => undefined); + } + }); + it("falls back to the CLI lane for a runner-less sandbox even when the ACP command is set", async () => { setNodeVersion("v24.11.0"); // Isolate the missing bidirectional runner as the sole fallback cause: diff --git a/packages/adapters/codex-local/src/server/acp.ts b/packages/adapters/codex-local/src/server/acp.ts index 1c58d836e3e..9bd45142eed 100644 --- a/packages/adapters/codex-local/src/server/acp.ts +++ b/packages/adapters/codex-local/src/server/acp.ts @@ -35,6 +35,10 @@ import { asString, parseObject, } from "@paperclipai/adapter-utils/server-utils"; +import { + classifyWorkspaceRestoreFailure, + describeWorkspaceRestoreFailure, +} from "@paperclipai/adapter-utils/workspace-restore-merge"; import { normalizeCodexModel } from "../index.js"; import { classifyCodexAuthRefreshFailure } from "./parse.js"; import { copyBackCodexAuth } from "./codex-auth-copyback.js"; @@ -243,17 +247,23 @@ async function prepareCodexRemoteManagedHome( "[paperclip] Restoring workspace changes and Codex auth from the sandbox.\n", ); await stagedRuntime.restoreWorkspace((line) => onLog("stdout", line)); + return { ok: true }; } catch (err) { // Fail-soft: a teardown copy-back miss loses this rotation and surfaces // loudly as refresh_token_reused on the next host Codex use (re-auth // recovers) — never silent host-credential corruption, so it must not // mask the run result. + // + // The run log is readable by any same-company actor, so it must never + // carry the caught error's own message: that message can hold a host + // filesystem path or a process id. Log only the fixed, allowlisted + // diagnostic for the classified code. + const code = classifyWorkspaceRestoreFailure(err); await onLog( "stderr", - `[paperclip] Codex ACP teardown restore/copy-back failed: ${ - err instanceof Error ? err.message : String(err) - }\n`, + `[paperclip] Codex ACP teardown restore/copy-back failed: ${describeWorkspaceRestoreFailure(code)}\n`, ); + return { ok: false, code }; } }, // One-time cleanup of the HOST staged home temp dir. Fired ONLY when the diff --git a/packages/adapters/codex-local/src/server/adapter-auth-promotion.ts b/packages/adapters/codex-local/src/server/adapter-auth-promotion.ts index 1bc6c78384b..27d4a012fc5 100644 --- a/packages/adapters/codex-local/src/server/adapter-auth-promotion.ts +++ b/packages/adapters/codex-local/src/server/adapter-auth-promotion.ts @@ -224,6 +224,7 @@ export async function promoteDeviceLoginCredential( keptLine: "[paperclip] Codex device-login promotion: kept the company credential home (the login is not a seed or a strictly-newer same-identity credential).", tempPrefix: "auth.json.promotion-home", errorLabel: "codex device-login promotion", + env, }); // A kept home has two very different meanings. The writer keeps the home when @@ -258,7 +259,7 @@ export async function promoteDeviceLoginCredential( if (isCodexAuthCacheEnabled(env)) { try { const cacheEntryPath = await ensureCodexAuthCacheEntryDir(env, accountId, companyId); - await writeCodexAuthCacheEntry({ sandboxAuthBytes: authBytes, cacheEntryPath, log }); + await writeCodexAuthCacheEntry({ sandboxAuthBytes: authBytes, cacheEntryPath, log, env }); } catch { await log( "[paperclip] Codex device-login promotion: the per-identity cache write failed; the company credential home is durable, so the login stays successful.", diff --git a/packages/adapters/codex-local/src/server/codex-auth-cache.test.ts b/packages/adapters/codex-local/src/server/codex-auth-cache.test.ts index f3818ecd8ac..014093bd653 100644 --- a/packages/adapters/codex-local/src/server/codex-auth-cache.test.ts +++ b/packages/adapters/codex-local/src/server/codex-auth-cache.test.ts @@ -204,7 +204,7 @@ describe("codex auth cache store", () => { "acct-x": subscriptionAuth({ accountId: "acct-x", lastRefresh: NEWER, marker: "cache" }), }, }); - const outcome = await selectVendCredential(sharedHomeAuthPath, resolveEntry(env), () => undefined); + const outcome = await selectVendCredential(sharedHomeAuthPath, resolveEntry(env), () => undefined, env); expect(outcome).toBe("vended"); const finalHost = await readFile(sharedHomeAuthPath, "utf8"); expect(finalHost).toContain("acct-x"); @@ -221,7 +221,7 @@ describe("codex auth cache store", () => { }, }); const before = await readFile(sharedHomeAuthPath, "utf8"); - const outcome = await selectVendCredential(sharedHomeAuthPath, resolveEntry(env), () => undefined); + const outcome = await selectVendCredential(sharedHomeAuthPath, resolveEntry(env), () => undefined, env); expect(outcome).toBe("kept-host"); expect(await readFile(sharedHomeAuthPath, "utf8")).toBe(before); } @@ -235,7 +235,7 @@ describe("codex auth cache store", () => { }, }); const before = await readFile(sharedHomeAuthPath, "utf8"); - const outcome = await selectVendCredential(sharedHomeAuthPath, resolveEntry(env), () => undefined); + const outcome = await selectVendCredential(sharedHomeAuthPath, resolveEntry(env), () => undefined, env); expect(outcome).toBe("kept-host"); expect(await readFile(sharedHomeAuthPath, "utf8")).toBe(before); }); @@ -246,7 +246,7 @@ describe("codex auth cache store", () => { "acct-y": subscriptionAuth({ accountId: "acct-y", lastRefresh: NEWER, marker: "other" }), }, }); - const outcome = await selectVendCredential(sharedHomeAuthPath, resolveEntry(env), () => undefined); + const outcome = await selectVendCredential(sharedHomeAuthPath, resolveEntry(env), () => undefined, env); expect(outcome).toBe("no-host-identity"); await expect(lstat(sharedHomeAuthPath)).rejects.toThrow(); }); @@ -259,7 +259,7 @@ describe("codex auth cache store", () => { }, }); const before = await readFile(sharedHomeAuthPath, "utf8"); - const outcome = await selectVendCredential(sharedHomeAuthPath, resolveEntry(env), () => undefined); + const outcome = await selectVendCredential(sharedHomeAuthPath, resolveEntry(env), () => undefined, env); expect(outcome).toBe("no-host-identity"); expect(await readFile(sharedHomeAuthPath, "utf8")).toBe(before); }); @@ -272,9 +272,14 @@ describe("codex auth cache store", () => { }, }); const logs: string[] = []; - await selectVendCredential(sharedHomeAuthPath, resolveEntry(env), (line) => { - logs.push(line); - }); + await selectVendCredential( + sharedHomeAuthPath, + resolveEntry(env), + (line) => { + logs.push(line); + }, + env, + ); const combined = logs.join("\n"); expect(combined).not.toContain("SENTINEL"); expect(combined).not.toContain("SECRET-ACCT"); diff --git a/packages/adapters/codex-local/src/server/codex-auth-cache.ts b/packages/adapters/codex-local/src/server/codex-auth-cache.ts index cf33c17f876..5d9755a2787 100644 --- a/packages/adapters/codex-local/src/server/codex-auth-cache.ts +++ b/packages/adapters/codex-local/src/server/codex-auth-cache.ts @@ -220,6 +220,8 @@ export async function writeCodexAuthCacheEntry(input: { sandboxAuthBytes: Buffer; cacheEntryPath: string; log: (line: string) => void | Promise; + /** Environment for the merge lock root. Defaults to `process.env`. */ + env?: NodeJS.ProcessEnv; }): Promise { const outcome = await writeCredentialSeedOrNewer({ sourceBytes: input.sandboxAuthBytes, @@ -231,6 +233,7 @@ export async function writeCodexAuthCacheEntry(input: { "[paperclip] Codex auth cache: kept the cache slot (source is not a strictly-newer same-identity subscription credential).", tempPrefix: "auth.json.cache-source", errorLabel: "codex auth cache", + env: input.env, }); return outcome === "written" ? "written" : "kept-slot"; } @@ -258,6 +261,7 @@ export async function selectVendCredential( sharedHomeAuthPath: string, resolveCacheEntryPath: (accountId: string) => string, log: (line: string) => void | Promise, + env: NodeJS.ProcessEnv = process.env, ): Promise { const hostBytes = await readFile(sharedHomeAuthPath).catch((error: NodeJS.ErrnoException) => { if (error.code === "ENOENT") return null; @@ -322,7 +326,7 @@ export async function selectVendCredential( await handle.close().catch(() => undefined); await rm(stagedTempPath, { force: true }).catch(() => undefined); } - }); + }, env); } /** @@ -345,9 +349,13 @@ export async function clearCodexAuthCacheEntry( throw error; }); if (!existing) return; - await withDirectoryMergeLock(entryDir, async () => { - await rm(entryDir, { recursive: true, force: true }); - }); + await withDirectoryMergeLock( + entryDir, + async () => { + await rm(entryDir, { recursive: true, force: true }); + }, + env, + ); } /** diff --git a/packages/adapters/codex-local/src/server/codex-auth-copyback.ts b/packages/adapters/codex-local/src/server/codex-auth-copyback.ts index 8206e7d1b29..96d29f3e208 100644 --- a/packages/adapters/codex-local/src/server/codex-auth-copyback.ts +++ b/packages/adapters/codex-local/src/server/codex-auth-copyback.ts @@ -96,43 +96,47 @@ export async function copyBackCodexAuth(input: CopyBackCodexAuthInput): Promise< const hostDir = path.dirname(hostAuthPath); await mkdir(hostDir, { recursive: true }); - const hostOutcome = await withDirectoryMergeLock(hostDir, async () => { - // Stage on the same filesystem as the host target so both the predicate read - // and the final rename stay device-local (rename across devices is not - // atomic and would fail with EXDEV). - const stagedTempPath = path.join(hostDir, `.auth.json.copyback-${process.pid}-${randomUUID()}.tmp`); - // `wx` + explicit mode create the temp private (0600) and fail if it somehow - // already exists, so we never write through a pre-existing symlink. - const handle = await open(stagedTempPath, "wx", 0o600); - try { - await handle.writeFile(sandboxAuthBytes); - await handle.close(); + const hostOutcome = await withDirectoryMergeLock( + hostDir, + async () => { + // Stage on the same filesystem as the host target so both the predicate read + // and the final rename stay device-local (rename across devices is not + // atomic and would fail with EXDEV). + const stagedTempPath = path.join(hostDir, `.auth.json.copyback-${process.pid}-${randomUUID()}.tmp`); + // `wx` + explicit mode create the temp private (0600) and fail if it somehow + // already exists, so we never write through a pre-existing symlink. + const handle = await open(stagedTempPath, "wx", 0o600); + try { + await handle.writeFile(sandboxAuthBytes); + await handle.close(); + + const decision = await decideCodexAuthMerge(stagedTempPath, hostAuthPath, { + errorLabel: "codex auth copy-back", + }); + if (decision === USE_SOURCE_EXIT) { + // Atomic same-directory swap; rename preserves the temp's 0600 mode. + await rename(stagedTempPath, hostAuthPath); + await log( + "[paperclip] Codex auth copy-back: sandbox credential is strictly newer for the same subscription identity; installed to the host at mode 0600.", + ); + return "copied"; + } - const decision = await decideCodexAuthMerge(stagedTempPath, hostAuthPath, { - errorLabel: "codex auth copy-back", - }); - if (decision === USE_SOURCE_EXIT) { - // Atomic same-directory swap; rename preserves the temp's 0600 mode. - await rename(stagedTempPath, hostAuthPath); await log( - "[paperclip] Codex auth copy-back: sandbox credential is strictly newer for the same subscription identity; installed to the host at mode 0600.", + "[paperclip] Codex auth copy-back: host credential kept (sandbox copy is not a strictly-newer same-identity subscription credential).", ); - return "copied"; + return "kept-host"; + } finally { + // Close is idempotent-safe to skip after an explicit close; the temp is the + // thing that must never linger. On the copy path rename already consumed it + // (force makes the removal a no-op); on every other path this deletes the + // staged credential bytes. + await handle.close().catch(() => undefined); + await rm(stagedTempPath, { force: true }).catch(() => undefined); } - - await log( - "[paperclip] Codex auth copy-back: host credential kept (sandbox copy is not a strictly-newer same-identity subscription credential).", - ); - return "kept-host"; - } finally { - // Close is idempotent-safe to skip after an explicit close; the temp is the - // thing that must never linger. On the copy path rename already consumed it - // (force makes the removal a no-op); on every other path this deletes the - // staged credential bytes. - await handle.close().catch(() => undefined); - await rm(stagedTempPath, { force: true }).catch(() => undefined); - } - }); + }, + env, + ); // Additive cache write. Independent of the host default overwrite above: it // runs on its own directory lock, keys the slot by the real sandbox @@ -150,7 +154,7 @@ export async function copyBackCodexAuth(input: CopyBackCodexAuthInput): Promise< const sandboxAccountId = readSubscriptionAccountId(sandboxAuthBytes); if (sandboxAccountId) { const cacheEntryPath = await resolveCacheEntryPath(sandboxAccountId); - await writeCodexAuthCacheEntry({ sandboxAuthBytes, cacheEntryPath, log }); + await writeCodexAuthCacheEntry({ sandboxAuthBytes, cacheEntryPath, log, env }); } } catch (error) { // Log only the errno code, never the error message. The message embeds the diff --git a/packages/adapters/codex-local/src/server/codex-auth-seed-write.ts b/packages/adapters/codex-local/src/server/codex-auth-seed-write.ts index aa675c00acf..a35b0504c09 100644 --- a/packages/adapters/codex-local/src/server/codex-auth-seed-write.ts +++ b/packages/adapters/codex-local/src/server/codex-auth-seed-write.ts @@ -33,6 +33,8 @@ export interface WriteCredentialSeedOrNewerInput { tempPrefix: string; /** The caller name that prefixes a predicate error. */ errorLabel: string; + /** Environment for the merge lock root. Defaults to `process.env`. */ + env?: NodeJS.ProcessEnv; } /** @@ -46,31 +48,35 @@ export async function writeCredentialSeedOrNewer( input: WriteCredentialSeedOrNewerInput, ): Promise { const destinationDir = path.dirname(input.destinationPath); - return withDirectoryMergeLock(destinationDir, async () => { - const stagedTempPath = path.join( - destinationDir, - `.${input.tempPrefix}-${process.pid}-${randomUUID()}.tmp`, - ); - // `wx` + explicit mode create the temp private (0600) and fail if it already - // exists, so the writer never writes through a pre-existing symlink. - const handle = await open(stagedTempPath, "wx", 0o600); - try { - await handle.writeFile(input.sourceBytes); - await handle.close(); - const decision = await decideCodexAuthMerge(stagedTempPath, input.destinationPath, { - seedIfDestAbsent: input.seedIfDestAbsent, - errorLabel: input.errorLabel, - }); - if (decision === USE_SOURCE_EXIT) { - await rename(stagedTempPath, input.destinationPath); - await input.log(input.writtenLine); - return "written"; + return withDirectoryMergeLock( + destinationDir, + async () => { + const stagedTempPath = path.join( + destinationDir, + `.${input.tempPrefix}-${process.pid}-${randomUUID()}.tmp`, + ); + // `wx` + explicit mode create the temp private (0600) and fail if it already + // exists, so the writer never writes through a pre-existing symlink. + const handle = await open(stagedTempPath, "wx", 0o600); + try { + await handle.writeFile(input.sourceBytes); + await handle.close(); + const decision = await decideCodexAuthMerge(stagedTempPath, input.destinationPath, { + seedIfDestAbsent: input.seedIfDestAbsent, + errorLabel: input.errorLabel, + }); + if (decision === USE_SOURCE_EXIT) { + await rename(stagedTempPath, input.destinationPath); + await input.log(input.writtenLine); + return "written"; + } + await input.log(input.keptLine); + return "kept"; + } finally { + await handle.close().catch(() => undefined); + await rm(stagedTempPath, { force: true }).catch(() => undefined); } - await input.log(input.keptLine); - return "kept"; - } finally { - await handle.close().catch(() => undefined); - await rm(stagedTempPath, { force: true }).catch(() => undefined); - } - }); + }, + input.env, + ); } diff --git a/packages/adapters/codex-local/src/server/device-login-export.ts b/packages/adapters/codex-local/src/server/device-login-export.ts index cdf258bba78..c281834f5f9 100644 --- a/packages/adapters/codex-local/src/server/device-login-export.ts +++ b/packages/adapters/codex-local/src/server/device-login-export.ts @@ -247,6 +247,7 @@ export async function installDeviceLoginCredential( sandboxAuthBytes, cacheEntryPath: authPath, log, + env, }); return outcome === "written" ? "seeded" : "kept"; } diff --git a/packages/adapters/codex-local/src/server/execute.ts b/packages/adapters/codex-local/src/server/execute.ts index 48fa6705d15..f2f0635ede7 100644 --- a/packages/adapters/codex-local/src/server/execute.ts +++ b/packages/adapters/codex-local/src/server/execute.ts @@ -665,6 +665,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise resolveCodexAuthCacheEntryPath(process.env, accountId, agent.companyId), diff --git a/packages/adapters/gemini-local/src/server/acp.test.ts b/packages/adapters/gemini-local/src/server/acp.test.ts index 27ce706f824..27ba27f7866 100644 --- a/packages/adapters/gemini-local/src/server/acp.test.ts +++ b/packages/adapters/gemini-local/src/server/acp.test.ts @@ -613,6 +613,90 @@ describe("gemini_local ACP lane", () => { await expect(fs.readFile(path.join(localCwd, "from-sandbox.txt"), "utf8")).resolves.toBe("synced"); }); + it("test_gemini_acp_teardown_restore_failure_sanitizes_the_run_log", async () => { + // Security regression for a workspace-restore write failure: the run log + // is readable by any same-company actor, so the teardown must never write + // the caught error's own message there — that message can carry the host + // workspace path. Force a real EACCES by making the workspace read-only, + // and name it with a sentinel marker so any leak is easy to spot. + const root = await makeTempRoot("paperclip-gemini-acp-restore-failure-"); + const localCwd = path.join(root, "SENTINEL-HOST-PATH-marker", "worktree"); + const remoteCwd = path.join(root, "remote-workspace"); + await fs.mkdir(localCwd, { recursive: true }); + await fs.mkdir(remoteCwd, { recursive: true }); + await fs.writeFile(path.join(localCwd, "hello.txt"), "hi", "utf8"); + + // The runtime writes a new file into the in-sandbox workspace during the + // turn, so the teardown's restore has something to copy back — and a new + // file is exactly what a read-only workspace directory rejects. The + // workspace turns read-only only after the turn's own writes, so the + // teardown restore that runs after the turn is the write this forces to + // fail. + const runtime = new FakeRuntime({}); + const startTurn = runtime.startTurn.bind(runtime); + runtime.startTurn = (input) => { + const turn = startTurn(input); + const remoteWorkspaceCwd = input.handle.cwd ?? remoteCwd; + return { + ...turn, + result: (async () => { + await fs.writeFile(path.join(remoteWorkspaceCwd, "from-sandbox.txt"), "synced", "utf8"); + await fs.chmod(localCwd, 0o500); + return await turn.result; + })(), + }; + }; + + const execute = createGeminiAcpExecutor({ + createRuntime: (options) => { + Object.assign(runtime.options, options); + return runtime as never; + }, + }); + + const loggedLines: string[] = []; + try { + const result = await execute( + buildContext(localCwd, { + config: { + engine: "acp", + cwd: localCwd, + agentCommand: "node ./fake-acp.js", + stateDir: path.join(root, "state"), + promptTemplate: "Do the assigned work.", + }, + context: { + issueId: "issue-1", + paperclipWorkspace: { cwd: localCwd, source: "project_workspace", workspaceId: "workspace-1" }, + }, + executionTarget: { + kind: "remote", + transport: "sandbox", + providerKey: "fake-plugin", + remoteCwd, + runner: createLocalSandboxRunner(), + } as never, + authToken: "real-run-jwt", + onLog: async (_stream, chunk) => { + loggedLines.push(chunk); + }, + }), + ); + + // Fail-open: the restore miss never changes the run's exit code or + // status, and it surfaces as one allowlisted code — never the raw error. + expect(result.exitCode).toBe(0); + expect(result.resultJson?.workspaceRestoreFailure).toBe("restore_permission_denied"); + const allLogs = loggedLines.join(""); + expect(allLogs).not.toContain("SENTINEL-HOST-PATH-marker"); + expect(allLogs).not.toContain(localCwd); + expect(allLogs).not.toContain("EACCES"); + expect(allLogs).toContain("permission denied"); + } finally { + await fs.chmod(localCwd, 0o700).catch(() => undefined); + } + }); + it("does not persist an api-key auth selector from a host-only credential", async () => { const root = await makeTempRoot("paperclip-gemini-acp-hostkey-"); const localCwd = path.join(root, "worktree"); diff --git a/packages/adapters/gemini-local/src/server/acp.ts b/packages/adapters/gemini-local/src/server/acp.ts index f169b458019..95d8db4b699 100644 --- a/packages/adapters/gemini-local/src/server/acp.ts +++ b/packages/adapters/gemini-local/src/server/acp.ts @@ -31,6 +31,10 @@ import { asString, parseObject, } from "@paperclipai/adapter-utils/server-utils"; +import { + classifyWorkspaceRestoreFailure, + describeWorkspaceRestoreFailure, +} from "@paperclipai/adapter-utils/workspace-restore-merge"; import { DEFAULT_GEMINI_LOCAL_MODEL } from "../index.js"; const moduleDir = path.dirname(fileURLToPath(import.meta.url)); @@ -166,13 +170,18 @@ async function prepareGeminiRemoteManagedHome( try { await onLog("stdout", "[paperclip] Restoring workspace changes from the sandbox.\n"); await stagedRuntime.restoreWorkspace((line) => onLog("stdout", line)); + return { ok: true }; } catch (err) { + // The run log is readable by any same-company actor, so it must never + // carry the caught error's own message: that message can hold a host + // filesystem path or a process id. Log only the fixed, allowlisted + // diagnostic for the classified code. + const code = classifyWorkspaceRestoreFailure(err); await onLog( "stderr", - `[paperclip] Gemini ACP teardown workspace restore failed: ${ - err instanceof Error ? err.message : String(err) - }\n`, + `[paperclip] Gemini ACP teardown workspace restore failed: ${describeWorkspaceRestoreFailure(code)}\n`, ); + return { ok: false, code }; } }; const geminiSkillsHome = resolveGeminiSkillsHome(input.config); From 802f2af154e68ed59d5012b44b78f36209a3d406 Mon Sep 17 00:00:00 2001 From: Nicky Leach Date: Tue, 25 Aug 2026 14:33:38 -0700 Subject: [PATCH 17/32] refactor(adapter-utils): delete the dead duplex body-chunk protocol code (#12186) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The adapter utilities package provides transport code for sandbox agents > - The retired `duplex_v1` broker no longer produces or consumes body-chunk frames > - Dead protocol code remains in the host codec, gateway copy, bridge options, and tests > - This pull request removes that dead code and keeps the READY handshake unchanged > - The benefit is a smaller transport surface with fewer unused paths to maintain ## Linked Issues or Issue Description **What existing behavior does this improve?** This change improves the adapter utilities code that supports sandbox duplex readiness and frame handling. **Subsystem affected** `packages/adapter-utils/` — sandbox transport codecs, execution targets, and callback bridge tests. **Current behavior** The repository keeps body-chunk frame types, validators, a body spool, decoder limits, and tests after the `duplex_v1` broker removal. No live producer or consumer uses this code. **Proposed behavior** Remove the unused body-chunk protocol code and retain the READY handshake, its strict checks, and its size limits. **Reason and benefit** The removal reduces dead code and keeps the host and embedded gateway paths easier to inspect. It adds no new behavior. **Breaking changes** The removed frame types now decode as `unknown_type`. The live readiness gate already ignores those frames. The READY handshake stays byte-for-byte compatible. **Additional context** This cleanup follows [PR #12171](https://github.com/paperclipai/paperclip/pull/12171), which removed the duplex broker. ## What Changed - Remove `duplex-body-spool.ts` and its test. - Remove unused body-chunk frame types, validators, decoder code, vectors, and limits. - Remove the unused `reassembledBody` option and decoder limit environment entry. - Remove the embedded gateway decoder copy and the unused frame type map. - Keep the READY handshake and its existing boundary tests unchanged in behavior. ## Verification - `pnpm -F @paperclip/adapter-utils typecheck` passes. - The duplex frame codec test passes with 30 tests. - The sandbox execution-target test passes with 136 tests. - The sandbox callback bridge test passes with 46 tests. - CI must confirm all required checks after it starts. ## Risks Low risk. The change removes code only. The READY handshake, HTTP/2 body path, and byte-ledger path remain unchanged. ## Model Used OpenAI Codex, GPT-5, tool use and code execution. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- .../src/duplex-body-spool.test.ts | 306 --------- .../adapter-utils/src/duplex-body-spool.ts | 596 ------------------ .../src/duplex-frame-codec.test.ts | 339 +--------- .../adapter-utils/src/duplex-frame-codec.ts | 378 +---------- .../src/duplex-frame-vectors.json | 438 +------------ .../src/execution-target-sandbox.test.ts | 269 ++------ .../adapter-utils/src/execution-target.ts | 33 +- .../src/sandbox-callback-bridge.test.ts | 6 +- .../src/sandbox-callback-bridge.ts | 264 +------- 9 files changed, 121 insertions(+), 2508 deletions(-) delete mode 100644 packages/adapter-utils/src/duplex-body-spool.test.ts delete mode 100644 packages/adapter-utils/src/duplex-body-spool.ts diff --git a/packages/adapter-utils/src/duplex-body-spool.test.ts b/packages/adapter-utils/src/duplex-body-spool.test.ts deleted file mode 100644 index 37307f2e67e..00000000000 --- a/packages/adapter-utils/src/duplex-body-spool.test.ts +++ /dev/null @@ -1,306 +0,0 @@ -import { promises as fs } from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; - -import { DUPLEX_FRAME_VERSION, type DuplexBodyChunkFrame } from "./duplex-frame-codec.js"; -import { - DuplexBodyReceiver, - type DuplexBodyReceiverConfig, - splitBodyIntoChunkFrames, - sweepStaleSpillDirectories, -} from "./duplex-body-spool.js"; - -// Read the whole reassembled body back, whatever path it took. A memory body -// returns its buffer; a spilled body streams from disk. The test uses this only -// to assert the round-trip content, never on a hot path. -async function readBodyBytes(body: { - spilled: boolean; - createReadStream: () => NodeJS.ReadableStream; -}): Promise { - const chunks: Buffer[] = []; - for await (const chunk of body.createReadStream()) { - chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); - } - return Buffer.concat(chunks); -} - -let spillRoot: string; - -beforeEach(async () => { - spillRoot = await fs.mkdtemp(path.join(os.tmpdir(), "duplex-spool-test-")); -}); - -afterEach(async () => { - await fs.rm(spillRoot, { recursive: true, force: true }).catch(() => undefined); -}); - -function makeReceiver(overrides: DuplexBodyReceiverConfig = {}): Promise { - return DuplexBodyReceiver.create({ spillRootDir: spillRoot, ...overrides }); -} - -const RAW = 8; - -// Feed a whole body through a receiver as an envelope plus chunk frames. Return -// the settled body handle. The caller pushes the chunks the split produces. -async function transfer( - receiver: DuplexBodyReceiver, - id: string, - body: Buffer, -): ReturnType { - const promise = receiver.begin(id, body.length); - const frames = splitBodyIntoChunkFrames(id, body, DUPLEX_FRAME_VERSION, RAW); - for (const frame of frames) { - const result = receiver.pushChunk(frame); - expect(result.ok).toBe(true); - } - return promise; -} - -describe("duplex body reassembler — round trip", () => { - it("reassembles a small body on the memory path", async () => { - const receiver = await makeReceiver({ spillThresholdBytes: 1024, rawChunkBytes: RAW }); - const source = Buffer.from("hello duplex body"); - const body = await transfer(receiver, "req-mem", source); - expect(body.spilled).toBe(false); - expect(body.byteCount).toBe(source.length); - expect(body.readMemory().equals(source)).toBe(true); - expect((await readBodyBytes(body)).equals(source)).toBe(true); - await body.dispose(); - }); - - it("reassembles a large body on the spill path without holding it in memory", async () => { - const receiver = await makeReceiver({ spillThresholdBytes: 16, rawChunkBytes: RAW }); - const source = Buffer.from("abcdefghijklmnopqrstuvwxyz0123456789ABCD"); // 40 bytes - const body = await transfer(receiver, "req-spill", source); - expect(body.spilled).toBe(true); - expect(body.byteCount).toBe(source.length); - // A spilled body never loads the whole body into memory on the receive side. - expect(() => body.readMemory()).toThrow(); - expect(receiver.spillUsedBytes).toBe(source.length); - expect(receiver.spillOpenFiles).toBe(1); - expect((await readBodyBytes(body)).equals(source)).toBe(true); - await body.dispose(); - // The dispose path unlinks the file and releases the reservation. - expect(receiver.spillUsedBytes).toBe(0); - expect(receiver.spillOpenFiles).toBe(0); - const entries = await fs.readdir(receiver.spillDir); - expect(entries).toHaveLength(0); - await receiver.destroy(); - }); - - it("completes a zero-length body at once and accepts no chunk", async () => { - const receiver = await makeReceiver({ spillThresholdBytes: 16, rawChunkBytes: RAW }); - const body = await receiver.begin("req-zero", 0); - expect(body.byteCount).toBe(0); - expect(body.readMemory().length).toBe(0); - const stray = receiver.pushChunk({ - version: DUPLEX_FRAME_VERSION, - type: "body_chunk", - id: "req-zero", - seq: 0, - data: Buffer.from("x").toString("base64"), - }); - expect(stray.ok).toBe(false); - if (!stray.ok) expect(stray.error.code).toBe("zero_body_has_chunk"); - await body.dispose(); - }); -}); - -describe("duplex body reassembler — rejection matrix", () => { - function chunk(id: string, seq: number, data: string): DuplexBodyChunkFrame { - return { version: DUPLEX_FRAME_VERSION, type: "body_chunk", id, seq, data }; - } - - it("rejects a duplicate seq", async () => { - const receiver = await makeReceiver({ spillThresholdBytes: 1024, rawChunkBytes: RAW }); - void receiver.begin("d", 16); - expect(receiver.pushChunk(chunk("d", 0, Buffer.alloc(RAW, 1).toString("base64"))).ok).toBe(true); - const dup = receiver.pushChunk(chunk("d", 0, Buffer.alloc(RAW, 2).toString("base64"))); - expect(dup.ok).toBe(false); - if (!dup.ok) expect(dup.error.code).toBe("seq_out_of_order"); - }); - - it("rejects a gap in the seq order", async () => { - const receiver = await makeReceiver({ spillThresholdBytes: 1024, rawChunkBytes: RAW }); - void receiver.begin("g", 24); - expect(receiver.pushChunk(chunk("g", 0, Buffer.alloc(RAW, 1).toString("base64"))).ok).toBe(true); - const gap = receiver.pushChunk(chunk("g", 2, Buffer.alloc(RAW, 2).toString("base64"))); - expect(gap.ok).toBe(false); - if (!gap.ok) expect(gap.error.code).toBe("seq_out_of_order"); - }); - - it("rejects a reordered seq", async () => { - const receiver = await makeReceiver({ spillThresholdBytes: 1024, rawChunkBytes: RAW }); - void receiver.begin("r", 24); - const reorder = receiver.pushChunk(chunk("r", 1, Buffer.alloc(RAW, 1).toString("base64"))); - expect(reorder.ok).toBe(false); - if (!reorder.ok) expect(reorder.error.code).toBe("seq_out_of_order"); - }); - - it("rejects non-canonical base64", async () => { - const receiver = await makeReceiver({ spillThresholdBytes: 1024, rawChunkBytes: RAW }); - const begun = receiver.begin("b", 8); - begun.catch(() => undefined); - // "AB==" carries non-zero bits in the final sextet, so it decodes to one - // byte and re-encodes to "AA==". The round-trip mismatch marks it not - // canonical. - const bad = receiver.pushChunk(chunk("b", 0, "AB==")); - expect(bad.ok).toBe(false); - if (!bad.ok) expect(bad.error.code).toBe("chunk_not_canonical_base64"); - }); - - it("rejects a short non-final chunk (truncation)", async () => { - const receiver = await makeReceiver({ spillThresholdBytes: 1024, rawChunkBytes: RAW }); - void receiver.begin("t", 24); - // A non-final chunk must be exactly RAW bytes. A 4-byte chunk that does not - // complete the 24-byte body is a truncation. - const truncation = receiver.pushChunk(chunk("t", 0, Buffer.alloc(4, 1).toString("base64"))); - expect(truncation.ok).toBe(false); - if (!truncation.ok) expect(truncation.error.code).toBe("chunk_wrong_size"); - }); - - it("rejects an empty non-final chunk", async () => { - const receiver = await makeReceiver({ spillThresholdBytes: 1024, rawChunkBytes: RAW }); - void receiver.begin("e", 16); - const empty = receiver.pushChunk(chunk("e", 0, "")); - expect(empty.ok).toBe(false); - if (!empty.ok) expect(empty.error.code).toBe("empty_chunk"); - }); - - it("rejects an overrun past the declared body size", async () => { - const receiver = await makeReceiver({ spillThresholdBytes: 1024, rawChunkBytes: RAW }); - // A 12-byte body takes one full RAW chunk, then a final chunk of 4 bytes. A - // second full RAW chunk overruns the declared size. - void receiver.begin("o", 12); - expect(receiver.pushChunk(chunk("o", 0, Buffer.alloc(RAW, 1).toString("base64"))).ok).toBe(true); - const overrun = receiver.pushChunk(chunk("o", 1, Buffer.alloc(RAW, 2).toString("base64"))); - expect(overrun.ok).toBe(false); - if (!overrun.ok) expect(overrun.error.code).toBe("body_overrun"); - }); - - it("rejects an over-size final chunk", async () => { - const receiver = await makeReceiver({ spillThresholdBytes: 1024, rawChunkBytes: RAW }); - void receiver.begin("os", 40); - const over = receiver.pushChunk(chunk("os", 0, Buffer.alloc(RAW + 4, 1).toString("base64"))); - expect(over.ok).toBe(false); - if (!over.ok) expect(over.error.code).toBe("chunk_wrong_size"); - }); - - it("rejects a chunk that arrives with no matching envelope", async () => { - const receiver = await makeReceiver({ spillThresholdBytes: 1024, rawChunkBytes: RAW }); - const orphan = receiver.pushChunk(chunk("missing", 0, Buffer.alloc(RAW, 1).toString("base64"))); - expect(orphan.ok).toBe(false); - if (!orphan.ok) expect(orphan.error.code).toBe("chunk_without_envelope"); - }); - - it("rejects a chunk that arrives after completion", async () => { - const receiver = await makeReceiver({ spillThresholdBytes: 1024, rawChunkBytes: RAW }); - const promise = receiver.begin("c", RAW); - expect(receiver.pushChunk(chunk("c", 0, Buffer.alloc(RAW, 1).toString("base64"))).ok).toBe(true); - await promise; - const late = receiver.pushChunk(chunk("c", 1, Buffer.alloc(RAW, 2).toString("base64"))); - expect(late.ok).toBe(false); - if (!late.ok) expect(late.error.code).toBe("chunk_after_completion"); - }); -}); - -describe("duplex body reassembler — spill caps fail closed", () => { - it("fails closed when a write exceeds the per-channel byte cap and unlinks the partial file", async () => { - // A 5-byte cap admits the first RAW=8 chunk write? No: reserve 8 > cap 5, so - // the first write fails closed. Use a cap that admits one chunk but not two. - const receiver = await makeReceiver({ - spillThresholdBytes: 4, - rawChunkBytes: RAW, - channelSpillByteCap: RAW + 2, - }); - const promise = receiver.begin("cap", 24); - expect(receiver.pushChunk({ version: DUPLEX_FRAME_VERSION, type: "body_chunk", id: "cap", seq: 0, data: Buffer.alloc(RAW, 1).toString("base64") }).ok).toBe(true); - // The second chunk write reserves past the byte cap. - receiver.pushChunk({ version: DUPLEX_FRAME_VERSION, type: "body_chunk", id: "cap", seq: 1, data: Buffer.alloc(RAW, 2).toString("base64") }); - await expect(promise).rejects.toMatchObject({ code: "spill_cap_exceeded" }); - // The partial file is gone and the reservation released. - expect(receiver.spillUsedBytes).toBe(0); - expect(receiver.spillOpenFiles).toBe(0); - const entries = await fs.readdir(receiver.spillDir); - expect(entries).toHaveLength(0); - await receiver.destroy(); - }); - - it("fails closed when the open-file cap is reached", async () => { - const receiver = await makeReceiver({ - spillThresholdBytes: 4, - rawChunkBytes: RAW, - channelSpillFileCap: 1, - }); - // The first spill body claims the one file slot and stays open. The channel - // dispose rejects this in-flight body, so attach the catch at once. - const first = receiver.begin("f1", 24); - first.catch(() => undefined); - expect(receiver.pushChunk({ version: DUPLEX_FRAME_VERSION, type: "body_chunk", id: "f1", seq: 0, data: Buffer.alloc(RAW, 1).toString("base64") }).ok).toBe(true); - // The second spill body cannot claim a file slot. - await expect(receiver.begin("f2", 24)).rejects.toMatchObject({ code: "spill_cap_exceeded" }); - expect(receiver.spillOpenFiles).toBe(1); - // The dispose of the in-flight first body rejects its begin promise with a - // terminal channel-closed error and frees the file slot. - await receiver.disposeBody("f1"); - await expect(first).rejects.toMatchObject({ code: "channel_closed" }); - expect(receiver.spillOpenFiles).toBe(0); - await receiver.destroy(); - }); -}); - -describe("duplex body reassembler — lifecycle cleanup", () => { - it("releases the reservation and unlinks the file on channel death", async () => { - const receiver = await makeReceiver({ spillThresholdBytes: 4, rawChunkBytes: RAW }); - // The channel death rejects this in-flight body, so attach the catch at once. - const dying = receiver.begin("death", 24); - dying.catch(() => undefined); - expect(receiver.pushChunk({ version: DUPLEX_FRAME_VERSION, type: "body_chunk", id: "death", seq: 0, data: Buffer.alloc(RAW, 1).toString("base64") }).ok).toBe(true); - // Wait for the write to land, so the file exists before the channel dies. - await new Promise((resolve) => setTimeout(resolve, 20)); - expect(receiver.spillOpenFiles).toBe(1); - await receiver.disposeAll(); - await expect(dying).rejects.toMatchObject({ code: "channel_closed" }); - expect(receiver.spillUsedBytes).toBe(0); - expect(receiver.spillOpenFiles).toBe(0); - const entries = await fs.readdir(receiver.spillDir); - expect(entries).toHaveLength(0); - await receiver.destroy(); - }); -}); - -describe("duplex body reassembler — spill file safety", () => { - it("creates the spill directory with mode 0700 and each spill file with mode 0600", async () => { - const receiver = await makeReceiver({ spillThresholdBytes: 4, rawChunkBytes: RAW }); - const dirStat = await fs.stat(receiver.spillDir); - expect(dirStat.mode & 0o777).toBe(0o700); - // The final destroy rejects this in-flight body, so attach the catch at once. - const pending = receiver.begin("mode", 8); - pending.catch(() => undefined); - receiver.pushChunk({ version: DUPLEX_FRAME_VERSION, type: "body_chunk", id: "mode", seq: 0, data: Buffer.alloc(RAW, 1).toString("base64") }); - await new Promise((resolve) => setTimeout(resolve, 20)); - const entries = await fs.readdir(receiver.spillDir); - expect(entries).toHaveLength(1); - // The file name is random, so no frame id rides the path. - expect(entries[0]).not.toContain("mode"); - const fileStat = await fs.stat(path.join(receiver.spillDir, entries[0])); - expect(fileStat.mode & 0o777).toBe(0o600); - await receiver.destroy(); - }); -}); - -describe("duplex body reassembler — startup sweep", () => { - it("removes a stale spill directory from a dead process but keeps a live one", async () => { - const deadPid = 999999; // A pid that is almost never alive. - const staleDir = path.join(spillRoot, `paperclip-duplex-spill-${deadPid}-stale`); - await fs.mkdir(staleDir); - await fs.writeFile(path.join(staleDir, "leftover.body"), "x"); - const liveDir = path.join(spillRoot, `paperclip-duplex-spill-${process.pid}-live`); - await fs.mkdir(liveDir); - await sweepStaleSpillDirectories(spillRoot); - await expect(fs.stat(staleDir)).rejects.toThrow(); - // The sweep keeps a directory that belongs to a live process. - await expect(fs.stat(liveDir)).resolves.toBeDefined(); - }); -}); diff --git a/packages/adapter-utils/src/duplex-body-spool.ts b/packages/adapter-utils/src/duplex-body-spool.ts deleted file mode 100644 index 576caca5afe..00000000000 --- a/packages/adapter-utils/src/duplex-body-spool.ts +++ /dev/null @@ -1,596 +0,0 @@ -/** - * Receive-side body reassembly and spill for the sandbox duplex channel. - * - * The duplex codec splits one request body or one response body into an envelope - * frame plus a sequence of `body_chunk` frames. This module reassembles those - * chunks back into one body. The receiver reads `bodyByteCount` from the envelope - * first, then chooses one of two paths before it reads the first chunk: - * - memory path: a body at or below the spill threshold stays in a memory - * buffer. - * - spill path: a body above the spill threshold streams to a temporary file, - * so the receiver never holds a whole large body in memory. - * - * The spill path bounds the disk a single channel can consume. A per-channel - * arena caps the bytes on disk and the number of open spill files. The receiver - * reserves a file slot and reserves bytes before it opens a file and before each - * write. A reserve that exceeds a cap fails closed: it rejects the body as a - * resource error and unlinks any partial file. - * - * The reassembler treats every frame as untrusted. It rejects a bad chunk as a - * loud, terminal protocol error: a non-canonical base64 payload, a wrong chunk - * size, an out-of-order `seq`, a total that does not equal `bodyByteCount`, or a - * chunk that arrives for a completed or unknown body. - */ - -import { randomBytes } from "node:crypto"; -import { createReadStream } from "node:fs"; -import { promises as fs } from "node:fs"; -import type { FileHandle } from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import { Readable } from "node:stream"; - -import { - DUPLEX_BODY_CHUNK_RAW_BYTES, - type DuplexBodyChunkFrame, -} from "./duplex-frame-codec.js"; - -/** - * The default spill threshold, in bytes. A body at or below this size stays in - * memory. A body above it spills to disk. The value is 1 MiB. It stays - * injectable so a test can lower it and exercise the spill path. - */ -export const DEFAULT_DUPLEX_BODY_SPILL_THRESHOLD_BYTES = 1024 * 1024; - -/** - * The default per-channel spill byte cap. The receiver keeps at most this many - * bytes on disk at one time for one channel. The value is 64 MiB. It is about - * 0.64% of the default 10 GiB sandbox disk and stays far below the security - * ceiling of 25% of the disk quota. It stays injectable so a test can lower it. - */ -export const DEFAULT_DUPLEX_CHANNEL_SPILL_BYTE_CAP = 64 * 1024 * 1024; - -/** - * The default per-channel open-file cap. The receiver keeps at most this many - * spill files open at one time for one channel. The value derives from the byte - * cap and the spill threshold: 64 MiB divided by the 1 MiB spill floor gives 64. - * It stays injectable so a test can lower it. - */ -export const DEFAULT_DUPLEX_CHANNEL_SPILL_FILE_CAP = 64; - -/** The prefix of every spill directory name. The startup sweep matches it. */ -const SPILL_DIR_PREFIX = "paperclip-duplex-spill-"; - -/** The config a caller passes to build a {@link DuplexBodyReceiver}. */ -export interface DuplexBodyReceiverConfig { - /** The spill threshold in bytes. Defaults to {@link DEFAULT_DUPLEX_BODY_SPILL_THRESHOLD_BYTES}. */ - spillThresholdBytes?: number; - /** The fixed raw chunk size in bytes. Defaults to {@link DUPLEX_BODY_CHUNK_RAW_BYTES}. */ - rawChunkBytes?: number; - /** The per-channel spill byte cap. Defaults to {@link DEFAULT_DUPLEX_CHANNEL_SPILL_BYTE_CAP}. */ - channelSpillByteCap?: number; - /** The per-channel open-file cap. Defaults to {@link DEFAULT_DUPLEX_CHANNEL_SPILL_FILE_CAP}. */ - channelSpillFileCap?: number; - /** The root directory that holds the spill directory. Defaults to the OS temp dir. */ - spillRootDir?: string; - /** A diagnostic sink for a non-fatal cleanup note. The receiver names no body id here. */ - logger?: (message: string) => void; -} - -/** The reason the reassembler rejected a body. Each reason is a terminal protocol error. */ -export type DuplexBodyErrorCode = - | "chunk_without_envelope" - | "chunk_after_completion" - | "seq_out_of_order" - | "chunk_not_canonical_base64" - | "chunk_wrong_size" - | "empty_chunk" - | "body_overrun" - | "zero_body_has_chunk" - | "spill_cap_exceeded" - | "spill_write_failed" - | "path_escape" - | "channel_closed"; - -/** A reassembly error. The receiver returns it; it never leaks a raw path or a raw provider string. */ -export class DuplexBodyError extends Error { - readonly code: DuplexBodyErrorCode; - constructor(code: DuplexBodyErrorCode, message: string) { - super(message); - this.name = "DuplexBodyError"; - this.code = code; - } -} - -/** - * One reassembled body. The memory path holds the whole body in a buffer. The - * spill path holds a temporary file. A caller reads the memory buffer, or opens - * a fresh read stream, then disposes the body once. - */ -export interface ReassembledBody { - /** The raw byte length of the body. */ - readonly byteCount: number; - /** True when the body spilled to disk. */ - readonly spilled: boolean; - /** - * The whole body as a buffer. The memory path returns it. The spill path - * throws, because the spill path must never load a whole large body into - * memory on the receive side. A caller streams a spilled body instead. - */ - readMemory(): Buffer; - /** A fresh read stream over the body. The memory path streams one buffer; the spill path streams the file. */ - createReadStream(): Readable; - /** Release the body. The spill path unlinks the file and releases the reservation exactly once. */ - dispose(): Promise; -} - -/** - * The per-channel arena that bounds the spill disk use. It tracks the bytes on - * disk and the open files, and it owns the spill directory. It hands out - * reservations and takes them back. The reservation math is synchronous, so a - * concurrent reserve never races past a cap. - */ -class SpillArena { - usedBytes = 0; - openFiles = 0; - constructor( - readonly dir: string, - private readonly byteCap: number, - private readonly fileCap: number, - ) {} - - /** Reserve one open-file slot. Return false when the file cap is already at the limit. */ - reserveFile(): boolean { - if (this.openFiles >= this.fileCap) return false; - this.openFiles += 1; - return true; - } - - /** Release one open-file slot. */ - releaseFile(): void { - if (this.openFiles > 0) this.openFiles -= 1; - } - - /** Reserve `n` bytes of disk. Return false when the byte cap would be exceeded. */ - reserveBytes(n: number): boolean { - if (this.usedBytes + n > this.byteCap) return false; - this.usedBytes += n; - return true; - } - - /** Release `n` bytes of disk. */ - releaseBytes(n: number): void { - this.usedBytes -= n; - if (this.usedBytes < 0) this.usedBytes = 0; - } -} - -/** The internal state of one in-flight body. */ -interface ActiveBody { - bodyByteCount: number; - nextSeq: number; - receivedBytes: number; - spilled: boolean; - /** Memory-path accumulation. Empty for the spill path. */ - memoryChunks: Buffer[]; - /** The spill file path. Null for the memory path. */ - filePath: string | null; - /** The open spill file handle. Null for the memory path or before the first write. */ - handle: FileHandle | null; - /** The bytes this body reserved in the arena. The dispose path releases exactly this many. */ - reservedBytes: number; - /** True when the body holds an arena file slot that the dispose path must release. */ - holdsFileSlot: boolean; - /** True once the dispose path ran, so it runs its release once. */ - disposed: boolean; - /** Resolve the completion promise the caller awaits. */ - resolve: (body: ReassembledBody) => void; - /** Reject the completion promise on a terminal error. */ - reject: (error: DuplexBodyError) => void; - /** Serialize the spill writes, so the chunks land on disk in `seq` order. */ - writeChain: Promise; - /** True once the body settled (resolved or rejected), so a late chunk never double-settles. */ - settled: boolean; -} - -/** The synchronous result of one `pushChunk` call. */ -export type DuplexBodyPushResult = - | { ok: true } - | { ok: false; error: DuplexBodyError }; - -/** - * A per-channel receive-side body reassembler. One instance serves one channel. - * The caller feeds it one envelope per body, then the `body_chunk` frames for - * that body. The reassembler reassembles the body on the memory path or the - * spill path and settles one completion promise per body. - */ -export class DuplexBodyReceiver { - private readonly spillThresholdBytes: number; - private readonly rawChunkBytes: number; - private readonly arena: SpillArena; - private readonly logger?: (message: string) => void; - private readonly active = new Map(); - - private constructor(arena: SpillArena, config: DuplexBodyReceiverConfig) { - this.arena = arena; - this.spillThresholdBytes = - config.spillThresholdBytes ?? DEFAULT_DUPLEX_BODY_SPILL_THRESHOLD_BYTES; - this.rawChunkBytes = config.rawChunkBytes ?? DUPLEX_BODY_CHUNK_RAW_BYTES; - this.logger = config.logger; - } - - /** - * Build a receiver. The receiver owns the spill directory. It creates the - * directory with `mkdtemp` and mode 0700, then sweeps stale spill directories - * from a prior process. The sweep runs and awaits before the receiver serves a - * body, so no caller races a half-swept directory. - */ - static async create(config: DuplexBodyReceiverConfig = {}): Promise { - const root = config.spillRootDir ?? os.tmpdir(); - await sweepStaleSpillDirectories(root, config.logger); - const dir = await fs.mkdtemp(path.join(root, `${SPILL_DIR_PREFIX}${process.pid}-`)); - // `mkdtemp` creates the directory with mode 0700 on a POSIX host by default, - // but a permissive umask can widen it. Set the mode so the owner alone reads, - // writes, and lists the spill directory. - await fs.chmod(dir, 0o700); - const arena = new SpillArena( - dir, - config.channelSpillByteCap ?? DEFAULT_DUPLEX_CHANNEL_SPILL_BYTE_CAP, - config.channelSpillFileCap ?? DEFAULT_DUPLEX_CHANNEL_SPILL_FILE_CAP, - ); - return new DuplexBodyReceiver(arena, config); - } - - /** The spill directory this receiver owns. A test reads it to assert containment and cleanup. */ - get spillDir(): string { - return this.arena.dir; - } - - /** The bytes on disk across all in-flight bodies for this channel. A test asserts the reservation. */ - get spillUsedBytes(): number { - return this.arena.usedBytes; - } - - /** The open spill files for this channel. A test asserts the file-slot reservation. */ - get spillOpenFiles(): number { - return this.arena.openFiles; - } - - /** - * Begin one body. The caller passes the envelope `id` and `bodyByteCount`. The - * receiver chooses the memory path or the spill path from `bodyByteCount` - * before the first chunk arrives. A `bodyByteCount` of 0 completes at once with - * an empty body and accepts no chunk. The returned promise resolves with the - * reassembled body, or rejects with a {@link DuplexBodyError} on a terminal - * protocol or resource error. - */ - begin(id: string, bodyByteCount: number): Promise { - return new Promise((resolve, reject) => { - // A repeated envelope id for an in-flight body is a protocol violation. The - // caller must not begin the same id twice. Reject the new begin and leave - // the in-flight body untouched. - if (this.active.has(id)) { - reject(new DuplexBodyError("chunk_after_completion", "duplicate body envelope id")); - return; - } - const body: ActiveBody = { - bodyByteCount, - nextSeq: 0, - receivedBytes: 0, - spilled: bodyByteCount > this.spillThresholdBytes, - memoryChunks: [], - filePath: null, - handle: null, - reservedBytes: 0, - holdsFileSlot: false, - disposed: false, - resolve, - reject, - writeChain: Promise.resolve(), - settled: false, - }; - this.active.set(id, body); - if (bodyByteCount === 0) { - // The zero-length body completes at once. It accepts no chunk, so the - // receiver keeps the id in a completed state to reject a stray chunk. - this.complete(id, body); - return; - } - if (body.spilled) { - // Reserve one file slot before the receiver opens the spill file. A - // reserve past the file cap fails closed at once. - if (!this.arena.reserveFile()) { - this.settleReject(id, body, new DuplexBodyError("spill_cap_exceeded", "spill file cap reached")); - return; - } - body.holdsFileSlot = true; - // Open the spill file with a random name and mode 0600. The name never - // derives from the frame id, so no id rides a path. Verify the resolved - // path stays inside the spill directory before the receiver writes. - this.openSpillFile(id, body); - } - }); - } - - /** - * Push one `body_chunk` frame. The receiver validates the chunk against the - * in-flight body, then appends it on the memory path or the spill path. The - * return value reports a synchronous validation outcome. A spill write failure - * or a completion rejects the begin promise instead. - */ - pushChunk(frame: DuplexBodyChunkFrame): DuplexBodyPushResult { - const body = this.active.get(frame.id); - if (!body) { - return fail("chunk_without_envelope", "body_chunk arrived with no matching envelope"); - } - if (body.bodyByteCount === 0) { - return fail("zero_body_has_chunk", "a zero-length body accepts no body_chunk"); - } - if (body.settled) { - return fail("chunk_after_completion", "body_chunk arrived after the body completed"); - } - if (frame.seq !== body.nextSeq) { - return fail("seq_out_of_order", "body_chunk seq is a duplicate, a gap, or a reorder"); - } - // Decode the base64 payload and confirm it is canonical. A non-canonical - // payload re-encodes to a different string, so the round-trip catches it. - const decoded = Buffer.from(frame.data, "base64"); - if (decoded.toString("base64") !== frame.data) { - return fail("chunk_not_canonical_base64", "body_chunk data is not canonical base64"); - } - if (decoded.length === 0) { - return fail("empty_chunk", "body_chunk is empty"); - } - const nextReceived = body.receivedBytes + decoded.length; - if (nextReceived > body.bodyByteCount) { - return fail("body_overrun", "body_chunk overruns the declared body size"); - } - const isFinal = nextReceived === body.bodyByteCount; - if (!isFinal && decoded.length !== this.rawChunkBytes) { - // A non-final chunk must be exactly the fixed raw slice size. A short - // non-final chunk is a truncation; an over-size chunk is a framing fault. - return fail("chunk_wrong_size", "a non-final body_chunk is not the fixed slice size"); - } - if (decoded.length > this.rawChunkBytes) { - // The final chunk must also stay within the fixed raw slice size. - return fail("chunk_wrong_size", "a body_chunk exceeds the fixed slice size"); - } - body.nextSeq += 1; - body.receivedBytes = nextReceived; - if (body.spilled) { - this.appendSpill(frame.id, body, decoded, isFinal); - } else { - body.memoryChunks.push(decoded); - if (isFinal) this.complete(frame.id, body); - } - return { ok: true }; - } - - /** - * Dispose one body by id, whatever its state. The dispose path unlinks the - * spill file and releases the arena reservation exactly once. The caller runs - * it on every settle path and on channel death, so a spill file never lingers. - */ - async disposeBody(id: string): Promise { - const body = this.active.get(id); - if (!body) return; - this.active.delete(id); - await this.release(body); - } - - /** - * Dispose every in-flight body. The caller runs it on channel death, so a - * channel that dies mid-body leaves no spill file behind. - */ - async disposeAll(): Promise { - const ids = [...this.active.keys()]; - await Promise.all(ids.map((id) => this.disposeBody(id))); - } - - /** - * Remove the spill directory this receiver owns. The caller runs it after it - * disposes every body, on channel shutdown. - */ - async destroy(): Promise { - await this.disposeAll(); - await fs.rm(this.arena.dir, { recursive: true, force: true }).catch(() => undefined); - } - - private openSpillFile(id: string, body: ActiveBody): void { - const name = `${randomBytes(24).toString("hex")}.body`; - const filePath = path.join(this.arena.dir, name); - // Path containment. The resolved file path must stay inside the spill - // directory. A generated random name never escapes, but the check is defense - // in depth against a future name scheme. - const resolvedDir = path.resolve(this.arena.dir); - const resolvedFile = path.resolve(filePath); - if (resolvedFile !== path.join(resolvedDir, name) || !resolvedFile.startsWith(resolvedDir + path.sep)) { - this.settleReject(id, body, new DuplexBodyError("path_escape", "spill path escapes the spill directory")); - return; - } - body.filePath = filePath; - // Open the file once, exclusive, mode 0600. The write chain appends each - // chunk in order to this one handle. - body.writeChain = body.writeChain.then(async () => { - body.handle = await fs.open(filePath, "wx", 0o600); - }); - body.writeChain.catch((error) => { - this.settleReject(id, body, new DuplexBodyError("spill_write_failed", errorText(error))); - }); - } - - private appendSpill(id: string, body: ActiveBody, decoded: Buffer, isFinal: boolean): void { - // Reserve the bytes before the write. A reserve past the byte cap fails - // closed: reject the body and unlink the partial file. - if (!this.arena.reserveBytes(decoded.length)) { - this.settleReject(id, body, new DuplexBodyError("spill_cap_exceeded", "spill byte cap reached")); - return; - } - body.reservedBytes += decoded.length; - body.writeChain = body.writeChain.then(async () => { - if (body.settled) return; - if (!body.handle) throw new Error("spill file handle is not open"); - await body.handle.write(decoded); - if (isFinal) { - await body.handle.close(); - body.handle = null; - this.complete(id, body); - } - }); - body.writeChain.catch((error) => { - if (error instanceof DuplexBodyError) return; - this.settleReject(id, body, new DuplexBodyError("spill_write_failed", errorText(error))); - }); - } - - private complete(id: string, body: ActiveBody): void { - if (body.settled) return; - body.settled = true; - const receiver = this; - const handle: ReassembledBody = { - byteCount: body.bodyByteCount, - spilled: body.spilled, - readMemory(): Buffer { - if (body.spilled) { - throw new DuplexBodyError("spill_write_failed", "a spilled body has no in-memory buffer"); - } - return Buffer.concat(body.memoryChunks); - }, - createReadStream(): Readable { - if (body.spilled && body.filePath) return createReadStream(body.filePath); - return Readable.from(Buffer.concat(body.memoryChunks)); - }, - async dispose(): Promise { - await receiver.disposeBody(id); - }, - }; - body.resolve(handle); - } - - private settleReject(id: string, body: ActiveBody, error: DuplexBodyError): void { - if (body.settled) return; - body.settled = true; - // Release the reservation and unlink the partial file before the reject, so - // a rejected body never keeps a spill file or a reservation. The reject fires - // after the release, so a caller that awaits the reject sees a clean arena. - void this.disposeBody(id).finally(() => body.reject(error)); - } - - private async release(body: ActiveBody): Promise { - if (body.disposed) return; - body.disposed = true; - // Wait for the pending writes to settle, so the unlink never races an open - // handle. Swallow a write error here; the reject path already reported it. - await body.writeChain.catch(() => undefined); - if (body.handle) { - await body.handle.close().catch(() => undefined); - body.handle = null; - } - if (body.filePath) { - await fs.rm(body.filePath, { force: true }).catch((error) => { - this.logger?.(`Duplex spill unlink failed: ${errorText(error)}`); - }); - } - if (body.reservedBytes > 0) { - this.arena.releaseBytes(body.reservedBytes); - body.reservedBytes = 0; - } - if (body.holdsFileSlot) { - this.arena.releaseFile(); - body.holdsFileSlot = false; - } - // A body that the channel disposes before it settles must not leave its - // begin promise pending. Reject it with a terminal channel-closed error, so - // a caller that awaits the body sees the channel death. A body that already - // settled keeps its outcome: `complete` resolves it, and `settleReject` sets - // `settled` before it calls dispose, so this guard never double-settles. - if (!body.settled) { - body.settled = true; - body.reject( - new DuplexBodyError("channel_closed", "the channel closed before the body completed"), - ); - } - } -} - -function fail(code: DuplexBodyErrorCode, message: string): DuplexBodyPushResult { - return { ok: false, error: new DuplexBodyError(code, message) }; -} - -function errorText(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} - -/** - * Split a whole body buffer into `body_chunk` frames. The send side reads the - * source body once, then calls this to build the frames. Each frame carries one - * raw slice of `rawChunkBytes` as base64 text, except the final frame, which - * carries the remaining bytes. A zero-length body yields no frame. - */ -export function splitBodyIntoChunkFrames( - id: string, - body: Buffer, - version: number, - rawChunkBytes: number = DUPLEX_BODY_CHUNK_RAW_BYTES, -): DuplexBodyChunkFrame[] { - const frames: DuplexBodyChunkFrame[] = []; - let seq = 0; - for (let offset = 0; offset < body.length; offset += rawChunkBytes) { - const slice = body.subarray(offset, offset + rawChunkBytes); - frames.push({ - version, - type: "body_chunk", - id, - seq, - data: slice.toString("base64"), - }); - seq += 1; - } - return frames; -} - -/** - * Sweep stale spill directories from a prior process. The receiver runs it at - * startup, before it serves a body. It removes a spill directory whose embedded - * pid is not this process and is not a live process, so a crash that left a - * directory behind does not leak disk. It never removes a directory of a live - * process, so a concurrent channel keeps its own spill directory. - */ -export async function sweepStaleSpillDirectories( - root: string, - logger?: (message: string) => void, -): Promise { - const entries = await fs.readdir(root, { withFileTypes: true }).catch(() => []); - await Promise.all( - entries.map(async (entry) => { - if (!entry.isDirectory() || !entry.name.startsWith(SPILL_DIR_PREFIX)) return; - const pid = parsePidFromSpillDir(entry.name); - if (pid !== null && pid !== process.pid && isProcessAlive(pid)) return; - if (pid === process.pid) return; - await fs.rm(path.join(root, entry.name), { recursive: true, force: true }).catch((error) => { - logger?.(`Duplex stale spill sweep failed: ${errorText(error)}`); - }); - }), - ); -} - -/** Read the pid segment from a spill directory name, or null when it is absent. */ -function parsePidFromSpillDir(name: string): number | null { - const rest = name.slice(SPILL_DIR_PREFIX.length); - const dash = rest.indexOf("-"); - const pidText = dash === -1 ? rest : rest.slice(0, dash); - const pid = Number(pidText); - return Number.isInteger(pid) && pid > 0 ? pid : null; -} - -/** Return true when a process with the pid is alive. A signal 0 probes without a real signal. */ -function isProcessAlive(pid: number): boolean { - try { - process.kill(pid, 0); - return true; - } catch (error) { - // EPERM means the process exists but this process cannot signal it. ESRCH - // means no such process. Treat only ESRCH as dead. - return (error as NodeJS.ErrnoException).code === "EPERM"; - } -} diff --git a/packages/adapter-utils/src/duplex-frame-codec.test.ts b/packages/adapter-utils/src/duplex-frame-codec.test.ts index 0fddd5e3cc2..fc9358dfb3d 100644 --- a/packages/adapter-utils/src/duplex-frame-codec.test.ts +++ b/packages/adapter-utils/src/duplex-frame-codec.test.ts @@ -3,16 +3,12 @@ import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; import { DEFAULT_MAX_DUPLEX_FRAME_BYTES, - DEFAULT_MAX_DUPLEX_REQUEST_ID_BYTES, DUPLEX_FRAME_VERSION, - DuplexFrameDecoder, decodeDuplexLine, encodeDuplexFrame, encodeDuplexFrameChecked, - type DuplexDecodeResult, type DuplexFrame, } from "./duplex-frame-codec.js"; -import { DuplexAggregateByteLedger } from "./duplex-aggregate-byte-ledger.js"; // One expected decode result in the fixture: either a decoded frame or the code // of a protocol error. The codec must never throw on the read path. @@ -20,17 +16,8 @@ type ExpectedResult = { frame: DuplexFrame } | { error: string }; interface Vector { name: string; - category: - | "valid" - | "invalid" - | "partial" - | "oversized" - | "versionMismatch" - | "idBoundValid" - | "idBoundInvalid"; + category: "valid" | "invalid" | "versionMismatch"; bytes: string; - splitByteOffsets?: number[]; - maxFrameBytes?: number; roundTrip?: boolean; expected: ExpectedResult[]; } @@ -56,40 +43,6 @@ const fixturePath = fileURLToPath( ); const fixture = JSON.parse(readFileSync(fixturePath, "utf8")) as Fixture; -// Cut one UTF-8 byte stream into chunks at the byte offsets. The decoder must -// keep partial bytes between chunks, so the split can fall inside a multi-byte -// character. -function toChunks(bytes: string, offsets: number[] | undefined): Buffer[] { - const buffer = Buffer.from(bytes, "utf8"); - if (!offsets || offsets.length === 0) return [buffer]; - const bounds = [0, ...offsets, buffer.length]; - const chunks: Buffer[] = []; - for (let i = 0; i < bounds.length - 1; i += 1) { - chunks.push(buffer.subarray(bounds[i], bounds[i + 1])); - } - return chunks; -} - -function pushAll(decoder: DuplexFrameDecoder, chunks: Buffer[]): DuplexDecodeResult[] { - const results: DuplexDecodeResult[] = []; - for (const chunk of chunks) results.push(...decoder.push(chunk)); - return results; -} - -function assertMatches(results: DuplexDecodeResult[], expected: ExpectedResult[]): void { - expect(results).toHaveLength(expected.length); - results.forEach((result, index) => { - const want = expected[index]; - if ("frame" in want) { - expect(result.ok).toBe(true); - if (result.ok) expect(result.frame).toEqual(want.frame); - } else { - expect(result.ok).toBe(false); - if (!result.ok) expect(result.error.code).toBe(want.error); - } - }); -} - describe("duplex frame codec fixture", () => { it("the fixture version matches the codec version", () => { expect(fixture.frameVersion).toBe(DUPLEX_FRAME_VERSION); @@ -98,19 +51,26 @@ describe("duplex frame codec fixture", () => { it("every category has at least one vector", () => { const categories = new Set(fixture.vectors.map((vector) => vector.category)); - for (const category of ["valid", "invalid", "partial", "oversized", "versionMismatch"]) { + for (const category of ["valid", "invalid", "versionMismatch"]) { expect(categories).toContain(category); } }); for (const vector of fixture.vectors) { it(`decodes the ${vector.name} vector`, () => { - const decoder = new DuplexFrameDecoder( - vector.maxFrameBytes ? { maxFrameBytes: vector.maxFrameBytes } : undefined, - ); - const chunks = toChunks(vector.bytes, vector.splitByteOffsets); - const results = pushAll(decoder, chunks); - assertMatches(results, vector.expected); + // Every remaining vector is one complete line, so `decodeDuplexLine` + // reads it directly. The trailing newline in the fixture bytes is not + // part of the line the decoder reads. + const line = vector.bytes.endsWith("\n") ? vector.bytes.slice(0, -1) : vector.bytes; + const result = decodeDuplexLine(line); + const want = vector.expected[0]; + if ("frame" in want) { + expect(result.ok).toBe(true); + if (result.ok) expect(result.frame).toEqual(want.frame); + } else { + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error.code).toBe(want.error); + } }); } }); @@ -126,7 +86,7 @@ describe("round-trip", () => { ), ), ); - for (const type of ["request", "response", "body_chunk", "ready", "heartbeat", "close", "error"]) { + for (const type of ["ready", "heartbeat", "close", "error"]) { expect(types).toContain(type); } }); @@ -191,249 +151,6 @@ describe("ready frame schema", () => { }); }); -describe("streaming decoder behavior", () => { - it("emits nothing until a full line arrives, then the complete frame", () => { - const decoder = new DuplexFrameDecoder(); - const line = encodeDuplexFrame({ version: DUPLEX_FRAME_VERSION, type: "heartbeat" }); - const bytes = Buffer.from(line, "utf8"); - const first = decoder.push(bytes.subarray(0, 3)); - expect(first).toHaveLength(0); - const second = decoder.push(bytes.subarray(3)); - expect(second).toHaveLength(1); - expect(second[0].ok).toBe(true); - }); - - it("keeps a multi-byte UTF-8 sequence valid across a chunk boundary", () => { - const decoder = new DuplexFrameDecoder(); - const frame: DuplexFrame = { - version: DUPLEX_FRAME_VERSION, - type: "request", - id: "req-emoji", - method: "POST", - path: "/x", - query: "", - headers: { "x-note": "😀" }, - bodyByteCount: 0, - }; - const bytes = Buffer.from(encodeDuplexFrame(frame), "utf8"); - // Split inside the four-byte emoji, right before a continuation byte. - let cut = -1; - for (let i = 1; i < bytes.length; i += 1) { - if ((bytes[i] & 0xc0) === 0x80) { - cut = i; - break; - } - } - expect(cut).toBeGreaterThan(0); - const results = [ - ...decoder.push(bytes.subarray(0, cut)), - ...decoder.push(bytes.subarray(cut)), - ]; - expect(results).toHaveLength(1); - expect(results[0].ok).toBe(true); - if (results[0].ok) expect(results[0].frame).toEqual(frame); - }); - - it("rejects an oversized frame with a protocol error, then resynchronizes", () => { - const decoder = new DuplexFrameDecoder({ maxFrameBytes: 32 }); - const flood = `${"z".repeat(100)}\n`; - const good = encodeDuplexFrame({ version: DUPLEX_FRAME_VERSION, type: "heartbeat" }); - const results = decoder.push(Buffer.from(flood + good, "utf8")); - expect(results).toHaveLength(2); - expect(results[0].ok).toBe(false); - if (!results[0].ok) expect(results[0].error.code).toBe("frame_too_large"); - expect(results[1].ok).toBe(true); - }); - - it("rejects a version-mismatch frame with a protocol error", () => { - const decoder = new DuplexFrameDecoder(); - const results = decoder.push( - Buffer.from(`${JSON.stringify({ version: 999, type: "heartbeat" })}\n`, "utf8"), - ); - expect(results).toHaveLength(1); - expect(results[0].ok).toBe(false); - if (!results[0].ok) expect(results[0].error.code).toBe("version_mismatch"); - }); - - it("never throws on a malformed read; it returns a protocol error", () => { - const decoder = new DuplexFrameDecoder(); - expect(() => decoder.push(Buffer.from("this is not json\n", "utf8"))).not.toThrow(); - const results = decoder.push(Buffer.from("still not json\n", "utf8")); - expect(results[0].ok).toBe(false); - }); -}); - -describe("request id byte bound", () => { - // The id byte bound caps the memory the host broker retains per distinct - // request. The bound is 256 bytes; a UTF-8 id byte can differ from a character. - function requestWithId(id: string): string { - return JSON.stringify({ - version: DUPLEX_FRAME_VERSION, - type: "request", - id, - method: "GET", - path: "/", - query: "", - headers: {}, - bodyByteCount: 0, - }); - } - - it("accepts an id at the maximum byte size", () => { - const id = "a".repeat(DEFAULT_MAX_DUPLEX_REQUEST_ID_BYTES); - const result = decodeDuplexLine(requestWithId(id)); - expect(result.ok).toBe(true); - if (result.ok && result.frame.type === "request") expect(result.frame.id).toBe(id); - }); - - it("rejects an over-limit id with an id_too_large protocol error and never throws", () => { - const id = "a".repeat(DEFAULT_MAX_DUPLEX_REQUEST_ID_BYTES + 1); - expect(() => decodeDuplexLine(requestWithId(id))).not.toThrow(); - const result = decodeDuplexLine(requestWithId(id)); - expect(result.ok).toBe(false); - if (!result.ok) expect(result.error.code).toBe("id_too_large"); - }); - - it("measures the id in bytes, not characters, so a multi-byte id over the byte bound is rejected", () => { - // Each "😀" is four UTF-8 bytes. 65 code points make 260 bytes, one code point - // makes 4 bytes, so 65 stays over the 256-byte bound while the character count - // stays under it. - const id = "😀".repeat(65); - expect(id.length).toBeLessThan(DEFAULT_MAX_DUPLEX_REQUEST_ID_BYTES); - const result = decodeDuplexLine(requestWithId(id)); - expect(result.ok).toBe(false); - if (!result.ok) expect(result.error.code).toBe("id_too_large"); - }); - - it("applies the same bound to the response id", () => { - const id = "b".repeat(DEFAULT_MAX_DUPLEX_REQUEST_ID_BYTES + 1); - const result = decodeDuplexLine( - JSON.stringify({ - version: DUPLEX_FRAME_VERSION, - type: "response", - id, - status: 200, - headers: {}, - bodyByteCount: 0, - outcome: "completed", - }), - ); - expect(result.ok).toBe(false); - if (!result.ok) expect(result.error.code).toBe("id_too_large"); - }); -}); - -describe("decoder aggregate byte ledger charging", () => { - // The host injects the process-owned ledger into the decoder. The decoder - // charges the raw partial-frame bytes it retains between chunks, plus the peak - // replacement buffer it allocates on concat. It never retains an uncharged - // buffer, and it fails closed when a reservation would pass the ceiling. - function makeLedger(ceilingBytes = 1024): DuplexAggregateByteLedger { - return new DuplexAggregateByteLedger({ ceilingBytes }); - } - - it("charges the retained partial frame, then releases it when the frame completes", () => { - const ledger = makeLedger(); - const decoder = new DuplexFrameDecoder({ aggregateByteLedger: ledger }); - const line = encodeDuplexFrame({ version: DUPLEX_FRAME_VERSION, type: "heartbeat" }); - const bytes = Buffer.from(line, "utf8"); - - // A partial frame with no newline stays retained and stays charged. - const first = decoder.push(bytes.subarray(0, 3)); - expect(first).toHaveLength(0); - expect(ledger.bytesInUse).toBe(3); - expect(ledger.liveTokenCount).toBe(1); - - // The completing chunk delivers the frame and releases the retained bytes. - const second = decoder.push(bytes.subarray(3)); - expect(second).toHaveLength(1); - expect(second[0].ok).toBe(true); - expect(ledger.bytesInUse).toBe(0); - expect(ledger.liveTokenCount).toBe(0); - }); - - it("covers the peak concat buffer and settles on the remaining bytes", () => { - const ledger = makeLedger(); - const decoder = new DuplexFrameDecoder({ aggregateByteLedger: ledger }); - const first = encodeDuplexFrame({ version: DUPLEX_FRAME_VERSION, type: "heartbeat" }); - const partial = '{"version":1,"type":"hea'; - // One full frame plus a partial second frame arrive in one chunk. The full - // frame decodes and releases; the partial tail stays charged at its exact size. - const results = decoder.push(Buffer.from(first + partial, "utf8")); - expect(results).toHaveLength(1); - expect(results[0].ok).toBe(true); - expect(ledger.bytesInUse).toBe(Buffer.byteLength(partial, "utf8")); - expect(ledger.liveTokenCount).toBe(1); - }); - - it("fails closed when a chunk would pass the ceiling and retains nothing", () => { - const ledger = makeLedger(16); - const decoder = new DuplexFrameDecoder({ aggregateByteLedger: ledger }); - const results = decoder.push(Buffer.from("z".repeat(64), "utf8")); - expect(results).toHaveLength(1); - expect(results[0].ok).toBe(false); - if (!results[0].ok) { - expect(results[0].error.code).toBe("aggregate_bytes_exceeded"); - } - // The decoder retained nothing, so the ledger stays at zero. - expect(ledger.bytesInUse).toBe(0); - expect(ledger.liveTokenCount).toBe(0); - }); - - it("resynchronizes on the next newline after an aggregate rejection", () => { - const ledger = makeLedger(64); - const decoder = new DuplexFrameDecoder({ aggregateByteLedger: ledger }); - // A first chunk over the ceiling fails closed. - const rejected = decoder.push(Buffer.from("z".repeat(128), "utf8")); - expect(rejected[0].ok).toBe(false); - - // The next chunk starts with a newline, so the decoder drops the stale tail - // and decodes the good frame that follows. - const good = encodeDuplexFrame({ version: DUPLEX_FRAME_VERSION, type: "heartbeat" }); - const results = decoder.push(Buffer.from(`\n${good}`, "utf8")); - expect(results).toHaveLength(1); - expect(results[0].ok).toBe(true); - expect(ledger.bytesInUse).toBe(0); - expect(ledger.liveTokenCount).toBe(0); - }); - - it("releases the retained token when an oversized frame is discarded", () => { - const ledger = makeLedger(); - const decoder = new DuplexFrameDecoder({ maxFrameBytes: 16, aggregateByteLedger: ledger }); - // An oversized frame with no newline is discarded and its bytes are released. - const results = decoder.push(Buffer.from("z".repeat(64), "utf8")); - expect(results).toHaveLength(1); - expect(results[0].ok).toBe(false); - if (!results[0].ok) expect(results[0].error.code).toBe("frame_too_large"); - expect(ledger.bytesInUse).toBe(0); - expect(ledger.liveTokenCount).toBe(0); - }); - - it("releases the retained partial frame on dispose", () => { - const ledger = makeLedger(); - const decoder = new DuplexFrameDecoder({ aggregateByteLedger: ledger }); - decoder.push(Buffer.from('{"version":1', "utf8")); - expect(ledger.bytesInUse).toBeGreaterThan(0); - decoder.dispose(); - expect(ledger.bytesInUse).toBe(0); - expect(ledger.liveTokenCount).toBe(0); - // A second dispose is a no-op and records no accounting defect. - decoder.dispose(); - expect(ledger.bytesInUse).toBe(0); - }); - - it("charges nothing when the caller injects no ledger", () => { - const decoder = new DuplexFrameDecoder(); - const results = decoder.push(Buffer.from('{"version":1', "utf8")); - expect(results).toHaveLength(0); - // No ledger means no charge; the decoder still buffers and decodes as before. - const line = encodeDuplexFrame({ version: DUPLEX_FRAME_VERSION, type: "heartbeat" }); - const done = decoder.push(Buffer.from(`}\n${line}`, "utf8")); - // The stitched first frame is malformed JSON; the second is a valid heartbeat. - expect(done.some((result) => result.ok)).toBe(true); - }); -}); - describe("size-checked encode", () => { it("runs every shared encode vector and matches the expected result", () => { // Every codec copy runs the same encode vectors. This copy proves it enforces @@ -458,12 +175,9 @@ describe("size-checked encode", () => { it("rejects an over-bound frame with a typed outcome and never throws", () => { const frame: DuplexFrame = { version: DUPLEX_FRAME_VERSION, - type: "response", - id: "big", - status: 200, - headers: { "x-pad": "x".repeat(2_000) }, - bodyByteCount: 0, - outcome: "completed", + type: "error", + code: "read_timeout", + message: "x".repeat(2_000), }; expect(() => encodeDuplexFrameChecked(frame, 1_000)).not.toThrow(); const result = encodeDuplexFrameChecked(frame, 1_000); @@ -475,17 +189,14 @@ describe("size-checked encode", () => { // Build a frame whose encoded JSON is exactly N bytes. The guard measures the // JSON without the newline, so N bytes encodes and N+1 bytes fails. This is the // same boundary the decoder applies, so encode and decode agree exactly. - // Pad a header value to grow the encoded JSON by an exact byte count. The - // "x-pad" value adds one byte per character, so the frame lands on an exact - // size the guard measures without the trailing newline. + // Pad the message to grow the encoded JSON by an exact byte count. Each + // padding character adds one byte, so the frame lands on an exact size the + // guard measures without the trailing newline. const pad = (n: number): DuplexFrame => ({ version: DUPLEX_FRAME_VERSION, - type: "response", - id: "boundary", - status: 200, - headers: { "x-pad": "x".repeat(n) }, - bodyByteCount: 0, - outcome: "completed", + type: "error", + code: "boundary", + message: "x".repeat(n), }); const baseBytes = Buffer.byteLength(JSON.stringify(pad(0)), "utf8"); const limit = baseBytes + 10; diff --git a/packages/adapter-utils/src/duplex-frame-codec.ts b/packages/adapter-utils/src/duplex-frame-codec.ts index 0a658a440dc..b2e848c7379 100644 --- a/packages/adapter-utils/src/duplex-frame-codec.ts +++ b/packages/adapter-utils/src/duplex-frame-codec.ts @@ -6,41 +6,19 @@ * fixture file (`duplex-frame-vectors.json`) proves the two copies stay wire * compatible: both copies decode the same bytes to the same frames. * - * The codec has two sides: - * - encode: turn a frame object into one line of JSON with a trailing newline. - * - decode: turn a byte stream into frames. The streaming decoder keeps partial - * bytes between chunks, so a frame split across chunks and a multi-byte UTF-8 - * sequence split across chunks both decode correctly. + * The decoder never throws on the read path. A malformed or version-mismatch + * frame becomes a protocol-error result, not an exception. This keeps one bad + * frame from crashing the read loop. * - * The decoder never throws on the read path. A malformed, oversized, or - * version-mismatch frame becomes a protocol-error result, not an exception. This - * keeps one bad frame from crashing the read loop. - * - * HTTP/2 is the preferred transport. `queue_v1` is the soft-deprecated fallback. * The `http2_v1` host readiness gate imports {@link decodeDuplexLine} from this - * file to read the one READY line every gateway sends, so this file's READY - * frame path stays live for both transports. `duplex-body-spool.ts` still - * imports the request, response, and body-chunk frame types from this file, so - * this file keeps every frame type here. + * file to read the one READY line every gateway sends. That is the only frame + * this module's decode side still reads in production; the gateway itself + * never decodes, it only writes one READY line with {@link encodeDuplexFrame}. */ -import { - DUPLEX_CHANNEL_AGGREGATE_BYTES_EXCEEDED, - type DuplexAggregateByteLedger, - type ReservationToken, -} from "./duplex-aggregate-byte-ledger.js"; - /** The wire version this codec reads and writes. */ export const DUPLEX_FRAME_VERSION = 2; -/** - * The fixed raw byte size of one body slice. The sender splits a body into - * `body_chunk` frames of this raw size, except the final chunk, which holds the - * remaining bytes. The base64 text of one full slice is about 349 KiB, so each - * encoded `body_chunk` frame stays well under {@link DEFAULT_MAX_DUPLEX_FRAME_BYTES}. - */ -export const DUPLEX_BODY_CHUNK_RAW_BYTES = 256 * 1024; - /** * The default maximum size of one frame, in bytes. The decoder rejects a longer * frame with a `frame_too_large` protocol error. The value matches the per-chunk @@ -48,88 +26,6 @@ export const DUPLEX_BODY_CHUNK_RAW_BYTES = 256 * 1024; */ export const DEFAULT_MAX_DUPLEX_FRAME_BYTES = 1_000_000; -/** - * The maximum size of the frame `id` field, in bytes. The decoder rejects a - * request or a response frame that carries a longer id with an `id_too_large` - * protocol error. The read path returns the error; it never throws. - * - * The generated gateway builds each request id with `randomUUID()`, so a real id - * is 36 ASCII bytes. This bound of 256 bytes gives large headroom for that id and - * for any short future id scheme. The bound also caps the bytes the host broker - * retains per distinct request. The broker keeps one id per distinct dispatched - * request for the no-replay guarantee, so the id bound sets the per-id ceiling of - * that retained memory. - */ -export const DEFAULT_MAX_DUPLEX_REQUEST_ID_BYTES = 256; - -const NEWLINE_BYTE = 0x0a; -const EMPTY = Buffer.alloc(0); - -/** The frame type strings. One value goes in the `type` field of every frame. */ -export const DUPLEX_FRAME_TYPES = { - request: "request", - response: "response", - body_chunk: "body_chunk", - ready: "ready", - heartbeat: "heartbeat", - close: "close", - error: "error", -} as const; - -/** The outcome of a response frame. A loss response carries a non-completed outcome. */ -export type DuplexResponseOutcome = "completed" | "indeterminate" | "unavailable"; - -/** - * A request envelope frame. The gateway forwards it to the host API path. The - * envelope carries `bodyByteCount`, the raw byte length of the request body. The - * body itself rides one or more `body_chunk` frames that share the same `id`. A - * `bodyByteCount` of 0 means an empty body and no `body_chunk` frame follows. - */ -export interface DuplexRequestFrame { - version: number; - type: "request"; - id: string; - method: string; - path: string; - query: string; - headers: Record; - bodyByteCount: number; -} - -/** - * A response envelope frame. The host returns it for one request id. The - * envelope carries `bodyByteCount`, the raw byte length of the response body. - * The body itself rides one or more `body_chunk` frames that share the same - * `id`. A `bodyByteCount` of 0 means an empty body and no `body_chunk` frame - * follows. - */ -export interface DuplexResponseFrame { - version: number; - type: "response"; - id: string; - status: number; - headers: Record; - bodyByteCount: number; - outcome: DuplexResponseOutcome; -} - -/** - * A body-chunk frame. One frame carries one raw body slice as base64 text in - * `data`. The frames for one body share the `id` of the envelope. `seq` starts - * at 0 and increases by one for each next chunk, with no gap and no repeat. Each - * raw slice is {@link DUPLEX_BODY_CHUNK_RAW_BYTES} bytes, except the final slice, - * which holds the remaining bytes. The receiver reassembles the slices in `seq` - * order and stops when the total raw byte count equals the envelope - * `bodyByteCount`. - */ -export interface DuplexBodyChunkFrame { - version: number; - type: "body_chunk"; - id: string; - seq: number; - data: string; -} - /** * The READY control frame. The gateway sends it one time after it binds the * host-assigned listener port. READY is a liveness signal, not an address @@ -170,14 +66,7 @@ export interface DuplexErrorFrame { } /** Any frame the codec reads or writes. */ -export type DuplexFrame = - | DuplexRequestFrame - | DuplexResponseFrame - | DuplexBodyChunkFrame - | DuplexReadyFrame - | DuplexHeartbeatFrame - | DuplexCloseFrame - | DuplexErrorFrame; +export type DuplexFrame = DuplexReadyFrame | DuplexHeartbeatFrame | DuplexCloseFrame | DuplexErrorFrame; /** The reason the decoder rejected one line. */ export type DuplexProtocolErrorCode = @@ -208,12 +97,6 @@ export type DuplexEncodeResult = | { ok: true; line: string } | { ok: false; error: { code: "frame_too_large"; message: string } }; -const RESPONSE_OUTCOMES: ReadonlySet = new Set([ - "completed", - "indeterminate", - "unavailable", -]); - function ok(frame: DuplexFrame): DuplexDecodeResult { return { ok: true, frame }; } @@ -226,24 +109,6 @@ function isPlainObject(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } -function isStringRecord(value: unknown): value is Record { - if (!isPlainObject(value)) return false; - for (const entry of Object.values(value)) { - if (typeof entry !== "string") return false; - } - return true; -} - -/** Return true when the id byte size is within {@link DEFAULT_MAX_DUPLEX_REQUEST_ID_BYTES}. */ -function idWithinLimit(id: string): boolean { - return Buffer.byteLength(id, "utf8") <= DEFAULT_MAX_DUPLEX_REQUEST_ID_BYTES; -} - -/** Return true when the value is a safe integer that is zero or positive. */ -function isSafeNonNegativeInteger(value: unknown): value is number { - return typeof value === "number" && Number.isSafeInteger(value) && value >= 0; -} - /** * Encode one frame to a single line of JSON with a trailing newline. `JSON.stringify` * escapes any newline inside a string value, so the returned line holds no @@ -307,12 +172,6 @@ export function decodeDuplexLine(line: string | Buffer): DuplexDecodeResult { function validateFrame(frame: Record): DuplexDecodeResult { switch (frame.type) { - case "request": - return validateRequest(frame); - case "response": - return validateResponse(frame); - case "body_chunk": - return validateBodyChunk(frame); case "ready": return validateReady(frame); case "heartbeat": @@ -326,68 +185,6 @@ function validateFrame(frame: Record): DuplexDecodeResult { } } -function validateRequest(frame: Record): DuplexDecodeResult { - if ( - typeof frame.id !== "string" || - typeof frame.method !== "string" || - typeof frame.path !== "string" || - typeof frame.query !== "string" || - !isSafeNonNegativeInteger(frame.bodyByteCount) || - !isStringRecord(frame.headers) - ) { - return fail("malformed_frame", "request frame has a missing or wrong-typed field"); - } - // Bound the id byte size at the protocol level. The host broker retains one id - // per distinct dispatched request, so an unbounded id is a memory-exhaustion - // path. Reject an over-limit id as a protocol error; the read path never throws. - if (!idWithinLimit(frame.id)) { - return fail("id_too_large", "request frame id exceeds the maximum size"); - } - return ok(frame as unknown as DuplexRequestFrame); -} - -function validateResponse(frame: Record): DuplexDecodeResult { - if ( - typeof frame.id !== "string" || - typeof frame.status !== "number" || - !isSafeNonNegativeInteger(frame.bodyByteCount) || - !isStringRecord(frame.headers) || - typeof frame.outcome !== "string" || - !RESPONSE_OUTCOMES.has(frame.outcome) - ) { - return fail("malformed_frame", "response frame has a missing or wrong-typed field"); - } - // Apply the same id byte bound to the response id. The host echoes the request - // id on the response, so the same rule keeps both frame ids under one ceiling. - if (!idWithinLimit(frame.id)) { - return fail("id_too_large", "response frame id exceeds the maximum size"); - } - return ok(frame as unknown as DuplexResponseFrame); -} - -/** - * Validate a `body_chunk` frame at the structural level. The codec is stateless, - * so it checks only the per-frame shape here: - * - `id` is a string within {@link DEFAULT_MAX_DUPLEX_REQUEST_ID_BYTES}. - * - `seq` is a safe integer that is zero or positive. - * - `data` is a string. - * The receive-side reassembler owns the stateful checks: the base64 canonical - * form, the fixed slice size, the `seq` order, and the total against - * `bodyByteCount`. The codec never decodes `data` here. - */ -function validateBodyChunk(frame: Record): DuplexDecodeResult { - if (typeof frame.id !== "string" || typeof frame.data !== "string") { - return fail("malformed_frame", "body_chunk frame has a missing or wrong-typed field"); - } - if (!isSafeNonNegativeInteger(frame.seq)) { - return fail("malformed_frame", "body_chunk frame has a missing or wrong-typed seq"); - } - if (!idWithinLimit(frame.id)) { - return fail("id_too_large", "body_chunk frame id exceeds the maximum size"); - } - return ok(frame as unknown as DuplexBodyChunkFrame); -} - function validateReady(frame: Record): DuplexDecodeResult { // READY carries a liveness nonce, not an address. The schema is strict: a valid // READY frame holds exactly `version`, `type`, and `nonce`. The decoder rejects @@ -421,164 +218,3 @@ function validateError(frame: Record): DuplexDecodeResult { } return ok(frame as unknown as DuplexErrorFrame); } - -/** Options for a {@link DuplexFrameDecoder}. */ -export interface DuplexFrameDecoderOptions { - /** The maximum size of one frame, in bytes. Defaults to {@link DEFAULT_MAX_DUPLEX_FRAME_BYTES}. */ - maxFrameBytes?: number; - /** - * The process-owned aggregate byte ledger. When present, the decoder reserves - * the exact bytes of the raw partial frame it retains between chunks, and the - * peak replacement buffer it allocates on concat, against this ledger under the - * `decoder_buffer` owner. The host injects the same ledger object it injects at - * every other retention site, so one gauge bounds the aggregate retained bytes. - * When absent the decoder charges nothing and behaves as before. - * - * The generated sandbox decoder runs in a separate operating-system process. It - * cannot share this host ledger. It receives its own separate cap instead. - */ - aggregateByteLedger?: DuplexAggregateByteLedger; -} - -/** - * A streaming decoder for a byte stream of newline-delimited JSON frames. - * - * Call `push` with each chunk. The decoder keeps the bytes of an incomplete - * frame between calls, so a frame that spans two chunks decodes on the chunk - * that completes it. The decoder buffers raw bytes and decodes UTF-8 only on a - * complete line, so a multi-byte sequence split across chunks stays valid. The - * newline byte `0x0A` never appears inside a multi-byte UTF-8 sequence, so a - * split on that byte is always safe. - * - * The decoder enforces the maximum frame size. It rejects an oversized frame - * with a `frame_too_large` protocol error, then discards bytes up to the next - * newline to resynchronize. It never throws on the read path. - */ -export class DuplexFrameDecoder { - private buffer: Buffer = EMPTY; - private discarding = false; - private readonly maxFrameBytes: number; - private readonly ledger: DuplexAggregateByteLedger | null; - /** - * The token for the bytes currently retained in `this.buffer`. Its byte amount - * equals `this.buffer.length` at the end of every `push`. It is `null` when the - * buffer is empty or the decoder has no ledger. - */ - private retainedToken: ReservationToken | null = null; - - constructor(options: DuplexFrameDecoderOptions = {}) { - this.maxFrameBytes = options.maxFrameBytes ?? DEFAULT_MAX_DUPLEX_FRAME_BYTES; - this.ledger = options.aggregateByteLedger ?? null; - } - - /** Feed one chunk. Return the frames and protocol errors that complete on it. */ - push(chunk: Buffer | Uint8Array | string): DuplexDecodeResult[] { - const incoming = - typeof chunk === "string" ? Buffer.from(chunk, "utf8") : Buffer.from(chunk); - - // Reserve the incoming bytes before the concat allocates the replacement - // buffer. The retained token already covers the current buffer, so the two - // tokens together cover the peak `old + incoming` allocation. A rejected - // reservation fails closed: the decoder drops the incoming chunk, releases - // the retained buffer, resynchronizes at the next newline, and reports the - // fixed aggregate rejection marker. It retains nothing uncharged. - let incomingToken: ReservationToken | null = null; - if (this.ledger && incoming.length > 0) { - incomingToken = this.ledger.reserve("decoder_buffer", incoming.length); - if (incomingToken === null) { - this.releaseRetained(); - this.buffer = EMPTY; - this.discarding = true; - return [fail("aggregate_bytes_exceeded", DUPLEX_CHANNEL_AGGREGATE_BYTES_EXCEEDED)]; - } - } - - this.buffer = - this.buffer.length === 0 ? incoming : Buffer.concat([this.buffer, incoming]); - - const results: DuplexDecodeResult[] = []; - for (;;) { - if (this.discarding) { - // Drop the tail of an oversized frame until the next newline. - const newlineIndex = this.buffer.indexOf(NEWLINE_BYTE); - if (newlineIndex === -1) { - this.buffer = EMPTY; - break; - } - this.buffer = this.buffer.subarray(newlineIndex + 1); - this.discarding = false; - continue; - } - - const newlineIndex = this.buffer.indexOf(NEWLINE_BYTE); - if (newlineIndex === -1) { - // No complete line yet. Reject an incomplete frame that already passed - // the size bound, then resynchronize at the next newline. - if (this.buffer.length > this.maxFrameBytes) { - results.push(fail("frame_too_large", "frame exceeds the maximum size")); - this.discarding = true; - this.buffer = EMPTY; - } - break; - } - - const line = this.buffer.subarray(0, newlineIndex); - this.buffer = this.buffer.subarray(newlineIndex + 1); - if (line.length === 0) continue; // Skip a blank line. - if (line.length > this.maxFrameBytes) { - results.push(fail("frame_too_large", "frame exceeds the maximum size")); - continue; - } - results.push(decodeDuplexLine(line)); - } - - // Reconcile the ledger to the bytes still retained in `this.buffer`. Release - // the old retained token and the incoming token, then reserve one token for - // the remainder. The remainder is a subset of the just-released - // `old + incoming` bytes, so this reserve always fits. - this.reconcileRetained(incomingToken); - return results; - } - - /** - * Release the retained-buffer token and drop the buffer. The host calls this at - * channel teardown, so the decoder never leaks a `decoder_buffer` token after - * the channel ends. A second call is a no-op, because the field is already - * `null`. - */ - dispose(): void { - this.releaseRetained(); - this.buffer = EMPTY; - this.discarding = false; - } - - /** Release the retained-buffer token one time and clear the field. */ - private releaseRetained(): void { - if (this.ledger && this.retainedToken) { - this.ledger.release(this.retainedToken); - } - this.retainedToken = null; - } - - /** - * Move the ledger charge to the bytes still in `this.buffer`. Release the old - * retained token and the `incoming` token, then reserve one token for the - * remaining bytes. A `null` reserve here is an accounting defect, because the - * remainder never passes the just-released bytes; the decoder fails closed and - * drops the buffer so it retains nothing uncharged. - */ - private reconcileRetained(incomingToken: ReservationToken | null): void { - if (!this.ledger) return; - this.releaseRetained(); - if (incomingToken) { - this.ledger.release(incomingToken); - } - if (this.buffer.length > 0) { - this.retainedToken = this.ledger.reserve("decoder_buffer", this.buffer.length); - if (this.retainedToken === null) { - this.buffer = EMPTY; - this.discarding = true; - } - } - } -} diff --git a/packages/adapter-utils/src/duplex-frame-vectors.json b/packages/adapter-utils/src/duplex-frame-vectors.json index 62d43d1c15f..99a6762b7a1 100644 --- a/packages/adapter-utils/src/duplex-frame-vectors.json +++ b/packages/adapter-utils/src/duplex-frame-vectors.json @@ -1,151 +1,8 @@ { "frameVersion": 2, "defaultMaxFrameBytes": 1000000, - "description": "Shared wire-compatibility vectors for the duplex frame codec. Every codec copy decodes the same bytes. bytes is a UTF-8 byte stream; splitByteOffsets cuts it into chunks; expected lists the ordered decode results (frame or protocol-error code).", + "description": "Shared wire-compatibility vectors for the duplex frame codec. Every codec copy decodes the same bytes. bytes is a UTF-8 byte stream; expected lists the decode result (frame or protocol-error code).", "vectors": [ - { - "name": "valid-request", - "category": "valid", - "bytes": "{\"version\":2,\"type\":\"request\",\"id\":\"req-1\",\"method\":\"GET\",\"path\":\"/api/issues/PAP-1\",\"query\":\"expand=comments\",\"headers\":{\"accept\":\"application/json\",\"content-type\":\"application/json\"},\"bodyByteCount\":0}\n", - "roundTrip": true, - "expected": [ - { - "frame": { - "version": 2, - "type": "request", - "id": "req-1", - "method": "GET", - "path": "/api/issues/PAP-1", - "query": "expand=comments", - "headers": { - "accept": "application/json", - "content-type": "application/json" - }, - "bodyByteCount": 0 - } - } - ] - }, - { - "name": "valid-request-with-body-count", - "category": "valid", - "bytes": "{\"version\":2,\"type\":\"request\",\"id\":\"req-body\",\"method\":\"GET\",\"path\":\"/api/issues/PAP-1\",\"query\":\"expand=comments\",\"headers\":{\"accept\":\"application/json\",\"content-type\":\"application/json\"},\"bodyByteCount\":1048576}\n", - "roundTrip": true, - "expected": [ - { - "frame": { - "version": 2, - "type": "request", - "id": "req-body", - "method": "GET", - "path": "/api/issues/PAP-1", - "query": "expand=comments", - "headers": { - "accept": "application/json", - "content-type": "application/json" - }, - "bodyByteCount": 1048576 - } - } - ] - }, - { - "name": "valid-response-completed", - "category": "valid", - "bytes": "{\"version\":2,\"type\":\"response\",\"id\":\"req-1\",\"status\":200,\"headers\":{\"content-type\":\"application/json\"},\"bodyByteCount\":11,\"outcome\":\"completed\"}\n", - "roundTrip": true, - "expected": [ - { - "frame": { - "version": 2, - "type": "response", - "id": "req-1", - "status": 200, - "headers": { - "content-type": "application/json" - }, - "bodyByteCount": 11, - "outcome": "completed" - } - } - ] - }, - { - "name": "valid-response-indeterminate", - "category": "valid", - "bytes": "{\"version\":2,\"type\":\"response\",\"id\":\"req-2\",\"status\":409,\"headers\":{\"content-type\":\"application/json\"},\"bodyByteCount\":33,\"outcome\":\"indeterminate\"}\n", - "roundTrip": true, - "expected": [ - { - "frame": { - "version": 2, - "type": "response", - "id": "req-2", - "status": 409, - "headers": { - "content-type": "application/json" - }, - "bodyByteCount": 33, - "outcome": "indeterminate" - } - } - ] - }, - { - "name": "valid-response-unavailable", - "category": "valid", - "bytes": "{\"version\":2,\"type\":\"response\",\"id\":\"req-3\",\"status\":503,\"headers\":{\"content-type\":\"application/json\"},\"bodyByteCount\":30,\"outcome\":\"unavailable\"}\n", - "roundTrip": true, - "expected": [ - { - "frame": { - "version": 2, - "type": "response", - "id": "req-3", - "status": 503, - "headers": { - "content-type": "application/json" - }, - "bodyByteCount": 30, - "outcome": "unavailable" - } - } - ] - }, - { - "name": "valid-body-chunk", - "category": "valid", - "bytes": "{\"version\":2,\"type\":\"body_chunk\",\"id\":\"req-1\",\"seq\":0,\"data\":\"aGVsbG8gYm9keSBjaHVuaw==\"}\n", - "roundTrip": true, - "expected": [ - { - "frame": { - "version": 2, - "type": "body_chunk", - "id": "req-1", - "seq": 0, - "data": "aGVsbG8gYm9keSBjaHVuaw==" - } - } - ] - }, - { - "name": "valid-body-chunk-final", - "category": "valid", - "bytes": "{\"version\":2,\"type\":\"body_chunk\",\"id\":\"req-1\",\"seq\":7,\"data\":\"dGFpbA==\"}\n", - "roundTrip": true, - "expected": [ - { - "frame": { - "version": 2, - "type": "body_chunk", - "id": "req-1", - "seq": 7, - "data": "dGFpbA==" - } - } - ] - }, { "name": "valid-ready", "category": "valid", @@ -275,223 +132,6 @@ } ] }, - { - "name": "invalid-missing-id", - "category": "invalid", - "bytes": "{\"version\":2,\"type\":\"request\",\"method\":\"GET\",\"path\":\"/\",\"query\":\"\",\"headers\":{},\"bodyByteCount\":0}\n", - "expected": [ - { - "error": "malformed_frame" - } - ] - }, - { - "name": "invalid-request-body-count-string", - "category": "invalid", - "bytes": "{\"version\":2,\"type\":\"request\",\"id\":\"req-1\",\"method\":\"GET\",\"path\":\"/\",\"query\":\"\",\"headers\":{},\"bodyByteCount\":\"10\"}\n", - "expected": [ - { - "error": "malformed_frame" - } - ] - }, - { - "name": "invalid-request-body-count-negative", - "category": "invalid", - "bytes": "{\"version\":2,\"type\":\"request\",\"id\":\"req-1\",\"method\":\"GET\",\"path\":\"/\",\"query\":\"\",\"headers\":{},\"bodyByteCount\":-1}\n", - "expected": [ - { - "error": "malformed_frame" - } - ] - }, - { - "name": "invalid-request-body-count-fractional", - "category": "invalid", - "bytes": "{\"version\":2,\"type\":\"request\",\"id\":\"req-1\",\"method\":\"GET\",\"path\":\"/\",\"query\":\"\",\"headers\":{},\"bodyByteCount\":1.5}\n", - "expected": [ - { - "error": "malformed_frame" - } - ] - }, - { - "name": "invalid-bad-headers", - "category": "invalid", - "bytes": "{\"version\":2,\"type\":\"response\",\"id\":\"req-1\",\"status\":200,\"headers\":{\"accept\":7},\"bodyByteCount\":10,\"outcome\":\"completed\"}\n", - "expected": [ - { - "error": "malformed_frame" - } - ] - }, - { - "name": "invalid-body-chunk-negative-seq", - "category": "invalid", - "bytes": "{\"version\":2,\"type\":\"body_chunk\",\"id\":\"req-1\",\"seq\":-1,\"data\":\"eA==\"}\n", - "expected": [ - { - "error": "malformed_frame" - } - ] - }, - { - "name": "invalid-body-chunk-fractional-seq", - "category": "invalid", - "bytes": "{\"version\":2,\"type\":\"body_chunk\",\"id\":\"req-1\",\"seq\":2.5,\"data\":\"eA==\"}\n", - "expected": [ - { - "error": "malformed_frame" - } - ] - }, - { - "name": "invalid-body-chunk-missing-data", - "category": "invalid", - "bytes": "{\"version\":2,\"type\":\"body_chunk\",\"id\":\"req-1\",\"seq\":0}\n", - "expected": [ - { - "error": "malformed_frame" - } - ] - }, - { - "name": "invalid-body-chunk-nonstring-data", - "category": "invalid", - "bytes": "{\"version\":2,\"type\":\"body_chunk\",\"id\":\"req-1\",\"seq\":0,\"data\":42}\n", - "expected": [ - { - "error": "malformed_frame" - } - ] - }, - { - "name": "partial-request-split", - "category": "partial", - "bytes": "{\"version\":2,\"type\":\"request\",\"id\":\"req-1\",\"method\":\"GET\",\"path\":\"/api/issues/PAP-1\",\"query\":\"expand=comments\",\"headers\":{\"accept\":\"application/json\",\"content-type\":\"application/json\"},\"bodyByteCount\":0}\n", - "splitByteOffsets": [ - 102 - ], - "expected": [ - { - "frame": { - "version": 2, - "type": "request", - "id": "req-1", - "method": "GET", - "path": "/api/issues/PAP-1", - "query": "expand=comments", - "headers": { - "accept": "application/json", - "content-type": "application/json" - }, - "bodyByteCount": 0 - } - } - ] - }, - { - "name": "partial-utf8-split", - "category": "partial", - "bytes": "{\"version\":2,\"type\":\"request\",\"id\":\"req-utf8\",\"method\":\"POST\",\"path\":\"/api/issues/PAP-1\",\"query\":\"expand=comments\",\"headers\":{\"content-type\":\"application/json\",\"x-note\":\"héllo 😀 wörld\"},\"bodyByteCount\":0}\n", - "splitByteOffsets": [ - 172 - ], - "expected": [ - { - "frame": { - "version": 2, - "type": "request", - "id": "req-utf8", - "method": "POST", - "path": "/api/issues/PAP-1", - "query": "expand=comments", - "headers": { - "content-type": "application/json", - "x-note": "héllo 😀 wörld" - }, - "bodyByteCount": 0 - } - } - ] - }, - { - "name": "partial-two-frames", - "category": "partial", - "bytes": "{\"version\":2,\"type\":\"request\",\"id\":\"req-1\",\"method\":\"GET\",\"path\":\"/api/issues/PAP-1\",\"query\":\"expand=comments\",\"headers\":{\"accept\":\"application/json\",\"content-type\":\"application/json\"},\"bodyByteCount\":0}\n{\"version\":2,\"type\":\"response\",\"id\":\"req-1\",\"status\":200,\"headers\":{\"content-type\":\"application/json\"},\"bodyByteCount\":11,\"outcome\":\"completed\"}\n", - "splitByteOffsets": [ - 7, - 174, - 344 - ], - "expected": [ - { - "frame": { - "version": 2, - "type": "request", - "id": "req-1", - "method": "GET", - "path": "/api/issues/PAP-1", - "query": "expand=comments", - "headers": { - "accept": "application/json", - "content-type": "application/json" - }, - "bodyByteCount": 0 - } - }, - { - "frame": { - "version": 2, - "type": "response", - "id": "req-1", - "status": 200, - "headers": { - "content-type": "application/json" - }, - "bodyByteCount": 11, - "outcome": "completed" - } - } - ] - }, - { - "name": "partial-no-trailing-newline", - "category": "partial", - "bytes": "{\"version\":2,\"type\":\"heartbeat\"}", - "expected": [] - }, - { - "name": "oversized-complete-line", - "category": "oversized", - "maxFrameBytes": 64, - "bytes": "{\"version\":2,\"type\":\"request\",\"id\":\"req-1\",\"method\":\"GET\",\"path\":\"/api/issues/PAP-1\",\"query\":\"q=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\",\"headers\":{\"accept\":\"application/json\",\"content-type\":\"application/json\"},\"bodyByteCount\":0}\n", - "expected": [ - { - "error": "frame_too_large" - } - ] - }, - { - "name": "oversized-flood-then-resync", - "category": "oversized", - "maxFrameBytes": 64, - "splitByteOffsets": [ - 40 - ], - "bytes": "yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy\n{\"version\":2,\"type\":\"heartbeat\"}\n", - "expected": [ - { - "error": "frame_too_large" - }, - { - "frame": { - "version": 2, - "type": "heartbeat" - } - } - ] - }, { "name": "version-mismatch-lower", "category": "versionMismatch", @@ -521,46 +161,6 @@ "error": "version_mismatch" } ] - }, - { - "name": "request-id-at-max-bytes", - "category": "idBoundValid", - "bytes": "{\"version\":2,\"type\":\"request\",\"id\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"method\":\"GET\",\"path\":\"/\",\"query\":\"\",\"headers\":{},\"bodyByteCount\":0}\n", - "roundTrip": true, - "expected": [ - { - "frame": { - "version": 2, - "type": "request", - "id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "method": "GET", - "path": "/", - "query": "", - "headers": {}, - "bodyByteCount": 0 - } - } - ] - }, - { - "name": "request-id-over-max-bytes", - "category": "idBoundInvalid", - "bytes": "{\"version\":2,\"type\":\"request\",\"id\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"method\":\"GET\",\"path\":\"/\",\"query\":\"\",\"headers\":{},\"bodyByteCount\":0}\n", - "expected": [ - { - "error": "id_too_large" - } - ] - }, - { - "name": "body-chunk-id-over-max-bytes", - "category": "idBoundInvalid", - "bytes": "{\"version\":2,\"type\":\"body_chunk\",\"id\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"seq\":0,\"data\":\"eA==\"}\n", - "expected": [ - { - "error": "id_too_large" - } - ] } ], "encodeDescription": "Encode-bound vectors. Each codec copy runs the size-checked encode against the frame with the maxFrameBytes limit, then asserts the same result. An ok result must decode back to the same frame. This proves both copies enforce the same bound on encode.", @@ -570,27 +170,9 @@ "maxFrameBytes": 200, "frame": { "version": 2, - "type": "request", - "id": "req-enc-1", - "method": "GET", - "path": "/", - "query": "", - "headers": {}, - "bodyByteCount": 5 - }, - "expected": { - "ok": true - } - }, - { - "name": "encode-body-chunk-within-bound", - "maxFrameBytes": 200, - "frame": { - "version": 2, - "type": "body_chunk", - "id": "req-enc-3", - "seq": 0, - "data": "Y2h1bmsgcGF5bG9hZA==" + "type": "error", + "code": "read_timeout", + "message": "upstream did not respond" }, "expected": { "ok": true @@ -612,15 +194,9 @@ "maxFrameBytes": 200, "frame": { "version": 2, - "type": "response", - "id": "req-enc-2", - "status": 200, - "headers": { - "content-type": "application/json", - "x-pad": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" - }, - "bodyByteCount": 0, - "outcome": "completed" + "type": "error", + "code": "read_timeout", + "message": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" }, "expected": { "ok": false, diff --git a/packages/adapter-utils/src/execution-target-sandbox.test.ts b/packages/adapter-utils/src/execution-target-sandbox.test.ts index e89a07d8c7f..3a26fbc439f 100644 --- a/packages/adapter-utils/src/execution-target-sandbox.test.ts +++ b/packages/adapter-utils/src/execution-target-sandbox.test.ts @@ -6,7 +6,6 @@ import { execFile, spawn } from "node:child_process"; import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; import { afterEach, describe, expect, it, vi } from "vitest"; @@ -45,7 +44,6 @@ import { } from "./acpx-engine/startup-timing.js"; import { DuplexAggregateByteLedger, - DUPLEX_AGGREGATE_TOKEN_OWNERS, DUPLEX_CHANNEL_AGGREGATE_BYTES_EXCEEDED, } from "./duplex-aggregate-byte-ledger.js"; import { createSandboxRunLogTailFactory, type SandboxRunLogTailFactory } from "./sandbox-run-log-stream.js"; @@ -54,11 +52,9 @@ import { shellQuote } from "./ssh.js"; import type { CommandManagedDuplexChannel } from "./command-managed-runtime.js"; import { DEFAULT_MAX_DUPLEX_FRAME_BYTES, - DuplexFrameDecoder, DUPLEX_FRAME_VERSION, decodeDuplexLine, encodeDuplexFrame, - type DuplexResponseFrame, } from "./duplex-frame-codec.js"; import { DUPLEX_CHANNEL_LOST_ERROR_CODE } from "./bridge-transport-contract.js"; import { @@ -3064,7 +3060,6 @@ describe("sandbox adapter execution targets", () => { // what the broker wrote back through it. interface DuplexSelectionControl { openCount: number; - written: DuplexResponseFrame[]; writtenTypes: string[]; stopCount: number; closeCount: number; @@ -3095,7 +3090,6 @@ describe("sandbox adapter execution targets", () => { const base = createLocalSandboxRunner(); const control: DuplexSelectionControl = { openCount: 0, - written: [], writtenTypes: [], stopCount: 0, closeCount: 0, @@ -3115,7 +3109,6 @@ describe("sandbox adapter execution targets", () => { const decoded = decodeDuplexLine(Buffer.from(data).toString("utf8").replace(/\n$/, "")); if (decoded.ok) { control.writtenTypes.push(decoded.frame.type); - if (decoded.frame.type === "response") control.written.push(decoded.frame); } }, onData(listener: (chunk: Uint8Array) => void): void { @@ -5797,6 +5790,46 @@ describe("sandbox adapter execution targets", () => { expect(mode).toBe("http2_v1"); }, 20000); + // Case 5: a version-2 line that once decoded as a `request` envelope frame + // now decodes as `unknown_type`, because the codec no longer validates that + // frame type. The gate treats it the same as any other non-READY line: skip + // it and keep scanning. This holds for both a bare line (the frame starts at + // offset 0, so the leading-prefix retry never runs) and a line where noise + // comes before the frame on the same line (the retry runs, decodes the frame + // part, and still does not find a READY type). Either way the valid READY + // line that follows still authenticates. + it("PTY replay: accepts READY after a bare and a prefixed former-frame line", async () => { + const { mode } = await runReadinessReplay((ctx) => { + const formerFrame = + '{"version":2,"type":"request","id":"r-1","method":"GET","path":"/","query":"","headers":{},"bodyByteCount":0}'; + ctx.emitRaw(`${formerFrame}\n`); + // No newline between the prompt prefix and the former-frame line: same line. + ctx.emitRaw(`prompt$ ${formerFrame}\n`); + ctx.emitRaw('{"version":2,"type":"ready","nonce":"' + ctx.nonce + '"}\n'); + ctx.connectHttp2(); + }); + expect(mode).toBe("http2_v1"); + }, 20000); + + // Case 6: three lines the gate must reject without ending the handshake — a + // wrong-version READY-shaped line, a READY line with a smuggled extra field, + // and a same-line second-frame smuggling attempt (no newline between the two + // JSON values, so the whole line fails to parse as one JSON value). Each one + // fails the strict decode and the gate skips it, the same as any other + // pre-READY noise; the valid READY line that follows still authenticates. + it("PTY replay: accepts READY after a wrong-version, an extra-field, and a same-line smuggling attempt", async () => { + const { mode } = await runReadinessReplay((ctx) => { + ctx.emitRaw(`{"version":1,"type":"ready","nonce":"${ctx.nonce}"}\n`); + ctx.emitRaw(`{"version":2,"type":"ready","nonce":"${ctx.nonce}","address":"http://127.0.0.1:1"}\n`); + ctx.emitRaw( + `{"version":2,"type":"ready","nonce":"${ctx.nonce}"}{"version":2,"type":"ready","nonce":"${ctx.nonce}"}\n`, + ); + ctx.emitRaw(`{"version":2,"type":"ready","nonce":"${ctx.nonce}"}\n`); + ctx.connectHttp2(); + }); + expect(mode).toBe("http2_v1"); + }, 20000); + it("test_the_session_starts_at_the_client_preface_after_the_ready_line", async () => { // The gate retains every byte after the accepted READY line, and the host // starts the HTTP/2 session at the client preface offset inside that @@ -5922,223 +5955,37 @@ describe("sandbox adapter execution targets", () => { }); -// One decode result from the embedded codec. The shape mirrors the host codec: -// a valid frame or a protocol error with a code. -interface EmbeddedDecodeResult { - ok: boolean; - frame?: unknown; - error?: { code: string; message: string }; -} - // The names the embedded codec source declares. A test wraps the source and -// reads these names back. -type EmbeddedEncodeResult = - | { ok: true; line: string } - | { ok: false; error: { code: string; message: string } }; - +// reads these names back. The generated gateway never decodes, so the embedded +// copy declares only the frame version and the encode function. interface EmbeddedCodec { encodeDuplexFrame: (frame: unknown) => string; - encodeDuplexFrameChecked: (frame: unknown, maxFrameBytes?: number) => EmbeddedEncodeResult; - decodeDuplexLine: (line: string | Buffer) => EmbeddedDecodeResult; - DuplexFrameDecoder: new (options?: { maxFrameBytes?: number; maxAggregateBytes?: number }) => { - push: (chunk: Buffer) => EmbeddedDecodeResult[]; - scope: string; - bytesInUse: number; - aggregateRejections: number; - }; DUPLEX_FRAME_VERSION: number; - DEFAULT_MAX_DUPLEX_FRAME_BYTES: number; - DUPLEX_DECODER_SCOPE: string; - DEFAULT_MAX_DUPLEX_DECODER_BYTES: number; -} - -type ExpectedVectorResult = { frame: unknown } | { error: string }; - -interface DuplexFrameVector { - name: string; - category: string; - bytes: string; - splitByteOffsets?: number[]; - maxFrameBytes?: number; - roundTrip?: boolean; - expected: ExpectedVectorResult[]; -} - -interface DuplexEncodeVector { - name: string; - maxFrameBytes: number; - frame: unknown; - expected: { ok: true } | { ok: false; error: string }; -} - -interface DuplexFrameFixture { - frameVersion: number; - defaultMaxFrameBytes: number; - vectors: DuplexFrameVector[]; - encodeVectors: DuplexEncodeVector[]; } // This describe block covers the zero-dependency codec every generated -// gateway embeds (`DUPLEX_GATEWAY_CODEC_SOURCE`), and the sandbox-process -// decoder byte cap. The `http2_v1` gateway embeds the same codec source to -// send its one READY line, so this coverage stays live for the active -// transport. +// gateway embeds (`DUPLEX_GATEWAY_CODEC_SOURCE`). The `http2_v1` gateway +// embeds this same codec source to send its one READY line, so this coverage +// stays live for the active transport. describe("embedded sandbox gateway codec", () => { - it("embedded gateway codec passes every vector in the shared fixture", async () => { + it("encodes the READY frame to the exact byte-for-byte wire format", () => { + // `JSON.stringify` writes object keys in insertion order, so this pins the + // exact key order the gateway writes: version, then type, then nonce. A + // reordered or reformatted call site would change the bytes on the wire + // without failing a looser, parse-then-compare assertion. const codecFactory = new Function( - `${getSandboxDuplexGatewayCodecSource()}\nreturn { encodeDuplexFrame, encodeDuplexFrameChecked, decodeDuplexLine, DuplexFrameDecoder, DUPLEX_FRAME_VERSION, DEFAULT_MAX_DUPLEX_FRAME_BYTES, DUPLEX_DECODER_SCOPE, DEFAULT_MAX_DUPLEX_DECODER_BYTES };`, + `${getSandboxDuplexGatewayCodecSource()}\nreturn { encodeDuplexFrame, DUPLEX_FRAME_VERSION };`, ) as unknown as () => EmbeddedCodec; const codec = codecFactory(); - const fixturePath = fileURLToPath(new URL("./duplex-frame-vectors.json", import.meta.url)); - const fixture = JSON.parse(await readFile(fixturePath, "utf8")) as DuplexFrameFixture; + expect(codec.DUPLEX_FRAME_VERSION).toBe(DUPLEX_FRAME_VERSION); + const nonce = "fixed-test-nonce"; + const encoded = codec.encodeDuplexFrame({ version: codec.DUPLEX_FRAME_VERSION, type: "ready", nonce }); + expect(encoded).toBe(`{"version":2,"type":"ready","nonce":"${nonce}"}\n`); - expect(fixture.frameVersion).toBe(codec.DUPLEX_FRAME_VERSION); - expect(fixture.defaultMaxFrameBytes).toBe(codec.DEFAULT_MAX_DUPLEX_FRAME_BYTES); - expect(fixture.vectors.length).toBeGreaterThanOrEqual(22); - - const failures: string[] = []; - for (const vector of fixture.vectors) { - const decoder = new codec.DuplexFrameDecoder( - vector.maxFrameBytes ? { maxFrameBytes: vector.maxFrameBytes } : undefined, - ); - const buffer = Buffer.from(vector.bytes, "utf8"); - const offsets = vector.splitByteOffsets; - const bounds = - offsets && offsets.length > 0 ? [0, ...offsets, buffer.length] : [0, buffer.length]; - const results: EmbeddedDecodeResult[] = []; - for (let index = 0; index < bounds.length - 1; index += 1) { - results.push(...decoder.push(buffer.subarray(bounds[index], bounds[index + 1]))); - } - - if (results.length !== vector.expected.length) { - failures.push(`${vector.name}: got ${results.length} results, want ${vector.expected.length}`); - continue; - } - vector.expected.forEach((want, index) => { - const got = results[index]; - if ("frame" in want) { - if (!got.ok) { - failures.push(`${vector.name}[${index}]: expected a frame, got an error`); - return; - } - try { - expect(got.frame).toEqual(want.frame); - } catch { - failures.push(`${vector.name}[${index}]: frame does not match`); - } - } else { - if (got.ok) { - failures.push(`${vector.name}[${index}]: expected an error, got a frame`); - return; - } - if (got.error?.code !== want.error) { - failures.push(`${vector.name}[${index}]: error ${got.error?.code} != ${want.error}`); - } - } - }); - } - expect(failures).toEqual([]); - - // The encode side stays wire compatible too: one line, one newline, and the - // same frame after a decode round trip. - for (const vector of fixture.vectors.filter((entry) => entry.roundTrip)) { - const want = vector.expected[0]; - if (!("frame" in want)) continue; - const encoded = codec.encodeDuplexFrame(want.frame); - expect(encoded.endsWith("\n")).toBe(true); - expect(encoded.slice(0, -1)).not.toContain("\n"); - const decoded = codec.decodeDuplexLine(encoded.slice(0, -1)); - expect(decoded.ok).toBe(true); - expect(decoded.frame).toEqual(want.frame); - } - - // The embedded copy enforces the same encode bound as the host copy. It runs - // every shared encode vector and matches the expected result, so both copies - // reject the same oversized frame with the same `frame_too_large` code. - expect(fixture.encodeVectors.length).toBeGreaterThanOrEqual(3); - const encodeFailures: string[] = []; - for (const vector of fixture.encodeVectors) { - const result = codec.encodeDuplexFrameChecked(vector.frame, vector.maxFrameBytes); - if (result.ok !== vector.expected.ok) { - encodeFailures.push(`${vector.name}: ok=${result.ok}, want ${vector.expected.ok}`); - continue; - } - if (result.ok) { - if (!result.line.endsWith("\n") || result.line.slice(0, -1).includes("\n")) { - encodeFailures.push(`${vector.name}: line is not exactly one frame line`); - continue; - } - const decoded = codec.decodeDuplexLine(result.line.slice(0, -1)); - if (!decoded.ok) { - encodeFailures.push(`${vector.name}: an ok encode did not decode back`); - continue; - } - try { - expect(decoded.frame).toEqual(vector.frame); - } catch { - encodeFailures.push(`${vector.name}: decoded frame does not match`); - } - } else if (!vector.expected.ok && result.error.code !== vector.expected.error) { - encodeFailures.push(`${vector.name}: error ${result.error.code} != ${vector.expected.error}`); - } - } - expect(encodeFailures).toEqual([]); - }); - - it("bounds the sandbox decoder with a separate sandbox_process scope, distinct from the host ledger scope", () => { - // The generated gateway runs in a separate operating-system process, so its - // decoder cannot share the host aggregate byte ledger. It enforces a separate - // local cap under the `sandbox_process` scope. This test wraps the embedded - // source and the host decoder, then proves the two scopes never overlap and the - // sandbox cap fails closed. - const codecFactory = new Function( - `${getSandboxDuplexGatewayCodecSource()}\nreturn { encodeDuplexFrame, decodeDuplexLine, DuplexFrameDecoder, DUPLEX_FRAME_VERSION, DEFAULT_MAX_DUPLEX_FRAME_BYTES, DUPLEX_DECODER_SCOPE, DEFAULT_MAX_DUPLEX_DECODER_BYTES };`, - ) as unknown as () => EmbeddedCodec; - const codec = codecFactory(); - - // The sandbox scope is the fixed `sandbox_process` label. - expect(codec.DUPLEX_DECODER_SCOPE).toBe("sandbox_process"); - expect(codec.DEFAULT_MAX_DUPLEX_DECODER_BYTES).toBeGreaterThan(codec.DEFAULT_MAX_DUPLEX_FRAME_BYTES); - // The two scopes are distinct: the host owner set never carries the sandbox - // scope, so the sandbox counter can never map to a host aggregate token. - expect((DUPLEX_AGGREGATE_TOKEN_OWNERS as readonly string[]).includes("sandbox_process")).toBe(false); - - // The sandbox decoder tracks its own `sandbox_process` counter. A frame under - // the cap charges the local counter, and the counter returns to zero once the - // frame drains. - const sandboxDecoder = new codec.DuplexFrameDecoder({ maxAggregateBytes: 64 }); - expect(sandboxDecoder.scope).toBe("sandbox_process"); - const partial = sandboxDecoder.push(Buffer.from('{"version":1,', "utf8")); - expect(partial).toEqual([]); - expect(sandboxDecoder.bytesInUse).toBe(Buffer.byteLength('{"version":1,', "utf8")); - sandboxDecoder.push(Buffer.from('"type":"heartbeat"}\n', "utf8")); - expect(sandboxDecoder.bytesInUse).toBe(0); - - // A chunk over the local cap fails closed. The decoder retains nothing, reports - // the aggregate rejection, and increments only its local `sandbox_process` - // counter. - const cappedDecoder = new codec.DuplexFrameDecoder({ maxAggregateBytes: 8 }); - const overCap = cappedDecoder.push(Buffer.from("x".repeat(64), "utf8")); - expect(overCap.length).toBe(1); - const rejection = overCap[0]; - expect(rejection.ok).toBe(false); - expect(rejection.error?.code).toBe("aggregate_bytes_exceeded"); - expect(cappedDecoder.bytesInUse).toBe(0); - expect(cappedDecoder.aggregateRejections).toBe(1); - - // The host decoder charges the host aggregate byte ledger under the - // `decoder_buffer` owner. It never uses the sandbox scope. This proves the two - // implementations use distinct scopes: the host uses the injected ledger; the - // sandbox uses its local counter. - const ledger = new DuplexAggregateByteLedger({ ceilingBytes: 1024 }); - const hostDecoder = new DuplexFrameDecoder({ aggregateByteLedger: ledger }); - hostDecoder.push(Buffer.from('{"version":1,', "utf8")); - expect(ledger.bytesInUse).toBe(Buffer.byteLength('{"version":1,', "utf8")); - expect(ledger.liveTokenCount).toBeGreaterThan(0); - hostDecoder.dispose(); - expect(ledger.bytesInUse).toBe(0); - expect(ledger.liveTokenCount).toBe(0); + // The host encode side produces the same bytes for the same frame, so the + // two copies stay wire compatible on the one frame the gateway still sends. + expect(encoded).toBe(encodeDuplexFrame({ version: DUPLEX_FRAME_VERSION, type: "ready", nonce })); }); }); diff --git a/packages/adapter-utils/src/execution-target.ts b/packages/adapter-utils/src/execution-target.ts index 123cf72d3e4..3f028b3cb63 100644 --- a/packages/adapter-utils/src/execution-target.ts +++ b/packages/adapter-utils/src/execution-target.ts @@ -2,7 +2,6 @@ import fs from "node:fs/promises"; import net from "node:net"; import os from "node:os"; import path from "node:path"; -import { Readable } from "node:stream"; import { randomBytes, randomUUID } from "node:crypto"; import type { SshRemoteExecutionSpec } from "./ssh.js"; import { @@ -33,7 +32,6 @@ import { createSandboxCallbackBridgeAsset, createSandboxCallbackBridgeToken, DEFAULT_SANDBOX_CALLBACK_BRIDGE_MAX_BODY_BYTES, - DEFAULT_SANDBOX_DUPLEX_DECODER_MAX_BYTES, SANDBOX_CALLBACK_BRIDGE_ENTRYPOINT, SANDBOX_CALLBACK_BRIDGE_HTTP2_MODE, sandboxCallbackBridgeDirectories, @@ -57,7 +55,6 @@ import { type DuplexBrokerRunDisposition, } from "./bridge-transport-contract.js"; import { decodeDuplexLine, DEFAULT_MAX_DUPLEX_FRAME_BYTES } from "./duplex-frame-codec.js"; -import type { ReassembledBody } from "./duplex-body-spool.js"; import { createDuplexObservability, mapHttp2EventToDuplexLossReason, @@ -3691,12 +3688,6 @@ export async function startAdapterExecutionTargetPaperclipBridge(input: { signal?: AbortSignal, options?: { suppressDebugLog?: boolean; - /** - * The duplex broker passes the reassembled request body here. The forward - * streams it to the host, so a spilled body never loads into memory on the - * receive side. The forward never disposes it; the broker owns its lifecycle. - */ - reassembledBody?: ReassembledBody; }, ): Promise<{ status: number; headers: Record; body: string }> => { const method = request.method.trim().toUpperCase() || "GET"; @@ -3722,24 +3713,15 @@ export async function startAdapterExecutionTargetPaperclipBridge(input: { // the forward budget here, whichever comes first. const timeoutSignal = AbortSignal.timeout(forwardTimeoutMs); const forwardSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal; - // Build the request-body init. A GET or a HEAD carries no body. The duplex - // broker passes the reassembled body: stream it, so a body that spilled to - // disk never loads into memory here. A streamed body needs `duplex: "half"`. - // The file bridge passes the whole body as one string. - const forwardInit: RequestInit & { duplex?: "half" } = { + // Build the request-body init. A GET or a HEAD carries no body. The file + // bridge passes the whole body as one string. + const forwardInit: RequestInit = { method, headers, signal: forwardSignal, }; - if (method !== "GET" && method !== "HEAD") { - if (options?.reassembledBody) { - forwardInit.body = Readable.toWeb( - options.reassembledBody.createReadStream(), - ) as unknown as ReadableStream; - forwardInit.duplex = "half"; - } else if (typeof request.body === "string") { - forwardInit.body = request.body; - } + if (method !== "GET" && method !== "HEAD" && typeof request.body === "string") { + forwardInit.body = request.body; } const response = await fetch(buildBridgeForwardUrl(hostApiUrl, request), forwardInit); if (emitDebugLog) { @@ -3873,11 +3855,6 @@ export async function startAdapterExecutionTargetPaperclipBridge(input: { PAPERCLIP_BRIDGE_PORT: String(assignedPort), PAPERCLIP_BRIDGE_NONCE: nonce, PAPERCLIP_BRIDGE_MAX_BODY_BYTES: String(maxBodyBytes), - // The separate sandbox-process raw-decoder cap. The generated gateway runs - // in a different operating-system process, so it cannot share the host - // aggregate byte ledger. It enforces this cap locally under the - // `sandbox_process` scope, and the provider memory allocation bounds it. - PAPERCLIP_BRIDGE_MAX_DUPLEX_DECODER_BYTES: String(DEFAULT_SANDBOX_DUPLEX_DECODER_MAX_BYTES), }; const command = buildDuplexGatewayLaunchArgv({ shellCommand, diff --git a/packages/adapter-utils/src/sandbox-callback-bridge.test.ts b/packages/adapter-utils/src/sandbox-callback-bridge.test.ts index 07e24ec155d..3ad63d9d870 100644 --- a/packages/adapter-utils/src/sandbox-callback-bridge.test.ts +++ b/packages/adapter-utils/src/sandbox-callback-bridge.test.ts @@ -3075,8 +3075,12 @@ describe("sandbox callback bridge", () => { const newlineIndex = firstBytes.indexOf(0x0a); expect(newlineIndex).toBeGreaterThan(0); + // Assert the exact UTF-8 bytes, not a parsed-and-matched object. + // `JSON.stringify` writes keys in the object-literal insertion order, so a + // reordered or reformatted call site at the gateway's one `writeFrame` call + // would change the bytes on the wire without failing a looser assertion. const readyLine = firstBytes.subarray(0, newlineIndex).toString("utf8"); - expect(JSON.parse(readyLine)).toMatchObject({ type: "ready", nonce }); + expect(readyLine).toBe(`{"version":2,"type":"ready","nonce":"${nonce}"}`); const afterReady = firstBytes.subarray(newlineIndex + 1, newlineIndex + 1 + preface.length); expect(afterReady).toEqual(preface); }, 15_000); diff --git a/packages/adapter-utils/src/sandbox-callback-bridge.ts b/packages/adapter-utils/src/sandbox-callback-bridge.ts index cee3ce0b1d9..dcbb1e7892f 100644 --- a/packages/adapter-utils/src/sandbox-callback-bridge.ts +++ b/packages/adapter-utils/src/sandbox-callback-bridge.ts @@ -21,14 +21,6 @@ const DEFAULT_BRIDGE_RESPONSE_TIMEOUT_MS = 30_000; const DEFAULT_BRIDGE_STOP_TIMEOUT_MS = 2_000; const DEFAULT_BRIDGE_MAX_QUEUE_DEPTH = 64; const DEFAULT_BRIDGE_MAX_BODY_BYTES = 256 * 1024; -// The default cap on the aggregate raw bytes the in-sandbox duplex frame decoder -// retains between chunks. The generated gateway runs in a separate operating-system -// process, so it cannot share the host aggregate byte ledger. It enforces this -// separate cap locally under the `sandbox_process` scope, and the provider memory -// allocation bounds it. The host passes the value through -// PAPERCLIP_BRIDGE_MAX_DUPLEX_DECODER_BYTES. The default is well above one maximum -// frame, so a legitimate single frame never trips it. -const DEFAULT_BRIDGE_MAX_DUPLEX_DECODER_BYTES = 8 * 1024 * 1024; // Per-iteration timeout for one poll-loop client call. A healthy control-plane // round trip finishes in well under one second, so 10s is far above a normal // iteration and never false-fires on a slow-but-live call. It is also well @@ -95,18 +87,6 @@ const CALLBACK_BRIDGE_WORKER_FAILED_SPAN = "sandbox.callbackBridge.workerFailed" export const DEFAULT_SANDBOX_CALLBACK_BRIDGE_MAX_BODY_BYTES = DEFAULT_BRIDGE_MAX_BODY_BYTES; -/** - * The default cap on the aggregate raw bytes the generated in-sandbox duplex frame - * decoder retains. The host passes it through - * `PAPERCLIP_BRIDGE_MAX_DUPLEX_DECODER_BYTES`. The in-sandbox decoder enforces the - * cap locally under the `sandbox_process` scope; it never touches the host - * aggregate byte ledger. - */ -export const DEFAULT_SANDBOX_DUPLEX_DECODER_MAX_BYTES = DEFAULT_BRIDGE_MAX_DUPLEX_DECODER_BYTES; - -/** The scope label the in-sandbox duplex decoder reports for its local byte counter. */ -export const SANDBOX_DUPLEX_DECODER_SCOPE = "sandbox_process"; - export interface SandboxCallbackBridgeRouteRule { method: string; path: RegExp; @@ -2027,246 +2007,30 @@ export function createSandboxHttp2BridgeGateway( /** * The zero-dependency codec the generated duplex gateway embeds. It is a plain - * JavaScript copy of the host codec in `duplex-frame-codec.ts`. It uses only the - * `Buffer`, `JSON`, `Object`, `Set`, and `Array` globals, so the generated - * `.mjs` needs no workspace import. It carries no template literal and no `${` - * sequence, so it embeds inside the gateway template literal with no escape. + * JavaScript copy of the encode side of the host codec in `duplex-frame-codec.ts`. + * The generated gateway never decodes: it writes exactly one READY line, then + * hands stdout to its transport. So this copy carries only the frame version and + * the encode function, not a decoder. It uses only the `Buffer` and `JSON` + * globals, so the generated `.mjs` needs no workspace import. It carries no + * template literal and no `${` sequence, so it embeds inside the gateway + * template literal with no escape. * - * A shared fixture file (`duplex-frame-vectors.json`) proves this copy stays wire - * compatible with the host codec. {@link getSandboxDuplexGatewayCodecSource} - * returns this exact source so a test can run every fixture vector against it. + * A shared fixture file (`duplex-frame-vectors.json`) proves the host encode side + * stays wire compatible with this copy. {@link getSandboxDuplexGatewayCodecSource} + * returns this exact source so a test can run the READY encode vector against it. */ const DUPLEX_GATEWAY_CODEC_SOURCE = `const DUPLEX_FRAME_VERSION = 2; -const DUPLEX_BODY_CHUNK_RAW_BYTES = 256 * 1024; -const DEFAULT_MAX_DUPLEX_FRAME_BYTES = 1000000; -const DEFAULT_MAX_DUPLEX_REQUEST_ID_BYTES = 256; -const DEFAULT_MAX_DUPLEX_DECODER_BYTES = ${DEFAULT_BRIDGE_MAX_DUPLEX_DECODER_BYTES}; -const DUPLEX_DECODER_SCOPE = "${SANDBOX_DUPLEX_DECODER_SCOPE}"; -const DUPLEX_NEWLINE_BYTE = 0x0a; -const DUPLEX_EMPTY = Buffer.alloc(0); -const DUPLEX_RESPONSE_OUTCOMES = new Set(["completed", "indeterminate", "unavailable"]); - -function duplexOk(frame) { - return { ok: true, frame: frame }; -} - -function duplexFail(code, message) { - return { ok: false, error: { code: code, message: message } }; -} - -function duplexIsPlainObject(value) { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function duplexIsStringRecord(value) { - if (!duplexIsPlainObject(value)) return false; - for (const entry of Object.values(value)) { - if (typeof entry !== "string") return false; - } - return true; -} - -function duplexIsSafeNonNegativeInteger(value) { - return typeof value === "number" && Number.isSafeInteger(value) && value >= 0; -} function encodeDuplexFrame(frame) { return JSON.stringify(frame) + "\\n"; -} - -function encodeDuplexFrameChecked(frame, maxFrameBytes) { - const limit = maxFrameBytes != null ? maxFrameBytes : DEFAULT_MAX_DUPLEX_FRAME_BYTES; - const json = JSON.stringify(frame); - if (Buffer.byteLength(json, "utf8") > limit) { - return { ok: false, error: { code: "frame_too_large", message: "frame exceeds the maximum size" } }; - } - return { ok: true, line: json + "\\n" }; -} - -function decodeDuplexLine(line) { - const text = typeof line === "string" ? line : line.toString("utf8"); - let parsed; - try { - parsed = JSON.parse(text); - } catch (error) { - return duplexFail("malformed_frame", "frame is not valid JSON"); - } - if (!duplexIsPlainObject(parsed)) { - return duplexFail("malformed_frame", "frame is not a JSON object"); - } - if (parsed.version !== DUPLEX_FRAME_VERSION) { - return duplexFail("version_mismatch", "frame version " + String(parsed.version) + " is not " + DUPLEX_FRAME_VERSION); - } - return duplexValidateFrame(parsed); -} - -function duplexValidateFrame(frame) { - switch (frame.type) { - case "request": - return duplexValidateRequest(frame); - case "response": - return duplexValidateResponse(frame); - case "body_chunk": - return duplexValidateBodyChunk(frame); - case "ready": - return duplexValidateReady(frame); - case "heartbeat": - return duplexOk(frame); - case "close": - return duplexOk(frame); - case "error": - return duplexValidateError(frame); - default: - return duplexFail("unknown_type", "unknown frame type " + JSON.stringify(frame.type)); - } -} - -function duplexValidateRequest(frame) { - if ( - typeof frame.id !== "string" || - typeof frame.method !== "string" || - typeof frame.path !== "string" || - typeof frame.query !== "string" || - !duplexIsSafeNonNegativeInteger(frame.bodyByteCount) || - !duplexIsStringRecord(frame.headers) - ) { - return duplexFail("malformed_frame", "request frame has a missing or wrong-typed field"); - } - if (Buffer.byteLength(frame.id, "utf8") > DEFAULT_MAX_DUPLEX_REQUEST_ID_BYTES) { - return duplexFail("id_too_large", "request frame id exceeds the maximum size"); - } - return duplexOk(frame); -} - -function duplexValidateResponse(frame) { - if ( - typeof frame.id !== "string" || - typeof frame.status !== "number" || - !duplexIsSafeNonNegativeInteger(frame.bodyByteCount) || - !duplexIsStringRecord(frame.headers) || - typeof frame.outcome !== "string" || - !DUPLEX_RESPONSE_OUTCOMES.has(frame.outcome) - ) { - return duplexFail("malformed_frame", "response frame has a missing or wrong-typed field"); - } - if (Buffer.byteLength(frame.id, "utf8") > DEFAULT_MAX_DUPLEX_REQUEST_ID_BYTES) { - return duplexFail("id_too_large", "response frame id exceeds the maximum size"); - } - return duplexOk(frame); -} - -function duplexValidateBodyChunk(frame) { - if (typeof frame.id !== "string" || typeof frame.data !== "string") { - return duplexFail("malformed_frame", "body_chunk frame has a missing or wrong-typed field"); - } - if (!duplexIsSafeNonNegativeInteger(frame.seq)) { - return duplexFail("malformed_frame", "body_chunk frame has a missing or wrong-typed seq"); - } - if (Buffer.byteLength(frame.id, "utf8") > DEFAULT_MAX_DUPLEX_REQUEST_ID_BYTES) { - return duplexFail("id_too_large", "body_chunk frame id exceeds the maximum size"); - } - return duplexOk(frame); -} - -function duplexValidateReady(frame) { - if (typeof frame.nonce !== "string") { - return duplexFail("malformed_frame", "ready frame has a missing or wrong-typed nonce"); - } - for (const key of Object.keys(frame)) { - if (key !== "version" && key !== "type" && key !== "nonce") { - return duplexFail("malformed_frame", "ready frame has an unexpected field"); - } - } - return duplexOk(frame); -} - -function duplexValidateError(frame) { - if (typeof frame.code !== "string") { - return duplexFail("malformed_frame", "error frame has a missing or wrong-typed code"); - } - if (frame.message !== undefined && typeof frame.message !== "string") { - return duplexFail("malformed_frame", "error frame has a wrong-typed message"); - } - return duplexOk(frame); -} - -class DuplexFrameDecoder { - constructor(options) { - this.buffer = DUPLEX_EMPTY; - this.discarding = false; - this.maxFrameBytes = - options && options.maxFrameBytes != null ? options.maxFrameBytes : DEFAULT_MAX_DUPLEX_FRAME_BYTES; - // The in-sandbox decoder runs in a separate operating-system process, so it - // cannot share the host aggregate byte ledger. It bounds its own retained - // bytes with a local cap under the "sandbox_process" scope. The counters here - // never increment or release a host aggregate token. - this.scope = DUPLEX_DECODER_SCOPE; - this.maxAggregateBytes = - options && options.maxAggregateBytes != null - ? options.maxAggregateBytes - : DEFAULT_MAX_DUPLEX_DECODER_BYTES; - this.bytesInUse = 0; - this.aggregateRejections = 0; - } - - push(chunk) { - const incoming = typeof chunk === "string" ? Buffer.from(chunk, "utf8") : Buffer.from(chunk); - // Enforce the local sandbox-process cap before the concat allocates the peak - // "old + incoming" buffer. A rejection fails closed: the decoder drops the - // retained buffer and the incoming chunk, resynchronizes at the next newline, - // and reports the aggregate rejection. It retains nothing over the cap. - if (this.buffer.length + incoming.length > this.maxAggregateBytes) { - this.buffer = DUPLEX_EMPTY; - this.discarding = true; - this.bytesInUse = 0; - this.aggregateRejections += 1; - return [duplexFail("aggregate_bytes_exceeded", "aggregate retained bytes exceeded the sandbox-process cap")]; - } - this.buffer = this.buffer.length === 0 ? incoming : Buffer.concat([this.buffer, incoming]); - const results = []; - for (;;) { - if (this.discarding) { - const newlineIndex = this.buffer.indexOf(DUPLEX_NEWLINE_BYTE); - if (newlineIndex === -1) { - this.buffer = DUPLEX_EMPTY; - break; - } - this.buffer = this.buffer.subarray(newlineIndex + 1); - this.discarding = false; - continue; - } - const newlineIndex = this.buffer.indexOf(DUPLEX_NEWLINE_BYTE); - if (newlineIndex === -1) { - if (this.buffer.length > this.maxFrameBytes) { - results.push(duplexFail("frame_too_large", "frame exceeds the maximum size")); - this.discarding = true; - this.buffer = DUPLEX_EMPTY; - } - break; - } - const line = this.buffer.subarray(0, newlineIndex); - this.buffer = this.buffer.subarray(newlineIndex + 1); - if (line.length === 0) continue; - if (line.length > this.maxFrameBytes) { - results.push(duplexFail("frame_too_large", "frame exceeds the maximum size")); - continue; - } - results.push(decodeDuplexLine(line)); - } - // Reconcile the local sandbox-process counter to the bytes still retained. - this.bytesInUse = this.buffer.length; - return results; - } }`; /** * Return the exact zero-dependency codec source the generated duplex gateway - * embeds. A test runs every fixture vector against this source, so it proves the - * embedded copy decodes the same bytes as the host codec. The source declares - * `encodeDuplexFrame`, `encodeDuplexFrameChecked`, `decodeDuplexLine`, - * `DuplexFrameDecoder`, `DUPLEX_FRAME_VERSION`, and - * `DEFAULT_MAX_DUPLEX_FRAME_BYTES`, but exports none of them; a caller wraps it to - * read those names. + * embeds. A test wraps this source and calls `encodeDuplexFrame` to prove the + * embedded copy encodes the READY frame the same way the host encode side does. + * The source declares `encodeDuplexFrame` and `DUPLEX_FRAME_VERSION`, but exports + * neither; a caller wraps it to read those names. */ export function getSandboxDuplexGatewayCodecSource(): string { return DUPLEX_GATEWAY_CODEC_SOURCE; From 0cedb45df3ed830baf5293cabe59ff03bb6091b2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 25 Aug 2026 14:49:05 -0700 Subject: [PATCH 18/32] build(deps-dev): bump typescript from 5.9.3 to 7.0.2 (#11880) Bumps [typescript](https://github.com/microsoft/TypeScript) from 5.9.3 to 7.0.2.

Release notes

Sourced from typescript's releases.

TypeScript 7.0.2

https://devblogs.microsoft.com/typescript/announcing-typescript-7-0/

This tag was originally released at: https://github.com/microsoft/typescript-go/releases/tag/typescript%2Fv7.0.2

TypeScript 6.0.3

For release notes, check out the release announcement blog post.

Downloads are available on:

TypeScript 6.0

For release notes, check out the release announcement blog post.

Downloads are available on:

TypeScript 6.0.1 RC

For release notes, check out the release announcement blog post.

Downloads are available on:

TypeScript 6.0 Beta

For release notes, check out the release announcement.

Downloads are available on:

Commits
  • 1e4744d Merge branch 'main' into ts7-release
  • a5a219cmicrosoft/typescript-go#4558
  • ecfe30d Update status localization
  • 5de25b5 Hide executable name in TypeScript status
  • d7ce74a Show bundled TypeScript version for packaged servers
  • 29be66a Correct TS 7 release version to 7.0.2
  • ed2bd1b Merge branch 'main' into ts7-release
  • 8873075 Bump the github-actions group across 1 directory with 3 updates (microsoft/ty...
  • 9427131 Set up stable / nightly extension split, other prep (microsoft/typescript-go#...
  • d4eaca5microsoft/typescript-go#4549
  • Additional commits viewable in compare view
Maintainer changes

This version was pushed to npm by microsoft1es, a new releaser for typescript since your current version.


--------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Priya Raman Co-authored-by: Paperclip --- cli/package.json | 2 +- package.json | 2 +- packages/adapter-utils/package.json | 2 +- packages/adapters/claude-local/package.json | 2 +- packages/adapters/codex-local/package.json | 2 +- packages/adapters/cursor-cloud/package.json | 2 +- packages/adapters/cursor-local/package.json | 2 +- packages/adapters/gemini-local/package.json | 2 +- packages/adapters/grok-local/package.json | 2 +- packages/adapters/hermes-gateway/package.json | 2 +- packages/adapters/hermes/package.json | 2 +- packages/adapters/kimi-local/package.json | 2 +- .../adapters/openclaw-gateway/package.json | 2 +- packages/adapters/opencode-local/package.json | 2 +- packages/adapters/pi-local/package.json | 2 +- packages/db/package.json | 2 +- .../google-sheets-mcp-server/package.json | 2 +- packages/kv-demo-mcp-server/package.json | 2 +- packages/mcp-server/package.json | 2 +- packages/paperclip-runner/package.json | 2 +- .../create-paperclip-plugin/package.json | 2 +- .../package.json | 2 +- .../plugin-file-browser-example/package.json | 2 +- .../plugin-hello-world-example/package.json | 2 +- .../plugin-kitchen-sink-example/package.json | 2 +- .../package.json | 2 +- .../package.json | 2 +- packages/plugins/plugin-llm-wiki/package.json | 2 +- .../plugins/plugin-llm-wiki/tsconfig.json | 1 + .../plugin-workspace-diff/package.json | 2 +- .../plugin-workspace-diff/tsconfig.json | 1 + packages/plugins/sdk/package.json | 2 +- packages/shared/package.json | 2 +- packages/tailscale-https-broker/package.json | 2 +- pnpm-lock.yaml | 757 +++++++++++------- server/package.json | 2 +- tsconfig.base.json | 1 + ui/package.json | 2 +- 38 files changed, 516 insertions(+), 312 deletions(-) diff --git a/cli/package.json b/cli/package.json index 47e34f1a593..43893476693 100644 --- a/cli/package.json +++ b/cli/package.json @@ -61,7 +61,7 @@ "devDependencies": { "@types/node": "^24.0.0", "tsx": "^4.23.12", - "typescript": "^5.7.3" + "typescript": "^7.0.2" }, "engines": { "node": ">=24.11.0" diff --git a/package.json b/package.json index 51b94466634..9dfde2719f8 100644 --- a/package.json +++ b/package.json @@ -73,7 +73,7 @@ "@playwright/test": "^1.62.1", "cross-env": "^10.1.0", "esbuild": "^0.28.2", - "typescript": "^5.7.3", + "typescript": "^7.0.2", "vitest": "^4.1.10" }, "engines": { diff --git a/packages/adapter-utils/package.json b/packages/adapter-utils/package.json index 7e5d745171d..cfa8c311267 100644 --- a/packages/adapter-utils/package.json +++ b/packages/adapter-utils/package.json @@ -48,7 +48,7 @@ }, "devDependencies": { "@types/node": "^24.0.0", - "typescript": "^5.7.3" + "typescript": "^7.0.2" }, "engines": { "node": ">=24.11.0" diff --git a/packages/adapters/claude-local/package.json b/packages/adapters/claude-local/package.json index fdcbe8fe2ca..a645a137099 100644 --- a/packages/adapters/claude-local/package.json +++ b/packages/adapters/claude-local/package.json @@ -60,7 +60,7 @@ }, "devDependencies": { "@types/node": "^24.0.0", - "typescript": "^5.7.3" + "typescript": "^7.0.2" }, "engines": { "node": ">=24.11.0" diff --git a/packages/adapters/codex-local/package.json b/packages/adapters/codex-local/package.json index a5812ceee8b..55360167c81 100644 --- a/packages/adapters/codex-local/package.json +++ b/packages/adapters/codex-local/package.json @@ -59,7 +59,7 @@ }, "devDependencies": { "@types/node": "^24.0.0", - "typescript": "^5.7.3" + "typescript": "^7.0.2" }, "engines": { "node": ">=24.11.0" diff --git a/packages/adapters/cursor-cloud/package.json b/packages/adapters/cursor-cloud/package.json index 24339e59f4d..665f1d2d188 100644 --- a/packages/adapters/cursor-cloud/package.json +++ b/packages/adapters/cursor-cloud/package.json @@ -56,7 +56,7 @@ }, "devDependencies": { "@types/node": "^24.0.0", - "typescript": "^5.7.3" + "typescript": "^7.0.2" }, "engines": { "node": ">=24.11.0" diff --git a/packages/adapters/cursor-local/package.json b/packages/adapters/cursor-local/package.json index 45f7cd846f5..15f36aa21c8 100644 --- a/packages/adapters/cursor-local/package.json +++ b/packages/adapters/cursor-local/package.json @@ -56,7 +56,7 @@ }, "devDependencies": { "@types/node": "^24.0.0", - "typescript": "^5.7.3" + "typescript": "^7.0.2" }, "engines": { "node": ">=24.11.0" diff --git a/packages/adapters/gemini-local/package.json b/packages/adapters/gemini-local/package.json index 787e5ba00cd..373f2c439ea 100644 --- a/packages/adapters/gemini-local/package.json +++ b/packages/adapters/gemini-local/package.json @@ -56,7 +56,7 @@ }, "devDependencies": { "@types/node": "^24.0.0", - "typescript": "^5.7.3" + "typescript": "^7.0.2" }, "engines": { "node": ">=24.11.0" diff --git a/packages/adapters/grok-local/package.json b/packages/adapters/grok-local/package.json index 70043ee0a2b..381b23139ea 100644 --- a/packages/adapters/grok-local/package.json +++ b/packages/adapters/grok-local/package.json @@ -55,7 +55,7 @@ }, "devDependencies": { "@types/node": "^24.0.0", - "typescript": "^5.7.3" + "typescript": "^7.0.2" }, "engines": { "node": ">=24.11.0" diff --git a/packages/adapters/hermes-gateway/package.json b/packages/adapters/hermes-gateway/package.json index 02c49f0e8bc..95beb0c0ba0 100644 --- a/packages/adapters/hermes-gateway/package.json +++ b/packages/adapters/hermes-gateway/package.json @@ -72,7 +72,7 @@ }, "devDependencies": { "@types/node": "^24.0.0", - "typescript": "^5.7.3", + "typescript": "^7.0.2", "vitest": "^4.1.10" }, "engines": { diff --git a/packages/adapters/hermes/package.json b/packages/adapters/hermes/package.json index e43f9e97e1d..8c605b23860 100644 --- a/packages/adapters/hermes/package.json +++ b/packages/adapters/hermes/package.json @@ -104,7 +104,7 @@ }, "devDependencies": { "@types/node": "^24.0.0", - "typescript": "^5.7.3", + "typescript": "^7.0.2", "vitest": "^4.1.10" }, "engines": { diff --git a/packages/adapters/kimi-local/package.json b/packages/adapters/kimi-local/package.json index d4007900555..3d04dd45baa 100644 --- a/packages/adapters/kimi-local/package.json +++ b/packages/adapters/kimi-local/package.json @@ -55,7 +55,7 @@ }, "devDependencies": { "@types/node": "^24.0.0", - "typescript": "^5.7.3" + "typescript": "^7.0.2" }, "engines": { "node": ">=24.11.0" diff --git a/packages/adapters/openclaw-gateway/package.json b/packages/adapters/openclaw-gateway/package.json index e8d110c52ae..892f5e25cac 100644 --- a/packages/adapters/openclaw-gateway/package.json +++ b/packages/adapters/openclaw-gateway/package.json @@ -57,7 +57,7 @@ "devDependencies": { "@types/node": "^24.0.0", "@types/ws": "^8.18.1", - "typescript": "^5.7.3" + "typescript": "^7.0.2" }, "engines": { "node": ">=24.11.0" diff --git a/packages/adapters/opencode-local/package.json b/packages/adapters/opencode-local/package.json index 42a4687880f..5035cc5a774 100644 --- a/packages/adapters/opencode-local/package.json +++ b/packages/adapters/opencode-local/package.json @@ -56,7 +56,7 @@ }, "devDependencies": { "@types/node": "^24.0.0", - "typescript": "^5.7.3" + "typescript": "^7.0.2" }, "engines": { "node": ">=24.11.0" diff --git a/packages/adapters/pi-local/package.json b/packages/adapters/pi-local/package.json index b9d258bf45b..421b3e97e0d 100644 --- a/packages/adapters/pi-local/package.json +++ b/packages/adapters/pi-local/package.json @@ -55,7 +55,7 @@ }, "devDependencies": { "@types/node": "^24.0.0", - "typescript": "^5.7.3" + "typescript": "^7.0.2" }, "engines": { "node": ">=24.11.0" diff --git a/packages/db/package.json b/packages/db/package.json index 50e6d11a8b8..01a90c1b7fa 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -56,7 +56,7 @@ "@types/node": "^24.0.0", "drizzle-kit": "^0.31.10", "tsx": "^4.23.12", - "typescript": "^5.7.3", + "typescript": "^7.0.2", "vitest": "^4.1.10" }, "engines": { diff --git a/packages/google-sheets-mcp-server/package.json b/packages/google-sheets-mcp-server/package.json index 0b602c1246f..8ac02e5adaf 100644 --- a/packages/google-sheets-mcp-server/package.json +++ b/packages/google-sheets-mcp-server/package.json @@ -51,7 +51,7 @@ }, "devDependencies": { "@types/node": "^24.0.0", - "typescript": "^5.7.3", + "typescript": "^7.0.2", "vitest": "^4.1.10" }, "engines": { diff --git a/packages/kv-demo-mcp-server/package.json b/packages/kv-demo-mcp-server/package.json index bc2701878d1..08969758201 100644 --- a/packages/kv-demo-mcp-server/package.json +++ b/packages/kv-demo-mcp-server/package.json @@ -49,7 +49,7 @@ }, "devDependencies": { "@types/node": "^24.0.0", - "typescript": "^5.7.3", + "typescript": "^7.0.2", "vitest": "^4.1.10" }, "engines": { diff --git a/packages/mcp-server/package.json b/packages/mcp-server/package.json index 371ac8e5e56..0db21264706 100644 --- a/packages/mcp-server/package.json +++ b/packages/mcp-server/package.json @@ -49,7 +49,7 @@ }, "devDependencies": { "@types/node": "^24.0.0", - "typescript": "^5.7.3", + "typescript": "^7.0.2", "vitest": "^4.1.10" }, "engines": { diff --git a/packages/paperclip-runner/package.json b/packages/paperclip-runner/package.json index fdc7ec5fd5c..06f1c933425 100644 --- a/packages/paperclip-runner/package.json +++ b/packages/paperclip-runner/package.json @@ -57,7 +57,7 @@ }, "devDependencies": { "@types/node": "^24.0.0", - "typescript": "^5.7.3", + "typescript": "^7.0.2", "vitest": "^4.1.10" } } diff --git a/packages/plugins/create-paperclip-plugin/package.json b/packages/plugins/create-paperclip-plugin/package.json index d83b8cd4da6..efba5eb49ce 100644 --- a/packages/plugins/create-paperclip-plugin/package.json +++ b/packages/plugins/create-paperclip-plugin/package.json @@ -46,7 +46,7 @@ }, "devDependencies": { "@types/node": "^24.0.0", - "typescript": "^5.7.3" + "typescript": "^7.0.2" }, "engines": { "node": ">=24.11.0" diff --git a/packages/plugins/examples/plugin-authoring-smoke-example/package.json b/packages/plugins/examples/plugin-authoring-smoke-example/package.json index 7c6a5702d28..a9a49e2fdae 100644 --- a/packages/plugins/examples/plugin-authoring-smoke-example/package.json +++ b/packages/plugins/examples/plugin-authoring-smoke-example/package.json @@ -36,7 +36,7 @@ "esbuild": "^0.28.2", "rollup": "^4.62.4", "tslib": "^2.8.1", - "typescript": "^5.7.3", + "typescript": "^7.0.2", "vitest": "^4.1.10" }, "peerDependencies": { diff --git a/packages/plugins/examples/plugin-file-browser-example/package.json b/packages/plugins/examples/plugin-file-browser-example/package.json index 9b404cedc6a..11d001449f0 100644 --- a/packages/plugins/examples/plugin-file-browser-example/package.json +++ b/packages/plugins/examples/plugin-file-browser-example/package.json @@ -34,7 +34,7 @@ "esbuild": "^0.28.2", "react": "^19.2.8", "react-dom": "^19.2.8", - "typescript": "^5.7.3" + "typescript": "^7.0.2" }, "peerDependencies": { "react": ">=18" diff --git a/packages/plugins/examples/plugin-hello-world-example/package.json b/packages/plugins/examples/plugin-hello-world-example/package.json index b2b8a436864..5b76de029ff 100644 --- a/packages/plugins/examples/plugin-hello-world-example/package.json +++ b/packages/plugins/examples/plugin-hello-world-example/package.json @@ -27,7 +27,7 @@ "@types/react-dom": "^19.2.4", "react": "^19.2.8", "react-dom": "^19.2.8", - "typescript": "^5.7.3" + "typescript": "^7.0.2" }, "peerDependencies": { "react": ">=18" diff --git a/packages/plugins/examples/plugin-kitchen-sink-example/package.json b/packages/plugins/examples/plugin-kitchen-sink-example/package.json index 2b405c060ea..3a60b444a39 100644 --- a/packages/plugins/examples/plugin-kitchen-sink-example/package.json +++ b/packages/plugins/examples/plugin-kitchen-sink-example/package.json @@ -29,7 +29,7 @@ "@types/react-dom": "^19.2.4", "react": "^19.2.8", "react-dom": "^19.2.8", - "typescript": "^5.7.3" + "typescript": "^7.0.2" }, "peerDependencies": { "react": ">=18" diff --git a/packages/plugins/examples/plugin-orchestration-smoke-example/package.json b/packages/plugins/examples/plugin-orchestration-smoke-example/package.json index 79e5635ecbc..1fed89f625d 100644 --- a/packages/plugins/examples/plugin-orchestration-smoke-example/package.json +++ b/packages/plugins/examples/plugin-orchestration-smoke-example/package.json @@ -37,7 +37,7 @@ "esbuild": "^0.28.2", "rollup": "^4.62.4", "tslib": "^2.8.1", - "typescript": "^5.7.3", + "typescript": "^7.0.2", "vitest": "^4.1.10" }, "peerDependencies": { diff --git a/packages/plugins/paperclip-plugin-fake-sandbox/package.json b/packages/plugins/paperclip-plugin-fake-sandbox/package.json index be4d6ea04cd..e6db052e436 100644 --- a/packages/plugins/paperclip-plugin-fake-sandbox/package.json +++ b/packages/plugins/paperclip-plugin-fake-sandbox/package.json @@ -23,7 +23,7 @@ }, "devDependencies": { "@types/node": "^24.0.0", - "typescript": "^5.7.3", + "typescript": "^7.0.2", "vitest": "^4.1.10" }, "engines": { diff --git a/packages/plugins/plugin-llm-wiki/package.json b/packages/plugins/plugin-llm-wiki/package.json index 147f3a66ba8..81e861c5b61 100644 --- a/packages/plugins/plugin-llm-wiki/package.json +++ b/packages/plugins/plugin-llm-wiki/package.json @@ -46,7 +46,7 @@ "react-dom": "^19.2.8", "rollup": "^4.62.4", "tslib": "^2.8.1", - "typescript": "^5.7.3", + "typescript": "^7.0.2", "vitest": "^4.1.10" }, "peerDependencies": { diff --git a/packages/plugins/plugin-llm-wiki/tsconfig.json b/packages/plugins/plugin-llm-wiki/tsconfig.json index a697519ed9d..217c4899200 100644 --- a/packages/plugins/plugin-llm-wiki/tsconfig.json +++ b/packages/plugins/plugin-llm-wiki/tsconfig.json @@ -7,6 +7,7 @@ "ES2022", "DOM" ], + "types": ["node"], "jsx": "react-jsx", "strict": true, "skipLibCheck": true, diff --git a/packages/plugins/plugin-workspace-diff/package.json b/packages/plugins/plugin-workspace-diff/package.json index 107353beb45..4acccdfd258 100644 --- a/packages/plugins/plugin-workspace-diff/package.json +++ b/packages/plugins/plugin-workspace-diff/package.json @@ -61,7 +61,7 @@ "esbuild": "^0.28.2", "react": "^19.2.8", "react-dom": "^19.2.8", - "typescript": "^5.7.3", + "typescript": "^7.0.2", "vitest": "^4.1.10" }, "peerDependencies": { diff --git a/packages/plugins/plugin-workspace-diff/tsconfig.json b/packages/plugins/plugin-workspace-diff/tsconfig.json index cf31221062c..7e89f41217c 100644 --- a/packages/plugins/plugin-workspace-diff/tsconfig.json +++ b/packages/plugins/plugin-workspace-diff/tsconfig.json @@ -4,6 +4,7 @@ "module": "NodeNext", "moduleResolution": "NodeNext", "lib": ["ES2022", "DOM"], + "types": ["node"], "jsx": "react-jsx", "strict": true, "skipLibCheck": true, diff --git a/packages/plugins/sdk/package.json b/packages/plugins/sdk/package.json index 3b216adea1b..1dd9473bdc6 100644 --- a/packages/plugins/sdk/package.json +++ b/packages/plugins/sdk/package.json @@ -114,7 +114,7 @@ "devDependencies": { "@types/node": "^24.0.0", "@types/react": "^19.2.18", - "typescript": "^5.7.3" + "typescript": "^7.0.2" }, "peerDependencies": { "react": ">=18" diff --git a/packages/shared/package.json b/packages/shared/package.json index aeb8fe12353..8904376a17a 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -49,7 +49,7 @@ }, "devDependencies": { "@types/node": "^24.0.0", - "typescript": "^5.7.3" + "typescript": "^7.0.2" }, "engines": { "node": ">=24.11.0" diff --git a/packages/tailscale-https-broker/package.json b/packages/tailscale-https-broker/package.json index c20893b5d29..4abd196b2d8 100644 --- a/packages/tailscale-https-broker/package.json +++ b/packages/tailscale-https-broker/package.json @@ -19,7 +19,7 @@ }, "devDependencies": { "@types/node": "^24.0.0", - "typescript": "^5.7.3", + "typescript": "^7.0.2", "vitest": "^4.1.10" }, "engines": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b27eccf9ad3..579f1650117 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -41,8 +41,8 @@ importers: specifier: ^0.28.2 version: 0.28.2 typescript: - specifier: ^5.7.3 - version: 5.9.3 + specifier: ^7.0.2 + version: 7.0.2 vitest: specifier: ^4.1.10 version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@25.2.3)(jsdom@30.0.1(@noble/hashes@2.3.0))(vite@8.2.2(@types/node@25.2.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)) @@ -120,8 +120,8 @@ importers: specifier: ^4.23.12 version: 4.23.12 typescript: - specifier: ^5.7.3 - version: 5.9.3 + specifier: ^7.0.2 + version: 7.0.2 packages/adapter-utils: dependencies: @@ -136,8 +136,8 @@ importers: specifier: ^24.0.0 version: 24.13.3 typescript: - specifier: ^5.7.3 - version: 5.9.3 + specifier: ^7.0.2 + version: 7.0.2 packages/adapters/claude-local: dependencies: @@ -158,8 +158,8 @@ importers: specifier: ^24.0.0 version: 24.13.3 typescript: - specifier: ^5.7.3 - version: 5.9.3 + specifier: ^7.0.2 + version: 7.0.2 packages/adapters/codex-local: dependencies: @@ -180,8 +180,8 @@ importers: specifier: ^24.0.0 version: 24.13.3 typescript: - specifier: ^5.7.3 - version: 5.9.3 + specifier: ^7.0.2 + version: 7.0.2 packages/adapters/cursor-cloud: dependencies: @@ -199,8 +199,8 @@ importers: specifier: ^24.0.0 version: 24.13.3 typescript: - specifier: ^5.7.3 - version: 5.9.3 + specifier: ^7.0.2 + version: 7.0.2 packages/adapters/cursor-local: dependencies: @@ -215,8 +215,8 @@ importers: specifier: ^24.0.0 version: 24.13.3 typescript: - specifier: ^5.7.3 - version: 5.9.3 + specifier: ^7.0.2 + version: 7.0.2 packages/adapters/gemini-local: dependencies: @@ -231,8 +231,8 @@ importers: specifier: ^24.0.0 version: 24.13.3 typescript: - specifier: ^5.7.3 - version: 5.9.3 + specifier: ^7.0.2 + version: 7.0.2 packages/adapters/grok-local: dependencies: @@ -247,8 +247,8 @@ importers: specifier: ^24.0.0 version: 24.13.3 typescript: - specifier: ^5.7.3 - version: 5.9.3 + specifier: ^7.0.2 + version: 7.0.2 packages/adapters/hermes: dependencies: @@ -263,8 +263,8 @@ importers: specifier: ^24.0.0 version: 24.13.3 typescript: - specifier: ^5.7.3 - version: 5.9.3 + specifier: ^7.0.2 + version: 7.0.2 vitest: specifier: ^4.1.10 version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(jsdom@30.0.1(@noble/hashes@2.3.0))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)) @@ -279,8 +279,8 @@ importers: specifier: ^24.0.0 version: 24.13.3 typescript: - specifier: ^5.7.3 - version: 5.9.3 + specifier: ^7.0.2 + version: 7.0.2 vitest: specifier: ^4.1.10 version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(jsdom@30.0.1(@noble/hashes@2.3.0))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)) @@ -298,8 +298,8 @@ importers: specifier: ^24.0.0 version: 24.13.3 typescript: - specifier: ^5.7.3 - version: 5.9.3 + specifier: ^7.0.2 + version: 7.0.2 packages/adapters/openclaw-gateway: dependencies: @@ -320,8 +320,8 @@ importers: specifier: ^8.18.1 version: 8.18.1 typescript: - specifier: ^5.7.3 - version: 5.9.3 + specifier: ^7.0.2 + version: 7.0.2 packages/adapters/opencode-local: dependencies: @@ -336,8 +336,8 @@ importers: specifier: ^24.0.0 version: 24.13.3 typescript: - specifier: ^5.7.3 - version: 5.9.3 + specifier: ^7.0.2 + version: 7.0.2 packages/adapters/pi-local: dependencies: @@ -352,8 +352,8 @@ importers: specifier: ^24.0.0 version: 24.13.3 typescript: - specifier: ^5.7.3 - version: 5.9.3 + specifier: ^7.0.2 + version: 7.0.2 packages/db: dependencies: @@ -380,8 +380,8 @@ importers: specifier: ^4.23.12 version: 4.23.12 typescript: - specifier: ^5.7.3 - version: 5.9.3 + specifier: ^7.0.2 + version: 7.0.2 vitest: specifier: ^4.1.10 version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(jsdom@30.0.1(@noble/hashes@2.3.0))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)) @@ -402,8 +402,8 @@ importers: specifier: ^24.0.0 version: 24.13.3 typescript: - specifier: ^5.7.3 - version: 5.9.3 + specifier: ^7.0.2 + version: 7.0.2 vitest: specifier: ^4.1.10 version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(jsdom@30.0.1(@noble/hashes@2.3.0))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)) @@ -421,8 +421,8 @@ importers: specifier: ^24.0.0 version: 24.13.3 typescript: - specifier: ^5.7.3 - version: 5.9.3 + specifier: ^7.0.2 + version: 7.0.2 vitest: specifier: ^4.1.10 version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(jsdom@30.0.1(@noble/hashes@2.3.0))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)) @@ -443,8 +443,8 @@ importers: specifier: ^24.0.0 version: 24.13.3 typescript: - specifier: ^5.7.3 - version: 5.9.3 + specifier: ^7.0.2 + version: 7.0.2 vitest: specifier: ^4.1.10 version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(jsdom@30.0.1(@noble/hashes@2.3.0))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)) @@ -462,8 +462,8 @@ importers: specifier: ^24.0.0 version: 24.13.3 typescript: - specifier: ^5.7.3 - version: 5.9.3 + specifier: ^7.0.2 + version: 7.0.2 vitest: specifier: ^4.1.10 version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(jsdom@30.0.1(@noble/hashes@2.3.0))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)) @@ -478,8 +478,8 @@ importers: specifier: ^24.0.0 version: 24.13.3 typescript: - specifier: ^5.7.3 - version: 5.9.3 + specifier: ^7.0.2 + version: 7.0.2 packages/plugins/examples/plugin-authoring-smoke-example: dependencies: @@ -495,7 +495,7 @@ importers: version: 16.0.3(rollup@4.62.4) '@rollup/plugin-typescript': specifier: ^12.1.2 - version: 12.3.0(rollup@4.62.4)(tslib@2.8.1)(typescript@5.9.3) + version: 12.3.0(rollup@4.62.4)(tslib@2.8.1)(typescript@7.0.2) '@types/node': specifier: ^24.0.0 version: 24.13.3 @@ -512,8 +512,8 @@ importers: specifier: ^2.8.1 version: 2.8.1 typescript: - specifier: ^5.7.3 - version: 5.9.3 + specifier: ^7.0.2 + version: 7.0.2 vitest: specifier: ^4.1.10 version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(jsdom@30.0.1(@noble/hashes@2.3.0))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)) @@ -561,8 +561,8 @@ importers: specifier: ^19.2.8 version: 19.2.8(react@19.2.8) typescript: - specifier: ^5.7.3 - version: 5.9.3 + specifier: ^7.0.2 + version: 7.0.2 packages/plugins/examples/plugin-hello-world-example: dependencies: @@ -586,8 +586,8 @@ importers: specifier: ^19.2.8 version: 19.2.8(react@19.2.8) typescript: - specifier: ^5.7.3 - version: 5.9.3 + specifier: ^7.0.2 + version: 7.0.2 packages/plugins/examples/plugin-kitchen-sink-example: dependencies: @@ -617,8 +617,8 @@ importers: specifier: ^19.2.8 version: 19.2.8(react@19.2.8) typescript: - specifier: ^5.7.3 - version: 5.9.3 + specifier: ^7.0.2 + version: 7.0.2 packages/plugins/paperclip-plugin-fake-sandbox: dependencies: @@ -630,8 +630,8 @@ importers: specifier: ^24.0.0 version: 24.13.3 typescript: - specifier: ^5.7.3 - version: 5.9.3 + specifier: ^7.0.2 + version: 7.0.2 vitest: specifier: ^4.1.10 version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(jsdom@30.0.1(@noble/hashes@2.3.0))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)) @@ -650,7 +650,7 @@ importers: version: 16.0.3(rollup@4.62.4) '@rollup/plugin-typescript': specifier: ^12.1.2 - version: 12.3.0(rollup@4.62.4)(tslib@2.8.1)(typescript@5.9.3) + version: 12.3.0(rollup@4.62.4)(tslib@2.8.1)(typescript@7.0.2) '@types/node': specifier: ^24.0.0 version: 24.13.3 @@ -673,8 +673,8 @@ importers: specifier: ^2.8.1 version: 2.8.1 typescript: - specifier: ^5.7.3 - version: 5.9.3 + specifier: ^7.0.2 + version: 7.0.2 vitest: specifier: ^4.1.10 version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(jsdom@30.0.1(@noble/hashes@2.3.0))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)) @@ -707,8 +707,8 @@ importers: specifier: ^19.2.8 version: 19.2.8(react@19.2.8) typescript: - specifier: ^5.7.3 - version: 5.9.3 + specifier: ^7.0.2 + version: 7.0.2 vitest: specifier: ^4.1.10 version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(jsdom@30.0.1(@noble/hashes@2.3.0))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)) @@ -732,8 +732,8 @@ importers: specifier: ^19.2.18 version: 19.2.18 typescript: - specifier: ^5.7.3 - version: 5.9.3 + specifier: ^7.0.2 + version: 7.0.2 packages/shared: dependencies: @@ -745,8 +745,8 @@ importers: specifier: ^24.0.0 version: 24.13.3 typescript: - specifier: ^5.7.3 - version: 5.9.3 + specifier: ^7.0.2 + version: 7.0.2 packages/skills-catalog: dependencies: @@ -764,8 +764,8 @@ importers: specifier: ^24.0.0 version: 24.13.3 typescript: - specifier: ^5.7.3 - version: 5.9.3 + specifier: ^7.0.2 + version: 7.0.2 vitest: specifier: ^4.1.10 version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(jsdom@30.0.1(@noble/hashes@2.3.0))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)) @@ -930,8 +930,8 @@ importers: specifier: ^4.23.12 version: 4.23.12 typescript: - specifier: ^5.7.3 - version: 5.9.3 + specifier: ^7.0.2 + version: 7.0.2 vite: specifier: ^8.2.2 version: 8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12) @@ -958,10 +958,10 @@ importers: version: 3.2.2(react@19.2.8) '@lexical/link': specifier: 0.49.0 - version: 0.49.0(typescript@5.9.3) + version: 0.49.0(typescript@7.0.2) '@mdxeditor/editor': specifier: ^4.2.1 - version: 4.2.1(@codemirror/language@6.12.4)(@lezer/highlight@1.2.3)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@5.9.3)(yjs@13.6.29) + version: 4.2.1(@codemirror/language@6.12.4)(@lezer/highlight@1.2.3)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@7.0.2)(yjs@13.6.29) '@paperclipai/adapter-claude-local': specifier: workspace:* version: link:../packages/adapters/claude-local @@ -1027,10 +1027,10 @@ importers: version: 1.1.1(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) i18next: specifier: ^26.3.6 - version: 26.3.6(typescript@5.9.3) + version: 26.3.6(typescript@7.0.2) lexical: specifier: 0.49.0 - version: 0.49.0(typescript@5.9.3) + version: 0.49.0(typescript@7.0.2) lucide-react: specifier: ^1.32.0 version: 1.32.0(react@19.2.8) @@ -1051,7 +1051,7 @@ importers: version: 19.2.8(react@19.2.8) react-i18next: specifier: ^17.0.11 - version: 17.0.11(i18next@26.3.6(typescript@5.9.3))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@5.9.3) + version: 17.0.11(i18next@26.3.6(typescript@7.0.2))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@7.0.2) react-markdown: specifier: ^10.1.0 version: 10.1.0(@types/react@19.2.18)(react@19.2.8) @@ -1076,7 +1076,7 @@ importers: version: 10.5.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.2)(rollup@4.63.0)(storybook@10.5.10(@types/react@19.2.18)(react@19.2.8))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)) '@storybook/react-vite': specifier: 10.5.10 - version: 10.5.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(rollup@4.63.0)(storybook@10.5.10(@types/react@19.2.18)(react@19.2.8))(typescript@5.9.3)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)) + version: 10.5.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(rollup@4.63.0)(storybook@10.5.10(@types/react@19.2.18)(react@19.2.8))(typescript@7.0.2)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)) '@tailwindcss/vite': specifier: ^4.3.3 version: 4.3.3(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)) @@ -1099,8 +1099,8 @@ importers: specifier: ^4.3.3 version: 4.3.3 typescript: - specifier: ^5.7.3 - version: 5.9.3 + specifier: ^7.0.2 + version: 7.0.2 vite: specifier: ^8.2.2 version: 8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12) @@ -4953,6 +4953,126 @@ packages: '@types/ws@8.18.1': resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@typescript/typescript-aix-ppc64@7.0.2': + resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [aix] + + '@typescript/typescript-darwin-arm64@7.0.2': + resolution: {integrity: sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [darwin] + + '@typescript/typescript-darwin-x64@7.0.2': + resolution: {integrity: sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [darwin] + + '@typescript/typescript-freebsd-arm64@7.0.2': + resolution: {integrity: sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [freebsd] + + '@typescript/typescript-freebsd-x64@7.0.2': + resolution: {integrity: sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [freebsd] + + '@typescript/typescript-linux-arm64@7.0.2': + resolution: {integrity: sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [linux] + + '@typescript/typescript-linux-arm@7.0.2': + resolution: {integrity: sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==} + engines: {node: '>=16.20.0'} + cpu: [arm] + os: [linux] + + '@typescript/typescript-linux-loong64@7.0.2': + resolution: {integrity: sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==} + engines: {node: '>=16.20.0'} + cpu: [loong64] + os: [linux] + + '@typescript/typescript-linux-mips64el@7.0.2': + resolution: {integrity: sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==} + engines: {node: '>=16.20.0'} + cpu: [mips64el] + os: [linux] + + '@typescript/typescript-linux-ppc64@7.0.2': + resolution: {integrity: sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [linux] + + '@typescript/typescript-linux-riscv64@7.0.2': + resolution: {integrity: sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==} + engines: {node: '>=16.20.0'} + cpu: [riscv64] + os: [linux] + + '@typescript/typescript-linux-s390x@7.0.2': + resolution: {integrity: sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==} + engines: {node: '>=16.20.0'} + cpu: [s390x] + os: [linux] + + '@typescript/typescript-linux-x64@7.0.2': + resolution: {integrity: sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [linux] + + '@typescript/typescript-netbsd-arm64@7.0.2': + resolution: {integrity: sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [netbsd] + + '@typescript/typescript-netbsd-x64@7.0.2': + resolution: {integrity: sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [netbsd] + + '@typescript/typescript-openbsd-arm64@7.0.2': + resolution: {integrity: sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [openbsd] + + '@typescript/typescript-openbsd-x64@7.0.2': + resolution: {integrity: sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [openbsd] + + '@typescript/typescript-sunos-x64@7.0.2': + resolution: {integrity: sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [sunos] + + '@typescript/typescript-win32-arm64@7.0.2': + resolution: {integrity: sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [win32] + + '@typescript/typescript-win32-x64@7.0.2': + resolution: {integrity: sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [win32] + '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} deprecated: Potential CWE-502 - Update to 1.3.1 or higher @@ -8126,9 +8246,9 @@ packages: typedarray@0.0.6: resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==} - typescript@5.9.3: - resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} - engines: {node: '>=14.17'} + typescript@7.0.2: + resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==} + engines: {node: '>=16.20.0'} hasBin: true undici-types@7.16.0: @@ -9899,13 +10019,13 @@ snapshots: wrap-ansi: 8.1.0 wrap-ansi-cjs: wrap-ansi@7.0.0 - '@joshwooding/vite-plugin-react-docgen-typescript@0.7.0(typescript@5.9.3)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12))': + '@joshwooding/vite-plugin-react-docgen-typescript@0.7.0(typescript@7.0.2)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12))': dependencies: glob: 13.0.6 - react-docgen-typescript: 2.4.0(typescript@5.9.3) + react-docgen-typescript: 2.4.0(typescript@7.0.2) vite: 8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12) optionalDependencies: - typescript: 5.9.3 + typescript: 7.0.2 '@jridgewell/gen-mapping@0.3.13': dependencies: @@ -9926,256 +10046,256 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@lexical/a11y@0.49.0(typescript@5.9.3)': + '@lexical/a11y@0.49.0(typescript@7.0.2)': dependencies: - '@lexical/extension': 0.49.0(typescript@5.9.3) - '@lexical/utils': 0.49.0(typescript@5.9.3) - lexical: 0.49.0(typescript@5.9.3) + '@lexical/extension': 0.49.0(typescript@7.0.2) + '@lexical/utils': 0.49.0(typescript@7.0.2) + lexical: 0.49.0(typescript@7.0.2) optionalDependencies: - typescript: 5.9.3 + typescript: 7.0.2 - '@lexical/clipboard@0.49.0(typescript@5.9.3)': + '@lexical/clipboard@0.49.0(typescript@7.0.2)': dependencies: - '@lexical/extension': 0.49.0(typescript@5.9.3) - '@lexical/html': 0.49.0(typescript@5.9.3) - '@lexical/internal': 0.49.0(typescript@5.9.3) - '@lexical/list': 0.49.0(typescript@5.9.3) - '@lexical/selection': 0.49.0(typescript@5.9.3) - '@lexical/utils': 0.49.0(typescript@5.9.3) + '@lexical/extension': 0.49.0(typescript@7.0.2) + '@lexical/html': 0.49.0(typescript@7.0.2) + '@lexical/internal': 0.49.0(typescript@7.0.2) + '@lexical/list': 0.49.0(typescript@7.0.2) + '@lexical/selection': 0.49.0(typescript@7.0.2) + '@lexical/utils': 0.49.0(typescript@7.0.2) '@types/trusted-types': 2.0.7 - lexical: 0.49.0(typescript@5.9.3) + lexical: 0.49.0(typescript@7.0.2) optionalDependencies: - typescript: 5.9.3 + typescript: 7.0.2 - '@lexical/code-core@0.49.0(typescript@5.9.3)': + '@lexical/code-core@0.49.0(typescript@7.0.2)': dependencies: - '@lexical/extension': 0.49.0(typescript@5.9.3) - '@lexical/html': 0.49.0(typescript@5.9.3) - '@lexical/internal': 0.49.0(typescript@5.9.3) - '@lexical/utils': 0.49.0(typescript@5.9.3) - lexical: 0.49.0(typescript@5.9.3) + '@lexical/extension': 0.49.0(typescript@7.0.2) + '@lexical/html': 0.49.0(typescript@7.0.2) + '@lexical/internal': 0.49.0(typescript@7.0.2) + '@lexical/utils': 0.49.0(typescript@7.0.2) + lexical: 0.49.0(typescript@7.0.2) optionalDependencies: - typescript: 5.9.3 + typescript: 7.0.2 - '@lexical/devtools-core@0.49.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@5.9.3)': + '@lexical/devtools-core@0.49.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@7.0.2)': dependencies: - '@lexical/html': 0.49.0(typescript@5.9.3) - '@lexical/link': 0.49.0(typescript@5.9.3) - '@lexical/mark': 0.49.0(typescript@5.9.3) - '@lexical/table': 0.49.0(typescript@5.9.3) - '@lexical/utils': 0.49.0(typescript@5.9.3) - lexical: 0.49.0(typescript@5.9.3) + '@lexical/html': 0.49.0(typescript@7.0.2) + '@lexical/link': 0.49.0(typescript@7.0.2) + '@lexical/mark': 0.49.0(typescript@7.0.2) + '@lexical/table': 0.49.0(typescript@7.0.2) + '@lexical/utils': 0.49.0(typescript@7.0.2) + lexical: 0.49.0(typescript@7.0.2) react: 19.2.8 react-dom: 19.2.8(react@19.2.8) optionalDependencies: - typescript: 5.9.3 + typescript: 7.0.2 - '@lexical/dragon@0.49.0(typescript@5.9.3)': + '@lexical/dragon@0.49.0(typescript@7.0.2)': dependencies: - '@lexical/extension': 0.49.0(typescript@5.9.3) - lexical: 0.49.0(typescript@5.9.3) + '@lexical/extension': 0.49.0(typescript@7.0.2) + lexical: 0.49.0(typescript@7.0.2) optionalDependencies: - typescript: 5.9.3 + typescript: 7.0.2 - '@lexical/extension@0.48.0(typescript@5.9.3)': + '@lexical/extension@0.48.0(typescript@7.0.2)': dependencies: - '@lexical/internal': 0.48.0(typescript@5.9.3) - '@lexical/utils': 0.49.0(typescript@5.9.3) + '@lexical/internal': 0.48.0(typescript@7.0.2) + '@lexical/utils': 0.49.0(typescript@7.0.2) '@preact/signals-core': 1.14.4 - lexical: 0.49.0(typescript@5.9.3) + lexical: 0.49.0(typescript@7.0.2) optionalDependencies: - typescript: 5.9.3 + typescript: 7.0.2 - '@lexical/extension@0.49.0(typescript@5.9.3)': + '@lexical/extension@0.49.0(typescript@7.0.2)': dependencies: - '@lexical/internal': 0.49.0(typescript@5.9.3) - '@lexical/utils': 0.49.0(typescript@5.9.3) + '@lexical/internal': 0.49.0(typescript@7.0.2) + '@lexical/utils': 0.49.0(typescript@7.0.2) '@preact/signals-core': 1.14.4 - lexical: 0.49.0(typescript@5.9.3) + lexical: 0.49.0(typescript@7.0.2) optionalDependencies: - typescript: 5.9.3 + typescript: 7.0.2 - '@lexical/hashtag@0.49.0(typescript@5.9.3)': + '@lexical/hashtag@0.49.0(typescript@7.0.2)': dependencies: - '@lexical/text': 0.49.0(typescript@5.9.3) - '@lexical/utils': 0.49.0(typescript@5.9.3) - lexical: 0.49.0(typescript@5.9.3) + '@lexical/text': 0.49.0(typescript@7.0.2) + '@lexical/utils': 0.49.0(typescript@7.0.2) + lexical: 0.49.0(typescript@7.0.2) optionalDependencies: - typescript: 5.9.3 + typescript: 7.0.2 - '@lexical/history@0.48.0(typescript@5.9.3)': + '@lexical/history@0.48.0(typescript@7.0.2)': dependencies: - '@lexical/extension': 0.48.0(typescript@5.9.3) - '@lexical/utils': 0.49.0(typescript@5.9.3) - lexical: 0.49.0(typescript@5.9.3) + '@lexical/extension': 0.48.0(typescript@7.0.2) + '@lexical/utils': 0.49.0(typescript@7.0.2) + lexical: 0.49.0(typescript@7.0.2) optionalDependencies: - typescript: 5.9.3 + typescript: 7.0.2 - '@lexical/history@0.49.0(typescript@5.9.3)': + '@lexical/history@0.49.0(typescript@7.0.2)': dependencies: - '@lexical/extension': 0.49.0(typescript@5.9.3) - '@lexical/utils': 0.49.0(typescript@5.9.3) - lexical: 0.49.0(typescript@5.9.3) + '@lexical/extension': 0.49.0(typescript@7.0.2) + '@lexical/utils': 0.49.0(typescript@7.0.2) + lexical: 0.49.0(typescript@7.0.2) optionalDependencies: - typescript: 5.9.3 + typescript: 7.0.2 - '@lexical/html@0.49.0(typescript@5.9.3)': + '@lexical/html@0.49.0(typescript@7.0.2)': dependencies: - '@lexical/extension': 0.49.0(typescript@5.9.3) - '@lexical/internal': 0.49.0(typescript@5.9.3) - '@lexical/selection': 0.49.0(typescript@5.9.3) - '@lexical/utils': 0.49.0(typescript@5.9.3) - lexical: 0.49.0(typescript@5.9.3) + '@lexical/extension': 0.49.0(typescript@7.0.2) + '@lexical/internal': 0.49.0(typescript@7.0.2) + '@lexical/selection': 0.49.0(typescript@7.0.2) + '@lexical/utils': 0.49.0(typescript@7.0.2) + lexical: 0.49.0(typescript@7.0.2) optionalDependencies: - typescript: 5.9.3 + typescript: 7.0.2 - '@lexical/internal@0.48.0(typescript@5.9.3)': + '@lexical/internal@0.48.0(typescript@7.0.2)': optionalDependencies: - typescript: 5.9.3 + typescript: 7.0.2 - '@lexical/internal@0.49.0(typescript@5.9.3)': + '@lexical/internal@0.49.0(typescript@7.0.2)': optionalDependencies: - typescript: 5.9.3 + typescript: 7.0.2 - '@lexical/link@0.49.0(typescript@5.9.3)': + '@lexical/link@0.49.0(typescript@7.0.2)': dependencies: - '@lexical/extension': 0.49.0(typescript@5.9.3) - '@lexical/html': 0.49.0(typescript@5.9.3) - '@lexical/internal': 0.49.0(typescript@5.9.3) - '@lexical/utils': 0.49.0(typescript@5.9.3) - lexical: 0.49.0(typescript@5.9.3) + '@lexical/extension': 0.49.0(typescript@7.0.2) + '@lexical/html': 0.49.0(typescript@7.0.2) + '@lexical/internal': 0.49.0(typescript@7.0.2) + '@lexical/utils': 0.49.0(typescript@7.0.2) + lexical: 0.49.0(typescript@7.0.2) optionalDependencies: - typescript: 5.9.3 + typescript: 7.0.2 - '@lexical/list@0.49.0(typescript@5.9.3)': + '@lexical/list@0.49.0(typescript@7.0.2)': dependencies: - '@lexical/extension': 0.49.0(typescript@5.9.3) - '@lexical/html': 0.49.0(typescript@5.9.3) - '@lexical/internal': 0.49.0(typescript@5.9.3) - '@lexical/utils': 0.49.0(typescript@5.9.3) - lexical: 0.49.0(typescript@5.9.3) + '@lexical/extension': 0.49.0(typescript@7.0.2) + '@lexical/html': 0.49.0(typescript@7.0.2) + '@lexical/internal': 0.49.0(typescript@7.0.2) + '@lexical/utils': 0.49.0(typescript@7.0.2) + lexical: 0.49.0(typescript@7.0.2) optionalDependencies: - typescript: 5.9.3 + typescript: 7.0.2 - '@lexical/mark@0.49.0(typescript@5.9.3)': + '@lexical/mark@0.49.0(typescript@7.0.2)': dependencies: - '@lexical/utils': 0.49.0(typescript@5.9.3) - lexical: 0.49.0(typescript@5.9.3) + '@lexical/utils': 0.49.0(typescript@7.0.2) + lexical: 0.49.0(typescript@7.0.2) optionalDependencies: - typescript: 5.9.3 + typescript: 7.0.2 - '@lexical/markdown@0.49.0(typescript@5.9.3)': + '@lexical/markdown@0.49.0(typescript@7.0.2)': dependencies: - '@lexical/code-core': 0.49.0(typescript@5.9.3) - '@lexical/internal': 0.49.0(typescript@5.9.3) - '@lexical/link': 0.49.0(typescript@5.9.3) - '@lexical/list': 0.49.0(typescript@5.9.3) - '@lexical/rich-text': 0.49.0(typescript@5.9.3) - '@lexical/selection': 0.49.0(typescript@5.9.3) - '@lexical/text': 0.49.0(typescript@5.9.3) - '@lexical/utils': 0.49.0(typescript@5.9.3) - lexical: 0.49.0(typescript@5.9.3) + '@lexical/code-core': 0.49.0(typescript@7.0.2) + '@lexical/internal': 0.49.0(typescript@7.0.2) + '@lexical/link': 0.49.0(typescript@7.0.2) + '@lexical/list': 0.49.0(typescript@7.0.2) + '@lexical/rich-text': 0.49.0(typescript@7.0.2) + '@lexical/selection': 0.49.0(typescript@7.0.2) + '@lexical/text': 0.49.0(typescript@7.0.2) + '@lexical/utils': 0.49.0(typescript@7.0.2) + lexical: 0.49.0(typescript@7.0.2) optionalDependencies: - typescript: 5.9.3 + typescript: 7.0.2 - '@lexical/overflow@0.49.0(typescript@5.9.3)': + '@lexical/overflow@0.49.0(typescript@7.0.2)': dependencies: - lexical: 0.49.0(typescript@5.9.3) + lexical: 0.49.0(typescript@7.0.2) optionalDependencies: - typescript: 5.9.3 + typescript: 7.0.2 - '@lexical/plain-text@0.49.0(typescript@5.9.3)': + '@lexical/plain-text@0.49.0(typescript@7.0.2)': dependencies: - '@lexical/clipboard': 0.49.0(typescript@5.9.3) - '@lexical/dragon': 0.49.0(typescript@5.9.3) - '@lexical/extension': 0.49.0(typescript@5.9.3) - '@lexical/selection': 0.49.0(typescript@5.9.3) - '@lexical/utils': 0.49.0(typescript@5.9.3) - lexical: 0.49.0(typescript@5.9.3) + '@lexical/clipboard': 0.49.0(typescript@7.0.2) + '@lexical/dragon': 0.49.0(typescript@7.0.2) + '@lexical/extension': 0.49.0(typescript@7.0.2) + '@lexical/selection': 0.49.0(typescript@7.0.2) + '@lexical/utils': 0.49.0(typescript@7.0.2) + lexical: 0.49.0(typescript@7.0.2) optionalDependencies: - typescript: 5.9.3 + typescript: 7.0.2 - '@lexical/react@0.49.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@5.9.3)(yjs@13.6.29)': + '@lexical/react@0.49.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@7.0.2)(yjs@13.6.29)': dependencies: '@floating-ui/react': 0.27.20(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@lexical/a11y': 0.49.0(typescript@5.9.3) - '@lexical/devtools-core': 0.49.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@5.9.3) - '@lexical/dragon': 0.49.0(typescript@5.9.3) - '@lexical/extension': 0.49.0(typescript@5.9.3) - '@lexical/hashtag': 0.49.0(typescript@5.9.3) - '@lexical/history': 0.49.0(typescript@5.9.3) - '@lexical/internal': 0.49.0(typescript@5.9.3) - '@lexical/link': 0.49.0(typescript@5.9.3) - '@lexical/list': 0.49.0(typescript@5.9.3) - '@lexical/mark': 0.49.0(typescript@5.9.3) - '@lexical/markdown': 0.49.0(typescript@5.9.3) - '@lexical/overflow': 0.49.0(typescript@5.9.3) - '@lexical/plain-text': 0.49.0(typescript@5.9.3) - '@lexical/rich-text': 0.49.0(typescript@5.9.3) - '@lexical/table': 0.49.0(typescript@5.9.3) - '@lexical/text': 0.49.0(typescript@5.9.3) - '@lexical/utils': 0.49.0(typescript@5.9.3) - '@lexical/yjs': 0.49.0(typescript@5.9.3)(yjs@13.6.29) - lexical: 0.49.0(typescript@5.9.3) + '@lexical/a11y': 0.49.0(typescript@7.0.2) + '@lexical/devtools-core': 0.49.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@7.0.2) + '@lexical/dragon': 0.49.0(typescript@7.0.2) + '@lexical/extension': 0.49.0(typescript@7.0.2) + '@lexical/hashtag': 0.49.0(typescript@7.0.2) + '@lexical/history': 0.49.0(typescript@7.0.2) + '@lexical/internal': 0.49.0(typescript@7.0.2) + '@lexical/link': 0.49.0(typescript@7.0.2) + '@lexical/list': 0.49.0(typescript@7.0.2) + '@lexical/mark': 0.49.0(typescript@7.0.2) + '@lexical/markdown': 0.49.0(typescript@7.0.2) + '@lexical/overflow': 0.49.0(typescript@7.0.2) + '@lexical/plain-text': 0.49.0(typescript@7.0.2) + '@lexical/rich-text': 0.49.0(typescript@7.0.2) + '@lexical/table': 0.49.0(typescript@7.0.2) + '@lexical/text': 0.49.0(typescript@7.0.2) + '@lexical/utils': 0.49.0(typescript@7.0.2) + '@lexical/yjs': 0.49.0(typescript@7.0.2)(yjs@13.6.29) + lexical: 0.49.0(typescript@7.0.2) react: 19.2.8 react-dom: 19.2.8(react@19.2.8) optionalDependencies: - typescript: 5.9.3 + typescript: 7.0.2 yjs: 13.6.29 - '@lexical/rich-text@0.49.0(typescript@5.9.3)': + '@lexical/rich-text@0.49.0(typescript@7.0.2)': dependencies: - '@lexical/clipboard': 0.49.0(typescript@5.9.3) - '@lexical/dragon': 0.49.0(typescript@5.9.3) - '@lexical/extension': 0.49.0(typescript@5.9.3) - '@lexical/html': 0.49.0(typescript@5.9.3) - '@lexical/selection': 0.49.0(typescript@5.9.3) - '@lexical/utils': 0.49.0(typescript@5.9.3) - lexical: 0.49.0(typescript@5.9.3) + '@lexical/clipboard': 0.49.0(typescript@7.0.2) + '@lexical/dragon': 0.49.0(typescript@7.0.2) + '@lexical/extension': 0.49.0(typescript@7.0.2) + '@lexical/html': 0.49.0(typescript@7.0.2) + '@lexical/selection': 0.49.0(typescript@7.0.2) + '@lexical/utils': 0.49.0(typescript@7.0.2) + lexical: 0.49.0(typescript@7.0.2) optionalDependencies: - typescript: 5.9.3 + typescript: 7.0.2 - '@lexical/selection@0.49.0(typescript@5.9.3)': + '@lexical/selection@0.49.0(typescript@7.0.2)': dependencies: - '@lexical/internal': 0.49.0(typescript@5.9.3) - lexical: 0.49.0(typescript@5.9.3) + '@lexical/internal': 0.49.0(typescript@7.0.2) + lexical: 0.49.0(typescript@7.0.2) optionalDependencies: - typescript: 5.9.3 + typescript: 7.0.2 - '@lexical/table@0.49.0(typescript@5.9.3)': + '@lexical/table@0.49.0(typescript@7.0.2)': dependencies: - '@lexical/clipboard': 0.49.0(typescript@5.9.3) - '@lexical/extension': 0.49.0(typescript@5.9.3) - '@lexical/html': 0.49.0(typescript@5.9.3) - '@lexical/internal': 0.49.0(typescript@5.9.3) - '@lexical/utils': 0.49.0(typescript@5.9.3) - lexical: 0.49.0(typescript@5.9.3) + '@lexical/clipboard': 0.49.0(typescript@7.0.2) + '@lexical/extension': 0.49.0(typescript@7.0.2) + '@lexical/html': 0.49.0(typescript@7.0.2) + '@lexical/internal': 0.49.0(typescript@7.0.2) + '@lexical/utils': 0.49.0(typescript@7.0.2) + lexical: 0.49.0(typescript@7.0.2) optionalDependencies: - typescript: 5.9.3 + typescript: 7.0.2 - '@lexical/text@0.49.0(typescript@5.9.3)': + '@lexical/text@0.49.0(typescript@7.0.2)': dependencies: - '@lexical/internal': 0.49.0(typescript@5.9.3) - lexical: 0.49.0(typescript@5.9.3) + '@lexical/internal': 0.49.0(typescript@7.0.2) + lexical: 0.49.0(typescript@7.0.2) optionalDependencies: - typescript: 5.9.3 + typescript: 7.0.2 - '@lexical/utils@0.49.0(typescript@5.9.3)': + '@lexical/utils@0.49.0(typescript@7.0.2)': dependencies: - '@lexical/internal': 0.49.0(typescript@5.9.3) - '@lexical/selection': 0.49.0(typescript@5.9.3) - lexical: 0.49.0(typescript@5.9.3) + '@lexical/internal': 0.49.0(typescript@7.0.2) + '@lexical/selection': 0.49.0(typescript@7.0.2) + lexical: 0.49.0(typescript@7.0.2) optionalDependencies: - typescript: 5.9.3 + typescript: 7.0.2 - '@lexical/yjs@0.49.0(typescript@5.9.3)(yjs@13.6.29)': + '@lexical/yjs@0.49.0(typescript@7.0.2)(yjs@13.6.29)': dependencies: - '@lexical/internal': 0.49.0(typescript@5.9.3) - '@lexical/selection': 0.49.0(typescript@5.9.3) - lexical: 0.49.0(typescript@5.9.3) + '@lexical/internal': 0.49.0(typescript@7.0.2) + '@lexical/selection': 0.49.0(typescript@7.0.2) + lexical: 0.49.0(typescript@7.0.2) yjs: 13.6.29 optionalDependencies: - typescript: 5.9.3 + typescript: 7.0.2 '@lezer/common@1.5.1': {} @@ -10280,7 +10400,7 @@ snapshots: '@types/react': 19.2.18 react: 19.2.8 - '@mdxeditor/editor@4.2.1(@codemirror/language@6.12.4)(@lezer/highlight@1.2.3)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@5.9.3)(yjs@13.6.29)': + '@mdxeditor/editor@4.2.1(@codemirror/language@6.12.4)(@lezer/highlight@1.2.3)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@7.0.2)(yjs@13.6.29)': dependencies: '@codemirror/commands': 6.11.0 '@codemirror/lang-markdown': 6.5.2 @@ -10288,17 +10408,17 @@ snapshots: '@codemirror/merge': 6.12.2 '@codemirror/state': 6.7.1 '@codemirror/view': 6.43.9 - '@lexical/clipboard': 0.49.0(typescript@5.9.3) - '@lexical/extension': 0.48.0(typescript@5.9.3) - '@lexical/history': 0.48.0(typescript@5.9.3) - '@lexical/link': 0.49.0(typescript@5.9.3) - '@lexical/list': 0.49.0(typescript@5.9.3) - '@lexical/markdown': 0.49.0(typescript@5.9.3) - '@lexical/plain-text': 0.49.0(typescript@5.9.3) - '@lexical/react': 0.49.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@5.9.3)(yjs@13.6.29) - '@lexical/rich-text': 0.49.0(typescript@5.9.3) - '@lexical/selection': 0.49.0(typescript@5.9.3) - '@lexical/utils': 0.49.0(typescript@5.9.3) + '@lexical/clipboard': 0.49.0(typescript@7.0.2) + '@lexical/extension': 0.48.0(typescript@7.0.2) + '@lexical/history': 0.48.0(typescript@7.0.2) + '@lexical/link': 0.49.0(typescript@7.0.2) + '@lexical/list': 0.49.0(typescript@7.0.2) + '@lexical/markdown': 0.49.0(typescript@7.0.2) + '@lexical/plain-text': 0.49.0(typescript@7.0.2) + '@lexical/react': 0.49.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@7.0.2)(yjs@13.6.29) + '@lexical/rich-text': 0.49.0(typescript@7.0.2) + '@lexical/selection': 0.49.0(typescript@7.0.2) + '@lexical/utils': 0.49.0(typescript@7.0.2) '@mdxeditor/gurx': 1.2.4(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@radix-ui/colors': 3.0.0 '@radix-ui/react-dialog': 1.1.23(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -10314,7 +10434,7 @@ snapshots: codemirror: 6.0.2 downshift: 7.6.2(react@19.2.8) js-yaml: 4.3.1 - lexical: 0.49.0(typescript@5.9.3) + lexical: 0.49.0(typescript@7.0.2) mdast-util-directive: 3.1.0 mdast-util-from-markdown: 2.0.3 mdast-util-frontmatter: 2.0.1 @@ -11583,11 +11703,11 @@ snapshots: optionalDependencies: rollup: 4.62.4 - '@rollup/plugin-typescript@12.3.0(rollup@4.62.4)(tslib@2.8.1)(typescript@5.9.3)': + '@rollup/plugin-typescript@12.3.0(rollup@4.62.4)(tslib@2.8.1)(typescript@7.0.2)': dependencies: '@rollup/pluginutils': 5.3.0(rollup@4.62.4) resolve: 1.22.11 - typescript: 5.9.3 + typescript: 7.0.2 optionalDependencies: rollup: 4.62.4 tslib: 2.8.1 @@ -11904,12 +12024,12 @@ snapshots: '@types/react': 19.2.18 '@types/react-dom': 19.2.4(@types/react@19.2.18) - '@storybook/react-vite@10.5.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(rollup@4.63.0)(storybook@10.5.10(@types/react@19.2.18)(react@19.2.8))(typescript@5.9.3)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12))': + '@storybook/react-vite@10.5.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(rollup@4.63.0)(storybook@10.5.10(@types/react@19.2.18)(react@19.2.8))(typescript@7.0.2)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12))': dependencies: - '@joshwooding/vite-plugin-react-docgen-typescript': 0.7.0(typescript@5.9.3)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)) + '@joshwooding/vite-plugin-react-docgen-typescript': 0.7.0(typescript@7.0.2)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)) '@rollup/pluginutils': 5.4.0(rollup@4.63.0) '@storybook/builder-vite': 10.5.10(esbuild@0.28.2)(rollup@4.63.0)(storybook@10.5.10(@types/react@19.2.18)(react@19.2.8))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)) - '@storybook/react': 10.5.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.10(@types/react@19.2.18)(react@19.2.8))(typescript@5.9.3) + '@storybook/react': 10.5.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.10(@types/react@19.2.18)(react@19.2.8))(typescript@7.0.2) empathic: 2.0.1 magic-string: 0.30.21 react: 19.2.8 @@ -11920,7 +12040,7 @@ snapshots: tsconfig-paths: 4.2.0 vite: 8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12) optionalDependencies: - typescript: 5.9.3 + typescript: 7.0.2 transitivePeerDependencies: - '@types/react' - '@types/react-dom' @@ -11929,19 +12049,19 @@ snapshots: - supports-color - webpack - '@storybook/react@10.5.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.10(@types/react@19.2.18)(react@19.2.8))(typescript@5.9.3)': + '@storybook/react@10.5.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.10(@types/react@19.2.18)(react@19.2.8))(typescript@7.0.2)': dependencies: '@storybook/global': 5.0.0 '@storybook/react-dom-shim': 10.5.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.10(@types/react@19.2.18)(react@19.2.8)) react: 19.2.8 react-docgen: 8.0.3 - react-docgen-typescript: 2.4.0(typescript@5.9.3) + react-docgen-typescript: 2.4.0(typescript@7.0.2) react-dom: 19.2.8(react@19.2.8) storybook: 10.5.10(@types/react@19.2.18)(react@19.2.8) optionalDependencies: '@types/react': 19.2.18 '@types/react-dom': 19.2.4(@types/react@19.2.18) - typescript: 5.9.3 + typescript: 7.0.2 transitivePeerDependencies: - supports-color @@ -12337,6 +12457,66 @@ snapshots: dependencies: '@types/node': 24.13.3 + '@typescript/typescript-aix-ppc64@7.0.2': + optional: true + + '@typescript/typescript-darwin-arm64@7.0.2': + optional: true + + '@typescript/typescript-darwin-x64@7.0.2': + optional: true + + '@typescript/typescript-freebsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-freebsd-x64@7.0.2': + optional: true + + '@typescript/typescript-linux-arm64@7.0.2': + optional: true + + '@typescript/typescript-linux-arm@7.0.2': + optional: true + + '@typescript/typescript-linux-loong64@7.0.2': + optional: true + + '@typescript/typescript-linux-mips64el@7.0.2': + optional: true + + '@typescript/typescript-linux-ppc64@7.0.2': + optional: true + + '@typescript/typescript-linux-riscv64@7.0.2': + optional: true + + '@typescript/typescript-linux-s390x@7.0.2': + optional: true + + '@typescript/typescript-linux-x64@7.0.2': + optional: true + + '@typescript/typescript-netbsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-netbsd-x64@7.0.2': + optional: true + + '@typescript/typescript-openbsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-openbsd-x64@7.0.2': + optional: true + + '@typescript/typescript-sunos-x64@7.0.2': + optional: true + + '@typescript/typescript-win32-arm64@7.0.2': + optional: true + + '@typescript/typescript-win32-x64@7.0.2': + optional: true + '@ungap/structured-clone@1.3.0': {} '@upsetjs/venn.js@2.0.0': @@ -13886,9 +14066,9 @@ snapshots: ms: 2.1.3 optional: true - i18next@26.3.6(typescript@5.9.3): + i18next@26.3.6(typescript@7.0.2): optionalDependencies: - typescript: 5.9.3 + typescript: 7.0.2 iconv-lite@0.6.3: dependencies: @@ -14074,11 +14254,11 @@ snapshots: layout-base@2.0.1: {} - lexical@0.49.0(typescript@5.9.3): + lexical@0.49.0(typescript@7.0.2): dependencies: - '@lexical/internal': 0.49.0(typescript@5.9.3) + '@lexical/internal': 0.49.0(typescript@7.0.2) optionalDependencies: - typescript: 5.9.3 + typescript: 7.0.2 lib0@0.2.117: dependencies: @@ -15354,9 +15534,9 @@ snapshots: strip-json-comments: 2.0.1 optional: true - react-docgen-typescript@2.4.0(typescript@5.9.3): + react-docgen-typescript@2.4.0(typescript@7.0.2): dependencies: - typescript: 5.9.3 + typescript: 7.0.2 react-docgen@8.0.3: dependencies: @@ -15382,16 +15562,16 @@ snapshots: dependencies: react: 19.2.8 - react-i18next@17.0.11(i18next@26.3.6(typescript@5.9.3))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@5.9.3): + react-i18next@17.0.11(i18next@26.3.6(typescript@7.0.2))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@7.0.2): dependencies: '@babel/runtime': 7.29.7 html-parse-stringify: 4.0.1 - i18next: 26.3.6(typescript@5.9.3) + i18next: 26.3.6(typescript@7.0.2) react: 19.2.8 use-sync-external-store: 1.6.0(react@19.2.8) optionalDependencies: react-dom: 19.2.8(react@19.2.8) - typescript: 5.9.3 + typescript: 7.0.2 react-is@16.13.1: {} @@ -16188,7 +16368,28 @@ snapshots: typedarray@0.0.6: {} - typescript@5.9.3: {} + typescript@7.0.2: + optionalDependencies: + '@typescript/typescript-aix-ppc64': 7.0.2 + '@typescript/typescript-darwin-arm64': 7.0.2 + '@typescript/typescript-darwin-x64': 7.0.2 + '@typescript/typescript-freebsd-arm64': 7.0.2 + '@typescript/typescript-freebsd-x64': 7.0.2 + '@typescript/typescript-linux-arm': 7.0.2 + '@typescript/typescript-linux-arm64': 7.0.2 + '@typescript/typescript-linux-loong64': 7.0.2 + '@typescript/typescript-linux-mips64el': 7.0.2 + '@typescript/typescript-linux-ppc64': 7.0.2 + '@typescript/typescript-linux-riscv64': 7.0.2 + '@typescript/typescript-linux-s390x': 7.0.2 + '@typescript/typescript-linux-x64': 7.0.2 + '@typescript/typescript-netbsd-arm64': 7.0.2 + '@typescript/typescript-netbsd-x64': 7.0.2 + '@typescript/typescript-openbsd-arm64': 7.0.2 + '@typescript/typescript-openbsd-x64': 7.0.2 + '@typescript/typescript-sunos-x64': 7.0.2 + '@typescript/typescript-win32-arm64': 7.0.2 + '@typescript/typescript-win32-x64': 7.0.2 undici-types@7.16.0: optional: true diff --git a/server/package.json b/server/package.json index 747d52b622f..75d2b88cbec 100644 --- a/server/package.json +++ b/server/package.json @@ -96,7 +96,7 @@ "cross-env": "^10.1.0", "supertest": "^7.0.0", "tsx": "^4.23.12", - "typescript": "^5.7.3", + "typescript": "^7.0.2", "vite": "^8.2.2", "vitest": "^4.1.10" }, diff --git a/tsconfig.base.json b/tsconfig.base.json index 25e06d83fae..2d0e41f723a 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -3,6 +3,7 @@ "target": "ES2023", "module": "NodeNext", "moduleResolution": "NodeNext", + "types": ["node"], "esModuleInterop": true, "strict": true, "skipLibCheck": true, diff --git a/ui/package.json b/ui/package.json index f4a68372616..bf0fb32c38b 100644 --- a/ui/package.json +++ b/ui/package.json @@ -82,7 +82,7 @@ "@vitejs/plugin-react": "^5.2.0", "storybook": "10.5.10", "tailwindcss": "^4.3.3", - "typescript": "^5.7.3", + "typescript": "^7.0.2", "vite": "^8.2.2", "vitest": "^4.1.10" }, From 29d12045f4d3d5836e24f2c2eb45d118ab5d87cd Mon Sep 17 00:00:00 2001 From: Nicky Leach Date: Tue, 25 Aug 2026 14:56:17 -0700 Subject: [PATCH 19/32] perf(gateway): remove the whole-body string round trip on the HTTP/2 send path (#12189) ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Sandbox agents send callback requests through a generated gateway. > - The HTTP/2 callback path converts each full request body to a UTF-8 string. > - The same path converts that string back to a buffer before it sends the request. > - This pull request sends the body buffer directly to the HTTP/2 forwarder. > - The benefit is less copying and unchanged queue payload behavior. ## Linked Issues or Issue Description **What existing behavior does this improve?** The sandbox callback gateway HTTP/2 send path copies the full request body through a UTF-8 string before it sends the body. **Subsystem affected** The affected subsystem is `packages/adapter-utils`, which contains sandbox gateway and HTTP/2 adapter utilities. **Current behavior** The gateway reads the body into a buffer, converts the complete body to a UTF-8 string, and converts that string back to a buffer for the HTTP/2 path. The queue path stores the string payload. **Proposed behavior** The gateway keeps a byte reader for the HTTP/2 path. A thin text wrapper keeps the queue path behavior. The HTTP/2 path sends the original body buffer. **Reason and benefit** The extra conversions add work and memory use without changing the HTTP/2 body bytes. Direct buffer forwarding removes that work and preserves the size limit and reject behavior. **Breaking changes** None. The queue payload remains a string. The body size limit, content type check, and reject point remain unchanged. **Additional context** This change has no public issue link. The repository roadmap search found no duplicate planned work. The implementation also adds tests for non-ASCII JSON, malformed UTF-8, size limits, and queue payload shape. ## What Changed - Add `readBodyBytes(req)` for byte-preserving body reads. - Keep `readBody(req)` as a string wrapper for the queue path. - Send the byte buffer directly on the HTTP/2 path. - Add tests for byte identity, malformed UTF-8, size limits, and queue payload shape. ## Verification - `pnpm --filter @paperclip/adapter-utils test src/sandbox-callback-bridge.test.ts` passed with 51 tests. - `pnpm --filter @paperclip/adapter-utils exec tsc --noEmit` passed. - The tests spawn the generated gateway and the real host HTTP/2 bridge. - The tests verify byte identity, malformed UTF-8, pre-forward size rejection, queue file protection, and string queue payloads. - Full repository CI must pass before merge. ## Risks Low risk. The HTTP/2 path changes its internal body conversion only. The queue path keeps the prior string payload. The size limit and reject point stay unchanged. ## Model Used OpenAI Codex, GPT-5, tool use and code review assistance. The exact runtime context window is managed by the Codex service. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes: #` / `Refs: #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge Co-authored-by: Paperclip --- .../src/sandbox-callback-bridge.test.ts | 350 ++++++++++++++++++ .../src/sandbox-callback-bridge.ts | 10 +- 2 files changed, 357 insertions(+), 3 deletions(-) diff --git a/packages/adapter-utils/src/sandbox-callback-bridge.test.ts b/packages/adapter-utils/src/sandbox-callback-bridge.test.ts index 3ad63d9d870..6fdb44805bc 100644 --- a/packages/adapter-utils/src/sandbox-callback-bridge.test.ts +++ b/packages/adapter-utils/src/sandbox-callback-bridge.test.ts @@ -22,6 +22,9 @@ import { startSandboxCallbackBridgeWorker, } from "./sandbox-callback-bridge.js"; import type { SandboxCallbackBridgeQueueClient } from "./sandbox-callback-bridge.js"; +import { createHttp2BridgeServer } from "./http2-bridge-server.js"; +import type { Http2BridgeForwardRequest, Http2BridgeForwardResult } from "./http2-bridge-server.js"; +import type { CommandManagedDuplexChannel } from "./command-managed-runtime.js"; import type { RuntimeSpanRunner } from "./acpx-engine/startup-timing.js"; import type { RunProcessResult } from "./server-utils.js"; @@ -3084,4 +3087,351 @@ describe("sandbox callback bridge", () => { const afterReady = firstBytes.subarray(newlineIndex + 1, newlineIndex + 1 + preface.length); expect(afterReady).toEqual(preface); }, 15_000); + + /** + * Spawn the real generated gateway in `http2_v1` mode, then bind the real + * host-side `createHttp2BridgeServer` to its stdio. The gateway writes one + * READY line before it hands stdout to its HTTP/2 client, so this helper + * strips that line first and feeds the host only the raw HTTP/2 bytes that + * follow. A test then drives the gateway with ordinary HTTP/1.1 requests + * against its loopback port and inspects the exact bytes `forwardRequest` + * receives, proving the send path carries no intermediate string. + */ + async function startHttp2GatewayForTest(options: { + bridgeToken: string; + maxBodyBytes?: number; + forwardRequest: (request: Http2BridgeForwardRequest) => Promise; + }): Promise<{ baseUrl: string; stop: () => Promise }> { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-bridge-http2-test-")); + cleanupDirs.push(rootDir); + const entrypoint = path.join(rootDir, "paperclip-bridge-server.mjs"); + await writeFile(entrypoint, getSandboxCallbackBridgeServerSource(), "utf8"); + + const probe = createServer(); + const assignedPort = await new Promise((resolve, reject) => { + probe.once("error", reject); + probe.listen(0, "127.0.0.1", () => { + const address = probe.address(); + if (!address || typeof address === "string") { + reject(new Error("Could not reserve a loopback port for the test.")); + return; + } + probe.close(() => resolve(address.port)); + }); + }); + + const child = spawn(process.execPath, [entrypoint], { + env: { + ...process.env, + PAPERCLIP_API_BRIDGE_MODE: "http2_v1", + PAPERCLIP_BRIDGE_TOKEN: options.bridgeToken, + PAPERCLIP_BRIDGE_PORT: String(assignedPort), + PAPERCLIP_BRIDGE_NONCE: "test-nonce", + ...(options.maxBodyBytes != null + ? { PAPERCLIP_BRIDGE_MAX_BODY_BYTES: String(options.maxBodyBytes) } + : {}), + }, + stdio: ["pipe", "pipe", "pipe"], + }); + let stderr = ""; + child.stderr.on("data", (chunk: Buffer) => { + stderr += chunk.toString("utf8"); + }); + + const dataListeners: Array<(chunk: Uint8Array) => void> = []; + const pending: Buffer[] = []; + let dispatchStarted = false; + let sawReadyLine = false; + let readyBuffer = Buffer.alloc(0); + let resolveReady!: () => void; + let rejectReady!: (error: Error) => void; + const readyPromise = new Promise((resolve, reject) => { + resolveReady = resolve; + rejectReady = reject; + }); + const readyTimer = setTimeout( + () => rejectReady(new Error("Timed out waiting for the http2 gateway READY line. stderr: " + stderr)), + 5000, + ); + const deliver = (chunk: Buffer) => { + if (dispatchStarted) { + for (const listener of dataListeners) listener(chunk); + } else { + pending.push(chunk); + } + }; + child.stdout.on("data", (chunk: Buffer) => { + if (!sawReadyLine) { + readyBuffer = Buffer.concat([readyBuffer, chunk]); + const newlineIndex = readyBuffer.indexOf(0x0a); + if (newlineIndex === -1) return; + sawReadyLine = true; + clearTimeout(readyTimer); + const rest = readyBuffer.subarray(newlineIndex + 1); + readyBuffer = Buffer.alloc(0); + resolveReady(); + if (rest.length > 0) deliver(rest); + return; + } + deliver(chunk); + }); + child.once("exit", (code) => { + clearTimeout(readyTimer); + if (!sawReadyLine) { + rejectReady(new Error("The http2 gateway exited early with code " + String(code) + ". stderr: " + stderr)); + } + }); + + const channel: CommandManagedDuplexChannel = { + write: (data) => { + child.stdin.write(Buffer.from(data)); + }, + onData: (listener) => { + dataListeners.push(listener); + }, + onExit: (listener) => { + child.once("exit", (code) => listener({ exitCode: code })); + }, + stop: () => { + child.kill(); + }, + close: async () => { + child.stdin.end(); + }, + }; + + await readyPromise; + + const handle = createHttp2BridgeServer({ + bridgeToken: options.bridgeToken, + forwardRequest: options.forwardRequest, + }); + const boundDuplex = handle.bindChannel(channel); + dispatchStarted = true; + const buffered = pending.splice(0); + for (const chunk of buffered) { + for (const listener of dataListeners) listener(chunk); + } + + // Destroy the bound duplex directly instead of `handle.close()`. The + // duplex here wraps a spawned process's raw stdio, not a real socket, + // and `close()` waits on a graceful HTTP/2 GOAWAY exchange that never + // settles over this transport. A direct destroy ends the session at + // once, which is correct for test teardown. + const stop = async () => { + boundDuplex.destroy(); + child.kill(); + }; + cleanupFns.push(stop); + + return { baseUrl: `http://127.0.0.1:${assignedPort}`, stop }; + } + + it("forwards the exact request body bytes to the HTTP/2 host handler, including a non-ASCII character", async () => { + const bridgeToken = createSandboxCallbackBridgeToken(); + const seenBodies: Buffer[] = []; + const gateway = await startHttp2GatewayForTest({ + bridgeToken, + forwardRequest: async (request) => { + seenBodies.push(request.body); + return { status: 200, headers: { "content-type": "application/json" }, body: JSON.stringify({ ok: true }) }; + }, + }); + + // "café" holds one multi-byte UTF-8 character. A body-to-string-to-body + // round trip still reproduces this text correctly, so the byte-for-byte + // comparison below is the real proof: it fails if any re-encoding step + // runs, even one that happens to preserve valid UTF-8 text. + const bodyText = JSON.stringify({ note: "café" }); + const bodyBytes = Buffer.from(bodyText, "utf8"); + const response = await fetch(`${gateway.baseUrl}/api/issues/issue-1/comments`, { + method: "POST", + headers: { + authorization: `Bearer ${bridgeToken}`, + "content-type": "application/json", + }, + body: bodyBytes, + }); + expect(response.status).toBe(200); + expect(seenBodies).toHaveLength(1); + expect(seenBodies[0]?.equals(bodyBytes)).toBe(true); + }, 15_000); + + it("forwards malformed UTF-8 bytes to the HTTP/2 host handler unchanged", async () => { + const bridgeToken = createSandboxCallbackBridgeToken(); + const seenBodies: Buffer[] = []; + const gateway = await startHttp2GatewayForTest({ + bridgeToken, + forwardRequest: async (request) => { + seenBodies.push(request.body); + return { status: 200, headers: {}, body: "" }; + }, + }); + + // Byte 0xC3 opens a two-byte UTF-8 sequence; 0x28 is not a valid + // continuation byte, so this body is not valid UTF-8. The gateway does + // not decode or validate the body, so these exact bytes must still + // arrive at the host handler unchanged. + const malformedBytes = Buffer.from([0x7b, 0x22, 0x61, 0x22, 0x3a, 0xc3, 0x28, 0x7d]); + const response = await fetch(`${gateway.baseUrl}/api/issues/issue-1/comments`, { + method: "POST", + headers: { + authorization: `Bearer ${bridgeToken}`, + "content-type": "application/json", + }, + body: malformedBytes, + }); + expect(response.status).toBe(200); + expect(seenBodies).toHaveLength(1); + expect(seenBodies[0]?.equals(malformedBytes)).toBe(true); + }, 15_000); + + it("rejects a request body over maxBodyBytes on the HTTP/2 path before it forwards a byte", async () => { + const bridgeToken = createSandboxCallbackBridgeToken(); + const maxBodyBytes = 32; + let forwardCalls = 0; + const gateway = await startHttp2GatewayForTest({ + bridgeToken, + maxBodyBytes, + forwardRequest: async () => { + forwardCalls += 1; + return { status: 200, headers: {}, body: "" }; + }, + }); + + const oversizeBody = Buffer.alloc(maxBodyBytes + 1, 0x41); + const response = await fetch(`${gateway.baseUrl}/api/issues/issue-1/comments`, { + method: "POST", + headers: { + authorization: `Bearer ${bridgeToken}`, + "content-type": "application/json", + }, + body: oversizeBody, + }); + expect(response.status).toBe(502); + await expect(response.json()).resolves.toMatchObject({ + error: "Bridge request body exceeded the configured size limit.", + }); + expect(forwardCalls).toBe(0); + }, 15_000); + + it("rejects a request body over maxBodyBytes on the queue path before it writes the queue file", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-bridge-queue-maxbody-")); + cleanupDirs.push(rootDir); + const localWorkspaceDir = path.join(rootDir, "local-workspace"); + const remoteWorkspaceDir = path.join(rootDir, "remote-workspace"); + await mkdir(localWorkspaceDir, { recursive: true }); + await mkdir(remoteWorkspaceDir, { recursive: true }); + await writeFile(path.join(localWorkspaceDir, "README.md"), "bridge maxBodyBytes test\n", "utf8"); + + const runner = createExecRunner(); + const bridgeAsset = await createSandboxCallbackBridgeAsset(); + cleanupFns.push(bridgeAsset.cleanup); + const prepared = await prepareCommandManagedRuntime({ + runner, + spec: { remoteCwd: remoteWorkspaceDir, timeoutMs: 30_000 }, + adapterKey: "codex", + workspaceLocalDir: localWorkspaceDir, + assets: [{ key: "bridge", localDir: bridgeAsset.localDir }], + }); + + const queueDir = path.posix.join(prepared.runtimeRootDir, "paperclip-bridge"); + const directories = sandboxCallbackBridgeDirectories(queueDir); + const bridgeToken = createSandboxCallbackBridgeToken(); + const maxBodyBytes = 32; + + const bridge = await startSandboxCallbackBridgeServer({ + runner, + remoteCwd: remoteWorkspaceDir, + assetRemoteDir: prepared.assetDirs.bridge, + queueDir, + bridgeToken, + timeoutMs: 30_000, + maxBodyBytes, + }); + cleanupFns.push(async () => { + await bridge.stop(); + }); + + const oversizeBody = Buffer.alloc(maxBodyBytes + 1, 0x41); + const response = await fetch(`${bridge.baseUrl}/api/issues/issue-1/comments`, { + method: "POST", + headers: { + authorization: `Bearer ${bridgeToken}`, + "content-type": "application/json", + }, + body: oversizeBody, + }); + expect(response.status).toBe(502); + await expect(response.json()).resolves.toMatchObject({ + error: "Bridge request body exceeded the configured size limit.", + }); + + const requestFiles = await readdir(directories.requestsDir); + expect(requestFiles.filter((name) => name.endsWith(".json"))).toHaveLength(0); + }); + + it("keeps the queue request payload's body field as a plain JSON string", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-bridge-queue-body-shape-")); + cleanupDirs.push(rootDir); + const localWorkspaceDir = path.join(rootDir, "local-workspace"); + const remoteWorkspaceDir = path.join(rootDir, "remote-workspace"); + await mkdir(localWorkspaceDir, { recursive: true }); + await mkdir(remoteWorkspaceDir, { recursive: true }); + await writeFile(path.join(localWorkspaceDir, "README.md"), "bridge body shape test\n", "utf8"); + + const runner = createExecRunner(); + const bridgeAsset = await createSandboxCallbackBridgeAsset(); + cleanupFns.push(bridgeAsset.cleanup); + const prepared = await prepareCommandManagedRuntime({ + runner, + spec: { remoteCwd: remoteWorkspaceDir, timeoutMs: 30_000 }, + adapterKey: "codex", + workspaceLocalDir: localWorkspaceDir, + assets: [{ key: "bridge", localDir: bridgeAsset.localDir }], + }); + + const queueDir = path.posix.join(prepared.runtimeRootDir, "paperclip-bridge"); + const bridgeToken = createSandboxCallbackBridgeToken(); + const requestBodyText = JSON.stringify({ note: "café" }); + + const seenRequests: Array<{ body: string }> = []; + const worker = await startSandboxCallbackBridgeWorker({ + client: createFileSystemSandboxCallbackBridgeQueueClient(), + queueDir, + authorizeRequest: async () => null, + handleRequest: async (request) => { + seenRequests.push({ body: request.body }); + return { status: 200, headers: {}, body: JSON.stringify({ ok: true }) }; + }, + }); + cleanupFns.push(async () => { + await worker.stop(); + }); + + const bridge = await startSandboxCallbackBridgeServer({ + runner, + remoteCwd: remoteWorkspaceDir, + assetRemoteDir: prepared.assetDirs.bridge, + queueDir, + bridgeToken, + timeoutMs: 30_000, + }); + cleanupFns.push(async () => { + await bridge.stop(); + }); + + const response = await fetch(`${bridge.baseUrl}/api/issues/issue-1/comments`, { + method: "POST", + headers: { + authorization: `Bearer ${bridgeToken}`, + "content-type": "application/json", + }, + body: requestBodyText, + }); + expect(response.status).toBe(200); + expect(seenRequests).toHaveLength(1); + expect(typeof seenRequests[0]?.body).toBe("string"); + expect(seenRequests[0]?.body).toBe(requestBodyText); + }); }); diff --git a/packages/adapter-utils/src/sandbox-callback-bridge.ts b/packages/adapter-utils/src/sandbox-callback-bridge.ts index dcbb1e7892f..2ca6a8dec6a 100644 --- a/packages/adapter-utils/src/sandbox-callback-bridge.ts +++ b/packages/adapter-utils/src/sandbox-callback-bridge.ts @@ -2133,7 +2133,7 @@ function normalizeHeaders(headers) { return out; } -async function readBody(req) { +async function readBodyBytes(req) { const chunks = []; let totalBytes = 0; for await (const chunk of req) { @@ -2144,7 +2144,11 @@ async function readBody(req) { throw new Error("Bridge request body exceeded the configured size limit."); } } - return Buffer.concat(chunks).toString("utf8"); + return Buffer.concat(chunks); +} + +async function readBody(req) { + return (await readBodyBytes(req)).toString("utf8"); } function tokensMatch(received) { @@ -2475,7 +2479,7 @@ function runHttp2Gateway() { writeJsonResponse(res, 415, { error: "Bridge only accepts JSON request bodies." }); return; } - const requestBodyBuffer = Buffer.from(await readBody(req), "utf8"); + const requestBodyBuffer = await readBodyBytes(req); let response; try { response = await forwardOverHttp2({ From a5c2add7be1664be4092ec49eddf8ae6880898c3 Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Tue, 25 Aug 2026 15:48:24 -0700 Subject: [PATCH 20/32] fix(adapter-utils): repair referenced-source ignore resolution type break and symlink descendant check (#12201) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Referenced projects are staged into run sandboxes, and a recent change made each staging site resolve the project's Git-ignored paths explicitly (`ignoreResolution` on `SandboxAdditionalSource`) > - One test call site was left without the now-required property, so `tsc` fails in adapter-utils and every Build/Typecheck CI job on master is red (surfaced when the TypeScript 7 bump landed in the same window) > - Separately, the new descendant check compares a logical caller path against the physical toplevel git prints, so any symlinked path fails ignore resolution spuriously — three of the suite's own tests fail on macOS because temp dirs live under the `/var` → `/private/var` symlink > - This pull request supplies the missing property at the test call site and makes the descendant comparison symlink-safe via realpath on both sides > - The benefit is a green master again, plus referenced-project staging that works from symlinked checkouts and temp directories ## Linked Issues or Issue Description No public issue exists; the underlying problem follows the bug-report template. **What happened?** `packages/adapter-utils` fails `tsc` on master: `src/sandbox-managed-runtime.test.ts(2284,29): error TS2741: Property 'ignoreResolution' is missing in type '{ localPath: string; projectId: string; }' but required in type 'SandboxAdditionalSource'.` Every Build/Typecheck CI job is red. Independently, `resolveReferencedSourceIgnore` returns `{ kind: "failed", reason: "referenced project path is not a descendant of its own Git top level: /var/... under /private/var/..." }` for any symlinked project path, and three tests in the suite fail on macOS. **Expected behavior** Master typechecks. A referenced project whose path reaches git through a symlink (macOS temp dirs, symlinked checkouts) resolves its ignore set normally, and the descendant check still fails closed for genuinely foreign paths. **Steps to reproduce** 1. `pnpm --filter @paperclipai/adapter-utils exec tsc --noEmit` on master — TS2741 at `sandbox-managed-runtime.test.ts:2284`. 2. On macOS: `pnpm vitest run packages/adapter-utils/src/sandbox-managed-runtime.test.ts` — three `resolveReferencedSourceIgnore` tests fail with the "not a descendant" reason above. **Paperclip version or commit** master `29d12045f`. ## What Changed - `sandbox-managed-runtime.test.ts:2284`: the one call site missing `ignoreResolution` now passes `{ kind: "other" }`, matching every sibling call site from the same change. - `sandbox-managed-runtime.ts`: `resolveReferencedSourceIgnore` resolves both the git toplevel and the caller's `localPath` through a new `physicalPath` helper (realpath with a resolve fallback) before the descendant comparison. Git prints physical toplevels, so both sides must be physical; the fallback keeps the check failing closed when a path vanishes mid-run. ## Verification - `pnpm vitest run packages/adapter-utils/src/sandbox-managed-runtime.test.ts` — 61/61 pass on macOS (previously 58 passing, 3 failing, plus the typecheck break). - `tsc --noEmit` in `packages/adapter-utils` is clean. ## Risks - Low. The behavioral change is confined to path normalization before an existing comparison; a realpath failure falls back to the prior string comparison, so the fail-closed property is preserved. ## Model Used Claude Fable 5 (`claude-fable-5`, Anthropic) via Claude Code — agentic coding session with tool use (code search, editing, local test execution). ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --- .../src/sandbox-managed-runtime.test.ts | 2 +- .../src/sandbox-managed-runtime.ts | 22 ++++++++++++++++++- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/packages/adapter-utils/src/sandbox-managed-runtime.test.ts b/packages/adapter-utils/src/sandbox-managed-runtime.test.ts index 9eb425002d7..a889f6b34fd 100644 --- a/packages/adapter-utils/src/sandbox-managed-runtime.test.ts +++ b/packages/adapter-utils/src/sandbox-managed-runtime.test.ts @@ -2281,7 +2281,7 @@ describe("sandbox managed runtime", () => { adapterKey: "test-adapter", client, workspaceLocalDir: localWorkspaceDir, - additionalSources: [{ localPath: referencedDir, projectId: "proj-first" }], + additionalSources: [{ localPath: referencedDir, projectId: "proj-first", ignoreResolution: { kind: "other" } }], onProgress: (line) => { lines.push(line); }, }); diff --git a/packages/adapter-utils/src/sandbox-managed-runtime.ts b/packages/adapter-utils/src/sandbox-managed-runtime.ts index c409533af08..bd5d5c5e719 100644 --- a/packages/adapter-utils/src/sandbox-managed-runtime.ts +++ b/packages/adapter-utils/src/sandbox-managed-runtime.ts @@ -212,6 +212,23 @@ export function referencedSourceIgnoreExcludeEntries(resolution: ReferencedSourc * are otherwise not comparable. The caller treats `null` as a resolution * failure (fail closed), never as "nothing to exclude". */ +/** + * Physical (symlink-resolved) form of a path, falling back to the plain + * resolved form when realpath fails (e.g. the path vanished mid-run — the + * downstream descendant check then fails closed on the string form). + * Git prints physical toplevels, so both sides of the descendant comparison + * must be physical too; comparing a logical path against a physical one makes + * any symlinked project or temp directory (macOS `/var` → `/private/var`) + * fail resolution spuriously. + */ +async function physicalPath(input: string): Promise { + try { + return await fs.realpath(input); + } catch { + return path.resolve(input); + } +} + function relativizeUnderGitToplevel(input: { toplevel: string; localPath: string }): string | null { const toplevel = path.resolve(input.toplevel); const localPath = path.resolve(input.localPath); @@ -263,7 +280,10 @@ export async function resolveReferencedSourceIgnore(localPath: string): Promise< if (!scan) { return { kind: "other" }; } - const offset = relativizeUnderGitToplevel({ toplevel: scan.toplevel, localPath }); + const offset = relativizeUnderGitToplevel({ + toplevel: await physicalPath(scan.toplevel), + localPath: await physicalPath(localPath), + }); if (offset === null) { return { kind: "failed", From 9c03443c488672cfb57281b0c45d5c59502edba2 Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Tue, 25 Aug 2026 16:06:35 -0700 Subject: [PATCH 21/32] feat: hideable company settings pages, with import floored on cloud-managed instances (#12199) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The app can run self-hosted or as a cloud-managed instance, where a hosting platform provisions the instance with its company already materialized (the existing `isCloudManagedInstance()` predicate and `cloud_managed` floors) > - The company Import/Export surface lets an operator materialize whole companies from an export bundle; on a cloud-managed instance this bypasses the existing `cloud_managed` company-creation floor and conflicts with platform-owned provisioning > - Importing should be disabled on cloud-managed instances, while export stays open as the data-portability escape hatch > - This pull request floors every import route with 403 `code=cloud_managed` on cloud-managed instances and hides the Import UI there, using the existing predicate and the established floor pattern > - It also extends the operator-hidden settings registry with keys for every top-level company settings page, so a hosting operator can hide any of them with `PAPERCLIP_HIDDEN_SETTINGS` alone next time > - The benefit is one consistent managed-instance policy: cloud-managed instances cannot import companies, self-hosted installs keep the full import surface unchanged ## Linked Issues or Issue Description No public issue exists; the underlying problem follows the enhancement template. **What existing behavior does this improve?** The company import surface (`/api/companies/import*`, `/api/companies/:companyId/imports/*`) and its UI entry points on cloud-managed instances. **Subsystem affected** Server routes (`server/src/routes/companies.ts`) and UI navigation/pages (settings sidebar, settings tabs, org chart, `/company/import` route). **Current behavior** A cloud-managed instance floors direct company creation (`POST /api/companies` answers 403 `cloud_managed`), but the import routes still accept company bundles, so an import can materialize companies the hosting platform did not provision. The UI offers Import entry points that lead to a surface that is not available on cloud-managed instances. **Proposed behavior** On instances where `isCloudManagedInstance()` is true, every import route answers 403 `code=cloud_managed` before auth and body work, and the Import UI (sidebar entry, settings tab, org-chart button, `/company/import` route) is hidden or redirected. Export remains fully available. Self-hosted instances are unchanged. **Reason and benefit** Cloud-managed instances keep one consistent provisioning authority, and users do not see an Import surface that dead-ends in a 403. ## What Changed - `server/src/routes/companies.ts`: a router-level floor mounted at the `/import` and `/:companyId/imports` prefixes. It covers the single-shot upload, preview, job polling, chunked transfer declare/part-upload/status/preview/apply, and the agent-safe per-company import routes. It throws `forbidden(..., { code: "cloud_managed" })` on cloud-managed instances, or `403 settings_operator_managed` when the operator hides `company.import` — both before auth and body validation, mirroring the company-creation floor. - `packages/shared/src/settings-visibility.ts`: new `HIDEABLE_COMPANY_PAGES` registry group — `company.members`, `company.invites`, `company.secrets`, `company.export`, `company.import` — with a `hidesCompanyPage` helper. The company General page stays non-hideable (settings root). `company.import` floors its API; the other keys are UI-visibility only, as documented in the registry, so membership/invite/secret/export APIs stay live for agents. - `ui/src/components/CloudManagedPageGate.tsx` (new): route gate that redirects cloud-managed instances to `/company/settings`, modeled on `HiddenSettingsPageGate`. - `ui/src/App.tsx`: wraps the `company/import` route in `CloudManagedPageGate`. - `ui/src/components/CompanySettingsSidebar.tsx`, `ui/src/components/access/CompanySettingsNav.tsx`, `ui/src/pages/OrgChart.tsx`: hide the Import entry points when `useCloudInstance()` reports a managed instance, and honor the new `company.*` hidden-settings keys for every company page entry (sidebar item, tab, org-chart buttons). - `ui/src/App.tsx`: `HiddenSettingsPageGate` route gates for the members (incl. the legacy access route), invites, secrets, export, and import pages under their `company.*` keys. - `docs/deploy/environment-variables.md`: documents the new keys and their semantics; the CLI and board-operator guides note that import is unavailable on cloud-managed instances. - Tests: new `server/src/__tests__/company-import-cloud-floor.test.ts` and `ui/src/components/CloudManagedPageGate.test.tsx`, registry cases in `packages/shared/src/settings-visibility.test.ts`, plus cloud and hidden-key cases in the sidebar, settings-nav, and org-chart suites. ## Verification - TypeScript typechecks pass for every workspace package (`tsc` in shared, server, ui; the runner's Rust leg needs a local cargo toolchain and is covered by CI). - `pnpm test` on this branch fails only in 9 files that also fail on a clean `origin/master` checkout on the same machine (environment-dependent suites: live-listener probes, workspace/native-runtime spawns, skill materialization). Zero branch-only failures against that baseline; every suite touched by this change passes. - `server/src/__tests__/company-import-cloud-floor.test.ts` asserts: every import route answers 403 `cloud_managed` under the server-token signal; the managed-config signal alone also floors; every import route answers 403 `settings_operator_managed` when `company.import` is hidden; hiding other company pages leaves import open; the floor applies before auth and body validation; export stays open on cloud-managed instances; self-hosted import preview and job polling still work. - `packages/shared/src/settings-visibility.test.ts` covers the new `company.*` keys and `hidesCompanyPage`. - UI suites assert the Import tab, sidebar entry, and org-chart button disappear on a cloud-managed instance while Export stays, that `/company/import` redirects through the gate, and that the `company.*` keys hide their sidebar entries and tabs. ## Risks - Low risk for self-hosted installs: the floor is inert unless a cloud signal (`PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN` or `PAPERCLIP_MANAGED_CONFIG`) is present, and the self-hosted paths are regression-tested. - On cloud-managed instances this is a deliberate behavioral removal: import (including agent-driven safe imports and resumable transfers) stops working the moment an instance runs this build. In-flight chunked transfers on such instances cannot be applied afterward; they answer 403. - CLI import commands against a cloud-managed instance now fail with the `cloud_managed` error; the message names the reason. - The new `company.*` keys change nothing unless an operator sets them: `PAPERCLIP_HIDDEN_SETTINGS` unset keeps behavior identical, and older images ignore unknown keys by design. The four non-import company keys hide UI only; their APIs stay live, which the registry documents explicitly. ## Model Used Claude Fable 5 (`claude-fable-5`, Anthropic) via Claude Code — agentic coding session with tool use (code search, editing, local test execution). ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --- docs/cli/control-plane-commands.md | 4 + docs/deploy/environment-variables.md | 9 + .../board-operator/importing-and-exporting.md | 2 + packages/shared/src/index.ts | 3 + .../shared/src/settings-visibility.test.ts | 22 ++ packages/shared/src/settings-visibility.ts | 36 ++- .../company-import-cloud-floor.test.ts | 205 ++++++++++++++++++ .../company-portability-routes.test.ts | 20 +- server/src/routes/companies.ts | 39 +++- ui/src/App.tsx | 39 ++-- .../components/CloudManagedPageGate.test.tsx | 72 ++++++ ui/src/components/CloudManagedPageGate.tsx | 17 ++ .../CompanySettingsSidebar.test.tsx | 28 ++- ui/src/components/CompanySettingsSidebar.tsx | 36 ++- .../access/CompanySettingsNav.test.tsx | 48 +++- .../components/access/CompanySettingsNav.tsx | 10 + ui/src/pages/OrgChart.test.tsx | 16 ++ ui/src/pages/OrgChart.tsx | 37 +++- 18 files changed, 589 insertions(+), 54 deletions(-) create mode 100644 server/src/__tests__/company-import-cloud-floor.test.ts create mode 100644 ui/src/components/CloudManagedPageGate.test.tsx create mode 100644 ui/src/components/CloudManagedPageGate.tsx diff --git a/docs/cli/control-plane-commands.md b/docs/cli/control-plane-commands.md index 75f197cc354..06c8143567c 100644 --- a/docs/cli/control-plane-commands.md +++ b/docs/cli/control-plane-commands.md @@ -57,6 +57,10 @@ npx paperclipai company import \ --include company,agents ``` +`company import` is unavailable against cloud-managed instances — the +server answers `403` with `code: "cloud_managed"`. Export remains available +there. + With agent authentication, use `company list` or `company current` to resolve the scoped company. `company list` first tries the board-wide list; if that is forbidden, it falls back to `--company-id`, `PAPERCLIP_COMPANY_ID`, context, or diff --git a/docs/deploy/environment-variables.md b/docs/deploy/environment-variables.md index df5b2008f15..dc0bcbe8f3d 100644 --- a/docs/deploy/environment-variables.md +++ b/docs/deploy/environment-variables.md @@ -41,6 +41,15 @@ All environment variables that Paperclip uses for server configuration. - Any experimental toggle: `instance.experimental.` (e.g. `instance.experimental.enableSmokeLab`) — the card disappears and value-changing writes are rejected. +- Any top-level company settings page: `company.members`, `company.invites`, + `company.secrets`, `company.export`, `company.import` — removed from the + settings sidebar, tab bar, and routing (the company General page is the + settings root and stays visible). These are UI-visibility keys: the + membership, invite, secret, and export APIs stay live for agents and + integrations. `company.import` is the exception — hiding it also floors + every company-import route with `403 settings_operator_managed`. On + cloud-managed instances import is floored unconditionally with + `403 cloud_managed`, independent of this variable. Unknown keys are logged and ignored, so one list can be rolled across a fleet of mixed app versions. With the variable unset nothing is hidden and behavior diff --git a/docs/guides/board-operator/importing-and-exporting.md b/docs/guides/board-operator/importing-and-exporting.md index 1d8e40d241e..46b2929f5a8 100644 --- a/docs/guides/board-operator/importing-and-exporting.md +++ b/docs/guides/board-operator/importing-and-exporting.md @@ -33,6 +33,8 @@ my-company/ Both flows are also available in the web UI as company settings pages: **Export** and **Import** appear in the company settings navigation. +> **Cloud-managed instances:** when a hosting platform manages the instance, the company is provisioned by the platform and importing is disabled — the Import page and buttons are hidden, and every import API route answers `403` with `code: "cloud_managed"`. Export stays available, so you can always take your company's data with you. Self-hosted instances keep the full import surface. + The **Export** page lets you pick exactly which files go into the bundle before downloading it. Above the file tree it shows a **"Not included in this export"** panel — the export fidelity report — listing data the bundle will not carry (for example attachments, approvals, cost history, or activity log entries), with blocking issues highlighted. The **Import** page previews the package, lets you resolve name collisions and adapter assignments, and applies the import. A **"Start imported agents and routines paused"** checkbox (on by default) makes imported agents and routines land paused instead of live. After the import finishes, an **"Activate imported agents and routines"** panel lists everything that was imported paused so you can resume the agents and activate the routines you select — nothing starts running until you say so. diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index eba88a35d52..378f8c4a44f 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -2499,16 +2499,19 @@ export { type InstanceFeatureKey, } from "./feature-catalog.js"; export { + HIDEABLE_COMPANY_PAGES, HIDEABLE_GENERAL_SECTIONS, HIDEABLE_INSTANCE_PAGES, HIDEABLE_SETTING_KEYS, SETTINGS_OPERATOR_MANAGED_ERROR_CODE, UI_ONLY_GENERAL_SECTIONS, experimentalSettingKey, + hidesCompanyPage, hidesExperimentalSetting, hidesGeneralSection, hidesInstancePage, parseHiddenSettingsList, + type HideableCompanyPage, type HideableExperimentalSetting, type HideableGeneralSection, type HideableInstancePage, diff --git a/packages/shared/src/settings-visibility.test.ts b/packages/shared/src/settings-visibility.test.ts index 8bdba67d9e7..ece26654eee 100644 --- a/packages/shared/src/settings-visibility.test.ts +++ b/packages/shared/src/settings-visibility.test.ts @@ -1,10 +1,12 @@ import { describe, expect, it } from "vitest"; import { INSTANCE_FEATURE_KEYS } from "./feature-catalog.js"; import { + HIDEABLE_COMPANY_PAGES, HIDEABLE_GENERAL_SECTIONS, HIDEABLE_SETTING_KEYS, UI_ONLY_GENERAL_SECTIONS, experimentalSettingKey, + hidesCompanyPage, hidesExperimentalSetting, hidesGeneralSection, hidesInstancePage, @@ -21,6 +23,19 @@ describe("hideable setting keys", () => { expect(new Set(HIDEABLE_SETTING_KEYS).size).toBe(HIDEABLE_SETTING_KEYS.length); }); + it("covers every top-level company settings page except the General root", () => { + expect(HIDEABLE_COMPANY_PAGES).toEqual([ + "company.members", + "company.invites", + "company.secrets", + "company.export", + "company.import", + ]); + for (const page of HIDEABLE_COMPANY_PAGES) { + expect(HIDEABLE_SETTING_KEYS).toContain(page); + } + }); + it("maps field-backed general sections onto real general-settings fields", () => { const generalFields = Object.keys(instanceGeneralSettingsSchema.shape); const uiOnly = new Set(UI_ONLY_GENERAL_SECTIONS); @@ -57,6 +72,13 @@ describe("membership helpers", () => { ).hidden, ); + it("answers company-page membership", () => { + const companyHidden = new Set(parseHiddenSettingsList("company.import,company.secrets").hidden); + expect(hidesCompanyPage(companyHidden, "company.import")).toBe(true); + expect(hidesCompanyPage(companyHidden, "company.secrets")).toBe(true); + expect(hidesCompanyPage(companyHidden, "company.export")).toBe(false); + }); + it("answers page, section, and experimental membership", () => { expect(hidesInstancePage(hidden, "instance.plugins")).toBe(true); expect(hidesInstancePage(hidden, "instance.adapters")).toBe(false); diff --git a/packages/shared/src/settings-visibility.ts b/packages/shared/src/settings-visibility.ts index 2dd9fe7e5b3..f7fa75c583a 100644 --- a/packages/shared/src/settings-visibility.ts +++ b/packages/shared/src/settings-visibility.ts @@ -4,14 +4,17 @@ import { INSTANCE_FEATURE_KEYS, type InstanceFeatureKey } from "./feature-catalo * Operator-configurable settings visibility. * * A hosting operator (a managed cloud, an internal shared server) can hide - * instance-settings surfaces that do not apply to their deployment by setting - * the `PAPERCLIP_HIDDEN_SETTINGS` environment variable to a comma-separated + * settings surfaces that do not apply to their deployment by setting the + * `PAPERCLIP_HIDDEN_SETTINGS` environment variable to a comma-separated * list of keys from this registry. Hiding a surface removes it from the UI * (nav, routes, page sections). Surfaces backed by instance-level mutation * routes are also floored with a 403 carrying * `SETTINGS_OPERATOR_MANAGED_ERROR_CODE`: the Access, Plugins, and Adapters - * pages, every field-backed General section, and every experimental toggle - * (individually or via the whole Experimental page). + * pages, every field-backed General section, every experimental toggle + * (individually or via the whole Experimental page), and the company Import + * page (whose whole route surface is floored). The other company pages are + * UI-visibility keys only: their APIs (memberships, invites, secrets, + * exports) stay live for agents and integrations. * * Nothing is hidden by default: with the variable unset, UI and API behave * exactly as before this mechanism existed. @@ -39,6 +42,22 @@ export const HIDEABLE_INSTANCE_PAGES = [ export type HideableInstancePage = (typeof HIDEABLE_INSTANCE_PAGES)[number]; +/** + * Company-level settings pages that can be hidden (nav entry + tab + route). + * The company General page is deliberately not hideable: it is the settings + * root and the redirect target for hidden pages. `company.import` also floors + * the import API routes; the rest only hide UI surfaces. + */ +export const HIDEABLE_COMPANY_PAGES = [ + "company.members", + "company.invites", + "company.secrets", + "company.export", + "company.import", +] as const; + +export type HideableCompanyPage = (typeof HIDEABLE_COMPANY_PAGES)[number]; + /** * Sections of Instance → General that can be hidden. Field-backed sections * (their suffix names a general-settings field) also floor writes to that @@ -70,12 +89,14 @@ export function experimentalSettingKey(key: InstanceFeatureKey): HideableExperim export type HideableSettingKey = | HideableInstancePage + | HideableCompanyPage | HideableGeneralSection | HideableExperimentalSetting; /** Every key `PAPERCLIP_HIDDEN_SETTINGS` accepts. */ export const HIDEABLE_SETTING_KEYS: readonly HideableSettingKey[] = [ ...HIDEABLE_INSTANCE_PAGES, + ...HIDEABLE_COMPANY_PAGES, ...HIDEABLE_GENERAL_SECTIONS, ...INSTANCE_FEATURE_KEYS.map(experimentalSettingKey), ]; @@ -117,6 +138,13 @@ export function hidesInstancePage( return hidden.has(page); } +export function hidesCompanyPage( + hidden: ReadonlySet, + page: HideableCompanyPage, +): boolean { + return hidden.has(page); +} + export function hidesGeneralSection( hidden: ReadonlySet, section: HideableGeneralSection, diff --git a/server/src/__tests__/company-import-cloud-floor.test.ts b/server/src/__tests__/company-import-cloud-floor.test.ts new file mode 100644 index 00000000000..71482ac2276 --- /dev/null +++ b/server/src/__tests__/company-import-cloud-floor.test.ts @@ -0,0 +1,205 @@ +import express from "express"; +import request from "supertest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mockPortabilityService = vi.hoisted(() => ({ + previewExport: vi.fn(), + exportBundle: vi.fn(), + previewImport: vi.fn(), + importBundle: vi.fn(), +})); + +const mockTransferRunService = vi.hoisted(() => ({ + resumeOrCreate: vi.fn(), + getRunForActor: vi.fn(), + recordCompletedPart: vi.fn(), + claimApply: vi.fn(), + releaseApplyClaim: vi.fn(), + completeRun: vi.fn(), + failRun: vi.fn(), +})); + +const mockLogActivity = vi.hoisted(() => vi.fn()); + +vi.mock("../services/index.js", () => ({ + accessService: () => ({}), + agentService: () => ({}), + budgetService: () => ({}), + companyArtifactsService: () => ({}), + companyPortabilityService: () => mockPortabilityService, + companyService: () => ({}), + feedbackService: () => ({}), + logActivity: mockLogActivity, + workTimelineService: () => ({}), +})); + +vi.mock("../services/company-transfer-runs.js", () => ({ + companyTransferRunService: mockTransferRunService, +})); + +const TRANSFER_ID = "6e0a4f6e-6f7d-4a37-9a83-0b8f2f9f2b11"; + +/** + * Every route in the company-import surface. The floor must answer all of + * them, including the read-only polling routes: with imports disabled no job + * or transfer can exist, so a uniform 403 is clearer than a mixed surface. + */ +const IMPORT_ROUTES: Array<{ method: "get" | "post" | "put"; path: string }> = [ + { method: "post", path: "/api/companies/import/preview" }, + { method: "post", path: "/api/companies/import" }, + { method: "get", path: "/api/companies/import/jobs/some-job" }, + { method: "post", path: "/api/companies/import/transfers" }, + { method: "put", path: `/api/companies/import/transfers/${TRANSFER_ID}/parts/0` }, + { method: "get", path: `/api/companies/import/transfers/${TRANSFER_ID}` }, + { method: "post", path: `/api/companies/import/transfers/${TRANSFER_ID}/preview` }, + { method: "post", path: `/api/companies/import/transfers/${TRANSFER_ID}/apply` }, + { method: "post", path: "/api/companies/11111111-2222-4333-8444-555555555555/imports/preview" }, + { method: "post", path: "/api/companies/11111111-2222-4333-8444-555555555555/imports/apply" }, +]; + +async function createApp(actor: Record) { + const [{ companyRoutes }, { errorHandler }] = await Promise.all([ + import("../routes/companies.js"), + import("../middleware/index.js"), + ]); + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + (req as any).actor = actor; + next(); + }); + app.use("/api/companies", companyRoutes({} as any)); + app.use(errorHandler); + return app; +} + +const boardAdmin = { + type: "board", + source: "local_implicit", + userId: "local-user", + isInstanceAdmin: true, +}; + +function expectNoImportWork() { + expect(mockPortabilityService.previewImport).not.toHaveBeenCalled(); + expect(mockPortabilityService.importBundle).not.toHaveBeenCalled(); + expect(mockTransferRunService.resumeOrCreate).not.toHaveBeenCalled(); + expect(mockTransferRunService.getRunForActor).not.toHaveBeenCalled(); +} + +describe("company import Cloud floor", () => { + beforeEach(() => { + vi.clearAllMocks(); + delete process.env.PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN; + delete process.env.PAPERCLIP_MANAGED_CONFIG; + delete process.env.PAPERCLIP_HIDDEN_SETTINGS; + }); + + afterEach(() => { + delete process.env.PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN; + delete process.env.PAPERCLIP_MANAGED_CONFIG; + delete process.env.PAPERCLIP_HIDDEN_SETTINGS; + }); + + it("returns 403 cloud_managed on every import route on a cloud-managed instance", async () => { + process.env.PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN = "tenant-secret"; + const app = await createApp(boardAdmin); + + for (const route of IMPORT_ROUTES) { + const res = await request(app)[route.method](route.path).send({}); + expect(res.status, `${route.method.toUpperCase()} ${route.path}`).toBe(403); + expect(res.body, `${route.method.toUpperCase()} ${route.path}`).toMatchObject({ + code: "cloud_managed", + }); + } + expectNoImportWork(); + }); + + it("floors on the managed-config signal alone", async () => { + process.env.PAPERCLIP_MANAGED_CONFIG = JSON.stringify({ v: 1, mode: "cloud" }); + const app = await createApp(boardAdmin); + + const res = await request(app).post("/api/companies/import").send({}); + + expect(res.status).toBe(403); + expect(res.body).toMatchObject({ code: "cloud_managed" }); + expectNoImportWork(); + }); + + it("applies the floor before auth and request-body validation", async () => { + process.env.PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN = "tenant-secret"; + // An agent actor never passes the import routes' own assertBoard, and the + // body is not schema-valid either — the floor must still answer first so a + // Cloud caller sees one consistent refusal. + const app = await createApp({ type: "agent", agentId: "agent-1", companyId: "company-1" }); + + const res = await request(app) + .post("/api/companies/import/transfers") + .send({ nonsense: true }); + + expect(res.status).toBe(403); + expect(res.body).toMatchObject({ code: "cloud_managed" }); + expectNoImportWork(); + }); + + it("floors every import route with settings_operator_managed when company.import is hidden", async () => { + process.env.PAPERCLIP_HIDDEN_SETTINGS = "company.import"; + const app = await createApp(boardAdmin); + + for (const route of IMPORT_ROUTES) { + const res = await request(app)[route.method](route.path).send({}); + expect(res.status, `${route.method.toUpperCase()} ${route.path}`).toBe(403); + expect(res.body, `${route.method.toUpperCase()} ${route.path}`).toMatchObject({ + code: "settings_operator_managed", + }); + } + expectNoImportWork(); + }); + + it("keeps import open when only other company pages are hidden", async () => { + process.env.PAPERCLIP_HIDDEN_SETTINGS = "company.secrets,company.members"; + const app = await createApp(boardAdmin); + + const res = await request(app).get("/api/companies/import/jobs/unknown-job"); + + expect(res.status).toBe(404); + expect(res.body).toMatchObject({ error: "Import job not found" }); + }); + + it("keeps company export open on a cloud-managed instance", async () => { + process.env.PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN = "tenant-secret"; + mockPortabilityService.exportBundle.mockResolvedValue({ ok: true }); + const app = await createApp(boardAdmin); + + const res = await request(app) + .post("/api/companies/11111111-2222-4333-8444-555555555555/exports") + .send({}); + + expect(res.status).toBe(200); + expect(mockPortabilityService.exportBundle).toHaveBeenCalledTimes(1); + }); + + it("preserves self-hosted import preview", async () => { + mockPortabilityService.previewImport.mockResolvedValue({ companies: [] }); + const app = await createApp(boardAdmin); + + const res = await request(app) + .post("/api/companies/import/preview") + .send({ + source: { type: "github", url: "https://github.com/example/export" }, + target: { mode: "new_company" }, + }); + + expect(res.status).toBe(200); + expect(mockPortabilityService.previewImport).toHaveBeenCalledTimes(1); + }); + + it("preserves self-hosted import job polling", async () => { + const app = await createApp(boardAdmin); + + const res = await request(app).get("/api/companies/import/jobs/unknown-job"); + + expect(res.status).toBe(404); + expect(res.body).toMatchObject({ error: "Import job not found" }); + }); +}); diff --git a/server/src/__tests__/company-portability-routes.test.ts b/server/src/__tests__/company-portability-routes.test.ts index 010d4e2b21a..94c57559ee2 100644 --- a/server/src/__tests__/company-portability-routes.test.ts +++ b/server/src/__tests__/company-portability-routes.test.ts @@ -859,9 +859,12 @@ describe.sequential("company portability routes", () => { expect(mockLogActivity).not.toHaveBeenCalled(); }); - it.sequential("keeps Cloud-managed global import apply synchronous when async opt-in is absent", async () => { + it.sequential("floors global import apply on a cloud-managed instance, even for the trusted tenant actor", async () => { + // The trusted-tenant tests above run without the cloud env signal on + // purpose: the import floor keys on isCloudManagedInstance(), not on the + // actor. With the signal present, even the trusted tenant actor is + // floored — importing is disabled on cloud-managed instances outright. vi.stubEnv("PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN", "tenant-secret"); - mockCompanyPortabilityService.importBundle.mockResolvedValueOnce(createImportResult("created")); try { const app = await createApp(cloudTenantActor()); @@ -870,15 +873,10 @@ describe.sequential("company portability routes", () => { .set(cloudHeaders) .send(importRequest); - expect(res.status).toBe(200); - expect(res.body.company.id).toBe(companyId); - expect(res.body.company.action).toBe("created"); - expect(res.body.job).toBeUndefined(); - expect(mockCompanyPortabilityService.importBundle).toHaveBeenCalledWith(importRequest, "cloud-user-1", { pauseAutomations: false }); - expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ - action: "company.imported", - companyId, - })); + expect(res.status).toBe(403); + expect(res.body).toMatchObject({ code: "cloud_managed" }); + expect(mockCompanyPortabilityService.importBundle).not.toHaveBeenCalled(); + expect(mockLogActivity).not.toHaveBeenCalled(); } finally { vi.unstubAllEnvs(); } diff --git a/server/src/routes/companies.ts b/server/src/routes/companies.ts index 6a929196e14..a6152c303a3 100644 --- a/server/src/routes/companies.ts +++ b/server/src/routes/companies.ts @@ -1,5 +1,5 @@ import { createHash, randomUUID } from "node:crypto"; -import express, { Router, type Request, type Response } from "express"; +import express, { Router, type NextFunction, type Request, type Response } from "express"; import multer from "multer"; import { and, count as countFn, eq } from "drizzle-orm"; import { z } from "zod"; @@ -13,6 +13,7 @@ import { } from "@paperclipai/shared/portability-zip"; import { DEFAULT_FEEDBACK_DATA_SHARING_TERMS_VERSION, + SETTINGS_OPERATOR_MANAGED_ERROR_CODE, companyArtifactsQuerySchema, companyPortabilityExportSchema, companyPortabilityImportSchema, @@ -21,6 +22,7 @@ import { feedbackTargetTypeSchema, feedbackTraceStatusSchema, feedbackVoteValueSchema, + hidesCompanyPage, updateCompanyBrandingSchema, updateCompanySchema, } from "@paperclipai/shared"; @@ -61,6 +63,7 @@ import { workTimelineService, } from "../services/index.js"; import { isCloudManagedInstance } from "../services/cloud-instance.js"; +import { getHiddenSettings } from "../services/settings-visibility.js"; import type { StorageService } from "../storage/types.js"; import { assertBoard, assertCompanyAccess, assertInstanceAdmin, getActorInfo, hasCompanyAccess } from "./authz.js"; import { COMPANY_IMPORT_ROUTE_PATH } from "./company-import-paths.js"; @@ -503,6 +506,40 @@ export function companyRoutes(db: Db, storage?: StorageService, options?: Compan res.json(buildExportFidelityReport(companyId, counts)); }); + /** + * Floor for the whole company-import surface: single-shot upload, preview, + * job polling, chunked transfers, and the per-company agent-safe import + * routes. Two independent signals close it, both checked up front — before + * auth or body work, the same way the company-creation floor does: + * + * - a cloud-managed instance (`cloud_managed`): the hosting platform + * provisions the company, so materializing imported companies on a + * managed instance is disabled unconditionally; + * - the operator hiding the Import page (`company.import` in + * `PAPERCLIP_HIDDEN_SETTINGS` → `settings_operator_managed`): hiding the + * page also disables its API, so the hide is real rather than cosmetic. + * + * Export routes stay open either way — they are the tenant's + * data-portability escape hatch. + */ + const importFloor = (_req: Request, _res: Response, next: NextFunction) => { + if (isCloudManagedInstance()) { + throw forbidden("Company import is disabled on cloud-managed instances", { + code: "cloud_managed", + }); + } + if (hidesCompanyPage(getHiddenSettings(), "company.import")) { + throw forbidden("Company import is hidden by the hosting operator", { + code: SETTINGS_OPERATOR_MANAGED_ERROR_CODE, + }); + } + next(); + }; + // COMPANY_IMPORT_TRANSFERS_ROUTE_PATH nests under the import path, so these + // two prefixes cover every import route registered below. + router.use(COMPANY_IMPORT_ROUTE_PATH, importFloor); + router.use("/:companyId/imports", importFloor); + router.post("/import/preview", async (req, res) => { assertBoard(req); const body = companyPortabilityPreviewSchema.parse(await resolveImportPayload(req, res)); diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 161c2b915f7..bf877bfa348 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -9,6 +9,7 @@ import { PipelinesExperimentalGate } from "./components/PipelinesExperimentalGat import { CasesExperimentalGate } from "./components/CasesExperimentalGate"; import { StatusCardsExperimentalGate } from "./components/StatusCardsExperimentalGate"; import { AppsExperimentalGate } from "./components/AppsExperimentalGate"; +import { CloudManagedPageGate } from "./components/CloudManagedPageGate"; import { HiddenSettingsPageGate } from "./components/HiddenSettingsPageGate"; import { useHiddenSettings } from "./hooks/useHiddenSettings"; import { Cases } from "./pages/Cases"; @@ -115,19 +116,31 @@ function boardRoutes() { } /> } /> } /> - } /> - } /> - } /> - }> - - - )} - /> - } /> - } /> + }> + } /> + } /> + + }> + } /> + + }> + }> + + + )} + /> + + }> + }> + } /> + + + }> + } /> + } /> } /> } /> diff --git a/ui/src/components/CloudManagedPageGate.test.tsx b/ui/src/components/CloudManagedPageGate.test.tsx new file mode 100644 index 00000000000..c07bc80dc1b --- /dev/null +++ b/ui/src/components/CloudManagedPageGate.test.tsx @@ -0,0 +1,72 @@ +// @vitest-environment jsdom + +import { flushSync } from "react-dom"; +import { createRoot, type Root } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { queryKeys } from "@/lib/queryKeys"; +import { CloudManagedPageGate } from "./CloudManagedPageGate"; + +vi.mock("@/lib/router", () => ({ + Navigate: ({ to, replace }: { to: string; replace?: boolean }) => ( +
+ ), + Outlet: () =>
Page content
, +})); + +async function flushReact() { + for (let index = 0; index < 5; index += 1) { + await Promise.resolve(); + await new Promise((resolve) => window.setTimeout(resolve, 0)); + } + flushSync(() => {}); +} + +describe("CloudManagedPageGate", () => { + let container: HTMLDivElement; + let root: Root | null = null; + + async function renderGate(health?: Record) { + root = createRoot(container); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + if (health !== undefined) { + queryClient.setQueryData(queryKeys.health, health); + } + flushSync(() => { + root!.render( + + + , + ); + }); + await flushReact(); + } + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + }); + + afterEach(() => { + flushSync(() => root?.unmount()); + root = null; + container.remove(); + vi.clearAllMocks(); + }); + + it("redirects to the settings root on a Cloud-managed instance", async () => { + await renderGate({ status: "ok", cloud: { managed: true } }); + + expect(container.querySelector('[data-testid="navigate"]')?.getAttribute("data-to")).toBe( + "/company/settings", + ); + expect(container.querySelector('[data-testid="page-content"]')).toBeNull(); + }); + + it("renders the page on a self-hosted instance", async () => { + await renderGate({ status: "ok" }); + + expect(container.querySelector('[data-testid="page-content"]')).not.toBeNull(); + expect(container.querySelector('[data-testid="navigate"]')).toBeNull(); + }); +}); diff --git a/ui/src/components/CloudManagedPageGate.tsx b/ui/src/components/CloudManagedPageGate.tsx new file mode 100644 index 00000000000..7673965083c --- /dev/null +++ b/ui/src/components/CloudManagedPageGate.tsx @@ -0,0 +1,17 @@ +import { Navigate, Outlet } from "@/lib/router"; +import { useCloudInstance } from "@/hooks/useCloudInstance"; + +/** + * Route gate for pages that are floored on cloud-managed instances (the + * server answers 403 `cloud_managed`), like company import. Cloud-managed + * instances redirect to the settings root instead of rendering a dead-ended + * page. Under + * CloudAccessGate the health response is always cached before board routes + * mount, so the cloud flag is already resolved when this renders. + */ +export function CloudManagedPageGate() { + const isCloud = Boolean(useCloudInstance()); + + if (isCloud) return ; + return ; +} diff --git a/ui/src/components/CompanySettingsSidebar.test.tsx b/ui/src/components/CompanySettingsSidebar.test.tsx index d8bc36c3eff..a0671cb8446 100644 --- a/ui/src/components/CompanySettingsSidebar.test.tsx +++ b/ui/src/components/CompanySettingsSidebar.test.tsx @@ -377,12 +377,13 @@ describe("CompanySettingsSidebar operator-hidden entries", () => { vi.clearAllMocks(); }); - async function renderSidebar(hiddenSettings?: string[]) { + async function renderSidebar(hiddenSettings?: string[], cloud?: { managed: boolean }) { const root = createRoot(container); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); queryClient.setQueryData(queryKeys.health, { status: "ok", ...(hiddenSettings ? { hiddenSettings } : {}), + ...(cloud ? { cloud } : {}), }); await act(async () => { root.render( @@ -412,6 +413,31 @@ describe("CompanySettingsSidebar operator-hidden entries", () => { expect(container.textContent).toContain("Plugins"); expect(container.textContent).toContain("Heartbeats"); expect(container.textContent).toContain("Adapters"); + expect(container.textContent).toContain("Import"); expect(mockPluginsApi.list).toHaveBeenCalled(); }); + + it("hides Import but keeps Export on a Cloud-managed instance", async () => { + await renderSidebar(undefined, { managed: true }); + + expect(container.textContent).not.toContain("Import"); + expect(container.textContent).toContain("Export"); + }); + + it("hides operator-hidden company pages", async () => { + await renderSidebar([ + "company.members", + "company.invites", + "company.secrets", + "company.export", + "company.import", + ]); + + expect(container.textContent).toContain("General"); + expect(container.textContent).not.toContain("Members"); + expect(container.textContent).not.toContain("Invites"); + expect(container.textContent).not.toContain("Secrets"); + expect(container.textContent).not.toContain("Export"); + expect(container.textContent).not.toContain("Import"); + }); }); diff --git a/ui/src/components/CompanySettingsSidebar.tsx b/ui/src/components/CompanySettingsSidebar.tsx index d1c01eeb8be..2f9e7abaaaf 100644 --- a/ui/src/components/CompanySettingsSidebar.tsx +++ b/ui/src/components/CompanySettingsSidebar.tsx @@ -25,6 +25,7 @@ import { SIDEBAR_SCROLL_RESET_STATE } from "@/lib/navigation-scroll"; import { queryKeys } from "@/lib/queryKeys"; import { useCompany } from "@/context/CompanyContext"; import { useSidebar } from "@/context/SidebarContext"; +import { useCloudInstance } from "@/hooks/useCloudInstance"; import { useHiddenSettings } from "@/hooks/useHiddenSettings"; import { usePluginSlots } from "@/plugins/slots"; import { SidebarNavItem } from "./SidebarNavItem"; @@ -47,6 +48,9 @@ export function CompanySettingsSidebar() { const { hidden: hiddenSettings } = useHiddenSettings(); const showPage = (pageKey: string) => !hiddenSettings.has(pageKey); const showPlugins = showPage("instance.plugins"); + // Import is floored server-side on cloud-managed instances (403 cloud_managed), so the + // nav entry is hidden rather than dead-ending. Export stays available. + const isCloud = Boolean(useCloudInstance()); const { slots: companySettingsPluginSlots } = usePluginSlots({ slotTypes: ["companySettingsPage"], companyId: selectedCompanyId, @@ -105,13 +109,15 @@ export function CompanySettingsSidebar() { end /> )} - + {showPage("company.members") && ( + + )} {companySettingsPluginSlots .filter((slot) => slot.routePath) .map((slot) => ( @@ -123,8 +129,12 @@ export function CompanySettingsSidebar() { end /> ))} - - + {showPage("company.invites") && ( + + )} + {showPage("company.secrets") && ( + + )} {showPage("instance.environments") && ( )} - - + {showPage("company.export") && ( + + )} + {!isCloud && showPage("company.import") && ( + + )} {showPage("instance.experimental") && ( { expect(getCompanySettingsTab("/company/settings/instance/adapters")).toBe("instance-adapters"); }); - function renderNav(root: ReturnType, hiddenSettings?: string[]) { + function renderNav( + root: ReturnType, + hiddenSettings?: string[], + cloud?: { managed: boolean }, + ) { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); queryClient.setQueryData(queryKeys.health, { status: "ok", ...(hiddenSettings ? { hiddenSettings } : {}), + ...(cloud ? { cloud } : {}), }); root.render( @@ -177,4 +182,45 @@ describe("CompanySettingsNav", () => { root.unmount(); }); }); + + it("filters operator-hidden company tabs out of the tab bar", async () => { + currentPathname = "/PAP/company/settings/members"; + const root = createRoot(container); + + await act(async () => { + renderNav(root, ["company.import", "company.secrets"]); + }); + + const renderedValues = pageTabBarMock.mock.calls.at(-1)?.[0]?.items?.map( + (item: { value: string }) => item.value, + ); + expect(renderedValues).not.toContain("import"); + expect(renderedValues).not.toContain("secrets"); + expect(renderedValues).toContain("export"); + expect(renderedValues).toContain("members"); + expect(renderedValues).toContain("invites"); + + await act(async () => { + root.unmount(); + }); + }); + + it("suppresses the Import tab on a Cloud-managed instance", async () => { + currentPathname = "/PAP/company/settings/members"; + const root = createRoot(container); + + await act(async () => { + renderNav(root, undefined, { managed: true }); + }); + + const renderedValues = pageTabBarMock.mock.calls.at(-1)?.[0]?.items?.map( + (item: { value: string }) => item.value, + ); + expect(renderedValues).not.toContain("import"); + expect(renderedValues).toContain("export"); + + await act(async () => { + root.unmount(); + }); + }); }); diff --git a/ui/src/components/access/CompanySettingsNav.tsx b/ui/src/components/access/CompanySettingsNav.tsx index e07147bcdce..96ff1c1b97f 100644 --- a/ui/src/components/access/CompanySettingsNav.tsx +++ b/ui/src/components/access/CompanySettingsNav.tsx @@ -1,5 +1,6 @@ import { PageTabBar } from "@/components/PageTabBar"; import { Tabs } from "@/components/ui/tabs"; +import { useCloudInstance } from "@/hooks/useCloudInstance"; import { useHiddenSettings } from "@/hooks/useHiddenSettings"; import { INSTANCE_SETTINGS_PATH_PREFIX } from "@/lib/instance-settings"; import { useLocation, useNavigate } from "@/lib/router"; @@ -24,6 +25,11 @@ type CompanySettingsTab = (typeof items)[number]["value"]; /** Tab values suppressed when their page is operator-hidden. */ const hiddenSettingKeyByTab: Partial> = { + export: "company.export", + import: "company.import", + members: "company.members", + invites: "company.invites", + secrets: "company.secrets", "instance-profile": "instance.profile", "instance-environments": "instance.environments", "instance-access": "instance.access", @@ -97,8 +103,12 @@ export function CompanySettingsNav() { const location = useLocation(); const navigate = useNavigate(); const { hidden: hiddenSettings } = useHiddenSettings(); + // Import is floored server-side on cloud-managed instances (403 cloud_managed), so the + // tab is suppressed there rather than dead-ending. + const isCloud = Boolean(useCloudInstance()); const activeTab = getCompanySettingsTab(location.pathname); const visibleItems = items.filter((item) => { + if (item.value === "import" && isCloud) return false; const hiddenKey = hiddenSettingKeyByTab[item.value]; return !hiddenKey || !hiddenSettings.has(hiddenKey); }); diff --git a/ui/src/pages/OrgChart.test.tsx b/ui/src/pages/OrgChart.test.tsx index e29b0e8927f..d76f32ae701 100644 --- a/ui/src/pages/OrgChart.test.tsx +++ b/ui/src/pages/OrgChart.test.tsx @@ -4,6 +4,7 @@ import { act } from "react"; import { createRoot } from "react-dom/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { queryKeys } from "@/lib/queryKeys"; import { OrgChart } from "./OrgChart"; const navigateMock = vi.fn(); @@ -262,4 +263,19 @@ describe("OrgChart mobile gestures", () => { expect(layer.style.transform).toBe("translate(-45px, 40px) scale(1.5)"); }); + + it("shows both portability buttons on self-hosted instances", async () => { + await renderOrgChart(); + + expect(container.textContent).toContain("Import company"); + expect(container.textContent).toContain("Export company"); + }); + + it("hides the Import button but keeps Export on a Cloud-managed instance", async () => { + queryClient.setQueryData(queryKeys.health, { status: "ok", cloud: { managed: true } }); + await renderOrgChart(); + + expect(container.textContent).not.toContain("Import company"); + expect(container.textContent).toContain("Export company"); + }); }); diff --git a/ui/src/pages/OrgChart.tsx b/ui/src/pages/OrgChart.tsx index 8c67809304e..d394489b43e 100644 --- a/ui/src/pages/OrgChart.tsx +++ b/ui/src/pages/OrgChart.tsx @@ -13,6 +13,8 @@ import { PageSkeleton } from "../components/PageSkeleton"; import { AgentIcon } from "../components/AgentIconPicker"; import { Download, Maximize2, Minus, Network, Plus, Upload } from "lucide-react"; import { AGENT_ROLE_LABELS, type Agent } from "@paperclipai/shared"; +import { useCloudInstance } from "@/hooks/useCloudInstance"; +import { useHiddenSettings } from "@/hooks/useHiddenSettings"; // Layout constants const CARD_W = 200; @@ -175,6 +177,13 @@ export function OrgChart() { const { selectedCompanyId } = useCompany(); const { setBreadcrumbs } = useBreadcrumbs(); const navigate = useNavigate(); + // Import is floored server-side on cloud-managed instances (403 cloud_managed), so the + // button is hidden rather than dead-ending. Export stays available. Both + // buttons also respect the operator-hidden settings registry. + const isCloud = Boolean(useCloudInstance()); + const { hidden: hiddenSettings } = useHiddenSettings(); + const showImport = !isCloud && !hiddenSettings.has("company.import"); + const showExport = !hiddenSettings.has("company.export"); const { data: orgTree, isLoading } = useQuery({ queryKey: queryKeys.org(selectedCompanyId!), @@ -444,18 +453,22 @@ export function OrgChart() { return (
- - - - - - + {showImport && ( + + + + )} + {showExport && ( + + + + )}
Date: Tue, 25 Aug 2026 16:12:02 -0700 Subject: [PATCH 22/32] fix(ui): keep the installed service worker fresh on parked tabs (#12198) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - The web UI registers a service worker (`/sw.js`) that caches the app shell for an offline fallback. > - Browsers only re-fetch a worker script on navigation or a ~24h timer, and Paperclip is a parked-tab SPA: a tab can sit open for weeks without one navigation. > - An installed worker — and the shell it cached — can therefore keep serving an old bundle long after a deploy, and the server let `sw.js` inherit the generic 1h static TTL on top of that. > - This pull request adds explicit update checks (tab-visible + hourly), applies a discovered update with one reload while the tab is hidden, and serves `sw.js` with `Cache-Control: no-cache`. > - The benefit is that a deploy reaches every open tab within about an hour, instead of some tabs silently running stale UI indefinitely. ## Linked Issues or Issue Description Refs #11292 (the network-first `sw.js` fallback fix; this PR closes the delivery gap that can keep clients pinned on a pre-#11292 worker). **What happened?** A browser that had an older cache-first worker installed kept rendering a stale app shell — old feature set, old naming — while the server was verified to be running the current release. Nothing on the client checks for a new worker outside navigations, so a parked tab never picked up the fixed worker, and `sw.js` was served with a 1h cache TTL that further delayed update checks. **Expected behavior** Every open tab converges to the deployed bundle shortly after a release, without users unregistering workers in DevTools or hard-reloading. **Steps to reproduce** Install a build's service worker, deploy a newer build, and leave the tab parked (no navigation): the tab keeps running the old bundle indefinitely; the worker update check only happens if the user navigates, and even then a cached `sw.js` can answer it. ## What Changed - New `ui/src/lib/service-worker-updates.ts`: registers `/sw.js`, runs `registration.update()` when the tab becomes visible and on an hourly timer, and on `controllerchange` of a previously-controlled page applies the update with a single reload — only while the tab is hidden, so an update never yanks the page mid-session; a takeover while visible defers the reload to the next hidden transition. First-ever installs never reload. - `ui/src/main.tsx`: replaces the fire-and-forget `register()` with the new module. - New `server/src/static-ui-cache.ts` (`staticUiCacheControl`): `index.html` and `sw.js` are served `Cache-Control: no-cache`; other non-hashed statics keep the 1h default. `server/src/app.ts` uses it in the static middleware. ## Verification - `npx vitest run ui/src/lib/service-worker-updates.test.ts` — 8 tests: registration, hidden-takeover reload (once), deferred reload on visible takeover, no reload on first install, visibility-triggered and timer-triggered update checks, cleanup, no-container no-op. - `npx vitest run server/src/__tests__/static-ui-cache.test.ts` — 3 tests incl. the `sw.js.map` lookalike keeping the default TTL. - `tsc -b` (ui) and `tsc --noEmit` (server) clean; `pnpm check:tokens` clean. ## Risks - Behavioral shift: tabs now reload once, while hidden, after a deploy lands. Unsaved in-page state in a hidden tab is lost at that moment — the same exposure as a browser discarding a background tab, which SPAs must already tolerate. - Self-hosted behavior is otherwise unchanged: same worker script, same registration URL, one added conditional header. - Low risk on the server side: the header change only widens revalidation. ## Model Used Claude Fable 5 (`claude-fable-5`, Anthropic) via Claude Code — agentic coding session with tool use and extended thinking. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --- server/src/__tests__/static-ui-cache.test.ts | 22 +++ server/src/app.ts | 11 +- server/src/static-ui-cache.ts | 19 +++ ui/src/lib/service-worker-updates.test.ts | 170 +++++++++++++++++++ ui/src/lib/service-worker-updates.ts | 101 +++++++++++ ui/src/main.tsx | 13 +- 6 files changed, 326 insertions(+), 10 deletions(-) create mode 100644 server/src/__tests__/static-ui-cache.test.ts create mode 100644 server/src/static-ui-cache.ts create mode 100644 ui/src/lib/service-worker-updates.test.ts create mode 100644 ui/src/lib/service-worker-updates.ts diff --git a/server/src/__tests__/static-ui-cache.test.ts b/server/src/__tests__/static-ui-cache.test.ts new file mode 100644 index 00000000000..1285d2bd0eb --- /dev/null +++ b/server/src/__tests__/static-ui-cache.test.ts @@ -0,0 +1,22 @@ +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { staticUiCacheControl } from "../static-ui-cache.js"; + +describe("staticUiCacheControl", () => { + it("forces revalidation for index.html", () => { + expect(staticUiCacheControl(path.join("/srv", "ui-dist", "index.html"))).toBe("no-cache"); + }); + + it("forces revalidation for the service-worker script", () => { + // Browsers refresh an installed worker only by re-fetching /sw.js; a + // cached copy pins every client on old worker code for the TTL. + expect(staticUiCacheControl(path.join("/srv", "ui-dist", "sw.js"))).toBe("no-cache"); + }); + + it("leaves other static files on the middleware default", () => { + expect(staticUiCacheControl(path.join("/srv", "ui-dist", "favicon.ico"))).toBeUndefined(); + expect(staticUiCacheControl(path.join("/srv", "ui-dist", "robots.txt"))).toBeUndefined(); + // Lookalikes keep the default: only the exact worker filename is special. + expect(staticUiCacheControl(path.join("/srv", "ui-dist", "sw.js.map"))).toBeUndefined(); + }); +}); diff --git a/server/src/app.ts b/server/src/app.ts index 5c16989a23a..e7d4230ca8c 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -85,6 +85,7 @@ import { mcpGatewayProtocolRoutes, toolGatewayRoutes } from "./routes/tool-gatew import { adapterRoutes } from "./routes/adapters.js"; import { pluginUiStaticRoutes } from "./routes/plugin-ui-static.js"; import { readBrandedStaticIndexHtml } from "./static-index-html.js"; +import { staticUiCacheControl } from "./static-ui-cache.js"; import { applyUiBranding } from "./ui-branding.js"; import { logger } from "./middleware/logger.js"; import { DEFAULT_LOCAL_PLUGIN_DIR, pluginLoader, type PluginLoader } from "./services/plugin-loader.js"; @@ -686,15 +687,15 @@ export async function createApp( ); // Non-hashed static files (favicon.ico, manifest, robots.txt, etc.): // short cache so operators who swap them out see the new version - // reasonably fast. Override for `index.html` specifically — it is - // served by this middleware for `/` and `/index.html`, and it must - // never outlive the asset hashes it points at. + // reasonably fast, with must-revalidate overrides for index.html and + // sw.js (see staticUiCacheControl for why those two). app.use( express.static(uiDist, { maxAge: "1h", setHeaders(res, filePath) { - if (path.basename(filePath) === "index.html") { - res.set("Cache-Control", "no-cache"); + const override = staticUiCacheControl(filePath); + if (override) { + res.set("Cache-Control", override); } }, }), diff --git a/server/src/static-ui-cache.ts b/server/src/static-ui-cache.ts new file mode 100644 index 00000000000..7555036bd5f --- /dev/null +++ b/server/src/static-ui-cache.ts @@ -0,0 +1,19 @@ +import path from "node:path"; + +/** + * Cache-Control override for non-hashed UI static files (everything outside + * /assets, which is content-hashed and immutable). Two files must always be + * revalidated: + * + * - `index.html` must never outlive the asset hashes it points at. + * - `sw.js` is the browser's only channel for updating an installed service + * worker: clients re-fetch this exact URL to discover new worker code, so + * any cache TTL here delays every client's update by that long on top of + * the browser's own update timer. + * + * Returns undefined for files where the middleware's default TTL applies. + */ +export function staticUiCacheControl(filePath: string): "no-cache" | undefined { + const basename = path.basename(filePath); + return basename === "index.html" || basename === "sw.js" ? "no-cache" : undefined; +} diff --git a/ui/src/lib/service-worker-updates.test.ts b/ui/src/lib/service-worker-updates.test.ts new file mode 100644 index 00000000000..74709abc677 --- /dev/null +++ b/ui/src/lib/service-worker-updates.test.ts @@ -0,0 +1,170 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { startServiceWorkerUpdates } from "./service-worker-updates"; + +type Listener = () => void; + +function fakeContainer(opts: { controlled: boolean }) { + const listeners = new Map>(); + const registration = { update: vi.fn(() => Promise.resolve()) }; + const container = { + controller: opts.controlled ? ({} as ServiceWorker) : null, + register: vi.fn(() => Promise.resolve(registration)), + addEventListener: vi.fn((type: string, listener: Listener) => { + if (!listeners.has(type)) listeners.set(type, new Set()); + listeners.get(type)!.add(listener); + }), + removeEventListener: vi.fn((type: string, listener: Listener) => { + listeners.get(type)?.delete(listener); + }), + }; + const emit = (type: string) => { + for (const listener of listeners.get(type) ?? []) listener(); + }; + return { container: container as unknown as ServiceWorkerContainer, registration, emit, listeners }; +} + +function fakeDocument(initialVisibility: DocumentVisibilityState = "visible") { + const listeners = new Map>(); + const doc = { + visibilityState: initialVisibility, + addEventListener: (type: string, listener: Listener) => { + if (!listeners.has(type)) listeners.set(type, new Set()); + listeners.get(type)!.add(listener); + }, + removeEventListener: (type: string, listener: Listener) => { + listeners.get(type)?.delete(listener); + }, + }; + const emit = (type: string) => { + for (const listener of listeners.get(type) ?? []) listener(); + }; + return { doc: doc as unknown as Document & { visibilityState: DocumentVisibilityState }, emit, listeners }; +} + +describe("startServiceWorkerUpdates", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("registers /sw.js", async () => { + const { container } = fakeContainer({ controlled: false }); + const { doc } = fakeDocument(); + startServiceWorkerUpdates({ container, documentRef: doc, reload: vi.fn() }); + expect(container.register).toHaveBeenCalledWith("/sw.js"); + }); + + it("reloads once when a new worker takes over a hidden, already-controlled page", async () => { + const { container, emit } = fakeContainer({ controlled: true }); + const { doc } = fakeDocument("hidden"); + const reload = vi.fn(); + startServiceWorkerUpdates({ container, documentRef: doc, reload }); + + emit("controllerchange"); + emit("controllerchange"); + expect(reload).toHaveBeenCalledTimes(1); + }); + + it("defers the reload to the next hidden transition when the takeover lands mid-session", async () => { + const { container, emit } = fakeContainer({ controlled: true }); + const docState = fakeDocument("visible"); + const reload = vi.fn(); + startServiceWorkerUpdates({ container, documentRef: docState.doc, reload }); + + emit("controllerchange"); + // The user is looking at the page: never yank it out from under them. + expect(reload).not.toHaveBeenCalled(); + + docState.doc.visibilityState = "hidden"; + docState.emit("visibilitychange"); + expect(reload).toHaveBeenCalledTimes(1); + + docState.emit("visibilitychange"); + expect(reload).toHaveBeenCalledTimes(1); + }); + + it("does not reload on a first-ever install", async () => { + const { container, emit } = fakeContainer({ controlled: false }); + const { doc } = fakeDocument("hidden"); + const reload = vi.fn(); + startServiceWorkerUpdates({ container, documentRef: doc, reload }); + + emit("controllerchange"); + expect(reload).not.toHaveBeenCalled(); + }); + + it("reloads when a later deploy replaces the worker a first-visit tab installed", async () => { + const { container, emit } = fakeContainer({ controlled: false }); + const { doc } = fakeDocument("hidden"); + const reload = vi.fn(); + startServiceWorkerUpdates({ container, documentRef: doc, reload }); + + // First takeover: the fresh install controls the page, no reload. + emit("controllerchange"); + expect(reload).not.toHaveBeenCalled(); + + // A deploy lands while the same tab is still open: now the controller + // change means newer code, and the hidden tab reloads onto it. + emit("controllerchange"); + expect(reload).toHaveBeenCalledTimes(1); + }); + + it("checks for updates when the tab becomes visible", async () => { + const { container, registration, emit } = fakeContainer({ controlled: true }); + const docState = fakeDocument("hidden"); + startServiceWorkerUpdates({ container, documentRef: docState.doc, reload: vi.fn() }); + await vi.waitFor(() => expect(container.register).toHaveBeenCalled()); + // Let the register() promise settle so the registration is captured. + await Promise.resolve(); + + docState.emit("visibilitychange"); + expect(registration.update).not.toHaveBeenCalled(); + + docState.doc.visibilityState = "visible"; + docState.emit("visibilitychange"); + expect(registration.update).toHaveBeenCalledTimes(1); + }); + + it("checks for updates on the timer", async () => { + const { container, registration } = fakeContainer({ controlled: true }); + const { doc } = fakeDocument(); + startServiceWorkerUpdates({ + container, + documentRef: doc, + reload: vi.fn(), + updateIntervalMs: 1000, + }); + await Promise.resolve(); + + vi.advanceTimersByTime(3000); + expect(registration.update).toHaveBeenCalledTimes(3); + }); + + it("stops listening and ticking after cleanup", async () => { + const { container, registration, emit, listeners } = fakeContainer({ controlled: true }); + const docState = fakeDocument(); + const reload = vi.fn(); + const stop = startServiceWorkerUpdates({ + container, + documentRef: docState.doc, + reload, + updateIntervalMs: 1000, + }); + await Promise.resolve(); + + stop(); + emit("controllerchange"); + docState.emit("visibilitychange"); + vi.advanceTimersByTime(5000); + expect(reload).not.toHaveBeenCalled(); + expect(registration.update).not.toHaveBeenCalled(); + expect(listeners.get("controllerchange")?.size ?? 0).toBe(0); + }); + + it("is a no-op without a service worker container", () => { + expect(() => startServiceWorkerUpdates({ reload: vi.fn() })()).not.toThrow(); + }); +}); diff --git a/ui/src/lib/service-worker-updates.ts b/ui/src/lib/service-worker-updates.ts new file mode 100644 index 00000000000..7ce1d337399 --- /dev/null +++ b/ui/src/lib/service-worker-updates.ts @@ -0,0 +1,101 @@ +/** + * Registers `/sw.js` and keeps the installed worker fresh on a long-lived tab. + * + * Browsers only re-fetch a service-worker script on navigation or on a ~24h + * timer. Paperclip is a parked-tab SPA — a tab can stay open for weeks without + * a single navigation — so without explicit update checks an old worker (and + * the app shell it cached) can outlive a deploy indefinitely. The symptom is + * invisible: the tab just keeps running the old bundle. + * + * Two behaviors close the gap: + * - `registration.update()` runs when the tab becomes visible and on an + * hourly timer, so parked tabs learn about new workers without navigating. + * - When a new worker takes control (`controllerchange`), the page reloads + * once so the fresh shell actually replaces the running bundle — but only + * while the tab is hidden, so an update landing mid-session never yanks + * the page out from under the user; a takeover while visible defers the + * reload to the next time the tab is hidden. First-ever installs skip the + * reload entirely: an uncontrolled page is already running the code the + * server just handed it. + */ +const DEFAULT_UPDATE_INTERVAL_MS = 60 * 60 * 1000; + +export function startServiceWorkerUpdates( + options: { + container?: ServiceWorkerContainer; + documentRef?: Document; + reload?: () => void; + updateIntervalMs?: number; + } = {}, +): () => void { + const container = + options.container ?? + (typeof navigator !== "undefined" && "serviceWorker" in navigator + ? navigator.serviceWorker + : undefined); + if (!container) { + return () => {}; + } + const documentRef = options.documentRef ?? document; + const reload = options.reload ?? (() => window.location.reload()); + const updateIntervalMs = options.updateIntervalMs ?? DEFAULT_UPDATE_INTERVAL_MS; + + // Only a page that is already worker-controlled is running + // potentially-stale code when the controller changes; a first-ever + // install taking control is caching the very bundle the page is running, + // so reloading would be a no-op. The flag is promoted on that first + // takeover: any later controller change on this (possibly weeks-old) tab + // does mean newer code exists. + let wasControlled = Boolean(container.controller); + let reloaded = false; + let reloadPending = false; + const applyUpdate = () => { + if (reloaded) return; + reloaded = true; + reload(); + }; + const onControllerChange = () => { + if (!wasControlled) { + wasControlled = true; + return; + } + if (documentRef.visibilityState === "hidden") { + applyUpdate(); + } else { + reloadPending = true; + } + }; + container.addEventListener("controllerchange", onControllerChange); + + let registration: ServiceWorkerRegistration | undefined; + const checkForUpdates = () => { + // update() rejects while offline or mid-deploy; the next visibility + // change or timer tick retries, so failures are deliberately swallowed. + void registration?.update().catch(() => {}); + }; + const onVisibilityChange = () => { + if (documentRef.visibilityState === "visible") { + checkForUpdates(); + } else if (reloadPending) { + applyUpdate(); + } + }; + documentRef.addEventListener("visibilitychange", onVisibilityChange); + const intervalId = setInterval(checkForUpdates, updateIntervalMs); + + void container + .register("/sw.js") + .then((reg) => { + registration = reg; + }) + .catch(() => { + // Registration can fail in private windows or hardened browsers; the + // app works without a worker, it just loses the offline fallback. + }); + + return () => { + container.removeEventListener("controllerchange", onControllerChange); + documentRef.removeEventListener("visibilitychange", onVisibilityChange); + clearInterval(intervalId); + }; +} diff --git a/ui/src/main.tsx b/ui/src/main.tsx index 2766c51e1e2..c6aa4eb5598 100644 --- a/ui/src/main.tsx +++ b/ui/src/main.tsx @@ -19,6 +19,7 @@ import { TooltipProvider } from "@/components/ui/tooltip"; import { initPluginBridge } from "./plugins/bridge-init"; import { PluginLauncherProvider } from "./plugins/launchers"; import { startPerfMeasureReaper } from "./lib/perf-measure-reaper"; +import { startServiceWorkerUpdates } from "./lib/service-worker-updates"; import "@mdxeditor/editor/style.css"; import "./index.css"; @@ -29,11 +30,13 @@ initPluginBridge(React, ReactDOM); // accumulate into millions of native objects (GBs). Reap them periodically. startPerfMeasureReaper(); -if ("serviceWorker" in navigator) { - window.addEventListener("load", () => { - navigator.serviceWorker.register("/sw.js"); - }); -} +// Parked SPA tabs never navigate, so beyond registering the worker this also +// re-checks /sw.js on tab focus and hourly, and applies a discovered update +// with one reload while the tab is hidden — otherwise an old worker and its +// cached shell can outlive a deploy indefinitely. +window.addEventListener("load", () => { + startServiceWorkerUpdates(); +}); const queryClient = new QueryClient({ defaultOptions: { From dc30dc4f341057a94c0c6c0acde69324a9ce3d18 Mon Sep 17 00:00:00 2001 From: Nicky Leach Date: Tue, 25 Aug 2026 19:48:44 -0700 Subject: [PATCH 23/32] fix(setup-token): pin the start guard to the served adapter (#12179) ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Sandbox agents use adapter login routes to start authenticated sessions > - The setup-token start route accepted adapter types that later routes and cleanup did not serve > - This mismatch could create sessions that no route or reaper could reach > - The OpenAPI body schema and synchronous login capability defaults also differed from the enforced behavior > - This pull request pins the start guard to the served adapter, shares the adapter constant, aligns the schema, and exposes login capabilities early > - The benefit is consistent session access, cleanup, API documentation, and login UI behavior ## Linked Issues or Issue Description Refs: #11730 Refs: #11286 **Subsystem affected** Cross-cutting server and UI login behavior. **Problem or motivation** The setup-token start route accepted a non-served adapter type. Follow-up routes and the reaper only handled the served adapter. This could create an unreachable session that held its slot. The OpenAPI schema and early capability defaults also did not match the route behavior. **Proposed solution** Pin the start guard, follow-up key, and reaper filter to one exported served-adapter constant. Derive the OpenAPI body from the strict shared schema. Add the login capability projection to synchronous defaults. **Alternatives considered** Keep separate adapter constants and add another guard at each follow-up route. This would preserve duplicate sources of truth and leave future drift possible. **Roadmap alignment** This change supports the Cloud / Sandbox agents milestone in `ROADMAP.md`. ## What Changed - Reject a setup-token start request when its adapter type is not the served adapter. - Reuse one exported adapter constant for the start guard, follow-up key, and reaper filter. - Derive the company adapter login-sessions start body from the strict shared schema. - Add the `login` capability projection to the synchronous Claude and Codex adapter defaults. - Add regression coverage for the rejected non-served adapter request. ## Verification - The setup-token route suite passes, including the non-served adapter regression test. - The setup-token session-service suite passes. - The setup-token reaper suite passes. - The OpenAPI suite passes. - The server TypeScript check passes. - The UI TypeScript check passes. - GitHub Actions must confirm all required checks after pull request creation. ## Risks The start route now rejects adapter types that follow-up routes cannot serve. No database migration exists. Revert the one commit to roll back the change. ## Model Used OpenAI Codex, GPT-5, exact runtime model ID not exposed, large context window, reasoning, tool use, and code execution. The implementing engineer used AI assistance. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes: #` / `Refs: #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- .../heartbeat-local-environment.test.ts | 14 +++++++++-- server/src/routes/agents.ts | 18 ++++++++++--- server/src/routes/openapi.ts | 12 +++++---- server/src/routes/setup-token-route.test.ts | 25 ++++++++++++------- server/src/services/setup-token-session.ts | 15 ++++++++++- ui/src/adapters/use-adapter-capabilities.ts | 8 ++++-- 6 files changed, 69 insertions(+), 23 deletions(-) diff --git a/server/src/__tests__/heartbeat-local-environment.test.ts b/server/src/__tests__/heartbeat-local-environment.test.ts index 6375bf874a7..cef995d59e8 100644 --- a/server/src/__tests__/heartbeat-local-environment.test.ts +++ b/server/src/__tests__/heartbeat-local-environment.test.ts @@ -62,6 +62,7 @@ async function waitForRunLeasesToRelease( describeEmbeddedPostgres("heartbeat local environment lifecycle", () => { let db!: ReturnType; + let heartbeat!: ReturnType; let tempDb: Awaited> | null = null; let previousAgentJwtSecret: string | undefined; @@ -70,9 +71,16 @@ describeEmbeddedPostgres("heartbeat local environment lifecycle", () => { process.env.PAPERCLIP_AGENT_JWT_SECRET = "heartbeat-local-environment-test-secret"; tempDb = await startEmbeddedPostgresTestDatabase("heartbeat-local-environment-"); db = createDb(tempDb.connectionString); + heartbeat = heartbeatService(db); }, 20_000); afterEach(async () => { + // A run reaches its terminal status before finalizeRun finishes writing + // its trailing lifecycle events and side effects (see the comment on + // drainActiveRunExecutions in heartbeat.ts). Drain those in-flight writes + // before the TRUNCATE below, or a write that lands after the company row + // is gone violates heartbeat_run_events' foreign key. + await heartbeat.drainActiveRunExecutions(); await db.execute(sql.raw(` TRUNCATE TABLE "environment_leases", @@ -90,6 +98,10 @@ describeEmbeddedPostgres("heartbeat local environment lifecycle", () => { }); afterAll(async () => { + // Same reasoning as the afterEach drain: closing the embedded database + // while a run's finalize work is still in flight lets a queued write hit + // a socket that cleanup() already tore down. + await heartbeat.drainActiveRunExecutions(); await tempDb?.cleanup(); if (previousAgentJwtSecret === undefined) { delete process.env.PAPERCLIP_AGENT_JWT_SECRET; @@ -126,7 +138,6 @@ describeEmbeddedPostgres("heartbeat local environment lifecycle", () => { permissions: {}, }); - const heartbeat = heartbeatService(db); const queued = await heartbeat.invoke(agentId, "on_demand", {}, "manual"); expect(queued).not.toBeNull(); @@ -198,7 +209,6 @@ describeEmbeddedPostgres("heartbeat local environment lifecycle", () => { permissions: {}, }); - const heartbeat = heartbeatService(db); const queued = await heartbeat.invoke(agentId, "on_demand", {}, "manual"); expect(queued).not.toBeNull(); diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 94ab86d2dd4..fbdc97b0aea 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -123,6 +123,7 @@ import { type SetupTokenSessionScope, type SetupTokenSessionState, type SetupTokenSessionDescriptor, + SETUP_TOKEN_ADAPTER_TYPE, } from "../services/setup-token-session.js"; import type { DeploymentMode, @@ -4770,9 +4771,6 @@ export function agentRoutes( // Each route writes its full path as a plain string literal, so the static // OpenAPI coverage test can read the path from the source text. - // The company-and-environment login serves only the Claude adapter. - const CLAUDE_SETUP_TOKEN_ADAPTER_TYPE = "claude_local"; - // Maps the internal session state to the public login status. The public union // carries no server-only state, so the route never returns the internal // `submitting` or `stored` state to a client. @@ -4828,7 +4826,7 @@ export function agentRoutes( const companySetupTokenKey = (companyId: string, ownerUserId: string) => ({ companyId, ownerUserId, - adapterType: CLAUDE_SETUP_TOKEN_ADAPTER_TYPE, + adapterType: SETUP_TOKEN_ADAPTER_TYPE, }); // The stored Claude OAuth token status read. It returns @@ -4898,6 +4896,18 @@ export function agentRoutes( res.status(400).json({ error: "This adapter does not support a setup-token login." }); return true; } + // The five follow-up routes and the restart reaper both read only the + // one pinned adapter type. A capability match alone is not enough: an + // adapter that declares `storedSessionId` but is not the served type + // would pass the check above, then create a session that no follow-up + // route and no reaper scan can reach. Reject that case here, before any + // sandbox assertion, lease, durable row, or pseudo-terminal, with the + // same fixed 400 as the capability check above, so the response + // discloses no difference between the two rejection reasons. + if (data.adapterType !== SETUP_TOKEN_ADAPTER_TYPE) { + res.status(400).json({ error: "This adapter does not support a setup-token login." }); + return true; + } if (!SETUP_TOKEN_LOGIN_TRANSPORT_READY) { res.status(503).json({ error: SETUP_TOKEN_START_FAILED }); return true; diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index 690c4883e33..d56bbb8fb39 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -232,6 +232,7 @@ import { claudeSetupTokenSessionOwnerResponseSchema, claudeSetupTokenCompletionResponseSchema, claudeOAuthTokenStatusResponseSchema, + startAdapterAuthSessionRequestSchema, } from "@paperclipai/shared"; import { COMPANY_IMPORT_TRANSFERS_API_PATH, @@ -642,11 +643,12 @@ const refreshExternalObjectsBodySchema = z.object({ objectIds: z.array(z.string().guid()).max(50).optional(), }).strict(); -// The start route reads the body directly, so document the accepted fields -// here. A sandbox environment is required. The time-to-live is optional. -const startAdapterLoginSessionSchema = z.object({ - environmentId: z.string().min(1), - ttlSeconds: z.number().optional(), +// The route enforces the shared strict request schema. The route spine +// injects the adapter type from the path, so the client body never carries +// it; derive the documented body from the shared schema and omit that field, +// so the documented body cannot drift from the route again. +const startAdapterLoginSessionSchema = startAdapterAuthSessionRequestSchema.omit({ + adapterType: true, }); const environmentCustomImageCompanyQuerySchema = z.object({ diff --git a/server/src/routes/setup-token-route.test.ts b/server/src/routes/setup-token-route.test.ts index c1075cce773..09c583021f1 100644 --- a/server/src/routes/setup-token-route.test.ts +++ b/server/src/routes/setup-token-route.test.ts @@ -693,12 +693,13 @@ describe("company-and-environment setup-token route — object-level authorizati expect(transport.records).toEqual([]); }); - it("starts a setup-token login for a third adapter that declares the capability", async () => { - // A third adapter, not the Claude adapter, declares the pseudo-terminal - // setup-token capability with the stored-session claim. The guard reads the - // capability, not the adapter name, so the adapter passes the guard and - // starts a session. This proves no adapter-name branch remains in the guard - // path. The start response reads the panel mode from the capability. + it("rejects a third adapter that declares the capability but is not the served adapter", async () => { + // A third adapter, not the Claude adapter, declares the same pseudo-terminal + // setup-token capability with the stored-session claim. The five follow-up + // routes and the reaper both read only the one served adapter type, so the + // guard must reject this adapter even though its capability matches. It + // rejects with the same fixed 400 as the capability-mismatch case, before + // any sandbox assertion, lease, durable row, or pseudo-terminal. mockFindActiveServerAdapter.mockImplementation((type: string) => type === "gemini_local" ? { @@ -714,9 +715,15 @@ describe("company-and-environment setup-token route — object-level authorizati environmentId: ENVIRONMENT_ID, adapterType: "gemini_local", }); - expect(res.status, JSON.stringify(res.body)).toBe(201); - expect(res.body.panelMode).toBe("displayed_code"); - expect(res.body.status).toBe("waiting_for_user"); + expect(res.status, JSON.stringify(res.body)).toBe(400); + expect(res.body.error).toBe("This adapter does not support a setup-token login."); + // The route rejected the adapter before any sandbox, lease, or store side + // effect: no cleanup record, no lease acquire, no pseudo-terminal start, and + // no environment or provider guard call. + expect(transport.records).toEqual([]); + expect(transport.factoryInvocations.count).toBe(0); + expect(mockEnvironmentService.getById).not.toHaveBeenCalled(); + expect(mockResolvePluginSandboxProviderDriverByKey).not.toHaveBeenCalled(); }); }); diff --git a/server/src/services/setup-token-session.ts b/server/src/services/setup-token-session.ts index efe18f3dda6..813286a168d 100644 --- a/server/src/services/setup-token-session.ts +++ b/server/src/services/setup-token-session.ts @@ -41,7 +41,20 @@ import type { AgentAdapterType } from "@paperclipai/shared"; // unified `adapter_auth_sessions` table also holds the Codex device-login rows, // so every store scan filters by this adapter to reach only the setup-token // rows. -const SETUP_TOKEN_ADAPTER_TYPE: AgentAdapterType = "claude_local"; +// +// Three consumers share this one constant, and they must change together: +// 1. The start-route guard in `agents.ts`, which rejects a request for any +// other adapter type before it creates a lease, a durable row, or a +// pseudo-terminal. +// 2. The five follow-up routes in `agents.ts`, which build their session +// lookup key from this constant through `companySetupTokenKey`. +// 3. The restart reaper scan below, which filters on this constant to reach +// only the setup-token rows and to leave every Codex device-login row +// alone. +// A future change that serves a second adapter through this flow must widen +// all three consumers, not just one, or a served adapter will create a row +// that a follow-up route or the reaper cannot find. +export const SETUP_TOKEN_ADAPTER_TYPE: AgentAdapterType = "claude_local"; /** * The session states. The four terminal states end the login. The `stored` diff --git a/ui/src/adapters/use-adapter-capabilities.ts b/ui/src/adapters/use-adapter-capabilities.ts index 9be335be997..c05cf0a1437 100644 --- a/ui/src/adapters/use-adapter-capabilities.ts +++ b/ui/src/adapters/use-adapter-capabilities.ts @@ -15,10 +15,14 @@ const ALL_FALSE: AdapterCapabilities = { /** * Synchronous fallback for known built-in adapter types so capability checks * return correct values on first render before the /api/adapters call resolves. + * + * The `login` value for `claude_local` and `codex_local` mirrors the server's + * login capability declaration in `server/src/adapters/registry.ts`. Reconcile + * the two together if either adapter's login flow changes. */ const KNOWN_DEFAULTS: Record = { - claude_local: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: false, supportsModelProfiles: true, supportsAcp: true }, - codex_local: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: false, supportsModelProfiles: true, supportsAcp: true }, + claude_local: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: false, supportsModelProfiles: true, supportsAcp: true, login: { panelMode: "submitted_browser_code", timeoutPolicy: "fixed" } }, + codex_local: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: false, supportsModelProfiles: true, supportsAcp: true, login: { panelMode: "displayed_code", timeoutPolicy: "caller_bounded" } }, paperclip_runner: { supportsInstructionsBundle: false, supportsSkills: true, supportsLocalAgentJwt: false, requiresMaterializedRuntimeSkills: false, supportsModelProfiles: false, supportsAcp: false }, cursor: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: true, supportsModelProfiles: true, supportsAcp: false }, gemini_local: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: true, supportsModelProfiles: true, supportsAcp: true }, From 821573ede850441d5043ecd4860ee70a2a0374b1 Mon Sep 17 00:00:00 2001 From: Nicky Leach Date: Tue, 25 Aug 2026 21:38:27 -0700 Subject: [PATCH 24/32] refactor(adapter-utils): extract the shared workspace-restore teardown factory (#12196) ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Agent adapters run workspace restore steps when an ACP run ends. > - Claude, Codex, and Gemini each kept a near-identical teardown closure. > - Duplicate closures require the same defect fix in three files. > - This pull request adds one shared workspace-restore teardown factory and keeps each adapter's message strings. > - The benefit is one tested restore-failure path with the same output and outcome for all three adapters. ## Linked Issues or Issue Description **What existing behavior does this improve?** The Claude, Codex, and Gemini ACP adapters restore the workspace during teardown and report restore failures with an allowlisted message. **Subsystem affected** `packages/adapters/` and `packages/adapter-utils/`. **Current behavior** Each adapter keeps a near-identical closure. The closure logs a start line, restores the workspace, classifies errors, and logs a fixed failure line. **Proposed behavior** A shared `createWorkspaceRestoreTeardown` factory owns the common steps. Each adapter passes its staged runtime, log sink, start line, and failure prefix. **Reason and benefit** The shared factory removes duplicate error handling. One tested implementation now preserves the existing output and outcome for all three adapters. **Breaking changes** None. The refactor preserves the emitted lines and returned outcomes. **Additional context** This pull request contains no public issue reference because no related public issue was found. ## What Changed - Add `createWorkspaceRestoreTeardown` to `packages/adapter-utils`. - Move the shared restore, classify, and allowlisted log flow into the factory. - Update the Claude, Codex, and Gemini ACP adapters to call the factory. - Add a table-driven test for all three message pairs. - Keep one end-to-end restore-failure regression test per adapter. ## Verification - `pnpm --filter @paperclipai/adapter-claude-local typecheck` - `pnpm --filter @paperclipai/adapter-codex-local typecheck` - `pnpm --filter @paperclipai/adapter-gemini-local typecheck` - `npx vitest run packages/adapter-utils/src/workspace-restore-teardown.test.ts` - `npx vitest run packages/adapter-utils/src/workspace-restore-merge.test.ts` - `npx vitest run packages/adapters/claude-local/src/server/acp.test.ts` - `npx vitest run packages/adapters/codex-local/src/server/acp.test.ts` - `npx vitest run packages/adapters/gemini-local/src/server/acp.test.ts` - Continuous integration must pass before merge, except for the known pre-existing failures listed in the handoff. ## Risks Low risk. This change moves shared code without changing behavior. The adapter-specific message strings remain unchanged. ## Model Used OpenAI GPT-5, exact model ID `gpt-5`, tool use and code review assistance. The context window size was not provided by the runtime. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- .../src/workspace-restore-teardown.test.ts | 78 ++++++++++ .../src/workspace-restore-teardown.ts | 51 ++++++ .../adapters/claude-local/src/server/acp.ts | 30 +--- .../codex-local/src/server/acp.test.ts | 147 ++++++++---------- .../adapters/codex-local/src/server/acp.ts | 41 ++--- .../gemini-local/src/server/acp.test.ts | 135 +++++++--------- .../adapters/gemini-local/src/server/acp.ts | 30 +--- 7 files changed, 276 insertions(+), 236 deletions(-) create mode 100644 packages/adapter-utils/src/workspace-restore-teardown.test.ts create mode 100644 packages/adapter-utils/src/workspace-restore-teardown.ts diff --git a/packages/adapter-utils/src/workspace-restore-teardown.test.ts b/packages/adapter-utils/src/workspace-restore-teardown.test.ts new file mode 100644 index 00000000000..664c4dcbff8 --- /dev/null +++ b/packages/adapter-utils/src/workspace-restore-teardown.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest"; + +import { createWorkspaceRestoreTeardown } from "./workspace-restore-teardown.js"; + +// One row per adapter's two message strings, taken verbatim from +// claude-local, codex-local, and gemini-local. The factory's contract must +// hold identically for each pair. +const ADAPTER_MESSAGE_PAIRS = [ + { + adapter: "claude-local", + startMessage: "[paperclip] Restoring workspace changes from the sandbox.\n", + failurePrefix: "[paperclip] Claude ACP teardown workspace restore failed", + }, + { + adapter: "codex-local", + startMessage: "[paperclip] Restoring workspace changes and Codex auth from the sandbox.\n", + failurePrefix: "[paperclip] Codex ACP teardown restore/copy-back failed", + }, + { + adapter: "gemini-local", + startMessage: "[paperclip] Restoring workspace changes from the sandbox.\n", + failurePrefix: "[paperclip] Gemini ACP teardown workspace restore failed", + }, +]; + +describe.each(ADAPTER_MESSAGE_PAIRS)("createWorkspaceRestoreTeardown ($adapter)", ({ startMessage, failurePrefix }) => { + it("logs the start message to stdout and returns ok on a clean restore", async () => { + const logLines: Array<{ stream: "stdout" | "stderr"; chunk: string }> = []; + const teardown = createWorkspaceRestoreTeardown({ + stagedRuntime: { restoreWorkspace: async () => {} }, + onLog: async (stream, chunk) => { + logLines.push({ stream, chunk }); + }, + startMessage, + failurePrefix, + }); + + const outcome = await teardown(); + + expect(outcome).toEqual({ ok: true }); + expect(logLines).toEqual([{ stream: "stdout", chunk: startMessage }]); + }); + + it("classifies a thrown EACCES error and sanitizes the stderr line", async () => { + const logLines: Array<{ stream: "stdout" | "stderr"; chunk: string }> = []; + // A host filesystem path that must never reach the run log. + const secretHostPath = "/home/host-user/.secret-project/workspace"; + const thrown: NodeJS.ErrnoException = new Error(`EACCES: permission denied, open '${secretHostPath}'`); + thrown.code = "EACCES"; + const teardown = createWorkspaceRestoreTeardown({ + stagedRuntime: { + restoreWorkspace: async () => { + throw thrown; + }, + }, + onLog: async (stream, chunk) => { + logLines.push({ stream, chunk }); + }, + startMessage, + failurePrefix, + }); + + const outcome = await teardown(); + + expect(outcome).toEqual({ ok: false, code: "restore_permission_denied" }); + const stderrLines = logLines.filter((line) => line.stream === "stderr"); + expect(stderrLines).toEqual([ + { + stream: "stderr", + chunk: `${failurePrefix}: the restore could not write to the workspace (permission denied)\n`, + }, + ]); + for (const line of logLines) { + expect(line.chunk).not.toContain(secretHostPath); + expect(line.chunk).not.toContain(thrown.message); + } + }); +}); diff --git a/packages/adapter-utils/src/workspace-restore-teardown.ts b/packages/adapter-utils/src/workspace-restore-teardown.ts new file mode 100644 index 00000000000..7c5a298d919 --- /dev/null +++ b/packages/adapter-utils/src/workspace-restore-teardown.ts @@ -0,0 +1,51 @@ +import type { RuntimeProgressSink } from "./runtime-progress.js"; +import { + classifyWorkspaceRestoreFailure, + describeWorkspaceRestoreFailure, + type WorkspaceRestoreOutcome, +} from "./workspace-restore-merge.js"; + +/** + * The one staged-runtime capability the teardown factory needs: restore the + * sandbox workspace back onto the host. + */ +export interface WorkspaceRestoreTeardownRuntime { + restoreWorkspace(onProgress?: RuntimeProgressSink): Promise; +} + +/** + * Builds the shared ACP adapter teardown step. The Claude, Codex, and Gemini + * adapters run the same five steps at teardown and differ only in two log + * strings: the start message and the failure prefix. Give this factory the + * staged runtime, the `onLog` sink, and those two strings; it returns the + * teardown step. + * + * The returned step logs `startMessage` to `stdout`, restores the workspace, + * and on success returns `{ ok: true }`. On a caught error, it classifies the + * error into an allowlisted {@link WorkspaceRestoreOutcome} code, logs + * `failurePrefix` plus the allowlisted diagnostic to `stderr`, and returns + * `{ ok: false, code }`. + */ +export function createWorkspaceRestoreTeardown(input: { + stagedRuntime: WorkspaceRestoreTeardownRuntime; + onLog: (stream: "stdout" | "stderr", chunk: string) => Promise; + startMessage: string; + failurePrefix: string; +}): () => Promise { + const { stagedRuntime, onLog, startMessage, failurePrefix } = input; + return async () => { + try { + await onLog("stdout", startMessage); + await stagedRuntime.restoreWorkspace((line) => onLog("stdout", line)); + return { ok: true }; + } catch (err) { + // The run log is readable by any same-company actor, so it must never + // carry the caught error's own message: that message can hold a host + // filesystem path or a process id. Log only the fixed, allowlisted + // diagnostic for the classified code. + const code = classifyWorkspaceRestoreFailure(err); + await onLog("stderr", `${failurePrefix}: ${describeWorkspaceRestoreFailure(code)}\n`); + return { ok: false, code }; + } + }; +} diff --git a/packages/adapters/claude-local/src/server/acp.ts b/packages/adapters/claude-local/src/server/acp.ts index d064cfae520..019e06baa67 100644 --- a/packages/adapters/claude-local/src/server/acp.ts +++ b/packages/adapters/claude-local/src/server/acp.ts @@ -48,10 +48,7 @@ import { classifyThrownErrorClass, logSandboxProbeDiagnostic, } from "./probe-diagnostics.js"; -import { - classifyWorkspaceRestoreFailure, - describeWorkspaceRestoreFailure, -} from "@paperclipai/adapter-utils/workspace-restore-merge"; +import { createWorkspaceRestoreTeardown } from "@paperclipai/adapter-utils/workspace-restore-teardown"; import { buildLocalAdapterTestProbeEnv } from "./probe-env.js"; import { detectClaudeLoginRequired, parseClaudeStreamJson } from "./parse.js"; import { buildClaudeProbePermissionArgs } from "./permissions.js"; @@ -219,24 +216,13 @@ async function prepareClaudeRemoteManagedHome( // the host. A restore miss is logged and never fails the run. const registerWorkspaceSyncBack = ( stagedRuntime: AcpxRemoteManagedHomeResult["stagedRuntime"], - ): AcpxRemoteManagedHomeResult["teardown"] => async () => { - try { - await onLog("stdout", "[paperclip] Restoring workspace changes from the sandbox.\n"); - await stagedRuntime.restoreWorkspace((line) => onLog("stdout", line)); - return { ok: true }; - } catch (err) { - // The run log is readable by any same-company actor, so it must never - // carry the caught error's own message: that message can hold a host - // filesystem path or a process id. Log only the fixed, allowlisted - // diagnostic for the classified code. - const code = classifyWorkspaceRestoreFailure(err); - await onLog( - "stderr", - `[paperclip] Claude ACP teardown workspace restore failed: ${describeWorkspaceRestoreFailure(code)}\n`, - ); - return { ok: false, code }; - } - }; + ): AcpxRemoteManagedHomeResult["teardown"] => + createWorkspaceRestoreTeardown({ + stagedRuntime, + onLog, + startMessage: "[paperclip] Restoring workspace changes from the sandbox.\n", + failurePrefix: "[paperclip] Claude ACP teardown workspace restore failed", + }); const envConfig = parseObject(input.config.env); const explicitClaudeConfigDir = typeof envConfig.CLAUDE_CONFIG_DIR === "string" && envConfig.CLAUDE_CONFIG_DIR.trim().length > 0 diff --git a/packages/adapters/codex-local/src/server/acp.test.ts b/packages/adapters/codex-local/src/server/acp.test.ts index 6ce672ae98f..d398e4e57de 100644 --- a/packages/adapters/codex-local/src/server/acp.test.ts +++ b/packages/adapters/codex-local/src/server/acp.test.ts @@ -1,9 +1,26 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import type { AdapterExecutionContext, AdapterInvocationMeta } from "@paperclipai/adapter-utils"; import { runChildProcess } from "@paperclipai/adapter-utils/server-utils"; + +// Every test in this file needs a real teardown, so the mock below delegates +// to the actual factory by default. Only the wiring test further down reads +// the call arguments; it does not change this behavior. +const mockCreateWorkspaceRestoreTeardown = vi.hoisted(() => vi.fn()); + +vi.mock("@paperclipai/adapter-utils/workspace-restore-teardown", async (importOriginal) => { + const actual = await importOriginal>(); + mockCreateWorkspaceRestoreTeardown.mockImplementation( + actual.createWorkspaceRestoreTeardown as (...args: unknown[]) => unknown, + ); + return { + ...actual, + createWorkspaceRestoreTeardown: mockCreateWorkspaceRestoreTeardown, + }; +}); + import { buildCodexAcpConfig, createCodexAcpExecutor, @@ -1135,99 +1152,57 @@ describe("codex_local ACP lane", () => { expect(hostAuth.tokens.refresh_token).toBe("ref-sandbox-newer"); }); - it("test_codex_acp_teardown_restore_failure_sanitizes_the_run_log", async () => { - // Security regression for a workspace-restore write failure: the run log - // is readable by any same-company actor, so the teardown must never write - // the caught error's own message there — that message can carry the host - // workspace path. Force a real EACCES by making the workspace read-only, - // and name it with a sentinel marker so any leak is easy to spot. - const runId = "run-restore-failure"; - const root = await makeTempRoot("paperclip-codex-acp-restore-failure-"); - const localCwd = path.join(root, "SENTINEL-HOST-PATH-marker", "worktree"); + it("passes the Codex-specific teardown messages to the shared workspace-restore-teardown factory", async () => { + // Wiring test only: `createWorkspaceRestoreTeardown` owns the + // classify-and-redact contract, proven once for all three adapters by its + // own table-driven test in `packages/adapter-utils`. This test proves only + // that the Codex adapter passes its own two message strings to it. + // Clear the shared, hoisted mock first: earlier tests in this file also + // call through it, and a leftover call could hide a real wiring bug. + mockCreateWorkspaceRestoreTeardown.mockClear(); + const root = await makeTempRoot("paperclip-codex-acp-teardown-wiring-"); + const localCwd = path.join(root, "worktree"); const remoteCwd = path.join(root, "remote-workspace"); - const sourceHome = path.join(root, "codex-home"); - const sharedHostHome = path.join(root, "shared-codex-home"); await fs.mkdir(localCwd, { recursive: true }); await fs.mkdir(remoteCwd, { recursive: true }); - await fs.mkdir(sourceHome, { recursive: true }); - await fs.mkdir(sharedHostHome, { recursive: true }); - await fs.writeFile(path.join(localCwd, "hello.txt"), "hi", "utf8"); - process.env.CODEX_HOME = sharedHostHome; - // The runtime writes a new file into the in-sandbox workspace during the - // turn, so the teardown's restore has something to copy back — and a new - // file is exactly what a read-only workspace directory rejects. The - // workspace turns read-only only after the turn's own writes, so the - // teardown restore that runs after the turn is the write this forces to - // fail. - const runtime = new FakeRuntime({}); - const startTurn = runtime.startTurn.bind(runtime); - runtime.startTurn = (input) => { - const turn = startTurn(input); - const remoteWorkspaceCwd = input.handle.cwd ?? remoteCwd; - return { - ...turn, - result: (async () => { - await fs.writeFile(path.join(remoteWorkspaceCwd, "from-sandbox.txt"), "synced", "utf8"); - await fs.chmod(localCwd, 0o500); - return await turn.result; - })(), - }; - }; - - const stagedRuntimes = new Map(); const execute = createCodexAcpExecutor({ - createRuntime: (options: FakeRuntimeOptions) => { - Object.assign(runtime.options, options); - return runtime as never; - }, - stagedRuntimes, + createRuntime: (options: FakeRuntimeOptions) => new FakeRuntime(options) as never, + stagedRuntimes: new Map(), stagingLocks: new Map(), }); + const result = await execute( + buildContext(localCwd, { + config: { + engine: "acp", + cwd: localCwd, + agentCommand: "node ./fake-acp.js", + stateDir: path.join(root, "state"), + env: { CODEX_HOME: path.join(root, "codex-home") }, + promptTemplate: "Do the assigned work.", + }, + context: { + issueId: "issue-1", + paperclipWorkspace: { cwd: localCwd, source: "project_workspace", workspaceId: "workspace-1" }, + }, + executionTarget: { + kind: "remote", + transport: "sandbox", + providerKey: "fake-plugin", + remoteCwd, + runner: createLocalSandboxRunner(), + } as never, + authToken: "real-run-jwt", + }), + ); - const loggedLines: string[] = []; - try { - const result = await execute( - buildContext(localCwd, { - runId, - config: { - engine: "acp", - cwd: localCwd, - agentCommand: "node ./fake-acp.js", - stateDir: path.join(root, "state"), - env: { CODEX_HOME: sourceHome }, - promptTemplate: "Do the assigned work.", - }, - context: { - issueId: "issue-1", - paperclipWorkspace: { cwd: localCwd, source: "project_workspace", workspaceId: "workspace-1" }, - }, - executionTarget: { - kind: "remote", - transport: "sandbox", - providerKey: "fake-plugin", - remoteCwd, - runner: createLocalSandboxRunner(), - } as never, - authToken: "real-run-jwt", - onLog: async (_stream, chunk) => { - loggedLines.push(chunk); - }, - }), - ); - - // Fail-open: the restore miss never changes the run's exit code or - // status, and it surfaces as one allowlisted code — never the raw error. - expect(result.exitCode).toBe(0); - expect(result.resultJson?.workspaceRestoreFailure).toBe("restore_permission_denied"); - const allLogs = loggedLines.join(""); - expect(allLogs).not.toContain("SENTINEL-HOST-PATH-marker"); - expect(allLogs).not.toContain(localCwd); - expect(allLogs).not.toContain("EACCES"); - expect(allLogs).toContain("permission denied"); - } finally { - await fs.chmod(localCwd, 0o700).catch(() => undefined); - } + expect(result.exitCode).toBe(0); + expect(mockCreateWorkspaceRestoreTeardown).toHaveBeenCalledWith( + expect.objectContaining({ + startMessage: "[paperclip] Restoring workspace changes and Codex auth from the sandbox.\n", + failurePrefix: "[paperclip] Codex ACP teardown restore/copy-back failed", + }), + ); }); it("falls back to the CLI lane for a runner-less sandbox even when the ACP command is set", async () => { diff --git a/packages/adapters/codex-local/src/server/acp.ts b/packages/adapters/codex-local/src/server/acp.ts index 9bd45142eed..650fb040965 100644 --- a/packages/adapters/codex-local/src/server/acp.ts +++ b/packages/adapters/codex-local/src/server/acp.ts @@ -35,10 +35,7 @@ import { asString, parseObject, } from "@paperclipai/adapter-utils/server-utils"; -import { - classifyWorkspaceRestoreFailure, - describeWorkspaceRestoreFailure, -} from "@paperclipai/adapter-utils/workspace-restore-merge"; +import { createWorkspaceRestoreTeardown } from "@paperclipai/adapter-utils/workspace-restore-teardown"; import { normalizeCodexModel } from "../index.js"; import { classifyCodexAuthRefreshFailure } from "./parse.js"; import { copyBackCodexAuth } from "./codex-auth-copyback.js"; @@ -240,32 +237,16 @@ async function prepareCodexRemoteManagedHome( // without its staged home. Host staged-temp removal is deliberately NOT here // — see `disposeStaged` — so caching this runtime for reuse never destroys // resources the next resume needs. - teardown: async () => { - try { - await onLog( - "stdout", - "[paperclip] Restoring workspace changes and Codex auth from the sandbox.\n", - ); - await stagedRuntime.restoreWorkspace((line) => onLog("stdout", line)); - return { ok: true }; - } catch (err) { - // Fail-soft: a teardown copy-back miss loses this rotation and surfaces - // loudly as refresh_token_reused on the next host Codex use (re-auth - // recovers) — never silent host-credential corruption, so it must not - // mask the run result. - // - // The run log is readable by any same-company actor, so it must never - // carry the caught error's own message: that message can hold a host - // filesystem path or a process id. Log only the fixed, allowlisted - // diagnostic for the classified code. - const code = classifyWorkspaceRestoreFailure(err); - await onLog( - "stderr", - `[paperclip] Codex ACP teardown restore/copy-back failed: ${describeWorkspaceRestoreFailure(code)}\n`, - ); - return { ok: false, code }; - } - }, + // Fail-soft: a teardown copy-back miss loses this rotation and surfaces + // loudly as refresh_token_reused on the next host Codex use (re-auth + // recovers) — never silent host-credential corruption, so it must not + // mask the run result. + teardown: createWorkspaceRestoreTeardown({ + stagedRuntime, + onLog, + startMessage: "[paperclip] Restoring workspace changes and Codex auth from the sandbox.\n", + failurePrefix: "[paperclip] Codex ACP teardown restore/copy-back failed", + }), // One-time cleanup of the HOST staged home temp dir. Fired ONLY when the // staged runtime is dropped (failed/cancelled/timed-out turn, incompatible // re-stage, idle eviction) — never on a clean turn that keeps the runtime diff --git a/packages/adapters/gemini-local/src/server/acp.test.ts b/packages/adapters/gemini-local/src/server/acp.test.ts index 27ba27f7866..53d369f3e95 100644 --- a/packages/adapters/gemini-local/src/server/acp.test.ts +++ b/packages/adapters/gemini-local/src/server/acp.test.ts @@ -1,9 +1,26 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import type { AdapterExecutionContext, AdapterInvocationMeta } from "@paperclipai/adapter-utils"; import { runChildProcess } from "@paperclipai/adapter-utils/server-utils"; + +// Every test in this file needs a real teardown, so the mock below delegates +// to the actual factory by default. Only the wiring test further down reads +// the call arguments; it does not change this behavior. +const mockCreateWorkspaceRestoreTeardown = vi.hoisted(() => vi.fn()); + +vi.mock("@paperclipai/adapter-utils/workspace-restore-teardown", async (importOriginal) => { + const actual = await importOriginal>(); + mockCreateWorkspaceRestoreTeardown.mockImplementation( + actual.createWorkspaceRestoreTeardown as (...args: unknown[]) => unknown, + ); + return { + ...actual, + createWorkspaceRestoreTeardown: mockCreateWorkspaceRestoreTeardown, + }; +}); + import { buildGeminiAcpConfig, createGeminiAcpExecutor, @@ -613,88 +630,54 @@ describe("gemini_local ACP lane", () => { await expect(fs.readFile(path.join(localCwd, "from-sandbox.txt"), "utf8")).resolves.toBe("synced"); }); - it("test_gemini_acp_teardown_restore_failure_sanitizes_the_run_log", async () => { - // Security regression for a workspace-restore write failure: the run log - // is readable by any same-company actor, so the teardown must never write - // the caught error's own message there — that message can carry the host - // workspace path. Force a real EACCES by making the workspace read-only, - // and name it with a sentinel marker so any leak is easy to spot. - const root = await makeTempRoot("paperclip-gemini-acp-restore-failure-"); - const localCwd = path.join(root, "SENTINEL-HOST-PATH-marker", "worktree"); + it("passes the Gemini-specific teardown messages to the shared workspace-restore-teardown factory", async () => { + // Wiring test only: `createWorkspaceRestoreTeardown` owns the + // classify-and-redact contract, proven once for all three adapters by its + // own table-driven test in `packages/adapter-utils`. This test proves only + // that the Gemini adapter passes its own two message strings to it. + // Clear the shared, hoisted mock first: earlier tests in this file also + // call through it, and a leftover call could hide a real wiring bug. + mockCreateWorkspaceRestoreTeardown.mockClear(); + const root = await makeTempRoot("paperclip-gemini-acp-teardown-wiring-"); + const localCwd = path.join(root, "worktree"); const remoteCwd = path.join(root, "remote-workspace"); await fs.mkdir(localCwd, { recursive: true }); await fs.mkdir(remoteCwd, { recursive: true }); - await fs.writeFile(path.join(localCwd, "hello.txt"), "hi", "utf8"); - - // The runtime writes a new file into the in-sandbox workspace during the - // turn, so the teardown's restore has something to copy back — and a new - // file is exactly what a read-only workspace directory rejects. The - // workspace turns read-only only after the turn's own writes, so the - // teardown restore that runs after the turn is the write this forces to - // fail. - const runtime = new FakeRuntime({}); - const startTurn = runtime.startTurn.bind(runtime); - runtime.startTurn = (input) => { - const turn = startTurn(input); - const remoteWorkspaceCwd = input.handle.cwd ?? remoteCwd; - return { - ...turn, - result: (async () => { - await fs.writeFile(path.join(remoteWorkspaceCwd, "from-sandbox.txt"), "synced", "utf8"); - await fs.chmod(localCwd, 0o500); - return await turn.result; - })(), - }; - }; const execute = createGeminiAcpExecutor({ - createRuntime: (options) => { - Object.assign(runtime.options, options); - return runtime as never; - }, + createRuntime: (options) => new FakeRuntime(options) as never, }); + const result = await execute( + buildContext(localCwd, { + config: { + engine: "acp", + cwd: localCwd, + agentCommand: "node ./fake-acp.js", + stateDir: path.join(root, "state"), + promptTemplate: "Do the assigned work.", + }, + context: { + issueId: "issue-1", + paperclipWorkspace: { cwd: localCwd, source: "project_workspace", workspaceId: "workspace-1" }, + }, + executionTarget: { + kind: "remote", + transport: "sandbox", + providerKey: "fake-plugin", + remoteCwd, + runner: createLocalSandboxRunner(), + } as never, + authToken: "real-run-jwt", + }), + ); - const loggedLines: string[] = []; - try { - const result = await execute( - buildContext(localCwd, { - config: { - engine: "acp", - cwd: localCwd, - agentCommand: "node ./fake-acp.js", - stateDir: path.join(root, "state"), - promptTemplate: "Do the assigned work.", - }, - context: { - issueId: "issue-1", - paperclipWorkspace: { cwd: localCwd, source: "project_workspace", workspaceId: "workspace-1" }, - }, - executionTarget: { - kind: "remote", - transport: "sandbox", - providerKey: "fake-plugin", - remoteCwd, - runner: createLocalSandboxRunner(), - } as never, - authToken: "real-run-jwt", - onLog: async (_stream, chunk) => { - loggedLines.push(chunk); - }, - }), - ); - - // Fail-open: the restore miss never changes the run's exit code or - // status, and it surfaces as one allowlisted code — never the raw error. - expect(result.exitCode).toBe(0); - expect(result.resultJson?.workspaceRestoreFailure).toBe("restore_permission_denied"); - const allLogs = loggedLines.join(""); - expect(allLogs).not.toContain("SENTINEL-HOST-PATH-marker"); - expect(allLogs).not.toContain(localCwd); - expect(allLogs).not.toContain("EACCES"); - expect(allLogs).toContain("permission denied"); - } finally { - await fs.chmod(localCwd, 0o700).catch(() => undefined); - } + expect(result.exitCode).toBe(0); + expect(mockCreateWorkspaceRestoreTeardown).toHaveBeenCalledWith( + expect.objectContaining({ + startMessage: "[paperclip] Restoring workspace changes from the sandbox.\n", + failurePrefix: "[paperclip] Gemini ACP teardown workspace restore failed", + }), + ); }); it("does not persist an api-key auth selector from a host-only credential", async () => { diff --git a/packages/adapters/gemini-local/src/server/acp.ts b/packages/adapters/gemini-local/src/server/acp.ts index 95d8db4b699..97ea0f584bf 100644 --- a/packages/adapters/gemini-local/src/server/acp.ts +++ b/packages/adapters/gemini-local/src/server/acp.ts @@ -31,10 +31,7 @@ import { asString, parseObject, } from "@paperclipai/adapter-utils/server-utils"; -import { - classifyWorkspaceRestoreFailure, - describeWorkspaceRestoreFailure, -} from "@paperclipai/adapter-utils/workspace-restore-merge"; +import { createWorkspaceRestoreTeardown } from "@paperclipai/adapter-utils/workspace-restore-teardown"; import { DEFAULT_GEMINI_LOCAL_MODEL } from "../index.js"; const moduleDir = path.dirname(fileURLToPath(import.meta.url)); @@ -166,24 +163,13 @@ async function prepareGeminiRemoteManagedHome( // the host. A restore miss is logged and never fails the run. const registerWorkspaceSyncBack = ( stagedRuntime: AcpxRemoteManagedHomeResult["stagedRuntime"], - ): AcpxRemoteManagedHomeResult["teardown"] => async () => { - try { - await onLog("stdout", "[paperclip] Restoring workspace changes from the sandbox.\n"); - await stagedRuntime.restoreWorkspace((line) => onLog("stdout", line)); - return { ok: true }; - } catch (err) { - // The run log is readable by any same-company actor, so it must never - // carry the caught error's own message: that message can hold a host - // filesystem path or a process id. Log only the fixed, allowlisted - // diagnostic for the classified code. - const code = classifyWorkspaceRestoreFailure(err); - await onLog( - "stderr", - `[paperclip] Gemini ACP teardown workspace restore failed: ${describeWorkspaceRestoreFailure(code)}\n`, - ); - return { ok: false, code }; - } - }; + ): AcpxRemoteManagedHomeResult["teardown"] => + createWorkspaceRestoreTeardown({ + stagedRuntime, + onLog, + startMessage: "[paperclip] Restoring workspace changes from the sandbox.\n", + failurePrefix: "[paperclip] Gemini ACP teardown workspace restore failed", + }); const geminiSkillsHome = resolveGeminiSkillsHome(input.config); const stagedRuntime = await input.stage( geminiSkillsHome From 6524d2b67f4a908f7b54ce82fa370ce7634444f8 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Wed, 26 Aug 2026 09:08:31 -0500 Subject: [PATCH 25/32] fix(dev): honor --data-dir isolation (#12193) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - The development runner starts the local API, UI, and embedded PostgreSQL services. > - Developers need separate data roots when they run more than one local checkout. > - The runner forwarded `--data-dir` to a child process that did not use it. > - The migration check and server therefore continued to use the default Paperclip home. > - This pull request applies the data root before worktree setup and migration checks. > - The benefit is an isolated database and state root for each requested development run. ## Linked Issues or Issue Description Refs #7466 ## What Changed - Parse and consume `--data-dir`, `--data-dir=`, and `-d` in the development runner. - Set isolated default home, config, and context paths before worktree setup and migration checks. - Keep explicit config and context paths unchanged. - Include the normalized data root in the local service identity. - Let `dev:list` and `dev:stop` select the matching isolated service registry. - Keep explicit option environments independent of ambient process instance values. - Add regression tests and development documentation. ## Verification - `PAPERCLIP_INSTANCE_ID=ambient-test-instance pnpm exec vitest run server/src/__tests__/dev-runner-options.test.ts` passes with 8 tests. - `pnpm --filter @paperclipai/server typecheck` passes. - `pnpm --filter @paperclipai/adapter-utils build` passes. - `pnpm -r typecheck` passes. - `pnpm build` passes. - `pnpm dev:list --data-dir ./tmp/dev-service-review-fixture` selects the isolated registry. - A live run of `pnpm dev --data-dir ./tmp/data-dir-pr-smoke` became healthy on port 3101 while another checkout used port 3100. - The live run used `./tmp/data-dir-pr-smoke/instances/default/db` on a separate PostgreSQL port. - The latest-head Linux CI matrix passes, including build, typecheck, canary, all general and serialized test shards, and all e2e shards. - `pnpm test:run` was attempted on macOS. Current `master` has unrelated workspace path failures because `/tmp` resolves to `/private/tmp`. The focused regression suite passes, and the full Linux matrix is green. ## Risks - Risk is low. The change only affects development runs that pass `--data-dir` and matching service-management commands. - Explicit `PAPERCLIP_CONFIG` and `PAPERCLIP_CONTEXT` values still take priority. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex with `gpt-5.6-sol` produced this change. The run used tool-enabled reasoning and code execution. The runtime did not expose its context window size. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- doc/DEVELOPING.md | 17 ++++ scripts/dev-runner-options.ts | 74 +++++++++++++++++ scripts/dev-runner.ts | 17 +++- scripts/dev-service.ts | 15 ++++ .../src/__tests__/dev-runner-options.test.ts | 83 +++++++++++++++++++ 5 files changed, 203 insertions(+), 3 deletions(-) create mode 100644 scripts/dev-runner-options.ts create mode 100644 server/src/__tests__/dev-runner-options.test.ts diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index 20747f26818..33c5666c706 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -43,6 +43,23 @@ This starts: `pnpm dev` and `pnpm dev:once` are now idempotent for the current repo and instance: if the matching Paperclip dev runner is already alive, Paperclip reports the existing process instead of starting a duplicate. +To run against a separate local state root, pass `--data-dir`. The dev runner +translates it to an isolated `PAPERCLIP_HOME` before migration checks or server +startup, so embedded PostgreSQL and other default instance state live under that +directory: + +```sh +pnpm dev --data-dir ./tmp/paperclip-dev +``` + +Pass the same option to the service-management commands so they use the +isolated runtime-service registry: + +```sh +pnpm dev:list --data-dir ./tmp/paperclip-dev +pnpm dev:stop --data-dir ./tmp/paperclip-dev +``` + Issue execution may also use project execution workspace policies and workspace runtime services for per-project worktrees, preview servers, and managed dev commands. Configure those through the project workspace/runtime surfaces rather than starting long-running unmanaged processes when a task needs a reusable service. ### Mobile-friendly preview (`pnpm dev:mobile`) diff --git a/scripts/dev-runner-options.ts b/scripts/dev-runner-options.ts new file mode 100644 index 00000000000..c1613b4328d --- /dev/null +++ b/scripts/dev-runner-options.ts @@ -0,0 +1,74 @@ +import path from "node:path"; +import { + DEFAULT_PAPERCLIP_INSTANCE_ID, + expandHomePrefix, + resolvePaperclipConfigPathForInstance, + resolvePaperclipInstanceId, +} from "../packages/shared/src/home-paths.ts"; + +export interface AppliedDevRunnerOptions { + forwardedArgs: string[]; + dataDir: string | null; +} + +function requireOptionValue( + args: string[], + index: number, + option: string, +): string { + const value = args[index + 1]?.trim(); + if (!value || value.startsWith("-")) { + throw new Error(`${option} requires a value`); + } + return value; +} + +export function applyDevRunnerOptions( + args: string[], + env: NodeJS.ProcessEnv = process.env, + cwd: string = process.cwd(), +): AppliedDevRunnerOptions { + const forwardedArgs: string[] = []; + let dataDirRaw: string | null = null; + + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg === "--data-dir" || arg === "-d") { + dataDirRaw = requireOptionValue(args, index, arg); + index += 1; + continue; + } + if (arg.startsWith("--data-dir=")) { + const value = arg.slice("--data-dir=".length).trim(); + if (!value) throw new Error("--data-dir requires a value"); + dataDirRaw = value; + continue; + } + forwardedArgs.push(arg); + } + + if (!dataDirRaw) { + return { forwardedArgs, dataDir: null }; + } + + const dataDir = path.resolve(cwd, expandHomePrefix(dataDirRaw)); + const hasExplicitConfig = Boolean(env.PAPERCLIP_CONFIG?.trim()); + const hasExplicitContext = Boolean(env.PAPERCLIP_CONTEXT?.trim()); + + env.PAPERCLIP_HOME = dataDir; + if (!hasExplicitConfig) { + const instanceId = resolvePaperclipInstanceId( + env.PAPERCLIP_INSTANCE_ID ?? DEFAULT_PAPERCLIP_INSTANCE_ID, + ); + env.PAPERCLIP_INSTANCE_ID = instanceId; + env.PAPERCLIP_CONFIG = resolvePaperclipConfigPathForInstance({ + homeDir: dataDir, + instanceId, + }); + } + if (!hasExplicitContext) { + env.PAPERCLIP_CONTEXT = path.resolve(dataDir, "context.json"); + } + + return { forwardedArgs, dataDir }; +} diff --git a/scripts/dev-runner.ts b/scripts/dev-runner.ts index 28e152e9949..54e7198a74d 100644 --- a/scripts/dev-runner.ts +++ b/scripts/dev-runner.ts @@ -6,6 +6,7 @@ import path from "node:path"; import { createInterface } from "node:readline/promises"; import { stdin, stdout } from "node:process"; import { createCapturedOutputBuffer, parseJsonResponseWithLimit } from "./dev-runner-output.ts"; +import { applyDevRunnerOptions } from "./dev-runner-options.ts"; import { collectWatchedSnapshot as collectDevServerWatchedSnapshot, diffSnapshots } from "./dev-runner-snapshot.mjs"; import { createDevServiceIdentity, repoRoot } from "./dev-service-profile.ts"; import { bootstrapDevRunnerWorktreeEnv, isWorktreeSeedPending } from "../server/src/dev-runner-worktree.ts"; @@ -21,6 +22,18 @@ import { const BIND_MODES = ["loopback", "lan", "tailnet", "custom"] as const; type BindMode = (typeof BIND_MODES)[number]; +const mode = process.argv[2] === "watch" ? "watch" : "dev"; +let cliArgs: string[]; +let dataDir: string | null; +try { + const appliedOptions = applyDevRunnerOptions(process.argv.slice(3), process.env, repoRoot); + cliArgs = appliedOptions.forwardedArgs; + dataDir = appliedOptions.dataDir; +} catch (error) { + console.error(`[paperclip] ${error instanceof Error ? error.message : String(error)}`); + process.exit(1); +} + const worktreeEnvBootstrap = bootstrapDevRunnerWorktreeEnv(repoRoot, process.env); if (worktreeEnvBootstrap.missingEnv) { console.error( @@ -35,8 +48,6 @@ if (isWorktreeSeedPending(repoRoot)) { process.exit(1); } -const mode = process.argv[2] === "watch" ? "watch" : "dev"; -const cliArgs = process.argv.slice(3); const scanIntervalMs = 1500; const autoRestartPollIntervalMs = 2500; const gracefulShutdownTimeoutMs = 10_000; @@ -196,7 +207,7 @@ if (tailscaleAuth || bindMode) { const serverPort = Number.parseInt(env.PORT ?? process.env.PORT ?? "3100", 10) || 3100; const devService = createDevServiceIdentity({ mode, - forwardedArgs, + forwardedArgs: dataDir ? [...forwardedArgs, `--data-dir=${dataDir}`] : forwardedArgs, networkProfile: tailscaleAuth ? `legacy:${bindMode ?? "lan"}` : (bindMode ?? "default"), port: serverPort, }); diff --git a/scripts/dev-service.ts b/scripts/dev-service.ts index 978607ec3f0..af03bf930da 100644 --- a/scripts/dev-service.ts +++ b/scripts/dev-service.ts @@ -1,5 +1,6 @@ #!/usr/bin/env -S node --import tsx import { listLocalServiceRegistryRecords, removeLocalServiceRegistryRecord, terminateLocalService } from "../server/src/services/local-service-supervisor.ts"; +import { applyDevRunnerOptions } from "./dev-runner-options.ts"; import { repoRoot } from "./dev-service-profile.ts"; function toDisplayLines(records: Awaited>) { @@ -11,6 +12,20 @@ function toDisplayLines(records: Awaited 0) { + throw new Error(`Unknown dev-service option: ${forwardedArgs[0]}`); + } +} catch (error) { + console.error(`[paperclip] ${error instanceof Error ? error.message : String(error)}`); + process.exit(1); +} + const records = await listLocalServiceRegistryRecords({ profileKind: "paperclip-dev", metadata: { repoRoot }, diff --git a/server/src/__tests__/dev-runner-options.test.ts b/server/src/__tests__/dev-runner-options.test.ts new file mode 100644 index 00000000000..af0d85091ed --- /dev/null +++ b/server/src/__tests__/dev-runner-options.test.ts @@ -0,0 +1,83 @@ +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { applyDevRunnerOptions } from "../../../scripts/dev-runner-options.ts"; + +describe("applyDevRunnerOptions", () => { + it("turns --data-dir into isolated Paperclip paths and consumes the option", () => { + const env: NodeJS.ProcessEnv = {}; + const cwd = path.join(os.tmpdir(), "paperclip-dev-runner-options"); + const previousInstanceId = process.env.PAPERCLIP_INSTANCE_ID; + process.env.PAPERCLIP_INSTANCE_ID = "ambient-test-instance"; + + try { + const result = applyDevRunnerOptions( + ["--bind", "loopback", "--data-dir", "./tmp", "--future-option"], + env, + cwd, + ); + + const expectedHome = path.resolve(cwd, "tmp"); + expect(result).toEqual({ + forwardedArgs: ["--bind", "loopback", "--future-option"], + dataDir: expectedHome, + }); + expect(env.PAPERCLIP_HOME).toBe(expectedHome); + expect(env.PAPERCLIP_INSTANCE_ID).toBe("default"); + expect(env.PAPERCLIP_CONFIG).toBe( + path.join(expectedHome, "instances", "default", "config.json"), + ); + expect(env.PAPERCLIP_CONTEXT).toBe(path.join(expectedHome, "context.json")); + } finally { + if (previousInstanceId === undefined) { + delete process.env.PAPERCLIP_INSTANCE_ID; + } else { + process.env.PAPERCLIP_INSTANCE_ID = previousInstanceId; + } + } + }); + + it.each([ + ["short option", ["-d", "~/paperclip-dev"]], + ["equals form", ["--data-dir=~/paperclip-dev"]], + ])("supports the %s", (_label, args) => { + const env: NodeJS.ProcessEnv = {}; + + const result = applyDevRunnerOptions(args, env, "/unused"); + + expect(result.forwardedArgs).toEqual([]); + expect(result.dataDir).toBe(path.join(os.homedir(), "paperclip-dev")); + }); + + it("uses the selected instance for the default config path", () => { + const env: NodeJS.ProcessEnv = { PAPERCLIP_INSTANCE_ID: "experiment" }; + + applyDevRunnerOptions(["--data-dir", "/isolated/home"], env, "/unused"); + + expect(env.PAPERCLIP_CONFIG).toBe( + path.join("/isolated/home", "instances", "experiment", "config.json"), + ); + }); + + it("preserves explicit config and context paths", () => { + const env: NodeJS.ProcessEnv = { + PAPERCLIP_CONFIG: "/explicit/config.json", + PAPERCLIP_CONTEXT: "/explicit/context.json", + }; + + applyDevRunnerOptions(["--data-dir", "/isolated/home"], env, "/unused"); + + expect(env.PAPERCLIP_HOME).toBe("/isolated/home"); + expect(env.PAPERCLIP_CONFIG).toBe("/explicit/config.json"); + expect(env.PAPERCLIP_CONTEXT).toBe("/explicit/context.json"); + }); + + it.each([["--data-dir"], ["-d"], ["--data-dir="]])( + "rejects a missing value for %s", + (...args) => { + expect(() => applyDevRunnerOptions(args, {}, "/unused")).toThrow( + /requires a value/, + ); + }, + ); +}); From a9d0927fe8375be0089528cbe8393d25f85ebdc6 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Wed, 26 Aug 2026 09:45:24 -0500 Subject: [PATCH 26/32] fix(adapters): restore Paperclip skill for legacy runners (#12225) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Legacy local adapters run agents that use the Paperclip skill for the control-plane workflow. > - PR #7029 removed the required-skill fallback and made runtime skill selection depend only on stored preferences. > - No migration or runtime fallback replaced that behavior for existing agents or non-CEO agents. > - PR #12138 added core skills to new CEOs, and PR #12147 added Claude skill discovery. These changes did not mount the operational skill for all legacy agents. > - This pull request makes the operational skill a legacy adapter runtime invariant. It keeps all other skills configurable. > - The native runner stays unchanged because its protocol supplies the control-plane contract. > - The benefit is that new and existing legacy agents can always operate through Paperclip. ## Linked Issues or Issue Description Refs #7029 Refs #12138 Refs #12147 **What happened?** A skill-capable legacy local agent could start without `paperclipai/paperclip/paperclip`. This happened when the agent had no stored skill preference. An explicit empty preference also removed the skill. The agent then reported that the Paperclip skill was not available. **Expected behavior** Every skill-capable legacy local adapter must mount the Paperclip operational skill when the runtime inventory contains it. Optional skills must remain configurable. The native runner must keep its current protocol-based behavior. **Steps to reproduce** 1. Create a non-CEO `codex_local` agent without `paperclipSkillSync` preferences. 2. Start a legacy heartbeat. 3. Inspect the managed `CODEX_HOME/skills` directory. 4. Observe that the Paperclip skill is absent before this change. **Paperclip version or commit** The problem reproduces on `master` before this pull request. PR #7029 introduced the configured-only selection behavior. **Deployment mode** Local development and self-hosted legacy local adapters. ## What Changed - Added a shared legacy skill resolver that always selects the canonical Paperclip operational skill when it is available. - Applied the resolver to direct adapter execution, ACPX execution, skill snapshots, and persistent skill sync. - Added Hermes skill materialization at sync and run boundaries. - Aligned Cursor, Gemini, and OpenCode execution-time injection with the configured child `HOME`. - Made Hermes stop execution when another installation blocks the required operational skill. - Kept optional skills controlled by `paperclipSkillSync.desiredSkills`. - Kept `paperclip_runner` on the configurable-only resolver. - Added regression coverage for missing preferences, empty preferences, each skill-capable legacy adapter, ACPX, Hermes, and native runner isolation. - Documented the legacy runtime invariant. ## Verification - `pnpm -r typecheck` passed on the pushed commit. - `pnpm build` passed on the pushed commit. - The adapter utility regression suites passed: 236 tests. - The changed server adapter suites passed: 48 tests across 12 files. - The OpenCode adapter suite passed: 8 tests. - The Hermes adapter suite passed: 7 tests. - `git diff --check` passed. - `pnpm test:run` is not clean on this macOS host. The command reported failures in unchanged workspace and filesystem suites. An isolated rerun of `company-skills.test.ts` and `company-skills-service.test.ts` reproduced 11 failures because macOS resolved `/var/...` paths as `/private/var/...`. The changed adapter suites pass independently. ## Risks - This change deliberately makes the operational skill non-removable for skill-capable legacy local adapters. - Existing agents receive the skill on their next list, sync, or run boundary. No database migration is required. - The resolver does not create a skill when the runtime inventory does not contain the canonical entry. - Hermes aborts a run if another installation occupies the required operational skill target. - Hermes removes only an undesired Paperclip-owned symlink that still points to the known Paperclip source. - The native runner does not receive the legacy default. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used OpenAI Codex based on GPT-5. The exact serving model ID and context window were not exposed. The agent used reasoning, tool use, and code execution. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- doc/DEVELOPING.md | 9 ++ .../src/acpx-engine/execute.test.ts | 20 ++++ .../adapter-utils/src/acpx-engine/execute.ts | 6 +- .../adapter-utils/src/server-utils.test.ts | 42 +++++++++ packages/adapter-utils/src/server-utils.ts | 27 ++++++ .../claude-local/src/server/skills.ts | 6 +- .../codex-local/src/server/execute.ts | 1 - .../adapters/codex-local/src/server/skills.ts | 14 ++- .../cursor-local/src/server/execute.ts | 14 ++- .../cursor-local/src/server/skills.ts | 13 ++- .../gemini-local/src/server/execute.ts | 18 ++-- .../gemini-local/src/server/skills.ts | 13 ++- .../adapters/grok-local/src/server/execute.ts | 4 +- .../adapters/grok-local/src/server/skills.ts | 4 +- packages/adapters/hermes/src/index.test.ts | 93 +++++++++++++++++++ .../adapters/hermes/src/server/execute.ts | 20 ++++ packages/adapters/hermes/src/server/skills.ts | 56 +++++++++-- .../adapters/kimi-local/src/server/execute.ts | 6 +- .../adapters/kimi-local/src/server/skills.ts | 9 +- .../opencode-local/src/server/execute.test.ts | 84 ++++++++++++++++- .../opencode-local/src/server/execute.ts | 14 ++- .../opencode-local/src/server/skills.ts | 13 ++- .../adapters/pi-local/src/server/execute.ts | 6 +- .../adapters/pi-local/src/server/skills.ts | 11 ++- .../__tests__/claude-local-skill-sync.test.ts | 7 +- .../src/__tests__/codex-local-execute.test.ts | 5 +- .../__tests__/codex-local-skill-sync.test.ts | 18 +++- .../__tests__/cursor-local-execute.test.ts | 11 ++- .../__tests__/cursor-local-skill-sync.test.ts | 24 ++++- .../__tests__/gemini-local-execute.test.ts | 65 +++++++++++++ .../__tests__/gemini-local-skill-sync.test.ts | 5 +- .../__tests__/grok-local-skill-sync.test.ts | 8 +- .../__tests__/kimi-local-skill-sync.test.ts | 5 +- .../opencode-local-skill-sync.test.ts | 5 +- server/src/__tests__/pi-local-execute.test.ts | 4 +- .../src/__tests__/pi-local-skill-sync.test.ts | 5 +- server/src/routes/agents.ts | 5 +- 37 files changed, 549 insertions(+), 121 deletions(-) diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index 33c5666c706..d5bcfb03493 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -756,6 +756,15 @@ Server and CLI import the generated manifest; they do not crawl repository paths at request time. Root `skills/` remains reserved for Paperclip runtime skills and is not part of the catalog. +Skill-capable legacy local adapters always select the bundled +`paperclipai/paperclip/paperclip` operational skill when it is present in the +runtime inventory. This applies to existing agents without a stored skill +preference and to explicit empty optional-skill selections. The operational +skill supplies the control-plane workflow that those adapters need for +heartbeats. Other runtime skills remain controlled by +`paperclipSkillSync.desiredSkills`. The native `paperclip_runner` does not use +this legacy default because its protocol supplies the control-plane contract. + Validate the catalog without writing the manifest: ```sh diff --git a/packages/adapter-utils/src/acpx-engine/execute.test.ts b/packages/adapter-utils/src/acpx-engine/execute.test.ts index 9ba7ea4dde0..63c5fe47896 100644 --- a/packages/adapter-utils/src/acpx-engine/execute.test.ts +++ b/packages/adapter-utils/src/acpx-engine/execute.test.ts @@ -1182,6 +1182,26 @@ describe("shared ACPX engine runtime behavior", () => { expect(await pathExists(path.join(codexHome, "skills", remove.runtimeName))).toBe(false); }); + it.skipIf(process.platform === "win32")("keeps the operational skill in an ACPX Codex home after an empty replacement", async () => { + const root = await makeTempRoot(); + const skillRoot = path.join(root, "skills"); + const codexHome = path.join(root, "codex-home"); + const operational = { + ...await createSkill(skillRoot, "paperclip"), + key: "paperclipai/paperclip/paperclip", + }; + + await runExecutor({ + agent: "codex", + stateDir: path.join(root, "state"), + env: { CODEX_HOME: codexHome }, + paperclipRuntimeSkills: [operational], + paperclipSkillSync: { desiredSkills: [] }, + }); + + expect(await pathExists(path.join(codexHome, "skills", operational.runtimeName, "SKILL.md"))).toBe(true); + }); + it.skipIf(process.platform === "win32")("removes legacy ACPX Codex skill symlinks when a skill is no longer desired", async () => { const root = await makeTempRoot(); const skillRoot = path.join(root, "skills"); diff --git a/packages/adapter-utils/src/acpx-engine/execute.ts b/packages/adapter-utils/src/acpx-engine/execute.ts index 4442e7f642b..29261ea596d 100644 --- a/packages/adapter-utils/src/acpx-engine/execute.ts +++ b/packages/adapter-utils/src/acpx-engine/execute.ts @@ -64,7 +64,7 @@ import { renderTemplate, resolvePaperclipInstanceRootForAdapter, selectPaperclipTaskMarkdown, - resolvePaperclipDesiredSkillNames, + resolveLegacyPaperclipDesiredSkillNames, removeMaintainerOnlySkillSymlinks, rewriteWorkspaceCwdEnvVarsForExecution, shapePaperclipWorkspaceEnvForExecution, @@ -910,7 +910,7 @@ async function resolveSelectedRuntimeSkills( moduleDir: string, ): Promise<{ allSkills: PaperclipSkillEntry[]; selectedSkills: PaperclipSkillEntry[]; desiredSkillNames: string[] }> { const allSkills = await readPaperclipRuntimeSkillEntries(config, moduleDir); - const desiredSkillNames = resolvePaperclipDesiredSkillNames(config, allSkills); + const desiredSkillNames = resolveLegacyPaperclipDesiredSkillNames(config, allSkills); const desiredSet = new Set(desiredSkillNames); return { allSkills, @@ -1852,7 +1852,7 @@ async function buildRuntime(input: { skillsIdentity = preparedSkills.identity; skillCommandNotes.push(...preparedSkills.commandNotes); } else { - const desired = resolvePaperclipDesiredSkillNames( + const desired = resolveLegacyPaperclipDesiredSkillNames( config, await readPaperclipRuntimeSkillEntries(config, input.engine.moduleDir), ); diff --git a/packages/adapter-utils/src/server-utils.test.ts b/packages/adapter-utils/src/server-utils.test.ts index 3b497239e81..17063ef4242 100644 --- a/packages/adapter-utils/src/server-utils.test.ts +++ b/packages/adapter-utils/src/server-utils.test.ts @@ -13,8 +13,11 @@ import { buildPaperclipEnv, DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE, materializePaperclipSkillCopy, + PAPERCLIP_OPERATIONAL_SKILL_KEY, refreshPaperclipWorkspaceEnvForExecution, renderPaperclipWakePrompt, + resolveLegacyPaperclipDesiredSkillNames, + resolvePaperclipDesiredSkillNames, selectPaperclipTaskMarkdown, runningProcesses, runChildProcess, @@ -28,6 +31,45 @@ import { WATCHDOG_DEFAULT_MANDATE, } from "./server-utils.js"; +describe("legacy adapter skill selection", () => { + const operationalEntry = { + key: PAPERCLIP_OPERATIONAL_SKILL_KEY, + runtimeName: "paperclip", + }; + const optionalEntry = { + key: "company/example/reviewer", + runtimeName: "reviewer", + }; + + it("keeps the operational skill selected without a stored preference", () => { + expect(resolveLegacyPaperclipDesiredSkillNames({}, [operationalEntry, optionalEntry])).toEqual([ + PAPERCLIP_OPERATIONAL_SKILL_KEY, + ]); + }); + + it("keeps the operational skill selected after an explicit empty replacement", () => { + expect(resolveLegacyPaperclipDesiredSkillNames( + { paperclipSkillSync: { desiredSkills: [] } }, + [operationalEntry, optionalEntry], + )).toEqual([PAPERCLIP_OPERATIONAL_SKILL_KEY]); + }); + + it("does not force optional skills or synthesize a missing operational entry", () => { + const config = { paperclipSkillSync: { desiredSkills: [optionalEntry.key] } }; + expect(resolveLegacyPaperclipDesiredSkillNames(config, [operationalEntry, optionalEntry])).toEqual([ + PAPERCLIP_OPERATIONAL_SKILL_KEY, + optionalEntry.key, + ]); + expect(resolveLegacyPaperclipDesiredSkillNames(config, [optionalEntry])).toEqual([ + optionalEntry.key, + ]); + }); + + it("leaves the configurable resolver available for native runners", () => { + expect(resolvePaperclipDesiredSkillNames({}, [operationalEntry])).toEqual([]); + }); +}); + function isPidAlive(pid: number) { try { process.kill(pid, 0); diff --git a/packages/adapter-utils/src/server-utils.ts b/packages/adapter-utils/src/server-utils.ts index 48d229fb478..d3e73b35ce7 100644 --- a/packages/adapter-utils/src/server-utils.ts +++ b/packages/adapter-utils/src/server-utils.ts @@ -2989,6 +2989,33 @@ export function resolvePaperclipDesiredSkillNames( return Array.from(new Set(desiredSkills)); } +/** + * Legacy adapters call the Paperclip API through the operational skill. Keep + * that skill mounted even when an agent predates skill preferences or carries + * an explicit empty desired set. Native runners provide the same authority + * through their protocol and must continue to use the configurable-only + * resolver above. + */ +export const PAPERCLIP_OPERATIONAL_SKILL_KEY = "paperclipai/paperclip/paperclip"; + +export function resolveLegacyPaperclipDesiredSkillNames( + config: Record, + availableEntries: Array<{ key: string; runtimeName?: string | null }>, +): string[] { + const desiredSkills = resolvePaperclipDesiredSkillNames(config, availableEntries); + const operationalEntry = availableEntries.find( + (entry) => entry.key.trim().toLowerCase() === PAPERCLIP_OPERATIONAL_SKILL_KEY, + ); + if (!operationalEntry) return desiredSkills; + + return [ + operationalEntry.key, + ...desiredSkills.filter( + (key) => key.trim().toLowerCase() !== PAPERCLIP_OPERATIONAL_SKILL_KEY, + ), + ]; +} + export function writePaperclipSkillSyncPreference( config: Record, desiredSkills: Array, diff --git a/packages/adapters/claude-local/src/server/skills.ts b/packages/adapters/claude-local/src/server/skills.ts index 75fb27ce33c..724263605c4 100644 --- a/packages/adapters/claude-local/src/server/skills.ts +++ b/packages/adapters/claude-local/src/server/skills.ts @@ -9,7 +9,7 @@ import { buildRuntimeMountedSkillSnapshot, readPaperclipRuntimeSkillEntries, readInstalledSkillTargets, - resolvePaperclipDesiredSkillNames, + resolveLegacyPaperclipDesiredSkillNames, } from "@paperclipai/adapter-utils/server-utils"; const __moduleDir = path.dirname(fileURLToPath(import.meta.url)); @@ -30,7 +30,7 @@ function resolveClaudeSkillsHome(config: Record) { async function buildClaudeSkillSnapshot(config: Record): Promise { const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir); - const desiredSkills = resolvePaperclipDesiredSkillNames(config, availableEntries); + const desiredSkills = resolveLegacyPaperclipDesiredSkillNames(config, availableEntries); const skillsHome = resolveClaudeSkillsHome(config); const installed = await readInstalledSkillTargets(skillsHome); return buildRuntimeMountedSkillSnapshot({ @@ -60,5 +60,5 @@ export function resolveClaudeDesiredSkillNames( config: Record, availableEntries: Array<{ key: string; required?: boolean }>, ) { - return resolvePaperclipDesiredSkillNames(config, availableEntries); + return resolveLegacyPaperclipDesiredSkillNames(config, availableEntries); } diff --git a/packages/adapters/codex-local/src/server/execute.ts b/packages/adapters/codex-local/src/server/execute.ts index f2f0635ede7..430a77bcc20 100644 --- a/packages/adapters/codex-local/src/server/execute.ts +++ b/packages/adapters/codex-local/src/server/execute.ts @@ -43,7 +43,6 @@ import { isPaperclipSkillSourceMissing, readPaperclipRuntimeSkillEntries, readPaperclipIssueWorkModeFromContext, - resolvePaperclipDesiredSkillNames, renderTemplate, renderPaperclipWakePrompt, isPaperclipRecoveryWakePayload, diff --git a/packages/adapters/codex-local/src/server/skills.ts b/packages/adapters/codex-local/src/server/skills.ts index 6d52cb338b5..bdf4879dc81 100644 --- a/packages/adapters/codex-local/src/server/skills.ts +++ b/packages/adapters/codex-local/src/server/skills.ts @@ -7,6 +7,7 @@ import type { import { buildRuntimeMountedSkillSnapshot, readPaperclipRuntimeSkillEntries, + resolveLegacyPaperclipDesiredSkillNames, resolvePaperclipDesiredSkillNames, } from "@paperclipai/adapter-utils/server-utils"; @@ -14,11 +15,14 @@ const __moduleDir = path.dirname(fileURLToPath(import.meta.url)); async function buildCodexSkillSnapshot( config: Record, + adapterType: string, ): Promise { const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir); - const desiredSkills = resolvePaperclipDesiredSkillNames(config, availableEntries); + const desiredSkills = adapterType === "paperclip_runner" + ? resolvePaperclipDesiredSkillNames(config, availableEntries) + : resolveLegacyPaperclipDesiredSkillNames(config, availableEntries); return buildRuntimeMountedSkillSnapshot({ - adapterType: "codex_local", + adapterType, availableEntries, desiredSkills, configuredDetail: "Will be linked into the effective CODEX_HOME/skills/ directory on the next run.", @@ -26,19 +30,19 @@ async function buildCodexSkillSnapshot( } export async function listCodexSkills(ctx: AdapterSkillContext): Promise { - return buildCodexSkillSnapshot(ctx.config); + return buildCodexSkillSnapshot(ctx.config, ctx.adapterType); } export async function syncCodexSkills( ctx: AdapterSkillContext, _desiredSkills: string[], ): Promise { - return buildCodexSkillSnapshot(ctx.config); + return buildCodexSkillSnapshot(ctx.config, ctx.adapterType); } export function resolveCodexDesiredSkillNames( config: Record, availableEntries: Array<{ key: string; required?: boolean }>, ) { - return resolvePaperclipDesiredSkillNames(config, availableEntries); + return resolveLegacyPaperclipDesiredSkillNames(config, availableEntries); } diff --git a/packages/adapters/cursor-local/src/server/execute.ts b/packages/adapters/cursor-local/src/server/execute.ts index 30f23950e88..2a7edeebf0e 100644 --- a/packages/adapters/cursor-local/src/server/execute.ts +++ b/packages/adapters/cursor-local/src/server/execute.ts @@ -39,7 +39,7 @@ import { isPaperclipSkillSourceMissing, readPaperclipRuntimeSkillEntries, readPaperclipIssueWorkModeFromContext, - resolvePaperclipDesiredSkillNames, + resolveLegacyPaperclipDesiredSkillNames, removeMaintainerOnlySkillSymlinks, renderTemplate, renderPaperclipWakePrompt, @@ -53,6 +53,7 @@ import { parseCursorJsonl, isCursorUnknownSessionError } from "./parse.js"; import { prepareCursorSandboxCommand } from "./remote-command.js"; import { normalizeCursorStreamLine } from "../shared/stream.js"; import { hasCursorTrustBypassArg } from "../shared/trust.js"; +import { resolveCursorSkillsHome } from "./skills.js"; const __moduleDir = path.dirname(fileURLToPath(import.meta.url)); @@ -117,16 +118,12 @@ function renderPaperclipEnvNote(env: Record): string { ].join("\n"); } -function cursorSkillsHome(): string { - return path.join(os.homedir(), ".cursor", "skills"); -} - async function buildCursorSkillsDir(config: Record): Promise { const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-cursor-skills-")); const target = path.join(tmp, "skills"); await fs.mkdir(target, { recursive: true }); const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir); - const desiredNames = new Set(resolvePaperclipDesiredSkillNames(config, availableEntries)); + const desiredNames = new Set(resolveLegacyPaperclipDesiredSkillNames(config, availableEntries)); for (const entry of availableEntries) { if (!desiredNames.has(entry.key)) continue; if (isPaperclipSkillSourceMissing(entry)) continue; @@ -158,7 +155,7 @@ export async function ensureCursorSkillsInjected( : await readPaperclipRuntimeSkillEntries({}, __moduleDir)); if (skillsEntries.length === 0) return; - const skillsHome = options.skillsHome ?? cursorSkillsHome(); + const skillsHome = options.skillsHome ?? resolveCursorSkillsHome({}); try { await fs.mkdir(skillsHome, { recursive: true }); } catch (err) { @@ -233,12 +230,13 @@ export async function execute(ctx: AdapterExecutionContext): Promise desiredCursorSkillNames.includes(entry.key) && !isPaperclipSkillSourceMissing(entry), ), + skillsHome: resolveCursorSkillsHome(config), }); } diff --git a/packages/adapters/cursor-local/src/server/skills.ts b/packages/adapters/cursor-local/src/server/skills.ts index 854fc133a68..a725df4378e 100644 --- a/packages/adapters/cursor-local/src/server/skills.ts +++ b/packages/adapters/cursor-local/src/server/skills.ts @@ -11,7 +11,7 @@ import { ensurePaperclipSkillSymlink, readPaperclipRuntimeSkillEntries, readInstalledSkillTargets, - resolvePaperclipDesiredSkillNames, + resolveLegacyPaperclipDesiredSkillNames, } from "@paperclipai/adapter-utils/server-utils"; const __moduleDir = path.dirname(fileURLToPath(import.meta.url)); @@ -20,7 +20,7 @@ function asString(value: unknown): string | null { return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; } -function resolveCursorSkillsHome(config: Record) { +export function resolveCursorSkillsHome(config: Record) { const env = typeof config.env === "object" && config.env !== null && !Array.isArray(config.env) ? (config.env as Record) @@ -32,7 +32,7 @@ function resolveCursorSkillsHome(config: Record) { async function buildCursorSkillSnapshot(config: Record): Promise { const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir); - const desiredSkills = resolvePaperclipDesiredSkillNames(config, availableEntries); + const desiredSkills = resolveLegacyPaperclipDesiredSkillNames(config, availableEntries); const skillsHome = resolveCursorSkillsHome(config); const installed = await readInstalledSkillTargets(skillsHome); return buildPersistentSkillSnapshot({ @@ -57,7 +57,10 @@ export async function syncCursorSkills( desiredSkills: string[], ): Promise { const availableEntries = await readPaperclipRuntimeSkillEntries(ctx.config, __moduleDir); - const desiredSet = new Set(desiredSkills); + const desiredSet = new Set([ + ...resolveLegacyPaperclipDesiredSkillNames({}, availableEntries), + ...desiredSkills, + ]); const skillsHome = resolveCursorSkillsHome(ctx.config); await fs.mkdir(skillsHome, { recursive: true }); const installed = await readInstalledSkillTargets(skillsHome); @@ -84,5 +87,5 @@ export function resolveCursorDesiredSkillNames( config: Record, availableEntries: Array<{ key: string }>, ) { - return resolvePaperclipDesiredSkillNames(config, availableEntries); + return resolveLegacyPaperclipDesiredSkillNames(config, availableEntries); } diff --git a/packages/adapters/gemini-local/src/server/execute.ts b/packages/adapters/gemini-local/src/server/execute.ts index ce01307b965..fb81512d792 100644 --- a/packages/adapters/gemini-local/src/server/execute.ts +++ b/packages/adapters/gemini-local/src/server/execute.ts @@ -41,7 +41,7 @@ import { isPaperclipSkillSourceMissing, readPaperclipRuntimeSkillEntries, readPaperclipIssueWorkModeFromContext, - resolvePaperclipDesiredSkillNames, + resolveLegacyPaperclipDesiredSkillNames, removeMaintainerOnlySkillSymlinks, parseObject, renderTemplate, @@ -66,6 +66,7 @@ import { formatGeminiAcpFallbackMessage, resolveGeminiExecutionEngineForRun, } from "./acp.js"; +import { resolveGeminiSkillsHome } from "./skills.js"; const __moduleDir = path.dirname(fileURLToPath(import.meta.url)); const executeGeminiAcp = createGeminiAcpExecutor(); @@ -132,25 +133,21 @@ function renderApiAccessNote(env: Record): string { ].join("\n"); } -function geminiSkillsHome(): string { - return path.join(os.homedir(), ".gemini", "skills"); -} - /** - * Inject Paperclip skills directly into `~/.gemini/skills/` via symlinks. + * Inject Paperclip skills directly into the effective Gemini skills home. * This avoids needing GEMINI_CLI_HOME overrides, so the CLI naturally finds - * both its auth credentials and the injected skills in the real home directory. + * both its auth credentials and the injected skills under the child HOME. */ async function ensureGeminiSkillsInjected( onLog: AdapterExecutionContext["onLog"], skillsEntries: Array<{ key: string; runtimeName: string; source: string }>, desiredSkillNames?: string[], + skillsHome = resolveGeminiSkillsHome({}), ): Promise { const desiredSet = new Set(desiredSkillNames ?? skillsEntries.map((entry) => entry.key)); const selectedEntries = skillsEntries.filter((entry) => desiredSet.has(entry.key)); if (selectedEntries.length === 0) return; - const skillsHome = geminiSkillsHome(); try { await fs.mkdir(skillsHome, { recursive: true }); } catch (err) { @@ -197,7 +194,7 @@ async function buildGeminiSkillsDir( const target = path.join(tmp, "skills"); await fs.mkdir(target, { recursive: true }); const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir); - const desiredNames = new Set(resolvePaperclipDesiredSkillNames(config, availableEntries)); + const desiredNames = new Set(resolveLegacyPaperclipDesiredSkillNames(config, availableEntries)); for (const entry of availableEntries) { if (!desiredNames.has(entry.key)) continue; if (isPaperclipSkillSourceMissing(entry)) continue; @@ -258,12 +255,13 @@ export async function execute(ctx: AdapterExecutionContext): Promise !isPaperclipSkillSourceMissing(entry)), desiredGeminiSkillNames, + resolveGeminiSkillsHome(config), ); } diff --git a/packages/adapters/gemini-local/src/server/skills.ts b/packages/adapters/gemini-local/src/server/skills.ts index 246d6eb44bd..ea2ead22981 100644 --- a/packages/adapters/gemini-local/src/server/skills.ts +++ b/packages/adapters/gemini-local/src/server/skills.ts @@ -11,7 +11,7 @@ import { ensurePaperclipSkillSymlink, readPaperclipRuntimeSkillEntries, readInstalledSkillTargets, - resolvePaperclipDesiredSkillNames, + resolveLegacyPaperclipDesiredSkillNames, } from "@paperclipai/adapter-utils/server-utils"; const __moduleDir = path.dirname(fileURLToPath(import.meta.url)); @@ -20,7 +20,7 @@ function asString(value: unknown): string | null { return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; } -function resolveGeminiSkillsHome(config: Record) { +export function resolveGeminiSkillsHome(config: Record) { const env = typeof config.env === "object" && config.env !== null && !Array.isArray(config.env) ? (config.env as Record) @@ -32,7 +32,7 @@ function resolveGeminiSkillsHome(config: Record) { async function buildGeminiSkillSnapshot(config: Record): Promise { const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir); - const desiredSkills = resolvePaperclipDesiredSkillNames(config, availableEntries); + const desiredSkills = resolveLegacyPaperclipDesiredSkillNames(config, availableEntries); const skillsHome = resolveGeminiSkillsHome(config); const installed = await readInstalledSkillTargets(skillsHome); return buildPersistentSkillSnapshot({ @@ -57,7 +57,10 @@ export async function syncGeminiSkills( desiredSkills: string[], ): Promise { const availableEntries = await readPaperclipRuntimeSkillEntries(ctx.config, __moduleDir); - const desiredSet = new Set(desiredSkills); + const desiredSet = new Set([ + ...resolveLegacyPaperclipDesiredSkillNames({}, availableEntries), + ...desiredSkills, + ]); const skillsHome = resolveGeminiSkillsHome(ctx.config); await fs.mkdir(skillsHome, { recursive: true }); const installed = await readInstalledSkillTargets(skillsHome); @@ -84,5 +87,5 @@ export function resolveGeminiDesiredSkillNames( config: Record, availableEntries: Array<{ key: string }>, ) { - return resolvePaperclipDesiredSkillNames(config, availableEntries); + return resolveLegacyPaperclipDesiredSkillNames(config, availableEntries); } diff --git a/packages/adapters/grok-local/src/server/execute.ts b/packages/adapters/grok-local/src/server/execute.ts index 72ab25d6b0f..ba2fef1a8bc 100644 --- a/packages/adapters/grok-local/src/server/execute.ts +++ b/packages/adapters/grok-local/src/server/execute.ts @@ -34,7 +34,7 @@ import { renderTemplate, renderPaperclipWakePrompt, isPaperclipRecoveryWakePayload, - resolvePaperclipDesiredSkillNames, + resolveLegacyPaperclipDesiredSkillNames, stringifyPaperclipWakePayload, refreshPaperclipWorkspaceEnvForExecution, DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE, @@ -233,7 +233,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise, ): Promise { const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir); - const desiredSkills = resolvePaperclipDesiredSkillNames(config, availableEntries); + const desiredSkills = resolveLegacyPaperclipDesiredSkillNames(config, availableEntries); return buildRuntimeMountedSkillSnapshot({ adapterType: "grok_local", availableEntries, diff --git a/packages/adapters/hermes/src/index.test.ts b/packages/adapters/hermes/src/index.test.ts index 03e88092621..fec9fdf724d 100644 --- a/packages/adapters/hermes/src/index.test.ts +++ b/packages/adapters/hermes/src/index.test.ts @@ -1,3 +1,6 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; import { expect, test } from "vitest"; import { @@ -61,3 +64,93 @@ test("Hermes adapter exposes bundled Paperclip task bridge skill", async () => { expect(snapshot?.entries.some((entry) => entry.runtimeName === "paperclip-task-bridge")).toBe(true); }); + +test("Hermes keeps the operational Paperclip skill linked after an empty replacement", async () => { + const home = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-hermes-core-skill-")); + try { + const source = path.join(home, "runtime-skills", "paperclip"); + await fs.mkdir(source, { recursive: true }); + await fs.writeFile(path.join(source, "SKILL.md"), "# Paperclip\n", "utf8"); + const adapter = createServerAdapter(); + const snapshot = await adapter.syncSkills?.({ + adapterType: "hermes_local", + agentId: "11111111-1111-4111-8111-111111111111", + companyId: "22222222-2222-4222-8222-222222222222", + config: { + env: { HOME: home }, + paperclipRuntimeSkills: [{ + key: "paperclipai/paperclip/paperclip", + runtimeName: "paperclip", + source, + }], + paperclipSkillSync: { desiredSkills: [] }, + }, + }, []); + + expect(snapshot?.desiredSkills).toContain("paperclipai/paperclip/paperclip"); + expect((await fs.lstat(path.join(home, ".hermes", "skills", "paperclip"))).isSymbolicLink()).toBe(true); + } finally { + await fs.rm(home, { recursive: true, force: true }); + } +}); + +test("Hermes rejects a conflicting operational skill target", async () => { + const home = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-hermes-core-conflict-")); + try { + const source = path.join(home, "runtime-skills", "paperclip"); + const target = path.join(home, ".hermes", "skills", "paperclip"); + await fs.mkdir(source, { recursive: true }); + await fs.writeFile(path.join(source, "SKILL.md"), "# Paperclip\n", "utf8"); + await fs.mkdir(target, { recursive: true }); + await fs.writeFile(path.join(target, "SKILL.md"), "# Conflicting skill\n", "utf8"); + const adapter = createServerAdapter(); + + await expect(adapter.syncSkills?.({ + adapterType: "hermes_local", + agentId: "11111111-1111-4111-8111-111111111111", + companyId: "22222222-2222-4222-8222-222222222222", + config: { + env: { HOME: home }, + paperclipRuntimeSkills: [{ + key: "paperclipai/paperclip/paperclip", + runtimeName: "paperclip", + source, + }], + }, + }, [])).rejects.toThrow("occupied by another installation"); + } finally { + await fs.rm(home, { recursive: true, force: true }); + } +}); + +test("Hermes rejects a live symlink owned by another operational skill", async () => { + const home = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-hermes-core-link-conflict-")); + try { + const source = path.join(home, "runtime-skills", "paperclip"); + const conflictingSource = path.join(home, "external-skills", "paperclip"); + const target = path.join(home, ".hermes", "skills", "paperclip"); + await fs.mkdir(source, { recursive: true }); + await fs.writeFile(path.join(source, "SKILL.md"), "# Paperclip\n", "utf8"); + await fs.mkdir(conflictingSource, { recursive: true }); + await fs.writeFile(path.join(conflictingSource, "SKILL.md"), "# External skill\n", "utf8"); + await fs.mkdir(path.dirname(target), { recursive: true }); + await fs.symlink(conflictingSource, target); + const adapter = createServerAdapter(); + + await expect(adapter.syncSkills?.({ + adapterType: "hermes_local", + agentId: "11111111-1111-4111-8111-111111111111", + companyId: "22222222-2222-4222-8222-222222222222", + config: { + env: { HOME: home }, + paperclipRuntimeSkills: [{ + key: "paperclipai/paperclip/paperclip", + runtimeName: "paperclip", + source, + }], + }, + }, [])).rejects.toThrow("occupied by another installation"); + } finally { + await fs.rm(home, { recursive: true, force: true }); + } +}); diff --git a/packages/adapters/hermes/src/server/execute.ts b/packages/adapters/hermes/src/server/execute.ts index 3b4c5d4bc41..98b6b518cf8 100644 --- a/packages/adapters/hermes/src/server/execute.ts +++ b/packages/adapters/hermes/src/server/execute.ts @@ -52,6 +52,7 @@ import { detectModel, resolveProvider, } from "./detect-model.js"; +import { reconcileHermesPaperclipSkills } from "./skills.js"; // --------------------------------------------------------------------------- // Config helpers @@ -350,6 +351,25 @@ export async function execute( (ctx.runtime?.sessionParams as Record | null)?.sessionId, ); + // The server adds this runtime inventory at the run boundary. Requiring the + // marker avoids touching a developer's real Hermes home in direct unit or + // library calls that did not opt into Paperclip runtime skills. + if (Object.prototype.hasOwnProperty.call(config, "paperclipRuntimeSkills")) { + try { + const selectedSkills = await reconcileHermesPaperclipSkills(config); + if (selectedSkills.length > 0) { + await ctx.onLog( + "stdout", + `[hermes] Reconciled ${selectedSkills.length} Paperclip-managed skill(s) into the Hermes skills home.\n`, + ); + } + } catch (err) { + const reason = err instanceof Error ? err.message : String(err); + await ctx.onLog("stderr", `[hermes] Cannot start without the required Paperclip-managed skills: ${reason}\n`); + throw err; + } + } + // ── Resolve provider (defense in depth) ──────────────────────────────── // Priority chain: // 1. Explicit provider in adapterConfig (user override) diff --git a/packages/adapters/hermes/src/server/skills.ts b/packages/adapters/hermes/src/server/skills.ts index 6a9ea3c0d7b..44e9453c45b 100644 --- a/packages/adapters/hermes/src/server/skills.ts +++ b/packages/adapters/hermes/src/server/skills.ts @@ -7,8 +7,11 @@ import type { AdapterSkillSnapshot, } from "@paperclipai/adapter-utils"; import { + ensurePaperclipSkillSymlink, + isPaperclipSkillSourceMissing, + readInstalledSkillTargets, readPaperclipRuntimeSkillEntries, - resolvePaperclipDesiredSkillNames, + resolveLegacyPaperclipDesiredSkillNames, } from "@paperclipai/adapter-utils/server-utils"; import { fileURLToPath } from "node:url"; @@ -132,7 +135,7 @@ async function buildHermesSkillSnapshot(config: Record): Promis // 1. Scan Paperclip-managed skills (bundled with the adapter) const paperclipEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir); - const desiredSkills = resolvePaperclipDesiredSkillNames(config, paperclipEntries); + const desiredSkills = resolveLegacyPaperclipDesiredSkillNames(config, paperclipEntries); const desiredSet = new Set(desiredSkills); const availableByKey = new Map(paperclipEntries.map((e) => [e.key, e])); @@ -209,12 +212,53 @@ export async function listHermesSkills( return buildHermesSkillSnapshot(ctx.config); } +export async function reconcileHermesPaperclipSkills( + config: Record, + requestedDesiredSkills?: string[], +): Promise { + const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir); + const desiredSkills = requestedDesiredSkills + ? Array.from(new Set([ + ...resolveLegacyPaperclipDesiredSkillNames({}, availableEntries), + ...requestedDesiredSkills, + ])) + : resolveLegacyPaperclipDesiredSkillNames(config, availableEntries); + const desiredSet = new Set(desiredSkills); + const skillsHome = path.join(resolveHermesHome(config), ".hermes", "skills"); + await fs.mkdir(skillsHome, { recursive: true }); + const installed = await readInstalledSkillTargets(skillsHome); + const availableByRuntimeName = new Map(availableEntries.map((entry) => [entry.runtimeName, entry])); + + for (const entry of availableEntries) { + if (!desiredSet.has(entry.key) || isPaperclipSkillSourceMissing(entry)) continue; + const target = path.join(skillsHome, entry.runtimeName); + await ensurePaperclipSkillSymlink(entry.source, target); + const linkedSource = await fs.readlink(target).catch(() => null); + const resolvedSource = linkedSource + ? path.resolve(path.dirname(target), linkedSource) + : null; + if (resolvedSource !== path.resolve(entry.source)) { + throw new Error( + `Cannot reconcile Hermes skill "${entry.key}" because ${target} is occupied by another installation.`, + ); + } + } + + for (const [name, installedEntry] of installed.entries()) { + const available = availableByRuntimeName.get(name); + if (!available || desiredSet.has(available.key)) continue; + if (installedEntry.targetPath !== available.source) continue; + await fs.unlink(path.join(skillsHome, name)).catch(() => {}); + } + + return desiredSkills; +} + export async function syncHermesSkills( ctx: AdapterSkillContext, - _desiredSkills: string[], + desiredSkills: string[], ): Promise { - // Hermes manages its own skill loading — sync is a no-op. - // Return the current snapshot so the UI stays in sync. + await reconcileHermesPaperclipSkills(ctx.config, desiredSkills); return buildHermesSkillSnapshot(ctx.config); } @@ -222,5 +266,5 @@ export function resolveHermesDesiredSkillNames( config: Record, availableEntries: Array<{ key: string; runtimeName?: string | null }>, ): string[] { - return resolvePaperclipDesiredSkillNames(config, availableEntries); + return resolveLegacyPaperclipDesiredSkillNames(config, availableEntries); } diff --git a/packages/adapters/kimi-local/src/server/execute.ts b/packages/adapters/kimi-local/src/server/execute.ts index 4ec307bbcad..5d16e37bde2 100644 --- a/packages/adapters/kimi-local/src/server/execute.ts +++ b/packages/adapters/kimi-local/src/server/execute.ts @@ -34,7 +34,7 @@ import { isPaperclipSkillSourceMissing, readPaperclipRuntimeSkillEntries, readPaperclipIssueWorkModeFromContext, - resolvePaperclipDesiredSkillNames, + resolveLegacyPaperclipDesiredSkillNames, parseObject, renderTemplate, renderPaperclipWakePrompt, @@ -174,7 +174,7 @@ async function buildKimiSkillsDir( const target = path.join(tmp, "skills"); await fs.mkdir(target, { recursive: true }); const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir); - const desiredNames = new Set(resolvePaperclipDesiredSkillNames(config, availableEntries)); + const desiredNames = new Set(resolveLegacyPaperclipDesiredSkillNames(config, availableEntries)); for (const entry of availableEntries) { if (!desiredNames.has(entry.key)) continue; if (isPaperclipSkillSourceMissing(entry)) continue; @@ -232,7 +232,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise) { async function buildKimiSkillSnapshot(config: Record): Promise { const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir); - const desiredSkills = resolvePaperclipDesiredSkillNames(config, availableEntries); + const desiredSkills = resolveLegacyPaperclipDesiredSkillNames(config, availableEntries); const skillsHome = resolveKimiSkillsHome(config); const installed = await readInstalledSkillTargets(skillsHome); return buildPersistentSkillSnapshot({ @@ -63,7 +63,10 @@ export async function syncKimiSkills( desiredSkills: string[], ): Promise { const availableEntries = await readPaperclipRuntimeSkillEntries(ctx.config, __moduleDir); - const desiredSet = new Set(desiredSkills); + const desiredSet = new Set([ + ...resolveLegacyPaperclipDesiredSkillNames({}, availableEntries), + ...desiredSkills, + ]); const skillsHome = resolveKimiSkillsHome(ctx.config); await fs.mkdir(skillsHome, { recursive: true }); const installed = await readInstalledSkillTargets(skillsHome); diff --git a/packages/adapters/opencode-local/src/server/execute.test.ts b/packages/adapters/opencode-local/src/server/execute.test.ts index 136cd6ca8a5..74ec4d560a7 100644 --- a/packages/adapters/opencode-local/src/server/execute.test.ts +++ b/packages/adapters/opencode-local/src/server/execute.test.ts @@ -1,15 +1,25 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; vi.mock("@paperclipai/adapter-utils/execution-target", async (importOriginal) => { const actual = (await importOriginal()) as Record; return { ...actual, runAdapterExecutionTargetProcess: vi.fn() }; }); -import { ensureRemoteOpenCodeModelConfiguredAndAvailable } from "./execute.js"; +import { ensureRemoteOpenCodeModelConfiguredAndAvailable, execute } from "./execute.js"; import { runAdapterExecutionTargetProcess } from "@paperclipai/adapter-utils/execution-target"; const runProcessMock = vi.mocked(runAdapterExecutionTargetProcess); +async function createSkillDir(root: string, name: string): Promise { + const skillDir = path.join(root, name); + await fs.mkdir(skillDir, { recursive: true }); + await fs.writeFile(path.join(skillDir, "SKILL.md"), `# ${name}\n`, "utf8"); + return skillDir; +} + function probeResult(overrides: Record) { return { exitCode: 0, @@ -23,6 +33,78 @@ function probeResult(overrides: Record) { } as never; } +describe("OpenCode local skill injection", () => { + it("injects runtime skills into the configured child HOME", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-opencode-configured-home-")); + const processHome = path.join(root, "process-home"); + const configuredHome = path.join(root, "configured-home"); + const workspace = path.join(root, "workspace"); + const commandPath = path.join(root, "opencode"); + const skillSource = await createSkillDir(path.join(root, "runtime-skills"), "paperclip"); + await fs.mkdir(workspace, { recursive: true }); + await fs.writeFile(commandPath, "#!/bin/sh\nexit 0\n", "utf8"); + await fs.chmod(commandPath, 0o755); + + const previousHome = process.env.HOME; + process.env.HOME = processHome; + runProcessMock.mockReset(); + runProcessMock.mockResolvedValueOnce(probeResult({ + stdout: JSON.stringify({ + type: "text", + sessionID: "session-configured-home", + part: { text: "done" }, + }), + })); + + try { + const result = await execute({ + runId: "run-configured-home", + agent: { + id: "agent-1", + companyId: "company-1", + name: "OpenCode Coder", + adapterType: "opencode_local", + adapterConfig: {}, + }, + runtime: { + sessionId: null, + sessionParams: null, + sessionDisplayId: null, + taskKey: null, + }, + config: { + command: commandPath, + cwd: workspace, + model: "openai/gpt-5", + env: { + HOME: configuredHome, + OPENCODE_ALLOW_ALL_MODELS: "1", + }, + paperclipRuntimeSkills: [{ + key: "paperclipai/paperclip/paperclip", + runtimeName: "paperclip", + source: skillSource, + }], + promptTemplate: "Follow the paperclip heartbeat.", + }, + context: {}, + authToken: "run-jwt-token", + onLog: async () => {}, + }); + + expect(result.exitCode).toBe(0); + const installedSkill = path.join(configuredHome, ".claude", "skills", "paperclip"); + expect((await fs.lstat(installedSkill)).isSymbolicLink()).toBe(true); + expect(await fs.realpath(installedSkill)).toBe(await fs.realpath(skillSource)); + await expect(fs.lstat(path.join(processHome, ".claude", "skills", "paperclip"))).rejects.toThrow(); + } finally { + if (previousHome === undefined) delete process.env.HOME; + else process.env.HOME = previousHome; + await fs.rm(root, { recursive: true, force: true }); + } + }); +}); + describe("ensureRemoteOpenCodeModelConfiguredAndAvailable", () => { afterEach(() => { delete process.env.OPENCODE_ALLOW_ALL_MODELS; diff --git a/packages/adapters/opencode-local/src/server/execute.ts b/packages/adapters/opencode-local/src/server/execute.ts index df3467511b6..555a1d67570 100644 --- a/packages/adapters/opencode-local/src/server/execute.ts +++ b/packages/adapters/opencode-local/src/server/execute.ts @@ -46,7 +46,7 @@ import { isPaperclipSkillSourceMissing, readPaperclipRuntimeSkillEntries, readPaperclipIssueWorkModeFromContext, - resolvePaperclipDesiredSkillNames, + resolveLegacyPaperclipDesiredSkillNames, } from "@paperclipai/adapter-utils/server-utils"; import { isOpenCodeUnknownSessionError, parseOpenCodeJsonl } from "./parse.js"; import { @@ -58,6 +58,7 @@ import { import { removeMaintainerOnlySkillSymlinks } from "@paperclipai/adapter-utils/server-utils"; import { prepareOpenCodeRuntimeConfig } from "./runtime-config.js"; import { SANDBOX_INSTALL_COMMAND } from "../index.js"; +import { resolveOpenCodeSkillsHome } from "./skills.js"; const __moduleDir = path.dirname(fileURLToPath(import.meta.url)); @@ -165,16 +166,12 @@ export async function ensureRemoteOpenCodeModelConfiguredAndAvailable(input: { } } -function claudeSkillsHome(): string { - return path.join(os.homedir(), ".claude", "skills"); -} - async function ensureOpenCodeSkillsInjected( onLog: AdapterExecutionContext["onLog"], skillsEntries: Array<{ key: string; runtimeName: string; source: string }>, desiredSkillNames?: string[], + skillsHome = resolveOpenCodeSkillsHome({}), ) { - const skillsHome = claudeSkillsHome(); await fs.mkdir(skillsHome, { recursive: true }); const desiredSet = new Set(desiredSkillNames ?? skillsEntries.map((entry) => entry.key)); const selectedEntries = skillsEntries.filter((entry) => desiredSet.has(entry.key)); @@ -212,7 +209,7 @@ async function buildOpenCodeSkillsDir(config: Record): Promise< const target = path.join(tmp, "skills"); await fs.mkdir(target, { recursive: true }); const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir); - const desiredNames = new Set(resolvePaperclipDesiredSkillNames(config, availableEntries)); + const desiredNames = new Set(resolveLegacyPaperclipDesiredSkillNames(config, availableEntries)); for (const entry of availableEntries) { if (!desiredNames.has(entry.key)) continue; if (isPaperclipSkillSourceMissing(entry)) continue; @@ -256,12 +253,13 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0 ? value.trim() : null; } -function resolveOpenCodeSkillsHome(config: Record) { +export function resolveOpenCodeSkillsHome(config: Record) { const env = typeof config.env === "object" && config.env !== null && !Array.isArray(config.env) ? (config.env as Record) @@ -32,7 +32,7 @@ function resolveOpenCodeSkillsHome(config: Record) { async function buildOpenCodeSkillSnapshot(config: Record): Promise { const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir); - const desiredSkills = resolvePaperclipDesiredSkillNames(config, availableEntries); + const desiredSkills = resolveLegacyPaperclipDesiredSkillNames(config, availableEntries); const skillsHome = resolveOpenCodeSkillsHome(config); const installed = await readInstalledSkillTargets(skillsHome); return buildPersistentSkillSnapshot({ @@ -61,7 +61,10 @@ export async function syncOpenCodeSkills( desiredSkills: string[], ): Promise { const availableEntries = await readPaperclipRuntimeSkillEntries(ctx.config, __moduleDir); - const desiredSet = new Set(desiredSkills); + const desiredSet = new Set([ + ...resolveLegacyPaperclipDesiredSkillNames({}, availableEntries), + ...desiredSkills, + ]); const skillsHome = resolveOpenCodeSkillsHome(ctx.config); await fs.mkdir(skillsHome, { recursive: true }); const installed = await readInstalledSkillTargets(skillsHome); @@ -88,5 +91,5 @@ export function resolveOpenCodeDesiredSkillNames( config: Record, availableEntries: Array<{ key: string }>, ) { - return resolvePaperclipDesiredSkillNames(config, availableEntries); + return resolveLegacyPaperclipDesiredSkillNames(config, availableEntries); } diff --git a/packages/adapters/pi-local/src/server/execute.ts b/packages/adapters/pi-local/src/server/execute.ts index 9d3e50a35b1..b601982033a 100644 --- a/packages/adapters/pi-local/src/server/execute.ts +++ b/packages/adapters/pi-local/src/server/execute.ts @@ -40,7 +40,7 @@ import { isPaperclipSkillSourceMissing, readPaperclipRuntimeSkillEntries, readPaperclipIssueWorkModeFromContext, - resolvePaperclipDesiredSkillNames, + resolveLegacyPaperclipDesiredSkillNames, removeMaintainerOnlySkillSymlinks, renderTemplate, renderPaperclipWakePrompt, @@ -127,7 +127,7 @@ async function buildPiSkillsDir(config: Record): Promise) { async function buildPiSkillSnapshot(config: Record): Promise { const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir); - const desiredSkills = resolvePaperclipDesiredSkillNames(config, availableEntries); + const desiredSkills = resolveLegacyPaperclipDesiredSkillNames(config, availableEntries); const skillsHome = resolvePiSkillsHome(config); const installed = await readInstalledSkillTargets(skillsHome); return buildPersistentSkillSnapshot({ @@ -57,7 +57,10 @@ export async function syncPiSkills( desiredSkills: string[], ): Promise { const availableEntries = await readPaperclipRuntimeSkillEntries(ctx.config, __moduleDir); - const desiredSet = new Set(desiredSkills); + const desiredSet = new Set([ + ...resolveLegacyPaperclipDesiredSkillNames({}, availableEntries), + ...desiredSkills, + ]); const skillsHome = resolvePiSkillsHome(ctx.config); await fs.mkdir(skillsHome, { recursive: true }); const installed = await readInstalledSkillTargets(skillsHome); @@ -84,5 +87,5 @@ export function resolvePiDesiredSkillNames( config: Record, availableEntries: Array<{ key: string }>, ) { - return resolvePaperclipDesiredSkillNames(config, availableEntries); + return resolveLegacyPaperclipDesiredSkillNames(config, availableEntries); } diff --git a/server/src/__tests__/claude-local-skill-sync.test.ts b/server/src/__tests__/claude-local-skill-sync.test.ts index ce5b4284707..eeec2991355 100644 --- a/server/src/__tests__/claude-local-skill-sync.test.ts +++ b/server/src/__tests__/claude-local-skill-sync.test.ts @@ -28,7 +28,7 @@ describe("claude local skill sync", () => { cleanupDirs.clear(); }); - it("reports built-in Paperclip skills as available when no explicit selection exists", async () => { + it("keeps the operational Paperclip skill configured when no explicit selection exists", async () => { const snapshot = await listClaudeSkills({ agentId: "agent-1", companyId: "company-1", @@ -38,8 +38,9 @@ describe("claude local skill sync", () => { expect(snapshot.mode).toBe("ephemeral"); expect(snapshot.supported).toBe(true); - expect(snapshot.desiredSkills).toEqual([]); - expect(snapshot.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("available"); + expect(snapshot.desiredSkills).toEqual([paperclipKey]); + expect(snapshot.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("configured"); + expect(snapshot.entries.find((entry) => entry.key === createAgentKey)?.state).toBe("available"); }); it("respects an explicit desired skill list without mutating a persistent home", async () => { diff --git a/server/src/__tests__/codex-local-execute.test.ts b/server/src/__tests__/codex-local-execute.test.ts index a9071199700..841b7d6fa7b 100644 --- a/server/src/__tests__/codex-local-execute.test.ts +++ b/server/src/__tests__/codex-local-execute.test.ts @@ -1322,7 +1322,7 @@ process.exit(1); await fs.rm(root, { recursive: true, force: true }); } }); - it("uses a worktree-isolated CODEX_HOME while preserving shared auth and config", async () => { + it("uses a worktree-isolated CODEX_HOME and mounts the operational skill by default", async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-execute-")); const workspace = path.join(root, "workspace"); const commandPath = path.join(root, "codex"); @@ -1380,9 +1380,6 @@ process.exit(1); PAPERCLIP_TEST_CAPTURE_PATH: capturePath, }, promptTemplate: "Follow the paperclip heartbeat.", - paperclipSkillSync: { - desiredSkills: ["paperclip"], - }, }, context: {}, authToken: "run-jwt-token", diff --git a/server/src/__tests__/codex-local-skill-sync.test.ts b/server/src/__tests__/codex-local-skill-sync.test.ts index 94862a0147b..17750781b8a 100644 --- a/server/src/__tests__/codex-local-skill-sync.test.ts +++ b/server/src/__tests__/codex-local-skill-sync.test.ts @@ -20,7 +20,7 @@ describe("codex local skill sync", () => { cleanupDirs.clear(); }); - it("reports configured Paperclip skills for workspace injection on the next run", async () => { + it("defaults the operational Paperclip skill for workspace injection on the next run", async () => { const codexHome = await makeTempDir("paperclip-codex-skill-sync-"); cleanupDirs.add(codexHome); @@ -32,9 +32,6 @@ describe("codex local skill sync", () => { env: { CODEX_HOME: codexHome, }, - paperclipSkillSync: { - desiredSkills: [paperclipKey], - }, }, } as const; @@ -45,6 +42,19 @@ describe("codex local skill sync", () => { expect(before.entries.find((entry) => entry.key === paperclipKey)?.detail).toContain("CODEX_HOME/skills/"); }); + it("does not apply the legacy operational skill default to the native runner", async () => { + const snapshot = await listCodexSkills({ + agentId: "agent-native", + companyId: "company-1", + adapterType: "paperclip_runner", + config: {}, + }); + + expect(snapshot.adapterType).toBe("paperclip_runner"); + expect(snapshot.desiredSkills).toEqual([]); + expect(snapshot.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("available"); + }); + it("does not persist Paperclip skills into CODEX_HOME during sync", async () => { const codexHome = await makeTempDir("paperclip-codex-skill-prune-"); cleanupDirs.add(codexHome); diff --git a/server/src/__tests__/cursor-local-execute.test.ts b/server/src/__tests__/cursor-local-execute.test.ts index e37f12b478f..ac0c8480611 100644 --- a/server/src/__tests__/cursor-local-execute.test.ts +++ b/server/src/__tests__/cursor-local-execute.test.ts @@ -252,6 +252,8 @@ describe("cursor execute", () => { it("injects company-library runtime skills into the Cursor skills home before execution", async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-cursor-execute-runtime-skill-")); + const processHome = path.join(root, "process-home"); + const configuredHome = path.join(root, "configured-home"); const workspace = path.join(root, "workspace"); const commandPath = path.join(root, "agent"); const runtimeSkillsRoot = path.join(root, "runtime-skills"); @@ -262,7 +264,7 @@ describe("cursor execute", () => { const asciiHeartDir = await createSkillDir(runtimeSkillsRoot, "ascii-heart"); const previousHome = process.env.HOME; - process.env.HOME = root; + process.env.HOME = processHome; try { const result = await execute({ @@ -284,6 +286,7 @@ describe("cursor execute", () => { command: commandPath, cwd: workspace, model: "auto", + env: { HOME: configuredHome }, paperclipRuntimeSkills: [ { name: "paperclip", @@ -307,10 +310,12 @@ describe("cursor execute", () => { expect(result.exitCode).toBe(0); expect(result.errorMessage).toBeNull(); - expect((await fs.lstat(path.join(root, ".cursor", "skills", "ascii-heart"))).isSymbolicLink()).toBe(true); - expect(await fs.realpath(path.join(root, ".cursor", "skills", "ascii-heart"))).toBe( + const installedSkill = path.join(configuredHome, ".cursor", "skills", "ascii-heart"); + expect((await fs.lstat(installedSkill)).isSymbolicLink()).toBe(true); + expect(await fs.realpath(installedSkill)).toBe( await fs.realpath(asciiHeartDir), ); + await expect(fs.lstat(path.join(processHome, ".cursor", "skills", "ascii-heart"))).rejects.toThrow(); } finally { if (previousHome === undefined) { delete process.env.HOME; diff --git a/server/src/__tests__/cursor-local-skill-sync.test.ts b/server/src/__tests__/cursor-local-skill-sync.test.ts index 3660f8b2035..5f188fa0222 100644 --- a/server/src/__tests__/cursor-local-skill-sync.test.ts +++ b/server/src/__tests__/cursor-local-skill-sync.test.ts @@ -27,7 +27,7 @@ describe("cursor local skill sync", () => { cleanupDirs.clear(); }); - it("reports configured Paperclip skills and installs them into the Cursor skills home", async () => { + it("defaults and installs the operational Paperclip skill in the Cursor skills home", async () => { const home = await makeTempDir("paperclip-cursor-skill-sync-"); cleanupDirs.add(home); @@ -39,9 +39,6 @@ describe("cursor local skill sync", () => { env: { HOME: home, }, - paperclipSkillSync: { - desiredSkills: [paperclipKey], - }, }, } as const; @@ -55,6 +52,25 @@ describe("cursor local skill sync", () => { expect((await fs.lstat(path.join(home, ".cursor", "skills", "paperclip"))).isSymbolicLink()).toBe(true); }); + it("keeps the operational skill installed after an explicit empty replacement", async () => { + const home = await makeTempDir("paperclip-cursor-required-skill-"); + cleanupDirs.add(home); + + const snapshot = await syncCursorSkills({ + agentId: "agent-required", + companyId: "company-1", + adapterType: "cursor", + config: { + env: { HOME: home }, + paperclipSkillSync: { desiredSkills: [] }, + }, + }, []); + + expect(snapshot.desiredSkills).toEqual([paperclipKey]); + expect(snapshot.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("installed"); + expect((await fs.lstat(path.join(home, ".cursor", "skills", "paperclip"))).isSymbolicLink()).toBe(true); + }); + it("recognizes company-library runtime skills supplied outside the bundled Paperclip directory", async () => { const home = await makeTempDir("paperclip-cursor-runtime-skills-home-"); const runtimeSkills = await makeTempDir("paperclip-cursor-runtime-skills-src-"); diff --git a/server/src/__tests__/gemini-local-execute.test.ts b/server/src/__tests__/gemini-local-execute.test.ts index 83924852a57..80fc5d8eb85 100644 --- a/server/src/__tests__/gemini-local-execute.test.ts +++ b/server/src/__tests__/gemini-local-execute.test.ts @@ -73,7 +73,72 @@ type CapturePayload = { paperclipEnvKeys: string[]; }; +async function createSkillDir(root: string, name: string): Promise { + const skillDir = path.join(root, name); + await fs.mkdir(skillDir, { recursive: true }); + await fs.writeFile(path.join(skillDir, "SKILL.md"), `# ${name}\n`, "utf8"); + return skillDir; +} + describe("gemini execute", () => { + it("injects runtime skills into the configured child HOME", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-gemini-configured-home-")); + const processHome = path.join(root, "process-home"); + const configuredHome = path.join(root, "configured-home"); + const workspace = path.join(root, "workspace"); + const commandPath = path.join(root, "gemini"); + const skillSource = await createSkillDir(path.join(root, "runtime-skills"), "paperclip"); + await fs.mkdir(workspace, { recursive: true }); + await writeFakeGeminiCommand(commandPath); + + const previousHome = process.env.HOME; + process.env.HOME = processHome; + + try { + const result = await execute({ + runId: "run-configured-home", + agent: { + id: "agent-1", + companyId: "company-1", + name: "Gemini Coder", + adapterType: "gemini_local", + adapterConfig: { engine: "cli" }, + }, + runtime: { + sessionId: null, + sessionParams: null, + sessionDisplayId: null, + taskKey: null, + }, + config: { + engine: "cli", + command: commandPath, + cwd: workspace, + env: { HOME: configuredHome }, + paperclipRuntimeSkills: [{ + key: "paperclipai/paperclip/paperclip", + runtimeName: "paperclip", + source: skillSource, + }], + promptTemplate: "Follow the paperclip heartbeat.", + }, + context: {}, + authToken: "run-jwt-token", + onLog: async () => {}, + }); + + expect(result.exitCode).toBe(0); + const installedSkill = path.join(configuredHome, ".gemini", "skills", "paperclip"); + expect((await fs.lstat(installedSkill)).isSymbolicLink()).toBe(true); + expect(await fs.realpath(installedSkill)).toBe(await fs.realpath(skillSource)); + await expect(fs.lstat(path.join(processHome, ".gemini", "skills", "paperclip"))).rejects.toThrow(); + } finally { + if (previousHome === undefined) delete process.env.HOME; + else process.env.HOME = previousHome; + await fs.rm(root, { recursive: true, force: true }); + } + }); + it("passes prompt via --prompt and injects paperclip env vars", async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-gemini-execute-")); const workspace = path.join(root, "workspace"); diff --git a/server/src/__tests__/gemini-local-skill-sync.test.ts b/server/src/__tests__/gemini-local-skill-sync.test.ts index 646ac45a162..c5a5ec2958c 100644 --- a/server/src/__tests__/gemini-local-skill-sync.test.ts +++ b/server/src/__tests__/gemini-local-skill-sync.test.ts @@ -20,7 +20,7 @@ describe("gemini local skill sync", () => { cleanupDirs.clear(); }); - it("reports configured Paperclip skills and installs them into the Gemini skills home", async () => { + it("defaults and installs the operational Paperclip skill in the Gemini skills home", async () => { const home = await makeTempDir("paperclip-gemini-skill-sync-"); cleanupDirs.add(home); @@ -32,9 +32,6 @@ describe("gemini local skill sync", () => { env: { HOME: home, }, - paperclipSkillSync: { - desiredSkills: [paperclipKey], - }, }, } as const; diff --git a/server/src/__tests__/grok-local-skill-sync.test.ts b/server/src/__tests__/grok-local-skill-sync.test.ts index 64c0fe769df..8c2aa0e927a 100644 --- a/server/src/__tests__/grok-local-skill-sync.test.ts +++ b/server/src/__tests__/grok-local-skill-sync.test.ts @@ -7,16 +7,12 @@ import { describe("grok local skill sync", () => { const paperclipKey = "paperclipai/paperclip/paperclip"; - it("reports Grok skills as ephemeral workspace-mounted state", async () => { + it("defaults the operational Paperclip skill as ephemeral workspace-mounted state", async () => { const snapshot = await listGrokSkills({ agentId: "agent-1", companyId: "company-1", adapterType: "grok_local", - config: { - paperclipSkillSync: { - desiredSkills: [paperclipKey], - }, - }, + config: {}, }); expect(snapshot.adapterType).toBe("grok_local"); diff --git a/server/src/__tests__/kimi-local-skill-sync.test.ts b/server/src/__tests__/kimi-local-skill-sync.test.ts index 7bb904252a4..f88f144f74c 100644 --- a/server/src/__tests__/kimi-local-skill-sync.test.ts +++ b/server/src/__tests__/kimi-local-skill-sync.test.ts @@ -20,7 +20,7 @@ describe("kimi local skill sync", () => { cleanupDirs.clear(); }); - it("reports configured Paperclip skills and installs them into the Kimi skills home", async () => { + it("defaults and installs the operational Paperclip skill in the Kimi skills home", async () => { const kimiCodeHome = await makeTempDir("paperclip-kimi-skill-sync-"); cleanupDirs.add(kimiCodeHome); @@ -32,9 +32,6 @@ describe("kimi local skill sync", () => { env: { KIMI_CODE_HOME: kimiCodeHome, }, - paperclipSkillSync: { - desiredSkills: [paperclipKey], - }, }, } as const; diff --git a/server/src/__tests__/opencode-local-skill-sync.test.ts b/server/src/__tests__/opencode-local-skill-sync.test.ts index 48d97523c90..c07c5940c50 100644 --- a/server/src/__tests__/opencode-local-skill-sync.test.ts +++ b/server/src/__tests__/opencode-local-skill-sync.test.ts @@ -20,7 +20,7 @@ describe("opencode local skill sync", () => { cleanupDirs.clear(); }); - it("reports configured Paperclip skills and installs them into the shared Claude/OpenCode skills home", async () => { + it("defaults and installs the operational Paperclip skill in the shared Claude/OpenCode skills home", async () => { const home = await makeTempDir("paperclip-opencode-skill-sync-"); cleanupDirs.add(home); @@ -32,9 +32,6 @@ describe("opencode local skill sync", () => { env: { HOME: home, }, - paperclipSkillSync: { - desiredSkills: [paperclipKey], - }, }, } as const; diff --git a/server/src/__tests__/pi-local-execute.test.ts b/server/src/__tests__/pi-local-execute.test.ts index f9fe2d3ff72..265a26800f3 100644 --- a/server/src/__tests__/pi-local-execute.test.ts +++ b/server/src/__tests__/pi-local-execute.test.ts @@ -188,8 +188,8 @@ describe("pi_local execute", () => { cwd: workspace, model: "google/gemini-3-flash-preview", promptTemplate: "Keep working.", - // No explicit paperclipSkillSync preference → - // resolvePaperclipDesiredSkillNames returns [] → skill is not injected. + // The implicit legacy default applies only to the canonical Paperclip + // operational skill, so this unrelated skill remains unselected. paperclipRuntimeSkills: [ { key: "not-injected", runtimeName: "not-injected", source: nonInjectedSkillDir }, ], diff --git a/server/src/__tests__/pi-local-skill-sync.test.ts b/server/src/__tests__/pi-local-skill-sync.test.ts index a49b202e32c..4019ae5de4a 100644 --- a/server/src/__tests__/pi-local-skill-sync.test.ts +++ b/server/src/__tests__/pi-local-skill-sync.test.ts @@ -20,7 +20,7 @@ describe("pi local skill sync", () => { cleanupDirs.clear(); }); - it("reports configured Paperclip skills and installs them into the Pi skills home", async () => { + it("defaults and installs the operational Paperclip skill in the Pi skills home", async () => { const home = await makeTempDir("paperclip-pi-skill-sync-"); cleanupDirs.add(home); @@ -32,9 +32,6 @@ describe("pi local skill sync", () => { env: { HOME: home, }, - paperclipSkillSync: { - desiredSkills: [paperclipKey], - }, }, } as const; diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index fbdc97b0aea..cb510ce4d31 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -2227,8 +2227,9 @@ export function agentRoutes( // The default CEO instructions assume the core paperclip skills (board // coordination, planning, hiring, memory). Union them into every // skills-capable CEO hire/create so a fresh CEO never starts with an empty - // desired-skill set that contradicts its own instructions. Callers can still - // remove any of them afterwards via the per-agent skills sync. + // desired-skill set that contradicts its own instructions. Optional role + // skills remain removable afterwards. Legacy adapters separately guarantee + // the Paperclip operational skill as a runtime invariant. function defaultRoleSkillSelections( role: string | null | undefined, adapterType: string, From 198fc8b281dbf7ad9567c445129745e09c249dc3 Mon Sep 17 00:00:00 2001 From: Nicky Leach Date: Wed, 26 Aug 2026 08:30:42 -0700 Subject: [PATCH 27/32] fix(adapter-utils): harden the referenced-project ignore scan (#12214) ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Agent adapters stage referenced projects into controlled sandboxes > - The ignore scan must preserve exact Git path bytes and fail closed on unsafe input > - Unbounded ignored-path data and raw diagnostics can harm resource use or expose host details > - This pull request adds exact path parsing, input bounds, fixed failure categories, and saturation-only retry > - The benefit is safer and more predictable referenced-project staging ## Linked Issues or Issue Description **What happened?** The referenced-project ignore scan trimmed NUL-delimited Git paths. It also accepted a large ignored-path set and exposed raw failure details through staging errors and warnings. **Expected behavior** The scan must preserve leading and trailing whitespace in Git paths. It must reject oversized ignored-path data and expose only fixed failure categories. **Steps to reproduce** 1. Run the referenced-project ignore scan with paths that start or end with whitespace. 2. Provide more than 10,000 ignored entries or more than 2 MiB of path bytes. 3. Trigger a scan failure and inspect the reported reason. **Paperclip version or commit** d560bc2ae29e3a9d75058b868410e9fb9e120368 **Deployment mode** Built from source. **Installation method** Built from source. **Agent adapter(s) involved** Not adapter-specific. **Database mode** Not database-related. **Additional context** This change covers the overlay diff, untracked, deleted, and ignored Git paths. It also retries only typed scheduler saturation failures. ## What Changed - Preserve all bytes in NUL-delimited Git path records. - Bound ignored-entry count and total UTF-8 path bytes during parsing. - Redact scan failure details to three fixed reason categories. - Retry only the typed scheduler saturation error, with three total attempts and 1 second then 2 second waits. - Add tests for path whitespace, limits, diagnostics, retry behavior, and scheduler code parity. ## Verification - `npx tsc --noEmit` in `packages/adapter-utils` passed. - `npx vitest run packages/adapter-utils` passed with 977 tests and 4 skipped. - Continuous integration must run the server suite and the full repository gates. ## Risks The scan now rejects ignored-path data above fixed limits. Saturation retries add up to 3 seconds before a final failure. The resolver still fails closed for all other errors. ## Model Used OpenAI GPT-5. The model used tool calls, code inspection, and command execution. The exact context window and reasoning mode are not exposed by the runtime. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- .../src/acpx-engine/execute.test.ts | 32 ++ .../src/git-workspace-sync.test.ts | 192 ++++++++++ .../adapter-utils/src/git-workspace-sync.ts | 143 +++++++- .../src/remote-managed-runtime.test.ts | 60 ++++ .../src/sandbox-managed-runtime.test.ts | 335 +++++++++++++++++- .../src/sandbox-managed-runtime.ts | 113 +++++- .../workspace-git-operation-scheduler.test.ts | 11 + 7 files changed, 847 insertions(+), 39 deletions(-) diff --git a/packages/adapter-utils/src/acpx-engine/execute.test.ts b/packages/adapter-utils/src/acpx-engine/execute.test.ts index 63c5fe47896..5575b37b462 100644 --- a/packages/adapter-utils/src/acpx-engine/execute.test.ts +++ b/packages/adapter-utils/src/acpx-engine/execute.test.ts @@ -38,6 +38,7 @@ import { } from "./execute.js"; import { runChildProcess } from "../server-utils.js"; import { setExpensiveWorkspaceGitExecutor } from "../git-workspace-sync.js"; +import { resolveReferencedSourceIgnore } from "../sandbox-managed-runtime.js"; import { getActiveStepContext, runWithRuntimeParent, @@ -1591,6 +1592,37 @@ describe("shared ACPX engine runtime behavior", () => { expect(signature).toBe("unreadable:git status timed out"); }); + it("never leaks a raw absolute path into the signature, even when the underlying scan embedded one", async () => { + const root = await makeTempRoot(); + const localPath = path.join(root, "does-not-exist"); + + // A raw toplevel string that makes `localPath` a non-descendant, carrying + // a sensitive absolute path — exactly the shape a caught Git diagnostic + // could embed. `resolveReferencedSourceIgnore` is the single choke point + // that must reduce it to the fixed category before the signature (which + // embeds `reason` verbatim as `unreadable:${reason}`) ever sees it. + const sensitivePath = "/home/alice/project"; + let ignoreResolution; + try { + setExpensiveWorkspaceGitExecutor(async (input) => { + if (input.operation === "referenced_source.toplevel") { + return { stdout: `${sensitivePath}\n`, stderr: "" }; + } + return { stdout: "", stderr: "" }; + }); + ignoreResolution = await resolveReferencedSourceIgnore(localPath); + } finally { + setExpensiveWorkspaceGitExecutor(null); + } + expect(ignoreResolution.kind).toBe("failed"); + + const signature = await referencedSourceContentSignature(localPath, ignoreResolution); + + expect(signature).toBe("unreadable:git-toplevel-not-descendant"); + expect(signature).not.toContain(sensitivePath); + expect(signature).not.toContain(localPath); + }); + it("skips a Git-ignored file (exact match) so its content never affects the signature", async () => { const root = await makeTempRoot(); const localPath = path.join(root, "project"); diff --git a/packages/adapter-utils/src/git-workspace-sync.test.ts b/packages/adapter-utils/src/git-workspace-sync.test.ts index 035d3203eb2..31ac4590a80 100644 --- a/packages/adapter-utils/src/git-workspace-sync.test.ts +++ b/packages/adapter-utils/src/git-workspace-sync.test.ts @@ -14,7 +14,10 @@ import { integrateImportedGitHead, isMissingGitPrerequisiteError, readGitWorkspaceSnapshot, + ReferencedSourceIgnoreScanLimitExceededError, readReferencedSourceGitIgnoredPaths, + REFERENCED_SOURCE_IGNORE_MAX_ENTRY_COUNT, + REFERENCED_SOURCE_IGNORE_MAX_TOTAL_BYTES, runLocalGit, sanitizeGitRemoteUrl, setExpensiveWorkspaceGitExecutor, @@ -64,6 +67,44 @@ describe("git workspace sync", () => { ]); }); + it("keeps every filename byte for a padded name in each of the four anchor lanes", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-git-anchor-whitespace-")); + cleanupDirs.push(rootDir); + const repo = await createRepo(rootDir); + + // Deleted lane: commit the file first (in isolation, before anything else + // is staged), then remove it from the work tree. + const deletedName = " deleted padded "; + await writeFile(path.join(repo, deletedName), "deleted\n", "utf8"); + await git(repo, ["add", deletedName]); + await git(repo, ["commit", "-qm", "add deleted padded"]); + await rm(path.join(repo, deletedName)); + + // Overlay lane, staged-new half: `git diff --diff-filter=ACMRTUXB HEAD` + // reports a staged-but-uncommitted file as added. + const overlayName = " overlay padded "; + await writeFile(path.join(repo, overlayName), "overlay\n", "utf8"); + await git(repo, ["add", overlayName]); + + // Overlay lane, untracked half: `ls-files --others --exclude-standard`. + const untrackedName = " untracked padded "; + await writeFile(path.join(repo, untrackedName), "untracked\n", "utf8"); + + // Ignored lane: a double-wildcard pattern avoids the separate rule that + // Git trims an unescaped trailing space in a .gitignore PATTERN itself; + // the padding under test lives in the matched FILE name. + const ignoredName = " ignored padded "; + await writeFile(path.join(repo, ".gitignore"), "*ignored*padded*\n", "utf8"); + await writeFile(path.join(repo, ignoredName), "ignored\n", "utf8"); + + const snapshot = await readGitWorkspaceSnapshot(repo); + + expect(snapshot?.overlayPaths).toContain(overlayName); + expect(snapshot?.overlayPaths).toContain(untrackedName); + expect(snapshot?.deletedPaths).toContain(deletedName); + expect(snapshot?.ignoredPaths).toContain(ignoredName); + }); + async function createRepo(rootDir: string): Promise { const repo = path.join(rootDir, "repo"); await mkdir(repo, { recursive: true }); @@ -576,6 +617,157 @@ describe("git workspace sync", () => { expect(scan?.ignoredPaths).toEqual([paddedName]); }); + it("fails closed when the parsed ignored-entry count exceeds the bound", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-referenced-bound-count-")); + cleanupDirs.push(rootDir); + const repo = await createRepo(rootDir); + // Synthesize the `git ls-files --others --ignored -z` output directly, + // rather than creating ten thousand real files, by intercepting the + // scan at the executor seam. The parser must reject this before it + // sorts or re-relativizes the list. + const overLimitCount = REFERENCED_SOURCE_IGNORE_MAX_ENTRY_COUNT + 1; + const syntheticIgnored = `${Array.from({ length: overLimitCount }, (_, index) => `entry-${index}`).join("\0")}\0`; + setExpensiveWorkspaceGitExecutor(async (input) => { + if (input.operation === "referenced_source.ignored_files") { + return { stdout: syntheticIgnored, stderr: "" }; + } + return await runLocalGit(input.localDir, [...input.args], { + timeout: input.timeout, + maxBuffer: input.maxBuffer, + env: input.env, + }); + }); + + await expect(readReferencedSourceGitIgnoredPaths(repo)).rejects.toBeInstanceOf( + ReferencedSourceIgnoreScanLimitExceededError, + ); + }); + + it("fails closed when the summed UTF-8 byte size of ignored paths exceeds the bound", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-referenced-bound-bytes-")); + cleanupDirs.push(rootDir); + const repo = await createRepo(rootDir); + // One entry alone exceeds the byte bound, well under the entry-count bound. + const hugeEntry = "a".repeat(REFERENCED_SOURCE_IGNORE_MAX_TOTAL_BYTES + 1); + const syntheticIgnored = `${hugeEntry}\0`; + setExpensiveWorkspaceGitExecutor(async (input) => { + if (input.operation === "referenced_source.ignored_files") { + return { stdout: syntheticIgnored, stderr: "" }; + } + return await runLocalGit(input.localDir, [...input.args], { + timeout: input.timeout, + maxBuffer: input.maxBuffer, + env: input.env, + }); + }); + + await expect(readReferencedSourceGitIgnoredPaths(repo)).rejects.toBeInstanceOf( + ReferencedSourceIgnoreScanLimitExceededError, + ); + }); + + it("fails closed on the byte bound while it is still accumulating, before it would ever reach a later entry-count breach", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-referenced-bound-order-")); + cleanupDirs.push(rootDir); + const repo = await createRepo(rootDir); + // Three entries alone cross the byte bound. Many more small entries + // follow, so the FULL response also carries more than the entry-count + // bound. A parser that fully builds the list before checking either + // bound (post-parse) would report the entry-count breach, because it + // checks that bound first against the whole materialized list. A + // parser that checks both bounds while the list accumulates rejects on + // the byte bound instead, the moment the third entry crosses it, well + // before the count bound is ever reached. + const oversizedEntry = "a".repeat(Math.ceil(REFERENCED_SOURCE_IGNORE_MAX_TOTAL_BYTES / 2) + 1); + const bigEntries = Array.from({ length: 3 }, (_, index) => `${oversizedEntry}-${index}`); + const trailingEntries = Array.from( + { length: REFERENCED_SOURCE_IGNORE_MAX_ENTRY_COUNT + 10 }, + (_, index) => `trailing-${index}`, + ); + const syntheticIgnored = `${[...bigEntries, ...trailingEntries].join("\0")}\0`; + setExpensiveWorkspaceGitExecutor(async (input) => { + if (input.operation === "referenced_source.ignored_files") { + return { stdout: syntheticIgnored, stderr: "" }; + } + return await runLocalGit(input.localDir, [...input.args], { + timeout: input.timeout, + maxBuffer: input.maxBuffer, + env: input.env, + }); + }); + + await expect(readReferencedSourceGitIgnoredPaths(repo)).rejects.toThrow(/UTF-8 bytes/); + }); + + it("bounds the raw command-output allowance to the ignore-scan limits, not the general-purpose full-tree ceiling", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-referenced-raw-buffer-")); + cleanupDirs.push(rootDir); + const repo = await createRepo(rootDir); + let observedMaxBuffer: number | undefined; + setExpensiveWorkspaceGitExecutor(async (input) => { + if (input.operation === "referenced_source.ignored_files") { + observedMaxBuffer = input.maxBuffer; + } + return await runLocalGit(input.localDir, [...input.args], { + timeout: input.timeout, + maxBuffer: input.maxBuffer, + env: input.env, + }); + }); + + await readReferencedSourceGitIgnoredPaths(repo); + + // Enough headroom for a scan within bounds to complete, but a small + // multiple of the byte bound — not the far larger allowance the + // anchor workspace's general-purpose full-tree reads use. + expect(observedMaxBuffer).toBeGreaterThan(REFERENCED_SOURCE_IGNORE_MAX_TOTAL_BYTES); + expect(observedMaxBuffer).toBeLessThan(16 * 1024 * 1024); + }); + + it("does not fail closed on a huge amount of unrelated tracked-change and untracked noise, when the ignored set itself stays in bounds", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-referenced-mixed-status-")); + cleanupDirs.push(rootDir); + const repo = await createRepo(rootDir); + await writeFile(path.join(repo, ".gitignore"), "secret.env\n", "utf8"); + await writeFile(path.join(repo, "secret.env"), "TOKEN=abc\n", "utf8"); + + // Many long-named, untracked, NOT-ignored files at the repository root. + // `git status` reports one record per file (root-level files are never + // collapsed the way an entirely untracked directory is), so this alone + // makes the raw `git status --ignored` response exceed the raw buffer + // bound this scan used to apply to the WHOLE response, well before the + // parser ever got to discard these non-ignored records. The ignored set + // above stays a single small entry throughout. + const noiseNameLength = 220; + const noiseFileCount = 30_000; + const noiseNames = Array.from( + { length: noiseFileCount }, + (_, index) => `${"n".repeat(noiseNameLength - 6)}${String(index).padStart(6, "0")}`, + ); + const writeConcurrency = 200; + for (let start = 0; start < noiseNames.length; start += writeConcurrency) { + const batch = noiseNames.slice(start, start + writeConcurrency); + await Promise.all(batch.map((name) => writeFile(path.join(repo, name), "", "utf8"))); + } + + // Confirm this test actually reproduces the reported defect precondition: + // the raw `git status --ignored` response for this repository state is + // larger than the 4 MiB raw buffer bound the scan used to apply to the + // whole response, not just to the declared ignored-set limits. A large + // explicit maxBuffer is required here only to observe that raw size; + // the scan under test never issues this command. + const rawStatusResult = await runLocalGit( + repo, + ["status", "--ignored", "--porcelain=v1", "-z", "--untracked-files=normal"], + { maxBuffer: 16 * 1024 * 1024 }, + ); + expect(Buffer.byteLength(rawStatusResult.stdout, "utf8")).toBeGreaterThan(REFERENCED_SOURCE_IGNORE_MAX_TOTAL_BYTES * 2); + + const scan = await readReferencedSourceGitIgnoredPaths(repo); + + expect(scan?.ignoredPaths).toEqual(["secret.env"]); + }); + it("routes both scan commands through the registered scheduler instead of spawning git directly", async () => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-referenced-scheduler-")); cleanupDirs.push(rootDir); diff --git a/packages/adapter-utils/src/git-workspace-sync.ts b/packages/adapter-utils/src/git-workspace-sync.ts index b8a354c02cf..0d80e76cd4d 100644 --- a/packages/adapter-utils/src/git-workspace-sync.ts +++ b/packages/adapter-utils/src/git-workspace-sync.ts @@ -40,6 +40,19 @@ export type ExpensiveWorkspaceGitExecutor = ( let expensiveWorkspaceGitExecutor: ExpensiveWorkspaceGitExecutor | null = null; +/** + * The workspace Git scan scheduler's typed code for a saturated queue + * (`server/src/services/workspace-git-operation-scheduler.ts`, + * `WORKSPACE_GIT_SCAN_ERROR_CODES.saturated`). Declared again here because + * `adapter-utils` cannot import from `server` (the reverse direction is + * allowed, not this one); `server` carries a test that asserts the two + * literals stay equal. `resolveReferencedSourceIgnore` in + * `sandbox-managed-runtime.ts` reads this code off a caught error's `code` + * property, never off its message text, to retry only a saturated queue and + * fail closed on every other Git scan error. + */ +export const WORKSPACE_GIT_SCAN_SATURATED_CODE = "workspace_git_scan_saturated"; + /** * Lets a host process apply its process-wide admission policy to the adapter * package's full-tree Git walks. Standalone adapter-utils consumers retain the @@ -161,7 +174,16 @@ export async function readGitWorkspaceSnapshot(localDir: string): Promise value.split("\0").map((entry) => entry.trim()).filter(Boolean); + // `-z` already delimits each record with a NUL byte, so a leading or + // trailing space in a record is part of the path itself, not padding to + // remove — trimming it would resolve to a path that does not exist. A + // length check finds the one genuinely empty record `-z` appends after + // the last NUL, without eating a real path's own leading or trailing + // whitespace. This applies to all four NUL-delimited outputs below (the + // overlay diff, the untracked list, the deleted list, and the ignored + // list); `branchName` and `headCommit` come from non-`-z` commands and + // keep their own `.trim()` above and below, which is safe. + const splitNul = (value: string) => value.split("\0").filter((entry) => entry.length > 0); return { headCommit: headCommitResult.stdout.trim(), branchName: branchName && branchName !== "HEAD" ? branchName : null, @@ -180,7 +202,7 @@ export async function readGitWorkspaceSnapshot(localDir: string): Promise entry.length > 0) - .filter((entry) => entry.startsWith("!! ")) - .map((entry) => entry.slice(3).replace(/\/+$/, "")) - .filter((entry) => entry.length > 0) - .sort((left, right) => left.localeCompare(right)); + + // Read one NUL-delimited record at a time and enforce both bounds while the + // ignored-entry list accumulates, instead of splitting and mapping the + // whole response into a list first and only then checking its size. A + // pathologically large ignore set (a huge repository, or one crafted to + // hold many ignored entries) must fail closed the moment it breaches a + // bound, without this scan first retaining and transforming the full + // oversized response. + // + // Do not trim each entry: `-z` already delimits entries with a NUL byte, so + // a leading or trailing space in an entry is part of the path itself, not + // padding to remove. A length check finds the one genuinely empty record + // `-z` appends after the last NUL, without eating a real path's own + // leading or trailing whitespace. + const rawIgnored = ignoredResult.stdout; + const parsedIgnoredEntries: string[] = []; + let totalIgnoredBytes = 0; + let recordStart = 0; + while (recordStart < rawIgnored.length) { + const nulIndex = rawIgnored.indexOf("\0", recordStart); + const recordEnd = nulIndex === -1 ? rawIgnored.length : nulIndex; + const record = rawIgnored.slice(recordStart, recordEnd); + recordStart = nulIndex === -1 ? rawIgnored.length : nulIndex + 1; + + const entry = record.replace(/\/+$/, ""); + if (entry.length === 0) { + continue; + } + + if (parsedIgnoredEntries.length + 1 > REFERENCED_SOURCE_IGNORE_MAX_ENTRY_COUNT) { + throw new ReferencedSourceIgnoreScanLimitExceededError( + `referenced project ignore scan found more than ${REFERENCED_SOURCE_IGNORE_MAX_ENTRY_COUNT} ignored entries`, + ); + } + totalIgnoredBytes += Buffer.byteLength(entry, "utf8"); + if (totalIgnoredBytes > REFERENCED_SOURCE_IGNORE_MAX_TOTAL_BYTES) { + throw new ReferencedSourceIgnoreScanLimitExceededError( + `referenced project ignore scan exceeded ${REFERENCED_SOURCE_IGNORE_MAX_TOTAL_BYTES} UTF-8 bytes of ignored paths`, + ); + } + parsedIgnoredEntries.push(entry); + } + + // The list is bounded by both checks above, so sorting and re-relativizing + // it here never costs more than the accepted bounds allow. + const ignoredPaths = parsedIgnoredEntries.sort((left, right) => left.localeCompare(right)); return { toplevel, ignoredPaths }; } diff --git a/packages/adapter-utils/src/remote-managed-runtime.test.ts b/packages/adapter-utils/src/remote-managed-runtime.test.ts index 35f41348303..89659bb643e 100644 --- a/packages/adapter-utils/src/remote-managed-runtime.test.ts +++ b/packages/adapter-utils/src/remote-managed-runtime.test.ts @@ -26,6 +26,8 @@ vi.mock("./ssh.js", () => ({ })); import { prepareRemoteManagedRuntime } from "./remote-managed-runtime.js"; +import { resolveReferencedSourceIgnore } from "./sandbox-managed-runtime.js"; +import { setExpensiveWorkspaceGitExecutor } from "./git-workspace-sync.js"; describe("remote managed runtime", () => { const cleanupDirs: string[] = []; @@ -262,4 +264,62 @@ describe("remote managed runtime", () => { expect(Object.keys(prepared.additionalSourceDirs)).toEqual(["healthy"]); expect(syncDirectoryToSsh).not.toHaveBeenCalledWith(expect.objectContaining({ localDir: failedDir })); }); + + it("never leaks a raw absolute path into the remote per-project staging warning", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-remote-runtime-redact-")); + cleanupDirs.push(rootDir); + const workspaceDir = path.join(rootDir, "workspace"); + const failedDir = path.join(rootDir, "referenced-failed"); + await mkdir(workspaceDir, { recursive: true }); + await mkdir(failedDir, { recursive: true }); + + // A raw toplevel string that makes `failedDir` a non-descendant, carrying + // a sensitive absolute path — exactly the shape a caught Git diagnostic + // could embed. `resolveReferencedSourceIgnore` is the single choke point + // that must reduce it to the fixed category before anything downstream + // (here, the remote lane's warning) ever sees it. + const sensitivePath = "/srv/alice/project"; + let ignoreResolution; + try { + setExpensiveWorkspaceGitExecutor(async (input) => { + if (input.operation === "referenced_source.toplevel") { + return { stdout: `${sensitivePath}\n`, stderr: "" }; + } + return { stdout: "", stderr: "" }; + }); + ignoreResolution = await resolveReferencedSourceIgnore(failedDir); + } finally { + setExpensiveWorkspaceGitExecutor(null); + } + expect(ignoreResolution.kind).toBe("failed"); + + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined); + try { + await prepareRemoteManagedRuntime({ + spec: { + host: "127.0.0.1", + port: 2222, + username: "fixture", + remoteWorkspacePath: "/app", + remoteCwd: "/app", + privateKey: "PRIVATE KEY", + knownHosts: "KNOWN HOSTS", + strictHostKeyChecking: true, + }, + runId: "run-redact", + adapterKey: "codex", + workspaceLocalDir: workspaceDir, + workspaceRemoteDir: "/app", + syncWorkspace: false, + additionalSources: [{ localPath: failedDir, projectId: "failed", ignoreResolution }], + }); + + const warnedText = warnSpy.mock.calls.map((call) => call.join(" ")).join("\n"); + expect(warnedText).toContain("failed"); + expect(warnedText).not.toContain(sensitivePath); + expect(warnedText).not.toContain(failedDir); + } finally { + warnSpy.mockRestore(); + } + }); }); diff --git a/packages/adapter-utils/src/sandbox-managed-runtime.test.ts b/packages/adapter-utils/src/sandbox-managed-runtime.test.ts index a889f6b34fd..be36585a1eb 100644 --- a/packages/adapter-utils/src/sandbox-managed-runtime.test.ts +++ b/packages/adapter-utils/src/sandbox-managed-runtime.test.ts @@ -6,13 +6,19 @@ import path from "node:path"; import { execFile as execFileCallback, spawn } from "node:child_process"; import { promisify } from "node:util"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { resetLocalGitIndexToHead } from "./git-workspace-sync.js"; +import { + resetLocalGitIndexToHead, + runLocalGit, + setExpensiveWorkspaceGitExecutor, + WORKSPACE_GIT_SCAN_SATURATED_CODE, +} from "./git-workspace-sync.js"; import { assertSyncOperationsConfined, escapeTarExcludeLiteral, mirrorDirectory, prepareSandboxManagedRuntime, + REFERENCED_SOURCE_IGNORE_FAILURE_REASONS, resolveReferencedSourceIgnore, type PreparedSandboxManagedRuntime, type ReferencedSourceIgnoreResolution, @@ -1003,6 +1009,65 @@ describe("sandbox managed runtime", () => { expect(downloadMembers.some((entry) => entry.includes("/node_modules/") || entry.endsWith("/node_modules"))).toBe(false); }); + it("excludes an anchor-workspace ignored file whose name has leading and trailing whitespace from the staged tree", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-sandbox-ignored-whitespace-")); + cleanupDirs.push(rootDir); + const workspaceLocalDir = path.join(rootDir, "workspace"); + const remoteWorkspaceDir = path.join(rootDir, "remote-workspace"); + await initGitRepo(workspaceLocalDir); + + // A double-wildcard pattern avoids the separate rule that Git trims an + // unescaped trailing space in a .gitignore PATTERN itself; the padding + // under test lives in the matched FILE name, proving the anchor `splitNul` + // parser keeps it instead of trimming it away and missing the exclude. + const ignoredName = " ignored padded "; + await writeFile(path.join(workspaceLocalDir, ".gitignore"), "*ignored*padded*\n", "utf8"); + await writeFile(path.join(workspaceLocalDir, ignoredName), "TOKEN=abc\n", "utf8"); + await writeFile(path.join(workspaceLocalDir, "kept.txt"), "kept\n", "utf8"); + + const uploadedTars: { remotePath: string; bytes: Buffer }[] = []; + const client: SandboxManagedRuntimeClient = { + makeDir: async (remotePath) => { + await mkdir(remotePath, { recursive: true }); + }, + writeFile: async (remotePath, bytes) => { + await mkdir(path.dirname(remotePath), { recursive: true }); + const buffer = Buffer.from(bytes); + if (remotePath.endsWith("-upload.tar")) uploadedTars.push({ remotePath, bytes: buffer }); + await writeFile(remotePath, buffer); + }, + readFile: async (remotePath) => await readFile(remotePath), + listFiles: async () => [], + remove: async (remotePath) => { + await rm(remotePath, { recursive: true, force: true }); + }, + run: async (command) => { + await execFile("sh", ["-c", command], { maxBuffer: 32 * 1024 * 1024 }); + }, + }; + attachFallbackSyncIn(client); + + await prepareSandboxManagedRuntime({ + spec: { + transport: "sandbox", + provider: "test", + sandboxId: "sandbox-1", + remoteCwd: remoteWorkspaceDir, + timeoutMs: 30_000, + apiKey: null, + }, + adapterKey: "test-adapter", + client, + workspaceLocalDir, + }); + + const workspaceUpload = uploadedTars.find((entry) => path.posix.basename(entry.remotePath) === "workspace-upload.tar"); + expect(workspaceUpload).toBeDefined(); + const members = await listTarMembers(rootDir, "ignored-whitespace-workspace-upload.tar", workspaceUpload!.bytes); + expect(members).not.toContain(ignoredName); + expect(members).toContain("kept.txt"); + }); + it("builds workspace/asset tarballs without a './' self-entry (so untar does not chmod/utime an unowned target dir)", async () => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-sandbox-tarself-")); cleanupDirs.push(rootDir); @@ -2460,8 +2525,272 @@ describe("sandbox managed runtime", () => { const resolution = await resolveReferencedSourceIgnore(repo); - expect(resolution.kind).toBe("failed"); - expect((resolution as { reason: string }).reason.length).toBeGreaterThan(0); + // The reason is the fixed category, never the caught error's own message + // (which would embed `repo`, an absolute host path). + expect(resolution).toEqual({ kind: "failed", reason: REFERENCED_SOURCE_IGNORE_FAILURE_REASONS.scanFailed }); + }); + + // Nadia's required probes: none of these three example absolute paths — + // an ordinary POSIX path, a home directory, and a Windows path — may ever + // reach `reason`, however they arrive (a caught Git error, or a raw + // toplevel string that makes `localPath` a non-descendant). + const SENSITIVE_PATH_PROBES = ["/srv/alice/project", "/home/alice/project", "C:\\Users\\alice\\project"]; + + for (const sensitivePath of SENSITIVE_PATH_PROBES) { + it(`redacts a caught Git error embedding ${sensitivePath} to the fixed category`, async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-ignore-redact-caught-")); + cleanupDirs.push(rootDir); + const repo = path.join(rootDir, "repo"); + await initGitRepo(repo); + try { + setExpensiveWorkspaceGitExecutor(async (input) => { + if (input.operation === "referenced_source.ignored_files") { + throw Object.assign( + new Error(`fatal: unable to read tree object for ${sensitivePath}, pid 4242`), + { stderr: `fatal: unable to read tree object for ${sensitivePath}, pid 4242` }, + ); + } + return await runLocalGit(input.localDir, [...input.args], { + timeout: input.timeout, + maxBuffer: input.maxBuffer, + env: input.env, + }); + }); + + const resolution = await resolveReferencedSourceIgnore(repo); + + expect(resolution).toEqual({ kind: "failed", reason: REFERENCED_SOURCE_IGNORE_FAILURE_REASONS.scanFailed }); + } finally { + setExpensiveWorkspaceGitExecutor(null); + } + }); + + it(`redacts a non-descendant toplevel embedding ${sensitivePath} to the fixed category`, async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-ignore-redact-nondescendant-")); + cleanupDirs.push(rootDir); + const localPath = path.join(rootDir, "referenced"); + await mkdir(localPath, { recursive: true }); + try { + // A toplevel string with no relation to `localPath` — the resolver + // must treat it as a non-descendant and fail closed with the fixed + // category, never a message built from `sensitivePath` or `localPath`. + setExpensiveWorkspaceGitExecutor(async (input) => { + if (input.operation === "referenced_source.toplevel") { + return { stdout: `${sensitivePath}\n`, stderr: "" }; + } + return { stdout: "", stderr: "" }; + }); + + const resolution = await resolveReferencedSourceIgnore(localPath); + + expect(resolution).toEqual({ kind: "failed", reason: REFERENCED_SOURCE_IGNORE_FAILURE_REASONS.toplevelNotDescendant }); + } finally { + setExpensiveWorkspaceGitExecutor(null); + } + }); + } + + it("fails closed with a fixed category when the parsed ignored-entry count exceeds the bound", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-ignore-bound-count-")); + cleanupDirs.push(rootDir); + const repo = path.join(rootDir, "repo"); + await initGitRepo(repo); + const overLimitCount = 10_001; + const syntheticIgnored = `${Array.from({ length: overLimitCount }, (_, index) => `!! entry-${index}`).join("\0")}\0`; + try { + setExpensiveWorkspaceGitExecutor(async (input) => { + if (input.operation === "referenced_source.ignored_files") { + return { stdout: syntheticIgnored, stderr: "" }; + } + return await runLocalGit(input.localDir, [...input.args], { + timeout: input.timeout, + maxBuffer: input.maxBuffer, + env: input.env, + }); + }); + + const resolution = await resolveReferencedSourceIgnore(repo); + + expect(resolution).toEqual({ kind: "failed", reason: REFERENCED_SOURCE_IGNORE_FAILURE_REASONS.limitExceeded }); + } finally { + setExpensiveWorkspaceGitExecutor(null); + } + }); + + it("fails closed with a fixed category when the total UTF-8 byte size of ignored paths exceeds the bound", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-ignore-bound-bytes-")); + cleanupDirs.push(rootDir); + const repo = path.join(rootDir, "repo"); + await initGitRepo(repo); + // One entry alone exceeds the 2 MiB bound, well under the entry-count bound. + const hugeEntry = "a".repeat(3 * 1024 * 1024); + const syntheticIgnored = `!! ${hugeEntry}\0`; + try { + setExpensiveWorkspaceGitExecutor(async (input) => { + if (input.operation === "referenced_source.ignored_files") { + return { stdout: syntheticIgnored, stderr: "" }; + } + return await runLocalGit(input.localDir, [...input.args], { + timeout: input.timeout, + maxBuffer: input.maxBuffer, + env: input.env, + }); + }); + + const resolution = await resolveReferencedSourceIgnore(repo); + + expect(resolution).toEqual({ kind: "failed", reason: REFERENCED_SOURCE_IGNORE_FAILURE_REASONS.limitExceeded }); + } finally { + setExpensiveWorkspaceGitExecutor(null); + } + }); + + it("stages no bytes for either a count-breach or a byte-breach project, and stages a healthy sibling", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-ignore-bound-staging-")); + cleanupDirs.push(rootDir); + const localWorkspaceDir = path.join(rootDir, "local-workspace"); + const remoteWorkspaceDir = path.join(rootDir, "remote-workspace"); + const healthyDir = path.join(rootDir, "referenced-healthy"); + const countBreachDir = path.join(rootDir, "referenced-count-breach"); + const byteBreachDir = path.join(rootDir, "referenced-byte-breach"); + await mkdir(localWorkspaceDir, { recursive: true }); + await mkdir(healthyDir, { recursive: true }); + await mkdir(countBreachDir, { recursive: true }); + await mkdir(byteBreachDir, { recursive: true }); + await writeFile(path.join(localWorkspaceDir, "README.md"), "anchor\n", "utf8"); + await writeFile(path.join(healthyDir, "notes.md"), "healthy\n", "utf8"); + await writeFile(path.join(countBreachDir, "should-never-ship.txt"), "must not stage\n", "utf8"); + await writeFile(path.join(byteBreachDir, "should-never-ship.txt"), "must not stage\n", "utf8"); + + const countBreachReason = { kind: "failed" as const, reason: REFERENCED_SOURCE_IGNORE_FAILURE_REASONS.limitExceeded }; + const byteBreachReason = { kind: "failed" as const, reason: REFERENCED_SOURCE_IGNORE_FAILURE_REASONS.limitExceeded }; + + const prepared = await prepareCommandManagedRuntime({ + runner: makeInlineSpawnRunner(), + spec: { remoteCwd: remoteWorkspaceDir, timeoutMs: 30_000 }, + adapterKey: "test-adapter", + workspaceLocalDir: localWorkspaceDir, + additionalSources: [ + { localPath: healthyDir, projectId: "healthy", ignoreResolution: { kind: "other" } }, + { localPath: countBreachDir, projectId: "count-breach", ignoreResolution: countBreachReason }, + { localPath: byteBreachDir, projectId: "byte-breach", ignoreResolution: byteBreachReason }, + ], + }); + + expect(Object.keys(prepared.additionalSourceDirs).sort()).toEqual(["healthy"]); + expect(prepared.additionalSourceFailures.map((failure) => failure.projectId).sort()).toEqual([ + "byte-breach", + "count-breach", + ]); + const runtimeRootDir = path.posix.join(remoteWorkspaceDir, ".paperclip-runtime", "test-adapter"); + await expect(readFile(path.join(runtimeRootDir, "project-count-breach", "should-never-ship.txt"), "utf8")).rejects + .toMatchObject({ code: "ENOENT" }); + await expect(readFile(path.join(runtimeRootDir, "project-byte-breach", "should-never-ship.txt"), "utf8")).rejects + .toMatchObject({ code: "ENOENT" }); + }); + + describe("saturation retry", () => { + afterEach(() => { + vi.useRealTimers(); + setExpensiveWorkspaceGitExecutor(null); + }); + + function throwSaturated(): never { + throw Object.assign( + new Error("Changed files are temporarily unavailable because the Git scan queue is full"), + { code: WORKSPACE_GIT_SCAN_SATURATED_CODE }, + ); + } + + // Every case here synthesizes BOTH scan operations at the executor seam + // instead of spawning real `git` — the property under test is the + // retry's own timing and attempt count, and a fake-timer-driven test + // must not also depend on a real child process's independent, real-time + // completion. `toplevel` need not exist on disk: `resolveReferencedSourceIgnore` + // falls back to a plain string compare when `fs.realpath` fails, and the + // fixture path is used unchanged on both sides of that compare. + const repo = "/fixture/referenced-project"; + + it("retries a saturated scan up to two times and succeeds on the third attempt", async () => { + let ignoredCallCount = 0; + setExpensiveWorkspaceGitExecutor(async (input) => { + if (input.operation === "referenced_source.toplevel") { + return { stdout: `${repo}\n`, stderr: "" }; + } + ignoredCallCount += 1; + if (ignoredCallCount <= 2) throwSaturated(); + return { stdout: "", stderr: "" }; + }); + + vi.useFakeTimers(); + const resolutionPromise = resolveReferencedSourceIgnore(repo); + // Bounded backoff: none before attempt 1, 1 s before attempt 2, 2 s + // before attempt 3. + await vi.advanceTimersByTimeAsync(1_000); + await vi.advanceTimersByTimeAsync(2_000); + const resolution = await resolutionPromise; + + expect(resolution).toEqual({ kind: "git", ignoredPaths: [] }); + expect(ignoredCallCount).toBe(3); + }); + + it("fails closed after three saturated attempts, with no further Git invocation", async () => { + let ignoredCallCount = 0; + setExpensiveWorkspaceGitExecutor(async (input) => { + if (input.operation === "referenced_source.toplevel") { + return { stdout: `${repo}\n`, stderr: "" }; + } + ignoredCallCount += 1; + throwSaturated(); + }); + + vi.useFakeTimers(); + const resolutionPromise = resolveReferencedSourceIgnore(repo); + await vi.advanceTimersByTimeAsync(1_000); + await vi.advanceTimersByTimeAsync(2_000); + const resolution = await resolutionPromise; + + expect(resolution).toEqual({ kind: "failed", reason: REFERENCED_SOURCE_IGNORE_FAILURE_REASONS.scanFailed }); + // Three total attempts (the first plus two retries) — no fourth, + // unscheduled invocation past the retry budget. + expect(ignoredCallCount).toBe(3); + }); + + it("makes exactly one attempt and fails closed on a timeout, never retrying it", async () => { + let ignoredCallCount = 0; + setExpensiveWorkspaceGitExecutor(async (input) => { + if (input.operation === "referenced_source.toplevel") { + return { stdout: `${repo}\n`, stderr: "" }; + } + ignoredCallCount += 1; + throw Object.assign(new Error("Workspace Git scan timed out after 8000ms"), { + code: "workspace_git_scan_timeout", + }); + }); + + const resolution = await resolveReferencedSourceIgnore(repo); + + expect(resolution).toEqual({ kind: "failed", reason: REFERENCED_SOURCE_IGNORE_FAILURE_REASONS.scanFailed }); + expect(ignoredCallCount).toBe(1); + }); + + it("makes exactly one attempt and fails closed on an output-limit breach, never retrying it", async () => { + let ignoredCallCount = 0; + setExpensiveWorkspaceGitExecutor(async (input) => { + if (input.operation === "referenced_source.toplevel") { + return { stdout: `${repo}\n`, stderr: "" }; + } + ignoredCallCount += 1; + throw Object.assign(new Error("Workspace Git scan exceeded its output limit"), { + code: "workspace_git_scan_output_limit", + }); + }); + + const resolution = await resolveReferencedSourceIgnore(repo); + + expect(resolution).toEqual({ kind: "failed", reason: REFERENCED_SOURCE_IGNORE_FAILURE_REASONS.scanFailed }); + expect(ignoredCallCount).toBe(1); + }); }); }); diff --git a/packages/adapter-utils/src/sandbox-managed-runtime.ts b/packages/adapter-utils/src/sandbox-managed-runtime.ts index bd5d5c5e719..d3de8125007 100644 --- a/packages/adapter-utils/src/sandbox-managed-runtime.ts +++ b/packages/adapter-utils/src/sandbox-managed-runtime.ts @@ -14,9 +14,11 @@ import { GIT_ARCHIVE_EXCLUDES, integrateImportedGitHead, readGitWorkspaceSnapshot, + ReferencedSourceIgnoreScanLimitExceededError, readReferencedSourceGitIgnoredPaths, resetLocalGitIndexToHead, withShallowGitWorkspaceClone, + WORKSPACE_GIT_SCAN_SATURATED_CODE, } from "./git-workspace-sync.js"; import { captureDirectorySnapshot, mergeDirectoryWithBaseline } from "./workspace-restore-merge.js"; import { @@ -147,16 +149,39 @@ export interface SandboxManagedRuntimeAsset { * `resolveReferencedSourceIgnore`). * - `other`: `localPath` is not a Git work tree. The staging path keeps * today's fixed heavy-directory excludes. - * - `failed`: the Git read failed, timed out, or returned output the - * resolver could not parse or safely re-relativize. The project is NOT - * staged (fail closed) — every site records it as a per-project failure - * instead of shipping it unfiltered. + * - `failed`: the Git read failed, timed out, breached a parse bound, or + * returned output the resolver could not safely re-relativize. The project + * is NOT staged (fail closed) — every site records it as a per-project + * failure instead of shipping it unfiltered. `reason` is always one of + * {@link REFERENCED_SOURCE_IGNORE_FAILURE_REASONS} — never a raw Git or tar + * diagnostic, an absolute host path, or a basename. */ export type ReferencedSourceIgnoreResolution = | { kind: "git"; ignoredPaths: string[] } | { kind: "other" } | { kind: "failed"; reason: string }; +/** + * The fixed, allowlisted failure categories a `failed` + * {@link ReferencedSourceIgnoreResolution} reports as `reason`. This is the + * ENTIRE vocabulary: no absolute host path, no basename, no opaque token, and + * no raw Git or tar stderr ever reaches `reason` — only one of these three + * stable strings, chosen once at the single construction point in + * `resolveReferencedSourceIgnore`. A `failed` resolution always prevents + * staging and is always re-resolved before its next use, so two different + * underlying failures colliding on the same category (e.g. a timeout and a + * malformed-output error both reporting `scanFailed`) never weakens the + * fail-closed decision. + */ +export const REFERENCED_SOURCE_IGNORE_FAILURE_REASONS = { + /** A Git read failed, timed out, was cancelled, or returned malformed output — including a saturated scan queue that never recovered after its retries. */ + scanFailed: "git-ignore-scan-failed", + /** The parsed ignored-entry count or total UTF-8 byte size breached its bound (see `readReferencedSourceGitIgnoredPaths`). */ + limitExceeded: "git-ignore-scan-limit-exceeded", + /** The referenced project's `localPath` is not a descendant of its own Git top level. */ + toplevelNotDescendant: "git-toplevel-not-descendant", +} as const; + /** * A referenced (additional) project to stage into the run sandbox as a plain, * read-only tree. `localPath` is the host checkout directory. Upstream code @@ -240,7 +265,7 @@ function relativizeUnderGitToplevel(input: { toplevel: string; localPath: string } /** - * Re-relativize root-relative ignored paths (as `git status --ignored` + * Re-relativize root-relative ignored paths (as `readReferencedSourceGitIgnoredPaths` * reports them, from the repository toplevel) to `offset`, the position of * the referenced project's `localPath` under that toplevel. Keeps only the * entries that are `offset` itself or a descendant of it — an ignored path @@ -259,23 +284,78 @@ function reRelativizeIgnoredPathsToLocalPath(input: { ignoredPaths: string[]; of .filter(Boolean); } +/** + * Bounded backoff before each retry of a saturated Git scan: none before the + * first attempt, 1 second before the second, 2 seconds before the third. Three + * total attempts (the first plus these two retries) is a liveness parameter, + * not a security control — the retry only ever fires for the scheduler's + * typed saturation code (see {@link isWorkspaceGitScanSaturatedError}). + */ +const REFERENCED_SOURCE_IGNORE_SCAN_RETRY_DELAYS_MS = [1_000, 2_000] as const; + +async function delay(ms: number): Promise { + await new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * True only when `error` carries the workspace Git scan scheduler's typed + * saturation code on its `code` property. Matches the code alone, never + * message text — a message can change wording without changing meaning, and + * matching text would silently stop retrying (or start retrying the wrong + * failure) the moment it did. + */ +function isWorkspaceGitScanSaturatedError(error: unknown): boolean { + return ( + typeof error === "object" && + error !== null && + "code" in error && + (error as { code?: unknown }).code === WORKSPACE_GIT_SCAN_SATURATED_CODE + ); +} + /** * Resolve a referenced project's Git-ignored paths ONCE, before any staging * site runs. Called once per project (see `execute.ts`); the sandbox lane, * the SSH lane, and the content-signature walk all consume this one result, * so they can never apply a different exclusion set to the same project. * - * Fails closed: a Git read error, a timeout, malformed output, or a `localPath` - * that is not a plain descendant of its own Git toplevel all return `failed`, - * never an empty ignore list — an empty list means "resolved, nothing extra to - * exclude", which is a different claim than "the resolution did not run". + * Fails closed: a Git read error, a timeout, malformed output, a parse-bound + * breach, or a `localPath` that is not a plain descendant of its own Git + * toplevel all return `failed`, never an empty ignore list — an empty list + * means "resolved, nothing extra to exclude", which is a different claim than + * "the resolution did not run". + * + * Retries ONLY a saturated scan queue (the shared workspace Git operation + * scheduler rejecting before spawn because it is at capacity) — a liveness + * condition, not an integrity one. Three attempts total, with the bounded + * backoff in {@link REFERENCED_SOURCE_IGNORE_SCAN_RETRY_DELAYS_MS}, retried + * through the same registered scheduler every time. No direct-spawn fallback + * exists: bypassing the scheduler would defeat the process-wide concurrency + * limit it enforces. Every other failure — timeout, cancellation, an output + * limit, a permission error, malformed output, a real Git failure, or a bound + * breach — makes exactly one attempt and fails closed immediately. */ export async function resolveReferencedSourceIgnore(localPath: string): Promise { - let scan: Awaited>; - try { - scan = await readReferencedSourceGitIgnoredPaths(localPath); - } catch (error) { - return { kind: "failed", reason: error instanceof Error ? error.message : String(error) }; + let scan: Awaited> = null; + let failureReason: string | null = null; + for (let attempt = 0; attempt <= REFERENCED_SOURCE_IGNORE_SCAN_RETRY_DELAYS_MS.length; attempt += 1) { + try { + scan = await readReferencedSourceGitIgnoredPaths(localPath); + failureReason = null; + break; + } catch (error) { + failureReason = error instanceof ReferencedSourceIgnoreScanLimitExceededError + ? REFERENCED_SOURCE_IGNORE_FAILURE_REASONS.limitExceeded + : REFERENCED_SOURCE_IGNORE_FAILURE_REASONS.scanFailed; + const isLastAttempt = attempt === REFERENCED_SOURCE_IGNORE_SCAN_RETRY_DELAYS_MS.length; + if (isLastAttempt || !isWorkspaceGitScanSaturatedError(error)) { + break; + } + await delay(REFERENCED_SOURCE_IGNORE_SCAN_RETRY_DELAYS_MS[attempt]!); + } + } + if (failureReason !== null) { + return { kind: "failed", reason: failureReason }; } if (!scan) { return { kind: "other" }; @@ -285,10 +365,7 @@ export async function resolveReferencedSourceIgnore(localPath: string): Promise< localPath: await physicalPath(localPath), }); if (offset === null) { - return { - kind: "failed", - reason: `referenced project path is not a descendant of its own Git top level: ${localPath} under ${scan.toplevel}`, - }; + return { kind: "failed", reason: REFERENCED_SOURCE_IGNORE_FAILURE_REASONS.toplevelNotDescendant }; } return { kind: "git", diff --git a/server/src/services/workspace-git-operation-scheduler.test.ts b/server/src/services/workspace-git-operation-scheduler.test.ts index abd9a36930d..72fbb352a42 100644 --- a/server/src/services/workspace-git-operation-scheduler.test.ts +++ b/server/src/services/workspace-git-operation-scheduler.test.ts @@ -9,6 +9,7 @@ import { workspaceGitSchedulerOptionsFromEnv, type WorkspaceGitRunner, } from "./workspace-git-operation-scheduler.js"; +import { WORKSPACE_GIT_SCAN_SATURATED_CODE } from "@paperclipai/adapter-utils/git-workspace-sync"; const tempPaths: string[] = []; @@ -429,3 +430,13 @@ describe("WorkspaceGitOperationScheduler", () => { expect(scheduler.snapshot()).toMatchObject({ activeCount: 0, queuedCount: 0, inFlightCount: 0 }); }); }); + +describe("WORKSPACE_GIT_SCAN_SATURATED_CODE parity", () => { + it("stays equal to WORKSPACE_GIT_SCAN_ERROR_CODES.saturated", () => { + // `adapter-utils` cannot import this module (the reverse direction is + // allowed, not this one), so `resolveReferencedSourceIgnore` declares its + // own copy of the saturation code to key its retry off. This test is the + // one place both literals meet, so the two copies cannot drift apart. + expect(WORKSPACE_GIT_SCAN_SATURATED_CODE).toBe(WORKSPACE_GIT_SCAN_ERROR_CODES.saturated); + }); +}); From 6c7c0fd1f2426294887cbbe0f7fe703520bdcc0b Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Wed, 26 Aug 2026 10:19:19 -0700 Subject: [PATCH 28/32] fix(db): stop the postgres driver from crashing the process on a write/close race (#12227) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The server and its test suites talk to PostgreSQL through the `postgres` (postgres.js) driver, and tests routinely tear their databases down while connections still carry traffic > - The driver flushes small buffered frames from a `setImmediate`, and that deferred flush calls `socket.write()` without checking that the socket still exists; a reserved connection whose backend died keeps accepting queries, so the flush can fire with a null socket > - The resulting `TypeError` escapes from a timer callback with no try/catch above it, crashing the process — in CI this fails suites whose tests all passed ("Vitest caught 1 unhandled error"), and the same crash is reported against the driver in the wild after ECONNRESET > - The latest driver release (3.4.9) still has the bug, so this pull request adds a pnpm patch guarding the flush and normalizing timer state on close, plus a deterministic regression test > - The benefit is CI that no longer fails randomly on a teardown race, and production processes that survive a database connection dying at the wrong moment ## Linked Issues or Issue Description No public issue exists; the underlying problem follows the bug-report template. **What happened?** CI jobs fail with all tests passing: vitest reports `Vitest caught 1 unhandled error during the test run` with `TypeError: Cannot read properties of null (reading 'write')` at `postgres/src/connection.js` `Immediate.nextWrite`. The attribution points at whichever test file happened to be running (e.g. `native-codex-runner.integration.test.ts`), because the throw comes from a process-level timer callback, not from a test. The identical crash is reported against the upstream driver by other projects after `ECONNRESET` (e.g. immich-app/immich#25098). **Expected behavior** A connection dying between a write being scheduled and its deferred flush must settle the affected queries through the driver's normal connection-error path, never throw from a bare timer callback. **Steps to reproduce** Run the new `packages/db/src/postgres-driver-teardown.test.ts` with the patch removed: reserve a connection (`sql.reserve()` — the same surface `sql.begin()` uses), destroy the backend socket, wait for the client to process the close, then issue one query on the reserved connection. The deferred flush fires one tick later with `socket === null` and crashes the process with exactly the CI signature. **Paperclip version or commit** master `198fc8b28`, `postgres@3.4.9` (latest release; bug still present on the driver's master branch). ## What Changed - `patches/postgres@3.4.9.patch` (new, wired via `pnpm.patchedDependencies`): `nextWrite` returns without writing when `socket === null`, dropping the buffered bytes — the close path has already settled every in-flight query, so those bytes have nowhere to go. The `closed()` and `terminate()` handlers additionally reset `nextWriteTimer`/`chunk` after `clearImmediate`, so a stale cleared handle cannot silently block a future reconnect's first flush. All three shipped builds (`src`, `cjs`, `cf`) get the identical change. - `packages/db/src/postgres-driver-teardown.test.ts` (new): deterministic reproduction against a minimal in-process fake wire server (startup auth + an empty result for the `fetch_types` bootstrap). Asserts the late query settles with `CONNECTION_DESTROYED` through `sql.end()` instead of crashing the process. ## Verification - The regression test fails against unpatched `postgres@3.4.9` with the exact CI signature (verified by running the same scenario against an unpatched checkout) and passes with the patch. - Full `packages/db` suite: 27 files / 101 tests pass. - Spot-checked server suites that exercise the database through the patched driver. ## Risks - Low. The behavioral change activates only in a state that previously crashed the process (write flush with no socket). Dropping the buffered bytes matches what the connection's close path already promised callers: every in-flight query has been settled with a connection error. - The timer/chunk reset in `closed()`/`terminate()` prevents a theoretical stale-handle hang after reconnect; on the normal path both were already reset by `nextWrite`. - The patch pins to `postgres@3.4.9`; a future driver upgrade will surface the patch for re-evaluation (pnpm fails loudly on version mismatch), and the guard can be dropped if the fix lands upstream. ## Model Used Claude Fable 5 (`claude-fable-5`, Anthropic) via Claude Code — agentic coding session with tool use (driver source analysis, wire-protocol fake server, local test execution). ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --- package.json | 3 +- .../db/src/postgres-driver-teardown.test.ts | 108 ++++++++++++ patches/postgres@3.4.9.patch | 156 ++++++++++++++++++ 3 files changed, 266 insertions(+), 1 deletion(-) create mode 100644 packages/db/src/postgres-driver-teardown.test.ts create mode 100644 patches/postgres@3.4.9.patch diff --git a/package.json b/package.json index 9dfde2719f8..6fc29e7f726 100644 --- a/package.json +++ b/package.json @@ -83,7 +83,8 @@ "pnpm": { "patchedDependencies": { "embedded-postgres@18.1.0-beta.16": "patches/embedded-postgres@18.1.0-beta.16.patch", - "acpx@0.12.0": "patches/acpx@0.12.0.patch" + "acpx@0.12.0": "patches/acpx@0.12.0.patch", + "postgres@3.4.9": "patches/postgres@3.4.9.patch" }, "overrides": { "rollup": ">=4.59.0", diff --git a/packages/db/src/postgres-driver-teardown.test.ts b/packages/db/src/postgres-driver-teardown.test.ts new file mode 100644 index 00000000000..55b7ff04a93 --- /dev/null +++ b/packages/db/src/postgres-driver-teardown.test.ts @@ -0,0 +1,108 @@ +import net from "node:net"; +import { afterEach, describe, expect, it } from "vitest"; +import postgres from "postgres"; + +/** + * Regression coverage for patches/postgres@3.4.9.patch. + * + * The unpatched driver crashes the whole process when a write races a + * connection close: `write()` buffers small frames and flushes them from a + * `setImmediate(nextWrite)`, and `nextWrite` called `socket.write()` without + * checking that the socket still exists. A reserved connection (sql.reserve / + * sql.begin) whose backend dies keeps accepting queries — its handler calls + * `execute()` with no socket-state check — so the deferred flush fired with + * `socket === null` and threw `TypeError: Cannot read properties of null + * (reading 'write')` from a timer callback with no try/catch above it. In CI + * that surfaced as vitest's "Vitest caught 1 unhandled error" failing suites + * whose tests had all passed, whenever a test tore down its database while a + * connection still had traffic. + * + * The fake server below speaks just enough wire protocol to hand the client + * an open connection (startup auth, then an empty result set for the + * fetch_types bootstrap query); the crash path itself never needs a real + * query to succeed. Against the unpatched driver this test fails through + * vitest's unhandled-error detection with exactly the CI signature; with the + * patch, the late query settles through the normal CONNECTION_DESTROYED path. + */ +describe("postgres driver teardown race", () => { + let server: net.Server | null = null; + let sql: ReturnType | null = null; + + afterEach(async () => { + if (sql) await sql.end({ timeout: 1 }).catch(() => {}); + sql = null; + if (server) await new Promise((resolve) => server!.close(resolve)); + server = null; + }); + + it("does not crash the process when a reserved connection's backend dies before a write flushes", async () => { + const backendSockets: net.Socket[] = []; + + // AuthenticationOk (R, code 0) + ReadyForQuery (Z, idle). + const authOk = Buffer.from([0x52, 0, 0, 0, 8, 0, 0, 0, 0]); + const readyForQuery = Buffer.from([0x5a, 0, 0, 0, 5, 0x49]); + const emptyQueryReply = Buffer.concat([ + Buffer.from([0x31, 0, 0, 0, 4]), // ParseComplete + Buffer.from([0x32, 0, 0, 0, 4]), // BindComplete + Buffer.from([0x54, 0, 0, 0, 6, 0, 0]), // RowDescription, zero fields + // CommandComplete "SELECT 0" + Buffer.from([0x43, 0, 0, 0, 0x0d, 0x53, 0x45, 0x4c, 0x45, 0x43, 0x54, 0x20, 0x30, 0]), + ]); + + server = net.createServer((socket) => { + backendSockets.push(socket); + let greeted = false; + socket.on("data", () => { + if (!greeted) { + // First frame is the StartupMessage. + greeted = true; + socket.write(Buffer.concat([authOk, readyForQuery])); + return; + } + // Any later frame (the fetch_types bootstrap query) gets an empty + // result set + ReadyForQuery, enough for the client to finish + // opening and resolve the reserve. + socket.write(Buffer.concat([emptyQueryReply, readyForQuery])); + }); + socket.on("error", () => {}); + }); + await new Promise((resolve) => server!.listen(0, "127.0.0.1", resolve)); + const port = (server.address() as net.AddressInfo).port; + + const closed = new Promise((resolve) => { + sql = postgres({ + host: "127.0.0.1", + port, + user: "test", + database: "test", + max: 1, + idle_timeout: 0, + connect_timeout: 5, + onclose: () => resolve(), + }); + }); + + // A reserved connection keeps routing queries straight to this one + // physical connection — the same surface sql.begin() transactions use. + const reserved = await sql!.reserve(); + + // Kill the backend and wait until the client has fully processed the + // close (socket nulled, pool notified). + for (const socket of backendSockets) socket.destroy(); + await closed; + + // Late query on the dead reserved connection: unpatched, this buffered + // its frame and scheduled the deferred flush that crashed the process + // one tick later. Patched, execute() refuses it up front, so it settles + // immediately — no pool shutdown required. + const settled = await reserved`select 1`.catch((error: unknown) => error); + expect(settled).toBeInstanceOf(Error); + expect(String((settled as Error).message)).toContain("CONNECTION_CLOSED"); + + // Let any stray deferred flush run before the test ends, so a regression + // in the nextWrite guard still fails this test via the unhandled error. + await new Promise((resolve) => setImmediate(() => setImmediate(resolve))); + + reserved.release(); + }); +}); diff --git a/patches/postgres@3.4.9.patch b/patches/postgres@3.4.9.patch new file mode 100644 index 00000000000..bc47aee9c7c --- /dev/null +++ b/patches/postgres@3.4.9.patch @@ -0,0 +1,156 @@ +diff --git a/cf/src/connection.js b/cf/src/connection.js +index 8e79170aeb13efe8c3c77ccede0cd3a115b5e1b5..c17a54b62fefa1f13bc38cd82e25d52ed467c94e 100644 +--- a/cf/src/connection.js ++++ b/cf/src/connection.js +@@ -159,6 +159,14 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose + if (terminated) + return queryError(q, Errors.connection('CONNECTION_DESTROYED', options)) + ++ // The connection lost its socket (backend death, network close) and has ++ // not reconnected yet. Reserved connections keep routing queries here, ++ // and buffering their frames would schedule a flush with nowhere to ++ // write. Refuse up front the same way `terminated` does, so the query ++ // settles immediately instead of dangling until pool shutdown. ++ if (socket === null) ++ return queryError(q, Errors.connection('CONNECTION_CLOSED', options, socket)) ++ + if (stream) + return queryError(q, Errors.generic('COPY_IN_PROGRESS', 'You cannot execute queries during copy')) + +@@ -254,6 +262,16 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose + } + + function nextWrite(fn) { ++ if (socket === null) { ++ // The connection closed between this write being scheduled and the ++ // immediate firing (or a caller raced the close handler). The close ++ // path has already settled every in-flight query, so the buffered ++ // bytes have nowhere to go: drop them instead of crashing the ++ // process from a timer callback with no try/catch above it. ++ nextWriteTimer !== null && clearImmediate(nextWriteTimer) ++ chunk = nextWriteTimer = null ++ return false ++ } + const x = socket.write(chunk, fn) + nextWriteTimer !== null && clearImmediate(nextWriteTimer) + chunk = nextWriteTimer = null +@@ -427,6 +445,7 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose + error(Errors.connection('CONNECTION_DESTROYED', options)) + + clearImmediate(nextWriteTimer) ++ chunk = nextWriteTimer = null + if (socket) { + socket.removeListener('data', data) + socket.removeListener('connect', connected) +@@ -440,6 +459,7 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose + remaining = 0 + incomings = null + clearImmediate(nextWriteTimer) ++ chunk = nextWriteTimer = null + socket.removeListener('data', data) + socket.removeListener('connect', connected) + idleTimer.cancel() +diff --git a/cjs/src/connection.js b/cjs/src/connection.js +index 07f6716702ac888215c86ad6e0071aaf55ae519c..949aa427e1afd0f68bc4cdcdf062e571568fd24d 100644 +--- a/cjs/src/connection.js ++++ b/cjs/src/connection.js +@@ -157,6 +157,14 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose + if (terminated) + return queryError(q, Errors.connection('CONNECTION_DESTROYED', options)) + ++ // The connection lost its socket (backend death, network close) and has ++ // not reconnected yet. Reserved connections keep routing queries here, ++ // and buffering their frames would schedule a flush with nowhere to ++ // write. Refuse up front the same way `terminated` does, so the query ++ // settles immediately instead of dangling until pool shutdown. ++ if (socket === null) ++ return queryError(q, Errors.connection('CONNECTION_CLOSED', options, socket)) ++ + if (stream) + return queryError(q, Errors.generic('COPY_IN_PROGRESS', 'You cannot execute queries during copy')) + +@@ -252,6 +260,16 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose + } + + function nextWrite(fn) { ++ if (socket === null) { ++ // The connection closed between this write being scheduled and the ++ // immediate firing (or a caller raced the close handler). The close ++ // path has already settled every in-flight query, so the buffered ++ // bytes have nowhere to go: drop them instead of crashing the ++ // process from a timer callback with no try/catch above it. ++ nextWriteTimer !== null && clearImmediate(nextWriteTimer) ++ chunk = nextWriteTimer = null ++ return false ++ } + const x = socket.write(chunk, fn) + nextWriteTimer !== null && clearImmediate(nextWriteTimer) + chunk = nextWriteTimer = null +@@ -425,6 +443,7 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose + error(Errors.connection('CONNECTION_DESTROYED', options)) + + clearImmediate(nextWriteTimer) ++ chunk = nextWriteTimer = null + if (socket) { + socket.removeListener('data', data) + socket.removeListener('connect', connected) +@@ -438,6 +457,7 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose + remaining = 0 + incomings = null + clearImmediate(nextWriteTimer) ++ chunk = nextWriteTimer = null + socket.removeListener('data', data) + socket.removeListener('connect', connected) + idleTimer.cancel() +diff --git a/src/connection.js b/src/connection.js +index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..8bb205e5b4b92282711ec8d105960508ccfc4252 100644 +--- a/src/connection.js ++++ b/src/connection.js +@@ -157,6 +157,14 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose + if (terminated) + return queryError(q, Errors.connection('CONNECTION_DESTROYED', options)) + ++ // The connection lost its socket (backend death, network close) and has ++ // not reconnected yet. Reserved connections keep routing queries here, ++ // and buffering their frames would schedule a flush with nowhere to ++ // write. Refuse up front the same way `terminated` does, so the query ++ // settles immediately instead of dangling until pool shutdown. ++ if (socket === null) ++ return queryError(q, Errors.connection('CONNECTION_CLOSED', options, socket)) ++ + if (stream) + return queryError(q, Errors.generic('COPY_IN_PROGRESS', 'You cannot execute queries during copy')) + +@@ -252,6 +260,16 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose + } + + function nextWrite(fn) { ++ if (socket === null) { ++ // The connection closed between this write being scheduled and the ++ // immediate firing (or a caller raced the close handler). The close ++ // path has already settled every in-flight query, so the buffered ++ // bytes have nowhere to go: drop them instead of crashing the ++ // process from a timer callback with no try/catch above it. ++ nextWriteTimer !== null && clearImmediate(nextWriteTimer) ++ chunk = nextWriteTimer = null ++ return false ++ } + const x = socket.write(chunk, fn) + nextWriteTimer !== null && clearImmediate(nextWriteTimer) + chunk = nextWriteTimer = null +@@ -425,6 +443,7 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose + error(Errors.connection('CONNECTION_DESTROYED', options)) + + clearImmediate(nextWriteTimer) ++ chunk = nextWriteTimer = null + if (socket) { + socket.removeListener('data', data) + socket.removeListener('connect', connected) +@@ -438,6 +457,7 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose + remaining = 0 + incomings = null + clearImmediate(nextWriteTimer) ++ chunk = nextWriteTimer = null + socket.removeListener('data', data) + socket.removeListener('connect', connected) + idleTimer.cancel() From 8ed1f51f75c025bf36b0b5d8d03d36675f8c6da2 Mon Sep 17 00:00:00 2001 From: Nicky Leach Date: Wed, 26 Aug 2026 10:42:13 -0700 Subject: [PATCH 29/32] fix(scripts): silence pnpm DEP0169 at provisioning install call sites (#12228) ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Worktree provisioning prepares the dependencies and tools that these agents need. > - The pinned pnpm version calls the deprecated `url.parse()` function during each install. > - Node.js 24 reports this call as `DeprecationWarning [DEP0169]`. > - The provisioning scripts run more than one install, so the warning repeats in each run. > - This pull request disables only `DEP0169` at each affected pnpm install call site. > - The benefit is a clear provisioning log while other deprecation warnings remain visible. ## Linked Issues or Issue Description **What happened?** The worktree provisioning scripts printed `DeprecationWarning [DEP0169]` during each pnpm install. The warning came from pnpm 9.15.4 and its `toNerfDart` call to `url.parse()`. **Expected behavior** The provisioning scripts should hide this known warning from the pinned pnpm version. They should keep other deprecation warnings visible. **Steps to reproduce** 1. Use Node.js 24 with pnpm 9.15.4. 2. Run worktree provisioning with a base-workspace repair or dependency install. 3. Observe the repeated `DeprecationWarning [DEP0169]` output. **Paperclip version or commit** Commit `5cd41b1a9996713efdfdc62373da8045664c7f30`. **Deployment mode** Built from source. **Installation method** Built from source with pnpm. **Agent adapter(s) involved** Not adapter-specific (core bug). **Database mode** Not database-related. ## What Changed - Add `--disable-warning=DEP0169` to each affected pnpm install call site. - Append the flag to `NODE_OPTIONS` so the scripts keep existing values. - Add comments that name the source of the warning and the removal condition. - Add a regression test for all affected scripts and warning codes. ## Verification - `bash -n scripts/provision-worktree.sh` passes. - `bash -n scripts/provision-worktree-runtime.sh` passes. - `node --test scripts/__tests__/provision-worktree-self-heal.test.mjs` passes with 15 tests. - GitHub Actions must pass all required checks before merge. ## Risks This change has low risk. It changes warning output only for `DEP0169`. It does not overwrite existing `NODE_OPTIONS` values. Revert commit `5cd41b1a9996713efdfdc62373da8045664c7f30` to restore the prior output. ## Model Used OpenAI Codex, GPT-5. The model used tool calls and code execution. The runtime did not expose a context-window value. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes: #` / `Refs: #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge Co-authored-by: Paperclip --- .../provision-worktree-self-heal.test.mjs | 32 +++++++++++++++++++ scripts/provision-worktree-runtime.sh | 8 +++-- scripts/provision-worktree.sh | 13 ++++++-- 3 files changed, 48 insertions(+), 5 deletions(-) diff --git a/scripts/__tests__/provision-worktree-self-heal.test.mjs b/scripts/__tests__/provision-worktree-self-heal.test.mjs index bbe44698d19..c74c480b490 100644 --- a/scripts/__tests__/provision-worktree-self-heal.test.mjs +++ b/scripts/__tests__/provision-worktree-self-heal.test.mjs @@ -492,3 +492,35 @@ test("runtime provisioning does not trust a truncated verified manifest", () => 1, ); }); + +/** + * pnpm 9.15.4 calls the deprecated url.parse() once per `pnpm install` + * (see toNerfDart in the pnpm bundle), which Node 24 reports as DEP0169. + * Each `pnpm install` call site must silence that one warning code, and must + * append the flag to any NODE_OPTIONS value the environment already set + * instead of overwriting it. + */ +test("every pnpm install call site silences DEP0169 without overwriting NODE_OPTIONS", () => { + const disableWarningFlag = "--disable-warning=DEP0169"; + const appendsToExistingNodeOptions = /NODE_OPTIONS="\$\{NODE_OPTIONS:-\} --disable-warning=DEP0169"/; + + const provisionSource = fs.readFileSync(script, "utf8"); + const repairCallSites = provisionSource.match(/env -u NODE_ENV CI=true NODE_OPTIONS="\$repair_node_options" "\$\{repair_cmd\[@\]\}"/g) ?? []; + assert.equal(repairCallSites.length, 2, "expected the repair pnpm install to carry the flag at both call sites (locked and unlocked)"); + assert.match(provisionSource, /local repair_node_options="\$\{NODE_OPTIONS:-\} --disable-warning=DEP0169"/); + assert.match(provisionSource, appendsToExistingNodeOptions); + assert.match(provisionSource, /NODE_OPTIONS="\$\{NODE_OPTIONS:-\} --disable-warning=DEP0169" pnpm install --prod=false "\$@"/); + + const runtimeSource = fs.readFileSync(runtimeScript, "utf8"); + const runtimeRepairCallSites = runtimeSource.match(/env -u NODE_ENV CI=true NODE_OPTIONS="\$repair_node_options" "\$\{repair_cmd\[@\]\}"/g) ?? []; + assert.equal(runtimeRepairCallSites.length, 2, "expected the repair pnpm install to carry the flag at both call sites (locked and unlocked)"); + assert.match(runtimeSource, /local repair_node_options="\$\{NODE_OPTIONS:-\} --disable-warning=DEP0169"/); + + // No other warning code is suppressed anywhere in either script. + for (const source of [provisionSource, runtimeSource]) { + const disableWarningMatches = source.match(/--disable-warning=[^\s"]+/g) ?? []; + for (const match of disableWarningMatches) { + assert.equal(match, disableWarningFlag); + } + } +}); diff --git a/scripts/provision-worktree-runtime.sh b/scripts/provision-worktree-runtime.sh index a6d925918a9..93036454813 100755 --- a/scripts/provision-worktree-runtime.sh +++ b/scripts/provision-worktree-runtime.sh @@ -106,6 +106,10 @@ repair_base_workspace_install() { [[ -f "$base_cwd/package.json" && -f "$base_cwd/pnpm-lock.yaml" ]] || return 1 echo "Base workspace CLI at $base_cli_entry_path failed its health check (typically dangling pnpm symlinks after a partial install); repairing with pnpm install in $base_cwd." >&2 local repair_cmd=(pnpm install --prod=false --force --frozen-lockfile --config.confirmModulesPurge=false) + # pnpm 9.15.4 calls the deprecated url.parse() in toNerfDart on every + # install. Node 24 reports that call as DEP0169. Remove this flag when the + # pinned pnpm no longer calls url.parse() in that path. + local repair_node_options="${NODE_OPTIONS:-} --disable-warning=DEP0169" local repair_lock_dir="" if command -v git >/dev/null 2>&1; then repair_lock_dir="$(git -C "$base_cwd" rev-parse --absolute-git-dir 2>/dev/null || true)" @@ -122,11 +126,11 @@ repair_base_workspace_install() { echo "Base workspace CLI became healthy while waiting for the repair lock; skipping reinstall." >&2 exit 0 fi - env -u NODE_ENV CI=true "${repair_cmd[@]}" >&2 || exit 1 + env -u NODE_ENV CI=true NODE_OPTIONS="$repair_node_options" "${repair_cmd[@]}" >&2 || exit 1 base_cli_healthy ) else - (cd "$base_cwd" && env -u NODE_ENV CI=true "${repair_cmd[@]}" >&2 && base_cli_healthy) + (cd "$base_cwd" && env -u NODE_ENV CI=true NODE_OPTIONS="$repair_node_options" "${repair_cmd[@]}" >&2 && base_cli_healthy) fi } diff --git a/scripts/provision-worktree.sh b/scripts/provision-worktree.sh index d25f6a66724..1f41b030820 100644 --- a/scripts/provision-worktree.sh +++ b/scripts/provision-worktree.sh @@ -91,6 +91,10 @@ repair_base_workspace_install() { # otherwise skip the dangling symlinks; --frozen-lockfile keeps the repair # from mutating the shared base workspace's lockfile. local repair_cmd=(pnpm install --prod=false --force --frozen-lockfile --config.confirmModulesPurge=false) + # pnpm 9.15.4 calls the deprecated url.parse() in toNerfDart on every + # install. Node 24 reports that call as DEP0169. Remove this flag when the + # pinned pnpm no longer calls url.parse() in that path. + local repair_node_options="${NODE_OPTIONS:-} --disable-warning=DEP0169" # Resolve the real git dir so locking also covers base workspaces that are # linked worktrees, where "$base_cwd/.git" is a file rather than a directory. local repair_lock_dir="" @@ -113,11 +117,11 @@ repair_base_workspace_install() { echo "Base workspace CLI became healthy while waiting for the repair lock; skipping reinstall." >&2 exit 0 fi - env -u NODE_ENV CI=true "${repair_cmd[@]}" >&2 || exit 1 + env -u NODE_ENV CI=true NODE_OPTIONS="$repair_node_options" "${repair_cmd[@]}" >&2 || exit 1 base_cli_healthy ) else - (cd "$base_cwd" && env -u NODE_ENV CI=true "${repair_cmd[@]}" >&2 && base_cli_healthy) + (cd "$base_cwd" && env -u NODE_ENV CI=true NODE_OPTIONS="$repair_node_options" "${repair_cmd[@]}" >&2 && base_cli_healthy) fi } @@ -770,7 +774,10 @@ if [[ -f "$worktree_cwd/package.json" && -f "$worktree_cwd/pnpm-lock.yaml" ]]; t if ( cd "$worktree_cwd" - pnpm install --prod=false "$@" + # pnpm 9.15.4 calls the deprecated url.parse() in toNerfDart on every + # install. Node 24 reports that call as DEP0169. Remove this flag + # when the pinned pnpm no longer calls url.parse() in that path. + NODE_OPTIONS="${NODE_OPTIONS:-} --disable-warning=DEP0169" pnpm install --prod=false "$@" ) >"$stdout_path" 2>"$stderr_path"; then cat "$stdout_path" cat "$stderr_path" >&2 From 8ef39febd7132de5efb5d61549bd7d2c4b12a1b2 Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Wed, 26 Aug 2026 11:10:44 -0700 Subject: [PATCH 30/32] refactor(db): drop the vendored postgres teardown patch (reverts #12227) (#12234) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - #12227 vendored a pnpm patch of the `postgres` driver to stop a teardown race (`nextWrite` firing after the socket is nulled) from crashing the process and failing green CI shards > - Maintainer call: carrying a vendored driver patch is not worth it for a CI flake — the patch adds a maintenance obligation on every future driver upgrade > - The race is an upstream bug in `postgres@3.4.9`; the plan is to wait for an upstream release that fixes it and bump the dependency instead > - This pull request reverts #12227 in full: the patch file, its `package.json` registration, and the regression test that exercised the patched behavior > - The benefit is an unmodified dependency graph; the known flake signature returns and is retried when it bites ## Linked Issues or Issue Description Reverts #12227. **What existing behavior does this improve?** Dependency hygiene: `postgres@3.4.9` is consumed unmodified again, with no `pnpm.patchedDependencies` entry to re-evaluate on every driver upgrade. **Current behavior** The repo carries `patches/postgres@3.4.9.patch` (null-socket guard in the driver's deferred write flush, plus an `execute()` refusal on socketless connections) and a regression test for it. **Proposed behavior** Plain upstream `postgres@3.4.9`. The teardown race stays an upstream bug: a green test shard can occasionally fail with `Vitest caught 1 unhandled error` and `TypeError: Cannot read properties of null (reading 'write')` at `Immediate.nextWrite`; the remedy is retrying the shard until an upstream driver release fixes the race and we bump. **Reason and benefit** A vendored driver patch is a standing maintenance cost that outweighs the flake it suppressed. ## What Changed - Reverts #12227 (`6c7c0fd1f`) in full: removes `patches/postgres@3.4.9.patch`, its `pnpm.patchedDependencies` registration in root `package.json`, and `packages/db/src/postgres-driver-teardown.test.ts`. No lockfile involvement — the merged commit never touched `pnpm-lock.yaml` and the refresh bot had not yet recorded the patch. ## Verification - `pnpm install` on the reverted tree is coherent; the full `packages/db` suite passes (26 files / 100 tests). - `git revert` applied cleanly with no conflicts. ## Risks - Low. This restores the exact pre-#12227 state. The known flake signature returns; it fails jobs whose tests all passed and is cleared by retrying the shard. ## Model Used Claude Fable 5 (`claude-fable-5`, Anthropic) via Claude Code — agentic coding session with tool use. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --- package.json | 3 +- .../db/src/postgres-driver-teardown.test.ts | 108 ------------ patches/postgres@3.4.9.patch | 156 ------------------ 3 files changed, 1 insertion(+), 266 deletions(-) delete mode 100644 packages/db/src/postgres-driver-teardown.test.ts delete mode 100644 patches/postgres@3.4.9.patch diff --git a/package.json b/package.json index 6fc29e7f726..9dfde2719f8 100644 --- a/package.json +++ b/package.json @@ -83,8 +83,7 @@ "pnpm": { "patchedDependencies": { "embedded-postgres@18.1.0-beta.16": "patches/embedded-postgres@18.1.0-beta.16.patch", - "acpx@0.12.0": "patches/acpx@0.12.0.patch", - "postgres@3.4.9": "patches/postgres@3.4.9.patch" + "acpx@0.12.0": "patches/acpx@0.12.0.patch" }, "overrides": { "rollup": ">=4.59.0", diff --git a/packages/db/src/postgres-driver-teardown.test.ts b/packages/db/src/postgres-driver-teardown.test.ts deleted file mode 100644 index 55b7ff04a93..00000000000 --- a/packages/db/src/postgres-driver-teardown.test.ts +++ /dev/null @@ -1,108 +0,0 @@ -import net from "node:net"; -import { afterEach, describe, expect, it } from "vitest"; -import postgres from "postgres"; - -/** - * Regression coverage for patches/postgres@3.4.9.patch. - * - * The unpatched driver crashes the whole process when a write races a - * connection close: `write()` buffers small frames and flushes them from a - * `setImmediate(nextWrite)`, and `nextWrite` called `socket.write()` without - * checking that the socket still exists. A reserved connection (sql.reserve / - * sql.begin) whose backend dies keeps accepting queries — its handler calls - * `execute()` with no socket-state check — so the deferred flush fired with - * `socket === null` and threw `TypeError: Cannot read properties of null - * (reading 'write')` from a timer callback with no try/catch above it. In CI - * that surfaced as vitest's "Vitest caught 1 unhandled error" failing suites - * whose tests had all passed, whenever a test tore down its database while a - * connection still had traffic. - * - * The fake server below speaks just enough wire protocol to hand the client - * an open connection (startup auth, then an empty result set for the - * fetch_types bootstrap query); the crash path itself never needs a real - * query to succeed. Against the unpatched driver this test fails through - * vitest's unhandled-error detection with exactly the CI signature; with the - * patch, the late query settles through the normal CONNECTION_DESTROYED path. - */ -describe("postgres driver teardown race", () => { - let server: net.Server | null = null; - let sql: ReturnType | null = null; - - afterEach(async () => { - if (sql) await sql.end({ timeout: 1 }).catch(() => {}); - sql = null; - if (server) await new Promise((resolve) => server!.close(resolve)); - server = null; - }); - - it("does not crash the process when a reserved connection's backend dies before a write flushes", async () => { - const backendSockets: net.Socket[] = []; - - // AuthenticationOk (R, code 0) + ReadyForQuery (Z, idle). - const authOk = Buffer.from([0x52, 0, 0, 0, 8, 0, 0, 0, 0]); - const readyForQuery = Buffer.from([0x5a, 0, 0, 0, 5, 0x49]); - const emptyQueryReply = Buffer.concat([ - Buffer.from([0x31, 0, 0, 0, 4]), // ParseComplete - Buffer.from([0x32, 0, 0, 0, 4]), // BindComplete - Buffer.from([0x54, 0, 0, 0, 6, 0, 0]), // RowDescription, zero fields - // CommandComplete "SELECT 0" - Buffer.from([0x43, 0, 0, 0, 0x0d, 0x53, 0x45, 0x4c, 0x45, 0x43, 0x54, 0x20, 0x30, 0]), - ]); - - server = net.createServer((socket) => { - backendSockets.push(socket); - let greeted = false; - socket.on("data", () => { - if (!greeted) { - // First frame is the StartupMessage. - greeted = true; - socket.write(Buffer.concat([authOk, readyForQuery])); - return; - } - // Any later frame (the fetch_types bootstrap query) gets an empty - // result set + ReadyForQuery, enough for the client to finish - // opening and resolve the reserve. - socket.write(Buffer.concat([emptyQueryReply, readyForQuery])); - }); - socket.on("error", () => {}); - }); - await new Promise((resolve) => server!.listen(0, "127.0.0.1", resolve)); - const port = (server.address() as net.AddressInfo).port; - - const closed = new Promise((resolve) => { - sql = postgres({ - host: "127.0.0.1", - port, - user: "test", - database: "test", - max: 1, - idle_timeout: 0, - connect_timeout: 5, - onclose: () => resolve(), - }); - }); - - // A reserved connection keeps routing queries straight to this one - // physical connection — the same surface sql.begin() transactions use. - const reserved = await sql!.reserve(); - - // Kill the backend and wait until the client has fully processed the - // close (socket nulled, pool notified). - for (const socket of backendSockets) socket.destroy(); - await closed; - - // Late query on the dead reserved connection: unpatched, this buffered - // its frame and scheduled the deferred flush that crashed the process - // one tick later. Patched, execute() refuses it up front, so it settles - // immediately — no pool shutdown required. - const settled = await reserved`select 1`.catch((error: unknown) => error); - expect(settled).toBeInstanceOf(Error); - expect(String((settled as Error).message)).toContain("CONNECTION_CLOSED"); - - // Let any stray deferred flush run before the test ends, so a regression - // in the nextWrite guard still fails this test via the unhandled error. - await new Promise((resolve) => setImmediate(() => setImmediate(resolve))); - - reserved.release(); - }); -}); diff --git a/patches/postgres@3.4.9.patch b/patches/postgres@3.4.9.patch deleted file mode 100644 index bc47aee9c7c..00000000000 --- a/patches/postgres@3.4.9.patch +++ /dev/null @@ -1,156 +0,0 @@ -diff --git a/cf/src/connection.js b/cf/src/connection.js -index 8e79170aeb13efe8c3c77ccede0cd3a115b5e1b5..c17a54b62fefa1f13bc38cd82e25d52ed467c94e 100644 ---- a/cf/src/connection.js -+++ b/cf/src/connection.js -@@ -159,6 +159,14 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose - if (terminated) - return queryError(q, Errors.connection('CONNECTION_DESTROYED', options)) - -+ // The connection lost its socket (backend death, network close) and has -+ // not reconnected yet. Reserved connections keep routing queries here, -+ // and buffering their frames would schedule a flush with nowhere to -+ // write. Refuse up front the same way `terminated` does, so the query -+ // settles immediately instead of dangling until pool shutdown. -+ if (socket === null) -+ return queryError(q, Errors.connection('CONNECTION_CLOSED', options, socket)) -+ - if (stream) - return queryError(q, Errors.generic('COPY_IN_PROGRESS', 'You cannot execute queries during copy')) - -@@ -254,6 +262,16 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose - } - - function nextWrite(fn) { -+ if (socket === null) { -+ // The connection closed between this write being scheduled and the -+ // immediate firing (or a caller raced the close handler). The close -+ // path has already settled every in-flight query, so the buffered -+ // bytes have nowhere to go: drop them instead of crashing the -+ // process from a timer callback with no try/catch above it. -+ nextWriteTimer !== null && clearImmediate(nextWriteTimer) -+ chunk = nextWriteTimer = null -+ return false -+ } - const x = socket.write(chunk, fn) - nextWriteTimer !== null && clearImmediate(nextWriteTimer) - chunk = nextWriteTimer = null -@@ -427,6 +445,7 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose - error(Errors.connection('CONNECTION_DESTROYED', options)) - - clearImmediate(nextWriteTimer) -+ chunk = nextWriteTimer = null - if (socket) { - socket.removeListener('data', data) - socket.removeListener('connect', connected) -@@ -440,6 +459,7 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose - remaining = 0 - incomings = null - clearImmediate(nextWriteTimer) -+ chunk = nextWriteTimer = null - socket.removeListener('data', data) - socket.removeListener('connect', connected) - idleTimer.cancel() -diff --git a/cjs/src/connection.js b/cjs/src/connection.js -index 07f6716702ac888215c86ad6e0071aaf55ae519c..949aa427e1afd0f68bc4cdcdf062e571568fd24d 100644 ---- a/cjs/src/connection.js -+++ b/cjs/src/connection.js -@@ -157,6 +157,14 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose - if (terminated) - return queryError(q, Errors.connection('CONNECTION_DESTROYED', options)) - -+ // The connection lost its socket (backend death, network close) and has -+ // not reconnected yet. Reserved connections keep routing queries here, -+ // and buffering their frames would schedule a flush with nowhere to -+ // write. Refuse up front the same way `terminated` does, so the query -+ // settles immediately instead of dangling until pool shutdown. -+ if (socket === null) -+ return queryError(q, Errors.connection('CONNECTION_CLOSED', options, socket)) -+ - if (stream) - return queryError(q, Errors.generic('COPY_IN_PROGRESS', 'You cannot execute queries during copy')) - -@@ -252,6 +260,16 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose - } - - function nextWrite(fn) { -+ if (socket === null) { -+ // The connection closed between this write being scheduled and the -+ // immediate firing (or a caller raced the close handler). The close -+ // path has already settled every in-flight query, so the buffered -+ // bytes have nowhere to go: drop them instead of crashing the -+ // process from a timer callback with no try/catch above it. -+ nextWriteTimer !== null && clearImmediate(nextWriteTimer) -+ chunk = nextWriteTimer = null -+ return false -+ } - const x = socket.write(chunk, fn) - nextWriteTimer !== null && clearImmediate(nextWriteTimer) - chunk = nextWriteTimer = null -@@ -425,6 +443,7 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose - error(Errors.connection('CONNECTION_DESTROYED', options)) - - clearImmediate(nextWriteTimer) -+ chunk = nextWriteTimer = null - if (socket) { - socket.removeListener('data', data) - socket.removeListener('connect', connected) -@@ -438,6 +457,7 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose - remaining = 0 - incomings = null - clearImmediate(nextWriteTimer) -+ chunk = nextWriteTimer = null - socket.removeListener('data', data) - socket.removeListener('connect', connected) - idleTimer.cancel() -diff --git a/src/connection.js b/src/connection.js -index 1b1cccde43b4570d5d071d6ffaab2669b3c2065a..8bb205e5b4b92282711ec8d105960508ccfc4252 100644 ---- a/src/connection.js -+++ b/src/connection.js -@@ -157,6 +157,14 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose - if (terminated) - return queryError(q, Errors.connection('CONNECTION_DESTROYED', options)) - -+ // The connection lost its socket (backend death, network close) and has -+ // not reconnected yet. Reserved connections keep routing queries here, -+ // and buffering their frames would schedule a flush with nowhere to -+ // write. Refuse up front the same way `terminated` does, so the query -+ // settles immediately instead of dangling until pool shutdown. -+ if (socket === null) -+ return queryError(q, Errors.connection('CONNECTION_CLOSED', options, socket)) -+ - if (stream) - return queryError(q, Errors.generic('COPY_IN_PROGRESS', 'You cannot execute queries during copy')) - -@@ -252,6 +260,16 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose - } - - function nextWrite(fn) { -+ if (socket === null) { -+ // The connection closed between this write being scheduled and the -+ // immediate firing (or a caller raced the close handler). The close -+ // path has already settled every in-flight query, so the buffered -+ // bytes have nowhere to go: drop them instead of crashing the -+ // process from a timer callback with no try/catch above it. -+ nextWriteTimer !== null && clearImmediate(nextWriteTimer) -+ chunk = nextWriteTimer = null -+ return false -+ } - const x = socket.write(chunk, fn) - nextWriteTimer !== null && clearImmediate(nextWriteTimer) - chunk = nextWriteTimer = null -@@ -425,6 +443,7 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose - error(Errors.connection('CONNECTION_DESTROYED', options)) - - clearImmediate(nextWriteTimer) -+ chunk = nextWriteTimer = null - if (socket) { - socket.removeListener('data', data) - socket.removeListener('connect', connected) -@@ -438,6 +457,7 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose - remaining = 0 - incomings = null - clearImmediate(nextWriteTimer) -+ chunk = nextWriteTimer = null - socket.removeListener('data', data) - socket.removeListener('connect', connected) - idleTimer.cancel() From 8f1e3cfe24be689b1f49ed5150d5f2d3bfac3656 Mon Sep 17 00:00:00 2001 From: Nicky Leach Date: Wed, 26 Aug 2026 11:15:45 -0700 Subject: [PATCH 31/32] feat(observability): add opt-in Sentry error monitoring for the server and the browser (#12190) ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - The server and the browser need clear error reports when an operator enables external monitoring. > - Paperclip already uses an opt-in OpenTelemetry pattern for server traces. > - Sentry can provide error reports for both runtime paths when the operator sets one data source name. > - This pull request adds one opt-in Sentry gate for the server and the browser. > - The benefit is faster diagnosis while the default setup sends no Sentry data. ## Linked Issues or Issue Description **What is improved?** Paperclip gains optional error monitoring for server and browser failures. **Subsystem affected** Cross-cutting (server, UI, and shared authentication data). **Current behavior** Paperclip has no built-in Sentry error capture for server failures or browser boundary failures. Operators must inspect local logs and browser tools. **Proposed behavior** When the operator sets `SENTRY_DSN`, the server and authenticated browser use the same Sentry project. When the variable is absent, both paths stay inactive. The server loads Sentry dynamically and fails open when the optional package is absent. **Reason and benefit** Operators can inspect runtime errors in one Sentry project. The default setup remains local and sends no monitoring data. **Breaking changes** None when `SENTRY_DSN` remains unset. Authenticated session responses add the optional `sentryDsn` field. **Additional context** The implementation uses built-in Sentry privacy options. It disables default HTTP context and breadcrumb integrations and keeps `sendDefaultPii` false. ## What Changed - Add an opt-in server Sentry gate with dynamic package loading and fail-open behavior. - Add the Sentry data source name to the authenticated session response. - Add an authenticated browser Sentry gate and React error boundary capture. - Add tests for server, browser, route, and application error paths. - Document activation, installation, privacy settings, capture behavior, and operator controls. ## Verification - Run `npx vitest run server/src/__tests__/sentry.test.ts`. - Run `npx vitest run ui/src/lib/sentry.test.ts`. - Run `npx vitest run server/src/__tests__/auth-routes.test.ts server/src/__tests__/shutdown.test.ts`. - Confirm that the full continuous integration suite passes on this pull request. - Leave `SENTRY_DSN` unset and confirm that the server and browser gates stay inactive. - Set `SENTRY_DSN` and install the optional Sentry packages before a manual capture check. ## Risks The operator controls the Sentry project and accepts the data risk when the operator enables the feature. Error objects can contain messages, stacks, or cause chains with private values. The default configuration sends no data because the feature stays off without `SENTRY_DSN`. A missing optional server package does not stop server boot. ## Model Used OpenAI Codex, GPT-5, with tool use, repository inspection, GitHub CLI operations, and code review support. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- README.md | 2 + doc/observability.md | 203 ++++++- packages/shared/src/validators/access.test.ts | 32 + packages/shared/src/validators/access.ts | 6 + server/src/__tests__/auth-routes.test.ts | 60 +- .../heartbeat-process-recovery.test.ts | 29 +- server/src/__tests__/sentry.test.ts | 561 ++++++++++++++++++ server/src/index.ts | 9 +- server/src/middleware/error-handler.ts | 14 +- server/src/routes/auth.ts | 4 + server/src/sentry.ts | 173 ++++++ server/src/shutdown.test.ts | 9 + server/src/shutdown.ts | 5 + ui/package.json | 1 + ui/src/components/AppErrorBoundary.test.tsx | 30 + ui/src/components/AppErrorBoundary.tsx | 2 + ui/src/components/RouteErrorBoundary.test.tsx | 29 + ui/src/components/RouteErrorBoundary.tsx | 2 + ui/src/components/SentryGate.test.tsx | 157 +++++ ui/src/components/SentryGate.tsx | 39 ++ ui/src/lib/sentry.test.ts | 408 +++++++++++++ ui/src/lib/sentry.ts | 165 ++++++ ui/src/main.tsx | 2 + ui/storybook/fixtures/paperclipData.ts | 1 + 24 files changed, 1926 insertions(+), 17 deletions(-) create mode 100644 server/src/__tests__/sentry.test.ts create mode 100644 server/src/sentry.ts create mode 100644 ui/src/components/SentryGate.test.tsx create mode 100644 ui/src/components/SentryGate.tsx create mode 100644 ui/src/lib/sentry.test.ts create mode 100644 ui/src/lib/sentry.ts diff --git a/README.md b/README.md index 5f19d11df6b..80ce5ce44f5 100644 --- a/README.md +++ b/README.md @@ -469,6 +469,8 @@ Find Plugins and more at [awesome-paperclip](https://github.com/gsxdsm/awesome-p Paperclip ships with opt-in OpenTelemetry auto-instrumentation for the server (traces only). It activates when `OTEL_EXPORTER_OTLP_ENDPOINT` is set and supports `grpc`, `http/protobuf`, and `http/json` via the standard `OTEL_EXPORTER_OTLP_PROTOCOL` env var. The `@opentelemetry/*` packages are optional peer dependencies — install them only if you want tracing. See [doc/observability.md](doc/observability.md) for install commands and the full env-var reference. +Paperclip also ships with opt-in Sentry error monitoring for the server and the browser. Set `SENTRY_DSN` to activate it — the server and the browser then report to the same Sentry project. `@sentry/node` is an optional peer dependency for the server; install it only if you want error monitoring. See [doc/observability.md](doc/observability.md#sentry-error-monitoring) for the install command, the privacy settings, and the full default capture set. + ## Telemetry Paperclip collects anonymous usage telemetry to help us understand how the product is used and improve it. No personal information, issue content, prompts, file paths, or secrets are ever collected. Private repository references are hashed with a per-install salt before being sent. diff --git a/doc/observability.md b/doc/observability.md index 02ddef5f4be..db3d8bea263 100644 --- a/doc/observability.md +++ b/doc/observability.md @@ -1,7 +1,8 @@ # Observability This document is the Observability contract. It covers the OpenTelemetry -trace path and two local instrumentation contracts; see the +trace path, the opt-in Sentry error-monitoring path, and two local +instrumentation contracts; see the [Telemetry Data Contract](../packages/shared/src/telemetry/README.md) for the separate first-party event system. @@ -109,6 +110,206 @@ This document also holds two local instrumentation contracts: the sandbox startup trace spans, and the sandbox duplex transport instrumentation. Both sections follow below. +## Sentry Error Monitoring + +Paperclip ships with **opt-in** Sentry error monitoring for the server +process and the browser app. The operator activates it with one +environment variable, `SENTRY_DSN`. The server and the browser both read +this same value, so both report to **one** Sentry project. The feature +uses built-in Sentry options only. It adds no `beforeSend` hook and no +custom filter code. + +When `SENTRY_DSN` is unset, the feature is fully inactive. The server +imports no Sentry package. The browser fetches no Sentry chunk. + +### Enabling Sentry + +#### 1. Install the Sentry peer dependency + +Install `@sentry/node` in the server, the same way you install the +OpenTelemetry packages above. `@sentry/node` is an *optional peer +dependency*: it is not in the default lockfile, and the server loads it +dynamically only when `SENTRY_DSN` is set. + +```bash +pnpm add @sentry/node +``` + +The browser package, `@sentry/browser`, needs no install step. It is +already a development dependency of the `ui` package, so the browser code +ships inside every build. A signed-out browser, or a browser with no DSN, +never fetches the Sentry chunk — see "DSN delivery to the browser" below. + +#### 2. Set the environment + +```bash +export SENTRY_DSN="https://@/" +``` + +No other variable is needed. + +### One Sentry project + +The server and the browser report to **one** Sentry project, because both +read the same `SENTRY_DSN` value. The server reads it from the process +environment. The browser reads it from the authenticated +`GET /api/auth/get-session` response. + +### DSN delivery to the browser + +The browser never reads the DSN from a `` tag or from any other part +of `index.html`. The served `index.html` holds no DSN — it is a static +file, built once and served unchanged to every request. + +Instead, the browser receives the DSN inside the authenticated +`GET /api/auth/get-session` response body, next to the signed-in session +and the user profile. A signed-out browser calls this route with no board +actor, so the route answers 401 and sends no DSN. A signed-out browser +therefore loads no Sentry chunk and sends no event. These pages run +signed out: + +- `/auth` +- `/cli-auth/:id` +- `/board-claim/:token` +- `/invite/:token` + +**A gap the operator must know:** a browser error that happens before the +session response arrives is not captured. The gate opens only after the +session query resolves. + +### Privacy settings + +The feature uses built-in Sentry options only. + +- `sendDefaultPii` is `false`, on both runtimes. +- `tracesSampleRate` is `0`, on both runtimes. Paperclip sends no + performance trace and no profile. +- There is no `beforeSend` hook and no custom filter, on either runtime. + +### Server request data + +**A server event carries no request data at all.** It holds no URL, no +method, no header, no cookie, no query string, and no body. This is a +verified result, not the Sentry SDK's documented default. A live test +against the real `@sentry/node@10.71.0` package proves it: it captures an +event from inside a real HTTP request handler and confirms the event +holds no request field (see `server/src/__tests__/sentry.test.ts`, "a +server event captured inside a real HTTP request handler carries no +request field"). + +The reason is `skipOpenTelemetrySetup: true`. This feature sets that +option so it never fights Paperclip's separate, independently opt-in +OpenTelemetry feature for control of the global tracer. The same option +turns off Sentry's per-request context tracking. Sentry's built-in +`RequestData` integration needs that tracking to find a URL, a method, a +header set, a cookie set, or a query string to attach. `RequestData` +stays in the integration list — the initializer does not remove it — but +it attaches nothing under this configuration. + +This holds even when the operator turns on the separate OpenTelemetry +feature too (`OTEL_EXPORTER_OTLP_ENDPOINT` set). A live test with a real +OpenTelemetry SDK, a real HTTP instrumentation package, and a real +async-context manager registered still shows no request field on the +captured event. + +A server event carries only the exception, its stack trace, and the +context the other kept default integrations add: the host name, the +runtime version, and the dependency list. See "Default capture set" +below. + +### Browser data + +The browser sends no page URL, no referrer, no user agent, and no +breadcrumb. + +### Fail-open behavior + +A failed Sentry import or a failed init never stops the server and never +breaks the browser app. Both runtimes fall through to a single diagnostic +log line and keep running with no error monitoring. + +### Default capture set + +The lists below name every event and every context field this feature +sends, so an operator can read what the feature does before turning it on. +Each Sentry integration name below is verified against the default +integration list of `@sentry/node@10.71.0` and `@sentry/browser@10.71.0`. + +**Server events this feature adds** + +- An Express `HttpError` with `status >= 500`. +- Any unknown throw that is not a `ZodError`. It always answers 500. +- A server startup failure. + +**Server events the default integrations add** + +- `OnUncaughtException` — each uncaught exception on the main thread, at + level `fatal`. The process still exits. +- `OnUnhandledRejection` — each unhandled promise rejection. The mode is + `strict`, so the process exits after the capture. +- `ChildProcess` — one event for each worker-thread `error`. +- `LinkedErrors` — the `error.cause` chain of each captured error. + +**Server context the kept integrations attach** + +- `RequestData` — attaches nothing under this feature's configuration. See + "Server request data" above for the verified reason. +- `ChildProcess` — a non-zero child-process exit becomes a breadcrumb. +- `Modules` and `Context` — the dependency list, the host name, the + operating system, and the runtime version. +- `ProcessSession` — one release-health session for each process. +- `LocalVariablesAsync` — off. It needs `includeLocalVariables: true`, + which this feature omits. +- `NodeSystemError` — a Node system error (for example, `ENOENT`) gets a + `node_system_error` context field with its error code. The `path` and + `dest` fields are removed by default. + +**Server sources this feature removes** + +- `Console` — raw `console.*` arguments. +- `ContextLines` — 7 local source lines around each stack frame. +- The outbound breadcrumb of `Http` — outbound request URLs and query + strings. + +**Browser events this feature adds** + +- A crash in the application error boundary and a crash in the route + error boundary. + +**Browser events and context the kept integrations add** + +- `GlobalHandlers` — `window.onerror` and `window.onunhandledrejection`. +- `BrowserApiErrors` — a throw inside `setTimeout`, `setInterval`, + `requestAnimationFrame`, and an event listener. +- `CultureContext` — the locale and the timezone. +- `Dedupe`, `LinkedErrors`, and `BrowserSession`. + +**Browser sources this feature removes** + +- `HttpContext` — the page URL, the referrer, and the user agent. +- `Breadcrumbs` — console output, a click and a keypress target, a + `fetch` and an `XHR` request URL, and history navigation. + +**Not captured on either runtime** + +- A Zod validation error, which answers 400. +- Each `HttpError` below status 500, such as 401, 403, 404, 409, and 422. +- A performance trace and a profile, because `tracesSampleRate` is 0. + +### Operator responsibilities + +Two controls belong to the operator. This feature ships neither one. + +1. **Set a rate limit and a quota alert.** Set a per-client-key ingestion + rate limit and a quota alert in the Sentry project. The feature sends + no built-in rate limit of its own. +2. **Give a self-hosted sink a reachable host name.** If `SENTRY_DSN` + points at a self-hosted Sentry instance, give it an externally + reachable ingest host name, not an internal-only host name. The + browser sends its events from the operator's network, not from the + server's network, so an internal-only host name fails silently for + the browser even when it works for the server. + ## Sandbox Startup Trace Spans Paperclip opens OpenTelemetry spans on the sandbox start path. These spans are diff --git a/packages/shared/src/validators/access.test.ts b/packages/shared/src/validators/access.test.ts index b8d2ffcba4f..32ae79444f8 100644 --- a/packages/shared/src/validators/access.test.ts +++ b/packages/shared/src/validators/access.test.ts @@ -106,6 +106,7 @@ describe("authSessionSchema", () => { const result = authSessionSchema.safeParse({ session: { id: "s1", userId: "u1" }, user: { id: "u1", email: "a@b.com", name: "", image: null }, + sentryDsn: null, }); expect(result.success).toBe(true); expect(result.success && result.data.user.name).toBe(null); @@ -115,6 +116,7 @@ describe("authSessionSchema", () => { const result = authSessionSchema.safeParse({ session: { id: "s1", userId: "u1" }, user: { id: "u1", email: "a@b.com", name: "Jane", image: null }, + sentryDsn: null, }); expect(result.success).toBe(true); expect(result.success && result.data.user.name).toBe("Jane"); @@ -124,6 +126,7 @@ describe("authSessionSchema", () => { const result = authSessionSchema.safeParse({ session: { id: "s1", userId: "u1" }, user: { id: "u1", email: "a@b.com", name: null, image: null }, + sentryDsn: null, }); expect(result.success).toBe(true); expect(result.success && result.data.user.name).toBe(null); @@ -133,8 +136,37 @@ describe("authSessionSchema", () => { const result = authSessionSchema.safeParse({ session: { id: "s1", userId: "u1" }, user: { id: "u1", email: "", name: "Jane", image: null }, + sentryDsn: null, }); expect(result.success).toBe(true); expect(result.success && result.data.user.email).toBe(null); }); + + it("rejects a payload with no sentryDsn field", () => { + const result = authSessionSchema.safeParse({ + session: { id: "s1", userId: "u1" }, + user: { id: "u1", email: "a@b.com", name: "Jane", image: null }, + }); + expect(result.success).toBe(false); + }); + + it("accepts a null sentryDsn", () => { + const result = authSessionSchema.safeParse({ + session: { id: "s1", userId: "u1" }, + user: { id: "u1", email: "a@b.com", name: "Jane", image: null }, + sentryDsn: null, + }); + expect(result.success).toBe(true); + expect(result.success && result.data.sentryDsn).toBe(null); + }); + + it("accepts a real sentryDsn value", () => { + const result = authSessionSchema.safeParse({ + session: { id: "s1", userId: "u1" }, + user: { id: "u1", email: "a@b.com", name: "Jane", image: null }, + sentryDsn: "https://public@o0.ingest.sentry.io/1", + }); + expect(result.success).toBe(true); + expect(result.success && result.data.sentryDsn).toBe("https://public@o0.ingest.sentry.io/1"); + }); }); diff --git a/packages/shared/src/validators/access.ts b/packages/shared/src/validators/access.ts index cee3e5567ca..d9f7f39d489 100644 --- a/packages/shared/src/validators/access.ts +++ b/packages/shared/src/validators/access.ts @@ -198,6 +198,12 @@ export const authSessionSchema = z.object({ userId: z.string().min(1), }), user: currentUserProfileSchema, + // The Sentry DSN for the current instance, or `null` when the operator has + // not set `SENTRY_DSN`. Required, not optional: a missing value must fail + // the response schema instead of silently disabling browser error + // monitoring. The browser reads this value to open its own Sentry gate — + // see `ui/src/lib/sentry.ts`. + sentryDsn: z.string().min(1).nullable(), }); export type AuthSession = z.infer; diff --git a/server/src/__tests__/auth-routes.test.ts b/server/src/__tests__/auth-routes.test.ts index 5b08b978b30..549f67618e8 100644 --- a/server/src/__tests__/auth-routes.test.ts +++ b/server/src/__tests__/auth-routes.test.ts @@ -1,6 +1,6 @@ import express from "express"; import request from "supertest"; -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it } from "vitest"; import { errorHandler } from "../middleware/index.js"; import { authRoutes } from "../routes/auth.js"; @@ -58,8 +58,15 @@ describe.sequential("auth routes", () => { email: "jane@example.com", image: "https://example.com/jane.png", }; + const originalSentryDsn = process.env.SENTRY_DSN; + + afterEach(() => { + if (originalSentryDsn === undefined) delete process.env.SENTRY_DSN; + else process.env.SENTRY_DSN = originalSentryDsn; + }); it("returns the persisted user profile in the session payload", async () => { + delete process.env.SENTRY_DSN; const app = await createApp( { type: "board", @@ -78,9 +85,60 @@ describe.sequential("auth routes", () => { userId: "user-1", }, user: baseUser, + sentryDsn: null, }); }); + it("sends sentryDsn for a board actor when SENTRY_DSN is set", async () => { + process.env.SENTRY_DSN = "https://public@o0.ingest.sentry.io/1"; + const app = await createApp( + { + type: "board", + userId: "user-1", + source: "session", + }, + baseUser, + ); + + const res = await request(app).get("/api/auth/get-session"); + + expect(res.status).toBe(200); + expect(res.body.sentryDsn).toBe("https://public@o0.ingest.sentry.io/1"); + }); + + it("sends a null sentryDsn when SENTRY_DSN is unset", async () => { + delete process.env.SENTRY_DSN; + const app = await createApp( + { + type: "board", + userId: "user-1", + source: "session", + }, + baseUser, + ); + + const res = await request(app).get("/api/auth/get-session"); + + expect(res.status).toBe(200); + expect(res.body.sentryDsn).toBe(null); + }); + + it("answers 401 and sends no DSN when the actor type is none", async () => { + process.env.SENTRY_DSN = "https://public@o0.ingest.sentry.io/1"; + const app = await createApp( + { + type: "none", + source: "none", + }, + baseUser, + ); + + const res = await request(app).get("/api/auth/get-session"); + + expect(res.status).toBe(401); + expect(res.body.sentryDsn).toBeUndefined(); + }); + it("updates the signed-in profile", async () => { const app = await createApp( { diff --git a/server/src/__tests__/heartbeat-process-recovery.test.ts b/server/src/__tests__/heartbeat-process-recovery.test.ts index bd1136990d7..174f086b56c 100644 --- a/server/src/__tests__/heartbeat-process-recovery.test.ts +++ b/server/src/__tests__/heartbeat-process-recovery.test.ts @@ -5640,18 +5640,27 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { recoveryCause: "execution_review_participant_recovery", }); - const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, issueId)); - const recoveryComment = comments.find((comment) => - comment.body.includes("pending execution-review participant once") && - noticeMetadataReferencesRecoveryAction(comment.metadata, recoveryAction.id), - ); + // The source issue flips to "blocked" before the recovery service posts + // its escalation comment and writes the activity-log event, so a read + // right after the status check can race an in-flight write. Poll for + // each row instead of reading once. + const recoveryComment = await waitForValue(async () => { + const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, issueId)); + return comments.find((comment) => + comment.body.includes("pending execution-review participant once") && + noticeMetadataReferencesRecoveryAction(comment.metadata, recoveryAction.id), + ) ?? null; + }); expect(recoveryComment).toBeTruthy(); - const activity = await db.select().from(activityLog).where(eq(activityLog.entityId, issueId)); - expect(activity.some((event) => - (event.details as Record | null)?.source === - "recovery.reconcile_execution_review_participant", - )).toBe(true); + const recoveryActivityEvent = await waitForValue(async () => { + const activity = await db.select().from(activityLog).where(eq(activityLog.entityId, issueId)); + return activity.find((event) => + (event.details as Record | null)?.source === + "recovery.reconcile_execution_review_participant", + ) ?? null; + }); + expect(recoveryActivityEvent).toBeTruthy(); }); it("blocks failed execution-review recovery under the reviewer when the source assignee differs", async () => { diff --git a/server/src/__tests__/sentry.test.ts b/server/src/__tests__/sentry.test.ts new file mode 100644 index 00000000000..2e7ab883905 --- /dev/null +++ b/server/src/__tests__/sentry.test.ts @@ -0,0 +1,561 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createRequire } from "node:module"; +import http from "node:http"; +import express from "express"; +import request from "supertest"; +import type { NextFunction, Request, Response } from "express"; +import { HttpError } from "../errors.js"; +import { errorHandler } from "../middleware/error-handler.js"; +import { finalizeServerShutdown } from "../shutdown.js"; +import { authRoutes } from "../routes/auth.js"; +import * as sentryModule from "../sentry.js"; + +/** + * Tests for the opt-in Sentry error-monitoring gate. `@sentry/node` is an + * optional runtime dependency and is NOT installed in CI, which is itself + * part of the contract under test: with `SENTRY_DSN` set and the package + * absent, the module must warn and settle instead of crashing the server. + * + * The module reads `SENTRY_DSN` at import time, so each test resets the + * module registry and imports a fresh copy. + */ + +const DSN_ENV = "SENTRY_DSN"; +const originalDsn = process.env[DSN_ENV]; + +async function importFreshSentry() { + vi.resetModules(); + return await import("../sentry.js"); +} + +/** + * Register a fake `@sentry/node` module for the next dynamic import. Each + * mock function is returned so a test can assert on the call it received. + * The mock stays in place until `vi.doUnmock` runs, so `afterEach` clears it. + */ +function mockSentryPackage() { + const init = vi.fn(); + const captureException = vi.fn(() => "event-id"); + const close = vi.fn(async () => true); + const httpIntegration = vi.fn((options: unknown) => ({ name: "Http", ...(options as object) })); + const onUnhandledRejectionIntegration = vi.fn((options: unknown) => ({ + name: "OnUnhandledRejection", + ...(options as object), + })); + + vi.doMock("@sentry/node", () => ({ + init, + captureException, + close, + httpIntegration, + onUnhandledRejectionIntegration, + })); + + return { init, captureException, close, httpIntegration, onUnhandledRejectionIntegration }; +} + +// A representative default-integration list, shaped like the array +// `@sentry/node@10.71.0`'s `getDefaultIntegrations()` returns for a Node +// server. Recorded against the published package with: +// node -e "const S=require('@sentry/node'); \ +// console.log(S.getDefaultIntegrations({}).map(i=>i.name))" +const DEFAULT_INTEGRATION_NAMES = [ + "InboundFilters", + "FunctionToString", + "LinkedErrors", + "RequestData", + "NodeSystemError", + "ConversationId", + "Console", + "OnUncaughtException", + "OnUnhandledRejection", + "ContextLines", + "LocalVariablesAsync", + "Context", + "ChildProcess", + "ProcessSession", + "Modules", + "Http", + "NodeFetch", +]; + +beforeEach(() => { + delete process.env[DSN_ENV]; +}); + +afterEach(() => { + if (originalDsn === undefined) delete process.env[DSN_ENV]; + else process.env[DSN_ENV] = originalDsn; + vi.restoreAllMocks(); + vi.doUnmock("@sentry/node"); +}); + +describe("sentryReady", () => { + it("resolves and imports no SDK when SENTRY_DSN is unset", async () => { + // A spy that fails the test if the module attempts a dynamic import of + // an SDK it should never touch on the closed-gate path. `@sentry/node` + // is not installed, so an attempted import would settle this promise + // with a warning instead of resolving clean. + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const { sentryReady } = await importFreshSentry(); + + await expect(sentryReady).resolves.toBeUndefined(); + expect(warn).not.toHaveBeenCalled(); + }); +}); + +describe("captureException", () => { + it("is a no-op and does not throw when the gate is closed", async () => { + const { captureException, sentryReady } = await importFreshSentry(); + await sentryReady; + + expect(() => captureException(new Error("boom"))).not.toThrow(); + }); +}); + +describe("shutdownSentry", () => { + it("resolves once for concurrent callers", async () => { + const { shutdownSentry } = await importFreshSentry(); + + const first = shutdownSentry(); + const second = shutdownSentry(); + + // Memoized: concurrent callers share one shutdown promise. + expect(first).toBe(second); + await expect(first).resolves.toBeUndefined(); + }); +}); + +/** + * Seam tests for the two `errorHandler` call sites that report to Sentry. + * These tests spy on the exported `captureException` binding rather than + * import a real client, so they assert the call-site shape (one call, the + * Error object only) without a live Sentry SDK. + */ +describe("errorHandler Sentry capture", () => { + function makeReq(): Request { + return { + method: "GET", + originalUrl: "/api/test", + body: { a: 1 }, + params: { id: "123" }, + query: { q: "x" }, + } as unknown as Request; + } + + function makeRes(): Response { + const res = { + status: vi.fn(), + json: vi.fn(), + } as unknown as Response; + (res.status as unknown as ReturnType).mockReturnValue(res); + return res; + } + + it("captures one event for a 500-level HttpError", () => { + const capture = vi.spyOn(sentryModule, "captureException").mockImplementation(() => {}); + const req = makeReq(); + const res = makeRes(); + const next = vi.fn() as unknown as NextFunction; + const err = new HttpError(500, "db exploded"); + + errorHandler(err, req, res, next); + + expect(capture).toHaveBeenCalledTimes(1); + }); + + it("captures one event for an unknown error", () => { + const capture = vi.spyOn(sentryModule, "captureException").mockImplementation(() => {}); + const req = makeReq(); + const res = makeRes(); + const next = vi.fn() as unknown as NextFunction; + const err = new Error("boom"); + + errorHandler(err, req, res, next); + + expect(capture).toHaveBeenCalledTimes(1); + }); + + it("captures no event for a Zod validation 400 response", () => { + const capture = vi.spyOn(sentryModule, "captureException").mockImplementation(() => {}); + const req = makeReq(); + const res = makeRes(); + const next = vi.fn() as unknown as NextFunction; + const issue = { + code: "invalid_type", + expected: "string", + received: "undefined", + path: ["provider"], + message: "Required", + }; + const err = Object.assign(new Error("Validation failed"), { + name: "ZodError", + issues: [issue], + }); + + errorHandler(err, req, res, next); + + expect(res.status).toHaveBeenCalledWith(400); + expect(capture).not.toHaveBeenCalled(); + }); + + it("passes the Error object only to captureException, never the request-bearing ErrorContext", () => { + const capture = vi.spyOn(sentryModule, "captureException").mockImplementation(() => {}); + const req = makeReq(); + const res = makeRes(); + const next = vi.fn() as unknown as NextFunction; + const err = new Error("boom"); + + errorHandler(err, req, res, next); + + expect(capture).toHaveBeenCalledWith(err); + const [received] = capture.mock.calls[0]!; + expect(received).toBeInstanceOf(Error); + // The `ErrorContext` shape carries the request body, params, and query. + // The received value must not carry any of them. + expect(received).not.toHaveProperty("reqBody"); + expect(received).not.toHaveProperty("reqParams"); + expect(received).not.toHaveProperty("reqQuery"); + }); +}); + +describe("finalizeServerShutdown Sentry teardown", () => { + it("calls shutdownSentry after shutdownInstrumentation", async () => { + const order: string[] = []; + const shutdownInstrumentation = vi.fn(async () => { + order.push("instrumentation"); + }); + const shutdownSentry = vi.fn(async () => { + order.push("sentry"); + }); + + await finalizeServerShutdown({ + signal: "SIGTERM", + shutdownAppServices: undefined, + stopEmbeddedPostgres: null, + shutdownInstrumentation, + shutdownSentry, + log: { info: vi.fn(), error: vi.fn() }, + }); + + expect(order).toEqual(["instrumentation", "sentry"]); + }); +}); + +describe("missing @sentry/node package", () => { + it("logs one warning and resolves", async () => { + process.env[DSN_ENV] = "https://public@o0.ingest.sentry.io/1"; + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const { sentryReady } = await importFreshSentry(); + + // Bootstrap must absorb the failed dynamic import — the server keeps + // booting without error monitoring rather than crashing on an opt-in + // feature. + await expect(sentryReady).resolves.toBeUndefined(); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining("@sentry/node package is not installed"), + expect.anything(), + ); + }); +}); + +describe("buildSentryInitOptions", () => { + it("sets sendDefaultPii false, tracesSampleRate 0, and skipOpenTelemetrySetup true", async () => { + const { buildSentryInitOptions } = await importFreshSentry(); + + const options = buildSentryInitOptions("https://public@o0.ingest.sentry.io/1", { + httpIntegration: () => ({ name: "Http" }), + onUnhandledRejectionIntegration: () => ({ name: "OnUnhandledRejection" }), + }); + + expect(options.sendDefaultPii).toBe(false); + expect(options.tracesSampleRate).toBe(0); + expect(options.skipOpenTelemetrySetup).toBe(true); + }); + + it("passes onUnhandledRejection with mode strict", async () => { + const { buildSentryInitOptions } = await importFreshSentry(); + const onUnhandledRejectionIntegration = vi.fn((options: unknown) => ({ + name: "OnUnhandledRejection", + ...(options as object), + })); + + const options = buildSentryInitOptions("https://public@o0.ingest.sentry.io/1", { + httpIntegration: () => ({ name: "Http" }), + onUnhandledRejectionIntegration, + }); + const resolved = options.integrations(DEFAULT_INTEGRATION_NAMES.map((name) => ({ name }))); + + expect(onUnhandledRejectionIntegration).toHaveBeenCalledWith({ mode: "strict" }); + const rejectionIntegration = resolved.find((i) => i.name === "OnUnhandledRejection"); + expect(rejectionIntegration).toMatchObject({ mode: "strict" }); + // The default OnUnhandledRejection entry must not survive alongside it. + expect(resolved.filter((i) => i.name === "OnUnhandledRejection")).toHaveLength(1); + }); + + it("the resolved server integration list holds no Console integration and no ContextLines integration", async () => { + const { buildSentryInitOptions } = await importFreshSentry(); + + const options = buildSentryInitOptions("https://public@o0.ingest.sentry.io/1", { + httpIntegration: () => ({ name: "Http" }), + onUnhandledRejectionIntegration: () => ({ name: "OnUnhandledRejection" }), + }); + const resolved = options.integrations(DEFAULT_INTEGRATION_NAMES.map((name) => ({ name }))); + const names = resolved.map((i) => i.name); + + expect(names).not.toContain("Console"); + expect(names).not.toContain("ContextLines"); + }); + + it("the resolved server integration list keeps OnUncaughtException, OnUnhandledRejection, LinkedErrors, and RequestData", async () => { + const { buildSentryInitOptions } = await importFreshSentry(); + + const options = buildSentryInitOptions("https://public@o0.ingest.sentry.io/1", { + httpIntegration: () => ({ name: "Http" }), + onUnhandledRejectionIntegration: () => ({ name: "OnUnhandledRejection" }), + }); + const resolved = options.integrations(DEFAULT_INTEGRATION_NAMES.map((name) => ({ name }))); + const names = resolved.map((i) => i.name); + + // The full error-capture set the plan requires, not just the four named + // in this test's title. + expect(names).toEqual( + expect.arrayContaining([ + "OnUncaughtException", + "OnUnhandledRejection", + "ChildProcess", + "LinkedErrors", + "RequestData", + "Modules", + "Context", + "ProcessSession", + ]), + ); + }); + + it("turns the outbound HTTP breadcrumb off and keeps the rest of the Http integration", async () => { + const { buildSentryInitOptions } = await importFreshSentry(); + const httpIntegration = vi.fn((options: unknown) => ({ name: "Http", ...(options as object) })); + + const options = buildSentryInitOptions("https://public@o0.ingest.sentry.io/1", { + httpIntegration, + onUnhandledRejectionIntegration: () => ({ name: "OnUnhandledRejection" }), + }); + const resolved = options.integrations(DEFAULT_INTEGRATION_NAMES.map((name) => ({ name }))); + + expect(httpIntegration).toHaveBeenCalledWith({ breadcrumbs: false }); + expect(resolved.filter((i) => i.name === "Http")).toHaveLength(1); + }); +}); + +describe("with @sentry/node mocked", () => { + it("initializes the client and shares captureException / shutdownSentry with it", async () => { + process.env[DSN_ENV] = "https://public@o0.ingest.sentry.io/1"; + const mocks = mockSentryPackage(); + + const { sentryReady, captureException, shutdownSentry } = await importFreshSentry(); + await sentryReady; + + expect(mocks.init).toHaveBeenCalledTimes(1); + const initOptions = mocks.init.mock.calls[0][0] as { dsn: string }; + expect(initOptions.dsn).toBe("https://public@o0.ingest.sentry.io/1"); + + captureException(new Error("boom")); + expect(mocks.captureException).toHaveBeenCalledWith(expect.any(Error)); + + await shutdownSentry(); + expect(mocks.close).toHaveBeenCalledWith(5_000); + }); +}); + +/** + * Proves the one-project result: the server and the browser both resolve + * their Sentry client from the same `SENTRY_DSN` value, so both send events + * to the same Sentry project. The browser reads its DSN from the + * `GET /api/auth/get-session` response body (see `ui/src/lib/sentry.ts` and + * `ui/src/components/SentryGate.tsx`), never from a `` tag. + */ +describe("one-project resolution", () => { + function makeSessionApp() { + const app = express(); + app.use((req, _res, next) => { + req.actor = { type: "board", userId: "user-1", source: "session" }; + next(); + }); + const db = { + select: () => ({ + from: () => ({ + where: () => + Promise.resolve([ + { id: "user-1", name: "Jane Example", email: "jane@example.com", image: null }, + ]), + }), + }), + }; + app.use("/api/auth", authRoutes(db as unknown as Parameters[0])); + app.use(errorHandler); + return app; + } + + it("the server initializer and GET /api/auth/get-session resolve the same Sentry project", async () => { + process.env[DSN_ENV] = "https://public@o0.ingest.sentry.io/1"; + const mocks = mockSentryPackage(); + + const { sentryReady } = await importFreshSentry(); + await sentryReady; + const initOptions = mocks.init.mock.calls[0]![0] as { dsn: string }; + + const app = makeSessionApp(); + const res = await request(app).get("/api/auth/get-session"); + + expect(res.status).toBe(200); + expect(res.body.sentryDsn).toBe(initOptions.dsn); + }); +}); + +// `@sentry/node` is an optional runtime dependency (see the module comment +// in sentry.ts). When it is absent, the three tests below cannot run against +// the true SDK, so they are skipped — the same pattern instrumentation.test.ts +// uses for its OpenTelemetry-SDK-dependent test. +const sentryPackage = (() => { + try { + const require = createRequire(import.meta.url); + return require("@sentry/node") as { + init(options: Record): unknown; + captureException(error: unknown): string; + httpIntegration(options: { breadcrumbs: boolean }): { name: string }; + onUnhandledRejectionIntegration(options: { mode: string }): { name: string }; + flush(timeout?: number): Promise; + close(timeout?: number): Promise; + }; + } catch { + return null; + } +})(); + +describe.skipIf(!sentryPackage)("captured event shape against the real @sentry/node SDK", () => { + /** + * Initialize the real SDK with this module's exact options, plus a + * transport stub so no event leaves the test process, plus `beforeSend` + * so the test can inspect the resolved event before it would have been + * sent. `beforeSend` is test-only introspection — the module under test + * adds no `beforeSend` of its own (constraint: built-in options only). + */ + async function initRealSentryForTest(onEvent: (event: Record) => void) { + const Sentry = sentryPackage!; + const { buildSentryInitOptions } = await importFreshSentry(); + const options = { + ...buildSentryInitOptions("https://public@o0.ingest.sentry.io/1", Sentry), + transport: () => ({ send: async () => ({}), flush: async () => true }), + beforeSend: (event: Record) => { + onEvent(event); + return event; + }, + }; + Sentry.init(options); + } + + it("a server event captured after a console.error call carries no console breadcrumb", async () => { + const Sentry = sentryPackage!; + let captured: Record | null = null; + await initRealSentryForTest((event) => { + captured = event; + }); + + // eslint-disable-next-line no-console + console.error("child process stderr: simulated failure"); + Sentry.captureException(new Error("after console.error")); + await Sentry.flush(2000); + + expect(captured).not.toBeNull(); + expect((captured as Record).breadcrumbs).toBeUndefined(); + }); + + it("a server event carries no stack-frame source context", async () => { + const Sentry = sentryPackage!; + let captured: Record | null = null; + await initRealSentryForTest((event) => { + captured = event; + }); + + Sentry.captureException(new Error("stack frame check")); + await Sentry.flush(2000); + + const event = captured as unknown as { + exception: { values: Array<{ stacktrace: { frames: Array> } }> }; + }; + const frames = event.exception.values[0].stacktrace.frames; + expect(frames.length).toBeGreaterThan(0); + for (const frame of frames) { + expect(frame.context_line).toBeUndefined(); + expect(frame.pre_context).toBeUndefined(); + expect(frame.post_context).toBeUndefined(); + } + }); + + it("a server event carries no outbound HTTP breadcrumb", async () => { + const Sentry = sentryPackage!; + let captured: Record | null = null; + await initRealSentryForTest((event) => { + captured = event; + }); + + const server = http.createServer((_req, res) => res.end("ok")); + await new Promise((resolve) => server.listen(0, resolve)); + const port = (server.address() as { port: number }).port; + await new Promise((resolve) => { + http.get(`http://127.0.0.1:${port}/probe?token=secret`, (res) => { + res.resume(); + res.on("end", resolve); + }); + }); + server.close(); + + Sentry.captureException(new Error("after outbound http call")); + await Sentry.flush(2000); + + expect((captured as unknown as Record).breadcrumbs).toBeUndefined(); + }); + + /** + * `skipOpenTelemetrySetup: true` (set above) keeps this module out of + * Paperclip's separate, independently opt-in OpenTelemetry feature. It + * also turns off Sentry's own per-request async-context tracking. The + * `RequestData` integration reads the inbound URL, method, headers, + * cookies, and query string from that per-request context. With the + * context off, a captured event carries no request field — not the + * SDK's documented default. This test proves the gap, so the operator + * documentation states the true capture set. + */ + it("a server event captured inside a real HTTP request handler carries no request field", async () => { + const Sentry = sentryPackage!; + let captured: Record | null = null; + await initRealSentryForTest((event) => { + captured = event; + }); + + const server = http.createServer((req, res) => { + req.on("data", () => {}); + req.on("end", () => { + Sentry.captureException(new Error("boom from a real request handler")); + res.end("ok"); + }); + }); + await new Promise((resolve) => server.listen(0, resolve)); + const port = (server.address() as { port: number }).port; + await new Promise((resolve) => { + http.get(`http://127.0.0.1:${port}/api/test?foo=bar`, (res) => { + res.resume(); + res.on("end", resolve); + }); + }); + server.close(); + await Sentry.flush(2000); + + expect(captured).not.toBeNull(); + expect((captured as unknown as Record).request).toBeUndefined(); + }); +}); diff --git a/server/src/index.ts b/server/src/index.ts index e455de481b8..3890076b041 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -4,6 +4,7 @@ // instrumentationReady before opening DB connections or constructing the // HTTP server, so trace coverage does not depend on incidental timing. import { instrumentationReady, shutdownInstrumentation } from "./instrumentation.js"; +import { sentryReady, shutdownSentry, captureException } from "./sentry.js"; import { existsSync, readFileSync, rmSync } from "node:fs"; import { createServer } from "node:http"; import { resolve } from "node:path"; @@ -157,6 +158,9 @@ export async function startServer(): Promise { // Tracing must be active (or have failed and logged) before the first DB // connection or the HTTP server exists — see instrumentation.ts. await instrumentationReady; + // Error monitoring must be ready before the first request can fail — see + // sentry.ts. + await sentryReady; ensureDecisionSigningSecret(); let config = loadConfig(); initTelemetry({ enabled: config.telemetryEnabled }); @@ -1780,6 +1784,7 @@ export async function startServer(): Promise { shutdownAppServices: appShutdown, stopEmbeddedPostgres, shutdownInstrumentation, + shutdownSentry, log: logger, }); @@ -1814,8 +1819,10 @@ function isMainModule(metaUrl: string): boolean { } if (isMainModule(import.meta.url)) { - void startServer().catch((err) => { + void startServer().catch(async (err) => { logger.error({ err }, "Paperclip server failed to start"); + captureException(err); + await shutdownSentry(); process.exit(1); }); } diff --git a/server/src/middleware/error-handler.ts b/server/src/middleware/error-handler.ts index 0a4aa1a1703..0c0a218a1df 100644 --- a/server/src/middleware/error-handler.ts +++ b/server/src/middleware/error-handler.ts @@ -4,6 +4,7 @@ import { ZodError } from "zod"; import { HttpError } from "../errors.js"; import { trackErrorHandlerCrash } from "@paperclipai/shared/telemetry"; import { getTelemetryClient } from "../telemetry.js"; +import { captureException } from "../sentry.js"; import { COMPANY_IMPORT_API_PATH } from "../routes/company-import-paths.js"; import { logger } from "./logger.js"; import { @@ -49,6 +50,13 @@ function attachErrorContext( } } +/** Report a server-side crash to every error sink. */ +function reportCrash(error: Error): void { + const tc = getTelemetryClient(); + if (tc) trackErrorHandlerCrash(tc, { errorCode: error.name }); + captureException(error); +} + function getPaperclipDb(req: Request): Db | null { const locals = req.app?.locals as { paperclipDb?: Db; db?: Db } | undefined; return locals?.paperclipDb ?? locals?.db ?? null; @@ -107,8 +115,7 @@ export function errorHandler( { message: err.message, stack: err.stack, name: err.name, details: err.details }, err, ); - const tc = getTelemetryClient(); - if (tc) trackErrorHandlerCrash(tc, { errorCode: err.name }); + reportCrash(err); } res.status(err.status).json({ error: err.message, @@ -147,8 +154,7 @@ export function errorHandler( rootError, ); - const tc = getTelemetryClient(); - if (tc) trackErrorHandlerCrash(tc, { errorCode: rootError.name }); + reportCrash(rootError); res.status(500).json({ error: "Internal server error", diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts index ee94341c8fd..4cb37e05088 100644 --- a/server/src/routes/auth.ts +++ b/server/src/routes/auth.ts @@ -49,6 +49,10 @@ export function authRoutes(db: Db) { userId: req.actor.userId, }, user, + // The browser reads this value to open its own Sentry gate — see + // `ui/src/lib/sentry.ts`. `req.actor.type` already gates this whole + // handler, so no second authorization check runs here. + sentryDsn: process.env.SENTRY_DSN || null, })); }); diff --git a/server/src/sentry.ts b/server/src/sentry.ts new file mode 100644 index 00000000000..76f75d2a1db --- /dev/null +++ b/server/src/sentry.ts @@ -0,0 +1,173 @@ +// Optional Sentry error monitoring for the server process. +// +// Activated only when `SENTRY_DSN` is set. When unset, no Sentry package is +// loaded at all. +// +// The import is dynamic and the package is an optional runtime dependency — +// operators who want server-side error monitoring install `@sentry/node` +// themselves. That keeps Sentry off the default dependency graph and avoids +// forcing a lockfile bump for an opt-in feature. This gate mirrors the +// OpenTelemetry gate in `instrumentation.ts`. +// +// OpenTelemetry keeps ownership of trace setup: the initializer passes +// `skipOpenTelemetrySetup: true` and `tracesSampleRate: 0`, so this module +// adds error monitoring only and starts no span or trace behavior of its +// own. +// +// Default-integration privacy note: `sendDefaultPii: false` filters values +// by name, inside the `RequestData` integration only. Three other default +// integrations copy raw values past that filter, so the initializer removes +// or narrows them with built-in Sentry options — no custom filter code: +// - `Console` turns a `console.*` call into a breadcrumb with the raw +// arguments. The initializer drops it. +// - `ContextLines` reads local source lines around each stack frame off +// the host disk. The initializer drops it. +// - `Http` records a breadcrumb for each outbound request, with its URL +// and query string. The initializer keeps the integration (`RequestData` +// and request isolation need it) and turns the breadcrumb off with the +// integration's own `breadcrumbs` option. +// +// `onUnhandledRejectionIntegration` defaults to `mode: "warn"`, which +// registers a `process.on("unhandledRejection")` listener. Node cancels its +// own crash-on-unhandled-rejection behavior when any listener is registered. +// The server relies on that crash today, so the initializer passes +// `mode: "strict"`: Sentry still captures the event, then exits the process, +// so the existing crash-and-restart behavior stays. + +const dsn = process.env.SENTRY_DSN; + +/** The subset of the `@sentry/node` client surface this gate calls. */ +interface SentryHandle { + captureException(error: unknown): string; + close(timeout?: number): Promise; +} + +let sentryHandle: SentryHandle | null = null; +let shutdownPromise: Promise | null = null; + +/** + * Resolves once the Sentry SDK has started, or once bootstrap has failed and + * logged, or at once when `SENTRY_DSN` is unset. No caller needs to await + * this before calling `captureException` — it is a no-op until ready — but + * `index.ts` awaits it at startup so the first real error has a live client. + */ +export const sentryReady: Promise = dsn ? bootstrapSentry(dsn) : Promise.resolve(); + +/** + * Report an error to Sentry. A no-op before the gate opens, when the gate + * never opens (`SENTRY_DSN` unset), or when bootstrap failed. Never throws — + * observability must not change control flow. + */ +export function captureException(error: unknown): void { + if (!sentryHandle) return; + try { + sentryHandle.captureException(error); + } catch (err) { + // eslint-disable-next-line no-console + console.error("[paperclip] Sentry captureException failed", err); + } +} + +/** + * Flush buffered events and close the Sentry client. Idempotent — concurrent + * callers share one shutdown. A no-op when monitoring is off or bootstrap + * failed. + */ +export function shutdownSentry(): Promise { + shutdownPromise ??= (async () => { + await sentryReady; + if (!sentryHandle) return; + try { + // Awaiting matters: the client flushes buffered events to Sentry + // during close; exiting before it settles silently drops them. + await sentryHandle.close(5_000); + } catch (err) { + // eslint-disable-next-line no-console + console.error("[paperclip] Sentry shutdown failed", err); + } + })(); + return shutdownPromise; +} + +/** + * The subset of the `@sentry/node` module surface the initializer needs to + * build its options object. A structural type, not the real Sentry type — + * the real type is unavailable at compile time because the package is an + * optional runtime dependency (see the module comment above). + */ +interface SentryModuleLike { + httpIntegration(options: { breadcrumbs: boolean }): { name: string }; + onUnhandledRejectionIntegration(options: { mode: string }): { name: string }; +} + +/** The `Sentry.init` options this gate builds. */ +export interface SentryInitOptions { + dsn: string; + skipOpenTelemetrySetup: boolean; + tracesSampleRate: number; + sendDefaultPii: boolean; + integrations: (defaults: Array<{ name: string }>) => Array<{ name: string }>; +} + +/** + * Build the `Sentry.init` options object. A pure function, split out from + * `bootstrapSentry` so a test can call it with a real `@sentry/node` module + * and assert the resolved integration list and the captured-event shape + * against the true SDK, not a stand-in. + */ +export function buildSentryInitOptions( + dsn: string, + Sentry: SentryModuleLike, +): SentryInitOptions { + return { + dsn, + skipOpenTelemetrySetup: true, + tracesSampleRate: 0, + sendDefaultPii: false, + integrations: (defaults: Array<{ name: string }>) => { + const kept = defaults.filter( + (integration) => + integration.name !== "Console" && + integration.name !== "ContextLines" && + integration.name !== "Http" && + integration.name !== "OnUnhandledRejection", + ); + return [ + ...kept, + // Keep the rest of the Http integration — RequestData and request + // isolation need it — but turn the outbound breadcrumb off. + Sentry.httpIntegration({ breadcrumbs: false }), + // Keep today's crash-on-unhandled-rejection behavior. See the + // module comment above for why the default mode cannot stay. + Sentry.onUnhandledRejectionIntegration({ mode: "strict" }), + ]; + }, + }; +} + +async function bootstrapSentry(dsn: string): Promise { + try { + // Dynamic import so type-resolution doesn't require the package to be + // installed unless the operator actually opts in. + // @ts-ignore optional peer dep + const Sentry = await import("@sentry/node"); + + Sentry.init(buildSentryInitOptions(dsn, Sentry)); + + sentryHandle = { + captureException: (error) => Sentry.captureException(error), + close: (timeout) => Sentry.close(timeout), + }; + } catch (err) { + // The package is not installed, or the dynamic import or init call + // failed. Fall through with a single diagnostic so the opt-in path is + // self-documenting. The gate fails open — the server keeps booting + // without error monitoring rather than crashing on an opt-in feature. + // eslint-disable-next-line no-console + console.warn( + "[paperclip] SENTRY_DSN is set but the @sentry/node package is not " + + "installed. Install @sentry/node to enable server error monitoring.", + err, + ); + } +} diff --git a/server/src/shutdown.test.ts b/server/src/shutdown.test.ts index 5b8a3c7a954..9b994248f81 100644 --- a/server/src/shutdown.test.ts +++ b/server/src/shutdown.test.ts @@ -37,6 +37,9 @@ describe("finalizeServerShutdown", () => { const shutdownInstrumentation = vi.fn(async () => { order.push("instrumentation:flush"); }); + const shutdownSentry = vi.fn(async () => { + order.push("sentry:flush"); + }); let exited = false; const finalize = finalizeServerShutdown({ @@ -44,6 +47,7 @@ describe("finalizeServerShutdown", () => { shutdownAppServices, stopEmbeddedPostgres, shutdownInstrumentation, + shutdownSentry, log: stubLogger(), }).then(() => { // This models the caller's `process.exit(0)` continuation. @@ -67,6 +71,7 @@ describe("finalizeServerShutdown", () => { "appServices:settled", "postgres:stop", "instrumentation:flush", + "sentry:flush", "exit", ]); }); @@ -87,6 +92,7 @@ describe("finalizeServerShutdown", () => { const shutdownInstrumentation = vi.fn(async () => { order.push("instrumentation:flush"); }); + const shutdownSentry = vi.fn(async () => undefined); const log = stubLogger(); let exited = false; @@ -95,6 +101,7 @@ describe("finalizeServerShutdown", () => { shutdownAppServices, stopEmbeddedPostgres, shutdownInstrumentation, + shutdownSentry, log, }).then(() => { exited = true; @@ -120,6 +127,7 @@ describe("finalizeServerShutdown", () => { it("skips the database stop when no embedded PostgreSQL runs in this process", async () => { const shutdownAppServices = vi.fn(async () => undefined); const shutdownInstrumentation = vi.fn(async () => undefined); + const shutdownSentry = vi.fn(async () => undefined); const log = stubLogger(); await finalizeServerShutdown({ @@ -127,6 +135,7 @@ describe("finalizeServerShutdown", () => { shutdownAppServices, stopEmbeddedPostgres: null, shutdownInstrumentation, + shutdownSentry, log, }); diff --git a/server/src/shutdown.ts b/server/src/shutdown.ts index 9882e256ecf..bc15aca8f2c 100644 --- a/server/src/shutdown.ts +++ b/server/src/shutdown.ts @@ -25,6 +25,7 @@ export async function finalizeServerShutdown(input: { shutdownAppServices: (() => Promise) | undefined; stopEmbeddedPostgres: (() => Promise) | null; shutdownInstrumentation: () => Promise; + shutdownSentry: () => Promise; log: ShutdownLogger; }): Promise { const { signal } = input; @@ -50,6 +51,10 @@ export async function finalizeServerShutdown(input: { // Flush buffered OTel spans before the process goes away; without this await // the exporter's final batch is dropped on exit. await input.shutdownInstrumentation(); + + // Flush buffered Sentry events before the process goes away; without this + // await the last events are dropped on exit. + await input.shutdownSentry(); } const COORDINATED_SHUTDOWN_SIGNALS = ["SIGINT", "SIGTERM"] as const; diff --git a/ui/package.json b/ui/package.json index bf0fb32c38b..ec0e14d9f02 100644 --- a/ui/package.json +++ b/ui/package.json @@ -72,6 +72,7 @@ "tailwind-merge": "^3.6.0" }, "devDependencies": { + "@sentry/browser": "^10.71.0", "@storybook/addon-a11y": "10.5.10", "@storybook/addon-docs": "10.5.10", "@storybook/react-vite": "10.5.10", diff --git a/ui/src/components/AppErrorBoundary.test.tsx b/ui/src/components/AppErrorBoundary.test.tsx index 8de874f7e44..285b9c595fa 100644 --- a/ui/src/components/AppErrorBoundary.test.tsx +++ b/ui/src/components/AppErrorBoundary.test.tsx @@ -5,6 +5,12 @@ import { createRoot } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { AppErrorBoundary } from "./AppErrorBoundary"; +const captureBrowserExceptionMock = vi.hoisted(() => vi.fn()); + +vi.mock("@/lib/sentry", () => ({ + captureBrowserException: (error: unknown) => captureBrowserExceptionMock(error), +})); + function BoomRender(): never { throw new Error("Maximum update depth exceeded"); } @@ -30,6 +36,30 @@ describe("AppErrorBoundary", () => { afterEach(() => { consoleErrorSpy.mockRestore(); container.remove(); + captureBrowserExceptionMock.mockClear(); + }); + + it("reports one captured error and keeps the reload prompt", () => { + const root = createRoot(container); + act(() => { + root.render( + + + , + ); + }); + + expect(captureBrowserExceptionMock).toHaveBeenCalledTimes(1); + expect(captureBrowserExceptionMock).toHaveBeenCalledWith(expect.any(Error)); + expect( + Array.from(container.querySelectorAll("button")).some( + (button) => button.textContent === "Reload page", + ), + ).toBe(true); + + act(() => { + root.unmount(); + }); }); it("renders a reload prompt instead of a blank page when the shell throws in render", () => { diff --git a/ui/src/components/AppErrorBoundary.tsx b/ui/src/components/AppErrorBoundary.tsx index 12717b7747f..68884f06141 100644 --- a/ui/src/components/AppErrorBoundary.tsx +++ b/ui/src/components/AppErrorBoundary.tsx @@ -1,4 +1,5 @@ import { Component, type ErrorInfo, type ReactNode } from "react"; +import { captureBrowserException } from "@/lib/sentry"; type AppErrorBoundaryState = { error: Error | null; @@ -24,6 +25,7 @@ export class AppErrorBoundary extends Component<{ children: ReactNode }, AppErro override componentDidCatch(error: unknown, info: ErrorInfo): void { console.error("App shell crashed", { error, componentStack: info.componentStack }); + captureBrowserException(error); } override render() { diff --git a/ui/src/components/RouteErrorBoundary.test.tsx b/ui/src/components/RouteErrorBoundary.test.tsx index d6195f1ecfd..ba8f7d34cdd 100644 --- a/ui/src/components/RouteErrorBoundary.test.tsx +++ b/ui/src/components/RouteErrorBoundary.test.tsx @@ -7,12 +7,17 @@ import { RouteErrorBoundary } from "./RouteErrorBoundary"; const navigateMock = vi.hoisted(() => vi.fn()); const routerLocation = vi.hoisted(() => ({ current: { pathname: "/co/agents/new", search: "?adapterType=claude_local" } })); +const captureBrowserExceptionMock = vi.hoisted(() => vi.fn()); vi.mock("@/lib/router", () => ({ useLocation: () => routerLocation.current, useNavigate: () => navigateMock, })); +vi.mock("@/lib/sentry", () => ({ + captureBrowserException: (error: unknown) => captureBrowserExceptionMock(error), +})); + function Boom(): never { throw new Error("Maximum update depth exceeded"); } @@ -33,6 +38,30 @@ describe("RouteErrorBoundary", () => { afterEach(() => { consoleErrorSpy.mockRestore(); container.remove(); + captureBrowserExceptionMock.mockClear(); + }); + + it("reports one captured error and keeps the recovery button", () => { + const root = createRoot(container); + act(() => { + root.render( + + + , + ); + }); + + expect(captureBrowserExceptionMock).toHaveBeenCalledTimes(1); + expect(captureBrowserExceptionMock).toHaveBeenCalledWith(expect.any(Error)); + expect( + Array.from(container.querySelectorAll("button")).some( + (button) => button.textContent === "Go back", + ), + ).toBe(true); + + act(() => { + root.unmount(); + }); }); it("renders a recoverable error card instead of a blank page when a child throws", () => { diff --git a/ui/src/components/RouteErrorBoundary.tsx b/ui/src/components/RouteErrorBoundary.tsx index 69ab488445d..2c2bd9403aa 100644 --- a/ui/src/components/RouteErrorBoundary.tsx +++ b/ui/src/components/RouteErrorBoundary.tsx @@ -1,6 +1,7 @@ import { Component, type ErrorInfo, type ReactNode } from "react"; import { useLocation, useNavigate } from "@/lib/router"; import { Button } from "@/components/ui/button"; +import { captureBrowserException } from "@/lib/sentry"; type RouteErrorBoundaryInnerProps = { resetKey: string; @@ -21,6 +22,7 @@ class RouteErrorBoundaryInner extends Component vi.fn()); +const initBrowserErrorMonitoringMock = vi.hoisted(() => vi.fn(async (_dsn: string) => {})); +const teardownBrowserErrorMonitoringMock = vi.hoisted(() => vi.fn(async () => {})); + +vi.mock("@/api/auth", () => ({ + authApi: { getSession: () => getSessionMock() }, +})); + +vi.mock("@/lib/sentry", () => ({ + initBrowserErrorMonitoring: (dsn: string) => initBrowserErrorMonitoringMock(dsn), + teardownBrowserErrorMonitoring: () => teardownBrowserErrorMonitoringMock(), +})); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +async function flushReact() { + await act(async () => { + await Promise.resolve(); + await new Promise((resolve) => window.setTimeout(resolve, 0)); + }); +} + +describe("SentryGate", () => { + let container: HTMLDivElement; + let queryClient: QueryClient; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + }); + + afterEach(() => { + container.remove(); + document.body.innerHTML = ""; + vi.clearAllMocks(); + }); + + async function renderGate() { + const root = createRoot(container); + await act(async () => { + root.render( + + + , + ); + }); + await flushReact(); + await flushReact(); + return root; + } + + it("loads no SDK when the session query answers null", async () => { + getSessionMock.mockResolvedValue(null); + + const root = await renderGate(); + + expect(initBrowserErrorMonitoringMock).not.toHaveBeenCalled(); + expect(container.textContent).toBe(""); + root.unmount(); + }); + + it("loads no SDK when the session sentryDsn is null", async () => { + getSessionMock.mockResolvedValue({ + session: { id: "s1", userId: "u1" }, + user: { id: "u1", email: "a@b.com", name: "Jane", image: null }, + sentryDsn: null, + }); + + const root = await renderGate(); + + expect(initBrowserErrorMonitoringMock).not.toHaveBeenCalled(); + root.unmount(); + }); + + it("opens the gate once when the session carries a DSN", async () => { + getSessionMock.mockResolvedValue({ + session: { id: "s1", userId: "u1" }, + user: { id: "u1", email: "a@b.com", name: "Jane", image: null }, + sentryDsn: "https://public@o0.ingest.sentry.io/1", + }); + + const root = await renderGate(); + + expect(initBrowserErrorMonitoringMock).toHaveBeenCalledTimes(1); + expect(initBrowserErrorMonitoringMock).toHaveBeenCalledWith("https://public@o0.ingest.sentry.io/1"); + root.unmount(); + }); + + it("opens the gate after a signed-out session query refetches with a DSN", async () => { + getSessionMock.mockResolvedValue(null); + const root = await renderGate(); + expect(initBrowserErrorMonitoringMock).not.toHaveBeenCalled(); + + getSessionMock.mockResolvedValue({ + session: { id: "s1", userId: "u1" }, + user: { id: "u1", email: "a@b.com", name: "Jane", image: null }, + sentryDsn: "https://public@o0.ingest.sentry.io/1", + }); + await act(async () => { + await queryClient.refetchQueries({ queryKey: queryKeys.auth.session }); + }); + await flushReact(); + + expect(initBrowserErrorMonitoringMock).toHaveBeenCalledTimes(1); + expect(initBrowserErrorMonitoringMock).toHaveBeenCalledWith("https://public@o0.ingest.sentry.io/1"); + root.unmount(); + }); + + it("closes browser monitoring when sign-out clears the session's DSN", async () => { + getSessionMock.mockResolvedValue({ + session: { id: "s1", userId: "u1" }, + user: { id: "u1", email: "a@b.com", name: "Jane", image: null }, + sentryDsn: "https://public@o0.ingest.sentry.io/1", + }); + const root = await renderGate(); + expect(initBrowserErrorMonitoringMock).toHaveBeenCalledTimes(1); + expect(teardownBrowserErrorMonitoringMock).not.toHaveBeenCalled(); + + // `useSignOut` resets the session query on sign-out; a mounted observer + // (this component) sees its data drop to `undefined` immediately. + getSessionMock.mockResolvedValue(null); + await act(async () => { + queryClient.resetQueries({ queryKey: queryKeys.auth.session }); + }); + await flushReact(); + + expect(teardownBrowserErrorMonitoringMock).toHaveBeenCalledTimes(1); + // Signing back in must start a fresh client, not skip re-init. + expect(initBrowserErrorMonitoringMock).toHaveBeenCalledTimes(1); + root.unmount(); + }); + + it("closes browser monitoring when the gate unmounts while a session DSN is set", async () => { + getSessionMock.mockResolvedValue({ + session: { id: "s1", userId: "u1" }, + user: { id: "u1", email: "a@b.com", name: "Jane", image: null }, + sentryDsn: "https://public@o0.ingest.sentry.io/1", + }); + const root = await renderGate(); + expect(initBrowserErrorMonitoringMock).toHaveBeenCalledTimes(1); + + root.unmount(); + + expect(teardownBrowserErrorMonitoringMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/ui/src/components/SentryGate.tsx b/ui/src/components/SentryGate.tsx new file mode 100644 index 00000000000..0fa0d80e1ff --- /dev/null +++ b/ui/src/components/SentryGate.tsx @@ -0,0 +1,39 @@ +import { useEffect } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { authApi } from "@/api/auth"; +import { queryKeys } from "@/lib/queryKeys"; +import { initBrowserErrorMonitoring, teardownBrowserErrorMonitoring } from "@/lib/sentry"; + +/** + * Opens the browser Sentry gate for an authorized board actor. Reads the + * signed-in session and starts browser error monitoring only when the + * session carries a Sentry DSN. Renders nothing. + * + * Sets no `enabled` option on the session query, so this also runs in + * `local_trusted` mode — the actor middleware fabricates a board actor + * there, so `/api/auth/get-session` still answers 200 with a session. + * + * Sign-out clears the session query (`useSignOut`), so `dsn` goes back to + * falsy on the same render pass that drops the session. That change tears + * down monitoring through the effect cleanup below, closing the running + * client, so a signed-out browser sends Sentry no more events. + */ +export function SentryGate() { + const { data: session } = useQuery({ + queryKey: queryKeys.auth.session, + queryFn: () => authApi.getSession(), + retry: false, + }); + + const dsn = session?.sentryDsn; + + useEffect(() => { + if (!dsn) return; + void initBrowserErrorMonitoring(dsn); + return () => { + void teardownBrowserErrorMonitoring(); + }; + }, [dsn]); + + return null; +} diff --git a/ui/src/lib/sentry.test.ts b/ui/src/lib/sentry.test.ts new file mode 100644 index 00000000000..b2838ebb53d --- /dev/null +++ b/ui/src/lib/sentry.test.ts @@ -0,0 +1,408 @@ +// @vitest-environment jsdom + +import { afterEach, describe, expect, it, vi } from "vitest"; + +/** + * Tests for the browser Sentry gate. Unlike the server gate, + * `@sentry/browser` is a real development dependency of this package (see + * the constraint in the plan), so most tests here run against the true SDK + * instead of a stand-in. + * + * The module holds module-scoped state (the readiness promise, the client + * handle), so each test resets the module registry and imports a fresh copy. + */ + +const DSN = "https://public@o0.ingest.sentry.io/1"; + +async function importFreshSentry() { + vi.resetModules(); + return await import("./sentry"); +} + +/** + * Register a fake `@sentry/browser` module for the next dynamic import. Used + * by the tests that only care about the call the module makes into the SDK, + * not the shape of a captured event. + * + * `init` and `setClient` both write one shared `attachedClient` variable — + * the real SDK's `Sentry.init` and `Sentry.getCurrentScope().setClient` both + * mutate the same module-global scope, not a value scoped to one caller. A + * race that lets a stale `setClient(undefined)` land after a newer + * `Sentry.init` needs a mock that tracks this shared state to catch it — + * two mocks that record calls independently cannot see the clobber. + */ +function mockSentryPackage() { + let nextClientId = 0; + let attachedClient: { id: string; close: (timeout?: number) => Promise } | null = null; + + // One shared `close` mock, attached to every client `init` creates. The + // fix reads the client with `getClient()` and calls `client.close()` + // directly, so the mocked client — not the mocked module — needs the + // `close` method a test can hold open or reject. + const close = vi.fn(async () => true); + const init = vi.fn((_options: Record) => { + attachedClient = { id: `client-${nextClientId++}`, close }; + }); + const captureException = vi.fn(() => (attachedClient ? "event-id" : undefined)); + const getClient = vi.fn(() => attachedClient ?? undefined); + const setClient = vi.fn((client: typeof attachedClient | undefined) => { + attachedClient = client ?? null; + }); + const getCurrentScope = vi.fn(() => ({ setClient })); + + vi.doMock("@sentry/browser", () => ({ init, captureException, close, getClient, getCurrentScope })); + + return { + init, + captureException, + close, + getClient, + getCurrentScope, + setClient, + /** The `id` of whichever client `init`/`setClient` last attached, or `null` if none is. */ + attachedClientId: () => attachedClient?.id ?? null, + }; +} + +/** + * Hold a mocked `close()` call open until the test releases it. Returns the + * release function. Used to put a teardown mid-flight without a second, + * truly concurrent dynamic `import()` of the mocked `@sentry/browser` module + * — Vitest does not guarantee two in-flight `import()` calls for one mocked + * specifier both resolve against the same mock instance, so a test must + * never rely on that to race two sign-ins. + */ +function holdCloseOpen(mocks: ReturnType): () => void { + let release = () => {}; + const gate = new Promise((resolve) => { + release = resolve; + }); + mocks.close.mockImplementationOnce(async () => { + await gate; + return true; + }); + return release; +} + +afterEach(() => { + vi.restoreAllMocks(); + vi.doUnmock("@sentry/browser"); +}); + +// A representative default-integration list, shaped like the array +// `@sentry/browser@10.71.0`'s `getDefaultIntegrations()` returns. Recorded +// 2026-08-25 with `node -e` against the published package. +const DEFAULT_INTEGRATION_NAMES = [ + "InboundFilters", + "FunctionToString", + "ConversationId", + "BrowserApiErrors", + "Breadcrumbs", + "GlobalHandlers", + "LinkedErrors", + "Dedupe", + "HttpContext", + "CultureContext", + "BrowserSession", +]; + +describe("initBrowserErrorMonitoring", () => { + it("initializes with the DSN it receives", async () => { + const mocks = mockSentryPackage(); + const { initBrowserErrorMonitoring } = await importFreshSentry(); + + await initBrowserErrorMonitoring(DSN); + + expect(mocks.init).toHaveBeenCalledTimes(1); + const initOptions = mocks.init.mock.calls[0][0] as { dsn: string }; + expect(initOptions.dsn).toBe(DSN); + }); + + it("a second call starts no second client", async () => { + const mocks = mockSentryPackage(); + const { initBrowserErrorMonitoring } = await importFreshSentry(); + + await initBrowserErrorMonitoring(DSN); + await initBrowserErrorMonitoring(DSN); + + expect(mocks.init).toHaveBeenCalledTimes(1); + }); +}); + +describe("teardownBrowserErrorMonitoring", () => { + it("is a no-op when monitoring never started", async () => { + const { teardownBrowserErrorMonitoring } = await importFreshSentry(); + + await expect(teardownBrowserErrorMonitoring()).resolves.toBeUndefined(); + }); + + it("closes the running client and detaches it from the current scope", async () => { + const mocks = mockSentryPackage(); + const { initBrowserErrorMonitoring, teardownBrowserErrorMonitoring } = await importFreshSentry(); + await initBrowserErrorMonitoring(DSN); + + await teardownBrowserErrorMonitoring(); + + expect(mocks.close).toHaveBeenCalledTimes(1); + expect(mocks.setClient).toHaveBeenCalledWith(undefined); + }); + + it("detaches the client from the current scope before close() settles", async () => { + // The client stays attached, and stays enabled, for the whole `close()` + // call — a signed-out page can still reach it until detach runs. Hold + // `close()` open and assert the detach already ran while it is still + // pending, so a regression back to close-then-detach fails this test. + const mocks = mockSentryPackage(); + const { initBrowserErrorMonitoring, teardownBrowserErrorMonitoring } = await importFreshSentry(); + await initBrowserErrorMonitoring(DSN); + const releaseClose = holdCloseOpen(mocks); + + const teardownDone = teardownBrowserErrorMonitoring(); + // Give the queued teardown operation a few turns of the microtask queue + // to run up to its `await client.close()` call, without waiting for + // `close()` itself to settle — the gate above holds that call open. + await Promise.resolve(); + await Promise.resolve(); + + expect(mocks.setClient).toHaveBeenCalledWith(undefined); + expect(mocks.close).toHaveBeenCalledTimes(1); + + releaseClose(); + await expect(teardownDone).resolves.toBeUndefined(); + }); + + it("detaches the client from the current scope even when close() rejects, and still resolves", async () => { + const mocks = mockSentryPackage(); + mocks.close.mockRejectedValueOnce(new Error("close timed out")); + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const { initBrowserErrorMonitoring, teardownBrowserErrorMonitoring } = await importFreshSentry(); + await initBrowserErrorMonitoring(DSN); + + // The returned promise must resolve, not reject: SentryGate.tsx discards + // it with a bare `void` call, so a rejection would surface as an + // unhandled promise rejection in the browser. + await expect(teardownBrowserErrorMonitoring()).resolves.toBeUndefined(); + + expect(mocks.setClient).toHaveBeenCalledWith(undefined); + expect(consoleErrorSpy).toHaveBeenCalled(); + }); + + it("stops captureBrowserException from reaching the client", async () => { + const mocks = mockSentryPackage(); + const { initBrowserErrorMonitoring, teardownBrowserErrorMonitoring, captureBrowserException } = + await importFreshSentry(); + await initBrowserErrorMonitoring(DSN); + await teardownBrowserErrorMonitoring(); + + captureBrowserException(new Error("boom after sign-out")); + await Promise.resolve(); + await Promise.resolve(); + + expect(mocks.captureException).not.toHaveBeenCalled(); + }); + + it("a call to initBrowserErrorMonitoring after teardown starts a fresh client", async () => { + const mocks = mockSentryPackage(); + const { initBrowserErrorMonitoring, teardownBrowserErrorMonitoring } = await importFreshSentry(); + await initBrowserErrorMonitoring(DSN); + await teardownBrowserErrorMonitoring(); + + await initBrowserErrorMonitoring(DSN); + + expect(mocks.init).toHaveBeenCalledTimes(2); + }); + + it("a sign-back-in that overlaps a still-in-flight teardown ends up monitored, not silently disabled", async () => { + // A sign-out whose `Sentry.close()` call is still in flight when the + // browser signs back in. `Sentry.init` and `Sentry.getCurrentScope(). + // setClient` both mutate ONE shared scope, so the old teardown's + // `setClient(undefined)` call, if it lands after the new sign-in's + // `Sentry.init`, would detach the NEW client rather than the old one — + // silently disabling monitoring for the session that just signed in. + // The fix serializes the two: the old teardown's close-and-detach must + // finish in full before the new sign-in's `Sentry.init` runs. + const mocks = mockSentryPackage(); + const { initBrowserErrorMonitoring, teardownBrowserErrorMonitoring, captureBrowserException } = + await importFreshSentry(); + await initBrowserErrorMonitoring(DSN); + expect(mocks.init).toHaveBeenCalledTimes(1); + const clientAId = mocks.attachedClientId(); + expect(clientAId).not.toBeNull(); + + const releaseClose = holdCloseOpen(mocks); + const teardownDone = teardownBrowserErrorMonitoring(); + // The sign-back-in's own `Sentry.init` is now queued behind the + // in-flight teardown, so its returned promise settles only once the + // gate below releases — await it after releasing, not before. + const signInBDone = initBrowserErrorMonitoring(DSN); + + releaseClose(); + await teardownDone; + await signInBDone; + + expect(mocks.close).toHaveBeenCalledTimes(1); + expect(mocks.init).toHaveBeenCalledTimes(2); + // The old teardown's close-and-detach ran to completion BEFORE the new + // sign-in's `Sentry.init`, so the client left attached is the new one — + // not `undefined`, and not the old client A ever reused. + const clientBId = mocks.attachedClientId(); + expect(clientBId).not.toBeNull(); + expect(clientBId).not.toBe(clientAId); + + captureBrowserException(new Error("boom after the race")); + await Promise.resolve(); + await Promise.resolve(); + + expect(mocks.captureException).toHaveBeenCalledWith(expect.any(Error)); + }); +}); + +describe("captureBrowserException", () => { + it("does not throw when the gate is closed", async () => { + const { captureBrowserException } = await importFreshSentry(); + + expect(() => captureBrowserException(new Error("boom"))).not.toThrow(); + }); + + it("reaches the client once the gate opens", async () => { + const mocks = mockSentryPackage(); + const { initBrowserErrorMonitoring, captureBrowserException } = await importFreshSentry(); + + await initBrowserErrorMonitoring(DSN); + captureBrowserException(new Error("boom")); + // captureBrowserException resolves asynchronously; give its internal + // promise a turn to settle before asserting. + await Promise.resolve(); + await Promise.resolve(); + + expect(mocks.captureException).toHaveBeenCalledWith(expect.any(Error)); + }); +}); + +/** + * Call the `integrations` option with the given default list. The real + * `@sentry/browser` option type allows `integrations` to be an array instead + * of a function, so this guard narrows it before the call — this gate always + * builds a function, never an array, but the type does not know that. + */ +function resolveIntegrations( + options: ReturnType, + defaults: Array<{ name: string }>, +) { + if (typeof options.integrations !== "function") { + throw new Error("expected buildBrowserSentryInitOptions to set a function, not an array"); + } + return options.integrations(defaults); +} + +describe("buildBrowserSentryInitOptions", () => { + it("sets the recorded built-in privacy options", async () => { + const { buildBrowserSentryInitOptions } = await importFreshSentry(); + + const options = buildBrowserSentryInitOptions(DSN); + + expect(options.sendDefaultPii).toBe(false); + expect(options.tracesSampleRate).toBe(0); + }); + + it("holds no beforeSend hook and no custom filter function", async () => { + const { buildBrowserSentryInitOptions } = await importFreshSentry(); + + const options = buildBrowserSentryInitOptions(DSN); + + expect(options.beforeSend).toBeUndefined(); + expect(options.beforeSendTransaction).toBeUndefined(); + }); + + it("the resolved integration list holds no HttpContext integration and no Breadcrumbs integration", async () => { + const { buildBrowserSentryInitOptions } = await importFreshSentry(); + + const options = buildBrowserSentryInitOptions(DSN); + const resolved = resolveIntegrations(options, DEFAULT_INTEGRATION_NAMES.map((name) => ({ name }))); + const names = resolved.map((i) => i.name); + + expect(names).not.toContain("HttpContext"); + expect(names).not.toContain("Breadcrumbs"); + }); + + it("the resolved integration list keeps GlobalHandlers, BrowserApiErrors, Dedupe, and LinkedErrors", async () => { + const { buildBrowserSentryInitOptions } = await importFreshSentry(); + + const options = buildBrowserSentryInitOptions(DSN); + const resolved = resolveIntegrations(options, DEFAULT_INTEGRATION_NAMES.map((name) => ({ name }))); + const names = resolved.map((i) => i.name); + + expect(names).toEqual( + expect.arrayContaining(["GlobalHandlers", "BrowserApiErrors", "Dedupe", "LinkedErrors"]), + ); + }); +}); + +/** + * Tests against the real `@sentry/browser` SDK. `@sentry/browser` is not an + * optional dependency here — it is a real, always-installed development + * dependency of this package (see the module comment in `sentry.ts`) — so + * these tests run unconditionally. + */ +describe("captured event shape against the real @sentry/browser SDK", () => { + /** + * Initialize the real SDK with this module's exact options, plus a + * transport stub so no event leaves the test process, plus `beforeSend` + * so the test can inspect the resolved event before it would have been + * sent. `beforeSend` here is test-only introspection — the shipped module + * adds no `beforeSend` of its own (see the "holds no beforeSend hook" + * test above). + */ + async function initRealSentryForTest(onEvent: (event: Record) => void) { + const { buildBrowserSentryInitOptions } = await importFreshSentry(); + const Sentry = await import("@sentry/browser"); + Sentry.init({ + ...buildBrowserSentryInitOptions(DSN), + transport: () => ({ send: async () => ({}), flush: async () => true }), + beforeSend: (event) => { + onEvent(event as unknown as Record); + return event; + }, + }); + return Sentry; + } + + it("an event from a page URL that holds a test capability value carries no request URL, no query string, and no referrer", async () => { + window.history.pushState({}, "", "/dashboard?token=test-capability-value"); + Object.defineProperty(document, "referrer", { + value: "https://from.example/previous-page", + configurable: true, + }); + let captured: Record | null = null; + const Sentry = await initRealSentryForTest((event) => { + captured = event; + }); + + Sentry.captureException(new Error("boom")); + await Sentry.flush(2000); + + expect(captured).not.toBeNull(); + // `HttpContext` is the only default integration that writes + // `event.request`. With it removed, the field never appears. + expect((captured as unknown as Record).request).toBeUndefined(); + }); + + it("an event captured after a console call and a fetch call carries no breadcrumb", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = vi.fn(async () => new Response("ok")) as unknown as typeof fetch; + let captured: Record | null = null; + const Sentry = await initRealSentryForTest((event) => { + captured = event; + }); + + // eslint-disable-next-line no-console + console.warn("a console call the Breadcrumbs integration would otherwise record"); + await fetch("/api/probe?token=test-capability-value"); + Sentry.captureException(new Error("boom")); + await Sentry.flush(2000); + + globalThis.fetch = originalFetch; + expect(captured).not.toBeNull(); + expect((captured as unknown as Record).breadcrumbs).toBeUndefined(); + }); +}); diff --git a/ui/src/lib/sentry.ts b/ui/src/lib/sentry.ts new file mode 100644 index 00000000000..404f64191ea --- /dev/null +++ b/ui/src/lib/sentry.ts @@ -0,0 +1,165 @@ +// Optional Sentry error monitoring for the browser. +// +// Activated only when the signed-in session carries a Sentry DSN. `SentryGate` +// reads the DSN off `GET /api/auth/get-session` and calls +// `initBrowserErrorMonitoring` once. A signed-out browser, or a browser with +// no DSN, calls this module never — see `SentryGate.tsx`. +// +// Sign-out must stop monitoring, not just stop starting it: `SentryGate` +// calls `teardownBrowserErrorMonitoring` when the session's DSN goes away. +// The function detaches the client from the current scope first +// (`Sentry.getCurrentScope().setClient(undefined)`), then closes that same +// client (`client.close()`), so a signed-out page's global handlers find no +// attached client at any point. Detach runs first because a client stays +// attached, and stays enabled, for the whole close call — closing first +// would leave the signed-out page able to send one more event to Sentry for +// as long as the close call takes. Both calls are built-in Sentry client +// calls — no custom filter code. +// +// `initBrowserErrorMonitoring`, `teardownBrowserErrorMonitoring`, and +// `captureBrowserException` each queue their work on one shared promise +// chain (`enqueue`) instead of running at once. One operation runs at a +// time, in call order, so no operation can ever observe another one +// part-finished — a sign-out always closes the client a sign-in already +// finished starting, never one still starting. +// +// `@sentry/browser` loads through a dynamic import, so Vite puts it in a +// separate chunk that a browser with no DSN never fetches. +// +// Default-integration privacy note: two default integrations copy values +// this app does not want inside a Sentry event, so the initializer removes +// them with a built-in Sentry option — no custom filter code: +// - `Breadcrumbs` turns a console call, a click, and a fetch call into a +// breadcrumb with the raw arguments and the raw request URL. +// - `HttpContext` copies the page URL, the query string, and the referrer +// onto every event. +// The initializer keeps every other default integration, so the browser +// still captures `window.onerror` and `window.onunhandledrejection` +// (`GlobalHandlers`), the two React error boundaries, deduplicates a repeat +// event (`Dedupe`), and links a caused-by chain (`LinkedErrors`). + +let queue: Promise = Promise.resolve(); + +/** Run gate operations one at a time, in call order. */ +function enqueue(op: () => Promise): Promise { + const next = queue.then(op); + queue = next.catch(() => {}); + return next; +} + +/** The `@sentry/browser` module shape, resolved once. */ +type SentryBrowserModule = typeof import("@sentry/browser"); + +/** + * The `Sentry.init` options this gate builds. `Sentry.init`'s parameter is + * optional, so `Parameters<...>[0]` alone carries an `| undefined` arm this + * gate never returns. `NonNullable` removes only that arm — the object shape + * underneath stays the true `@sentry/browser` option type. + */ +type BrowserSentryInitOptions = NonNullable[0]>; + +let sentry: SentryBrowserModule | null = null; + +/** + * Load `@sentry/browser` and start the client with the given DSN. Idempotent + * — the session query can refetch and call this again, and a second call is + * a no-op because a client is already started. + */ +export function initBrowserErrorMonitoring(dsn: string): Promise { + return enqueue(async () => { + if (sentry) return; + try { + const Sentry = await import("@sentry/browser"); + Sentry.init(buildBrowserSentryInitOptions(dsn)); + sentry = Sentry; + } catch (err) { + // The dynamic import or the init call failed. Fall through with a + // single diagnostic. The gate fails open — the app keeps running + // without error monitoring rather than crashing on an opt-in feature. + // eslint-disable-next-line no-console + console.error("[paperclip] Sentry browser bootstrap failed", err); + } + }); +} + +/** + * Stop browser error monitoring and forget the started client. Call this on + * sign-out, so the browser sends Sentry no more events and no more + * breadcrumbs after the session ends. A no-op when monitoring never started. + * + * Detaches the client from the current scope, then closes that same client. + * A client stays attached, and stays enabled, for the whole close call — a + * signed-out page's `window.onerror` or `window.onunhandledrejection` + * handler would still reach it for as long as the close call takes. + * Detaching first removes that window instead of leaving it open. + */ +export function teardownBrowserErrorMonitoring(): Promise { + return enqueue(async () => { + const Sentry = sentry; + sentry = null; + if (!Sentry) return; + // Read the client before detaching it. The scope holds no client after + // detach, so the close step below needs this reference to flush and + // close the right client. + const client = Sentry.getClient(); + // Detach first, close second — the opposite order from a plain + // `Sentry.close()` call, which reads the client off the current scope + // and would find nothing to close if detach ran first. A separate + // guarded try keeps this step unable to reject, unlike a bare finally + // block: a thrown error here would still reach the caller, and + // `SentryGate.tsx` discards this function's returned promise with a + // bare `void` call, so a rejection would surface as an unhandled + // promise rejection in the browser. + try { + Sentry.getCurrentScope().setClient(undefined); + } catch (err) { + // eslint-disable-next-line no-console + console.error("[paperclip] Sentry client detach failed", err); + } + // A second, separate guarded try, for the same reason as the one + // above: this step must never reject the returned promise. + try { + // Awaiting matters: the client flushes buffered events to Sentry + // during close; a caller that does not wait may navigate away, or + // the page may unload, before the flush finishes. + await client?.close(2_000); + } catch (err) { + // eslint-disable-next-line no-console + console.error("[paperclip] Sentry teardownBrowserErrorMonitoring failed", err); + } + }); +} + +/** + * Report an error to Sentry. Never throws — observability must not change + * control flow. A no-op before the gate opens, when the gate never opens (no + * DSN on the session), or when bootstrap failed. + */ +export function captureBrowserException(error: unknown): void { + void enqueue(async () => { + try { + sentry?.captureException(error); + } catch (err) { + // eslint-disable-next-line no-console + console.error("[paperclip] Sentry captureBrowserException failed", err); + } + }); +} + +/** + * Build the `Sentry.init` options object. A pure function, split out from + * `initBrowserErrorMonitoring` so a test can call it with the real + * `@sentry/browser` module and assert the resolved integration list and the + * captured-event shape against the true SDK, not a stand-in. + */ +export function buildBrowserSentryInitOptions(dsn: string): BrowserSentryInitOptions { + return { + dsn, + tracesSampleRate: 0, + sendDefaultPii: false, + integrations: (defaults) => + defaults.filter( + (integration) => integration.name !== "HttpContext" && integration.name !== "Breadcrumbs", + ), + }; +} diff --git a/ui/src/main.tsx b/ui/src/main.tsx index c6aa4eb5598..6496fe35584 100644 --- a/ui/src/main.tsx +++ b/ui/src/main.tsx @@ -6,6 +6,7 @@ import { BrowserRouter } from "@/lib/router"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { App } from "./App"; import { AppErrorBoundary } from "./components/AppErrorBoundary"; +import { SentryGate } from "./components/SentryGate"; import { CompanyProvider, useCompany } from "./context/CompanyContext"; import { LiveUpdatesProvider } from "./context/LiveUpdatesProvider"; import { BreadcrumbProvider } from "./context/BreadcrumbContext"; @@ -60,6 +61,7 @@ createRoot(document.getElementById("root")!).render( + diff --git a/ui/storybook/fixtures/paperclipData.ts b/ui/storybook/fixtures/paperclipData.ts index ee9d4a35341..0bafed389b3 100644 --- a/ui/storybook/fixtures/paperclipData.ts +++ b/ui/storybook/fixtures/paperclipData.ts @@ -119,6 +119,7 @@ export const storybookAuthSession: AuthSession = { email: "riley@paperclip.local", image: null, }, + sentryDsn: null, }; export const storybookAgents: Agent[] = [ From 381b7638469b31d7c8c47a1329a9825ac4d61e3b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 26 Aug 2026 11:33:57 -0700 Subject: [PATCH 32/32] chore(lockfile): refresh pnpm-lock.yaml (#12232) Auto-generated lockfile refresh after dependencies changed on master. This PR only updates pnpm-lock.yaml. Co-authored-by: lockfile-bot --- pnpm-lock.yaml | 65 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 579f1650117..6969138f192 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1068,6 +1068,9 @@ importers: specifier: ^3.6.0 version: 3.6.0 devDependencies: + '@sentry/browser': + specifier: ^10.71.0 + version: 10.71.0 '@storybook/addon-a11y': specifier: 10.5.10 version: 10.5.10(storybook@10.5.10(@types/react@19.2.18)(react@19.2.8)) @@ -4444,6 +4447,34 @@ packages: cpu: [x64] os: [win32] + '@sentry/browser-utils@10.71.0': + resolution: {integrity: sha512-Djhb+RdEwSdYTlDaMzc2uRCA+bYDIFsFCtZzKEtB9UR7lJNV/bJ0k3el3ifaVGTRELO8WBll/X0elty1Dk5tTw==} + engines: {node: '>=18'} + + '@sentry/browser@10.71.0': + resolution: {integrity: sha512-fTE9tUDoggJSFv8cQ+h1UA9onovOoUNJWu8Var1MXmUVuVG/W3WqzJFVyKArZMj95lizZkq8aiS63SURvrBsFQ==} + engines: {node: '>=18'} + + '@sentry/conventions@0.16.0': + resolution: {integrity: sha512-fO9PLmHdVURcSPUpWCItWAtgKiMwGdJHbovoSEyLplX5sxs2ugvI4CBPTrkkgqhObnZOD0CnWBKDzSVQYBKEyQ==} + engines: {node: '>=14'} + + '@sentry/core@10.71.0': + resolution: {integrity: sha512-OIjT7rzcWJjUC6r3eBT3Td1j0afDBMkbbx9jTocSD+ZSfc25eEU7hoIPS0WvfeIOTIN3y8bfQnXavwMReaNVHQ==} + engines: {node: '>=18'} + + '@sentry/feedback@10.71.0': + resolution: {integrity: sha512-4+zMAmn1DcbYBtFE0pWt6zo8vWoBHTuP/UhtCMTCoGB4sVYvmwRxv9Hc9OpRHSzE9kSl8beD0zKFxuoVHizKQQ==} + engines: {node: '>=18'} + + '@sentry/replay-canvas@10.71.0': + resolution: {integrity: sha512-YTqesxBh9arExI49LrTm4Y2/xPX40q2GWi0gWRw6lNfYtyNz7/ZfHi8/1N6JEc8dldTslqFhII6ZFecUyU9iaA==} + engines: {node: '>=18'} + + '@sentry/replay@10.71.0': + resolution: {integrity: sha512-ytEgVg7isvavQL6hgsgYeuSCkcA3EyOKm9lMLQOZTLkiUCtFT/ItWwGNhzS46vQ6wsTv3Uc0Y1JlcJHSFKe/Kg==} + engines: {node: '>=18'} + '@shikijs/core@4.4.3': resolution: {integrity: sha512-QCR4q2ZO/ILJEuwiBMel4wdcTDb1JGwfjKTxPDF6x8ixOaluPrVqIn06C99AcRPhmYlBR56d/Fb+GN58GzExpg==} engines: {node: '>=20'} @@ -11878,6 +11909,40 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.63.0': optional: true + '@sentry/browser-utils@10.71.0': + dependencies: + '@sentry/conventions': 0.16.0 + '@sentry/core': 10.71.0 + + '@sentry/browser@10.71.0': + dependencies: + '@sentry/browser-utils': 10.71.0 + '@sentry/conventions': 0.16.0 + '@sentry/core': 10.71.0 + '@sentry/feedback': 10.71.0 + '@sentry/replay': 10.71.0 + '@sentry/replay-canvas': 10.71.0 + + '@sentry/conventions@0.16.0': {} + + '@sentry/core@10.71.0': + dependencies: + '@sentry/conventions': 0.16.0 + + '@sentry/feedback@10.71.0': + dependencies: + '@sentry/core': 10.71.0 + + '@sentry/replay-canvas@10.71.0': + dependencies: + '@sentry/core': 10.71.0 + '@sentry/replay': 10.71.0 + + '@sentry/replay@10.71.0': + dependencies: + '@sentry/browser-utils': 10.71.0 + '@sentry/core': 10.71.0 + '@shikijs/core@4.4.3': dependencies: '@shikijs/primitive': 4.4.3