Preserve managed Git launchers across legacy ACP startup

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-09-08 17:14:18 -05:00
parent 22d61006b9
commit 0b9314cf3e
3 changed files with 34 additions and 2 deletions

View File

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

View File

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

View File

@ -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<string, string> } | 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