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 <noreply@paperclip.ing>
This commit is contained in:
Nicky LeachandPaperclip authored and GitHub committed 2026-10-30 09:29:51 -07:00
1 parent abc270ee4c
commit 6d61990692
3 files changed
+42 -3

No files matched your search

+8
View File
@@ -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.
@@ -11,6 +11,31 @@ const cleanups: Array<() => Promise<unknown>> = [];
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 }));
@@ -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');