diff --git a/doc/sandbox-work-folders.md b/doc/sandbox-work-folders.md index 412093f6b0..1368031e66 100644 --- a/doc/sandbox-work-folders.md +++ b/doc/sandbox-work-folders.md @@ -47,7 +47,9 @@ 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. For API-key Codex ACP runs, the adapter writes the explicit +in the sandbox home. When managed Git launchers are present, the remote agent +starts without another login-shell profile pass, which could replace the assigned +PATH before the agent starts. For API-key Codex ACP runs, the adapter writes the explicit key to an owner-only login file in the staging copy; host credentials stay unchanged. Per-run GitHub launchers declare their own CommonJS package scope so warm runs inside ES-module repositories can still execute Git and GitHub CLI commands. Native runner launches carry the validated scoped paths diff --git a/packages/adapter-utils/src/acpx-engine/execute.ts b/packages/adapter-utils/src/acpx-engine/execute.ts index 04ec8e5879..77f2c63836 100644 --- a/packages/adapter-utils/src/acpx-engine/execute.ts +++ b/packages/adapter-utils/src/acpx-engine/execute.ts @@ -2277,7 +2277,9 @@ async function buildRuntime(input: { runtimeRootDir, adapterKey: input.engine.adapterType, command: "sh", - args: ["-lc", `exec ${agentCommandShell}`], + // The host has already projected the managed Git PATH. A login shell + // can replace it from /etc/profile before the agent even starts. + args: [env.PAPERCLIP_GITHUB_LAUNCHER_DIR ? "-c" : "-lc", `exec ${agentCommandShell}`], cwd: sessionCwd, env: launchEnv, timeoutSec, 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 8684bd71ef..2d8b4ec100 100644 --- a/packages/adapter-utils/src/acpx-engine/startup-characterization.test.ts +++ b/packages/adapter-utils/src/acpx-engine/startup-characterization.test.ts @@ -305,6 +305,34 @@ describe("ACPX engine startup characterization", () => { expect(bridgeExecEnv?.PAPERCLIP_SANDBOX_EXEC_CHANNEL).toBe("bridge"); expect(bridgeExecEnv?.PAPERCLIP_API_KEY).toBeUndefined(); }); + + it("keeps the managed Git executable first when the remote agent command starts", async () => { + const { root, stateDir, localCwd, executionTarget } = await setupRemoteSandbox(); + const launcherDir = path.join(root, "managed-github"); + await fs.mkdir(launcherDir); + await fs.writeFile(path.join(launcherDir, "git"), "#!/bin/sh\nprintf managed-git", { mode: 0o700 }); + const launchPath = `${launcherDir}:${path.dirname(process.execPath)}:/usr/bin:/bin`; + let launchPayload: { command: string; args: string[]; env: Record } | undefined; + executionTarget.runner = createLocalSandboxRunner((input) => { + const match = input.args?.[1]?.match(/PAPERCLIP_PROCESS_SESSION_COMMAND_B64='([^']+)'/); + if (match) launchPayload = JSON.parse(Buffer.from(match[1]!, "base64").toString("utf8")); + }); + await runExecutor({ + agent: "custom", agentCommand: "git", stateDir, cwd: localCwd, + env: { PATH: launchPath, PAPERCLIP_GITHUB_LAUNCHER_DIR: launcherDir }, + }, { authToken: "test-run-jwt", executionTarget }); + expect(launchPayload).toBeDefined(); + expect(launchPayload!.env.PATH).toBe(launchPath); + // Execute the actual launch payload. Checking only its env would miss a + // login shell subsequently replacing PATH with the image's defaults. + let stdout = ""; + const result = await runChildProcess("managed-git-launch", launchPayload!.command, launchPayload!.args, { + cwd: root, env: launchPayload!.env, timeoutSec: 5, graceSec: 1, + onLog: async (stream, chunk) => { if (stream === "stdout") stdout += chunk; }, + }); + expect(result.exitCode).toBe(0); + expect(stdout).toBe("managed-git"); + }); }); // Item 2: the 17 fingerprint fields folded into `configFingerprint`, and the