fix: preserve sandbox work folders through ACP launch boundaries
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
cbdf95d053
commit
0a9680a8b8
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue