;
/** 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 (
-
-
-
- Import company
-
-
-
-
-
- Export company
-
-
+ {showImport && (
+
+
+
+ Import company
+
+
+ )}
+ {showExport && (
+
+
+
+ Export company
+
+
+ )}
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