diff --git a/packages/adapters/claude-local/src/server/execute.remote.test.ts b/packages/adapters/claude-local/src/server/execute.remote.test.ts index 58118a838e..3a80ff225f 100644 --- a/packages/adapters/claude-local/src/server/execute.remote.test.ts +++ b/packages/adapters/claude-local/src/server/execute.remote.test.ts @@ -1,4 +1,4 @@ -import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, rm, stat, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; @@ -95,7 +95,7 @@ describe("claude remote execution", () => { } }); - it("prepares the workspace, syncs Claude runtime assets, and restores workspace changes for remote SSH execution", async () => { + it.each([true, false])("prepares and restores a copied SSH workspace (already exists: %s)", async (exists) => { vi.stubEnv("CLAUDE_CODE_USE_BEDROCK", "1"); vi.stubEnv("ANTHROPIC_MODEL", "host-only-model"); const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-claude-remote-")); @@ -104,7 +104,11 @@ describe("claude remote execution", () => { const alternateWorkspaceDir = path.join(rootDir, "workspace-other"); const instructionsPath = path.join(rootDir, "instructions.md"); const managedRemoteWorkspace = "/remote/workspace/.paperclip-runtime/runs/run-1/workspace"; - await mkdir(workspaceDir, { recursive: true }); + if (exists) await mkdir(workspaceDir, { recursive: true }); + prepareWorkspaceForSshExecution.mockImplementationOnce(async () => { + expect((await stat(workspaceDir)).isDirectory()).toBe(true); + return { gitBacked: false }; + }); await mkdir(alternateWorkspaceDir, { recursive: true }); await writeFile(instructionsPath, "Use the remote workspace.\n", "utf8"); diff --git a/packages/adapters/claude-local/src/server/execute.ts b/packages/adapters/claude-local/src/server/execute.ts index 40a7318b09..68c94789b5 100644 --- a/packages/adapters/claude-local/src/server/execute.ts +++ b/packages/adapters/claude-local/src/server/execute.ts @@ -219,7 +219,7 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise { describe("ACPX installation integrity", () => { describe.each(["runtime", "command"] as const)("%s executable hydration", (kind) => { const verify = kind === "runtime" ? openVerifiedRuntimeExecutable : inspectCommand; - it.each(["once", "every_read", "wrong_digest", "mode", "mtime", "replacement", "symlink", "second_content"] as const)("requires stable full re-verification after a ctime-only transition (%s)", async (transition) => { + it.each(["once", "every_read", "wrong_digest", "mode", "mtime", "replacement", "symlink", "second_content", "uid", "gid", "nlink"] as const)("requires stable full re-verification after a ctime-only transition (%s)", async (transition) => { const directory = await mkdtemp(join(tmpdir(), "paperclip-runtime-hydration-")); temporaryDirectories.push(directory); const executable = join(directory, "runtime"); @@ -74,8 +74,15 @@ describe("ACPX installation integrity", () => { const probe = await open(executable, "r"); const prototype = Object.getPrototypeOf(probe) as FileHandle; const originalRead = probe.read; + const originalStat = probe.stat; await probe.close(); let reads = 0; + // Model ownership changes without requiring privileged chown in the test. + const statSpy = vi.spyOn(prototype, "stat").mockImplementation(async function (this: FileHandle, ...args: Parameters) { + const metadata = await Reflect.apply(originalStat, this, args); + if (reads > 0 && (transition === "uid" || transition === "gid")) metadata[transition] += 1n; + return metadata; + }); const readSpy = vi.spyOn(prototype, "read").mockImplementation(async function (this: FileHandle, ...args: Parameters) { if (reads === 1 && transition === "second_content") { await writeFile(executable, Buffer.alloc(bytes.length, 120)); @@ -93,6 +100,7 @@ describe("ACPX installation integrity", () => { const after = await stat(executable, { bigint: true }); expect(after.ctimeNs).not.toBe(before.ctimeNs); expect([after.dev, after.ino, after.size, after.mode, after.mtimeNs]).toEqual([before.dev, before.ino, before.size, before.mode, before.mtimeNs]); + if (transition === "nlink") await link(executable, `${executable}.link`); if (transition === "mode") await chmod(executable, 0o700); if (transition === "mtime") await utimes(executable, new Date(0), new Date(0)); if (transition === "replacement" || transition === "symlink") { @@ -117,7 +125,7 @@ describe("ACPX installation integrity", () => { await expect(verify(executable, digest, "fixture")).rejects.toThrow(transition === "wrong_digest" ? "digest mismatch" : "changed while it was verified"); } expect(reads).toBe(["once", "every_read", "second_content"].includes(transition) ? 2 : 1); - } finally { readSpy.mockRestore(); } + } finally { readSpy.mockRestore(); statSpy.mockRestore(); } }); }); diff --git a/packages/paperclip-runner/src/drivers/acpx/installation-integrity.ts b/packages/paperclip-runner/src/drivers/acpx/installation-integrity.ts index b51da6efe5..b1a3a2f228 100644 --- a/packages/paperclip-runner/src/drivers/acpx/installation-integrity.ts +++ b/packages/paperclip-runner/src/drivers/acpx/installation-integrity.ts @@ -1188,7 +1188,10 @@ async function openVerifiedExecutable( const beforeIdentity = fileIdentity(before); if ( !sameIdentity(fileIdentity(lexicalBaseline), beforeIdentity) || - lexicalBaseline.mode !== before.mode + lexicalBaseline.mode !== before.mode || + lexicalBaseline.uid !== before.uid || + lexicalBaseline.gid !== before.gid || + lexicalBaseline.nlink !== before.nlink ) { throw new Error( `${label} changed while it was verified`, @@ -1226,7 +1229,13 @@ async function openVerifiedExecutable( !lexicalAfter.isFile() || !sameIdentity(fileIdentity(lexicalAfter), afterIdentity) || lexicalAfter.mode !== after.mode || + lexicalAfter.uid !== after.uid || + lexicalAfter.gid !== after.gid || + lexicalAfter.nlink !== after.nlink || before.mode !== after.mode || + before.uid !== after.uid || + before.gid !== after.gid || + before.nlink !== after.nlink || !sameIdentity( { ...beforeIdentity, changedNanoseconds: afterIdentity.changedNanoseconds }, afterIdentity, diff --git a/packages/paperclip-runner/src/drivers/codex/codex-security-config.ts b/packages/paperclip-runner/src/drivers/codex/codex-security-config.ts index d02e0cfb4b..aa16e4ace3 100644 --- a/packages/paperclip-runner/src/drivers/codex/codex-security-config.ts +++ b/packages/paperclip-runner/src/drivers/codex/codex-security-config.ts @@ -250,6 +250,8 @@ export function createIsolatedCodexAppServerArgs( readOnlyRoots: string[] = [], /** Server-registered run copy, never an environment/config-supplied root. */ instructionWorkingCopyRoot?: string, + /** Separate controller-registered personal directory, never an ambient env grant. */ + persistentAgentHome?: string, ): string[] { const explicitSource = source; source ??= process.env; @@ -268,7 +270,9 @@ export function createIsolatedCodexAppServerArgs( const commandEnvironment = codexCommandEnvironment(source); // Selected task values are inherited, never serialized into configuration argv. for (const key of configuredEnvironmentKeys(explicitSource)) delete commandEnvironment[key]; - if (instructionWorkingCopyRoot && source.AGENT_HOME === instructionWorkingCopyRoot) commandEnvironment.AGENT_HOME = instructionWorkingCopyRoot; + const registeredAgentHome = persistentAgentHome ?? instructionWorkingCopyRoot; + if (registeredAgentHome && source.AGENT_HOME === registeredAgentHome) commandEnvironment.AGENT_HOME = registeredAgentHome; + const registeredWritableRoots = [...new Set([instructionWorkingCopyRoot, persistentAgentHome].filter((root): root is string => Boolean(root)))]; const shellEnvironmentKeys = [...new Set([...inheritedGitHubKeys, ...Object.keys(commandEnvironment)])].sort(); if (source.PAPERCLIP_GITHUB_LAUNCHER_DIR) readOnlyRoots = [...readOnlyRoots, source.PAPERCLIP_GITHUB_LAUNCHER_DIR]; const deniedHostRoots = [ @@ -289,7 +293,7 @@ export function createIsolatedCodexAppServerArgs( ...readOnlyRoots.map((path) => `${tomlString(resolve(path))}="read"`), ...gitRoots.read.map((path) => `${tomlString(path)}="read"`), ...gitRoots.write.map((path) => `${tomlString(path)}="write"`), - ...(instructionWorkingCopyRoot ? [`${tomlString(resolve(instructionWorkingCopyRoot))}="write"`] : []), + ...registeredWritableRoots.map((root) => `${tomlString(resolve(root))}="write"`), ...(source.PAPERCLIP_GITHUB_BROKER_TOKEN && source.GH_CONFIG_DIR ? [`${tomlString(resolve(source.GH_CONFIG_DIR))}="write"`] : []), `":workspace_roots"={"."="write"}`, @@ -301,7 +305,7 @@ export function createIsolatedCodexAppServerArgs( ...deniedHostRoots.map((path) => `${tomlString(path)}="none"`), ...readOnlyRoots.map((path) => `${tomlString(resolve(path))}="read"`), ...[...gitRoots.read, ...gitRoots.write].map((path) => `${tomlString(path)}="read"`), - ...(instructionWorkingCopyRoot ? [`${tomlString(resolve(instructionWorkingCopyRoot))}="read"`] : []), + ...registeredWritableRoots.map((root) => `${tomlString(resolve(root))}="read"`), ...(source.PAPERCLIP_GITHUB_BROKER_TOKEN && source.GH_CONFIG_DIR ? [`${tomlString(resolve(source.GH_CONFIG_DIR))}="write"`] : []), `":workspace_roots"={"."="read"}`, diff --git a/packages/paperclip-runner/src/drivers/runtime-context-materializer.test.ts b/packages/paperclip-runner/src/drivers/runtime-context-materializer.test.ts index 4e68be2944..79fcd583bf 100644 --- a/packages/paperclip-runner/src/drivers/runtime-context-materializer.test.ts +++ b/packages/paperclip-runner/src/drivers/runtime-context-materializer.test.ts @@ -527,3 +527,16 @@ it("exposes a trusted persistent personal directory without instruction files", expect(supplied).not.toContain('AGENT_HOME="/untrusted"'); expect(createRunnerdCodexAppServerArgs(options).join("\n")).not.toContain("AGENT_HOME"); }); + +it("grants separate registered personal and instruction directories to Codex shells", () => { + const args = createRunnerdCodexAppServerArgs({ + environment: { AGENT_HOME: "/untrusted" }, codexHome: "/private-provider", + persistentAgentHome: "/personal/agent", instructionWorkingCopyRoot: "/instructions/run", + }).join("\n"); + expect(args).toContain('AGENT_HOME="/personal/agent"'); + for (const root of ["/personal/agent", "/instructions/run"]) { + expect(args).toContain(`"${root}"="write"`); + expect(args).toContain(`"${root}"="read"`); + } + expect(args).not.toContain("/untrusted"); +}); diff --git a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts index c127a1955d..a9a8221352 100644 --- a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts +++ b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts @@ -3493,7 +3493,8 @@ export function createRunnerdCodexAppServerArgs(input: { ...(input.persistentAgentHome ? { AGENT_HOME: input.persistentAgentHome } : {}), }, [...(input.readOnlyRoots ?? []), ...codexExecutableReadOnlyRoots(input.environment ?? {}, input.codexCommand)], - input.instructionWorkingCopyRoot ?? input.persistentAgentHome, + input.instructionWorkingCopyRoot, + input.persistentAgentHome, ); } diff --git a/server/src/__tests__/adapter-registry.test.ts b/server/src/__tests__/adapter-registry.test.ts index 05ff46ada7..43c4ede5ac 100644 --- a/server/src/__tests__/adapter-registry.test.ts +++ b/server/src/__tests__/adapter-registry.test.ts @@ -332,7 +332,7 @@ describe("server adapter registry", () => { expect(config).not.toHaveProperty("engine"); }); - it("verifies the Claude harness on a computer after checking its platform", async () => { + it.each([true, false])("retains the unverified ACPX warning after a computer Claude CLI probe (succeeds: %s)", async (succeeds) => { const calls: string[] = []; const result = await requireServerAdapter("paperclip_runner").testEnvironment({ companyId: "company-1", adapterType: "paperclip_runner", @@ -342,13 +342,15 @@ describe("server adapter registry", () => { calls.push(input.command); const stdout = input.command === "claude" ? JSON.stringify({ type: "result", result: "hello", usage: { input_tokens: 1, output_tokens: 1 } }) : input.args?.some(arg => arg.includes("uname -s")) ? "Linux\nx86_64\n" : ""; - return { exitCode: 0, timedOut: false, stdout, stderr: "", signal: null, pid: null, startedAt: new Date().toISOString() }; + return { exitCode: input.command === "claude" && !succeeds ? 1 : 0, timedOut: false, stdout, stderr: "", signal: null, pid: null, startedAt: new Date().toISOString() }; } }, } as never, }); expect(calls).toContain("claude"); - expect(result.checks).toContainEqual(expect.objectContaining({ code: "claude_hello_probe_passed", level: "info" })); - expect(result.checks.some(check => check.level === "error")).toBe(false); + expect(result.status).toBe(succeeds ? "warn" : "fail"); + expect(result.checks).toContainEqual(expect.objectContaining({ code: "acpx_remote_runtime_unverified", level: "warn" })); + if (succeeds) expect(result.checks).toContainEqual(expect.objectContaining({ code: "claude_hello_probe_passed", level: "info" })); + expect(result.checks.some(check => check.level === "error")).toBe(!succeeds); }); it.each([true, false])("checks actual local ACPX installation readiness (%s)", async (ready) => { diff --git a/server/src/adapters/registry.ts b/server/src/adapters/registry.ts index f35d9b4b23..c9c13c144f 100644 --- a/server/src/adapters/registry.ts +++ b/server/src/adapters/registry.ts @@ -432,16 +432,19 @@ const paperclipRunnerAdapter: ServerAdapterModule = { if (!((os === "Linux" && arch === "x86_64") || (os === "Darwin" && (arch === "arm64" || (profile.acpxAgent !== "grok" && arch === "x86_64"))))) { throw new Error(`ACPX ${profile.acpxAgent} requires a qualified Linux x64 or macOS architecture.`); } + const runtimeUnverified = { code: "acpx_remote_runtime_unverified", level: "warn" as const, + message: "The remote platform is supported. Runtime package integrity and readiness must still be verified by the remote runner before launch." }; if (target.transport === "computer" && profile.acpxAgent === "claude") { const hello = await claudeTestEnvironment({ ...context, adapterType: "claude_local", config: { ...context.config, engine: "cli", model: profile.model, dangerouslySkipPermissions: context.config.acpxPermissionMode === "approve-all" } }); - return { ...hello, adapterType: "paperclip_runner" }; + return { ...hello, adapterType: "paperclip_runner", + status: hello.status === "fail" ? "fail" as const : "warn" as const, + checks: [...hello.checks, runtimeUnverified] }; } return { adapterType: "paperclip_runner", status: "warn" as const, testedAt: new Date().toISOString(), - checks: [{ code: "acpx_remote_runtime_unverified", level: "warn" as const, - message: "The remote platform is supported. Runtime package integrity and readiness must still be verified by the remote runner before launch." }], + checks: [runtimeUnverified], }; } const { probeAcpxClaudeInstallation, probeAcpxGrokInstallation, probeAcpxCursorInstallation, probeAcpxPiInstallation } = await import("../vendor/paperclip-runner/live/index.js");