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 <noreply@paperclip.ing>
This commit is contained in:
Nicky LeachandPaperclip authored and GitHub committed 2026-08-25 21:38:27 -07:00
1 parent dc30dc4f34
commit 821573ede8
7 files changed
+276 -236

No files matched your search

@@ -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
@@ -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<Record<string, unknown>>();
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 () => {
+11 -30
View File
@@ -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
@@ -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<Record<string, unknown>>();
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 () => {
@@ -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