Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/amico-run/esbuild.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,8 @@ try {
["src/cli.ts", "amico-run.js"],
["src/amico.ts", "amico.js"],
["src/pasqal_cli.ts", "amico-pasqal.js"],
["src/gh_cli.ts", "gh.js"],
["src/git_credential_cli.ts", "amico-git-credential.js"],
]) {
const tmp = join(staging, name);
await build({ ...common, entryPoints: [entry], outfile: tmp });
Expand Down
18 changes: 18 additions & 0 deletions packages/amico-run/launcher/amico-git-credential
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
#!/usr/bin/env bash
# Thin launcher for `amico-git-credential` (issue #399): resolve node, exec
# the bundled git credential helper. No logic lives here (mirror of
# launcher/amico-run). Addressed by the ABSOLUTE path the extension registers
# in GIT_CONFIG env — never by PATH lookup — so there is no recursion concern.
set -euo pipefail
SOURCE="${BASH_SOURCE[0]}"
while [ -h "$SOURCE" ]; do # resolve symlink chains (node_modules/.bin)
DIR="$(cd -P "$(dirname "$SOURCE")" && pwd)"
SOURCE="$(readlink "$SOURCE")"
[[ $SOURCE != /* ]] && SOURCE="$DIR/$SOURCE"
done
DIR="$(cd -P "$(dirname "$SOURCE")" && pwd)"
if ! command -v node >/dev/null 2>&1; then
echo "amico-git-credential: node >= 20 not found on PATH (install node or fix PATH; see provisioning runbook)" >&2
exit 64
fi
exec node "$DIR/../dist/amico-git-credential.js" "$@"
19 changes: 19 additions & 0 deletions packages/amico-run/launcher/gh
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
#!/usr/bin/env bash
# Thin launcher for the `gh` PATH shim (issue #399): resolve node, exec the
# bundled shim. No logic lives here (mirror of launcher/amico-run). The shim
# itself is transparent when no GitHub App credential file exists — this file
# only exists so the bin dir (prepended to the server PATH) shadows the real
# gh, making every agent gh call ride the App identity when configured.
set -euo pipefail
SOURCE="${BASH_SOURCE[0]}"
while [ -h "$SOURCE" ]; do # resolve symlink chains (node_modules/.bin)
DIR="$(cd -P "$(dirname "$SOURCE")" && pwd)"
SOURCE="$(readlink "$SOURCE")"
[[ $SOURCE != /* ]] && SOURCE="$DIR/$SOURCE"
done
DIR="$(cd -P "$(dirname "$SOURCE")" && pwd)"
if ! command -v node >/dev/null 2>&1; then
echo "gh: node >= 20 not found on PATH (amico-gh shim cannot run; install node or fix PATH)" >&2
exit 64
fi
exec node "$DIR/../dist/gh.js" "$@"
8 changes: 7 additions & 1 deletion packages/amico-run/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,13 @@
"bin": {
"amico-run": "./launcher/amico-run",
"amico": "./launcher/amico",
"amico-pasqal": "./launcher/amico-pasqal"
"amico-pasqal": "./launcher/amico-pasqal",
"amico-git-credential": "./launcher/amico-git-credential"
},
"amicode": {
"shadowBins": {
"gh": "./launcher/gh"
}
},
"engines": {
"node": ">=20"
Expand Down
16 changes: 16 additions & 0 deletions packages/amico-run/src/gh_cli.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// The `gh` bin entry point (issue #399). Thin by design (the pasqal_cli.ts
// split): resolve argv → ghShimMain() → set the exit code. The logic — real-gh
// resolution, token carriage, passthrough — lives in gh_shim.ts and is unit-
// tested there; even the unexpected-error lane prints only the error's own
// text (github_app.ts guarantees no ConfigError message carries a secret).
import { ghShimMain } from "./gh_shim.js";

ghShimMain(process.argv.slice(2)).then(
(c) => {
process.exitCode = c;
},
(e) => {
console.error(`amico-gh: unexpected error: ${e instanceof Error ? (e.stack ?? e.message) : e}`);
process.exitCode = 64;
},
);
82 changes: 82 additions & 0 deletions packages/amico-run/src/gh_shim.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
// packages/amico-run/src/gh_shim.ts — the `gh` shim logic (issue #399).
//
// Staged as `launcher/gh` in the bin dir the extension prepends to the
// server's PATH, so EVERY gh invocation in an agent session — issue/PR
// creation, the handoff verb, repo-sync — runs as the App identity when the
// GitHub App connection is configured, and byte-identically as the user's own
// gh when it is not (config absent → argv/env untouched passthrough).
//
// SECURITY: the installation token rides ONLY the child env (GH_TOKEN) — never
// argv, never an error, never a log line. Config-class faults are the
// pasqal-launcher stance: one token-free actionable line on stderr, exit 64.
import { spawn } from "node:child_process";
import { constants as osConstants } from "node:os";
import { existsSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { ensureInstallationToken, githubAppConfigFile, resolveRealGh } from "./github_app.js";
import { ConfigError } from "./types.js";

function signalCode(signal: NodeJS.Signals | null): number {
const n = signal ? (osConstants.signals as Record<string, number>)[signal] : undefined;
return 128 + (n ?? 1);
}

/** This bundle is <launcherDir>/../dist/gh.js — the launcher dir is the one
* PATH entry we must skip when hunting the REAL gh (recursion guard). */
export function ownLauncherDir(bundleUrl: string): string | undefined {
try {
return join(dirname(dirname(fileURLToPath(bundleUrl))), "launcher");
} catch {
return undefined;
}
}

/** Exec the real gh with stdio inherited (gh's interactive prompts must work)
* and signals forwarded; exit code passes through verbatim. */
export async function runRealGh(
argv: string[],
env: NodeJS.ProcessEnv,
token: string | undefined,
bundleUrl: string,
): Promise<number> {
const gh = resolveRealGh(env.PATH, ownLauncherDir(bundleUrl));
if (!gh) {
console.error("gh: command not found (amico-gh shim: real gh not on PATH after its own dir — install the GitHub CLI)");
return 127;
}
const childEnv: Record<string, string> = {};
for (const [k, v] of Object.entries(env)) if (v !== undefined) childEnv[k] = v;
if (token !== undefined) childEnv.GH_TOKEN = token; // the token's ONLY carriage
return new Promise<number>((resolve) => {
const child = spawn(gh, argv, { stdio: "inherit", env: childEnv });
const forward = (sig: NodeJS.Signals) => () => child.kill(sig);
process.on("SIGINT", forward("SIGINT"));
process.on("SIGTERM", forward("SIGTERM"));
child.on("error", (e) => {
console.error(`amico-gh: failed to start gh: ${(e as NodeJS.ErrnoException).code ?? "spawn error"}`);
resolve(127);
});
child.on("close", (code, signal) => resolve(code ?? signalCode(signal)));
});
}

/** Unconfigured → transparent passthrough (no file read, no env change);
* configured → mint/reuse an installation token and arm GH_TOKEN. */
export async function ghShimMain(
argv: string[],
env: NodeJS.ProcessEnv = process.env,
bundleUrl: string = import.meta.url,
): Promise<number> {
if (!existsSync(githubAppConfigFile(env))) return runRealGh(argv, env, undefined, bundleUrl);
try {
const { token } = await ensureInstallationToken({ env });
return await runRealGh(argv, env, token, bundleUrl);
} catch (e) {
if (e instanceof ConfigError) {
console.error(`amico-gh: ${e.message}`);
return 64;
}
throw e;
}
}
43 changes: 43 additions & 0 deletions packages/amico-run/src/git_credential.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
// packages/amico-run/src/git_credential.ts — git credential helper logic
// (issue #399). Registered for github.com https remotes via GIT_CONFIG env
// the extension injects ONLY when the GitHub App connection is configured,
// so `git push` authenticates as the App while commit AUTHORSHIP stays the
// researcher's (the bot-PRs/human-commits split).
//
// Protocol (git-credential(7)): git feeds key=value lines on stdin until a
// blank line; a helper answers by PRINTING username=/password= lines. Silence
// + exit 0 means "no opinion" — git falls through to the next helper / ssh,
// which is exactly what we do when unconfigured, when the remote is not
// https github.com, or when minting fails (a credential helper must never
// BLOCK auth; a stderr note is the honest trace, stdout stays protocol-clean).
import { existsSync } from "node:fs";
import { ensureInstallationToken, githubAppConfigFile } from "./github_app.js";
import { ConfigError } from "./types.js";

/** Parse the helper request: protocol + host are all we route on. */
export function parseCredentialRequest(input: string): { protocol?: string; host?: string } {
const out: { protocol?: string; host?: string } = {};
for (const line of input.split("\n")) {
if (line === "") break; // blank line ends the request
const m = line.match(/^(protocol|host)=(.*)$/);
if (m) out[m[1] as "protocol" | "host"] = m[2];
}
return out;
}

export async function credentialMain(input: string, env: NodeJS.ProcessEnv = process.env): Promise<{ stdout: string; code: number }> {
const req = parseCredentialRequest(input);
if (req.protocol !== "https" || req.host !== "github.com") return { stdout: "", code: 0 };
if (!existsSync(githubAppConfigFile(env))) return { stdout: "", code: 0 };
try {
const { token } = await ensureInstallationToken({ env });
// The token's stdout carriage IS the protocol contract — nothing else may print.
return { stdout: `username=x-access-token\npassword=${token}\n`, code: 0 };
} catch (e) {
if (e instanceof ConfigError) {
console.error(`amico-git-credential: ${e.message}`);
return { stdout: "", code: 0 }; // fall through, never block auth
}
throw e;
}
}
21 changes: 21 additions & 0 deletions packages/amico-run/src/git_credential_cli.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// The `amico-git-credential` bin entry point (issue #399). Thin (the
// pasqal_cli.ts split): slurp stdin (the git-credential request), hand it to
// credentialMain(), write the protocol answer. The installation token's ONLY
// carriage is protocol stdout; errors are one token-free stderr line.
import { credentialMain } from "./git_credential.js";

let input = "";
process.stdin.setEncoding("utf8");
process.stdin.on("data", (c: string) => (input += c));
process.stdin.on("end", () => {
credentialMain(input).then(
({ stdout, code }) => {
process.stdout.write(stdout);
process.exitCode = code;
},
(e) => {
console.error(`amico-git-credential: unexpected error: ${e instanceof Error ? (e.stack ?? e.message) : e}`);
process.exitCode = 0; // never block auth — git falls through
},
);
});
Loading
Loading