diff --git a/doc/sandbox-work-folders.md b/doc/sandbox-work-folders.md index 16ace68e16..b3055a5de0 100644 --- a/doc/sandbox-work-folders.md +++ b/doc/sandbox-work-folders.md @@ -41,6 +41,9 @@ links before locating their runtime so task-local launch paths remain valid. Sandbox runs use the operating-system user's home directory. Both legacy adapters and the native runner enter the same host-owned lifecycle before dispatch. Local execution keeps its existing workspace and home behavior. +Legacy ACP proxies use a private host staging directory while agent sessions start +in the sandbox home. Native runner launches carry the validated scoped paths +through runnerd to ACPX; CLI configuration remains in its private runtime directories. Warm sandbox task bindings persist independently of the experimental isolated workspace setting. Only the active host run can establish that binding; the setting still controls user-configurable worktree operations. diff --git a/packages/adapter-utils/src/acpx-engine/execute.ts b/packages/adapter-utils/src/acpx-engine/execute.ts index c1de4487a3..04ec8e5879 100644 --- a/packages/adapter-utils/src/acpx-engine/execute.ts +++ b/packages/adapter-utils/src/acpx-engine/execute.ts @@ -22,6 +22,7 @@ import { prepareAdapterExecutionTargetRuntime, readAdapterExecutionTarget, resolveAdapterExecutionTargetTimeout, + resolveAdapterExecutionTargetCwd, resolveReferencedSourceIgnore, runAdapterExecutionTargetShellCommand, startAdapterExecutionTargetPaperclipBridge, @@ -1698,9 +1699,21 @@ async function buildRuntime(input: { const workspaceWorktreePath = asString(workspaceContext.worktreePath, ""); const agentHome = asString(workspaceContext.agentHome, ""); const configuredCwd = asString(config.cwd, ""); + const executionTarget = readAdapterExecutionTarget({ + executionTarget: input.ctx.executionTarget, + legacyRemoteExecution: input.ctx.executionTransport?.remoteExecution, + }); + const stateDir = path.resolve(asString(config.stateDir, "") || defaultStateDir(agent.companyId, agent.id)); + const workFolderHome = executionTarget?.kind === "remote" && executionTarget.transport === "sandbox" + ? executionTarget.workFolderHome + : undefined; const useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd.length > 0; const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd; - const cwd = effectiveWorkspaceCwd || configuredCwd || process.cwd(); + // Scoped workspaces exist only in the sandbox. The ACP proxy and staging + // metadata need a host directory; never materialize the remote repo on it. + const cwd = workFolderHome + ? path.join(stateDir, "work-folder-proxy") + : effectiveWorkspaceCwd || configuredCwd || process.cwd(); // Referenced (additional) projects to stage into the sandbox alongside the // anchor workspace, read from the workspace realization record. The list is // empty unless run prep resolved referenced projects — gated upstream by the @@ -1771,10 +1784,7 @@ async function buildRuntime(input: { (value): value is Record => typeof value === "object" && value !== null, ) : []; - const executionTarget = readAdapterExecutionTarget({ - executionTarget: input.ctx.executionTarget, - legacyRemoteExecution: input.ctx.executionTransport?.remoteExecution, - }); + const remoteExecutionIdentity = adapterExecutionTargetSessionIdentity(executionTarget); const effectiveExecutionCwd = remoteExecutionIdentity && typeof remoteExecutionIdentity.remoteCwd === "string" @@ -1838,7 +1848,6 @@ async function buildRuntime(input: { asNumber(config.timeoutSec, DEFAULT_ACP_ENGINE_TIMEOUT_SEC), ); const timeoutSec = timeoutResolution.timeoutSec; - const stateDir = path.resolve(asString(config.stateDir, "") || defaultStateDir(agent.companyId, agent.id)); await fs.mkdir(stateDir, { recursive: true }); const envConfig = parseObject(config.env); @@ -2061,7 +2070,7 @@ async function buildRuntime(input: { // diverge from the cwd that fed the fingerprint. const sessionCwd = useRemoteProcessSession && executionTarget?.kind === "remote" - ? executionTarget.remoteCwd + ? resolveAdapterExecutionTargetCwd(executionTarget, undefined, cwd) : cwd; // The 17 fields the session fingerprint hashes. Company, agent, and task // identifiers are NOT here; they scope the outer session key only (see diff --git a/packages/adapter-utils/src/acpx-engine/startup-characterization.test.ts b/packages/adapter-utils/src/acpx-engine/startup-characterization.test.ts index 9367f933e8..8684bd71ef 100644 --- a/packages/adapter-utils/src/acpx-engine/startup-characterization.test.ts +++ b/packages/adapter-utils/src/acpx-engine/startup-characterization.test.ts @@ -487,6 +487,30 @@ describe("ACPX engine startup characterization", () => { expect(sessionInputs[0]?.cwd).toBe(remoteCwd); }); + it("keeps sandbox work folders remote while spawning the ACP proxy on the host", async () => { + const { root, stateDir, executionTarget } = await setupRemoteSandbox(); + const home = path.join(root, "sandbox-home"); + const primary = path.join(home, "repos", "primary"); + await fs.mkdir(primary, { recursive: true }); + await fs.writeFile(path.join(primary, "keep.txt"), "sandbox work"); + const mkdir = vi.spyOn(fs, "mkdir"); + const { sessionInputs, runtimeOptions } = await runExecutor( + { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir, cwd: primary }, + { + authToken: "real-run-jwt", + context: { paperclipWorkspace: { cwd: primary, source: "project" } }, + executionTarget: { ...executionTarget, remoteCwd: primary, workFolderHome: home }, + }, + ); + expect(mkdir.mock.calls.some(([directory]) => directory === primary)).toBe(false); + expect(sessionInputs[0]?.cwd).toBe(home); + expect(runtimeOptions[0]?.spawnCwd).toBe(path.join(stateDir, "work-folder-proxy")); + await expect(fs.readFile(path.join(primary, "keep.txt"), "utf8")).resolves.toBe("sandbox work"); + expect(vi.mocked(prepareAdapterExecutionTargetRuntime).mock.calls[0]![0].workspaceLocalDir) + .toBe(path.join(stateDir, "work-folder-proxy")); + mkdir.mockRestore(); + }); + it("threads a managed-home asset through the same seam after the workspace", async () => { const { root, stateDir, localCwd, executionTarget } = await setupRemoteSandbox(); const managedHomeDir = path.join(root, "managed-home"); diff --git a/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.test.ts b/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.test.ts index d5a5fed36b..1b91749b3e 100644 --- a/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.test.ts +++ b/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.test.ts @@ -387,6 +387,46 @@ it("preserves only bounded GitHub credential projection at the runner spawn boun expect(launches[0]!.environment.DATABASE_URL).toBeUndefined(); }); +it.each(["codex", "claude", "pi"] as const)("carries scoped HOME through the runner launch boundary to the %s ACPX environment", async (agent) => { + const root = mkdtempSync(resolve(tmpdir(), "runner-scoped-home-")); + const home = resolve(root, "sandbox-home"); + const scoped = { + HOME: home, + PAPERCLIP_RUNNER_EXTERNAL_SANDBOX: "1", + PAPERCLIP_TASK_DIR: resolve(home, "task"), + PAPERCLIP_AGENT_DIR: resolve(home, "agent"), + PAPERCLIP_USER_DIR: resolve(home, "user"), + PAPERCLIP_PROJECT_DIR: resolve(home, "project"), + PAPERCLIP_REPOS_DIR: resolve(home, "repos"), + PAPERCLIP_PRIMARY_REPO: resolve(home, "repos", "primary"), + PAPERCLIP_WORKSPACE_CWD: resolve(home, "repos", "primary"), + AGENT_HOME: resolve(home, "agent"), + }; + const launches: RunnerProcessLaunchSpec[] = []; + try { + spawnRunner({ + connection: { mode: "connect", connectUrl: "ws://127.0.0.1:43127" }, + stateDirectory: root, identity, ticket: "bootstrap-ticket", + maxOutboxBytes: 256 * 1024, p0ReserveBytes: 64 * 1024, + runnerVersion: expectedRunnerVersion, runnerDigest: expectedRunnerDigest, + environment: { ...scoped, DATABASE_URL: "must-not-cross" }, + processLauncher: (spec) => { + launches.push(spec); + return { + child: { pid: 42, exitCode: null, signalCode: null, kill: () => true }, + completion: Promise.resolve({ code: 0, signal: null, stdout: "", stderr: "" }), + }; + }, + }); + expect(launches[0]!.environment).toMatchObject(scoped); + expect(launches[0]!.environment.DATABASE_URL).toBeUndefined(); + const { createSanitizedAcpxSpawnInput } = await import("../drivers/acpx/environment.js"); + expect(createSanitizedAcpxSpawnInput(launches[0]!.environment, agent).env).toMatchObject(scoped); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + it("preserves the controller-selected ACPX provider package root", () => { const launches: RunnerProcessLaunchSpec[] = []; spawnRunner({ diff --git a/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.ts b/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.ts index ffdefe9e0d..1277a09050 100644 --- a/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.ts +++ b/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.ts @@ -28,6 +28,7 @@ import { dirname, resolve } from "node:path"; import type { Duplex } from "node:stream"; import { fileURLToPath } from "node:url"; +import { externalWorkFolderEnvironment } from "../work-folder-environment.js"; import { githubCredentialEnvironment } from "../github-credential-environment.js"; import { validatePrpEvent, @@ -2060,7 +2061,7 @@ function runnerEnvironment( const value = explicitSource[key]; if (value !== undefined) environment[key] = value; } - Object.assign(environment, githubCredentialEnvironment(explicitSource)); + Object.assign(environment, githubCredentialEnvironment(explicitSource), externalWorkFolderEnvironment(explicitSource)); } return environment; }