fix: preserve sandbox work folders through ACP launch boundaries

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-09-08 13:02:36 -05:00
parent cbdf95d053
commit 0a9680a8b8
5 changed files with 85 additions and 8 deletions

View File

@ -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.

View File

@ -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<string, unknown> => 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

View File

@ -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");

View File

@ -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({

View File

@ -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;
}