-
Notifications
You must be signed in to change notification settings - Fork 2
feat(github-app): validator for Connections panel (#403) #406
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,124 @@ | ||
| // packages/amico-run/src/github_validate.ts — validator for the GitHub App | ||
| // connection panel (#403). Mirrors the pasqal_launch.ts / company-compute | ||
| // probe pattern: the panel collects app_id, installation_id, PEM file, writes | ||
| // ~/.amico/github.json + ~/.amico/github-app.pem (0600), and this validator | ||
| // exercises the REAL mint path (JWT → GET /app, installation-token mint) so | ||
| // the status card can report connected / invalid / unconfigured. | ||
| // | ||
| // SECURITY: same token-free stance as github_app.ts — the PEM and any minted | ||
| // token never appear in an error, log, or argv. The validator's ONLY carriage | ||
| // for secrets is the file it reads and the Authorization header it sends. | ||
|
|
||
| import { readFileSync } from "node:fs"; | ||
| import { ConfigError } from "./types.js"; | ||
| import { | ||
| fetchInstallationToken, | ||
| githubAppConfigFile, | ||
| mintAppJwt, | ||
| readGithubAppConfig, | ||
| type FetchImpl, | ||
| } from "./github_app.js"; | ||
|
|
||
| export type ValidateOutcome = | ||
| | { ok: true; appId: string; installationId: string } | ||
| | { ok: false; error: string }; | ||
|
|
||
| /** Validate the GitHub App connection by exercising the real mint path. | ||
| * Reads the file contract written by the panel (or hand-written for headless), | ||
| * mints a JWT, and attempts the installation-token mint. No network in tests — | ||
| * fetchImpl is injectable (the pasqal validator pattern). */ | ||
| export async function validateGithubAppConnection(opts: { | ||
| env?: NodeJS.ProcessEnv; | ||
| fetchImpl?: FetchImpl; | ||
| nowMs?: number; | ||
| } = {}): Promise<ValidateOutcome> { | ||
| const env = opts.env ?? process.env; | ||
| let cfg: ReturnType<typeof readGithubAppConfig>; | ||
| try { | ||
| cfg = readGithubAppConfig(env); | ||
| } catch (e) { | ||
| return { ok: false, error: e instanceof ConfigError ? e.message : String(e) }; | ||
| } | ||
| let pem: string; | ||
| try { | ||
| pem = readFileSync(cfg.pemPath, "utf8"); | ||
| } catch { | ||
| return { | ||
| ok: false, | ||
| error: `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`, | ||
| }; | ||
| } | ||
| let jwt: string; | ||
| try { | ||
| jwt = mintAppJwt(cfg.appId, pem, opts.nowMs ?? Date.now()); | ||
| } catch (e) { | ||
| return { ok: false, error: e instanceof ConfigError ? e.message : String(e) }; | ||
| } | ||
| // Also probe GET /app to confirm the App identity itself is valid — mirrors | ||
| // #403 AC2 (JWT signature + GET /app, then installation-token mint). | ||
| const fetchImpl = opts.fetchImpl ?? (fetch as unknown as FetchImpl); | ||
| try { | ||
| const appRes = await fetchImpl("https://api.github.com/app", { | ||
| method: "GET", | ||
| headers: { | ||
| Authorization: `Bearer ${jwt}`, | ||
| Accept: "application/vnd.github+json", | ||
| "X-GitHub-Api-Version": "2022-11-28", | ||
| }, | ||
| signal: AbortSignal.timeout(15_000), | ||
| }); | ||
| if (appRes.status === 401 || appRes.status === 403 || appRes.status === 404) { | ||
| return { | ||
| ok: false, | ||
| error: `GitHub App not found or PEM mismatch (HTTP ${appRes.status}) — check app_id and that the PEM matches the App; or remove the credential file to fall back to your own gh login`, | ||
| }; | ||
| } | ||
| if (appRes.status < 200 || appRes.status >= 300) { | ||
| return { ok: false, error: `GitHub App check failed (HTTP ${appRes.status}) — retry, or remove the credential file to fall back to your own gh login` }; | ||
| } | ||
| } catch { | ||
| return { ok: false, error: "GitHub App check did not answer within 15s — retry, or remove the credential file to fall back to your own gh login" }; | ||
| } | ||
| try { | ||
| await fetchInstallationToken(jwt, cfg.installationId, fetchImpl); | ||
| } catch (e) { | ||
| return { ok: false, error: e instanceof ConfigError ? e.message : String(e) }; | ||
| } | ||
| return { ok: true, appId: cfg.appId, installationId: cfg.installationId }; | ||
| } | ||
|
|
||
| /** Write the GitHub App credential file + PEM atomically (0600) — the panel's | ||
| * writer. Mirrors the pasqal credential writer's atomic 0600 discipline. */ | ||
| export function writeGithubAppCredentials(opts: { | ||
| env?: NodeJS.ProcessEnv; | ||
| appId: string; | ||
| installationId: string; | ||
| pemPath: string; | ||
| pemBody: string; | ||
| }): void { | ||
| const env = opts.env ?? process.env; | ||
| const file = githubAppConfigFile(env); | ||
| // PEM first so a crash mid-write never leaves a config pointing at a missing PEM | ||
| const { mkdirSync, writeFileSync, chmodSync, renameSync } = require("node:fs") as typeof import("node:fs"); | ||
| const { dirname, join } = require("node:path") as typeof import("node:path"); | ||
| const { homedir } = require("node:os") as typeof import("node:os"); | ||
| // Resolve PEM path — if caller passed a relative or default, expand; else use exactly | ||
| const pemFile = opts.pemPath.startsWith("~") | ||
| ? join(homedir(), opts.pemPath.slice(1).replace(/^\//, "")) | ||
| : opts.pemPath; | ||
| mkdirSync(dirname(pemFile), { recursive: true }); | ||
| const pemTmp = `${pemFile}.tmp-${process.pid}`; | ||
| writeFileSync(pemTmp, opts.pemBody, { mode: 0o600 }); | ||
| chmodSync(pemTmp, 0o600); | ||
| renameSync(pemTmp, pemFile); | ||
|
|
||
| mkdirSync(dirname(file), { recursive: true }); | ||
| const tmp = `${file}.tmp-${process.pid}`; | ||
| writeFileSync( | ||
| tmp, | ||
| JSON.stringify({ app_id: opts.appId, installation_id: opts.installationId, pem_path: pemFile }, null, 2) + "\n", | ||
| { mode: 0o600 }, | ||
| ); | ||
| chmodSync(tmp, 0o600); | ||
| renameSync(tmp, file); | ||
|
Comment on lines
+101
to
+123
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift Keep the active PEM and configuration as one recoverable credential generation. Line 113 replaces the active PEM before Lines 115-123 replace Write each new PEM to a new generation-specific path. Commit 🤖 Prompt for AI Agents |
||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
Repository: harmoniqs/amicode
Length of output: 9356
🏁 Script executed:
Repository: harmoniqs/amicode
Length of output: 50373
🏁 Script executed:
Repository: harmoniqs/amicode
Length of output: 39476
🏁 Script executed:
Repository: harmoniqs/amicode
Length of output: 235
Fix the credential writer’s ES module and path handling.
require(...)calls. This package uses ES modules, sorequireis undefined whenwriteGithubAppCredentialsruns.pemPathvalues to absolute paths before storingpem_path; consumers use the stored value directly.🤖 Prompt for AI Agents