From 6d61990692136223520fe08aef29edea2b7671aa Mon Sep 17 00:00:00 2001 From: Nicky Leach Date: Fri, 30 Oct 2026 09:29:51 -0700 Subject: [PATCH] fix: allow managed Git helpers to select a private interpreter (#18337) ## Thinking Path > - Paperclip manages AI agents and their execution environments. > - Managed Git helpers run next to those agents. > - Some environments keep their Node interpreter outside the agent PATH. > - The current generator always emits an env-based Node shebang. > - This pull request lets the environment select an absolute interpreter path. > - Existing callers keep the same default behavior. ## Linked Issues or Issue Description Refs #18212. **What happened?** A managed helper can fail when Node exists only as a private runtime binary. The caller must currently alter generated source or expose that runtime through PATH. **Expected behavior** The source generator accepts an optional interpreter path. The environment can keep its own runtime private. **Steps to reproduce** Generate a helper in an environment whose PATH has no usable Node. Run a local Git command through that helper. The env-based shebang cannot select the private interpreter. ## What Changed - Add optional `interpreterPath` to `githubLauncherSource`. - Preserve `/usr/bin/env node` when the option is absent. - Reject relative paths, whitespace and NUL bytes. - Document the option and test actual execution with an unusable Node on PATH. ## Verification - The affected adapter-utils package typecheck passed. - All 26 tests in `packages/adapter-utils/src/github-launcher.test.ts` passed. - The new test runs a generated helper as a child process. It proves that the selected interpreter bypasses a failing PATH-selected Node and that the helper body stays unchanged. - Invalid-path tests passed. Existing broker and local-Git tests passed. - Full checks run in GitHub CI per the user's request. No full local suite was run. ## Risks The option changes only the shebang. Callers own interpreter installation and immutability. Default behavior, PATH, credentials and permission policy stay unchanged. No schema or workflow changes. ## Model Used OpenAI GPT-6.1 Sol (`gpt-6.1-sol`), high reasoning, with source inspection and targeted execution. The host does not expose an exact context-window size. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge Co-authored-by: Paperclip --- doc/runner-launch-preparation.md | 8 ++++++ .../adapter-utils/src/github-launcher.test.ts | 25 +++++++++++++++++++ packages/adapter-utils/src/github-launcher.ts | 12 ++++++--- 3 files changed, 42 insertions(+), 3 deletions(-) diff --git a/doc/runner-launch-preparation.md b/doc/runner-launch-preparation.md index ccd2f172f37..c96c355adb9 100644 --- a/doc/runner-launch-preparation.md +++ b/doc/runner-launch-preparation.md @@ -79,3 +79,11 @@ The [environment task API](plugins/PLUGIN_ENVIRONMENT_TASKS.md#launch-preparatio defines the plugin preparation exchange. The [native plugin integration](plugins/PLUGIN_RUNNER_EXECUTION.md) connects the callback to task execution. A Launcher implementation remains the execution owner's responsibility. + +## Managed Git helper interpreter + +`githubLauncherSource({ interpreterPath })` can select an absolute Node interpreter +owned by the execution environment. Without this option, generated helpers retain +`#!/usr/bin/env node`. The option changes only the shebang; it does not change PATH, +command discovery, credential handling, or helper behavior. Interpreter paths must +be absolute and contain no whitespace or NUL bytes. diff --git a/packages/adapter-utils/src/github-launcher.test.ts b/packages/adapter-utils/src/github-launcher.test.ts index 85e25d63094..f69f5817e3f 100644 --- a/packages/adapter-utils/src/github-launcher.test.ts +++ b/packages/adapter-utils/src/github-launcher.test.ts @@ -11,6 +11,31 @@ const cleanups: Array<() => Promise> = []; afterEach(async () => { for (const cleanup of cleanups.splice(0).reverse()) await cleanup(); }); describe("managed GitHub launchers", () => { + + it("uses a private interpreter without selecting Node from PATH", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "paperclip-github-interpreter-")); + cleanups.push(() => rm(root, { recursive: true, force: true })); + const managed = path.join(root, "managed"), real = path.join(root, "real"); + await mkdir(managed); await mkdir(real); + const source = githubLauncherSource({ interpreterPath: process.execPath }); + const defaultSource = githubLauncherSource(); + expect(defaultSource.split("\n")[0]).toBe("#!/usr/bin/env node"); + expect(source.split("\n")[0]).toBe(`#!${process.execPath}`); + expect(source.slice(source.indexOf("\n"))).toBe(defaultSource.slice(defaultSource.indexOf("\n"))); + const launcher = path.join(managed, "git"); + await writeFile(launcher, source, { mode: 0o700 }); + await writeFile(path.join(real, "node"), "#!/bin/sh\nexit 126\n", { mode: 0o700 }); + await writeFile(path.join(real, "git"), "#!/bin/sh\nprintf 'private interpreter works'\n", { mode: 0o700 }); + const env = { ...githubBrokerEnvironment({}, { url: "", token: "" }), HOME: root, + GH_CONFIG_DIR: path.join(root, "config"), PATH: `${managed}${path.delimiter}${real}` }; + expect((await exec(launcher, ["--version"], { env })).stdout).toBe("private interpreter works"); + }); + + it.each(["", "relative/node", "/runtime/node --flag", "/runtime/node\nexit 1", "/runtime/node\0"])( + "rejects an invalid interpreter path %j", interpreterPath => { + expect(() => githubLauncherSource({ interpreterPath })).toThrow("absolute path without whitespace"); + }, + ); it.each([false, true])("rejects a PATH shim that re-enters the same helper before it can recurse (broker file: %s)", async brokerFile => { const root = await mkdtemp(path.join(os.tmpdir(), "paperclip-github-recursion-")); cleanups.push(() => rm(root, { recursive: true, force: true })); diff --git a/packages/adapter-utils/src/github-launcher.ts b/packages/adapter-utils/src/github-launcher.ts index ea38405306d..8e3bf2b53bc 100644 --- a/packages/adapter-utils/src/github-launcher.ts +++ b/packages/adapter-utils/src/github-launcher.ts @@ -1,6 +1,12 @@ -/** Standalone source is staged unchanged on local, SSH, and sandbox runtimes. No secrets in files. */ -export function githubLauncherSource(options: { brokerFile?: boolean } = {}): string { - return String.raw`#!/usr/bin/env node +import { isAbsolute } from "node:path"; + +/** Standalone source for local, SSH, and sandbox runtimes. No secrets in files. */ +export function githubLauncherSource(options: { brokerFile?: boolean; interpreterPath?: string } = {}): string { + if (options.interpreterPath !== undefined && (typeof options.interpreterPath !== "string" + || !isAbsolute(options.interpreterPath) || /[\s\0]/u.test(options.interpreterPath))) { + throw new Error("GitHub launcher interpreter must be an absolute path without whitespace"); + } + return String.raw`#!${options.interpreterPath ?? "/usr/bin/env node"} const fs = require('node:fs'); const path = require('node:path'); const os = require('node:os');