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');