@bridge4dev/runner 0.22.1 → 0.27.0

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.
@@ -5,6 +5,8 @@ import path from 'node:path';
5
5
  import { promisify } from 'node:util';
6
6
  import { log } from './log.js';
7
7
  import { maskString } from './policy.js';
8
+ import { runnerIdentity, whichExecutable } from './environment.js';
9
+ import { applyStoredClaudeToken, clearStoredClaudeToken, extractOauthToken, storeClaudeToken, storedClaudeToken, } from './agent-auth.js';
8
10
  import { adoptLoginResult, discardStagingHome, prepareStagingHome, repairCodexAuth, stagingCodexHomePath, } from './adapters/codex-home.js';
9
11
  const execFileAsync = promisify(execFile);
10
12
  /* eslint-disable no-control-regex -- this module parses raw pty output, so
@@ -18,6 +20,18 @@ const URL_PATTERNS = {
18
20
  claude: /https:\/\/(?:claude\.com|claude\.ai)\/[^\s\x07\x1b"']+/,
19
21
  codex: /https:\/\/[^\s\x07\x1b"']+/,
20
22
  };
23
+ /**
24
+ * Rejoin a secret the pty split across lines, so the masker can see it.
25
+ *
26
+ * `maskString` matches `sk-ant-[A-Za-z0-9_-]{8,}`, which a line break inside
27
+ * the token defeats — and the pty wraps at its width, so a ~100-character
28
+ * token arrives in pieces as a matter of course. Measured: the head gets
29
+ * masked and the tail is printed verbatim into an error detail that travels to
30
+ * the dashboard. Rejoining first costs nothing and closes it.
31
+ */
32
+ export function rejoinWrappedSecrets(text) {
33
+ return text.replace(/sk-ant-[A-Za-z0-9_-]*(?:\n[A-Za-z0-9_-]+)+/g, (match) => match.replace(/\n/g, ''));
34
+ }
21
35
  /** Strip ANSI/OSC control sequences so text matching sees plain output. */
22
36
  export function stripControl(raw) {
23
37
  return raw
@@ -38,18 +52,132 @@ export function extractDeviceCode(raw) {
38
52
  // Device-auth user codes look like XXXX-XXXX (letters/digits).
39
53
  return stripControl(raw).match(/\b[A-Z0-9]{4,8}-[A-Z0-9]{4,8}\b/)?.[0] ?? null;
40
54
  }
55
+ /**
56
+ * Did the CLI reject the subcommand itself, rather than fail the login?
57
+ *
58
+ * Commander prints «unknown command» / «error: unknown option» and exits
59
+ * before any network call. Anything else — a refused grant, no subscription, a
60
+ * DNS failure — is a real answer and must NOT be retried on the legacy command,
61
+ * or we would quietly downgrade a healthy machine to an inference-only token.
62
+ */
63
+ function looksLikeUnsupportedSubcommand(output) {
64
+ // Anchored to Commander's own usage-error wording. The loose version matched
65
+ // anywhere in the relayed CLI output, so a genuine login failure that merely
66
+ // mentioned an unknown option would silently downgrade a healthy machine to
67
+ // an inference-only token — the very thing this release stops doing.
68
+ return /error:\s*unknown (?:command|option)\b|unrecognized subcommand/i.test(output);
69
+ }
70
+ /**
71
+ * What «sign in» actually is, per agent.
72
+ *
73
+ * `claude auth login` and NOT `claude setup-token`, and the difference is the
74
+ * whole of ticket «Sign in does nothing». Measured against the 2.1.220 binary
75
+ * and stated outright in Anthropic's docs: `setup-token` mints a long-lived
76
+ * INFERENCE-ONLY token, prints it to the terminal, and «does not save the token
77
+ * anywhere». It never touches `~/.claude/.credentials.json` — the file
78
+ * `claudeAuthStatus()` below reads to decide whether this machine is signed in.
79
+ * So the relay completed a real OAuth flow, the CLI exited 0, we reported
80
+ * success, and the panel's immediate re-probe answered «No Claude login on this
81
+ * server». Nobody was wrong; the two halves were simply about different things.
82
+ *
83
+ * `claude auth login --claudeai` is the same OAuth flow through the same pty
84
+ * harness — verified live: it prints the authorize URL and then waits on stdin
85
+ * with «Paste code here if prompted >» — except it persists the credential and
86
+ * asks for the full subscription scope rather than inference alone.
87
+ *
88
+ * `--claudeai` is explicit so the CLI never stops to ask «subscription or
89
+ * Console?»: a menu waiting for an arrow key is indistinguishable, from here,
90
+ * from a login that hung.
91
+ */
41
92
  const LOGIN_COMMANDS = {
42
- claude: 'claude setup-token',
93
+ claude: 'claude auth login --claudeai',
43
94
  codex: 'codex login --device-auth',
44
95
  };
96
+ /**
97
+ * The pre-0.27.0 command, kept for one job only: a `claude` old enough to have
98
+ * no `auth login` subcommand. There the CLI exits immediately with a usage
99
+ * error, and leaving that machine with no way to sign in at all would be a
100
+ * worse regression than the bug this replaced. On that path we capture the
101
+ * printed token ourselves (see `agent-auth.ts`) so the outcome is still a
102
+ * login the panel can see.
103
+ */
104
+ const CLAUDE_LEGACY_LOGIN = 'claude setup-token';
105
+ /**
106
+ * A pty on a headless server must not try to launch a browser.
107
+ *
108
+ * `claude auth login` prints «Opening browser to sign in…» and calls the
109
+ * platform opener first. On a server that is merely noise, but on a machine
110
+ * with a desktop session it pops a window in front of whoever is sitting there
111
+ * — for a sign-in they did not start, on a host they may not own. The URL is
112
+ * printed regardless, which is the only part this flow uses.
113
+ */
114
+ function relayEnv(extra = {}) {
115
+ const env = { ...process.env, ...extra };
116
+ env['BROWSER'] = 'true';
117
+ delete env['DISPLAY'];
118
+ delete env['WAYLAND_DISPLAY'];
119
+ return env;
120
+ }
121
+ /**
122
+ * Is the command this relay is about to run actually present?
123
+ *
124
+ * Split out because the answer differs by shape: a bare name is looked up on
125
+ * PATH, while an absolute path (what the tests inject, and what a hand-written
126
+ * config could hold) is checked where it points. Without this the missing-CLI
127
+ * case surfaced as «login exited before printing a sign-in URL: script:
128
+ * command not found» — an error about the pty helper, on a machine whose real
129
+ * problem was that nobody had installed the agent for that user. That was
130
+ * axon-prod-01 exactly: a dedicated user, an empty home, no `claude` anywhere.
131
+ */
132
+ function commandExists(command) {
133
+ const binary = command.trim().split(/\s+/)[0] ?? '';
134
+ if (!binary)
135
+ return false;
136
+ if (binary.includes(path.sep)) {
137
+ try {
138
+ fs.accessSync(binary, fs.constants.X_OK);
139
+ return true;
140
+ }
141
+ catch {
142
+ return false;
143
+ }
144
+ }
145
+ return whichExecutable(binary) !== null;
146
+ }
45
147
  export class AuthRelay {
46
148
  commands;
149
+ deps;
47
150
  active = null;
48
- constructor(commands = LOGIN_COMMANDS) {
151
+ constructor(commands = LOGIN_COMMANDS, deps = {}) {
49
152
  this.commands = commands;
153
+ this.deps = deps;
50
154
  }
51
155
  /** Start (or restart) a login flow and wait until the sign-in URL appears. */
52
156
  async start(agent) {
157
+ this.cancel();
158
+ const command = this.commands[agent] ?? '';
159
+ if (!commandExists(command)) {
160
+ const binary = command.trim().split(/\s+/)[0] ?? agent;
161
+ throw new Error(`\`${binary}\` is not installed for ${runnerIdentity().user} on this server — ` +
162
+ 'install the agent CLI for that user (or use the installer’s --user mode, which does it) and try again');
163
+ }
164
+ if (!whichExecutable('script')) {
165
+ // util-linux, and the only reason a pty exists here at all.
166
+ throw new Error('the `script` command (util-linux) is missing on this server — the sign-in needs it to run the agent CLI on a terminal');
167
+ }
168
+ try {
169
+ return await this.startWith(agent, this.commands[agent] ?? '', false);
170
+ }
171
+ catch (error) {
172
+ // Only the one recoverable shape: a `claude` too old to have `auth
173
+ // login`. Everything else is the answer, not a reason to try again.
174
+ if (agent !== 'claude' || !looksLikeUnsupportedSubcommand(String(error)))
175
+ throw error;
176
+ log.warn('auth-relay: this claude has no `auth login` — falling back to setup-token');
177
+ return this.startWith(agent, CLAUDE_LEGACY_LOGIN, true);
178
+ }
179
+ }
180
+ async startWith(agent, command, captureToken) {
53
181
  this.cancel();
54
182
  // Codex logs into a THROWAWAY home and is promoted only on success. The
55
183
  // old flow deleted the shared credential link up front, so abandoning the
@@ -57,10 +185,10 @@ export class AuthRelay {
57
185
  // with no way back except restarting the daemon — and writing through the
58
186
  // link would have overwritten the host user's own account (QA-100 MINOR-5).
59
187
  const stagingHome = agent === 'codex' ? prepareStagingHome() : null;
60
- const proc = spawn('script', ['-qec', this.commands[agent], '/dev/null'], {
188
+ const proc = spawn('script', ['-qec', command, '/dev/null'], {
61
189
  stdio: ['pipe', 'pipe', 'pipe'],
62
190
  // Codex must log in to a home WE control, never the host user's ~/.codex.
63
- ...(stagingHome ? { env: { ...process.env, CODEX_HOME: stagingHome } } : {}),
191
+ env: relayEnv(stagingHome ? { CODEX_HOME: stagingHome } : {}),
64
192
  });
65
193
  const relay = {
66
194
  agent,
@@ -68,6 +196,8 @@ export class AuthRelay {
68
196
  buffer: '',
69
197
  exited: false,
70
198
  exitCode: null,
199
+ command,
200
+ captureToken,
71
201
  killTimer: setTimeout(() => this.cancel(), RELAY_MAX_LIFETIME_MS),
72
202
  };
73
203
  relay.killTimer.unref();
@@ -92,6 +222,12 @@ export class AuthRelay {
92
222
  try {
93
223
  if (code === 0 && adoptLoginResult(stagingCodexHomePath())) {
94
224
  log.info('codex: device login completed — credential adopted');
225
+ // The ONLY place a Codex sign-in can be reported as finished. Its
226
+ // flow never sends `login_code` (device auth needs no paste-back),
227
+ // so without this line a refusal we recorded earlier would keep the
228
+ // panel demanding a re-login the user has just done — #121's own
229
+ // complaint, reproduced on the other half of the panel (QA-117 H1).
230
+ clearAgentAuthFailure('codex');
95
231
  }
96
232
  else {
97
233
  discardStagingHome();
@@ -115,7 +251,7 @@ export class AuthRelay {
115
251
  return { url, ...(code ? { code } : {}), expectsCode: agent === 'claude' };
116
252
  }
117
253
  if (relay.exited) {
118
- const tail = maskString(stripControl(relay.buffer)).slice(-400);
254
+ const tail = maskString(rejoinWrappedSecrets(stripControl(relay.buffer))).slice(-400);
119
255
  this.cancel();
120
256
  throw new Error(`${agent} login exited before printing a sign-in URL: ${tail}`);
121
257
  }
@@ -142,21 +278,79 @@ export class AuthRelay {
142
278
  while (Date.now() < deadline) {
143
279
  const fresh = stripControl(relay.buffer.slice(bufferMark));
144
280
  if (relay.exited) {
281
+ const raw = relay.buffer;
145
282
  this.cancel();
146
- if (relay.exitCode === 0)
147
- return { ok: true };
148
- return { ok: false, detail: maskString(fresh).slice(-400) || 'Login failed' };
283
+ if (relay.exitCode !== 0) {
284
+ return {
285
+ ok: false,
286
+ detail: maskString(rejoinWrappedSecrets(fresh)).slice(-400) || 'Login failed',
287
+ };
288
+ }
289
+ // Exit 0 is the CLI's opinion. Ours has to be «is this machine signed
290
+ // in now», because those two came apart before and nobody noticed for
291
+ // weeks: `setup-token` exits 0 over a credential it never stored.
292
+ return this.confirmSignedIn(relay, raw);
149
293
  }
150
294
  if (/invalid|error|failed|expired/i.test(fresh)) {
151
295
  // The CLI usually re-prompts after a bad code; surface it and keep
152
296
  // the relay alive so the user can retry with a corrected code.
153
- return { ok: false, detail: maskString(fresh).trim().slice(-400) };
297
+ return { ok: false, detail: maskString(rejoinWrappedSecrets(fresh)).trim().slice(-400) };
154
298
  }
155
299
  await sleep(300);
156
300
  }
157
301
  this.cancel();
158
302
  return { ok: false, detail: 'Timed out waiting for the login to complete' };
159
303
  }
304
+ /**
305
+ * The CLI exited 0 — but is the machine actually signed in?
306
+ *
307
+ * Asked rather than assumed. Reporting a success the panel then contradicts
308
+ * one second later is worse than reporting a failure: it sends the person
309
+ * looking for a permissions problem that does not exist, which is exactly
310
+ * what happened on axon-prod-01.
311
+ */
312
+ async confirmSignedIn(relay, raw) {
313
+ if (relay.agent !== 'claude')
314
+ return { ok: true };
315
+ // Legacy `setup-token`: the token exists only in the output. Keep it, or
316
+ // the whole flow was for nothing.
317
+ if (relay.captureToken) {
318
+ const token = extractOauthToken(stripControl(raw));
319
+ if (!token) {
320
+ return {
321
+ ok: false,
322
+ detail: 'the sign-in finished but this older Claude CLI printed no usable token — ' +
323
+ 'update the Claude CLI on the server and try again',
324
+ };
325
+ }
326
+ try {
327
+ storeClaudeToken(token);
328
+ applyStoredClaudeToken();
329
+ }
330
+ catch (error) {
331
+ return { ok: false, detail: `could not store the token on this server: ${String(error)}` };
332
+ }
333
+ log.info('auth-relay: stored a long-lived Claude token for this runner');
334
+ clearAgentAuthFailure('claude');
335
+ return { ok: true, detail: 'signed in with a long-lived token stored on this server' };
336
+ }
337
+ // `claude auth login` writes the credential just before it exits; give the
338
+ // filesystem a couple of beats rather than racing it.
339
+ const probe = this.deps.claudeStatus ?? claudeAuthStatus;
340
+ for (let attempt = 0; attempt < 4; attempt++) {
341
+ const status = await probe();
342
+ if (status.status === 'ok') {
343
+ clearAgentAuthFailure('claude');
344
+ return { ok: true };
345
+ }
346
+ await sleep(300);
347
+ }
348
+ return {
349
+ ok: false,
350
+ detail: 'the sign-in completed but no login was stored on this server — ' +
351
+ 'check that the user the runner runs as can write its own ~/.claude directory',
352
+ };
353
+ }
160
354
  cancel() {
161
355
  const relay = this.active;
162
356
  if (!relay)
@@ -174,36 +368,220 @@ export class AuthRelay {
174
368
  function sleep(ms) {
175
369
  return new Promise((resolve) => setTimeout(resolve, ms));
176
370
  }
177
- // ─── Auth health probe ───────────────────────────────────────────────
371
+ function isNonEmptyString(value) {
372
+ return typeof value === 'string' && value.length > 0;
373
+ }
374
+ /**
375
+ * A millisecond timestamp we are willing to hand to `new Date(...)`.
376
+ *
377
+ * The bound is not decoration: `new Date(1e21).toISOString()` throws
378
+ * `RangeError`, and thrown out of here it takes BOTH agents' verdicts down
379
+ * with it (they share one `Promise.all`) on every poll, forever, because only
380
+ * successes are cached (QA-117 M1).
381
+ */
382
+ const MAX_TIMESTAMP_MS = 8.64e15;
383
+ function asTimestamp(value) {
384
+ if (typeof value !== 'number' || !Number.isFinite(value))
385
+ return undefined;
386
+ return Math.abs(value) <= MAX_TIMESTAMP_MS ? value : undefined;
387
+ }
178
388
  /**
179
- * Claude: the subscription token's expiry is recorded in the CLI's own
180
- * credentials file. The RUNNER reads it (its own host user's file) — the
181
- * agent itself is still denied this path by layer-1 policy.
389
+ * Claude: the subscription login is recorded in the CLI's own credentials file.
390
+ * The RUNNER reads it (its own host user's file) — the agent itself is still
391
+ * denied this path by layer-1 policy.
392
+ *
393
+ * `expiresAt` is NOT the login. It is the expiry of a short-lived access token
394
+ * (~8 hours on a live file), and next to it sits `refreshToken` with
395
+ * `refreshTokenExpiresAt` ~26 days out, which the CLI spends silently on its
396
+ * next run. Judging the login by `expiresAt` alone is why every server nobody
397
+ * had touched since the morning reported "login expired — re-login needed"
398
+ * over a login that was good for another three weeks (#121). Codex has carried
399
+ * exactly this guard since day one (`readCodexCredential`); Claude did not.
400
+ *
401
+ * Deliberately NOT asking the CLI. `claude auth status --json` looks like an
402
+ * arbiter and is not one: measured live (SDK binary 2.1.218), it answers
403
+ * `loggedIn: true` for a credential whose access token has expired AND which
404
+ * carries no refresh token at all — i.e. for a genuinely dead login. It never
405
+ * leaves the machine, so it cannot see a server-side revocation either. It
406
+ * would have cost a ~800 ms / ~300 MB subprocess per poll under `MemoryMax=2G`
407
+ * (gotcha #100) and echoed the account's e-mail and org name to every member of
408
+ * the organization, in exchange for no truth at all. A revoked login is caught
409
+ * instead by `noteAgentAuthFailure` below — from a real refusal, not a guess.
182
410
  */
183
411
  export async function claudeAuthStatus(homedir = os.homedir()) {
184
412
  if (process.env['CLAUDE_CODE_OAUTH_TOKEN']) {
185
413
  return { status: 'ok', detail: 'CLAUDE_CODE_OAUTH_TOKEN is configured' };
186
414
  }
415
+ /**
416
+ * A token this runner captured itself (the `setup-token` fallback) is the
417
+ * LAST word, never the first.
418
+ *
419
+ * It used to short-circuit ahead of the credentials file, and that was three
420
+ * bugs in one line: a real `/login` afterwards could never show through, the
421
+ * post-exchange re-probe could not fail (so `confirmSignedIn` always agreed
422
+ * with itself), and there was no way to get back to «signed out» short of
423
+ * deleting a file nobody documents. Read below, after the file has had its
424
+ * say — and read from disk rather than from the environment, so `doctor` (a
425
+ * different process, which never applied it) gives the same verdict as the
426
+ * daemon.
427
+ */
428
+ const fallbackToken = () => storedClaudeToken()
429
+ ? {
430
+ status: 'ok',
431
+ detail: 'signed in with a long-lived token stored on this server',
432
+ }
433
+ : null;
187
434
  const file = path.join(homedir, '.claude', '.credentials.json');
435
+ let raw;
188
436
  try {
189
- const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
190
- const oauth = parsed.claudeAiOauth;
191
- if (!oauth)
192
- return { status: 'missing', detail: 'No subscription login found' };
193
- const expiresAt = oauth.expiresAt ? new Date(oauth.expiresAt).toISOString() : undefined;
194
- if (oauth.expiresAt && oauth.expiresAt < Date.now()) {
195
- return { status: 'expired', ...(expiresAt ? { expiresAt } : {}) };
437
+ raw = fs.readFileSync(file, 'utf8');
438
+ }
439
+ catch (error) {
440
+ const code = error.code;
441
+ // Never signed in here is a different answer from "we could not look".
442
+ // EACCES on somebody else's HOME used to read as "not signed in", which
443
+ // sends the user re-authenticating a credential that is sitting right
444
+ // there (the same mistake #121 is about, one layer down).
445
+ if (code === 'ENOENT' || code === 'ENOTDIR') {
446
+ return fallbackToken() ?? { status: 'missing', detail: 'No Claude login on this server' };
196
447
  }
448
+ // No `log.warn` here: this probe is on a 60-second timer since #121, and a
449
+ // machine with EACCES on that file would write the same line forever.
450
+ // `logVerdictChange` already reports the `unknown`, once, when it starts
451
+ // (QA-117 L5).
197
452
  return {
198
- status: 'ok',
199
- ...(expiresAt ? { expiresAt } : {}),
200
- ...(oauth.subscriptionType ? { detail: `subscription ${oauth.subscriptionType}` } : {}),
453
+ status: 'unknown',
454
+ detail: `could not read the login on this server (${code ?? 'unknown error'})`,
201
455
  };
202
456
  }
457
+ let oauth;
458
+ try {
459
+ oauth = JSON.parse(raw).claudeAiOauth;
460
+ }
203
461
  catch {
204
- return { status: 'missing', detail: 'No Claude login on this server' };
462
+ // Truncated or hand-edited file: the CLI cannot use it either, and signing
463
+ // in again is the fix — so say `missing` (which offers that button) rather
464
+ // than `unknown` (which offers nothing).
465
+ return { status: 'missing', detail: 'the stored login could not be read — sign in again' };
466
+ }
467
+ // A parseable file is not a credential. `{"claudeAiOauth":{}}` is what a
468
+ // partial write and a hand-edit both leave behind, and reading it as a
469
+ // healthy login puts a green dot and NO way out on the panel — «I cannot
470
+ // tell» turned into «all good», which is the rule this ticket exists to
471
+ // uphold, upside down (QA-117 M2).
472
+ if (!oauth || typeof oauth !== 'object' || Array.isArray(oauth)) {
473
+ return { status: 'missing', detail: 'the stored login could not be read — sign in again' };
474
+ }
475
+ const hasAccess = isNonEmptyString(oauth.accessToken);
476
+ const hasRefresh = isNonEmptyString(oauth.refreshToken);
477
+ const now = Date.now();
478
+ const accessExpiry = asTimestamp(oauth.expiresAt);
479
+ const refreshExpiry = asTimestamp(oauth.refreshTokenExpiresAt);
480
+ // Nothing recognisable in the blob at all — no token, not even a date. That
481
+ // is «never signed in here», and it must offer the button that fixes it.
482
+ if (!hasAccess && !hasRefresh && accessExpiry === undefined) {
483
+ return fallbackToken() ?? { status: 'missing', detail: 'No subscription login found' };
484
+ }
485
+ // A date we can read outranks the token beside it (the pre-#121 contract, and
486
+ // the reason a dated-but-token-less fixture still reads as expired); with no
487
+ // readable date, the presence of the token is all we have.
488
+ const accessLive = accessExpiry === undefined ? hasAccess : accessExpiry > now;
489
+ // No `refreshTokenExpiresAt` next to a refresh token means the CLI did not
490
+ // record one — that is not evidence of death, so we do not read it as death.
491
+ const refreshLive = hasRefresh && (refreshExpiry === undefined || refreshExpiry > now);
492
+ // Report the date this login actually dies on, not the one that moves every
493
+ // eight hours: a panel reading "token until <today>" is alarming and wrong.
494
+ // Only while the refresh token is the operative one, though — a live access
495
+ // token beside a dead refresh token dies on its OWN date. And when the CLI
496
+ // recorded no date for a live refresh token we say NOTHING: printing the
497
+ // access token's lapsed date beside the word «signed in» is the very screen
498
+ // this function's docblock promises not to draw (QA-117 M3).
499
+ const effectiveExpiry = refreshLive ? refreshExpiry : accessExpiry;
500
+ const expiresAt = effectiveExpiry === undefined ? undefined : new Date(effectiveExpiry).toISOString();
501
+ if (!accessLive && !refreshLive) {
502
+ return (fallbackToken() ?? {
503
+ status: 'expired',
504
+ ...(expiresAt ? { expiresAt } : {}),
505
+ detail: 'the stored login has expired',
506
+ });
507
+ }
508
+ return {
509
+ status: 'ok',
510
+ ...(expiresAt ? { expiresAt } : {}),
511
+ // Only a plain string, and only a short one: this value is read off disk
512
+ // and printed in every member's panel.
513
+ ...(isNonEmptyString(oauth.subscriptionType)
514
+ ? { detail: `subscription ${oauth.subscriptionType.slice(0, 40)}` }
515
+ : {}),
516
+ };
517
+ }
518
+ // ─── What the agent actually experienced ─────────────────────────────
519
+ //
520
+ // The credentials file cannot tell us the provider revoked this login: the
521
+ // refresh token still sits there, dated. The one authority on that is a real
522
+ // refusal from a real run — so a session that failed to authenticate demotes
523
+ // the file's verdict for a while, and any successful turn clears it again.
524
+ //
525
+ // Process memory on purpose: no protocol, no disk. The consequence is worth
526
+ // naming — restarting the daemon forgets the refusal and the panel goes back
527
+ // to trusting the file until the next session tries.
528
+ const AUTH_FAILURE_TTL_MS = 15 * 60_000;
529
+ const authFailures = new Map();
530
+ /** A session just failed to authenticate as this agent. */
531
+ export function noteAgentAuthFailure(agent) {
532
+ authFailures.set(agent, Date.now());
533
+ log.warn('auth-relay: agent sign-in refused during a session', { agent });
534
+ // A refusal is the only authority on a revoked credential, and a token we
535
+ // captured ourselves has no other expiry we can see. Keeping it would let a
536
+ // dead login outlive the evidence: the failure marker times out after 15
537
+ // minutes and the panel would go green again over the same dead token.
538
+ if (agent === 'claude' && storedClaudeToken()) {
539
+ clearStoredClaudeToken();
540
+ log.warn('auth-relay: discarded the stored Claude token after a refusal');
541
+ }
542
+ }
543
+ /** The agent just worked — whatever was wrong with the sign-in is not. */
544
+ export function clearAgentAuthFailure(agent) {
545
+ if (authFailures.delete(agent)) {
546
+ log.info('auth-relay: agent sign-in is working again', { agent });
205
547
  }
206
548
  }
549
+ /**
550
+ * Is a refusal still being held against this agent?
551
+ *
552
+ * Exported so the wiring in `supervisor.ts` can be pinned by a test without
553
+ * shelling out to the agent CLIs: this predicate IS the mechanism the panel's
554
+ * demotion reads, and three unguarded lines were carrying it (QA-117 M6).
555
+ */
556
+ export function agentAuthFailureActive(agent) {
557
+ return hasRecentAuthFailure(agent);
558
+ }
559
+ function hasRecentAuthFailure(agent) {
560
+ const at = authFailures.get(agent);
561
+ if (at === undefined)
562
+ return false;
563
+ if (Date.now() - at > AUTH_FAILURE_TTL_MS) {
564
+ authFailures.delete(agent);
565
+ return false;
566
+ }
567
+ return true;
568
+ }
569
+ /** Last verdict we published per agent — logged only when it changes. */
570
+ const lastVerdict = new Map();
571
+ function logVerdictChange(agent, status) {
572
+ const previous = lastVerdict.get(agent);
573
+ if (previous === status.status)
574
+ return;
575
+ lastVerdict.set(agent, status.status);
576
+ // Without this line there is no way to confirm on a live server that the
577
+ // verdict changed — and no way to see it flapping (lesson from #103).
578
+ log.info('auth-relay: login verdict', {
579
+ agent,
580
+ from: previous ?? 'none',
581
+ to: status.status,
582
+ ...(status.expiresAt ? { expiresAt: status.expiresAt } : {}),
583
+ });
584
+ }
207
585
  /**
208
586
  * Codex reports its own login state via an exit code (0 signed in / 1 not).
209
587
  * Probed against the RUNNER's home: the host user can be signed in while our
@@ -282,8 +660,28 @@ function readCodexCredential(homePath) {
282
660
  return null;
283
661
  }
284
662
  }
663
+ /**
664
+ * A verdict read off disk, overruled by what a real session experienced.
665
+ *
666
+ * Only ever downgrades: a refusal we witnessed outranks a file that looks fine,
667
+ * and never the other way round — a file saying `expired` is not made `ok` by
668
+ * the absence of failures.
669
+ */
670
+ function withObservedFailures(agent, status) {
671
+ if (status.status !== 'ok' || !hasRecentAuthFailure(agent))
672
+ return status;
673
+ return {
674
+ status: 'expired',
675
+ ...(status.expiresAt ? { expiresAt: status.expiresAt } : {}),
676
+ detail: 'the agent was refused with this sign-in — re-login needed',
677
+ };
678
+ }
285
679
  export async function agentAuthStatuses() {
286
- const [claude, codex] = await Promise.all([claudeAuthStatus(), codexAuthStatus()]);
680
+ const [claudeRaw, codexRaw] = await Promise.all([claudeAuthStatus(), codexAuthStatus()]);
681
+ const claude = withObservedFailures('claude', claudeRaw);
682
+ const codex = withObservedFailures('codex', codexRaw);
683
+ logVerdictChange('claude', claude);
684
+ logVerdictChange('codex', codex);
287
685
  return { claude, codex };
288
686
  }
289
687
  //# sourceMappingURL=auth-relay.js.map