mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-10 12:07:09 +02:00
## Thinking Path > - Paperclip runs agents in local and remote execution environments. > - Managed GitHub launchers select credentials for each Git operation. > - Remote launchers are written inside the project checkout as extensionless CommonJS scripts. > - An ES module project makes Node interpret those launchers as ESM, so they crash before credential resolution. > - When the launcher can start, empty identity variables also override valid repository and command-line Git configuration. > - This change gives the launchers their own CommonJS scope and clears empty identity overrides while preserving managed credential isolation. ## Linked Issues or Issue Description **What happened?** In a repository with `"type": "module"`, the managed `git` and `gh` launchers fail immediately with `ReferenceError: require is not defined in ES module scope`. The launchers use CommonJS but inherited the enclosing project's module type. Sandbox agents also report empty `GIT_AUTHOR_NAME` and `GIT_COMMITTER_NAME` variables and try to unset them for each command. With no managed identity available, the Git launcher recreated those empty values. `git commit` failed with `fatal: empty ident name`, even with explicit `user.name` and `user.email` configuration. **Expected behavior** Managed `git` and `gh` start in both ES module and CommonJS projects. Local commits with an explicitly configured identity work without manual environment cleanup. Managed credentials and captured identity continue to take precedence. Missing identity does not silently select the host user's details. **Steps to reproduce** 1. Create a sandbox project whose `package.json` contains `"type": "module"`. 2. Stage the managed GitHub launchers and run `git --version` or `gh --version`. Before this fix, the launcher fails at its first `require()`. 3. In a CommonJS project with no available managed identity, configure repository `user.name` and `user.email`, or supply them with `git -c`. 4. Run `git commit --allow-empty -m test`. Before this fix, both identity configuration forms fail with empty identity. **Paperclip version or commit** Reproduced from master commit `165b10bd9`. **Deployment mode** Sandbox execution. The shared launcher is also used for managed local and SSH execution. Related work: #13094 introduced the local-operation fallback; #13053 changes launcher discovery on Windows. Neither fixes empty identity overrides. Related identity work in #8945 and #8946 configures worktree authorship and does not remove these environment overrides. ## What Changed - Stage `package.json` with `"type": "commonjs"` in the launcher directory before the Node scripts. Keep the project's package configuration unchanged. - Leave inherited author and committer variables unset in the real Git process. When credentials are absent, require explicit Git identity configuration with `user.useConfigOnly`. - Clear empty identity merge overrides in staged shell profiles after environment merging. Preserve nonempty captured identity values. - Exercise real Git commits with repository and command-line identity, broker failures, and managed-user switching. Verify startup in ES module and CommonJS projects, shell cleanup, and captured identity preservation. - Document launcher module scope and local identity behavior in the execution GitHub identity contract. ## Verification - Confirmed both new local-commit regression cases fail before the fix with `fatal: empty ident name`. - Confirmed the new ES module project regression fails before the fix with `require is not defined in ES module scope`. - Focused launcher and shell tests: 28 passed. - `pnpm exec vitest run --project @paperclipai/adapter-utils --exclude '**/dist/**'`: 1,216 passed, 11 skipped across 58 files. - `pnpm --filter @paperclipai/adapter-utils typecheck` and `pnpm --filter @paperclipai/adapter-utils build`: passed. - `pnpm -r typecheck` and `pnpm build`: attempted; both stop in the unchanged native runner because Cargo is not installed on this machine. - Full `pnpm test:run`: started locally; stopped the duplicate run after the complete CI suite passed. No local full-suite success is claimed. - CI on `99ea8050e`: all 53 checks passed (2 skipped), including full tests, typecheck, build, native runner checks, and browser checks. - Greptile reviewed `99ea8050e`: 5/5 with no findings or unresolved comments. GitHub reports no merge conflicts with master. - No live sandbox or GitHub push probe performed. ## Risks - The new package scope is confined to the run-specific launcher directory. It does not change the project's module type, launcher names, or credential selection. - Without a managed identity, an explicitly configured repository author can now create local commits. GitHub access remains subject to the existing credential broker. Global/system Git configuration, ambient credentials, and SSH identity remain isolated. - Managed identity still wins over repository settings. Missing local identity still fails instead of guessing host details. - New or resumed executions must stage the updated launcher and shell profiles. Existing processes retain their prior files and environment until refreshed. No database migration or sandbox image rebuild is required. - Revert this change to restore the prior behavior. ## Model Used - OpenAI GPT-6 via Codex, with code inspection, implementation, and local test execution. The hosted model variant and context window were not exposed. ## 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 references) - [x] My branch name describes the change and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass (the affected adapter-utils package) - [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>
310 lines
17 KiB
TypeScript
310 lines
17 KiB
TypeScript
import { execFile } from "node:child_process";
|
|
import { mkdtemp, mkdir, readFile, realpath, rm, writeFile } from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { promisify } from "node:util";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import * as ssh from "./ssh.js";
|
|
import type { CommandManagedRuntimeRunner } from "./command-managed-runtime.js";
|
|
import { githubBrokerEnvironment } from "./github-launcher.js";
|
|
import {
|
|
ensureAdapterExecutionTargetCommandResolvable,
|
|
prepareGitHubOperationLaunchers,
|
|
prepareGitHubExecutionEnvironment,
|
|
runAdapterExecutionTargetProcess,
|
|
} from "./execution-target.js";
|
|
|
|
const exec = promisify(execFile);
|
|
const roots: string[] = [];
|
|
afterEach(async () => {
|
|
vi.restoreAllMocks();
|
|
vi.unstubAllEnvs();
|
|
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
|
|
});
|
|
|
|
async function sandbox(layout: string) {
|
|
const root = await mkdtemp(path.join(os.tmpdir(), "paperclip-launcher-env-"));
|
|
roots.push(root);
|
|
const bin = path.join(root, layout);
|
|
await mkdir(bin, { recursive: true });
|
|
for (const cli of ["claude", "codex", "git", "gh"]) {
|
|
await writeFile(path.join(bin, cli), `#!/bin/sh\nprintf '%s\\n' '${cli} started'\n`, { mode: 0o700 });
|
|
}
|
|
const remotePath = `${bin}:${path.dirname(process.execPath)}:/usr/local/bin:/usr/bin:/bin`;
|
|
// Execute real shells and staged launchers, with a provider-owned environment.
|
|
// Do not inherit the controller's PATH, HOME, credentials, or shell hooks.
|
|
const execute: CommandManagedRuntimeRunner["execute"] = async (input) => {
|
|
const startedAt = new Date().toISOString();
|
|
try {
|
|
const execution = exec(input.command, input.args ?? [], {
|
|
cwd: input.cwd ?? root,
|
|
env: { HOME: root, PATH: remotePath, ...input.env },
|
|
timeout: input.timeoutMs ?? 15_000,
|
|
});
|
|
const inputComplete = new Promise<void>((resolve, reject) => {
|
|
const stdin = execution.child.stdin;
|
|
if (!stdin) return resolve();
|
|
// Hash-skip staging can exit before reading the supplied file body.
|
|
// Its exit result still determines success; other input errors fail.
|
|
stdin.on("error", (error: NodeJS.ErrnoException) => {
|
|
if (error.code === "EPIPE") resolve();
|
|
else reject(error);
|
|
});
|
|
stdin.end(input.stdin ?? "", resolve);
|
|
});
|
|
const [result] = await Promise.all([execution, inputComplete]);
|
|
return { ...result, exitCode: 0, signal: null, timedOut: false, pid: null, startedAt };
|
|
} catch (error) {
|
|
const result = error as Error & { code?: number; killed?: boolean; stdout?: string; stderr?: string };
|
|
return { exitCode: result.code ?? 1, signal: null, timedOut: result.killed ?? false,
|
|
stdout: result.stdout ?? "", stderr: result.stderr ?? "", pid: null, startedAt };
|
|
}
|
|
};
|
|
const runner = { execute: vi.fn(execute) };
|
|
const target = { kind: "remote" as const, transport: "sandbox" as const,
|
|
providerKey: "fixture", remoteCwd: root, runner };
|
|
return { root, bin, remotePath, runner, target };
|
|
}
|
|
|
|
describe("managed GitHub launcher environment", () => {
|
|
it.each(["module", "commonjs"])("runs managed GitHub launchers inside a %s project", async (type) => {
|
|
const fixture = await sandbox("usr/bin");
|
|
const packageJson = JSON.stringify({ type });
|
|
await writeFile(path.join(fixture.root, "package.json"), packageJson);
|
|
// Exercise real Git; gh uses the fixture CLI because it need not be installed.
|
|
await rm(path.join(fixture.bin, "git"));
|
|
const env = await prepareGitHubOperationLaunchers({
|
|
runId: "run-package-type", target: fixture.target, cwd: fixture.root,
|
|
env: githubBrokerEnvironment({}, { url: "", token: "" }),
|
|
});
|
|
for (const cli of ["git", "gh"]) {
|
|
const result = await fixture.runner.execute({
|
|
command: path.join(env.PAPERCLIP_GITHUB_LAUNCHER_DIR, cli), args: ["--version"], env,
|
|
});
|
|
expect(result.exitCode, result.stderr).toBe(0);
|
|
expect(result.stdout).toMatch(cli === "git" ? /^git version / : /^gh started\n$/);
|
|
}
|
|
expect(await readFile(path.join(fixture.root, "package.json"), "utf8")).toBe(packageJson);
|
|
});
|
|
|
|
it("clears empty identity overrides in sandbox shells and preserves a captured identity", async () => {
|
|
const fixture = await sandbox("usr/bin");
|
|
const env = await prepareGitHubOperationLaunchers({
|
|
runId: "run-git-identity", target: fixture.target, cwd: fixture.root,
|
|
env: githubBrokerEnvironment({ GIT_AUTHOR_NAME: "Host Author" }, { url: "", token: "" }),
|
|
});
|
|
const readIdentity = `node -e 'process.stdout.write(JSON.stringify(Object.fromEntries(Object.entries(process.env).filter(([key]) => /^GIT_(AUTHOR|COMMITTER)_(NAME|EMAIL)$/.test(key)))))'`;
|
|
const shell = await fixture.runner.execute({ command: "bash", args: ["--noprofile", "--norc", "-c", readIdentity], env });
|
|
expect(shell.exitCode, shell.stderr).toBe(0);
|
|
expect(JSON.parse(shell.stdout)).toEqual({});
|
|
|
|
const identity = { GIT_AUTHOR_NAME: "Captured Author", GIT_AUTHOR_EMAIL: "author@example.test",
|
|
GIT_COMMITTER_NAME: "Captured Committer", GIT_COMMITTER_EMAIL: "committer@example.test" };
|
|
for (const profile of [".profile", ".bash_profile", ".bashrc", ".zshenv", ".zprofile", ".zshrc"]) {
|
|
const script = await readFile(path.join(env.PAPERCLIP_GITHUB_LAUNCHER_DIR, profile), "utf8");
|
|
for (const captured of [false, true]) {
|
|
const result = await fixture.runner.execute({ command: "sh", args: ["-c", `${script}\n${readIdentity}`],
|
|
env: { ...env, ...(captured ? identity : {}) } });
|
|
expect(result.exitCode, result.stderr).toBe(0);
|
|
expect(JSON.parse(result.stdout)).toEqual(captured ? identity : {});
|
|
}
|
|
}
|
|
});
|
|
|
|
it.each([false, true])("probes the remote workspace when the controller cwd is absent (host credentials: %s)", async (hostCredentials) => {
|
|
const fixture = await sandbox("usr/bin");
|
|
const env = await prepareGitHubExecutionEnvironment({
|
|
target: fixture.target,
|
|
cwd: path.join(fixture.root, "controller-only", "agent-workspace"),
|
|
env: {},
|
|
hostCredentials,
|
|
networkAccess: true,
|
|
});
|
|
|
|
expect(env.PAPERCLIP_RUNNER_NETWORK_ACCESS).toBe("enabled");
|
|
expect(env.PAPERCLIP_GIT_METADATA_ROOTS).toBe("[]");
|
|
expect(JSON.parse(env.PAPERCLIP_RUNNER_NETWORK_ROOTS!)).not.toHaveLength(0);
|
|
expect(fixture.runner.execute).toHaveBeenCalledWith(expect.objectContaining({ cwd: fixture.root }));
|
|
});
|
|
|
|
it("reads Git metadata from the SSH workspace instead of an existing controller directory", async () => {
|
|
const fixture = await sandbox("ssh-toolchain/bin");
|
|
// Use real Git for the probe, not the launcher fixture's stub.
|
|
await rm(path.join(fixture.bin, "git"));
|
|
await exec("git", ["init", fixture.root]);
|
|
const controllerCwd = path.join(fixture.root, "controller");
|
|
await mkdir(controllerCwd);
|
|
vi.spyOn(ssh, "createSshCommandManagedRuntimeRunner").mockReturnValue(fixture.runner);
|
|
const target = { kind: "remote" as const, transport: "ssh" as const, remoteCwd: fixture.root,
|
|
spec: { host: "sandbox.example.test", port: 22, username: "runner", remoteCwd: fixture.root,
|
|
remoteWorkspacePath: fixture.root, privateKey: null, knownHosts: null, strictHostKeyChecking: true } };
|
|
|
|
const env = await prepareGitHubExecutionEnvironment({
|
|
target, cwd: controllerCwd, env: {}, hostCredentials: false, networkAccess: true,
|
|
});
|
|
|
|
expect(JSON.parse(env.PAPERCLIP_GIT_METADATA_ROOTS!)).toEqual([await realpath(path.join(fixture.root, ".git"))]);
|
|
});
|
|
|
|
it("uses target Git configuration without importing controller credentials", async () => {
|
|
const fixture = await sandbox("usr/bin");
|
|
vi.stubEnv("GH_TOKEN", "controller-secret");
|
|
await mkdir(path.join(fixture.root, ".config/gh"), { recursive: true });
|
|
await writeFile(path.join(fixture.root, ".config/gh/hosts.yml"), "host credential fixture");
|
|
const execute = fixture.runner.execute.getMockImplementation()!;
|
|
fixture.runner.execute.mockImplementation(async (input) => {
|
|
expect(input.command).toBe("sh"); // No Node executable is required on the SSH host.
|
|
const result = await execute(input);
|
|
return { ...result, stdout: `SSH login banner\n${result.stdout}\nlogout` };
|
|
});
|
|
const env = await prepareGitHubExecutionEnvironment({
|
|
target: fixture.target, cwd: fixture.root, env: {
|
|
PAPERCLIP_GIT_METADATA_ROOTS: '["/injected"]',
|
|
PAPERCLIP_RUNNER_NETWORK_ROOTS: '["/injected"]',
|
|
PAPERCLIP_GITHUB_HOST_HOME: "/injected",
|
|
PAPERCLIP_GITHUB_AUTH_MODE: "managed",
|
|
PAPERCLIP_RUNNER_NETWORK_ACCESS: "disabled",
|
|
}, hostCredentials: true, networkAccess: true,
|
|
});
|
|
expect(env.PAPERCLIP_GIT_METADATA_ROOTS).not.toContain("/injected");
|
|
expect(env.PAPERCLIP_RUNNER_NETWORK_ROOTS).not.toContain("/injected");
|
|
expect(env.PAPERCLIP_GITHUB_AUTH_MODE).toBe("host");
|
|
expect(env.PAPERCLIP_RUNNER_NETWORK_ACCESS).toBe("enabled");
|
|
expect(env.PAPERCLIP_GITHUB_HOST_HOME).toBe(fixture.root);
|
|
expect(env.GH_CONFIG_DIR).toBe(path.join(fixture.root, ".config/gh"));
|
|
expect(env.GH_TOKEN).toBeUndefined();
|
|
expect(env.PAPERCLIP_GITHUB_LAUNCHER_DIR).toBeUndefined();
|
|
});
|
|
|
|
it("preserves local host credential helpers and validates worktree metadata", async () => {
|
|
const root = await mkdtemp(path.join(os.tmpdir(), "paperclip-host-git-")); roots.push(root);
|
|
vi.stubEnv("HOME", root);
|
|
vi.stubEnv("GH_TOKEN", "legacy-token");
|
|
await writeFile(path.join(root, ".gitconfig"), '[credential]\n helper = store\n');
|
|
await exec("git", ["init", path.join(root, "repo")]);
|
|
const env = await prepareGitHubExecutionEnvironment({ target: null, cwd: path.join(root, "repo"), env: {}, hostCredentials: true, networkAccess: true });
|
|
expect(env.GH_TOKEN).toBe("legacy-token");
|
|
expect(env.GIT_CONFIG_GLOBAL).toBeUndefined();
|
|
expect(env.PAPERCLIP_GIT_METADATA_ROOTS).toContain("/repo/.git");
|
|
const config = await exec("git", ["config", "credential.helper"], { cwd: root, env: { ...process.env, ...env } });
|
|
expect(config.stdout.trim()).toBe("store");
|
|
const isolated = await prepareGitHubExecutionEnvironment({ target: null, cwd: root, env: {}, hostCredentials: false, networkAccess: false });
|
|
expect(isolated.GH_TOKEN).toBeUndefined();
|
|
expect(isolated.PAPERCLIP_RUNNER_NETWORK_ACCESS).toBe("disabled");
|
|
expect(isolated.PAPERCLIP_GITHUB_HOST_HOME).toBeUndefined();
|
|
});
|
|
|
|
it.each(["nvm/current/bin", "usr/local/bin", "tools with 'quotes'/bin"])(
|
|
"preserves %s CLIs and keeps GitHub wrappers first in child shells",
|
|
async (layout) => {
|
|
const fixture = await sandbox(layout);
|
|
vi.stubEnv("PATH", "/controller-only/bin");
|
|
const env = await prepareGitHubOperationLaunchers({
|
|
runId: "run-layout", target: fixture.target, cwd: "/controller", env: {},
|
|
});
|
|
expect(env.PATH).toBe(`${env.PAPERCLIP_GITHUB_LAUNCHER_DIR}:${fixture.remotePath}`);
|
|
for (const cli of ["claude", "codex"]) {
|
|
await ensureAdapterExecutionTargetCommandResolvable(cli, fixture.target, fixture.root, env);
|
|
const result = await runAdapterExecutionTargetProcess("run-layout", fixture.target, "bash", [
|
|
"--noprofile", "--norc", "-c", `command -v git; command -v gh; ${cli}`,
|
|
], { cwd: fixture.root, env, timeoutSec: 5, graceSec: 1, onLog: async () => {} });
|
|
expect(result.exitCode, result.stderr).toBe(0);
|
|
expect(result.stdout.trim().split("\n")).toEqual([
|
|
`${env.PAPERCLIP_GITHUB_LAUNCHER_DIR}/git`,
|
|
`${env.PAPERCLIP_GITHUB_LAUNCHER_DIR}/gh`,
|
|
`${cli} started`,
|
|
]);
|
|
}
|
|
for (const profile of [".profile", ".bash_profile", ".bashrc", ".zshenv", ".zprofile", ".zshrc"]) {
|
|
const script = await readFile(path.join(env.PAPERCLIP_GITHUB_LAUNCHER_DIR, profile), "utf8");
|
|
const result = await fixture.runner.execute({ command: "sh", args: ["-c", `${script}\nprintf '%s' "$PATH"`] });
|
|
expect(result.stdout).toBe(env.PATH);
|
|
}
|
|
// The wrappers' Node interpreter and underlying commands are still reachable.
|
|
const github = await fixture.runner.execute({ command: "bash", args: ["-c", "git; gh"], env });
|
|
expect(github.exitCode, github.stderr).toBe(0);
|
|
expect(github.stdout).toBe("git started\ngh started\n");
|
|
},
|
|
);
|
|
|
|
it("preserves an explicit remote PATH without querying the remote environment", async () => {
|
|
const fixture = await sandbox("custom/bin");
|
|
const env = await prepareGitHubOperationLaunchers({
|
|
runId: "run-explicit", target: fixture.target, cwd: fixture.root, env: { PATH: fixture.remotePath },
|
|
});
|
|
expect(env.PATH).toBe(`${env.PAPERCLIP_GITHUB_LAUNCHER_DIR}:${fixture.remotePath}`);
|
|
expect(fixture.runner.execute.mock.calls.every(([input]) => !input.args?.join(" ").includes("$PATH"))).toBe(true);
|
|
});
|
|
|
|
it("does not copy an inherited controller PATH into a remote launcher", async () => {
|
|
const fixture = await sandbox("nvm/bin");
|
|
vi.stubEnv("PATH", "/controller-only/bin");
|
|
const env = await prepareGitHubOperationLaunchers({
|
|
runId: "run-inherited", target: fixture.target, cwd: fixture.root, env: { PATH: process.env.PATH! },
|
|
});
|
|
expect(env.PATH).toBe(`${env.PAPERCLIP_GITHUB_LAUNCHER_DIR}:${fixture.remotePath}`);
|
|
});
|
|
|
|
it("keeps an explicit empty remote PATH empty apart from the managed wrappers", async () => {
|
|
const fixture = await sandbox("nvm/bin");
|
|
const env = await prepareGitHubOperationLaunchers({
|
|
runId: "run-empty", target: fixture.target, cwd: fixture.root, env: { PATH: "" },
|
|
});
|
|
expect(env.PATH).toBe(env.PAPERCLIP_GITHUB_LAUNCHER_DIR);
|
|
expect(fixture.runner.execute.mock.calls.every(([input]) => !input.args?.join(" ").includes("$PATH"))).toBe(true);
|
|
const result = await fixture.runner.execute({ command: "/bin/sh", args: ["-c", "command -v claude"], env });
|
|
expect(result.exitCode).not.toBe(0);
|
|
});
|
|
|
|
it("reads the SSH target PATH and ignores login banners", async () => {
|
|
const fixture = await sandbox("ssh-toolchain/bin");
|
|
fixture.runner.execute.mockResolvedValueOnce({ exitCode: 0, timedOut: false, signal: null,
|
|
stdout: `Welcome\n\0${fixture.remotePath}\0\n`, stderr: "", pid: null, startedAt: new Date().toISOString() });
|
|
vi.spyOn(ssh, "createSshCommandManagedRuntimeRunner").mockReturnValue(fixture.runner);
|
|
const target = { kind: "remote" as const, transport: "ssh" as const, remoteCwd: fixture.root,
|
|
spec: { host: "sandbox.example.test", port: 22, username: "runner", remoteCwd: fixture.root,
|
|
remoteWorkspacePath: fixture.root, privateKey: null, knownHosts: null, strictHostKeyChecking: true } };
|
|
const env = await prepareGitHubOperationLaunchers({ runId: "run-ssh", target, cwd: fixture.root, env: {} });
|
|
expect(env.PATH).toBe(`${env.PAPERCLIP_GITHUB_LAUNCHER_DIR}:${fixture.remotePath}`);
|
|
expect(fixture.runner.execute.mock.calls[0]?.[0].env).toBeUndefined();
|
|
});
|
|
|
|
it("uses the launch environment for install and re-probe after a missing command", async () => {
|
|
const fixture = await sandbox("custom/bin");
|
|
const env = { PATH: fixture.remotePath, HOME: fixture.root };
|
|
await ensureAdapterExecutionTargetCommandResolvable("fixture-cli", fixture.target, fixture.root, env, {
|
|
installCommand: `cp ${ssh.shellQuote(path.join(fixture.bin, "claude"))} ${ssh.shellQuote(path.join(fixture.bin, "fixture-cli"))}`,
|
|
});
|
|
expect(fixture.runner.execute.mock.calls).toHaveLength(3);
|
|
for (const [input] of fixture.runner.execute.mock.calls) expect(input.env).toEqual(env);
|
|
});
|
|
|
|
it("checks command availability with the launch environment, not the provider default", async () => {
|
|
const fixture = await sandbox("nvm/bin");
|
|
const env = { PATH: "/usr/bin:/bin" };
|
|
// The binary exists on the provider PATH, but the requested launch excludes it.
|
|
await expect(ensureAdapterExecutionTargetCommandResolvable(
|
|
"claude", fixture.target, fixture.root, env,
|
|
)).rejects.toThrow('Command "claude" is not installed or not on PATH');
|
|
const result = await runAdapterExecutionTargetProcess("run-missing", fixture.target, "sh", ["-c", "claude"], {
|
|
cwd: fixture.root, env, timeoutSec: 5, graceSec: 1, onLog: async () => {},
|
|
});
|
|
expect(result.exitCode).toBe(127);
|
|
});
|
|
|
|
it.each([
|
|
{ exitCode: 1, timedOut: false, stdout: "" },
|
|
{ exitCode: 0, timedOut: true, stdout: "" },
|
|
{ exitCode: 0, timedOut: false, stdout: "login banner only" },
|
|
{ exitCode: 0, timedOut: false, stdout: "\0\0" },
|
|
])("fails before staging when remote PATH discovery fails: %j", async (failure) => {
|
|
const fixture = await sandbox("nvm/bin");
|
|
fixture.runner.execute.mockResolvedValueOnce({ ...failure, signal: null, stderr: "private diagnostic",
|
|
pid: null, startedAt: new Date().toISOString() });
|
|
await expect(prepareGitHubOperationLaunchers({
|
|
runId: "run-failure", target: fixture.target, cwd: fixture.root, env: {},
|
|
})).rejects.toThrow("Could not resolve remote PATH for managed GitHub launchers");
|
|
expect(fixture.runner.execute).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|