From d2ef1547bb47dd8bb8623b8e5fa0861f661d4179 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 10 Oct 2026 15:29:47 -0500 Subject: [PATCH 1/3] fix(ui): honor forced Kubernetes before Boat engine checks Co-Authored-By: Paperclip --- .../AgentConfigForm.render.test.tsx | 25 +++++++++++++++++++ ui/src/components/AgentConfigForm.tsx | 1 + 2 files changed, 26 insertions(+) diff --git a/ui/src/components/AgentConfigForm.render.test.tsx b/ui/src/components/AgentConfigForm.render.test.tsx index e61f003ead..6e7329c64a 100644 --- a/ui/src/components/AgentConfigForm.render.test.tsx +++ b/ui/src/components/AgentConfigForm.render.test.tsx @@ -3923,6 +3923,31 @@ describe("AgentConfigForm managed-sandbox-only host surfaces", () => { expect(result.onSave).toHaveBeenCalledWith(expect.objectContaining({ adapterConfig: expect.objectContaining({ engine: "cli" }) })); }); + it.each(["codex_local", "claude_local"] as const)("does not apply Boat engine restrictions when Kubernetes overrides %s execution", async adapterType => { + setManagedSandboxOnly(true); + mockInstanceSettingsApi.getGeneral.mockResolvedValue({ executionMode: "kubernetes" }); + const saveActions = vi.fn(); + const result = await renderForm( + [ + makeEnvironment({ id: "boat-1", name: "Boat", driver: "computer", status: "active", config: { provider: "boat" } }), + makeEnvironment({ id: "kubernetes-1", name: "Kubernetes", driver: "sandbox", status: "active", config: { provider: "kubernetes" } }), + ], + { adapterType, defaultEnvironmentId: "boat-1", adapterConfig: { engine: "acp" } }, + { onSaveActionChange: saveActions, showAdapterTestEnvironmentButton: true }, + ); + roots.push(result.root); + await act(async () => { + for (const button of result.container.querySelectorAll("button")) { + if (["Advanced", "Advanced Run Policy"].includes(button.textContent?.trim() ?? "")) button.click(); + } + }); + await flushReact(); + expect(result.container.textContent).not.toContain("CLI before saving or testing"); + expect(fieldLabels(result.container)).not.toContain("Execution engine"); + expect(findButton(result.container, "Test")?.disabled).toBe(false); + expect(saveActions.mock.lastCall?.[0]).toEqual(expect.any(Function)); + }); + it.each(["active", "archived"] as const)("only exposes the engine for a resolved active Boat, keeping managed host paths hidden (%s)", async status => { setManagedSandboxOnly(true); const result = await renderForm( diff --git a/ui/src/components/AgentConfigForm.tsx b/ui/src/components/AgentConfigForm.tsx index 6d4b78304c..06533ff9bc 100644 --- a/ui/src/components/AgentConfigForm.tsx +++ b/ui/src/components/AgentConfigForm.tsx @@ -807,6 +807,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { [environments, effectiveLoginEnvironmentId], ); const attachedBoat = experimentalSettings !== undefined + && !forcedKubernetes && effectiveLoginEnvironment?.status === "active" && effectiveLoginEnvironment.driver === "computer" && effectiveLoginEnvironment.config.provider === "boat"; From 8b2a71c1e4a9029d5a8e96ce22c760fccaab4d9f Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 10 Oct 2026 15:31:41 -0500 Subject: [PATCH 2/3] fix(adapters): expose Boat instructions and managed Claude skills Keep controller instruction reads while advertising the registered persistent execution path. Mount approved remote Claude skills into the isolated user configuration so user-only settings can discover them without enabling project settings. Co-Authored-By: Paperclip --- .../src/server/claude-config.test.ts | 32 +++++- .../claude-local/src/server/claude-config.ts | 18 ++- .../claude-local/src/server/execute.ts | 16 ++- .../codex-local/src/server/execute.ts | 14 ++- .../__tests__/claude-local-execute.test.ts | 104 ++++++++++++++++++ .../src/__tests__/codex-local-execute.test.ts | 95 ++++++++++++++++ server/src/services/heartbeat.ts | 3 + 7 files changed, 272 insertions(+), 10 deletions(-) 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 f1fc02d1f7..5a07f802fb 100644 --- a/packages/adapters/claude-local/src/server/claude-config.test.ts +++ b/packages/adapters/claude-local/src/server/claude-config.test.ts @@ -1,5 +1,7 @@ import * as fs from "node:fs/promises"; import os from "node:os"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { AdapterExecutionTarget } from "@paperclipai/adapter-utils/execution-target"; @@ -22,7 +24,7 @@ vi.mock("@paperclipai/adapter-utils/execution-target", async () => { }; }); -import { prepareClaudeConfigSeed, prepareSandboxClaudeProbeRuntime } from "./claude-config.js"; +import { buildRemoteClaudeConfigMaterializationCommand, prepareClaudeConfigSeed, prepareSandboxClaudeProbeRuntime } from "./claude-config.js"; describe("prepareClaudeConfigSeed", () => { const cleanupDirs: string[] = []; @@ -267,3 +269,31 @@ describe("prepareSandboxClaudeProbeRuntime managed-config diagnostics", () => { warnSpy.mockRestore(); }); }); + + +describe("managed Claude user-scope skills", () => { + it("refreshes only its skill symlink and refuses an existing directory", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "claude-user-skills-")); + const config = path.join(root, "config"); + const seed = path.join(root, "seed"); + const first = path.join(root, "first-skills"); + const second = path.join(root, "second-skills"); + const run = (remoteSkillsDir: string) => promisify(execFile)("sh", ["-c", + buildRemoteClaudeConfigMaterializationCommand({ + remoteClaudeConfigDir: config, remoteClaudeConfigSeedDir: seed, remoteSkillsDir, + }), + ], { env: { PATH: process.env.PATH, HOME: root } }); + try { + await fs.mkdir(first); await fs.mkdir(second); + await run(first); + expect(await fs.realpath(path.join(config, "skills"))).toBe(await fs.realpath(first)); + await run(second); + expect(await fs.realpath(path.join(config, "skills"))).toBe(await fs.realpath(second)); + await fs.unlink(path.join(config, "skills")); + await fs.mkdir(path.join(config, "skills")); + await fs.writeFile(path.join(config, "skills", "keep.txt"), "user file"); + await expect(run(first)).rejects.toThrow(); + expect(await fs.readFile(path.join(config, "skills", "keep.txt"), "utf8")).toBe("user file"); + } finally { await fs.rm(root, { recursive: true, force: true }); } + }); +}); diff --git a/packages/adapters/claude-local/src/server/claude-config.ts b/packages/adapters/claude-local/src/server/claude-config.ts index dd759a8099..fd87ccb0d3 100644 --- a/packages/adapters/claude-local/src/server/claude-config.ts +++ b/packages/adapters/claude-local/src/server/claude-config.ts @@ -221,7 +221,16 @@ export async function prepareClaudeConfigSeed( export function buildRemoteClaudeConfigMaterializationCommand(input: { remoteClaudeConfigDir: string; remoteClaudeConfigSeedDir: string; + remoteSkillsDir?: string; }): string { + // --setting-sources user excludes --add-dir's project-scoped skills. Mount + // only the approved bundle into this isolated runtime's user skill scope. + const skillsLink = shellQuote(path.posix.join(input.remoteClaudeConfigDir, "skills")); + const mountSkills = input.remoteSkillsDir + ? ` && { if [ -e ${skillsLink} ] && [ ! -L ${skillsLink} ]; then ` + + `echo 'Managed Claude skills path is not a symlink' >&2; exit 1; fi; ` + + `ln -sfn ${shellQuote(input.remoteSkillsDir)} ${skillsLink}; }` + : ""; return `mkdir -p ${shellQuote(input.remoteClaudeConfigDir)} && ` + `if [ -d ${shellQuote(input.remoteClaudeConfigSeedDir)} ]; then ` + `cp -R ${shellQuote(`${input.remoteClaudeConfigSeedDir}/.`)} ${shellQuote(input.remoteClaudeConfigDir)}/; ` + @@ -230,7 +239,7 @@ export function buildRemoteClaudeConfigMaterializationCommand(input: { `if [ -n "\${HOME:-}" ] && [ -f "\${HOME}/.claude/\${file}" ] && [ ! -f ${shellQuote(input.remoteClaudeConfigDir)}/"\${file}" ]; then ` + `cp "\${HOME}/.claude/\${file}" ${shellQuote(input.remoteClaudeConfigDir)}/"\${file}"; ` + `fi; ` + - `done`; + `done` + mountSkills; } export async function materializeRemoteClaudeConfig(input: { @@ -238,17 +247,22 @@ export async function materializeRemoteClaudeConfig(input: { target: AdapterExecutionTarget | null | undefined; remoteClaudeConfigDir: string; remoteClaudeConfigSeedDir: string; + remoteSkillsDir?: string; options: AdapterExecutionTargetShellOptions; }): Promise { - await runAdapterExecutionTargetShellCommand( + const result = await runAdapterExecutionTargetShellCommand( input.runId, input.target, buildRemoteClaudeConfigMaterializationCommand({ remoteClaudeConfigDir: input.remoteClaudeConfigDir, remoteClaudeConfigSeedDir: input.remoteClaudeConfigSeedDir, + remoteSkillsDir: input.remoteSkillsDir, }), input.options, ); + if (result.exitCode !== 0 || result.timedOut) { + throw new Error("Could not materialize the managed Claude config and skills"); + } } function isNonEmptyString(value: unknown): value is string { diff --git a/packages/adapters/claude-local/src/server/execute.ts b/packages/adapters/claude-local/src/server/execute.ts index 53b3baf045..40a7318b09 100644 --- a/packages/adapters/claude-local/src/server/execute.ts +++ b/packages/adapters/claude-local/src/server/execute.ts @@ -464,7 +464,13 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0; const instructionsFilePath = asString(config.instructionsFilePath, "").trim(); - const instructionsFileDir = instructionsFilePath ? `${path.dirname(instructionsFilePath)}/` : ""; + // Controller scratch supplies prompt bytes; persistent computer paths come + // from the registered instruction copy, including a nested entry file. + const persistentComputer = executionTarget?.kind === "remote" && executionTarget.transport === "computer"; + const promptInstructionsFilePath = persistentComputer + ? asString(workspaceContext.instructionsFilePath, "").trim() + : instructionsFilePath; + const instructionsFileDir = promptInstructionsFilePath ? `${(persistentComputer ? path.posix : path).dirname(promptInstructionsFilePath)}/` : ""; const runtimeConfig = await buildClaudeRuntimeConfig({ agentIdentity: ctx.agentIdentity, runId, @@ -513,12 +519,12 @@ export async function execute(ctx: AdapterExecutionContext): Promise { } }, 10_000); + it("uses registered persistent instruction paths in computer CLI prompts", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-execute-sandbox-")); + const localWorkspace = path.join(root, "workspace"); + const remoteWorkspace = path.join(root, "sandbox-$HOME"); + const binDir = path.join(root, "bin"); + const commandPath = path.join(binDir, "claude"); + const capturePath1 = path.join(remoteWorkspace, "capture-1.json"); + const claudeRoot = path.join(root, ".claude"); + const localInstructions = path.join(localWorkspace, "AGENTS.md"); + const remoteInstructions = path.join(remoteWorkspace, "nested", "AGENTS.md"); + const runner = createLocalSandboxRunner(); + const previousHome = process.env.HOME; + const previousPath = process.env.PATH; + + await fs.mkdir(localWorkspace, { recursive: true }); + await fs.mkdir(remoteWorkspace, { recursive: true }); + await fs.mkdir(binDir, { recursive: true }); + await fs.mkdir(claudeRoot, { recursive: true }); + await fs.writeFile(path.join(claudeRoot, "settings.json"), JSON.stringify({ theme: "test" }), "utf8"); + await writeFakeClaudeCommand(commandPath); + + await fs.writeFile(localInstructions, "Persistent guidance contents."); + process.env.HOME = root; + process.env.PATH = `${binDir}${path.delimiter}${process.env.PATH ?? ""}`; + + try { + const result = await execute({ + runId: "run-sandbox-auth", + agent: { + id: "agent-1", + companyId: "company-1", + name: "Claude Coder", + adapterType: "claude_local", + adapterConfig: { engine: "cli" }, + }, + runtime: { + sessionId: null, + sessionParams: null, + sessionDisplayId: null, + taskKey: null, + }, + config: { + engine: "cli", + managedAiConnection: true, + command: commandPath, + cwd: localWorkspace, + instructionsFilePath: localInstructions, + env: { + CLAUDE_CONFIG_DIR: claudeRoot, + PAPERCLIP_TEST_CAPTURE_PATH: capturePath1, + }, + promptTemplate: "Follow the paperclip heartbeat.", + }, + context: { paperclipWorkspace: { instructionsFilePath: remoteInstructions, agentHome: remoteWorkspace } }, + executionTarget: { + kind: "remote", + transport: "computer", + providerKey: "e2b", + environmentId: "env-1", + leaseId: "lease-1", + remoteCwd: remoteWorkspace, + timeoutMs: 30_000, + runner, + processRunner: runner, + listenerPort: 12345, + resourceAuthority: { kind: "computer-owner", computerId: "computer-1", ownerId: "owner-1", generation: 1 }, + fileAuthority: { kind: "remote-persistent", placementId: "placement-1", root: remoteWorkspace, agentHome: remoteWorkspace }, + workspaceRealization: { mode: "in_place", authoritativeRoot: remoteWorkspace }, + launch: async () => ({}), + inspectProcess: async () => ({ running: false, claim: null }), + retainWarm: async () => {}, + retire: async () => true, + computerTool: { command: "unused", args: [] }, + }, + authToken: "run-jwt-token", + onLog: async () => {}, + }); + + expect(result.exitCode).toBe(0); + const capture = JSON.parse(await fs.readFile(capturePath1, "utf8")) as CapturePayload; + expect(capture.argv).toContain("--dangerously-skip-permissions"); + expect(capture.argv).not.toContain("--allowedTools"); + expect(capture.claudeConfigDir).toContain(path.join(remoteWorkspace, ".paperclip-runtime", "claude", "owners")); + expect(capture.argv.slice(capture.argv.indexOf("--setting-sources"), capture.argv.indexOf("--setting-sources") + 2)).toEqual(["--setting-sources", "user"]); + const userSkills = path.join(capture.claudeConfigDir!, "skills"); + expect(await fs.realpath(userSkills)).toBe(await fs.realpath(path.join(capture.addDir!, ".claude", "skills"))); + expect(await fs.readFile(path.join(userSkills, "paperclip", "SKILL.md"), "utf8")).toContain("name: paperclip"); + expect(capture.instructionsContents).toContain("Persistent guidance contents."); + expect(capture.claudeConfigEntries).toContain("settings.json"); + expect(capture.prompt).toContain(remoteInstructions); + expect(capture.prompt).toContain(`${path.dirname(remoteInstructions)}/`); + expect(capture.prompt).not.toContain(localInstructions); + expect(capture.paperclipApiUrl).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/); + expect(capture.paperclipApiKey).not.toBe("run-jwt-token"); + expect(capture.paperclipApiBridgeMode).toBe("queue_v1"); + } finally { + if (previousHome === undefined) delete process.env.HOME; + else process.env.HOME = previousHome; + if (previousPath === undefined) delete process.env.PATH; + else process.env.PATH = previousPath; + await fs.rm(root, { recursive: true, force: true }); + } + }, 10_000); + it("omits --effort for sandbox-managed runs when the installed Claude CLI does not advertise it", async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-execute-sandbox-effort-")); const { workspace, commandPath, capturePath, restore } = await setupExecuteEnv(root, { diff --git a/server/src/__tests__/codex-local-execute.test.ts b/server/src/__tests__/codex-local-execute.test.ts index 29a3a2b225..265a7d3ef3 100644 --- a/server/src/__tests__/codex-local-execute.test.ts +++ b/server/src/__tests__/codex-local-execute.test.ts @@ -538,6 +538,101 @@ describe("codex execute", () => { } }); + it("uses registered persistent instruction paths in computer CLI prompts", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-execute-sandbox-")); + const localWorkspace = path.join(root, "workspace"); + const remoteWorkspace = path.join(root, "sandbox"); + const binDir = path.join(root, "bin"); + const commandPath = path.join(binDir, "codex"); + const capturePath = path.join(remoteWorkspace, "capture.json"); + const localInstructions = path.join(localWorkspace, "AGENTS.md"); + const remoteInstructions = path.join(remoteWorkspace, "nested", "AGENTS.md"); + const runner = createLocalSandboxRunner(); + const previousHome = process.env.HOME; + const previousPath = process.env.PATH; + + await fs.mkdir(localWorkspace, { recursive: true }); + await fs.mkdir(remoteWorkspace, { recursive: true }); + await fs.mkdir(binDir, { recursive: true }); + await writeFakeCodexCommand(commandPath); + + await fs.writeFile(localInstructions, "Persistent guidance contents."); + process.env.HOME = root; + process.env.PATH = `${binDir}${path.delimiter}${process.env.PATH ?? ""}`; + await seedSharedCodexAuth(root); + + try { + const result = await execute({ + runId: "run-sandbox-auth", + agent: { + id: "agent-1", + companyId: "company-1", + name: "Codex Coder", + adapterType: "codex_local", + adapterConfig: { engine: "cli" }, + }, + runtime: { + sessionId: null, + sessionParams: null, + sessionDisplayId: null, + taskKey: null, + }, + config: { + engine: "cli", + command: commandPath, + cwd: localWorkspace, + instructionsFilePath: localInstructions, + env: { + PAPERCLIP_TEST_CAPTURE_PATH: capturePath, + }, + promptTemplate: "Follow the paperclip heartbeat.", + }, + context: { paperclipWorkspace: { instructionsFilePath: remoteInstructions, agentHome: remoteWorkspace } }, + executionTarget: { + kind: "remote", + transport: "computer", + providerKey: "e2b", + environmentId: "env-1", + leaseId: "lease-1", + remoteCwd: remoteWorkspace, + timeoutMs: 30_000, + runner, + processRunner: runner, + listenerPort: 12345, + resourceAuthority: { kind: "computer-owner", computerId: "computer-1", ownerId: "owner-1", generation: 1 }, + fileAuthority: { kind: "remote-persistent", placementId: "placement-1", root: remoteWorkspace, agentHome: remoteWorkspace }, + workspaceRealization: { mode: "in_place", authoritativeRoot: remoteWorkspace }, + launch: async () => ({}), + inspectProcess: async () => ({ running: false, claim: null }), + retainWarm: async () => {}, + retire: async () => true, + computerTool: { command: "unused", args: [] }, + }, + authToken: "run-jwt-token", + onLog: async () => {}, + }); + + expect(result.exitCode).toBe(0); + expect(result.errorMessage).toBeNull(); + + const capture = JSON.parse(await fs.readFile(capturePath, "utf8")) as CapturePayload; + expect(capture.codexHome).toContain(path.join(remoteWorkspace, ".paperclip-runtime", "codex", "owners")); + expect(capture.prompt).toContain("Persistent guidance contents."); + expect(capture.prompt).toContain(remoteInstructions); + expect(capture.prompt).toContain(`${path.dirname(remoteInstructions)}/`); + expect(capture.prompt).not.toContain(localInstructions); + expect(capture.paperclipApiUrl).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/); + expect(capture.paperclipApiKey).not.toBe("run-jwt-token"); + expect(capture.paperclipApiBridgeMode).toBe("queue_v1"); + } finally { + if (previousHome === undefined) delete process.env.HOME; + else process.env.HOME = previousHome; + if (previousPath === undefined) delete process.env.PATH; + else process.env.PATH = previousPath; + await fs.rm(root, { recursive: true, force: true }); + } + }); + it.each([false, true])("delivers oversized wake context through stdin (sandbox=%s)", async (sandbox) => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-execute-wake-")); const workspace = path.join(root, "workspace"); diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 78ce37bc33..1d7ca4f550 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -4711,6 +4711,9 @@ export function heartbeatService( if (isAgentDirectoryCopy(instructionCopy)) { const workspace = parseObject(context.paperclipWorkspace); context.paperclipWorkspace = { ...workspace, agentHome: instructionCopy.executionRoot, + ...(instructionCopy.receipt?.fileAuthority === "remote-persistent" ? { + instructionsFilePath: path.posix.join(instructionCopy.executionRoot, instructionCopy.entryFile), + } : {}), // Keep the pre-existing permission root stable for ACP session // identity. The per-run copy is already under the company root. agentHomeForPermissions: workspace.agentHome, From 5c3cd777df95d364f96e0c9210c6eec3117336f0 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 10 Oct 2026 15:32:22 -0500 Subject: [PATCH 3/3] fix(computers): expose persistent instructions and managed CLI skills Integrate the scoped production correction and existing config test from 2700054c6a. Full Claude and Codex execute regressions remain in the stacked acceptance PR. Co-Authored-By: Paperclip --- .../src/server/claude-config.test.ts | 32 ++++++++++++++++++- .../claude-local/src/server/claude-config.ts | 18 +++++++++-- .../claude-local/src/server/execute.ts | 16 +++++++--- .../codex-local/src/server/execute.ts | 14 ++++++-- server/src/services/heartbeat.ts | 3 ++ 5 files changed, 73 insertions(+), 10 deletions(-) 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 f1fc02d1f7..5a07f802fb 100644 --- a/packages/adapters/claude-local/src/server/claude-config.test.ts +++ b/packages/adapters/claude-local/src/server/claude-config.test.ts @@ -1,5 +1,7 @@ import * as fs from "node:fs/promises"; import os from "node:os"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { AdapterExecutionTarget } from "@paperclipai/adapter-utils/execution-target"; @@ -22,7 +24,7 @@ vi.mock("@paperclipai/adapter-utils/execution-target", async () => { }; }); -import { prepareClaudeConfigSeed, prepareSandboxClaudeProbeRuntime } from "./claude-config.js"; +import { buildRemoteClaudeConfigMaterializationCommand, prepareClaudeConfigSeed, prepareSandboxClaudeProbeRuntime } from "./claude-config.js"; describe("prepareClaudeConfigSeed", () => { const cleanupDirs: string[] = []; @@ -267,3 +269,31 @@ describe("prepareSandboxClaudeProbeRuntime managed-config diagnostics", () => { warnSpy.mockRestore(); }); }); + + +describe("managed Claude user-scope skills", () => { + it("refreshes only its skill symlink and refuses an existing directory", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "claude-user-skills-")); + const config = path.join(root, "config"); + const seed = path.join(root, "seed"); + const first = path.join(root, "first-skills"); + const second = path.join(root, "second-skills"); + const run = (remoteSkillsDir: string) => promisify(execFile)("sh", ["-c", + buildRemoteClaudeConfigMaterializationCommand({ + remoteClaudeConfigDir: config, remoteClaudeConfigSeedDir: seed, remoteSkillsDir, + }), + ], { env: { PATH: process.env.PATH, HOME: root } }); + try { + await fs.mkdir(first); await fs.mkdir(second); + await run(first); + expect(await fs.realpath(path.join(config, "skills"))).toBe(await fs.realpath(first)); + await run(second); + expect(await fs.realpath(path.join(config, "skills"))).toBe(await fs.realpath(second)); + await fs.unlink(path.join(config, "skills")); + await fs.mkdir(path.join(config, "skills")); + await fs.writeFile(path.join(config, "skills", "keep.txt"), "user file"); + await expect(run(first)).rejects.toThrow(); + expect(await fs.readFile(path.join(config, "skills", "keep.txt"), "utf8")).toBe("user file"); + } finally { await fs.rm(root, { recursive: true, force: true }); } + }); +}); diff --git a/packages/adapters/claude-local/src/server/claude-config.ts b/packages/adapters/claude-local/src/server/claude-config.ts index dd759a8099..fd87ccb0d3 100644 --- a/packages/adapters/claude-local/src/server/claude-config.ts +++ b/packages/adapters/claude-local/src/server/claude-config.ts @@ -221,7 +221,16 @@ export async function prepareClaudeConfigSeed( export function buildRemoteClaudeConfigMaterializationCommand(input: { remoteClaudeConfigDir: string; remoteClaudeConfigSeedDir: string; + remoteSkillsDir?: string; }): string { + // --setting-sources user excludes --add-dir's project-scoped skills. Mount + // only the approved bundle into this isolated runtime's user skill scope. + const skillsLink = shellQuote(path.posix.join(input.remoteClaudeConfigDir, "skills")); + const mountSkills = input.remoteSkillsDir + ? ` && { if [ -e ${skillsLink} ] && [ ! -L ${skillsLink} ]; then ` + + `echo 'Managed Claude skills path is not a symlink' >&2; exit 1; fi; ` + + `ln -sfn ${shellQuote(input.remoteSkillsDir)} ${skillsLink}; }` + : ""; return `mkdir -p ${shellQuote(input.remoteClaudeConfigDir)} && ` + `if [ -d ${shellQuote(input.remoteClaudeConfigSeedDir)} ]; then ` + `cp -R ${shellQuote(`${input.remoteClaudeConfigSeedDir}/.`)} ${shellQuote(input.remoteClaudeConfigDir)}/; ` + @@ -230,7 +239,7 @@ export function buildRemoteClaudeConfigMaterializationCommand(input: { `if [ -n "\${HOME:-}" ] && [ -f "\${HOME}/.claude/\${file}" ] && [ ! -f ${shellQuote(input.remoteClaudeConfigDir)}/"\${file}" ]; then ` + `cp "\${HOME}/.claude/\${file}" ${shellQuote(input.remoteClaudeConfigDir)}/"\${file}"; ` + `fi; ` + - `done`; + `done` + mountSkills; } export async function materializeRemoteClaudeConfig(input: { @@ -238,17 +247,22 @@ export async function materializeRemoteClaudeConfig(input: { target: AdapterExecutionTarget | null | undefined; remoteClaudeConfigDir: string; remoteClaudeConfigSeedDir: string; + remoteSkillsDir?: string; options: AdapterExecutionTargetShellOptions; }): Promise { - await runAdapterExecutionTargetShellCommand( + const result = await runAdapterExecutionTargetShellCommand( input.runId, input.target, buildRemoteClaudeConfigMaterializationCommand({ remoteClaudeConfigDir: input.remoteClaudeConfigDir, remoteClaudeConfigSeedDir: input.remoteClaudeConfigSeedDir, + remoteSkillsDir: input.remoteSkillsDir, }), input.options, ); + if (result.exitCode !== 0 || result.timedOut) { + throw new Error("Could not materialize the managed Claude config and skills"); + } } function isNonEmptyString(value: unknown): value is string { diff --git a/packages/adapters/claude-local/src/server/execute.ts b/packages/adapters/claude-local/src/server/execute.ts index 53b3baf045..40a7318b09 100644 --- a/packages/adapters/claude-local/src/server/execute.ts +++ b/packages/adapters/claude-local/src/server/execute.ts @@ -464,7 +464,13 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0; const instructionsFilePath = asString(config.instructionsFilePath, "").trim(); - const instructionsFileDir = instructionsFilePath ? `${path.dirname(instructionsFilePath)}/` : ""; + // Controller scratch supplies prompt bytes; persistent computer paths come + // from the registered instruction copy, including a nested entry file. + const persistentComputer = executionTarget?.kind === "remote" && executionTarget.transport === "computer"; + const promptInstructionsFilePath = persistentComputer + ? asString(workspaceContext.instructionsFilePath, "").trim() + : instructionsFilePath; + const instructionsFileDir = promptInstructionsFilePath ? `${(persistentComputer ? path.posix : path).dirname(promptInstructionsFilePath)}/` : ""; const runtimeConfig = await buildClaudeRuntimeConfig({ agentIdentity: ctx.agentIdentity, runId, @@ -513,12 +519,12 @@ export async function execute(ctx: AdapterExecutionContext): Promise