From 65c5d8d60064cb8a6b65b7fe1974ef60317bd4cd Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Fri, 6 Nov 2026 06:44:43 -0800 Subject: [PATCH] fix(claude-local): start approve-all sandbox ACP sessions in bypassPermissions (#19430) 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 `claude_local` adapter runs Claude through the ACP engine (claude-agent-acp) inside remote sandboxes > - Paperclip seeds the sandbox Claude settings with `permissions.defaultMode: "default"`, so Claude asks acpx for permission before every MCP call and every Bash call that is not read-only > - In the default `approve-all` mode, acpx approves every request. The permission round trip gives no access control, but it is the step where tool calls stall > - Intermittently, the Claude CLI never sends that permission request. The call stays `pending` until the 30-minute stalled-tool watchdog ends the turn (`acpx_tool_stalled`) > - This pull request starts approve-all sandbox sessions in `bypassPermissions`, so Claude does not use the permission round trip at all > - The benefit is that sandbox runs no longer lose about 35 minutes per stall, and `approve-reads` and `deny-all` agents keep their current permission behavior ## Linked Issues or Issue Description Refs #16688 Refs #16769 **What happened?** Sandbox `claude_local` ACP runs ended with `acpx_tool_stalled`: "A pending tool call (kind other) received no update for 30 minutes, so the run ended the turn instead of waiting for the execution timeout." The diagnostics from #16688 and #16769 show the shape. The stalled call got its `pending` update and its input updates. Then its permission request never reached acpx. Finished calls of the same kind in the same run did send a request. The stall occurred on MCP calls and on Bash calls, in the main agent and in background subagents. Calls that need no permission (ToolSearch, read-only Bash) never stalled. **Expected behavior** In `approve-all` mode, a tool call must not wait on a permission round trip that acpx always approves. **Steps to reproduce** 1. Run a `claude_local` agent with the ACP engine on a sandbox target, with the default `approve-all` permission mode. 2. Make many MCP or network Bash calls during the turn. 3. Sometimes a call stays `pending` with no permission request. The watchdog then ends the turn after 30 minutes. **Agent adapter(s) involved** Claude Code (`claude_local`, ACP engine, sandbox target). ## What Changed - `claude-config.ts`: `prepareClaudeConfigSeed` accepts `permissionDefaultMode` (`"default"` or `"bypassPermissions"`). The sanitized seed writes that value into `permissions.defaultMode`. In bypass mode the seed writes `settings.json` also when the host has no `settings.json`. Hooks, MCP servers and host permission rules are still removed. - `claude-config.ts`: `overrideRemoteClaudeLocalPermissionMode` sets `bypassPermissions` in the **sandbox copy** of the workspace `.claude/settings.local.json`. The engine writes that file with `defaultMode: "default"`, and the local tier overrides the user seed. Before workspace copy-back, the override is reset in the sandbox, so copy-back never treats it as an agent edit. After copy-back, if the host file still has `bypassPermissions`, only `defaultMode` is put back. Host and agent edits to other settings are kept. - `acp.ts`: the sandbox managed-home seam reads the run's effective ACPX permission mode. If the mode is `approve-all`, the seam seeds `bypassPermissions`, applies the workspace override above, and sets `IS_SANDBOX=1` through the existing `claudeSandboxPermissionEnv` helper. This lets the bypass apply when the sandbox user is root, the same rule as the CLI lane. The mode resolution is the same as the engine's: an absent value or `"default"` becomes `approve-all`. An unrecognized value keeps `"default"`. - These keep `defaultMode: "default"` and do not change: `approve-reads`, `deny-all`, the CLI lane, the Test probes, managed AI connections, and an operator `CLAUDE_CONFIG_DIR`. - Tests: two seed tests in `claude-config.test.ts`. In `acp.test.ts`: an end-to-end executor test for five permission modes (absent, `approve-all`, `default`, `approve-reads`, `deny-all`) that checks the mode Claude actually gets during the turn and that the host settings keep `default`, and a test that host and agent edits to the workspace settings survive copy-back without `bypassPermissions`. ## Verification - `pnpm exec vitest run packages/adapters/claude-local`: 393 passed, 1 skipped (before the copy-back commits); full CI is green on the current head. - Regression proof: with the new tests and the old `acp.ts` and `claude-config.ts`, the seed tests and the absent, `approve-all` and `default` cases fail (`expected 'default' to be 'bypassPermissions'`). The `approve-reads` and `deny-all` cases pass on both versions, because their behavior does not change. - `pnpm exec tsc --noEmit` in `packages/adapters/claude-local`: clean. ## Risks - Behavior change for sandbox `claude_local` ACP runs in `approve-all` mode (the default): Claude does not ask permission for tools. Paperclip already approves every request in this mode, so the access outcome does not change. Claude's bypass-immune safety checks still go through `canUseTool`. - The seed directory is content-addressed, so the bypass seed and the default seed are different snapshots and do not overwrite each other. - Agents that set `approve-reads` or `deny-all` keep the permission round trip. The stall can still occur for them. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, with tool use and code execution in an agent harness. ## 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 - [ ] 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 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- .../claude-local/src/server/acp.test.ts | 157 ++++++++++++++++++ .../adapters/claude-local/src/server/acp.ts | 41 ++++- .../src/server/claude-config.test.ts | 44 +++++ .../claude-local/src/server/claude-config.ts | 129 ++++++++++++-- 4 files changed, 356 insertions(+), 15 deletions(-) diff --git a/packages/adapters/claude-local/src/server/acp.test.ts b/packages/adapters/claude-local/src/server/acp.test.ts index cd837397020..6be80f537bc 100644 --- a/packages/adapters/claude-local/src/server/acp.test.ts +++ b/packages/adapters/claude-local/src/server/acp.test.ts @@ -919,6 +919,163 @@ describe("claude_local ACP lane", () => { expect((await fs.stat(toolShellPrefix)).mode & 0o777).toBe(0o700); }); + it.each([ + { permissionMode: undefined, hostSettings: true, defaultMode: "bypassPermissions", isSandbox: "1" }, + { permissionMode: "approve-all", hostSettings: false, defaultMode: "bypassPermissions", isSandbox: "1" }, + { permissionMode: "default", hostSettings: true, defaultMode: "bypassPermissions", isSandbox: "1" }, + { permissionMode: "approve-reads", hostSettings: true, defaultMode: "default", isSandbox: undefined }, + { permissionMode: "deny-all", hostSettings: true, defaultMode: "default", isSandbox: undefined }, + ])( + "starts sandbox Claude in $defaultMode mode when the ACPX permission mode is $permissionMode", + async ({ permissionMode, hostSettings, defaultMode, isSandbox }) => { + const root = await makeTempRoot("paperclip-claude-acp-permission-mode-"); + const localCwd = path.join(root, "worktree"); + const remoteCwd = path.join(root, "remote-workspace"); + const sharedClaudeConfig = path.join(root, "shared-claude-config"); + await fs.mkdir(localCwd, { recursive: true }); + await fs.mkdir(remoteCwd, { recursive: true }); + await fs.mkdir(sharedClaudeConfig, { recursive: true }); + if (hostSettings) { + await fs.writeFile( + path.join(sharedClaudeConfig, "settings.json"), + JSON.stringify({ theme: "dark", permissions: { defaultMode: "acceptEdits", allow: ["Bash(*)"] } }), + "utf8", + ); + } + process.env.PAPERCLIP_HOME = path.join(root, "paperclip-home"); + process.env.PAPERCLIP_INSTANCE_ID = "test"; + process.env.CLAUDE_CONFIG_DIR = sharedClaudeConfig; + + const meta: AdapterInvocationMeta[] = []; + let effectiveDefaultMode: unknown; + const runtime = new FakeRuntime({}); + const startTurn = runtime.startTurn.bind(runtime); + runtime.startTurn = (turnInput) => { + const turn = startTurn(turnInput); + return { + ...turn, + result: (async () => { + const readSettings = async (file: string) => + JSON.parse(await fs.readFile(file, "utf8").catch(() => "{}")); + const local = await readSettings(path.join(turnInput.handle.cwd ?? remoteCwd, ".claude", "settings.local.json")); + const user = await readSettings(path.join(String(meta[0]?.env?.CLAUDE_CONFIG_DIR ?? ""), "settings.json")); + effectiveDefaultMode = local.permissions?.defaultMode ?? user.permissions?.defaultMode; + return await turn.result; + })(), + }; + }; + const execute = createClaudeAcpExecutor({ + createRuntime: (options: FakeRuntimeOptions) => { + Object.assign(runtime.options, options); + return runtime 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.", + ...(permissionMode ? { permissionMode } : {}), + }, + 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", + onMeta: async (payload: AdapterInvocationMeta) => { + meta.push(payload); + }, + }), + ); + + expect(result.exitCode).toBe(0); + const remoteConfigDir = String(meta[0]?.env?.CLAUDE_CONFIG_DIR ?? ""); + const remoteSettings = JSON.parse(await fs.readFile(path.join(remoteConfigDir, "settings.json"), "utf8")); + expect(remoteSettings.permissions).toEqual({ defaultMode }); + expect(remoteSettings.theme).toBe(hostSettings ? "dark" : undefined); + expect(meta[0]?.env?.IS_SANDBOX).toBe(isSandbox); + expect(effectiveDefaultMode).toBe(defaultMode); + const hostLocalSettings = JSON.parse(await fs.readFile(path.join(localCwd, ".claude", "settings.local.json"), "utf8")); + expect(hostLocalSettings.permissions.defaultMode).toBe("default"); + }, + ); + + it.each([ + { name: "a host edit during the run", editHost: true }, + { name: "an agent edit in the sandbox", editHost: false }, + ])("keeps $name to the workspace Claude settings and never copies bypassPermissions home", async ({ editHost }) => { + const root = await makeTempRoot("paperclip-claude-acp-local-settings-"); + 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 }); + process.env.PAPERCLIP_HOME = path.join(root, "paperclip-home"); + process.env.PAPERCLIP_INSTANCE_ID = "test"; + process.env.CLAUDE_CONFIG_DIR = path.join(root, "shared-claude-config"); + + const hostSettingsPath = path.join(localCwd, ".claude", "settings.local.json"); + const runtime = new FakeRuntime({}); + const startTurn = runtime.startTurn.bind(runtime); + runtime.startTurn = (turnInput) => { + const turn = startTurn(turnInput); + return { + ...turn, + result: (async () => { + const target = editHost + ? hostSettingsPath + : path.join(turnInput.handle.cwd ?? remoteCwd, ".claude", "settings.local.json"); + const current = JSON.parse(await fs.readFile(target, "utf8")); + await fs.writeFile(target, JSON.stringify({ ...current, theme: "edited" }), "utf8"); + return await turn.result; + })(), + }; + }; + const execute = createClaudeAcpExecutor({ + createRuntime: (options: FakeRuntimeOptions) => { + Object.assign(runtime.options, options); + return runtime 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", + }), + ); + + expect(result.exitCode).toBe(0); + const hostSettings = JSON.parse(await fs.readFile(hostSettingsPath, "utf8")); + expect(hostSettings.theme).toBe("edited"); + expect(hostSettings.permissions.defaultMode).toBe("default"); + }); + it("keeps an operator CLAUDE_CODE_SHELL_PREFIX and installs no tool shell prefix", async () => { const root = await makeTempRoot("paperclip-claude-acp-operator-prefix-"); 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 5e24a81d5d7..8277d41168e 100644 --- a/packages/adapters/claude-local/src/server/acp.ts +++ b/packages/adapters/claude-local/src/server/acp.ts @@ -42,6 +42,7 @@ import { } from "@paperclipai/adapter-utils/server-utils"; import { materializeRemoteClaudeConfig, + overrideRemoteClaudeLocalPermissionMode, prepareClaudeConfigSeed, prepareSandboxClaudeProbeRuntime, resolveRemoteClaudeToolShellPrefixPath, @@ -122,6 +123,11 @@ function firstNonEmptyString(...values: unknown[]): string | undefined { return undefined; } +function resolveAcpPermissionMode(config: Record): string { + const requested = firstNonEmptyString(config.permissionMode); + return requested === undefined || requested === "default" ? DEFAULT_ACP_ENGINE_PERMISSION_MODE : requested; +} + export function buildClaudeAcpConfig( config: Record, inheritedEnv: Record = {}, @@ -271,9 +277,12 @@ async function prepareClaudeRemoteManagedHome( // Content-addressed sanitized seed (managed cache under the instance root, not // a temp dir — reused across runs, so no teardown cleanup). + const bypassPermissions = resolveAcpPermissionMode(input.config) === "approve-all"; const claudeConfigSeedDir = input.config.managedAiConnection ? explicitClaudeConfigDir - : await prepareClaudeConfigSeed(process.env, onLog, input.companyId); + : await prepareClaudeConfigSeed(process.env, onLog, input.companyId, { + permissionDefaultMode: bypassPermissions ? "bypassPermissions" : "default", + }); // Ship the per-run skill bundle, staged only when the run selected at // least one skill. The bundle directory holds a plain copy of each // selected skill's files (`materializePaperclipSkillCopy` never copies a @@ -316,10 +325,38 @@ async function prepareClaudeRemoteManagedHome( }); // Repoint CLAUDE_CONFIG_DIR onto the in-sandbox config dir. env.CLAUDE_CONFIG_DIR = remoteClaudeConfigDir; + const localPermissionOverride = bypassPermissions && !input.config.managedAiConnection + ? await overrideRemoteClaudeLocalPermissionMode({ + runId, + target: executionTarget, + workspaceLocalDir: input.workspaceLocalDir, + workspaceRemoteDir: stagedRuntime.workspaceRemoteDir ?? input.workspaceLocalDir, + defaultMode: "bypassPermissions", + options: { + cwd: stagedRuntime.workspaceRemoteDir ?? input.workspaceLocalDir, + env, + timeoutSec: Math.max(input.timeoutSec, 15), + graceSec: 20, + onLog, + }, + }) + : null; + if (bypassPermissions && !input.config.managedAiConnection) { + Object.assign(env, claudeSandboxPermissionEnv({ dangerouslySkipPermissions: true, targetIsSandbox: true })); + } if (toolShellPrefixInstalled && remoteToolShellPrefixPath) { env.CLAUDE_CODE_SHELL_PREFIX = remoteToolShellPrefixPath; } - return { stagedRuntime, teardown: registerWorkspaceSyncBack(stagedRuntime) }; + const syncBack = registerWorkspaceSyncBack(stagedRuntime); + const teardown: AcpxRemoteManagedHomeResult["teardown"] = localPermissionOverride && syncBack + ? async () => { + await localPermissionOverride.beforeRestore(); + const outcome = await syncBack(); + await localPermissionOverride.afterRestore(); + return outcome; + } + : syncBack; + return { stagedRuntime, teardown }; } export function classifyClaudeTerminalSessionFailure( diff --git a/packages/adapters/claude-local/src/server/claude-config.test.ts b/packages/adapters/claude-local/src/server/claude-config.test.ts index 3817f9260ac..1ee6ff63477 100644 --- a/packages/adapters/claude-local/src/server/claude-config.test.ts +++ b/packages/adapters/claude-local/src/server/claude-config.test.ts @@ -101,6 +101,50 @@ describe("prepareClaudeConfigSeed", () => { .resolves.toBe(JSON.stringify({ theme: "dark", permissions: { defaultMode: "default" } })); }); + it("seeds bypassPermissions only when it is requested", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-config-bypass-")); + cleanupDirs.push(root); + const sourceDir = path.join(root, "claude-source"); + await fs.mkdir(sourceDir, { recursive: true }); + await fs.writeFile(path.join(sourceDir, "settings.json"), JSON.stringify({ + theme: "light", + permissions: { defaultMode: "acceptEdits", allow: ["Bash(*)"] }, + hooks: { PreToolUse: [{ matcher: "*" }] }, + }), "utf8"); + + const onLog = vi.fn(async () => {}); + const env = createEnv(root, sourceDir); + const defaultSeed = await prepareClaudeConfigSeed(env, onLog, "company-1"); + const bypassSeed = await prepareClaudeConfigSeed(env, onLog, "company-1", { + permissionDefaultMode: "bypassPermissions", + }); + + expect(bypassSeed).not.toBe(defaultSeed); + await expect(fs.readFile(path.join(defaultSeed, "settings.json"), "utf8")) + .resolves.toBe(JSON.stringify({ theme: "light", permissions: { defaultMode: "default" } })); + await expect(fs.readFile(path.join(bypassSeed, "settings.json"), "utf8")) + .resolves.toBe(JSON.stringify({ theme: "light", permissions: { defaultMode: "bypassPermissions" } })); + }); + + it("writes a bypassPermissions settings file when the host has none", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-config-bypass-empty-")); + cleanupDirs.push(root); + const sourceDir = path.join(root, "claude-source"); + await fs.mkdir(sourceDir, { recursive: true }); + + const onLog = vi.fn(async () => {}); + const env = createEnv(root, sourceDir); + const defaultSeed = await prepareClaudeConfigSeed(env, onLog, "company-1"); + const bypassSeed = await prepareClaudeConfigSeed(env, onLog, "company-1", { + permissionDefaultMode: "bypassPermissions", + }); + + await expect(fs.access(path.join(defaultSeed, "settings.json"))) + .rejects.toMatchObject({ code: "ENOENT" }); + await expect(fs.readFile(path.join(bypassSeed, "settings.json"), "utf8")) + .resolves.toBe(JSON.stringify({ permissions: { defaultMode: "bypassPermissions" } })); + }); + it("strips local-only settings from remote Claude config seeds", async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-config-boundary-")); cleanupDirs.push(root); diff --git a/packages/adapters/claude-local/src/server/claude-config.ts b/packages/adapters/claude-local/src/server/claude-config.ts index adc836e7fcc..b242f879ea7 100644 --- a/packages/adapters/claude-local/src/server/claude-config.ts +++ b/packages/adapters/claude-local/src/server/claude-config.ts @@ -41,20 +41,25 @@ function isAlreadyExistsError(error: unknown): boolean { return code === "EEXIST" || code === "ENOTEMPTY"; } -function sanitizeRemoteClaudeSettings(raw: string): string { +export type RemoteClaudePermissionDefaultMode = "default" | "bypassPermissions"; + +function sanitizeRemoteClaudeSettings( + raw: string | null, + defaultMode: RemoteClaudePermissionDefaultMode, +): string { let parsed: unknown; try { - parsed = JSON.parse(raw); + parsed = raw === null ? null : JSON.parse(raw); } catch { - return JSON.stringify({ permissions: { defaultMode: "default" } }); + return JSON.stringify({ permissions: { defaultMode } }); } if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { - return JSON.stringify({ permissions: { defaultMode: "default" } }); + return JSON.stringify({ permissions: { defaultMode } }); } const settings = { ...(parsed as Record) }; - settings.permissions = { defaultMode: "default" }; + settings.permissions = { defaultMode }; delete settings.hooks; delete settings.mcpServers; delete settings.permissionMode; @@ -62,16 +67,22 @@ function sanitizeRemoteClaudeSettings(raw: string): string { return JSON.stringify(settings); } -async function collectSeedFiles(sourceDir: string): Promise { +async function collectSeedFiles( + sourceDir: string, + defaultMode: RemoteClaudePermissionDefaultMode, +): Promise { const files: SeedFile[] = []; for (const name of SEEDED_SHARED_FILES) { const sourcePath = path.join(sourceDir, name); - if (!(await pathExists(sourcePath))) continue; - const rawContents = await fs.readFile(sourcePath); - const contents = name === "settings.json" - ? Buffer.from(sanitizeRemoteClaudeSettings(rawContents.toString("utf8")), "utf8") - : rawContents; - files.push({ name, sourcePath, contents }); + const exists = await pathExists(sourcePath); + if (name === "settings.json") { + if (!exists && defaultMode === "default") continue; + const raw = exists ? (await fs.readFile(sourcePath)).toString("utf8") : null; + files.push({ name, sourcePath, contents: Buffer.from(sanitizeRemoteClaudeSettings(raw, defaultMode), "utf8") }); + continue; + } + if (!exists) continue; + files.push({ name, sourcePath, contents: await fs.readFile(sourcePath) }); } return files; } @@ -187,6 +198,7 @@ export async function prepareClaudeConfigSeed( env: NodeJS.ProcessEnv, onLog: AdapterExecutionContext["onLog"], companyId?: string, + options: { permissionDefaultMode?: RemoteClaudePermissionDefaultMode } = {}, ): Promise { const sourceDir = resolveSharedClaudeConfigDir(env); const targetRootDir = resolveManagedClaudeConfigSeedDir(env, companyId); @@ -195,7 +207,7 @@ export async function prepareClaudeConfigSeed( return targetRootDir; } - const copiedFiles = await collectSeedFiles(sourceDir); + const copiedFiles = await collectSeedFiles(sourceDir, options.permissionDefaultMode ?? "default"); const snapshotKey = await buildSeedSnapshotKey(copiedFiles); const targetDir = await materializeSeedSnapshot({ rootDir: targetRootDir, @@ -295,6 +307,97 @@ export async function materializeRemoteClaudeConfig(input: { }; } +type ClaudeLocalSettings = { settings: Record; permissions: Record }; + +function parseClaudeLocalSettings(raw: string | null): ClaudeLocalSettings | null { + if (raw === null) return null; + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return null; + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null; + const settings = parsed as Record; + const permissions = + settings.permissions && typeof settings.permissions === "object" && !Array.isArray(settings.permissions) + ? (settings.permissions as Record) + : {}; + return { settings, permissions }; +} + +function withClaudeLocalDefaultMode(local: ClaudeLocalSettings, defaultMode: unknown): string { + const permissions = { ...local.permissions }; + if (defaultMode === undefined) delete permissions.defaultMode; + else permissions.defaultMode = defaultMode; + return `${JSON.stringify({ ...local.settings, permissions }, null, 2)}\n`; +} + +export interface RemoteClaudeLocalPermissionOverride { + beforeRestore: () => Promise; + afterRestore: () => Promise; +} + +export async function overrideRemoteClaudeLocalPermissionMode(input: { + runId: string; + target: AdapterExecutionTarget | null | undefined; + workspaceLocalDir: string; + workspaceRemoteDir: string; + defaultMode: RemoteClaudePermissionDefaultMode; + options: AdapterExecutionTargetShellOptions; +}): Promise { + const localPath = path.join(input.workspaceLocalDir, ".claude", "settings.local.json"); + const original = await fs.readFile(localPath, "utf8").catch(() => null); + const originalLocal = parseClaudeLocalSettings(original); + if (original === null || !originalLocal) return null; + const originalMode = originalLocal.permissions.defaultMode; + if (originalMode === input.defaultMode) return null; + const overridden = withClaudeLocalDefaultMode(originalLocal, input.defaultMode); + const remotePath = shellQuote(path.posix.join(input.workspaceRemoteDir, ".claude", "settings.local.json")); + const quietOptions: AdapterExecutionTargetShellOptions = { ...input.options, onLog: async () => {} }; + const writeRemote = (contents: string) => + runAdapterExecutionTargetShellCommand( + input.runId, + input.target, + `if [ -f ${remotePath} ]; then printf '%s' ${shellQuote(contents)} > ${remotePath}; fi`, + quietOptions, + ); + const applied = await writeRemote(overridden); + if (applied.exitCode !== 0 || applied.timedOut) { + await input.options.onLog?.("stderr", "[paperclip] Could not apply the Claude permission mode to the sandbox workspace settings.\n"); + } + return { + beforeRestore: async () => { + try { + const read = await runAdapterExecutionTargetShellCommand( + input.runId, + input.target, + `cat ${remotePath}`, + quietOptions, + ); + if (read.exitCode !== 0 || read.timedOut) return; + if (read.stdout === overridden) { + await writeRemote(original); + return; + } + const current = parseClaudeLocalSettings(read.stdout); + if (current?.permissions.defaultMode === input.defaultMode) { + await writeRemote(withClaudeLocalDefaultMode(current, originalMode)); + } + } catch { + await input.options.onLog?.("stderr", "[paperclip] Could not reset the Claude permission mode in the sandbox workspace settings.\n"); + } + }, + afterRestore: async () => { + const current = parseClaudeLocalSettings(await fs.readFile(localPath, "utf8").catch(() => null)); + if (current?.permissions.defaultMode !== input.defaultMode) return; + await fs.writeFile(localPath, withClaudeLocalDefaultMode(current, originalMode)).catch(async () => { + await input.options.onLog?.("stderr", "[paperclip] Could not restore the host Claude workspace settings after the sandbox run.\n"); + }); + }, + }; +} + function isNonEmptyString(value: unknown): value is string { return typeof value === "string" && value.trim().length > 0; }