diff --git a/packages/amico-run/esbuild.config.mjs b/packages/amico-run/esbuild.config.mjs index 3375adc9..6da1a7d2 100644 --- a/packages/amico-run/esbuild.config.mjs +++ b/packages/amico-run/esbuild.config.mjs @@ -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 }); diff --git a/packages/amico-run/launcher/amico-git-credential b/packages/amico-run/launcher/amico-git-credential new file mode 100755 index 00000000..f7c426ba --- /dev/null +++ b/packages/amico-run/launcher/amico-git-credential @@ -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 0 +fi +exec node "$DIR/../dist/amico-git-credential.js" "$@" diff --git a/packages/amico-run/launcher/gh b/packages/amico-run/launcher/gh new file mode 100755 index 00000000..e9326dbf --- /dev/null +++ b/packages/amico-run/launcher/gh @@ -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" "$@" diff --git a/packages/amico-run/package.json b/packages/amico-run/package.json index cc429224..d4e76460 100644 --- a/packages/amico-run/package.json +++ b/packages/amico-run/package.json @@ -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" diff --git a/packages/amico-run/src/gh_cli.ts b/packages/amico-run/src/gh_cli.ts new file mode 100644 index 00000000..4479acf6 --- /dev/null +++ b/packages/amico-run/src/gh_cli.ts @@ -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; + }, +); diff --git a/packages/amico-run/src/gh_shim.ts b/packages/amico-run/src/gh_shim.ts new file mode 100644 index 00000000..549db6fe --- /dev/null +++ b/packages/amico-run/src/gh_shim.ts @@ -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)[signal] : undefined; + return 128 + (n ?? 1); +} + +/** This bundle is /../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 { + 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 = {}; + 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((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 { + 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; + } +} diff --git a/packages/amico-run/src/git_credential.ts b/packages/amico-run/src/git_credential.ts new file mode 100644 index 00000000..a0c7503e --- /dev/null +++ b/packages/amico-run/src/git_credential.ts @@ -0,0 +1,50 @@ +// 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 raw of input.split("\n")) { + const line = raw.endsWith("\r") ? raw.slice(0, -1) : raw; + 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, + operation: string | undefined = "get", +): Promise<{ stdout: string; code: number }> { + // git calls store/erase too; only `get` may produce credentials. + if (operation !== "get") return { stdout: "", code: 0 }; + 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; + } +} diff --git a/packages/amico-run/src/git_credential_cli.ts b/packages/amico-run/src/git_credential_cli.ts new file mode 100644 index 00000000..dc75bdc4 --- /dev/null +++ b/packages/amico-run/src/git_credential_cli.ts @@ -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, process.env, process.argv[2]).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 + }, + ); +}); diff --git a/packages/amico-run/src/github_app.ts b/packages/amico-run/src/github_app.ts new file mode 100644 index 00000000..1e39023e --- /dev/null +++ b/packages/amico-run/src/github_app.ts @@ -0,0 +1,298 @@ +// packages/amico-run/src/github_app.ts +// GitHub App identity core (issue #399): Amicode's own `amico[bot]` face on +// GitHub, replacing "every gh call rides the researcher's personal login". +// +// The contract mirrors the Pasqal credential stance (pasqal_launch.ts): a +// JSON credential file at ~/.amico/github.json (keys: app_id, installation_id, +// pem_path), an env override for tests ($AMICO_GITHUB_FILE), and TOKEN-FREE +// actionable errors — here the secrets are the PEM and the installation +// token, and neither may ever appear in an error, a log line, or argv. The +// token's ONLY carriage is the GH_TOKEN env of the real `gh` child (gh_cli.ts) +// and the stdout git-credential protocol of git_credential_cli.ts. +// +// Token lifecycle: RS256 JWT (app id + 3-minute window) → POST +// /app/installations/{id}/access_tokens → {token, expires_at}, cached at +// ~/.amico/github-token.json (0600, atomic write) and reused until +// REUSE_SKEW_SECONDS of life remain. A cache that is absent or corrupt is +// never an error — it is re-minted. Only CONFIG faults (missing keys, +// unreadable PEM, mint rejected) are exit-64-class. +import { createPrivateKey, sign as cryptoSign, verify as cryptoVerify, generateKeyPairSync, KeyObject } from "node:crypto"; +import { accessSync, chmodSync, constants as fsConstants, existsSync, mkdirSync, readFileSync, realpathSync, renameSync, statSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; +import { ConfigError } from "./types.js"; + +/** GitHub caps App JWT lifetimes at 10 minutes; 3 minutes is ample for one + * mint, and a short window keeps a leaked JWT useless quickly. */ +export const JWT_LIFETIME_SECONDS = 180; +/** GitHub's documented clock tolerance: issue the JWT 60s in the past so a + * slightly-ahead API server still accepts it. */ +export const JWT_ISSUED_AT_SKEW_SECONDS = 60; +/** Reuse a cached installation token only while this much life remains + * (GitHub tokens live 1 hour). 5 minutes absorbs one gh invocation's + * runtime without ever serving a token that dies mid-command. */ +export const REUSE_SKEW_SECONDS = 300; + +export interface GithubAppConfig { + appId: string; + installationId: string; + pemPath: string; +} + +export interface InstallationToken { + token: string; // ghs_… — secret; env/stdout carriage ONLY + expiresAt: string; // ISO 8601 (GitHub returns UTC); not a secret + appId?: string; // cache identity, not a secret + installationId?: string; // cache identity, not a secret +} + +/** $AMICO_GITHUB_FILE overrides the config path (tests / sandbox isolation) — + * the pasqalCredentialFile idiom. */ +export function githubAppConfigFile(env: NodeJS.ProcessEnv = process.env): string { + const v = env.AMICO_GITHUB_FILE; + if (v && v.trim() !== "") return v; + return join(homedir(), ".amico", "github.json"); +} + +/** $AMICO_GITHUB_TOKEN_FILE overrides the cache path (tests). */ +export function githubTokenCacheFile(env: NodeJS.ProcessEnv = process.env): string { + const v = env.AMICO_GITHUB_TOKEN_FILE; + if (v && v.trim() !== "") return v; + return join(homedir(), ".amico", "github-token.json"); +} + +/** Shape-check a parsed config. Errors name KEYS and the FILE only — a value + * could be a mistyped path to the secret. Pure (no fs) so tests never touch + * a real credential file. */ +export function parseGithubAppConfig(raw: unknown, file: string): GithubAppConfig { + if (typeof raw !== "object" || raw === null) + throw new ConfigError(`malformed GitHub App credential file at ${file} — expected a JSON object`); + const d = raw as Record; + if (typeof d.app_id !== "string" || d.app_id === "" || typeof d.installation_id !== "string" || d.installation_id === "") + throw new ConfigError( + `GitHub App credential file at ${file} needs non-empty string keys "app_id" and "installation_id" — re-add the GitHub connection to rewrite it`, + ); + if (typeof d.pem_path !== "string" || d.pem_path === "") + throw new ConfigError( + `GitHub App credential file at ${file} needs a non-empty string key "pem_path" pointing at the App's PEM private key — re-add the GitHub connection to rewrite it`, + ); + return { appId: d.app_id, installationId: d.installation_id, pemPath: d.pem_path }; +} + +/** Read + parse the config file. Distinct ConfigError per failure mode. */ +export function readGithubAppConfig(env: NodeJS.ProcessEnv = process.env): GithubAppConfig { + const file = githubAppConfigFile(env); + if (!existsSync(file)) + throw new ConfigError( + `not connected — no GitHub App credential file at ${file} (remove the expectation or re-add the connection)`, + ); + let raw: unknown; + try { + raw = JSON.parse(readFileSync(file, "utf8")); + } catch { + throw new ConfigError(`malformed GitHub App credential file at ${file} — re-add the GitHub connection to rewrite it`); + } + return parseGithubAppConfig(raw, file); +} + +function b64url(s: string): string { + return Buffer.from(s, "utf8").toString("base64url"); +} + +/** Mint the App JWT (pure: pem as string, injectable clock). RS256 per + * GitHub's App auth; claims iss=app_id, iat=now-60, exp=iat+180. */ +export function mintAppJwt(appId: string, pem: string, nowMs: number = Date.now()): string { + const header = b64url(JSON.stringify({ alg: "RS256", typ: "JWT" })); + const iat = Math.floor(nowMs / 1000) - JWT_ISSUED_AT_SKEW_SECONDS; + const exp = iat + JWT_LIFETIME_SECONDS; + const payload = b64url(JSON.stringify({ iat, exp, iss: appId })); + const signingInput = `${header}.${payload}`; + let key: KeyObject; + try { + key = createPrivateKey(pem); + } catch { + // The PEM text IS the secret — the error names the path, never the key. + throw new ConfigError(`unreadable PEM private key — re-download it from the GitHub App's settings page and rewrite pem_path`); + } + const signature = cryptoSign("sha256", Buffer.from(signingInput, "utf8"), key).toString("base64url"); + return `${signingInput}.${signature}`; +} + +/** Verify an App JWT against a public key (test-side assertion helper). */ +export function verifyAppJwt(jwt: string, publicKey: KeyObject): boolean { + const [h, p, s] = jwt.split("."); + if (!h || !p || !s) return false; + return cryptoVerify("sha256", Buffer.from(`${h}.${p}`, "utf8"), publicKey, Buffer.from(s, "base64url")); +} + +/** Test helper: a throwaway RSA keypair, so fixtures never ship a real PEM. */ +export function testKeyPair(): { privateKeyPem: string; publicKey: KeyObject } { + const { privateKey, publicKey } = generateKeyPairSync("rsa", { modulusLength: 2048 }); + return { privateKeyPem: privateKey.export({ type: "pkcs1", format: "pem" }).toString(), publicKey }; +} + +/** True while the cached token has more than REUSE_SKEW_SECONDS of life. */ +export function isCacheFresh(cache: InstallationToken, nowMs: number = Date.now(), skewSeconds = REUSE_SKEW_SECONDS): boolean { + const exp = Date.parse(cache.expiresAt); + return !Number.isNaN(exp) && exp - nowMs > skewSeconds * 1000; +} + +/** Parse the access-token endpoint's body. The token never lands in an error. */ +export function parseInstallationToken(body: unknown): InstallationToken { + let d: unknown; + if (typeof body === "string") { + try { + d = JSON.parse(body); + } catch { + throw new ConfigError("GitHub App token mint returned a malformed body — retry, or remove the credential file to fall back to your own gh login"); + } + } else { + d = body; + } + const o = (typeof d === "object" && d !== null ? d : {}) as Record; + if (typeof o.token !== "string" || o.token === "" || typeof o.expires_at !== "string" || o.expires_at === "") + throw new ConfigError("GitHub App token mint returned no token/expiry — check the App's installation, or remove the credential file to fall back to your own gh login"); + return { token: o.token, expiresAt: o.expires_at }; +} + +export type FetchImpl = ( + url: string, + init: { method: string; headers: Record; signal?: AbortSignal }, +) => Promise<{ status: number; json(): Promise }>; + +/** One mint must never outlive a human's patience for `gh` or `git push`. */ +export const MINT_TIMEOUT_MS = 15_000; + +/** POST /app/installations/{id}/access_tokens with the JWT as bearer. */ +export async function fetchInstallationToken(jwt: string, installationId: string, fetchImpl: FetchImpl): Promise { + let res: { status: number; json(): Promise }; + try { + res = await fetchImpl(`https://api.github.com/app/installations/${encodeURIComponent(installationId)}/access_tokens`, { + method: "POST", + headers: { + Authorization: `Bearer ${jwt}`, // secret: header carriage ONLY + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + }, + signal: AbortSignal.timeout(MINT_TIMEOUT_MS), + }); + } catch { + throw new ConfigError( + `GitHub App token mint did not answer within ${MINT_TIMEOUT_MS / 1000}s — retry, or remove the credential file to fall back to your own gh login`, + ); + } + if (res.status !== 201) + throw new ConfigError( + `GitHub App token mint failed (HTTP ${res.status}) — check app_id/installation_id and that the PEM matches the App; or remove the credential file to fall back to your own gh login`, + ); + let body: unknown; + try { + body = await res.json(); + } catch { + throw new ConfigError("GitHub App token mint returned a malformed body — retry, or remove the credential file to fall back to your own gh login"); + } + return parseInstallationToken(body); +} + +/** Cache read: absent or corrupt → undefined, NEVER an error (the cache is an + * optimization; a bad cache is re-minted, not diagnosed). */ +export function readTokenCache(env: NodeJS.ProcessEnv = process.env): InstallationToken | undefined { + const file = githubTokenCacheFile(env); + if (!existsSync(file)) return undefined; + try { + const d = JSON.parse(readFileSync(file, "utf8")) as Record; + if (typeof d.token === "string" && d.token !== "" && typeof d.expiresAt === "string" && d.expiresAt !== "") { + const out: InstallationToken = { token: d.token, expiresAt: d.expiresAt }; + if (typeof d.appId === "string" && d.appId !== "") out.appId = d.appId; + if (typeof d.installationId === "string" && d.installationId !== "") out.installationId = d.installationId; + // legacy cache without identity fields is still usable (backward compat) + return out; + } + return undefined; + } catch { + return undefined; + } +} + +/** Atomic 0600 cache write (tmp + rename, the esbuild-staging idiom). */ +export function writeTokenCache(cache: InstallationToken, env: NodeJS.ProcessEnv = process.env): void { + const file = githubTokenCacheFile(env); + mkdirSync(dirname(file), { recursive: true }); + const tmp = `${file}.tmp-${process.pid}`; + writeFileSync(tmp, JSON.stringify(cache, null, 2) + "\n", { mode: 0o600 }); + chmodSync(tmp, 0o600); // the tmp file's mode must survive however it was created + renameSync(tmp, file); +} + +/** The seams ensureInstallationToken needs; all injectable, all defaulted. */ +export interface EnsureDeps { + env?: NodeJS.ProcessEnv; + nowMs?: () => number; + fetchImpl?: FetchImpl; + readPem?: (path: string) => string; +} + +/** Cache-first token acquisition: fresh cache → reuse; else mint (config → + * JWT → API) → cache → return. The single entry point both CLIs share. */ +export async function ensureInstallationToken(deps: EnsureDeps = {}): Promise { + const env = deps.env ?? process.env; + const now = deps.nowMs ?? Date.now; + // Read config first so an unreadable config is an error even on a fresh cache + // (config faults are exit-64-class) and so we can key the cache by identity. + const cfg = readGithubAppConfig(env); + const cached = readTokenCache(env); + if ( + cached && + isCacheFresh(cached, now()) && + (cached.appId === undefined || cached.appId === cfg.appId) && + (cached.installationId === undefined || cached.installationId === cfg.installationId) + ) + return cached; + let pem: string; + try { + pem = (deps.readPem ?? ((p: string) => readFileSync(p, "utf8")))(cfg.pemPath); + } catch { + throw new ConfigError( + `PEM private key not found or unreadable at ${cfg.pemPath} — re-download it from the GitHub App's settings page and fix pem_path, or remove the credential file to fall back to your own gh login`, + ); + } + const jwt = mintAppJwt(cfg.appId, pem, now()); + const token = await fetchInstallationToken(jwt, cfg.installationId, deps.fetchImpl ?? (fetch as unknown as FetchImpl)); + const toCache: InstallationToken = { ...token, appId: cfg.appId, installationId: cfg.installationId }; + writeTokenCache(toCache, env); + return toCache; +} + +/** Scan PATH for the REAL gh, skipping every alias of THIS shim — a bare exec + * would recurse. The skip is by realpath of the candidate gh FILE against the + * realpath of our own launcher script, so it covers both the launcher dir + * itself AND symlink aliases planted in other PATH dirs (node_modules/.bin + * style). Pure; nonexistent candidates fall back to their own path. */ +export function resolveRealGh(pathValue: string | undefined, ownLauncherDir: string | undefined): string | undefined { + const own = ownLauncherDir ? realPathOrSelf(join(ownLauncherDir, "gh")) : undefined; + for (const dir of (pathValue ?? "").split(":").filter(Boolean)) { + const candidate = join(dir, "gh"); + if (!isExecutableFile(candidate)) continue; + if (own && realPathOrSelf(candidate) === own) continue; + return candidate; + } + return undefined; +} + +function isExecutableFile(p: string): boolean { + try { + if (!statSync(p).isFile()) return false; + accessSync(p, fsConstants.X_OK); + return true; + } catch { + return false; + } +} + +function realPathOrSelf(p: string): string { + try { + return realpathSync(p); + } catch { + return p; + } +} diff --git a/packages/amico-run/test/gh_cli.test.ts b/packages/amico-run/test/gh_cli.test.ts new file mode 100644 index 00000000..661d4507 --- /dev/null +++ b/packages/amico-run/test/gh_cli.test.ts @@ -0,0 +1,116 @@ +// The `gh` shim bundle (issue #399) — end-to-end through dist/gh.js with a +// STUB real-gh on PATH (records argv + GH_TOKEN, exits with a marker code). +// Hermetic: the GitHub App config/token-cache files point into temp dirs, and +// the configured cases use a PREFILLED fresh cache so no network is touched. +import { describe, it, expect, beforeAll } from "vitest"; +import { execFileSync, spawnSync } from "node:child_process"; +import { chmodSync, mkdirSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { testKeyPair } from "../src/github_app.js"; +import { tmpRoot } from "./helpers.js"; + +const ROOT = join(__dirname, ".."); +const BUNDLE = join(ROOT, "dist", "gh.js"); +beforeAll(() => { + execFileSync("node", [join(ROOT, "esbuild.config.mjs")], { cwd: ROOT }); +}); + +/** A fake "real gh": dumps {argv, ghToken} as JSON, exits 7 (marker passthrough). */ +function stubGh(dir: string): string { + const bin = join(dir, "gh"); + // node -e: process.argv is [node, ...args] — argv[1] is the FIRST real arg. + writeFileSync( + bin, + `#!/bin/sh\nnode -e 'console.log(JSON.stringify({argv: process.argv.slice(1), ghToken: process.env.GH_TOKEN ?? null}))' "$@"\nexit 7\n`, + ); + chmodSync(bin, 0o755); + return bin; +} + +function runShim(argv: string[], env: Record): { code: number; stdout: string; stderr: string } { + const r = spawnSync("node", [BUNDLE, ...argv], { encoding: "utf8", env: { ...process.env, ...env } }); + return { code: r.status ?? -1, stdout: r.stdout ?? "", stderr: r.stderr ?? "" }; +} + +function stubOut(r: { stdout: string }): { argv: string[]; ghToken: string | null } { + return JSON.parse(r.stdout.split("\n").filter((l) => l.startsWith("{"))[0]); +} + +function setup() { + const root = tmpRoot(); + const binDir = join(root, "bin"); + mkdirSync(binDir, { recursive: true }); + stubGh(binDir); + const { privateKeyPem } = testKeyPair(); + const pemFile = join(root, "k.pem"); + writeFileSync(pemFile, privateKeyPem); + const configFile = join(root, "github.json"); + const cacheFile = join(root, "tok.json"); + // The stub dir leads PATH but the real PATH follows: the stub's own `node` + // invocation (and libuv's child-binary lookup) must still resolve. + const baseEnv = { + PATH: `${binDir}:${process.env.PATH ?? ""}`, + AMICO_GITHUB_FILE: configFile, + AMICO_GITHUB_TOKEN_FILE: cacheFile, + }; + return { root, binDir, pemFile, configFile, cacheFile, baseEnv }; +} + +describe("gh shim (bundle)", () => { + it("unconfigured → transparent passthrough: argv verbatim, NO GH_TOKEN, exit code kept", () => { + const { configFile, baseEnv } = setup(); + // configFile intentionally never written — the not-connected state. + const r = runShim(["pr", "view", "123"], baseEnv); + const out = stubOut(r); + expect(out.argv).toEqual(["pr", "view", "123"]); + expect(out.ghToken).toBeNull(); + expect(r.code).toBe(7); + }); + + it("configured + fresh cached token → child env carries GH_TOKEN, argv verbatim", () => { + const { pemFile, configFile, cacheFile, baseEnv } = setup(); + writeFileSync(configFile, JSON.stringify({ app_id: "1", installation_id: "2", pem_path: pemFile })); + writeFileSync(cacheFile, JSON.stringify({ token: "ghs_test_bundle", expiresAt: new Date(Date.now() + 3600_000).toISOString() })); + const r = runShim(["issue", "list"], baseEnv); + const out = stubOut(r); + expect(out.argv).toEqual(["issue", "list"]); + expect(out.ghToken).toBe("ghs_test_bundle"); + expect(r.code).toBe(7); + }); + + it("configured but garbage PEM → exit 64, one token-free stderr line", () => { + const { root, configFile, baseEnv } = setup(); + const badPem = join(root, "bad.pem"); + writeFileSync(badPem, "-----BEGIN RSA PRIVATE KEY-----\ngarbage\n-----END RSA PRIVATE KEY-----\n"); + writeFileSync(configFile, JSON.stringify({ app_id: "1", installation_id: "2", pem_path: badPem })); + // No cache file → mint path → PEM parse fails before any network. + const r = runShim(["pr", "list"], baseEnv); + expect(r.code).toBe(64); + expect(r.stderr).toMatch(/PEM/); + expect(r.stderr).not.toContain("ghs_"); + expect(r.stdout).toBe(""); + }); + + it("configured + malformed config JSON → exit 64, actionable stderr", () => { + const { configFile, baseEnv } = setup(); + writeFileSync(configFile, "{nope"); + const r = runShim(["pr", "list"], baseEnv); + expect(r.code).toBe(64); + expect(r.stderr).toMatch(/malformed/); + }); + + it("recursion guard: a gh sitting in the shim's own launcher dir is never picked", () => { + // PATH = the launcher dir + ONLY the node bin dir (so the bundle itself and + // its child lookups resolve, but no real gh exists) → 127. The file vars + // stay pointed at a nonexistent tmp path so this is the passthrough lane + // regardless of the developer's real ~/.amico state. + const root = tmpRoot(); + const r = runShim(["pr", "list"], { + PATH: `${join(ROOT, "launcher")}:${dirname(process.execPath)}`, + AMICO_GITHUB_FILE: join(root, "github.json"), + AMICO_GITHUB_TOKEN_FILE: join(root, "tok.json"), + }); + expect(r.code).toBe(127); + expect(r.stderr).toMatch(/not found/i); + }); +}); diff --git a/packages/amico-run/test/git_credential_cli.test.ts b/packages/amico-run/test/git_credential_cli.test.ts new file mode 100644 index 00000000..ba50525d --- /dev/null +++ b/packages/amico-run/test/git_credential_cli.test.ts @@ -0,0 +1,92 @@ +// The git credential helper bundle (issue #399) — end-to-end through +// dist/amico-git-credential.js over the git-credential stdin protocol. +// Hermetic: config/token-cache files live in temp dirs and the configured +// cases use a PREFILLED fresh cache, so nothing touches the network. +import { describe, it, expect, beforeAll } from "vitest"; +import { execFileSync, spawnSync } from "node:child_process"; +import { writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { testKeyPair } from "../src/github_app.js"; +import { tmpRoot } from "./helpers.js"; +import { credentialMain, parseCredentialRequest } from "../src/git_credential.js"; + +const ROOT = join(__dirname, ".."); +const BUNDLE = join(ROOT, "dist", "amico-git-credential.js"); +beforeAll(() => { + execFileSync("node", [join(ROOT, "esbuild.config.mjs")], { cwd: ROOT }); +}); + +function runHelper(input: string, env: Record): { code: number; stdout: string; stderr: string } { + const r = spawnSync("node", [BUNDLE], { input, encoding: "utf8", env: { ...process.env, ...env } }); + return { code: r.status ?? -1, stdout: r.stdout ?? "", stderr: r.stderr ?? "" }; +} + +function setup() { + const root = tmpRoot(); + const { privateKeyPem } = testKeyPair(); + const pemFile = join(root, "k.pem"); + writeFileSync(pemFile, privateKeyPem); + const configFile = join(root, "github.json"); + const cacheFile = join(root, "tok.json"); + const baseEnv = { AMICO_GITHUB_FILE: configFile, AMICO_GITHUB_TOKEN_FILE: cacheFile }; + return { root, pemFile, configFile, cacheFile, baseEnv }; +} + +describe("parseCredentialRequest", () => { + it("reads protocol/host and stops at the blank line", () => { + expect(parseCredentialRequest("protocol=https\nhost=github.com\n\npath=x\n")).toEqual({ + protocol: "https", + host: "github.com", + }); + expect(parseCredentialRequest("protocol=https\nhost=github.com")).toEqual({ protocol: "https", host: "github.com" }); + expect(parseCredentialRequest("")).toEqual({}); + }); +}); + +describe("git credential helper (unit)", () => { + it("non-https or non-github → silent no-op", async () => { + const { baseEnv } = setup(); + expect(await credentialMain("protocol=http\nhost=github.com\n\n", baseEnv)).toEqual({ stdout: "", code: 0 }); + expect(await credentialMain("protocol=https\nhost=gitlab.com\n\n", baseEnv)).toEqual({ stdout: "", code: 0 }); + }); + it("unconfigured → silent no-op (git falls through)", async () => { + const { baseEnv } = setup(); // neither file is ever written + expect(await credentialMain("protocol=https\nhost=github.com\n\n", baseEnv)).toEqual({ stdout: "", code: 0 }); + }); +}); + +describe("git credential helper (bundle)", () => { + it("configured + fresh cached token → protocol answer with the token", () => { + const { pemFile, configFile, cacheFile, baseEnv } = setup(); + writeFileSync(configFile, JSON.stringify({ app_id: "1", installation_id: "2", pem_path: pemFile })); + writeFileSync(cacheFile, JSON.stringify({ token: "ghs_test_cred", expiresAt: new Date(Date.now() + 3600_000).toISOString() })); + const r = runHelper("protocol=https\nhost=github.com\n\n", baseEnv); + expect(r.code).toBe(0); + expect(r.stdout).toBe("username=x-access-token\npassword=ghs_test_cred\n"); + }); + + it("other hosts / plain http → empty stdout, exit 0", () => { + const { pemFile, configFile, cacheFile, baseEnv } = setup(); + writeFileSync(configFile, JSON.stringify({ app_id: "1", installation_id: "2", pem_path: pemFile })); + writeFileSync(cacheFile, JSON.stringify({ token: "ghs_test_cred", expiresAt: new Date(Date.now() + 3600_000).toISOString() })); + expect(runHelper("protocol=https\nhost=example.com\n\n", baseEnv).stdout).toBe(""); + expect(runHelper("protocol=http\nhost=github.com\n\n", baseEnv).stdout).toBe(""); + }); + + it("malformed config → token-free stderr note, stdout stays protocol-clean, exit 0 (never blocks auth)", () => { + const { configFile, baseEnv } = setup(); + writeFileSync(configFile, "{nope"); + const r = runHelper("protocol=https\nhost=github.com\n\n", baseEnv); + expect(r.code).toBe(0); + expect(r.stdout).toBe(""); + expect(r.stderr).toMatch(/malformed/); + expect(r.stderr).not.toContain("ghs_"); + }); + + it("unconfigured → silence", () => { + const { baseEnv } = setup(); + const r = runHelper("protocol=https\nhost=github.com\n\n", baseEnv); + expect(r.code).toBe(0); + expect(r.stdout).toBe(""); + }); +}); diff --git a/packages/amico-run/test/github_app.test.ts b/packages/amico-run/test/github_app.test.ts new file mode 100644 index 00000000..b1f1919a --- /dev/null +++ b/packages/amico-run/test/github_app.test.ts @@ -0,0 +1,228 @@ +// GitHub App identity core (issue #399) — hermetic unit tests. No network: +// the fetch seam is a fake; keys are throwaway RSA pairs minted in-process +// (testKeyPair), so no PEM fixture ever ships. The token strings below are +// obvious fakes (ghs_test_…), and one assertion class explicitly checks that +// no error message can carry a real one. +import { describe, it, expect } from "vitest"; +import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync, statSync, symlinkSync } from "node:fs"; +import { join } from "node:path"; +import { + parseGithubAppConfig, + readGithubAppConfig, + mintAppJwt, + verifyAppJwt, + testKeyPair, + isCacheFresh, + parseInstallationToken, + fetchInstallationToken, + readTokenCache, + writeTokenCache, + ensureInstallationToken, + resolveRealGh, + type FetchImpl, +} from "../src/github_app.js"; +import { ConfigError } from "../src/types.js"; +import { tmpRoot } from "./helpers.js"; + +function fakeOk(token: string, expiresAt: string): { calls: number; impl: FetchImpl } { + const state = { calls: 0 }; + return { + get calls() { + return state.calls; + }, + impl: async () => { + state.calls++; + return { status: 201, json: async () => ({ token, expires_at: expiresAt }) }; + }, + }; +} + +const hourFromNow = () => new Date(Date.now() + 3600_000).toISOString(); + +describe("github_app config", () => { + it("parses a valid config", () => { + const cfg = parseGithubAppConfig({ app_id: "123456", installation_id: "789", pem_path: "/keys/amico.pem" }, "/f.json"); + expect(cfg).toEqual({ appId: "123456", installationId: "789", pemPath: "/keys/amico.pem" }); + }); + it("missing app_id/installation_id → ConfigError naming keys, never values", () => { + expect(() => parseGithubAppConfig({ installation_id: "1", pem_path: "/p" }, "/f.json")).toThrow(/app_id.*installation_id/); + }); + it("missing pem_path → ConfigError naming the key", () => { + expect(() => parseGithubAppConfig({ app_id: "1", installation_id: "2" }, "/f.json")).toThrow(/pem_path/); + }); + it("non-object → ConfigError", () => { + expect(() => parseGithubAppConfig("nope", "/f.json")).toThrow(ConfigError); + }); + it("readGithubAppConfig: absent file / malformed JSON / valid, via $AMICO_GITHUB_FILE", () => { + const root = tmpRoot(); + const file = join(root, "github.json"); + const env = { AMICO_GITHUB_FILE: file }; + expect(() => readGithubAppConfig(env)).toThrow(/not connected/); + writeFileSync(file, "{nope"); + expect(() => readGithubAppConfig(env)).toThrow(/malformed/); + writeFileSync(file, JSON.stringify({ app_id: "1", installation_id: "2", pem_path: join(root, "k.pem") })); + expect(readGithubAppConfig(env).appId).toBe("1"); + }); +}); + +describe("github_app JWT", () => { + it("RS256 header, GitHub claim window, verifiable signature", () => { + const { privateKeyPem, publicKey } = testKeyPair(); + const now = Date.parse("2026-08-17T12:00:00Z"); + const jwt = mintAppJwt("123456", privateKeyPem, now); + const [h, p] = jwt + .split(".") + .slice(0, 2) // the third segment is the signature — binary, not JSON + .map((x) => JSON.parse(Buffer.from(x, "base64url").toString("utf8"))); + expect(h).toEqual({ alg: "RS256", typ: "JWT" }); + expect(p.iss).toBe("123456"); + expect(p.iat).toBe(Math.floor(now / 1000) - 60); + expect(p.exp - p.iat).toBe(180); + expect(verifyAppJwt(jwt, publicKey)).toBe(true); + // Deterministic tamper: mutate a real signature byte, not padding. + const [header, payload, sig] = jwt.split("."); + const sigBuf = Buffer.from(sig, "base64url"); + sigBuf[0] ^= 0xff; + const tampered = `${header}.${payload}.${sigBuf.toString("base64url")}`; + expect(verifyAppJwt(tampered, publicKey)).toBe(false); + }); + it("garbage PEM → ConfigError that never contains key material", () => { + const pem = "-----BEGIN RSA PRIVATE KEY-----\nnot a key\n-----END RSA PRIVATE KEY-----\n"; + try { + mintAppJwt("1", pem); + expect.unreachable(); + } catch (e) { + expect(e).toBeInstanceOf(ConfigError); + expect((e as Error).message).not.toContain("not a key"); + } + }); +}); + +describe("github_app token cache", () => { + it("fresh / stale / boundary, with the 5-minute reuse skew", () => { + const now = Date.now(); + const at = (s: number) => new Date(now + s * 1000).toISOString(); + expect(isCacheFresh({ token: "t", expiresAt: at(600) }, now)).toBe(true); + expect(isCacheFresh({ token: "t", expiresAt: at(100) }, now)).toBe(false); + expect(isCacheFresh({ token: "t", expiresAt: at(301) }, now)).toBe(true); + expect(isCacheFresh({ token: "t", expiresAt: at(300) }, now)).toBe(false); + expect(isCacheFresh({ token: "t", expiresAt: "not-a-date" }, now)).toBe(false); + }); + it("absent → undefined; corrupt → undefined (never an error); write is 0600 + round-trips", () => { + const root = tmpRoot(); + const env = { AMICO_GITHUB_TOKEN_FILE: join(root, "tok.json") }; + expect(readTokenCache(env)).toBeUndefined(); + writeFileSync(env.AMICO_GITHUB_TOKEN_FILE, "{corrupt"); + expect(readTokenCache(env)).toBeUndefined(); + writeTokenCache({ token: "ghs_test_1", expiresAt: hourFromNow() }, env); + expect(readTokenCache(env)).toEqual({ token: "ghs_test_1", expiresAt: expect.any(String) }); + expect(statSync(env.AMICO_GITHUB_TOKEN_FILE).mode & 0o777).toBe(0o600); + }); +}); + +describe("github_app mint", () => { + it("parseInstallationToken: valid / malformed / missing keys", () => { + expect(parseInstallationToken(JSON.stringify({ token: "ghs_test_2", expires_at: hourFromNow() })).token).toBe("ghs_test_2"); + expect(() => parseInstallationToken("")).toThrow(ConfigError); + expect(() => parseInstallationToken(JSON.stringify({ token: "x" }))).toThrow(ConfigError); + }); + it("fetchInstallationToken: non-201 → token-free ConfigError naming the status", async () => { + const impl: FetchImpl = async () => ({ status: 401, json: async () => ({ message: "Bad credentials" }) }); + try { + await fetchInstallationToken("jwt", "1", impl); + expect.unreachable(); + } catch (e) { + expect(e).toBeInstanceOf(ConfigError); + expect((e as Error).message).toMatch(/HTTP 401/); + expect((e as Error).message).not.toContain("Bad credentials"); + } + }); +}); + +describe("ensureInstallationToken", () => { + it("mints once, caches, reuses while fresh, re-mints when stale", async () => { + const root = tmpRoot(); + const { privateKeyPem } = testKeyPair(); + const pemFile = join(root, "k.pem"); + writeFileSync(pemFile, privateKeyPem); + const env = { + AMICO_GITHUB_FILE: (() => { + const f = join(root, "github.json"); + writeFileSync(f, JSON.stringify({ app_id: "1", installation_id: "2", pem_path: pemFile })); + return f; + })(), + AMICO_GITHUB_TOKEN_FILE: join(root, "tok.json"), + }; + const fake = fakeOk("ghs_test_3", hourFromNow()); + const t1 = await ensureInstallationToken({ env, fetchImpl: fake.impl }); + expect(t1.token).toBe("ghs_test_3"); + expect(fake.calls).toBe(1); + expect(existsSync(env.AMICO_GITHUB_TOKEN_FILE)).toBe(true); + // Second call within freshness: served from cache, no second mint. + await ensureInstallationToken({ env, fetchImpl: fake.impl }); + expect(fake.calls).toBe(1); + // Expire the cache: re-mint, and the cache file is rewritten. + const stale = { token: "ghs_test_4", expiresAt: new Date(Date.now() - 1000).toISOString() }; + writeFileSync(env.AMICO_GITHUB_TOKEN_FILE, JSON.stringify(stale)); + const t2 = await ensureInstallationToken({ env, fetchImpl: fake.impl }); + expect(fake.calls).toBe(2); + expect(readTokenCache(env)?.token).toBe(t2.token); + }); + it("malformed config → ConfigError before any fetch", async () => { + const root = tmpRoot(); + const env = { AMICO_GITHUB_FILE: join(root, "nope.json"), AMICO_GITHUB_TOKEN_FILE: join(root, "tok.json") }; + await expect(ensureInstallationToken({ env, fetchImpl: async () => expect.unreachable() as never })).rejects.toBeInstanceOf(ConfigError); + }); +}); + +describe("resolveRealGh", () => { + it("skips the shim's own launcher dir (recursion guard) and finds the next gh", () => { + const root = tmpRoot(); + const own = join(root, "launcher"); + const other = join(root, "real-bin"); + mkdirSync(own, { recursive: true }); + mkdirSync(other, { recursive: true }); + writeFileSync(join(own, "gh"), "#!/bin/sh\nexit 99\n"); // would loop if picked + chmodSync(join(own, "gh"), 0o755); + writeFileSync(join(other, "gh"), "#!/bin/sh\nexit 0\n"); + chmodSync(join(other, "gh"), 0o755); + expect(resolveRealGh(`${own}:${other}`, own)).toBe(join(other, "gh")); + }); + it("skips a SYMLINK alias of the shim planted in another PATH dir (the .bin trap)", () => { + // The CI failure mode: a PATH dir ahead of the real gh contains a gh that + // is a symlink TO our launcher. Realpath comparison must see through it. + const root = tmpRoot(); + const own = join(root, "launcher"); + const alias = join(root, "node_modules", ".bin"); + const other = join(root, "real-bin"); + mkdirSync(own, { recursive: true }); + mkdirSync(alias, { recursive: true }); + mkdirSync(other, { recursive: true }); + writeFileSync(join(own, "gh"), "#!/bin/sh\nexit 99\n"); + chmodSync(join(own, "gh"), 0o755); + symlinkSync(join(own, "gh"), join(alias, "gh")); + writeFileSync(join(other, "gh"), "#!/bin/sh\nexit 0\n"); + chmodSync(join(other, "gh"), 0o755); + expect(resolveRealGh(`${alias}:${other}`, own)).toBe(join(other, "gh")); + }); + it("a DIFFERENT gh in an earlier dir still wins (the guard must not over-skip)", () => { + const root = tmpRoot(); + const own = join(root, "launcher"); + const other = join(root, "real-bin"); + mkdirSync(own, { recursive: true }); + mkdirSync(other, { recursive: true }); + writeFileSync(join(own, "gh"), "#!/bin/sh\nexit 99\n"); + chmodSync(join(own, "gh"), 0o755); + const realGh = join(other, "gh"); + writeFileSync(realGh, "#!/bin/sh\nexit 0\n"); + chmodSync(realGh, 0o755); + expect(resolveRealGh(`${other}:${own}`, own)).toBe(realGh); + }); + it("no gh anywhere → undefined", () => { + const root = tmpRoot(); + expect(resolveRealGh(root, join(root, "launcher"))).toBeUndefined(); + }); + it("empty PATH → undefined", () => { + expect(resolveRealGh(undefined, undefined)).toBeUndefined(); + }); +}); diff --git a/packages/extension/esbuild.config.mjs b/packages/extension/esbuild.config.mjs index 43aec4ec..0be7c97a 100644 --- a/packages/extension/esbuild.config.mjs +++ b/packages/extension/esbuild.config.mjs @@ -14,9 +14,20 @@ const watch = process.argv.includes("--watch"); // `pnpm --filter amicode build` alone must not die) — CI reds them via // scripts/assert_packaged_cli.mjs, which re-reads the same bin map. const arRoot = "../amico-run"; -const declaredBins = Object.values(JSON.parse(readFileSync(`${arRoot}/package.json`, "utf8")).bin ?? {}).map((p) => - basename(p), -); +const arPkg = JSON.parse(readFileSync(`${arRoot}/package.json`, "utf8")); +const declaredBins = Object.values(arPkg.bin ?? {}).map((p) => basename(p)); +// #399 SHADOW bins (the package's `amicode.shadowBins` map): staged into +// bin/launcher so they ride the extension's prepended PATH — but deliberately +// NOT in the npm `bin` map, because a bin-map entry makes pnpm link the name +// into node_modules/.bin where it shadows the DEVELOPER's tooling for every +// pnpm script (CI's fetch:opencode died exactly that way: its `gh` resolved to +// our shim, which re-found the .bin alias, and pnpm's wrapper grew NODE_PATH +// on every recursive pass until exec hit E2BIG). The shadowing contract is +// agent-session-only: extension bin dir, nowhere else. +const shadowBins = Object.entries(arPkg.amicode?.shadowBins ?? {}).map(([name, p]) => ({ + name, + launcher: basename(String(p)), +})); if (declaredBins.some((name) => existsSync(`${arRoot}/dist/${name}.js`))) { mkdirSync("bin/launcher", { recursive: true }); mkdirSync("bin/dist", { recursive: true }); @@ -29,6 +40,16 @@ if (declaredBins.some((name) => existsSync(`${arRoot}/dist/${name}.js`))) { cpSync(`${arRoot}/dist/${name}.js`, `bin/dist/${name}.js`, { dereference: true }); chmodSync(`bin/launcher/${name}`, 0o755); // guarantee +x survives pack/unpack } + for (const { name, launcher } of shadowBins) { + const distName = launcher.replace(/(?:\.js)?$/, "") + ".js"; + if (!existsSync(`${arRoot}/dist/${distName}`)) { + console.warn(`[esbuild] amico-run/dist/${distName} not built — shadow bin "${name}" will be absent from the package`); + continue; + } + cpSync(`${arRoot}/launcher/${launcher}`, `bin/launcher/${name}`, { dereference: true }); + cpSync(`${arRoot}/dist/${distName}`, `bin/dist/${distName}`, { dereference: true }); + chmodSync(`bin/launcher/${name}`, 0o755); + } // The CLI bundles are ESM (amico-run has "type": "module"); without a scoped // marker node warns-and-reparses on EVERY invocation (MODULE_TYPELESS_PACKAGE_JSON // on stderr — noise on a channel the run gate reserves for failures). A diff --git a/packages/extension/scripts/assert_packaged_cli.mjs b/packages/extension/scripts/assert_packaged_cli.mjs index 0dd4264b..10be98a5 100644 --- a/packages/extension/scripts/assert_packaged_cli.mjs +++ b/packages/extension/scripts/assert_packaged_cli.mjs @@ -31,7 +31,7 @@ // --bin-map the CLI package.json carrying the `bin` map // (default: packages/amico-run/package.json) import { execFile } from "node:child_process"; -import { accessSync, constants as fsConstants, existsSync, mkdtempSync, readFileSync } from "node:fs"; +import { accessSync, constants as fsConstants, existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { basename, dirname, join, resolve } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; @@ -47,16 +47,28 @@ const UNKNOWN_EXECUTOR = /unknown --executor/; /** Declared bins from the CLI package's `bin` map → staged file layout. * Staging convention (extension esbuild.config.mjs): bin key K ships as - * bin/launcher/ + bin/dist/.js. */ + * bin/launcher/ + bin/dist/.js. + * + * #399 SHADOW bins (the package's `amicode.shadowBins` map, e.g. `gh`) are + * appended with the same shape: they are staged for the agent-session PATH + * but deliberately kept OUT of the npm `bin` map (a bin-map entry would link + * `gh` into node_modules/.bin and shadow the developer's own gh for every + * pnpm script). They are gated exactly like declared bins — staged, probed, + * and fail-closed if the probe is missing. */ export function declaredBins(binMapPath = DEFAULT_BIN_MAP) { const pkg = JSON.parse(readFileSync(binMapPath, "utf8")); const bin = pkg.bin; if (!bin || typeof bin !== "object" || Object.keys(bin).length === 0) throw new Error(`${binMapPath}: no \`bin\` map — nothing to gate is a failure, not a pass`); - return Object.entries(bin).map(([name, launcherPath]) => { + const fromMap = Object.entries(bin).map(([name, launcherPath]) => { const base = basename(String(launcherPath)); return { name, launcher: join("launcher", base), dist: join("dist", `${base}.js`) }; }); + const shadow = Object.entries(pkg.amicode?.shadowBins ?? {}).map(([name, launcherPath]) => { + const base = basename(String(launcherPath)); + return { name, launcher: join("launcher", name), dist: join("dist", `${base}.js`) }; + }); + return [...fromMap, ...shadow]; } /** Per-bin behavioral probes. Each entry: a list of { check, args(missingScript), @@ -117,11 +129,50 @@ export const PROBES = { : `--help is not the amico-pasqal launcher usage (exit ${code})`, }, ], + // The gh PATH shim (#399) is probed through its CONFIG lane: the probe seeds + // a MALFORMED credential file into the scratch HOME, so a fresh bundle fails + // config-class (exit 64, the amico-gh one-liner) BEFORE exec'ing any real gh + // — deterministic with or without gh installed, no network, no secrets. A + // stale/half-staged bundle cannot produce this line. + gh: [ + { + check: "config-class rejection on malformed credential file", + args: () => ["pr", "list"], + setup: (home) => { + mkdirSync(join(home, ".amico"), { recursive: true }); + writeFileSync(join(home, ".amico", "github.json"), "{not-json"); + }, + expect: ({ code, stderr }) => + code === 64 && /amico-gh: malformed GitHub App credential file/.test(stderr) + ? null + : `did not reject a malformed credential file config-class (exit ${code}) — stale or wrong bundle`, + }, + ], + // The git credential helper (#399): same malformed-file seed, driven through + // the git-credential stdin protocol. A fresh bundle answers NOTHING on + // stdout (protocol-clean fallthrough) while TRACEING the config fault on + // stderr — proving the bundle engaged the credential lane without blocking + // auth. Silence-with-no-stderr would mean the config lane is dead. + "amico-git-credential": [ + { + check: "protocol-clean fallthrough traces the config lane", + args: () => [], + input: "protocol=https\nhost=github.com\n\n", + setup: (home) => { + mkdirSync(join(home, ".amico"), { recursive: true }); + writeFileSync(join(home, ".amico", "github.json"), "{not-json"); + }, + expect: ({ code, stdout, stderr }) => + code === 0 && stdout === "" && /amico-git-credential: malformed GitHub App credential file/.test(stderr) + ? null + : `did not trace the malformed-config fault and stay protocol-clean (exit ${code}, stdout ${JSON.stringify(stdout)})`, + }, + ], }; -function execCapture(file, args, env) { +function execCapture(file, args, env, input) { return new Promise((resolveP) => { - execFile(file, args, { env, timeout: 30_000, encoding: "utf8" }, (err, stdout, stderr) => { + const child = execFile(file, args, { env, timeout: 30_000, encoding: "utf8" }, (err, stdout, stderr) => { // err.code is the exit code for non-zero exits; spawn faults carry errno strings. const code = err ? (typeof err.code === "number" ? err.code : -1) : 0; resolveP({ @@ -131,6 +182,7 @@ function execCapture(file, args, env) { spawnError: err && typeof err.code !== "number" ? String(err.code ?? err.message) : undefined, }); }); + if (input !== undefined) child.stdin.end(input); // protocol-driven bins read stdin }); } @@ -175,7 +227,8 @@ export async function runGate({ binDir = DEFAULT_BIN_DIR, binMapPath = DEFAULT_B continue; } for (const probe of probes) { - const r = await execCapture(launcher, probe.args(missingScript), env); + probe.setup?.(scratchHome); + const r = await execCapture(launcher, probe.args(missingScript), env, probe.input); if (r.spawnError) { push(bin.name, probe.check, `launcher did not run: ${r.spawnError}`); continue; diff --git a/packages/extension/src/chat_bridge.ts b/packages/extension/src/chat_bridge.ts index 7cde437a..0c31a00b 100644 --- a/packages/extension/src/chat_bridge.ts +++ b/packages/extension/src/chat_bridge.ts @@ -46,6 +46,10 @@ export interface BridgeIo { visible(): boolean; /** Replies (clipboard text) go back to the host webview; `tab` echoes along. */ postToWebview(msg: unknown): void; + /** Local opencode server for Connections proxy — lets the webview delegate + * credential POSTs to the extension host so they succeed even while the + * chat SSE is streaming (browser per-host connection-pool starvation). */ + server?: { url: string; authorization: string }; /** Bug-session lifecycle (bug-filed / bug-report-closed). Undefined until the * manager registers at activation; the kinds are consumed regardless. */ bugReport?: BugReportSink; @@ -628,6 +632,66 @@ export function handleAmicodeBridgeMessage(msg: unknown, io: BridgeIo): boolean return true; } + // Connections proxy (chat-busy fix): the webview delegates credential + // mutations to the extension host so they don't compete with the chat SSE + // for the browser's per-host connection pool. The extension forwards via + // Node fetch with the per-boot Authorization header. + if ( + typeof msg.kind === "string" && + (msg.kind === "connections-credential" || + msg.kind === "connections-disconnect" || + msg.kind === "connections-revalidate" || + msg.kind === "connections-auth" || + msg.kind === "connections-choose-project" || + msg.kind === "connections-add-custom" || + msg.kind === "connections-remove") + ) { + const tab = (msg as { tab?: string }).tab; + const nonce = (msg as { nonce?: string }).nonce; + const routeMap: Record = { + "connections-credential": "/amicode/connections/credential", + "connections-disconnect": "/amicode/connections/disconnect", + "connections-revalidate": "/amicode/connections/revalidate", + "connections-auth": "/amicode/connections/auth", + "connections-choose-project": "/amicode/connections/choose-project", + "connections-add-custom": "/amicode/connections/add-custom", + "connections-remove": "/amicode/connections/remove", + }; + const route = routeMap[msg.kind]; + if (!route) return true; + if (!io.server) { + io.postToWebview({ source: "amicode", kind: `${msg.kind}-result`, tab, nonce, ok: false, error: "Amico server not ready" }); + return true; + } + const body = (msg as { body?: unknown }).body; + const payload = typeof body === "string" ? body : JSON.stringify(body ?? {}); + void fetch(new URL(route, io.server.url).toString(), { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: io.server.authorization }, + body: payload, + }) + .then(async (res) => { + let text: string; + try { + text = await res.text(); + } catch { + text = JSON.stringify({ ok: false, error: "proxy: failed to read response" }); + } + io.postToWebview({ source: "amicode", kind: `${msg.kind}-result`, tab, nonce, ok: res.ok, body: text, status: res.status }); + }) + .catch((e) => { + io.postToWebview({ + source: "amicode", + kind: `${msg.kind}-result`, + tab, + nonce, + ok: false, + error: e instanceof Error ? e.message : String(e), + }); + }); + return true; + } + // Data & Storage settings (#378): query resolved defaults on mount, and // update overrides (validate, write VS Code settings, restart server). if (msg.kind === "data-storage-query") { diff --git a/packages/extension/src/chat_panel.ts b/packages/extension/src/chat_panel.ts index ed6bf236..eb8a52de 100644 --- a/packages/extension/src/chat_panel.ts +++ b/packages/extension/src/chat_panel.ts @@ -73,6 +73,11 @@ export class ChatPanel { null, this.disposables, ); + // Derive the extension's server auth header from the same per-boot token + // the iframe bootstraps with — so Connections proxy POSTs carry the + // identical #163 credential even while the chat SSE is streaming. + const serverAuth = authToken ? `Basic ${authToken}` : undefined; + const serverUrl = opencodeUrl.origin; this.panel.webview.onDidReceiveMessage( (msg) => { // iframe → extension bridge: the outer webview relay (renderHtml) @@ -81,6 +86,7 @@ export class ChatPanel { const handled = handleAmicodeBridgeMessage(msg, { visible: () => this.panel.visible, postToWebview: (m) => void this.panel.webview.postMessage(m), + ...(serverAuth ? { server: { url: serverUrl, authorization: serverAuth } } : {}), // Bug-session lifecycle (#250): the dock's bug-filed / // bug-report-closed route to the window's manager (undefined until // activation registers it; the bridge consumes the kinds regardless). @@ -264,7 +270,7 @@ export class ChatPanel { replyClipboardImage(d.nonce); return; } - if (d && d.source === "amicode" && (d.kind === "command" || d.kind === "clipboard-request" || d.kind === "clipboard-write" || d.kind === "open-external" || d.kind === "open-file" || d.kind === "save-file" || d.kind === "set-default-model" || d.kind === "bug-filed" || d.kind === "bug-report-closed" || d.kind === "bug-report-poke" || d.kind === "dev-tools-update" || d.kind === "dev-tools-rebuild" || d.kind === "data-storage-query" || d.kind === "data-storage-update" || d.kind === "device:refresh")) { + if (d && d.source === "amicode" && (d.kind === "command" || d.kind === "clipboard-request" || d.kind === "clipboard-write" || d.kind === "open-external" || d.kind === "open-file" || d.kind === "save-file" || d.kind === "set-default-model" || d.kind === "bug-filed" || d.kind === "bug-report-closed" || d.kind === "bug-report-poke" || d.kind === "dev-tools-update" || d.kind === "dev-tools-rebuild" || d.kind === "data-storage-query" || d.kind === "data-storage-update" || d.kind === "device:refresh" || d.kind === "connections-credential" || d.kind === "connections-disconnect" || d.kind === "connections-revalidate" || d.kind === "connections-auth" || d.kind === "connections-choose-project" || d.kind === "connections-add-custom" || d.kind === "connections-remove")) { vscode.postMessage(d); } return; @@ -273,7 +279,7 @@ export class ChatPanel { // (webview-internal origin, never the opencode origin). Forward only // our own envelopes, pinned to the opencode origin. #351 adds // run:*/device:* envelopes for the Work Column inspector tabs. - if (d && d.source === "amicode" && (d.kind === "theme" || d.kind === "clipboard" || d.kind === "open-compute-connect" || d.kind === "open-bug-report" || d.kind === "close-bug-report" || d.kind === "dev-tools-status" || d.kind === "dev-tools-rebuild-status" || d.kind === "data-storage-defaults" || d.kind === "data-storage-status" || (typeof d.kind === "string" && (d.kind.indexOf("run:") === 0 || d.kind.indexOf("device:") === 0)) || d.kind === "clipboard-image")) { + if (d && d.source === "amicode" && (d.kind === "theme" || d.kind === "clipboard" || d.kind === "open-compute-connect" || d.kind === "open-bug-report" || d.kind === "close-bug-report" || d.kind === "dev-tools-status" || d.kind === "dev-tools-rebuild-status" || d.kind === "data-storage-defaults" || d.kind === "data-storage-status" || d.kind === "connections-credential-result" || d.kind === "connections-disconnect-result" || d.kind === "connections-revalidate-result" || d.kind === "connections-auth-result" || d.kind === "connections-choose-project-result" || d.kind === "connections-add-custom-result" || d.kind === "connections-remove-result" || (typeof d.kind === "string" && (d.kind.indexOf("run:") === 0 || d.kind.indexOf("device:") === 0)) || d.kind === "clipboard-image")) { var f = document.querySelector("iframe"); if (f && f.contentWindow) f.contentWindow.postMessage(d, ${origin}); } diff --git a/packages/extension/src/deck/shell.ts b/packages/extension/src/deck/shell.ts index 5b05e1e8..b5566e9f 100644 --- a/packages/extension/src/deck/shell.ts +++ b/packages/extension/src/deck/shell.ts @@ -407,6 +407,19 @@ window.addEventListener("message", (e) => { if ((d.kind === "dev-tools-status" || d.kind === "dev-tools-rebuild-status" || d.kind === "data-storage-defaults" || d.kind === "data-storage-status") && typeof d.tab === "string") { frameByTab.get(d.tab)?.contentWindow?.postMessage(d, boot.origin); } + if ( + typeof d.kind === "string" && + (d.kind === "connections-credential-result" || + d.kind === "connections-disconnect-result" || + d.kind === "connections-revalidate-result" || + d.kind === "connections-auth-result" || + d.kind === "connections-choose-project-result" || + d.kind === "connections-add-custom-result" || + d.kind === "connections-remove-result") && + typeof d.tab === "string" + ) { + frameByTab.get(d.tab)?.contentWindow?.postMessage(d, boot.origin); + } // #351: inspector fan-out — broadcast to every live pane (no tab routing; // the app's Work Column tabs buffer per-run/per-device themselves). if (typeof d.kind === "string" && (d.kind.indexOf("run:") === 0 || d.kind.indexOf("device:") === 0)) { @@ -478,7 +491,27 @@ window.addEventListener("message", (e) => { // Everything else rides up to the extension, tagged with the asking pane so // replies (clipboard text) route back correctly. - if (d.kind === "command" || d.kind === "clipboard-request" || d.kind === "clipboard-write" || d.kind === "open-external" || d.kind === "open-file" || d.kind === "save-file" || d.kind === "set-default-model" || d.kind === "dev-tools-update" || d.kind === "dev-tools-rebuild" || d.kind === "data-storage-query" || d.kind === "data-storage-update" || d.kind === "device:refresh") { + if ( + d.kind === "command" || + d.kind === "clipboard-request" || + d.kind === "clipboard-write" || + d.kind === "open-external" || + d.kind === "open-file" || + d.kind === "save-file" || + d.kind === "set-default-model" || + d.kind === "dev-tools-update" || + d.kind === "dev-tools-rebuild" || + d.kind === "data-storage-query" || + d.kind === "data-storage-update" || + d.kind === "device:refresh" || + d.kind === "connections-credential" || + d.kind === "connections-disconnect" || + d.kind === "connections-revalidate" || + d.kind === "connections-auth" || + d.kind === "connections-choose-project" || + d.kind === "connections-add-custom" || + d.kind === "connections-remove" + ) { vscode.postMessage({ ...d, tab: tabId }); } }); diff --git a/packages/extension/src/deck_panel.ts b/packages/extension/src/deck_panel.ts index cb0b03c2..7d6d1a52 100644 --- a/packages/extension/src/deck_panel.ts +++ b/packages/extension/src/deck_panel.ts @@ -41,6 +41,8 @@ export class DeckPanel { null, this.disposables, ); + const serverAuth = authToken ? `Basic ${authToken}` : undefined; + const serverUrl = opencodeUrl.origin; this.panel.webview.onDidReceiveMessage( (msg) => { // Pane → extension bridge: the shell tags each envelope with the asking @@ -49,6 +51,7 @@ export class DeckPanel { const handled = handleAmicodeBridgeMessage(msg, { visible: () => this.panel.visible, postToWebview: (m) => void this.panel.webview.postMessage(m), + ...(serverAuth ? { server: { url: serverUrl, authorization: serverAuth } } : {}), // Bug-session lifecycle (#250) — deck panes never carry the // amicode_bug_report boot param, so no dock lives here; wired for // uniformity (the manager drops unknown ids anyway). diff --git a/packages/extension/src/server_auth.ts b/packages/extension/src/server_auth.ts index 1753b288..b6b14dd3 100644 --- a/packages/extension/src/server_auth.ts +++ b/packages/extension/src/server_auth.ts @@ -1,4 +1,7 @@ import { randomBytes } from "node:crypto"; +import { existsSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; // ============================================================================ // Per-boot server password (#163, ADR 0002 graft 1). @@ -67,6 +70,8 @@ const SANDBOX_ENV_PASSTHROUGH = [ "AMICODE_OPS_DIR", "AMICO_PASQAL_KEYCHAIN_SERVICE", "AMICO_PASQAL_VALIDATOR", + "AMICO_GITHUB_FILE", + "AMICO_GITHUB_TOKEN_FILE", ] as const // ============================================================================ @@ -189,6 +194,35 @@ export function buildTelemetryEnv(t: TelemetryContext | undefined): Record { + if (!amicoRunBinDir) return {}; + if (!existsSync(githubAppConfigFile(env))) return {}; + return { + GIT_CONFIG_COUNT: "1", + GIT_CONFIG_KEY_0: "credential.https://github.com.helper", + GIT_CONFIG_VALUE_0: `!"${join(amicoRunBinDir, "amico-git-credential")}"`, + }; +} + export function buildServerSpawnEnv(opts: { /** amico-run launcher bin dir; undefined = launcher missing (boot warns). */ amicoRunBinDir: string | undefined; @@ -207,7 +241,12 @@ export function buildServerSpawnEnv(opts: { * buildTelemetryEnv applies the consent gate, so an un-gated context still * yields zero OTLP vars — the exporter only wakes when the gate is open. */ telemetry?: TelemetryContext; + /** Env the sandbox passthrough + the #399 credential-helper gate read. + * Default: the host env (the spawn inherits it underneath anyway). Tests + * pass a controlled env so key-set assertions stay machine-independent. */ + env?: NodeJS.ProcessEnv; }): Record { + const envSource = opts.env ?? process.env; const env: Record = { PATH: `${opts.amicoRunBinDir ? opts.amicoRunBinDir + ":" : ""}${process.env.PATH ?? ""}`, OPENCODE_CONFIG_CONTENT: opts.configContent, @@ -222,9 +261,12 @@ export function buildServerSpawnEnv(opts: { ...(opts.amicoPython ? { AMICO_PYTHON: opts.amicoPython } : {}), // Gated OTLP env (contract). {} unless enabled + consent + endpoint all hold. ...buildTelemetryEnv(opts.telemetry), + // Gated git credential helper (issue #399). {} unless the GitHub App + // connection file exists AND the launcher dir resolved. + ...buildGitCredentialHelperEnv(opts.amicoRunBinDir, envSource), }; for (const key of SANDBOX_ENV_PASSTHROUGH) { - const value = process.env[key]; + const value = envSource[key]; if (value !== undefined && value !== "") env[key] = value; } return env; diff --git a/packages/extension/test/cli_gate.test.ts b/packages/extension/test/cli_gate.test.ts index 1bae5174..c09b5e34 100644 --- a/packages/extension/test/cli_gate.test.ts +++ b/packages/extension/test/cli_gate.test.ts @@ -90,6 +90,13 @@ describe("declaredBins", () => { expect(names).toContain("amico-run"); expect(names).toContain("amico"); expect(names).toContain("amico-pasqal"); + expect(names).toContain("amico-git-credential"); + }); + it("appends the #399 shadow bins (gh) under their STAGED names — gated like declared bins", () => { + const gh = declaredBins(REAL_BIN_MAP).find((b) => b.name === "gh")!; + expect(gh).toBeDefined(); + expect(gh.launcher).toBe(join("launcher", "gh")); + expect(gh.dist).toBe(join("dist", "gh.js")); }); it("maps each bin to its staged launcher + dist bundle", () => { const run = declaredBins(REAL_BIN_MAP).find((b) => b.name === "amico-run")!; diff --git a/packages/extension/test/packaging.test.ts b/packages/extension/test/packaging.test.ts index d47cee7c..49b67645 100644 --- a/packages/extension/test/packaging.test.ts +++ b/packages/extension/test/packaging.test.ts @@ -14,6 +14,13 @@ const REQUIRED = [ "extension/bin/launcher/amico", "extension/bin/dist/amico-pasqal.js", "extension/bin/launcher/amico-pasqal", + // #399 — the GitHub App identity bins: amico-git-credential (map-declared) + // and the gh PATH shim (a SHADOW bin — staged beyond the bin map precisely + // so pnpm cannot link it into node_modules/.bin; that pin lives HERE). + "extension/bin/dist/amico-git-credential.js", + "extension/bin/launcher/amico-git-credential", + "extension/bin/dist/gh.js", + "extension/bin/launcher/gh", // Pasqal connector assets — staged to /scripts/pasqal-connector at // activation (the Connections panel's default validator path, #161). Kept in // the vsix by explicit .vscodeignore negations against scripts/**. diff --git a/packages/extension/test/server_auth.test.ts b/packages/extension/test/server_auth.test.ts index ef41091e..3efc69cd 100644 --- a/packages/extension/test/server_auth.test.ts +++ b/packages/extension/test/server_auth.test.ts @@ -1,16 +1,29 @@ import { describe, it, expect } from "vitest"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { mintServerPassword, serverAuthHeader, serverAuthToken, buildServerSpawnEnv, buildTelemetryEnv, + buildGitCredentialHelperEnv, + githubAppConfigFile, telemetryGateOpen, TELEMETRY_ENV_KEYS, type TelemetryContext, } from "../src/server_auth"; import { buildOpencodeConfigContent } from "../src/opencode_config"; +/** A controlled env for spawn-env key-set assertions: AMICO_GITHUB_FILE pinned + * at a nonexistent path, so (a) the #399 credential-helper gate reads CLOSED + * and (b) the passthrough adds exactly that one key — deterministic on any + * machine, including one with a real configured ~/.amico/github.json. */ +function hermeticGithubEnv(): { AMICO_GITHUB_FILE: string } { + return { AMICO_GITHUB_FILE: join(mkdtempSync(join(tmpdir(), "amico-auth-test-")), "github.json") }; +} + // ============================================================================ // Per-boot server password (#163, ADR 0002 graft 1). The fork's route auth // (vendored opencode, packages/opencode/src/server/auth.ts @ v1.17.3-amicode.5) @@ -69,8 +82,11 @@ describe("buildServerSpawnEnv — the env keys the extension ADDS to the spawn", it("adds EXACTLY PATH + config + password + the headless plot backends (AC1)", () => { // Exactly the ADDED keys — the server inherits the host env by platform // design (ServerManager spreads process.env under these), so full-env - // equality is a known-wrong assertion; the contract is what WE add. - expect(Object.keys(buildServerSpawnEnv(opts)).sort()).toEqual([ + // equality is a known-wrong assertion; the contract is what WE add. With + // the controlled env, that set is the base five + AMICO_GITHUB_FILE (the + // passthrough echoing exactly what we pinned; the #399 gate stays closed). + expect(Object.keys(buildServerSpawnEnv({ ...opts, env: hermeticGithubEnv() })).sort()).toEqual([ + "AMICO_GITHUB_FILE", "GKSwstype", "MPLBACKEND", "OPENCODE_CONFIG_CONTENT", @@ -97,9 +113,11 @@ describe("buildServerSpawnEnv — the env keys the extension ADDS to the spawn", it("carries AMICO_PYTHON iff amicoPython is set — absent (never empty) otherwise, so the fork's python3 fallback is untouched", () => { // The fork's validator spawn resolves $AMICO_PYTHON → bare `python3`; the // provisioned venv interpreter rides this seam so no respawn path drops it. - const withPython = buildServerSpawnEnv({ ...opts, amicoPython: "/ops/venvs/pasqal-connector/bin/python" }); + const he = hermeticGithubEnv(); + const withPython = buildServerSpawnEnv({ ...opts, amicoPython: "/ops/venvs/pasqal-connector/bin/python", env: he }); expect(withPython.AMICO_PYTHON).toBe("/ops/venvs/pasqal-connector/bin/python"); expect(Object.keys(withPython).sort()).toEqual([ + "AMICO_GITHUB_FILE", "AMICO_PYTHON", "GKSwstype", "MPLBACKEND", @@ -108,8 +126,8 @@ describe("buildServerSpawnEnv — the env keys the extension ADDS to the spawn", "PATH", ]); // unset branch byte-identical to pre-provisioning behavior (AC7) - expect("AMICO_PYTHON" in buildServerSpawnEnv(opts)).toBe(false); - expect("AMICO_PYTHON" in buildServerSpawnEnv({ ...opts, amicoPython: undefined })).toBe(false); + expect("AMICO_PYTHON" in buildServerSpawnEnv({ ...opts, env: he })).toBe(false); + expect("AMICO_PYTHON" in buildServerSpawnEnv({ ...opts, amicoPython: undefined, env: he })).toBe(false); }); it("passes the config content through verbatim (the instructions/permission merge)", () => { expect(buildServerSpawnEnv(opts).OPENCODE_CONFIG_CONTENT).toBe(opts.configContent); @@ -270,7 +288,8 @@ describe("buildServerSpawnEnv — telemetry integration (gate applied through th for (const k of TELEMETRY_ENV_KEYS) expect(k in env).toBe(false); }); it("no telemetry opt at all → identical to the pre-telemetry builder (base keys only)", () => { - expect(Object.keys(buildServerSpawnEnv(base)).sort()).toEqual([ + expect(Object.keys(buildServerSpawnEnv({ ...base, env: hermeticGithubEnv() })).sort()).toEqual([ + "AMICO_GITHUB_FILE", "GKSwstype", "MPLBACKEND", "OPENCODE_CONFIG_CONTENT", @@ -280,6 +299,60 @@ describe("buildServerSpawnEnv — telemetry integration (gate applied through th }); }); +// ============================================================================ +// GitHub App credential-helper gate (issue #399 — amico[bot]). When the App +// connection file exists, the spawn env registers the bundled +// amico-git-credential helper for https github.com via GIT_CONFIG env, so +// `git push` authenticates as the App while commit authorship stays the +// researcher's. Unconfigured → ZERO vars: git behavior byte-identical. +// ============================================================================ + +describe("buildGitCredentialHelperEnv — the #399 gate", () => { + const binDir = "/ext/bin/launcher"; + const configuredEnv = (() => { + const dir = mkdtempSync(join(tmpdir(), "amico-auth-test-")); + const file = join(dir, "github.json"); + writeFileSync(file, JSON.stringify({ app_id: "1", installation_id: "2", pem_path: join(dir, "k.pem") })); + return { AMICO_GITHUB_FILE: file }; + })(); + const unconfiguredEnv = { AMICO_GITHUB_FILE: join(mkdtempSync(join(tmpdir(), "amico-auth-test-")), "github.json") }; + + it("unconfigured → {} (git untouched — regression-safe)", () => { + expect(buildGitCredentialHelperEnv(binDir, unconfiguredEnv)).toEqual({}); + }); + it("no launcher dir → {} even when configured (boot-warn state, never a broken path)", () => { + expect(buildGitCredentialHelperEnv(undefined, configuredEnv)).toEqual({}); + }); + it("configured + launcher dir → registers the helper for https github.com by ABSOLUTE path", () => { + expect(buildGitCredentialHelperEnv(binDir, configuredEnv)).toEqual({ + GIT_CONFIG_COUNT: "1", + GIT_CONFIG_KEY_0: "credential.https://github.com.helper", + GIT_CONFIG_VALUE_0: `!"/ext/bin/launcher/amico-git-credential"`, + }); + }); + it("flows through buildServerSpawnEnv: gate open adds the three keys, gate closed adds none", () => { + const open = buildServerSpawnEnv({ + amicoRunBinDir: binDir, + configContent: "{}", + serverPassword: "pw", + env: configuredEnv, + }); + expect(open.GIT_CONFIG_KEY_0).toBe("credential.https://github.com.helper"); + expect(open.GIT_CONFIG_VALUE_0).toBe(`!"/ext/bin/launcher/amico-git-credential"`); + const closed = buildServerSpawnEnv({ + amicoRunBinDir: binDir, + configContent: "{}", + serverPassword: "pw", + env: unconfiguredEnv, + }); + expect("GIT_CONFIG_COUNT" in closed).toBe(false); + }); + it("githubAppConfigFile honors $AMICO_GITHUB_FILE (path contract shared with amico-run)", () => { + expect(githubAppConfigFile({ AMICO_GITHUB_FILE: "/x/github.json" })).toBe("/x/github.json"); + expect(githubAppConfigFile({})).toContain(join(".amico", "github.json")); + }); +}); + describe("no-persist / no-log seams (AC3) — the spawn env is the ONLY carriage", () => { // The channel scans live with the transports: server_manager.test.ts sweeps // everything ServerManager writes across a real spawned boot, and