From 96db0624874f484be1fd8c38b15c8e46cfa9edef Mon Sep 17 00:00:00 2001 From: Dotta Date: Sun, 11 Oct 2026 07:49:03 -0500 Subject: [PATCH 1/2] Fix runtime prerequisite home, workspace, and integrity boundaries Co-Authored-By: Paperclip --- .../claude-local/src/server/execute.remote.test.ts | 10 +++++++--- .../adapters/claude-local/src/server/execute.ts | 2 +- .../src/drivers/acpx/installation-integrity.test.ts | 12 ++++++++++-- .../src/drivers/acpx/installation-integrity.ts | 11 ++++++++++- .../src/drivers/codex/codex-security-config.ts | 10 +++++++--- .../drivers/runtime-context-materializer.test.ts | 13 +++++++++++++ .../src/live/runnerd-codex-transport.ts | 3 ++- server/src/__tests__/adapter-registry.test.ts | 10 ++++++---- server/src/adapters/registry.ts | 9 ++++++--- 9 files changed, 62 insertions(+), 18 deletions(-) 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 58118a838e6..3a80ff225ff 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 40a7318b094..68c94789b5a 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 b51da6efe5b..b1a3a2f2285 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 d02e0cfb4b0..aa16e4ace3d 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 4e68be2944b..79fcd583bff 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 c127a1955db..a9a82213524 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 05ff46ada71..43c4ede5acf 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 f35d9b4b233..c9c13c144f2 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"); From ab1e8acc681154af651926e495e7a01b4d7715be Mon Sep 17 00:00:00 2001 From: Dotta Date: Sun, 11 Oct 2026 07:59:23 -0500 Subject: [PATCH 2/2] Carry explicit existing-branch intent in workspace realization Keep requested branch attachment separate from ownership established by realization, and round-trip the optional intent through the shared request contract. Co-Authored-By: Paperclip --- packages/shared/src/types/workspace-runtime.ts | 2 ++ server/src/__tests__/workspace-runtime.test.ts | 13 +++++++++++++ server/src/services/workspace-realization.ts | 2 ++ server/src/services/workspace-runtime.ts | 2 ++ 4 files changed, 19 insertions(+) diff --git a/packages/shared/src/types/workspace-runtime.ts b/packages/shared/src/types/workspace-runtime.ts index 25a8dbf6549..479260c30c3 100644 --- a/packages/shared/src/types/workspace-runtime.ts +++ b/packages/shared/src/types/workspace-runtime.ts @@ -351,6 +351,8 @@ export interface WorkspaceRealizationRequest { repoRef: string | null; strategy: "project_primary" | "git_worktree"; branchName: string | null; + /** Attach this exact pre-existing branch without creating or resetting it. */ + existingBranch?: string; worktreePath: string | null; }; /** diff --git a/server/src/__tests__/workspace-runtime.test.ts b/server/src/__tests__/workspace-runtime.test.ts index 021523b997b..7290a474b55 100644 --- a/server/src/__tests__/workspace-runtime.test.ts +++ b/server/src/__tests__/workspace-runtime.test.ts @@ -9444,6 +9444,19 @@ describe("workspace realization request additionalSources", () => { }; } + it.each([undefined, "operator/retained"])("round-trips exact-branch intent independently of branch ownership (%s)", existingBranch => { + const workspace = buildRealizedWorkspace({ strategy: "git_worktree", branchName: existingBranch ?? "new-task-branch", + existingBranch, branchCreatedByRuntime: false }); + const request = buildWorkspaceRealizationRequest({ adapterType: "codex", companyId: "company-1", + environmentId: "environment-1", executionWorkspaceId: null, issueId: "issue-1", heartbeatRunId: "run-1", + requestedMode: "isolated_workspace", workspace, workspaceConfig: null }); + const parsed = readWorkspaceRealizationRequest(JSON.parse(JSON.stringify(request))); + expect(parsed?.source.existingBranch).toBe(existingBranch); + expect(parsed?.source.branchName).toBe(workspace.branchName); + expect(workspace.branchCreatedByRuntime).toBe(false); + if (!existingBranch) expect(request.source).not.toHaveProperty("existingBranch"); + }); + it("round-trips additionalSources and runtime provisioning through build/read realization request", () => { const workspace = buildRealizedWorkspace({ additionalWorkspaces: [ diff --git a/server/src/services/workspace-realization.ts b/server/src/services/workspace-realization.ts index f69e393174e..3569611dc62 100644 --- a/server/src/services/workspace-realization.ts +++ b/server/src/services/workspace-realization.ts @@ -93,6 +93,7 @@ export function readWorkspaceRealizationRequest(value: unknown): WorkspaceRealiz repoRef: readString(source.repoRef), strategy: source.strategy === "git_worktree" ? "git_worktree" : "project_primary", branchName: readString(source.branchName), + ...(readString(source.existingBranch) ? { existingBranch: readString(source.existingBranch)! } : {}), worktreePath: readString(source.worktreePath), }, additionalSources: readAdditionalSources(parsed.additionalSources), @@ -137,6 +138,7 @@ export function buildWorkspaceRealizationRequest(input: { repoRef: input.workspace.repoRef, strategy: input.workspace.strategy, branchName: input.workspace.branchName, + ...(input.workspace.existingBranch ? { existingBranch: input.workspace.existingBranch } : {}), worktreePath: input.workspace.worktreePath, }, // The additional (referenced) sources carry the read-only referenced-project workspaces. Run diff --git a/server/src/services/workspace-runtime.ts b/server/src/services/workspace-runtime.ts index 5c161536697..64989dfa452 100644 --- a/server/src/services/workspace-runtime.ts +++ b/server/src/services/workspace-runtime.ts @@ -170,6 +170,8 @@ export interface RealizedExecutionWorkspace extends ExecutionWorkspaceInput { strategy: "project_primary" | "git_worktree"; cwd: string; branchName: string | null; + /** Remote realization intent; distinct from ownership established after creation. */ + existingBranch?: string; worktreePath: string | null; warnings: string[]; created: boolean;