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; }