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, 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";