diff --git a/packages/adapter-utils/src/execution-target-kind.ts b/packages/adapter-utils/src/execution-target-kind.ts index a95c76c50cd..bf4f809363e 100644 --- a/packages/adapter-utils/src/execution-target-kind.ts +++ b/packages/adapter-utils/src/execution-target-kind.ts @@ -6,4 +6,3 @@ export function adapterExecutionTargetIsCommandBacked( ): target is AdapterCommandBackedExecutionTarget { return target?.kind === "remote" && (target.transport === "sandbox" || target.transport === "computer"); } - diff --git a/packages/adapter-utils/src/execution-target.test.ts b/packages/adapter-utils/src/execution-target.test.ts index 46a4df7d6d5..88919a99f37 100644 --- a/packages/adapter-utils/src/execution-target.test.ts +++ b/packages/adapter-utils/src/execution-target.test.ts @@ -5,6 +5,9 @@ import * as ssh from "./ssh.js"; import * as serverUtils from "./server-utils.js"; import { cleanupGitHubOperationLaunchers, + adapterExecutionTargetRuntimeRoot, + prepareAdapterExecutionTargetRuntime, + type AdapterComputerExecutionTarget, prepareGitHubOperationLaunchers, adapterExecutionTargetUsesManagedHome, ensureAdapterExecutionTargetRuntimeCommandInstalled, @@ -437,3 +440,36 @@ describe("GitHub launcher lifecycle", () => { expect(runner.execute).toHaveBeenCalledTimes(1); }); }); + + +describe("computer legacy runtime ownership", () => { + it.each(["claude", "codex"])("isolates %s assets and callback roots for owners sharing a checkout", async adapterKey => { + const execute = vi.fn(async () => ({ exitCode: 0, signal: null, timedOut: false, stdout: "", stderr: "", pid: 123, startedAt: "2026-10-10T00:00:00Z" })); + const target = (ownerId: string, generation = 1) => ({ + kind: "remote", transport: "computer", providerKey: "boat", environmentId: "environment", + leaseId: ownerId, remoteCwd: "/shared/project", listenerPort: 43127, + runner: { execute }, processRunner: { execute }, + resourceAuthority: { kind: "computer-owner", computerId: "computer", ownerId, generation }, + fileAuthority: { kind: "remote-persistent", placementId: "shared", root: "/shared/project", agentHome: `/homes/${ownerId}` }, + launch: vi.fn(), inspectProcess: vi.fn(), retainWarm: vi.fn(), retire: vi.fn(), + computerTool: { command: "cua-driver", args: ["mcp"] }, + } satisfies AdapterComputerExecutionTarget); + const prepare = (owner: AdapterComputerExecutionTarget) => prepareAdapterExecutionTargetRuntime({ + target: owner, runId: owner.leaseId!, adapterKey, workspaceLocalDir: "/unused-local-workspace", syncWorkspace: false, + }); + const first = await prepare(target("agent-one")); + const second = await prepare(target("agent-two")); + expect(first.workspaceRemoteDir).toBe("/shared/project"); + expect(second.workspaceRemoteDir).toBe(first.workspaceRemoteDir); + expect(first.runtimeRootDir).toBe(adapterExecutionTargetRuntimeRoot(target("agent-one"), adapterKey)); + expect(second.runtimeRootDir).not.toBe(first.runtimeRootDir); + expect(adapterExecutionTargetRuntimeRoot(target("agent-one", 2), adapterKey)).not.toBe(first.runtimeRootDir); + expect((await prepare(target("agent-one"))).runtimeRootDir).toBe(first.runtimeRootDir); + expect(first.runtimeRootDir).toMatch(new RegExp(`^/shared/project/\\.paperclip-runtime/${adapterKey}/owners/[a-f0-9]{64}$`)); + }); + + it("preserves the existing sandbox runtime root", () => { + const target = { kind: "remote", transport: "sandbox", remoteCwd: "/workspace" } as const; + expect(adapterExecutionTargetRuntimeRoot(target, "claude")).toBe("/workspace/.paperclip-runtime/claude"); + }); +}); diff --git a/packages/adapter-utils/src/runner-connectivity.test.ts b/packages/adapter-utils/src/runner-connectivity.test.ts index 68a8b6d36de..e345a7ccc82 100644 --- a/packages/adapter-utils/src/runner-connectivity.test.ts +++ b/packages/adapter-utils/src/runner-connectivity.test.ts @@ -210,3 +210,29 @@ describe("paperclip runner transport routing", () => { ).toThrow(); }); }); + +it("uses each computer owner's allocated port without changing the sandbox default", async () => { + const { adapterExecutionTargetIsCommandBacked } = await import("./execution-target.js"); + const endpoint = ingress(); + const getRunnerIngressEndpoint = vi.fn(async () => endpoint); + const base = { + kind: "remote" as const, transport: "computer" as const, providerKey: "boat", remoteCwd: "/home/user/project", + leaseId: "attempt-lease", listenerPort: 45101, effectiveCapabilities: capabilities, getRunnerIngressEndpoint, + processRunner: { execute: async () => ({ exitCode: 0, signal: null, timedOut: false, stdout: "", stderr: "", pid: null, startedAt: new Date().toISOString() }) }, + resourceAuthority: { kind: "computer-owner" as const, computerId: "machine", ownerId: "owner-a", generation: 2 }, + fileAuthority: { kind: "remote-persistent" as const, placementId: "placement", root: "/home/user/project", agentHome: "/home/user/agent-a" }, + launch: async () => ({}), inspectProcess: async () => ({ running: true, claim: {} }), + retainWarm: async () => {}, retire: async () => true, computerTool: { command: "cua-driver", args: ["mcp"] }, + }; + expect(adapterExecutionTargetIsCommandBacked(base)).toBe(true); + for (const port of [45101, 45102]) { + const resolved = await resolvePaperclipRunnerTransport({ target: { ...base, listenerPort: port }, runId: "run-1", + localConnectUrl: "ws://localhost/unused", runnerIngressAuthorized: true }); + expect(resolved).toMatchObject({ mode: "provider_ingress", listenPort: port }); + expect(getRunnerIngressEndpoint).toHaveBeenLastCalledWith({ leaseId: "attempt-lease", port, path: "/api/runner/v1/connect/run-1" }); + } + const { listenerPort, processRunner, resourceAuthority, fileAuthority, launch, inspectProcess, retainWarm, retire, computerTool, ...common } = base; + const resolved = await resolvePaperclipRunnerTransport({ target: { ...common, transport: "sandbox" }, runId: "sandbox-run", + localConnectUrl: "ws://localhost/unused", runnerIngressAuthorized: true }); + expect(resolved).toMatchObject({ mode: "provider_ingress", listenPort: 43127 }); +}); diff --git a/packages/adapter-utils/src/ssh-fixture.test.ts b/packages/adapter-utils/src/ssh-fixture.test.ts index 39d3c52515e..bc2d6abc92b 100644 --- a/packages/adapter-utils/src/ssh-fixture.test.ts +++ b/packages/adapter-utils/src/ssh-fixture.test.ts @@ -6,6 +6,7 @@ import path from "node:path"; import { afterAll, afterEach, describe, expect, it } from "vitest"; import { buildSshSpawnTarget, + createSshCommandManagedRuntimeRunner, buildSshEnvLabFixtureConfig, getSshEnvLabSupport, prepareWorkspaceForSshExecution, @@ -181,6 +182,28 @@ describe("ssh env-lab fixture", () => { // this drains it too instead of stranding a listener until the process exits. afterAll(drainFixtureTeardowns); + it.each([0, 7])("handles a closed SSH input pipe without losing exit diagnostics (exit %i)", async (exitCode) => { + const rootDir = await createFixtureRootDir(); + const previousPath = process.env.PATH; + await writeFile(path.join(rootDir, "ssh"), `#!/bin/sh +exec 0<&- +printf 'remote rejected upload' >&2 +sleep 0.05 +exit ${exitCode} +`, { mode: 0o700 }); + process.env.PATH = `${rootDir}${path.delimiter}${previousPath ?? ""}`; + try { + const runner = createSshCommandManagedRuntimeRunner({ spec: UNREACHABLE_SSH_SPEC }); + const result = await runner.execute({ command: "cat", stdin: "x".repeat(8 * 1024 * 1024), timeoutMs: 5000 }); + expect(result.exitCode).toBe(exitCode === 0 ? null : exitCode); + expect(result.stderr).toBe("remote rejected upload"); + expect(result.timedOut).toBe(false); + } finally { + if (previousPath === undefined) delete process.env.PATH; + else process.env.PATH = previousPath; + } + }); + it("starts an isolated sshd fixture and executes commands through it", async () => { const rootDir = await createFixtureRootDir(); const statePath = path.join(rootDir, "state.json"); 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 cbb6ed55013..58118a838e6 100644 --- a/packages/adapters/claude-local/src/server/execute.remote.test.ts +++ b/packages/adapters/claude-local/src/server/execute.remote.test.ts @@ -231,6 +231,31 @@ describe("claude remote execution", () => { })); }); + it("keeps an authoritative remote workspace in place while delivering managed Claude assets", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-claude-in-place-")); + cleanupDirs.push(rootDir); + await execute({ + runId: "run-in-place", + agent: { id: "agent-1", companyId: "company-1", name: "Claude", adapterType: "claude_local", adapterConfig: {} }, + runtime: { sessionId: null, sessionParams: null, sessionDisplayId: null, taskKey: null }, + config: { engine: "cli", command: "claude", cwd: "/stale-host-cwd" }, + context: { paperclipWorkspace: { cwd: "/remote-only-agent-home", source: "agent_home" } }, + executionTarget: { + kind: "remote", transport: "ssh", remoteCwd: "/old-copied-workspace", + workspaceRealization: { mode: "in_place", authoritativeRoot: "/persistent-project", pathAliases: [], outboundRestorePaths: [] }, + spec: { host: "127.0.0.1", port: 2222, username: "fixture", remoteWorkspacePath: "/persistent-project", remoteCwd: "/persistent-project", + privateKey: "PRIVATE KEY", knownHosts: "[127.0.0.1]:2222 ssh-ed25519 AAAA", strictHostKeyChecking: true }, + }, + onLog: async () => {}, + }); + expect(prepareWorkspaceForSshExecution).not.toHaveBeenCalled(); + expect(restoreWorkspaceFromSshExecution).not.toHaveBeenCalled(); + expect(syncDirectoryToSsh).toHaveBeenCalledTimes(2); + expect(syncDirectoryToSsh).toHaveBeenCalledWith(expect.objectContaining({ remoteDir: "/persistent-project/.paperclip-runtime/claude/skills" })); + const call = runChildProcess.mock.calls[0] as unknown as [string, string, string[], { remoteExecution: { remoteCwd: string } }]; + expect(call[3].remoteExecution.remoteCwd).toBe("/persistent-project"); + }); + it("does not resume saved Claude sessions for remote SSH execution without a matching remote identity", async () => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-claude-remote-resume-")); cleanupDirs.push(rootDir); diff --git a/packages/paperclip-runner/src/contracts/runtime-context.test.ts b/packages/paperclip-runner/src/contracts/runtime-context.test.ts index 09a931317cf..cc5a0332541 100644 --- a/packages/paperclip-runner/src/contracts/runtime-context.test.ts +++ b/packages/paperclip-runner/src/contracts/runtime-context.test.ts @@ -37,6 +37,13 @@ function savedContext(revision: string, text: string) { } describe("persisted native execution prompts", () => { + it("preserves an authenticated persistent home independently of instruction copies", () => { + const context = { ...savedContext("saved", "Instructions"), persistentAgentHome: { rootPath: "/computer/agent" } }; + expect(parseNativeRuntimeContext(context).persistentAgentHome).toEqual({ rootPath: "/computer/agent" }); + expect(parseNativeRuntimeContext(context).instructions.workingCopy).toBeUndefined(); + expect(() => parseNativeRuntimeContext({ ...context, persistentAgentHome: { rootPath: "" } })).toThrow(); + expect(() => parseNativeRuntimeContext({ ...context, persistentAgentHome: { rootPath: "/computer/agent", untrusted: true } })).toThrow(); + }); it.each([ ["snapshot-before-upgrade", "Saved instructions from before the upgrade."], ["future-prompt-fixture", " Saved instructions absent from this release.\n"], diff --git a/packages/paperclip-runner/src/drivers/acpx/agent-files-binding.test.ts b/packages/paperclip-runner/src/drivers/acpx/agent-files-binding.test.ts index 27437e8fde9..846e4df593b 100644 --- a/packages/paperclip-runner/src/drivers/acpx/agent-files-binding.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/agent-files-binding.test.ts @@ -18,6 +18,19 @@ function fixture() { } describe("registered ACP agent files", () => { + it("binds a persistent computer home without inventing an instruction copy", () => { + const { root, parent, context } = fixture(); + delete context.instructions.workingCopy; + context.persistentAgentHome = { rootPath: root }; + const nestedRuntime = join(root, ".paperclip-runtime"); mkdirSync(nestedRuntime); + const binding = bindAcpxAgentFiles(context, [nestedRuntime])!; + expect(binding.root.endsWith("registered-run-copy")).toBe(true); + for (const protectedRoot of [parent, root]) { + expect(() => bindAcpxAgentFiles(context, [protectedRoot])).toThrow("overlaps"); + } + renameSync(root, `${root}-old`); mkdirSync(root); + expect(() => binding.assertHeld()).toThrow("changed"); + }); it("does not grant absent or legacy instruction copies", () => { expect(bindAcpxAgentFiles(null, [])).toBeNull(); const { context } = fixture(); diff --git a/packages/paperclip-runner/src/drivers/acpx/runtime-host.test.ts b/packages/paperclip-runner/src/drivers/acpx/runtime-host.test.ts index e3f1d9512d5..9af98b14c11 100644 --- a/packages/paperclip-runner/src/drivers/acpx/runtime-host.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/runtime-host.test.ts @@ -949,6 +949,34 @@ describe("ACPX runtime host", () => { expect(fixture.commandClose).toHaveBeenCalledOnce(); }); + it("gives Claude shells only the registered persistent home while keeping provider HOME isolated", async () => { + const fixture = await hostFixture(); + const home = await realpath(await mkdtemp(join(tmpdir(), "paperclip-computer-agent-home-"))); + temporaryDirectories.push(home); + const context: NativeRuntimeContextSnapshot = { + prompt: { revision: PAPERCLIP_EXECUTION_PROMPT_REVISION, text: PAPERCLIP_EXECUTION_PROMPT, digest: nativeRuntimePromptDigest() }, + instructions: { entryPath: "AGENTS.md", bundle: { schema: NATIVE_RUNTIME_ASSET_SCHEMA, digest: "0".repeat(64), manifestDigest: "0".repeat(64), rootPath: home, fileCount: 0, totalBytes: 0 } }, + persistentAgentHome: { rootPath: home }, skills: [], + mcp: { assignmentSetId: "none", digest: "0".repeat(64), bindingId: null }, aggregateDigest: "", + }; + context.aggregateDigest = canonicalNativeRuntimeContextDigest(context); + let observed: { home: string; agentHome: string } | undefined; + const host = await AcpxRuntimeHost.open({ ...fixture.options, agent: "claude", model: "claude-sonnet-5", + environment: { AGENT_HOME: "/ambient/untrusted" }, runtimeContext: context, + }, fixture.dependencies({ openRuntime: async launch => { + observed = JSON.parse(execFileSync(process.execPath, ["-e", 'console.log(JSON.stringify({home:process.env.HOME,agentHome:process.env.AGENT_HOME}))'], { + env: launch.launchEnvironment, encoding: "utf8", timeout: 5_000, + })); + return runtimePort({ getStatus: async () => ({ models: { currentModelId: "claude-sonnet-5" } }) }); + } })); + try { + expect(observed?.agentHome).toBe(home); + expect(observed?.home).not.toBe(home); + expect(observed?.home).toContain("home"); + expect(context.instructions.workingCopy).toBeUndefined(); + } finally { await host.close({ reason: "persistent home verified" }); } + }); + it.each(["cursor", "copilot", "pi"] as const)("binds %s agent files from each registered run copy, never ambient roots", async (agent) => { const fixture = await hostFixture(); const copies = await mkdtemp(join(tmpdir(), "paperclip-agent-copies-")); 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 062482ecad5..4e68be2944b 100644 --- a/packages/paperclip-runner/src/drivers/runtime-context-materializer.test.ts +++ b/packages/paperclip-runner/src/drivers/runtime-context-materializer.test.ts @@ -1,3 +1,4 @@ +import { createRunnerdCodexAppServerArgs } from "../live/runnerd-codex-transport.js"; import { chmod, lstat, @@ -27,6 +28,7 @@ import { nativeMcpLaunchBinding } from "./native-mcp.js"; import { materializeNativeRuntimeSkills, prepareIsolatedCodexHome, + releaseMaterializedNativeRuntimeSkills, } from "./runtime-context-materializer.js"; const roots: string[] = []; @@ -93,6 +95,19 @@ function context( } describe("runtime context materialization", () => { + it("installs only the trusted environment computer binding and removes it on refresh without that capability", async () => { + const root = await mkdtemp(join(tmpdir(), "paperclip-computer-context-")); + roots.push(root); + const codexHome = join(root, "codex-home"); + await prepareIsolatedCodexHome({ context: null, codexHome, + computerTool: { command: "/opt/ascii/cua-driver/cua-driver", args: ["mcp", "--socket", "/run/ascii-cua/driver.sock"] } }); + const config = await readFile(join(codexHome, "config.toml"), "utf8"); + expect(config).toContain("[mcp_servers.paperclip_computer]"); + expect(config).toContain('command = "/opt/ascii/cua-driver/cua-driver"'); + await releaseMaterializedNativeRuntimeSkills(join(codexHome, "skills")); + await prepareIsolatedCodexHome({ context: null, codexHome }); + expect(await readFile(join(codexHome, "config.toml"), "utf8")).not.toContain("paperclip_computer"); + }); it("restores the legacy working-copy contract without changing its digest or adding new fields", () => { const old = context("/skills", "/instructions"); old.instructions.workingCopy = { rootPath: "/old-run/copy", entryPath: "AGENTS.md" }; @@ -503,3 +518,12 @@ command = "untrusted-command" await expect(stat(join(symlinkCodexHome, "auth.json"))).rejects.toThrow(); }); }); + +it("exposes a trusted persistent personal directory without instruction files", () => { + const options = { environment: { AGENT_HOME: "/untrusted", HOME: "/ambient" }, codexHome: "/private-provider" }; + const supplied = createRunnerdCodexAppServerArgs({ ...options, persistentAgentHome: "/personal/agent" }).join("\n"); + expect(supplied).toContain('AGENT_HOME="/personal/agent"'); + expect(supplied).toContain('"/private-provider"="none"'); + expect(supplied).not.toContain('AGENT_HOME="/untrusted"'); + expect(createRunnerdCodexAppServerArgs(options).join("\n")).not.toContain("AGENT_HOME"); +}); diff --git a/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts b/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts index c0615de84ca..243a5c3c781 100644 --- a/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts +++ b/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts @@ -643,6 +643,46 @@ it("retargets only composed instruction framing and retains legacy custom instru }); +it.each([false, true])("preserves ACPX asset framing across cold, warm, and post-idle attachment (guidance=%s)", (guidance) => { + const source = assignedRuntimeContext("/controller/skills", "/controller/bundle"); + const cold = assignedRuntimeContext("/boat/skills", "/boat/context/cold"); + const resumed = assignedRuntimeContext("/boat/skills", "/boat/context/resumed"); + const custom = "Managed instructions with literal /controller/bundle example."; + const original = composeNativeSystemInstructions(source, custom); + const coldText = runnerdRecoveryInternals.retargetComposedInstructions( + runnerdRecoveryInternals.withComputerProcessInstructions(original, guidance, source), + source, + cold, + ); + const suffix = (context: NativeRuntimeContextSnapshot) => + composeNativeSystemInstructions(context, "").slice(context.prompt.text.length); + expect(coldText.endsWith(suffix(cold))).toBe(true); + const state = { runAttachTemplate: { provider: { + kind: "acpx", agent: "claude", runId: "cold-run", runtimeContext: cold, instructions: coldText, + } } }; + const desired = { + runnerInstanceId: "runner", environmentLeaseId: "workspace", runId: "warm-run", + normalizedSessionId: "same-session", turnId: "turn", itemId: "item", + }; + const warm = runnerdRecoveryInternals.rotatedRunAttachPayload(state, desired, null, undefined); + expect((warm.provider as { instructions: string }).instructions).toBe(coldText); + const recover = (text: string) => runnerdRecoveryInternals.rotatedRunAttachPayload( + { runAttachTemplate: warm }, { ...desired, runId: "post-idle-run" }, null, undefined, resumed, + { text, context: source }, + ).provider as { instructions: string; runtimeContext: NativeRuntimeContextSnapshot }; + const recovered = recover(composeNativeSystemInstructions(source, custom)); + expect(recovered.runtimeContext).toEqual(resumed); + expect(recovered.instructions.endsWith(suffix(resumed))).toBe(true); + // This is the immutable-prefix equality required by Claude's guest guard. + expect(recovered.instructions.slice(0, -suffix(resumed).length)) + .toBe(coldText.slice(0, -suffix(cold).length)); + expect(recovered.instructions).toContain(custom); + expect(recovered.instructions).not.toContain("Read-only instruction sibling root: /controller/"); + // Never silently substitute old managed instructions for current grants. + expect(recover(composeNativeSystemInstructions(source, "Changed managed instructions")).instructions) + .toContain("Changed managed instructions"); +}); + it("replays the durable run attachment outcome and latest provider identity", () => { expect( runnerdRecoveryInternals.recoveredRunAttachment({ @@ -2158,6 +2198,32 @@ it.each(["installed", "explicit"] as const)("records the selected %s Codex comma } }); +it.each([false, true])("binds detached-process guidance only for a trusted persistent computer (%s)", async (computer) => { + const root = await mkdtemp(join(tmpdir(), "paperclip-computer-process-guidance-")); + const runnerBinary = join(root, "runnerd"); + await writeFile(runnerBinary, "unexecuted fixture artifact", { mode: 0o700 }); + let instructions = ""; + const { transport } = createCapabilityRunnerdCodexTransport({ + provider: "codex", runnerBinary, stateDirectory: root, sourceCodexHome: "", + codexCommand: "/qualified/codex", persistentAgentHome: computer ? "/agent/home" : undefined, + controlPlaneRegistration: async (authority) => { + instructions = String((authority.store.state.runAttachTemplate!.provider as { instructions: string }).instructions); + throw new Error("fixture_stop_before_runner_launch"); + }, + runnerProcessLauncher: () => { throw new Error("must not launch"); }, + }); + try { + await expect(transport.request("thread/start", { cwd: root, model: "gpt-6.1-sol", dynamicTools: [], + developerInstructions: "Preserve the task instructions." })).rejects.toThrow("fixture_stop_before_runner_launch"); + expect(instructions).toContain("Preserve the task instructions."); + expect(instructions.includes("nohup setsid")).toBe(computer); + if (computer) { + expect(instructions).toContain("stop when its warm timeout expires"); + expect(instructions).toContain("Do not create services or change runner ownership"); + } + } finally { await transport.close(); await rm(root, { recursive: true, force: true }); } +}); + it("rejects remote Codex without a guest executable before resolving controller dependencies", async () => { const root = await mkdtemp(join(tmpdir(), "paperclip-runner-remote-codex-command-")); const runnerBinary = join(root, "runnerd"); diff --git a/packages/paperclip-runner/src/native-session-runtime.test.ts b/packages/paperclip-runner/src/native-session-runtime.test.ts index 610813ef9c6..83ecfe439df 100644 --- a/packages/paperclip-runner/src/native-session-runtime.test.ts +++ b/packages/paperclip-runner/src/native-session-runtime.test.ts @@ -326,7 +326,7 @@ describe("executeNativeSession recovery", () => { const completeRun = vi.fn(async () => {}); try { const execution = executeNativeSession({ - input, turnTimeoutMs, + input, turnTimeoutMs, bootstrapTimeoutMs: 5, backend: { async descriptor() { return { kind: "mock", name: "long-lived", version: "1", capabilities }; }, async openSession() { return session; }, @@ -2256,15 +2256,18 @@ describe("executeNativeSession recovery", () => { runnerInstanceId: "runner-recovery", controlPlaneInstanceId: "control-recovery", timeoutMs: 5, + bootstrapTimeoutMs: 10, }); const execution = execute(); const rejection = expect(execution).rejects.toThrow( - "native session bootstrap timed out after 5ms", + "native session bootstrap timed out after 10ms", ); await bootstrapStarted; expect(bootstrapSignal?.aborted).toBe(false); await vi.advanceTimersByTimeAsync(5); + expect(bootstrapSignal?.aborted).toBe(false); + await vi.advanceTimersByTimeAsync(5); await rejection; expect(bootstrapSignal?.aborted).toBe(true); expect(openRun).not.toHaveBeenCalled(); @@ -2659,6 +2662,7 @@ describe("executeNativeSession recovery", () => { runnerInstanceId: "runner-recovery", controlPlaneInstanceId: "control-recovery", timeoutMs: 10, + bootstrapTimeoutMs: 100, keepSessionOpen: true, onSession: (current) => retainedSessions.push(current), }); @@ -5513,6 +5517,7 @@ describe("executeNativeSession recovery", () => { controlPlaneInstanceId: "control-recovery", persistedSession: structuredClone(checkpoint), timeoutMs: 5, + bootstrapTimeoutMs: 100, onSession, }); const rejection = expect(execution).rejects.toThrow( @@ -5648,15 +5653,18 @@ describe("executeNativeSession recovery", () => { controlPlaneInstanceId: "control-recovery", persistedSession: structuredClone(checkpoint), timeoutMs: 5, + bootstrapTimeoutMs: 10, onSession, }); const rejection = expect(execution).rejects.toThrow( - "native session replacement bootstrap timed out after 5ms", + "native session replacement bootstrap timed out after 10ms", ); await replacementStarted; expect(replacementSignal?.aborted).toBe(false); await vi.advanceTimersByTimeAsync(5); + expect(replacementSignal?.aborted).toBe(false); + await vi.advanceTimersByTimeAsync(5); await rejection; expect(replacementSignal?.aborted).toBe(true); expect(openRun).not.toHaveBeenCalled(); diff --git a/server/src/__tests__/claude-local-execute.test.ts b/server/src/__tests__/claude-local-execute.test.ts index adea792ca6e..3423cd406c5 100644 --- a/server/src/__tests__/claude-local-execute.test.ts +++ b/server/src/__tests__/claude-local-execute.test.ts @@ -983,6 +983,110 @@ describe("claude execute", () => { } }, 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 29a3a2b2255..265a7d3ef3e 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-github-launchers.test.ts b/server/src/services/heartbeat-github-launchers.test.ts index 5bb4038bacb..3d5fd005259 100644 --- a/server/src/services/heartbeat-github-launchers.test.ts +++ b/server/src/services/heartbeat-github-launchers.test.ts @@ -8,10 +8,11 @@ import type { CommandManagedRuntimeRunner } from "@paperclipai/adapter-utils/com import { describe, expect, it, vi } from "vitest"; import { prepareHeartbeatGitHubLaunchers } from "./heartbeat-github-launchers.js"; +const computerTarget = { kind: "remote", transport: "computer", remoteCwd: "/workspace" } as Parameters[0]["target"]; const target = { kind: "remote" as const, transport: "sandbox" as const, providerKey: "daytona", remoteCwd: "/workspace" }; describe("heartbeat GitHub launcher lifetime", () => { - it.each([target, null])("defers native managed authorization/staging to the session owner (%j)", async (target) => { + it.each([target, computerTarget, null])("defers native managed authorization/staging to the session owner (%j)", async (target) => { const prepare = vi.fn(); const mint = vi.fn(); const result = await prepareHeartbeatGitHubLaunchers({ @@ -23,7 +24,7 @@ describe("heartbeat GitHub launcher lifetime", () => { expect(result.cleanupLocation).toBeNull(); expect(result.env).toMatchObject({ GH_TOKEN: "", PAPERCLIP_GITHUB_BROKER_TOKEN: "" }); }); - it("keeps anonymous native sandbox launchers stable without issuing a run capability", async () => { + it.each([target, computerTarget])("keeps anonymous native launchers stable without issuing a run capability (%j)", async (target) => { const createBrokerToken = vi.fn(() => "run-secret"); const prepareLaunchers = vi.fn(async (input) => input.env); const inputs = { native: true, githubConfigured: false, agentId: "agent-a", target, @@ -50,7 +51,7 @@ describe("heartbeat GitHub launcher lifetime", () => { expect(cleanupLaunchers).toHaveBeenCalledExactlyOnceWith({ runId: "failed-run", target }); }); - it("does not remove shared anonymous wrappers when staging a later run fails", async () => { + it.each([target, computerTarget])("does not remove shared anonymous wrappers when staging a later run fails (%j)", async (target) => { const stagingError = new Error("remote launcher staging failed"); const cleanupLaunchers = vi.fn(async () => undefined); await expect(prepareHeartbeatGitHubLaunchers({ @@ -64,6 +65,7 @@ describe("heartbeat GitHub launcher lifetime", () => { it.each([ { native: false, githubConfigured: true, target }, { native: false, githubConfigured: false, target }, + { native: false, githubConfigured: false, target: computerTarget }, { native: true, githubConfigured: false, target: null }, ])("preserves run-scoped managed capabilities outside anonymous native sandboxes: %j", async (mode) => { const createBrokerToken = vi.fn(() => "current-run-secret"); diff --git a/server/src/services/native-runtime/native-github-access.test.ts b/server/src/services/native-runtime/native-github-access.test.ts index e6c1aaea539..65f07102e31 100644 --- a/server/src/services/native-runtime/native-github-access.test.ts +++ b/server/src/services/native-runtime/native-github-access.test.ts @@ -2,6 +2,7 @@ import { afterEach, expect, it, vi } from "vitest"; import { spawn, execFile } from "node:child_process"; import { mkdtemp, mkdir, readFile, rm, writeFile, access } from "node:fs/promises"; import { promisify } from "node:util"; +import type { AdapterExecutionTarget } from "@paperclipai/adapter-utils/execution-target"; import type { CommandManagedRuntimeRunner } from "@paperclipai/adapter-utils/command-managed-runtime"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -13,7 +14,7 @@ const cleanups: (() => Promise)[] = []; afterEach(async () => { for (const cleanup of cleanups.splice(0).reverse()) await cleanup(); }); async function broker(resolveCredentials = vi.fn(async (binding: ReturnType) => ({ status: "available", env: { GH_TOKEN: `fixture-${binding.runId}` }, -})), remote = false, bridgeUnavailable = false, onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise) { +})), remote: boolean | "computer" = false, bridgeUnavailable = false, onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise) { const root = await mkdtemp(path.join(tmpdir(), "native-github-reuse-")); cleanups.push(() => rm(root, { recursive: true, force: true })); const bin = path.join(root, "real-bin"); @@ -29,20 +30,42 @@ async function broker(resolveCredentials = vi.fn(async (binding: ReturnType[0]) => { + if (!processLive) throw new Error("computer_process_claim_stale"); + return execute(input); + }) }; + const target: AdapterExecutionTarget | null = remote === "computer" ? { + kind: "remote", transport: "computer", remoteCwd: root, + runner: { execute: async (input: Parameters[0]) => { + if (generation !== 1) throw new Error("computer_attempt_superseded"); + return execute(input); + } }, processRunner, + listenerPort: 43127, + resourceAuthority: { kind: "computer-owner", computerId: "computer-a", ownerId: "owner-a", generation: 1 }, + fileAuthority: { kind: "remote-persistent", placementId: "placement-a", root, agentHome: root }, + launch: async () => { throw new Error("Fixture runner is already launched"); }, + inspectProcess: async () => ({ running: processLive, claim: { nonce: "fixture-process" } }), + retainWarm: async () => undefined, + retire: async () => { processLive = false; return true; }, + computerTool: { command: "fixture-computer-tool", args: [] }, + } : remote ? { + kind: "remote" as const, transport: "sandbox" as const, providerKey: "test", + remoteCwd: root, runner: { execute }, streamRunLogs: false, + } : null; const result = await createNativeGitHubAccess({ scope, target, cwd: root, env: { PATH: `${bin}:${path.dirname(process.execPath)}:/usr/bin:/bin`, PAPERCLIP_API_KEY: "old-run-api-key" }, resolveCredentials, onLog }, bridgeUnavailable ? async () => { throw new Error("fixture bridge unavailable"); } : undefined); cleanups.push(result.stop); - return { ...result, root, resolveCredentials }; + return { ...result, root, resolveCredentials, processRunner, advanceGeneration: () => { generation++; }, retireProcess: () => { processLive = false; } }; } function request(broker: NativeGitHubAccess, extra: RequestInit = {}, endpoint = "/runtime-tools/github/credentials") { return fetch(broker.env.PAPERCLIP_GITHUB_BROKER_URL + endpoint, { method: "POST", body: "{}", headers: { authorization: `Bearer ${broker.env.PAPERCLIP_GITHUB_BRIDGE_TOKEN}`, "content-type": "application/json" }, ...extra, }); } -it.each([false, true])("keeps one live parent and its original launcher environment across two authorized runs (callback bridge: %s)", async (remote) => { +it.each([false, true, "computer"] as const)("keeps one live parent and its original launcher environment across two authorized runs (callback bridge: %s)", async (remote) => { const b = await broker(undefined, remote); const parent = spawn(process.execPath, ["-e", ` const {execFile}=require('node:child_process'); @@ -61,6 +84,7 @@ it.each([false, true])("keeps one live parent and its original launcher environm expect(a.value).toBe("fixture-run-a"); releaseA(); expect((await operation()).value).toBe("anonymous"); + b.advanceGeneration(); const releaseB = b.activate(run("run-b")); releaseA(); // A's delayed cleanup cannot revoke B. const second = await operation(); @@ -70,6 +94,11 @@ it.each([false, true])("keeps one live parent and its original launcher environm expect(await readFile(path.join(b.env.PAPERCLIP_GITHUB_LAUNCHER_DIR, "gh"), "utf8")).not.toContain(b.env.PAPERCLIP_GITHUB_BROKER_TOKEN); releaseB(); await b.stop(); await expect(access(b.env.PAPERCLIP_GITHUB_LAUNCHER_DIR)).rejects.toThrow(); + if (remote === "computer") { + expect(b.processRunner.execute).toHaveBeenCalled(); + b.retireProcess(); + await expect(b.processRunner.execute({ command: "true" })).rejects.toThrow("computer_process_claim_stale"); + } }, 45_000); it("rejects other scopes, concurrent bindings, browser requests and forged authority", async () => { const b = await broker(); diff --git a/server/src/services/native-runtime/native-session-executor.test.ts b/server/src/services/native-runtime/native-session-executor.test.ts index 54c94047bf5..b8f7ad47577 100644 --- a/server/src/services/native-runtime/native-session-executor.test.ts +++ b/server/src/services/native-runtime/native-session-executor.test.ts @@ -8028,6 +8028,41 @@ describe("native warm session supervision", () => { expect(secondCopy.collectStopped).toHaveBeenCalledOnce(); }); + it.each(["same", "generation", "owner", "computer"])("preserves command-transport directory identity across %s changes", async change => { + const name = `managed-computer-identity-${change}`; + const current = { ...execution, binding: { ...execution.binding, runId: `${name}-one`, executionWorkspaceId: name }, + session: { ...execution.session, normalizedSessionId: name, lifecyclePolicy: { mode: "warm", idleTimeoutMs: 60_000 } } } as NativeExecutionInputV1; + const close = vi.fn(async () => undefined); + const collectStopped = vi.fn(async () => undefined); + const target = { kind: "remote", transport: "computer", environmentId: name, remoteCwd: `/tmp/${name}`, + resourceAuthority: { kind: "computer-owner", computerId: name, ownerId: name, generation: 1 } }; + state.execute.mockReset().mockImplementationOnce(async options => { await options.onSession?.({ close }); return result; }); + await executePaperclipNativeSession({ db: leaseDb(current), execution: current, runnerInstanceId: name, + runnerExecutionTarget: target as never, + instructionWorkingCopy: { runId: current.binding.runId, root: `/tmp/${name}/home`, collectStopped, + checkpointWarm: async () => true, hasChanges: async () => false } }); + const canReuse = vi.fn(async () => true); + const nextAuthority = { ...target.resourceAuthority, + ...(change === "generation" ? { generation: 2 } : {}), + ...(change === "owner" ? { ownerId: "different-owner" } : {}), + ...(change === "computer" ? { computerId: "different-computer" } : {}), + }; + const reservation = await reserveWarmNativeInstructionDirectory({ companyId: current.binding.companyId, + agentId: current.binding.agentId, previousRunId: current.binding.runId, runId: `${name}-two`, canReuse, + target: { ...target, resourceAuthority: nextAuthority } as never }); + if (change === "same" || change === "generation") { + expect(reservation?.reuseRunId).toBe(current.binding.runId); + expect(canReuse).toHaveBeenCalledOnce(); + expect(close).not.toHaveBeenCalled(); + await reservation!.release(); + } else { + expect(reservation).toBeNull(); + expect(canReuse).not.toHaveBeenCalled(); + expect(close).toHaveBeenCalledOnce(); + expect(collectStopped).toHaveBeenCalledOnce(); + } + }); + it("stops before collecting when a checkpoint cannot stabilize", async () => { const f = await start("managed-fallback", false); expect(f.close).toHaveBeenCalledOnce();