mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-07 06:16:06 -10:00
fix(claude-local): start approve-all sandbox ACP sessions in bypassPermissions (#19430)
## 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 <[email protected]>
This commit is contained in:
1 parent
d87f6fa4c2
commit
65c5d8d600
4 files changed
+356
-15
No files matched your search
@@ -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");
|
||||
|
||||
@@ -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, unknown>): string {
|
||||
const requested = firstNonEmptyString(config.permissionMode);
|
||||
return requested === undefined || requested === "default" ? DEFAULT_ACP_ENGINE_PERMISSION_MODE : requested;
|
||||
}
|
||||
|
||||
export function buildClaudeAcpConfig(
|
||||
config: Record<string, unknown>,
|
||||
inheritedEnv: Record<string, unknown> = {},
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<string, unknown>) };
|
||||
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<SeedFile[]> {
|
||||
async function collectSeedFiles(
|
||||
sourceDir: string,
|
||||
defaultMode: RemoteClaudePermissionDefaultMode,
|
||||
): Promise<SeedFile[]> {
|
||||
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<string> {
|
||||
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<string, unknown>; permissions: Record<string, unknown> };
|
||||
|
||||
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<string, unknown>;
|
||||
const permissions =
|
||||
settings.permissions && typeof settings.permissions === "object" && !Array.isArray(settings.permissions)
|
||||
? (settings.permissions as Record<string, unknown>)
|
||||
: {};
|
||||
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<void>;
|
||||
afterRestore: () => Promise<void>;
|
||||
}
|
||||
|
||||
export async function overrideRemoteClaudeLocalPermissionMode(input: {
|
||||
runId: string;
|
||||
target: AdapterExecutionTarget | null | undefined;
|
||||
workspaceLocalDir: string;
|
||||
workspaceRemoteDir: string;
|
||||
defaultMode: RemoteClaudePermissionDefaultMode;
|
||||
options: AdapterExecutionTargetShellOptions;
|
||||
}): Promise<RemoteClaudeLocalPermissionOverride | null> {
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user