@mmerterden/multi-agent-pipeline 20.17.0 → 20.17.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,30 +2,44 @@
2
2
  /**
3
3
  * usage-identity.mjs - the GitHub account this machine reports usage under.
4
4
  *
5
- * The reporting identity is the logged-in GitHub account that can read the
6
- * pipeline repository (`usageLog.accessRepo`, default
5
+ * The reporting identity is the GitHub account on this machine that can read
6
+ * the pipeline repository (`usageLog.accessRepo`, default
7
7
  * mmerterden/multi-agent-pipeline). Access to that repository is what grants
8
8
  * use of the pipeline, so it is also what decides whose name a run carries.
9
9
  * The account `gh` happens to have active is not used as such: a machine with
10
- * a work account and a personal account logged in reports under whichever of
11
- * the two actually holds access.
10
+ * a work account and a personal account reports under whichever of the two
11
+ * actually holds access.
12
12
  *
13
- * Resolution: every github.com account `gh auth status` lists is checked once,
14
- * in order `usageLog.reportAs` (when it is one of them), the active account,
15
- * then the rest. Each check runs `gh api repos/<accessRepo>` with that
16
- * account's token in the child's environment (GH_TOKEN), never on argv and
17
- * never written anywhere. The first account that can read the repository wins.
13
+ * Resolution stops at the first account that can read the repository:
18
14
  *
19
- * When none can, the active account (else the first) is used as a fallback and
20
- * marked so (`fallback: true`, `usageLog.loginFallback`). A work machine often
21
- * has only an enterprise-managed account logged in, which can never be a
22
- * collaborator of a personal repository; the server decides whether it accepts
23
- * that login, and refuses it with a status line when it does not. A fallback is
24
- * cached for NO_LOGIN_TTL_MS, so an account with access added later takes over.
15
+ * 1. gh: every github.com account `gh auth status` lists, in order
16
+ * `usageLog.reportAs` (when it is one of them), the active account, then
17
+ * the rest. Each check runs `gh api repos/<accessRepo>` with that
18
+ * account's token in the child's environment (GH_TOKEN).
19
+ * 2. credential: the token git's credential helper holds for
20
+ * https://github.com (`git credential fill`, prompts disabled). Its login
21
+ * comes from `GET /user`, its access from `GET /repos/<accessRepo>`, both
22
+ * through gh with GH_TOKEN, or straight to the API when gh is missing.
23
+ * 3. ssh: each private key `ssh -G github.com` names, plus ~/.ssh/id_ed25519,
24
+ * id_ecdsa and id_rsa, when its `.pub` sits next to it. `ssh -T` with only
25
+ * that key names the login ("Hi <login>!"), and
26
+ * `git ls-remote git@github.com:<accessRepo>.git HEAD` with the same key
27
+ * proves access. Batch mode throughout, so a key that needs a passphrase
28
+ * and is not in ssh-agent is skipped rather than prompted for.
25
29
  *
26
- * The result is cached in prefs as `usageLog.login` + `usageLog.loginCheckedAt`
27
- * (and `usageLog.loginRepo`; `usageLog.loginReason` says why none qualified), re-checked after LOGIN_TTL_MS, or after
28
- * NO_LOGIN_TTL_MS when no account had access or the login is a fallback, or on `--refresh`.
30
+ * No token is ever put on an argv, printed or written anywhere. A missing gh,
31
+ * git or ssh binary, or any failed check, only means "no candidate".
32
+ *
33
+ * When no account qualifies the login is null and `reason` says what was tried
34
+ * in one line; nothing is reported under another account.
35
+ *
36
+ * The result is cached in prefs as `usageLog.login`, `usageLog.loginMethod`
37
+ * (gh | credential | ssh), `usageLog.loginKey` (the key path, ssh only),
38
+ * `usageLog.loginRepo`, `usageLog.loginCheckedAt` and `usageLog.loginReason`
39
+ * (why none qualified), re-checked after LOGIN_TTL_MS, after NO_LOGIN_TTL_MS
40
+ * when no account had access, when the cached key file is gone, or on
41
+ * `--refresh`. A cache entry marked `usageLog.loginFallback` (written by
42
+ * 20.17.0) is never served.
29
43
  *
30
44
  * Usage:
31
45
  * node usage-identity.mjs [--refresh] [--prefs <path>] [--json]
@@ -37,7 +51,7 @@
37
51
 
38
52
  import { existsSync, readFileSync } from "node:fs";
39
53
  import { execFileSync, spawnSync } from "node:child_process";
40
- import { homedir } from "node:os";
54
+ import { homedir, userInfo } from "node:os";
41
55
  import { join } from "node:path";
42
56
  import { pathToFileURL } from "node:url";
43
57
  import { lockTimeoutFrom, updateJsonFileSync } from "../lib/json-file-lock.mjs";
@@ -46,7 +60,13 @@ export const ACCESS_REPO_DEFAULT = "mmerterden/multi-agent-pipeline";
46
60
  export const LOGIN_TTL_MS = 7 * 24 * 3_600_000;
47
61
  export const NO_LOGIN_TTL_MS = 24 * 3_600_000;
48
62
  const GH_TIMEOUT_MS = 6000;
63
+ export const SSH_TIMEOUT_MS = 12000;
64
+ const SSH_CONNECT_TIMEOUT_S = 8;
65
+ const MAX_SSH_KEYS = 8;
49
66
  const HOST = "github.com";
67
+ const API_DEFAULT = "https://api.github.com";
68
+ const DEFAULT_KEYS = ["id_ed25519", "id_ecdsa", "id_rsa"];
69
+ export const LOGIN_METHODS = ["gh", "credential", "ssh"];
50
70
  const LOGIN_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,38}$/;
51
71
  const REPO_RE = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
52
72
 
@@ -66,6 +86,28 @@ function ghBin(env) {
66
86
  return env.MA_GH_BIN || "gh";
67
87
  }
68
88
 
89
+ function gitBin(env) {
90
+ return env.MA_GIT_BIN || "git";
91
+ }
92
+
93
+ function sshBin(env) {
94
+ return env.MA_SSH_BIN || "ssh";
95
+ }
96
+
97
+ export function sshKeygenBin(env = process.env) {
98
+ return env.MA_SSH_KEYGEN_BIN || "ssh-keygen";
99
+ }
100
+
101
+ function homeOf(env) {
102
+ return env.HOME || homedir();
103
+ }
104
+
105
+ /** `~/...` for a path under the home directory, for status lines. */
106
+ export function displayPath(path, env = process.env) {
107
+ const home = homeOf(env);
108
+ return path && home && path.startsWith(`${home}/`) ? `~${path.slice(home.length)}` : path;
109
+ }
110
+
69
111
  export function accessRepoOf(prefs) {
70
112
  const r = prefs?.global?.usageLog?.accessRepo;
71
113
  return typeof r === "string" && REPO_RE.test(r.trim()) ? r.trim() : ACCESS_REPO_DEFAULT;
@@ -164,9 +206,182 @@ function orderCandidates(accounts, preferred) {
164
206
  }
165
207
 
166
208
  /**
167
- * Check each logged-in account against the access repository, live.
168
- * Returns { login, fallback, reason }: the first account with access, else the
169
- * active (or first) account with `fallback: true`, else login null.
209
+ * The token git's credential helper holds for https://github.com, or "".
210
+ * Prompts are disabled (terminal, askpass and helper UI), so a
211
+ * machine without a stored credential answers at once. The token is read from
212
+ * the helper's stdout and only ever passed on in a child's environment.
213
+ */
214
+ export function credentialToken(env = process.env) {
215
+ const r = spawnSync(gitBin(env), ["credential", "fill"], {
216
+ input: `protocol=https\nhost=${HOST}\n\n`,
217
+ encoding: "utf8",
218
+ timeout: GH_TIMEOUT_MS,
219
+ stdio: ["pipe", "pipe", "ignore"],
220
+ env: {
221
+ ...env,
222
+ GIT_TERMINAL_PROMPT: "0",
223
+ GCM_INTERACTIVE: "never",
224
+ GIT_ASKPASS: "",
225
+ SSH_ASKPASS: "",
226
+ },
227
+ });
228
+ if (r.status !== 0 || typeof r.stdout !== "string") return "";
229
+ const line = r.stdout.split("\n").find((l) => l.startsWith("password="));
230
+ return line ? line.slice("password=".length).trim() : "";
231
+ }
232
+
233
+ function apiBase(env) {
234
+ const v = String(env.MA_GITHUB_API_URL || "");
235
+ return /^http:\/\/(127\.0\.0\.1|localhost)(:\d+)?$/.test(v) ? v : API_DEFAULT;
236
+ }
237
+
238
+ const API_CHECK = `
239
+ const base = process.env.MA_CHECK_API;
240
+ const repo = process.env.MA_CHECK_REPO;
241
+ const headers = {
242
+ Authorization: "Bearer " + process.env.MA_CHECK_TOKEN,
243
+ Accept: "application/vnd.github+json",
244
+ "User-Agent": "multi-agent-pipeline",
245
+ };
246
+ const get = (path) =>
247
+ fetch(base + path, { headers, redirect: "error", signal: AbortSignal.timeout(${GH_TIMEOUT_MS}) });
248
+ (async () => {
249
+ const u = await get("/user");
250
+ if (!u.ok) process.exit(1);
251
+ const login = (await u.json())?.login;
252
+ const r = await get("/repos/" + repo);
253
+ if (!r.ok) process.exit(1);
254
+ process.stdout.write(String(login || ""));
255
+ })().catch(() => process.exit(1));
256
+ `;
257
+
258
+ /**
259
+ * The login a token belongs to, when that login can read `repo`; else null.
260
+ * Through gh when it is installed, otherwise a direct API call from a child
261
+ * node process. Either way the token travels in the child's environment.
262
+ */
263
+ function tokenLoginWithAccess(token, repo, env) {
264
+ const who = spawnSync(ghBin(env), ["api", "user", "--jq", ".login"], {
265
+ encoding: "utf8",
266
+ timeout: GH_TIMEOUT_MS,
267
+ stdio: ["ignore", "pipe", "ignore"],
268
+ env: { ...env, GH_TOKEN: token, GH_HOST: HOST, GITHUB_TOKEN: "" },
269
+ });
270
+ if (who.error?.code === "ENOENT") {
271
+ const r = spawnSync(process.execPath, ["-e", API_CHECK], {
272
+ encoding: "utf8",
273
+ timeout: GH_TIMEOUT_MS * 2,
274
+ stdio: ["ignore", "pipe", "ignore"],
275
+ env: { ...env, MA_CHECK_TOKEN: token, MA_CHECK_REPO: repo, MA_CHECK_API: apiBase(env) },
276
+ });
277
+ const login = (r.stdout || "").trim();
278
+ return r.status === 0 && LOGIN_RE.test(login) ? login : null;
279
+ }
280
+ const login = (who.stdout || "").trim();
281
+ if (who.status !== 0 || !LOGIN_RE.test(login)) return null;
282
+ return canRead(repo, token, env) ? login : null;
283
+ }
284
+
285
+ function shQuote(s) {
286
+ return `'${String(s).replace(/'/g, "'\\''")}'`;
287
+ }
288
+
289
+ const SSH_FLAGS = [
290
+ "-o",
291
+ "IdentitiesOnly=yes",
292
+ "-o",
293
+ "BatchMode=yes",
294
+ "-o",
295
+ `ConnectTimeout=${SSH_CONNECT_TIMEOUT_S}`,
296
+ "-o",
297
+ "StrictHostKeyChecking=accept-new",
298
+ ];
299
+
300
+ function sshEnv(env) {
301
+ return { ...env, SSH_ASKPASS_REQUIRE: "never", GIT_TERMINAL_PROMPT: "0" };
302
+ }
303
+
304
+ function expandIdentity(raw, env) {
305
+ const home = homeOf(env);
306
+ let user = env.USER || "";
307
+ if (!user) {
308
+ try {
309
+ user = userInfo().username;
310
+ } catch {
311
+ user = "";
312
+ }
313
+ }
314
+ const parts = raw
315
+ .trim()
316
+ .replace(/^"(.*)"$/, "$1")
317
+ .replace(/^~(?=\/|$)/, home)
318
+ .split("%%")
319
+ .map((part) => part.replace(/%d/g, home).replace(/%u/g, user).replace(/%h/g, HOST));
320
+ return parts.some((part) => part.includes("%")) ? null : parts.join("%");
321
+ }
322
+
323
+ function usableKey(path) {
324
+ if (!path) return false;
325
+ if (path.endsWith(".pub")) return existsSync(path);
326
+ return existsSync(path) && existsSync(`${path}.pub`);
327
+ }
328
+
329
+ /**
330
+ * Private-key paths worth trying for github.com: what `ssh -G github.com`
331
+ * names, then the default key names under ~/.ssh, each kept only when its
332
+ * `.pub` exists (a `.pub` named directly stands for a key held by an agent).
333
+ * Empty when ssh is not installed.
334
+ */
335
+ export function sshKeyCandidates(env = process.env) {
336
+ const r = spawnSync(sshBin(env), ["-G", HOST], {
337
+ encoding: "utf8",
338
+ timeout: GH_TIMEOUT_MS,
339
+ stdio: ["ignore", "pipe", "ignore"],
340
+ env,
341
+ });
342
+ if (r.error) return [];
343
+ const listed = [];
344
+ for (const line of (r.stdout || "").split("\n")) {
345
+ const m = line.match(/^identityfile\s+(.+)$/i);
346
+ if (m) listed.push(expandIdentity(m[1], env));
347
+ }
348
+ const defaults = DEFAULT_KEYS.map((n) => join(homeOf(env), ".ssh", n));
349
+ const out = [];
350
+ for (const p of [...listed, ...defaults]) {
351
+ if (p && !out.includes(p) && usableKey(p)) out.push(p);
352
+ }
353
+ return out.slice(0, MAX_SSH_KEYS);
354
+ }
355
+
356
+ /** The GitHub login an SSH key authenticates as, from `ssh -T`; else null. */
357
+ export function sshLogin(key, env = process.env) {
358
+ const r = spawnSync(sshBin(env), ["-i", key, ...SSH_FLAGS, "-T", `git@${HOST}`], {
359
+ encoding: "utf8",
360
+ timeout: SSH_TIMEOUT_MS,
361
+ stdio: ["ignore", "pipe", "pipe"],
362
+ env: sshEnv(env),
363
+ });
364
+ if (r.error) return null;
365
+ const m = `${r.stderr || ""}\n${r.stdout || ""}`.match(/Hi ([^\s!]+)!/);
366
+ return m && LOGIN_RE.test(m[1]) ? m[1] : null;
367
+ }
368
+
369
+ function sshCanRead(key, repo, env) {
370
+ const command = [shQuote(sshBin(env)), "-i", shQuote(key), ...SSH_FLAGS].join(" ");
371
+ const r = spawnSync(gitBin(env), ["ls-remote", `git@${HOST}:${repo}.git`, "HEAD"], {
372
+ encoding: "utf8",
373
+ timeout: SSH_TIMEOUT_MS,
374
+ stdio: ["ignore", "ignore", "ignore"],
375
+ env: { ...sshEnv(env), GIT_SSH_COMMAND: command },
376
+ });
377
+ return r.status === 0;
378
+ }
379
+
380
+ /**
381
+ * Check the accounts on this machine against the access repository, live, in
382
+ * the order gh, git credential helper, SSH keys. Returns
383
+ * { login, method, key, reason }: the first account with access, else login
384
+ * null with a one-line reason naming what was tried.
170
385
  */
171
386
  export function checkAccess({
172
387
  repo = ACCESS_REPO_DEFAULT,
@@ -174,33 +389,71 @@ export function checkAccess({
174
389
  env = process.env,
175
390
  } = {}) {
176
391
  const accounts = listAccounts({ env });
177
- if (accounts.length === 0) {
392
+ for (const a of orderCandidates(accounts, preferred)) {
393
+ const token = tokenFor(a.login, env);
394
+ if (token && canRead(repo, token, env)) {
395
+ return { login: a.login, method: "gh", key: null, reason: `can read ${repo}` };
396
+ }
397
+ }
398
+
399
+ const cred = credentialToken(env);
400
+ const credLogin = cred ? tokenLoginWithAccess(cred, repo, env) : null;
401
+ if (credLogin) {
178
402
  return {
179
- login: null,
180
- fallback: false,
181
- reason: `no GitHub account is logged in to gh, so ${repo} cannot be checked`,
403
+ login: credLogin,
404
+ method: "credential",
405
+ key: null,
406
+ reason: `can read ${repo}`,
182
407
  };
183
408
  }
184
- const ordered = orderCandidates(accounts, preferred);
185
- for (const a of ordered) {
186
- const token = tokenFor(a.login, env);
187
- if (token && canRead(repo, token, env)) {
188
- return { login: a.login, fallback: false, reason: `can read ${repo}` };
409
+
410
+ const keys = sshKeyCandidates(env);
411
+ const sshLogins = [];
412
+ for (const key of keys) {
413
+ const login = sshLogin(key, env);
414
+ if (!login) continue;
415
+ sshLogins.push(login);
416
+ if (sshCanRead(key, repo, env)) {
417
+ return {
418
+ login,
419
+ method: "ssh",
420
+ key,
421
+ reason: `can read ${repo}`,
422
+ };
189
423
  }
190
424
  }
191
- const fallback = accounts.find((a) => a.active) || accounts[0];
425
+
426
+ const ghPart = accounts.length ? accounts.map((a) => a.login).join(", ") : "none";
427
+ const credPart = cred ? "a token without access" : "none";
428
+ const sshPart = keys.length
429
+ ? `${keys.length} tried${sshLogins.length ? ` (${[...new Set(sshLogins)].join(", ")})` : ""}`
430
+ : "none";
192
431
  return {
193
- login: fallback.login,
194
- fallback: true,
195
- reason: `none of the logged-in GitHub accounts (${accounts.map((a) => a.login).join(", ")}) can read ${repo}; the server decides whether it accepts ${fallback.login}`,
432
+ login: null,
433
+ method: null,
434
+ key: null,
435
+ reason: `no GitHub account on this machine can read ${repo} (gh: ${ghPart}; git credential: ${credPart}; SSH keys: ${sshPart})`,
196
436
  };
197
437
  }
198
438
 
439
+ function cachedMethod(u) {
440
+ if (u.loginMethod === undefined || u.loginMethod === null) return "gh";
441
+ return LOGIN_METHODS.includes(u.loginMethod) ? u.loginMethod : null;
442
+ }
443
+
199
444
  function cacheFresh(u, repo, now) {
200
445
  const at = Date.parse(u?.loginCheckedAt || "");
201
446
  if (Number.isNaN(at)) return false;
202
447
  if ((u.loginRepo || ACCESS_REPO_DEFAULT) !== repo) return false;
203
- const ttl = u.login && u.loginFallback !== true ? LOGIN_TTL_MS : NO_LOGIN_TTL_MS;
448
+ if (u.loginFallback === true) return false;
449
+ if (u.login) {
450
+ const method = cachedMethod(u);
451
+ if (!method) return false;
452
+ if (method === "ssh" && !(typeof u.loginKey === "string" && usableKey(u.loginKey))) {
453
+ return false;
454
+ }
455
+ }
456
+ const ttl = u.login ? LOGIN_TTL_MS : NO_LOGIN_TTL_MS;
204
457
  return now - at < ttl && now >= at;
205
458
  }
206
459
 
@@ -238,20 +491,20 @@ export function resolveReportingLogin({
238
491
  const repo = accessRepoOf(prefs);
239
492
  if (!refresh && cacheFresh(u, repo, now)) {
240
493
  if (typeof u.login === "string" && LOGIN_RE.test(u.login)) {
241
- if (u.loginFallback === true) {
242
- return {
243
- login: u.login,
244
- fallback: true,
245
- reason: u.loginReason || `cached: no logged-in GitHub account can read ${repo}`,
246
- cached: true,
247
- };
248
- }
249
- return { login: u.login, fallback: false, reason: `cached: can read ${repo}`, cached: true };
494
+ const method = cachedMethod(u);
495
+ return {
496
+ login: u.login,
497
+ method,
498
+ key: method === "ssh" ? u.loginKey : null,
499
+ reason: `cached: can read ${repo}`,
500
+ cached: true,
501
+ };
250
502
  }
251
503
  return {
252
504
  login: null,
253
- fallback: false,
254
- reason: u.loginReason || `cached: no logged-in GitHub account can read ${repo}`,
505
+ method: null,
506
+ key: null,
507
+ reason: u.loginReason || `cached: no GitHub account on this machine can read ${repo}`,
255
508
  cached: true,
256
509
  };
257
510
  }
@@ -262,10 +515,12 @@ export function resolveReportingLogin({
262
515
  prefsPath,
263
516
  {
264
517
  login: result.login,
265
- loginFallback: result.fallback,
518
+ loginMethod: result.method,
519
+ loginKey: result.method === "ssh" ? result.key : null,
520
+ loginFallback: undefined,
266
521
  loginRepo: repo,
267
522
  loginCheckedAt: new Date(now).toISOString(),
268
- loginReason: result.login && !result.fallback ? null : result.reason,
523
+ loginReason: result.login ? null : result.reason,
269
524
  },
270
525
  env,
271
526
  );
@@ -287,12 +542,16 @@ function main(argv) {
287
542
  });
288
543
  if (opts.json) {
289
544
  process.stdout.write(
290
- `${JSON.stringify({ login: r.login, fallback: r.fallback, reason: r.reason, cached: r.cached })}\n`,
545
+ `${JSON.stringify({ login: r.login, method: r.method, key: r.key, reason: r.reason, cached: r.cached })}\n`,
291
546
  );
292
- } else if (r.login && r.fallback) {
293
- process.stdout.write(`usage identity: ${r.login} (fallback - ${r.reason})\n`);
294
547
  } else if (r.login) {
295
- process.stdout.write(`usage identity: ${r.login} (${r.reason})\n`);
548
+ const via =
549
+ r.method === "ssh"
550
+ ? `SSH key ${displayPath(r.key)}`
551
+ : r.method === "credential"
552
+ ? "git credential helper"
553
+ : "gh";
554
+ process.stdout.write(`usage identity: ${r.login} (${via}; ${r.reason})\n`);
296
555
  } else {
297
556
  process.stdout.write(`usage identity: none - ${r.reason}; nothing will be reported\n`);
298
557
  }