From 30b9cd8c00a648a6912a260f93c04a1f6c0a357d Mon Sep 17 00:00:00 2001 From: Dotta Date: Tue, 8 Sep 2026 13:40:19 -0500 Subject: [PATCH] fix: isolate GitHub launcher module format Co-Authored-By: Paperclip --- doc/sandbox-work-folders.md | 4 ++- .../adapter-utils/src/execution-target.ts | 3 +++ .../adapter-utils/src/github-launcher.test.ts | 27 +++++++++++++++++++ 3 files changed, 33 insertions(+), 1 deletion(-) diff --git a/doc/sandbox-work-folders.md b/doc/sandbox-work-folders.md index c197e628ba..6b9a0cbbd0 100644 --- a/doc/sandbox-work-folders.md +++ b/doc/sandbox-work-folders.md @@ -43,7 +43,9 @@ 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 -key to an owner-only login file in the staging copy; host credentials stay unchanged. Native runner launches carry the validated scoped paths +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 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 diff --git a/packages/adapter-utils/src/execution-target.ts b/packages/adapter-utils/src/execution-target.ts index 1b036c960b..e03bda3629 100644 --- a/packages/adapter-utils/src/execution-target.ts +++ b/packages/adapter-utils/src/execution-target.ts @@ -1539,6 +1539,9 @@ export async function prepareGitHubOperationLaunchers(input: { // the managed launchers after startup without loading a host user's profile. const profile = `export PATH=${shellQuote(managedPath)}\n`; const files: Record = Object.fromEntries([ + // Warm task paths can live inside an ESM repository. These extensionless + // launchers use CommonJS regardless of the surrounding project's type. + ["package.json", JSON.stringify({ type: "commonjs" })], ...["git", "gh"].map((name) => [name, githubLauncherSource()] as const), ...[".zshenv", ".zprofile", ".zshrc", ".bash_profile", ".bashrc", ".profile"].map((name) => [name, profile] as const), ]); diff --git a/packages/adapter-utils/src/github-launcher.test.ts b/packages/adapter-utils/src/github-launcher.test.ts index 0837c1ae72..da9a0b7125 100644 --- a/packages/adapter-utils/src/github-launcher.test.ts +++ b/packages/adapter-utils/src/github-launcher.test.ts @@ -6,11 +6,38 @@ import path from "node:path"; import { promisify } from "node:util"; import { afterEach, describe, expect, it } from "vitest"; import { githubBrokerEnvironment, githubLauncherSource } from "./github-launcher.js"; +import { prepareGitHubOperationLaunchers } from "./execution-target.js"; +import { runChildProcess } from "./server-utils.js"; +import type { CommandManagedRuntimeRunner } from "./command-managed-runtime.js"; const exec = promisify(execFile); const cleanups: Array<() => Promise> = []; afterEach(async () => { for (const cleanup of cleanups.splice(0).reverse()) await cleanup(); }); describe("managed GitHub launchers", () => { + it("runs staged git and gh launchers inside an ES-module repository", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "paperclip-github-esm-")); + cleanups.push(() => rm(root, { recursive: true, force: true })); + const realBin = path.join(root, "real-bin"); + await mkdir(realBin); + await writeFile(path.join(root, "package.json"), JSON.stringify({ type: "module" })); + await writeFile(path.join(realBin, "gh"), "#!/bin/sh\nprintf gh-fixture", { mode: 0o700 }); + const target = { kind: "remote" as const, transport: "sandbox" as const, + providerKey: "fixture", remoteCwd: root, + runner: { execute: async (input: Parameters[0]) => runChildProcess("github-esm-fixture", input.command, input.args ?? [], { + cwd: input.cwd ?? root, env: input.env ?? {}, stdin: input.stdin, + timeoutSec: 15, graceSec: 1, onLog: async () => {}, + }) }, + }; + // The second run models the fresh per-run wrapper staged on warm startup. + for (const runId of ["cold", "warm"]) { + const env = await prepareGitHubOperationLaunchers({ runId, target, cwd: root, + env: { PATH: `${realBin}:${process.env.PATH}`, PAPERCLIP_GITHUB_BROKER_TOKEN: "" } }); + const launcher = env.PAPERCLIP_GITHUB_LAUNCHER_DIR; + expect((await exec(path.join(launcher, "git"), ["--version"], { cwd: root, env: { ...process.env, ...env } })).stdout).toMatch(/^git version /); + expect((await exec(path.join(launcher, "gh"), [], { cwd: root, env: { ...process.env, ...env } })).stdout).toBe("gh-fixture"); + } + }); + it("captures each command's identity and clears host credentials when the next person has none", async () => { const root = await mkdtemp(path.join(os.tmpdir(), "paperclip-github-launcher-test-")); cleanups.push(() => rm(root, { recursive: true, force: true }));