@bridge4dev/runner 0.65.1 → 0.66.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.
@@ -6,8 +6,10 @@ import { promisify } from 'node:util';
6
6
  import { log } from './log.js';
7
7
  import { maskString } from './policy.js';
8
8
  import { runnerIdentity, whichExecutable } from './environment.js';
9
- import { applyStoredClaudeToken, clearStoredClaudeToken, extractOauthToken, storeClaudeToken, storedClaudeToken, } from './agent-auth.js';
9
+ import { applyStoredClaudeToken, environmentCarriesStoredToken, extractOauthToken, readAgentAuth, storeClaudeToken, } from './agent-auth.js';
10
10
  import { invalidateUsageCache } from './adapters/claude-usage.js';
11
+ import { AccountError, activeAccountId, adoptLoginResult as adoptClaudeLogin, buildAccountList, clearLoginExpired, discardStagingHome as discardClaudeStagingHome, identityProbeEnv, machineLoginStatus, markLoginExpired, prepareStagingHome as prepareClaudeStagingHome, readActiveAccountSummary, refreshAccountIdentity, savedLoginStatus, setActiveAccount, } from './claude-homes.js';
12
+ import { MACHINE_ACCOUNT_ID, clearRefusal, noteRefusal, refusalActive } from './login-marks.js';
11
13
  import { adoptLoginResult, discardStagingHome, prepareStagingHome, repairCodexAuth, stagingCodexHomePath, } from './adapters/codex-home.js';
12
14
  const execFileAsync = promisify(execFile);
13
15
  /* eslint-disable no-control-regex -- this module parses raw pty output, so
@@ -63,7 +65,16 @@ export function extractLoginUrl(agent, raw) {
63
65
  * outside DevBridge) — that one corrects itself within the cache interval.
64
66
  */
65
67
  function forgetClaudeUsage() {
66
- invalidateUsageCache();
68
+ // The machine's slots only (#422 R16): the slot of the subscription the
69
+ // machine WAS, and the one of a machine nobody has identified yet. A saved
70
+ // account's figures are not touched by a sign-in to the machine.
71
+ // The identity ON FILE, not the checked one: by now the new login has
72
+ // rewritten the home, so the checked identity is already «contradicted» and
73
+ // would name nothing – and the slot to drop is the one of the login replaced.
74
+ const known = readAgentAuth().claudeMachine?.lastSeenIdentity?.orgId;
75
+ if (known)
76
+ invalidateUsageCache({ id: MACHINE_ACCOUNT_ID, orgId: known });
77
+ invalidateUsageCache({ id: MACHINE_ACCOUNT_ID });
67
78
  }
68
79
  export function extractDeviceCode(raw) {
69
80
  // Device-auth user codes look like XXXX-XXXX (letters/digits).
@@ -172,6 +183,61 @@ export class AuthRelay {
172
183
  /** Start (or restart) a login flow and wait until the sign-in URL appears. */
173
184
  async start(agent) {
174
185
  this.cancel();
186
+ this.assertCanRun(agent);
187
+ try {
188
+ return await this.startWith(agent, this.commands[agent] ?? '', false, {
189
+ flow: 'legacy',
190
+ target: 'machine',
191
+ });
192
+ }
193
+ catch (error) {
194
+ // Only the one recoverable shape: a `claude` too old to have `auth
195
+ // login`. Everything else is the answer, not a reason to try again.
196
+ if (agent !== 'claude' || !looksLikeUnsupportedSubcommand(String(error)))
197
+ throw error;
198
+ log.warn('auth-relay: this claude has no `auth login` — falling back to setup-token');
199
+ return this.startWith(agent, CLAUDE_LEGACY_LOGIN, true, {
200
+ flow: 'legacy',
201
+ target: 'machine',
202
+ });
203
+ }
204
+ }
205
+ /**
206
+ * `agent_account_login_start` for Claude (#422 §8, R2).
207
+ *
208
+ * `saved` signs in inside a fresh staging home (`CLAUDE_CONFIG_DIR`), which
209
+ * becomes a saved account only when the code is accepted – the machine's own
210
+ * login is not touched by it at all. `machine` is today's sign-in into
211
+ * `~/.claude`, the one legitimate way to write that file, pressed by a person.
212
+ *
213
+ * Either way the CLI runs WITHOUT the operator's `CLAUDE_CODE_OAUTH_TOKEN`
214
+ * (and API keys): the CLI puts that variable above every login file, so a
215
+ * sign-in started with it could report the token's blind identity instead of
216
+ * the account that just signed in.
217
+ *
218
+ * No `setup-token` fallback for `saved`: that command stores nothing in a home
219
+ * (it prints an inference-only token, D1), so a «saved account» made from it
220
+ * would be a row with no login. `machine` keeps the fallback it always had.
221
+ */
222
+ async startAccountLogin(target) {
223
+ this.cancel();
224
+ this.assertCanRun('claude');
225
+ const command = this.commands.claude ?? '';
226
+ try {
227
+ return await this.startWith('claude', command, false, { flow: 'account', target });
228
+ }
229
+ catch (error) {
230
+ if (!looksLikeUnsupportedSubcommand(String(error)))
231
+ throw error;
232
+ if (target === 'saved') {
233
+ throw new Error('the Claude CLI on this server is too old to sign in to a saved account – update Claude Code and try again', { cause: error });
234
+ }
235
+ log.warn('auth-relay: this claude has no `auth login` — falling back to setup-token');
236
+ return this.startWith('claude', CLAUDE_LEGACY_LOGIN, true, { flow: 'account', target });
237
+ }
238
+ }
239
+ /** The CLI and the pty helper are both there – or a sentence saying which is not. */
240
+ assertCanRun(agent) {
175
241
  const command = this.commands[agent] ?? '';
176
242
  if (!commandExists(command)) {
177
243
  const binary = command.trim().split(/\s+/)[0] ?? agent;
@@ -184,19 +250,8 @@ export class AuthRelay {
184
250
  // util-linux, and the only reason a pty exists here at all.
185
251
  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');
186
252
  }
187
- try {
188
- return await this.startWith(agent, this.commands[agent] ?? '', false);
189
- }
190
- catch (error) {
191
- // Only the one recoverable shape: a `claude` too old to have `auth
192
- // login`. Everything else is the answer, not a reason to try again.
193
- if (agent !== 'claude' || !looksLikeUnsupportedSubcommand(String(error)))
194
- throw error;
195
- log.warn('auth-relay: this claude has no `auth login` — falling back to setup-token');
196
- return this.startWith(agent, CLAUDE_LEGACY_LOGIN, true);
197
- }
198
253
  }
199
- async startWith(agent, command, captureToken) {
254
+ async startWith(agent, command, captureToken, door) {
200
255
  this.cancel();
201
256
  // Codex logs into a THROWAWAY home and is promoted only on success. The
202
257
  // old flow deleted the shared credential link up front, so abandoning the
@@ -204,10 +259,19 @@ export class AuthRelay {
204
259
  // with no way back except restarting the daemon — and writing through the
205
260
  // link would have overwritten the host user's own account (QA-100 MINOR-5).
206
261
  const stagingHome = agent === 'codex' ? prepareStagingHome() : null;
262
+ // A saved Claude account signs in inside its own staging home (#422 R2).
263
+ // `captureToken` never reaches here with `saved` – see `startAccountLogin`.
264
+ const claudeStaging = agent === 'claude' && door.flow === 'account' && door.target === 'saved'
265
+ ? prepareClaudeStagingHome()
266
+ : null;
207
267
  const proc = spawn('script', ['-qec', command, '/dev/null'], {
208
268
  stdio: ['pipe', 'pipe', 'pipe'],
209
- // Codex must log in to a home WE control, never the host user's ~/.codex.
210
- env: relayEnv(stagingHome ? { CODEX_HOME: stagingHome } : {}),
269
+ env: door.flow === 'account'
270
+ ? // The staging home, or the machine's own `~/.claude` (no variable);
271
+ // and in both, no operator token and no API key (§8).
272
+ identityProbeEnv(claudeStaging, relayEnv())
273
+ : // Codex must log in to a home WE control, never the host user's ~/.codex.
274
+ relayEnv(stagingHome ? { CODEX_HOME: stagingHome } : {}),
211
275
  });
212
276
  const relay = {
213
277
  agent,
@@ -217,7 +281,14 @@ export class AuthRelay {
217
281
  exitCode: null,
218
282
  command,
219
283
  captureToken,
220
- killTimer: setTimeout(() => this.cancel(), RELAY_MAX_LIFETIME_MS),
284
+ flow: door.flow,
285
+ target: door.target,
286
+ claudeStaging,
287
+ exchanging: false,
288
+ killTimer: setTimeout(() => {
289
+ if (this.active === relay)
290
+ this.cancel();
291
+ }, RELAY_MAX_LIFETIME_MS),
221
292
  };
222
293
  relay.killTimer.unref();
223
294
  this.active = relay;
@@ -265,17 +336,24 @@ export class AuthRelay {
265
336
  const deadline = Date.now() + URL_START_TIMEOUT_MS;
266
337
  for (;;) {
267
338
  const url = extractLoginUrl(agent, relay.buffer);
339
+ if (url && this.active !== relay) {
340
+ // A newer start took the slot while this one waited: its CLI is killed,
341
+ // and a link to it would lead nowhere – a code pasted for it would reach
342
+ // the newer CLI (found by the tests of the S2 check).
343
+ this.abandon(relay);
344
+ throw new Error('a newer sign-in was started on this server – use its link');
345
+ }
268
346
  if (url) {
269
347
  const code = agent === 'codex' ? extractDeviceCode(relay.buffer) : null;
270
348
  return { url, ...(code ? { code } : {}), expectsCode: agent === 'claude' };
271
349
  }
272
350
  if (relay.exited) {
273
351
  const tail = maskString(rejoinWrappedSecrets(stripControl(relay.buffer))).slice(-400);
274
- this.cancel();
352
+ this.abandon(relay);
275
353
  throw new Error(`${agent} login exited before printing a sign-in URL: ${tail}`);
276
354
  }
277
355
  if (Date.now() > deadline) {
278
- this.cancel();
356
+ this.abandon(relay);
279
357
  throw new Error(`${agent} login did not print a sign-in URL in time`);
280
358
  }
281
359
  await sleep(200);
@@ -284,41 +362,137 @@ export class AuthRelay {
284
362
  /** Paste the confirmation code back into the waiting CLI (Claude flow). */
285
363
  async submitCode(agent, code) {
286
364
  const relay = this.active;
287
- if (!relay || relay.agent !== agent) {
365
+ // A sign-in started through the account commands is finished only by them.
366
+ if (!relay || relay.agent !== agent || relay.flow !== 'legacy') {
288
367
  return { ok: false, detail: 'No login in progress — start again' };
289
368
  }
369
+ const exchanged = await this.exchangeCode(relay, code);
370
+ if (!('raw' in exchanged))
371
+ return exchanged;
372
+ // Exit 0 is the CLI's opinion. Ours has to be «is this machine signed
373
+ // in now», because those two came apart before and nobody noticed for
374
+ // weeks: `setup-token` exits 0 over a credential it never stored.
375
+ return this.confirmSignedIn(relay, exchanged.raw);
376
+ }
377
+ /**
378
+ * `agent_account_login_code` – finish a sign-in the account commands started.
379
+ *
380
+ * `saved`: the staging home is adopted by rename – one subscription, one row,
381
+ * the signed-in row active (`adoptLoginResult`, R15). `machine`: the same
382
+ * check the old door makes, then the machine login becomes active and its
383
+ * identity is asked once, so the answer names who ACTUALLY signed in (§2:
384
+ * «Signed in as B, not A») rather than who the machine was before.
385
+ */
386
+ async submitAccountCode(code) {
387
+ const relay = this.active;
388
+ if (!relay || relay.agent !== 'claude' || relay.flow !== 'account') {
389
+ return { ok: false, detail: 'No login in progress – start again' };
390
+ }
391
+ const exchanged = await this.exchangeCode(relay, code);
392
+ if (!('raw' in exchanged)) {
393
+ return { ok: false, detail: exchanged.detail ?? 'Login failed' };
394
+ }
395
+ // The door and the target that STARTED the sign-in decide how it ends (§8,
396
+ // R2): a saved sign-in is never finished as the machine's, even when its
397
+ // staging home was removed in the moment between the CLI's exit and this
398
+ // line (found by the independent check of S2).
399
+ if (relay.target === 'saved') {
400
+ if (!relay.claudeStaging) {
401
+ return {
402
+ ok: false,
403
+ detail: 'the sign-in was abandoned before it could be saved – start again',
404
+ };
405
+ }
406
+ try {
407
+ const adopted = await adoptClaudeLogin(relay.claudeStaging);
408
+ // Adopted: the staging path no longer exists, nothing left to remove.
409
+ relay.claudeStaging = null;
410
+ clearAgentAuthFailure('claude', adopted.account.id);
411
+ return {
412
+ ok: true,
413
+ account: adopted.account,
414
+ active: adopted.active,
415
+ ...(adopted.replaced ? { replaced: adopted.replaced } : {}),
416
+ };
417
+ }
418
+ catch (error) {
419
+ this.discardStaging(relay);
420
+ return {
421
+ ok: false,
422
+ detail: error instanceof AccountError
423
+ ? error.message
424
+ : maskString(String(error instanceof Error ? error.message : error)).slice(0, 400),
425
+ };
426
+ }
427
+ }
428
+ const confirmed = await this.confirmSignedIn(relay, exchanged.raw);
429
+ if (!confirmed.ok)
430
+ return { ok: false, detail: confirmed.detail ?? 'Login failed' };
431
+ // R14 (b): one ask, at the adoption of a sign-in – the login file was just
432
+ // rewritten, and the list must not name the previous subscription.
433
+ await refreshAccountIdentity(MACHINE_ACCOUNT_ID, { force: true }).catch(() => null);
434
+ const list = buildAccountList();
435
+ const account = list.accounts.find((row) => row.id === MACHINE_ACCOUNT_ID);
436
+ if (!account)
437
+ return { ok: false, detail: 'the machine login is missing from the list' };
438
+ return { ok: true, account, active: list.active };
439
+ }
440
+ /**
441
+ * Write the code, wait for the CLI's answer.
442
+ *
443
+ * `{ raw }` – the CLI exited 0 and the relay is released (its staging home, if
444
+ * any, is kept for the caller to adopt). Anything else is the answer to hand
445
+ * back: a non-zero exit or a timeout ends the sign-in; a line saying «invalid»
446
+ * keeps it alive, because the CLI usually asks again.
447
+ */
448
+ async exchangeCode(relay, code) {
449
+ // Checked before «exited», and in the same tick as the flag is set below: a
450
+ // second code arriving in the up to 300 ms between the CLI's exit and the
451
+ // running loop's next look used to take that exit as ITS cue to abandon the
452
+ // sign-in – and removed the staging home with the login the CLI had just
453
+ // written (found by the second independent check of S2).
454
+ if (relay.exchanging) {
455
+ return {
456
+ ok: false,
457
+ detail: 'A code for this sign-in is already being checked – wait for its answer',
458
+ };
459
+ }
290
460
  if (relay.exited) {
291
- this.cancel();
461
+ this.abandon(relay);
292
462
  return { ok: false, detail: 'The login process has already exited — start again' };
293
463
  }
294
- relay.proc.stdin?.write(`${code.trim()}\n`);
295
- const deadline = Date.now() + CODE_EXCHANGE_TIMEOUT_MS;
296
- const bufferMark = relay.buffer.length;
297
- while (Date.now() < deadline) {
298
- const fresh = stripControl(relay.buffer.slice(bufferMark));
299
- if (relay.exited) {
300
- const raw = relay.buffer;
301
- this.cancel();
302
- if (relay.exitCode !== 0) {
303
- return {
304
- ok: false,
305
- detail: maskString(rejoinWrappedSecrets(fresh)).slice(-400) || 'Login failed',
306
- };
464
+ relay.exchanging = true;
465
+ try {
466
+ relay.proc.stdin?.write(`${code.trim()}\n`);
467
+ const deadline = Date.now() + CODE_EXCHANGE_TIMEOUT_MS;
468
+ const bufferMark = relay.buffer.length;
469
+ while (Date.now() < deadline) {
470
+ const fresh = stripControl(relay.buffer.slice(bufferMark));
471
+ if (relay.exited) {
472
+ const raw = relay.buffer;
473
+ if (relay.exitCode !== 0) {
474
+ this.abandon(relay);
475
+ return {
476
+ ok: false,
477
+ detail: maskString(rejoinWrappedSecrets(fresh)).slice(-400) || 'Login failed',
478
+ };
479
+ }
480
+ this.release(relay);
481
+ return { raw };
307
482
  }
308
- // Exit 0 is the CLI's opinion. Ours has to be «is this machine signed
309
- // in now», because those two came apart before and nobody noticed for
310
- // weeks: `setup-token` exits 0 over a credential it never stored.
311
- return this.confirmSignedIn(relay, raw);
312
- }
313
- if (/invalid|error|failed|expired/i.test(fresh)) {
314
- // The CLI usually re-prompts after a bad code; surface it and keep
315
- // the relay alive so the user can retry with a corrected code.
316
- return { ok: false, detail: maskString(rejoinWrappedSecrets(fresh)).trim().slice(-400) };
483
+ if (/invalid|error|failed|expired/i.test(fresh)) {
484
+ // The CLI usually re-prompts after a bad code; surface it and keep
485
+ // the relay alive so the user can retry with a corrected code.
486
+ return { ok: false, detail: maskString(rejoinWrappedSecrets(fresh)).trim().slice(-400) };
487
+ }
488
+ await sleep(300);
317
489
  }
318
- await sleep(300);
490
+ this.abandon(relay);
491
+ return { ok: false, detail: 'Timed out waiting for the login to complete' };
492
+ }
493
+ finally {
494
+ relay.exchanging = false;
319
495
  }
320
- this.cancel();
321
- return { ok: false, detail: 'Timed out waiting for the login to complete' };
322
496
  }
323
497
  /**
324
498
  * The CLI exited 0 — but is the machine actually signed in?
@@ -352,16 +526,21 @@ export class AuthRelay {
352
526
  log.info('auth-relay: stored a long-lived Claude token for this runner');
353
527
  clearAgentAuthFailure('claude');
354
528
  forgetClaudeUsage();
529
+ activateMachineLogin();
355
530
  return { ok: true, detail: 'signed in with a long-lived token stored on this server' };
356
531
  }
357
532
  // `claude auth login` writes the credential just before it exits; give the
358
533
  // filesystem a couple of beats rather than racing it.
359
- const probe = this.deps.claudeStatus ?? claudeAuthStatus;
534
+ // The MACHINE login, explicitly: this relay signs in to `~/.claude`, and with
535
+ // a saved account active «the active verdict» would be about a different
536
+ // home than the one this sign-in just wrote (#422).
537
+ const probe = this.deps.claudeStatus ?? (() => claudeAuthStatus(undefined, { account: 'machine' }));
360
538
  for (let attempt = 0; attempt < 4; attempt++) {
361
539
  const status = await probe();
362
540
  if (status.status === 'ok') {
363
541
  clearAgentAuthFailure('claude');
364
542
  forgetClaudeUsage();
543
+ activateMachineLogin();
365
544
  return { ok: true };
366
545
  }
367
546
  await sleep(300);
@@ -374,9 +553,24 @@ export class AuthRelay {
374
553
  }
375
554
  cancel() {
376
555
  const relay = this.active;
377
- if (!relay)
378
- return;
379
- this.active = null;
556
+ if (relay)
557
+ this.abandon(relay);
558
+ }
559
+ /**
560
+ * End THIS sign-in: its process, its slot if it still holds it, its staging
561
+ * home. A loop driving an older sign-in must never end a newer one that took
562
+ * the slot meanwhile – with `cancel()` in its place, two starts close together
563
+ * killed each other, and a stale code exchange killed the newer start (found by
564
+ * the independent check of S2).
565
+ */
566
+ abandon(relay) {
567
+ this.release(relay);
568
+ this.discardStaging(relay);
569
+ }
570
+ /** Stop driving a relay: its process, its timer, its slot. Its staging home stays. */
571
+ release(relay) {
572
+ if (this.active === relay)
573
+ this.active = null;
380
574
  clearTimeout(relay.killTimer);
381
575
  try {
382
576
  relay.proc.kill('SIGKILL');
@@ -385,207 +579,120 @@ export class AuthRelay {
385
579
  // already gone
386
580
  }
387
581
  }
582
+ /** A sign-in that will not be adopted leaves nothing behind (§8, S2 item 2). */
583
+ discardStaging(relay) {
584
+ const staging = relay.claudeStaging;
585
+ if (!staging)
586
+ return;
587
+ relay.claudeStaging = null;
588
+ try {
589
+ discardClaudeStagingHome(staging);
590
+ }
591
+ catch (error) {
592
+ log.warn('auth-relay: could not remove an abandoned sign-in home', {
593
+ error: String(error),
594
+ });
595
+ }
596
+ }
597
+ }
598
+ /**
599
+ * A sign-in into the machine's own login makes that login active (#422 R15).
600
+ *
601
+ * The old window's sign-in means «this login from now on» – with several logins
602
+ * switched off for the organization, «a token replaces the token» (D27). A saved
603
+ * account left active over it would keep every new session on the previous
604
+ * subscription while the window said the machine was signed in again.
605
+ */
606
+ function activateMachineLogin() {
607
+ try {
608
+ setActiveAccount(MACHINE_ACCOUNT_ID);
609
+ }
610
+ catch (error) {
611
+ log.warn('auth-relay: could not make the machine login active', { error: String(error) });
612
+ }
388
613
  }
389
614
  function sleep(ms) {
390
615
  return new Promise((resolve) => setTimeout(resolve, ms));
391
616
  }
392
- function isNonEmptyString(value) {
393
- return typeof value === 'string' && value.length > 0;
394
- }
617
+ // ─── Auth health probe ───────────────────────────────────────────────
395
618
  /**
396
- * A millisecond timestamp we are willing to hand to `new Date(...)`.
619
+ * Claude: the verdict on the account the next session starts under (#422 R14).
397
620
  *
398
- * The bound is not decoration: `new Date(1e21).toISOString()` throws
399
- * `RangeError`, and thrown out of here it takes BOTH agents' verdicts down
400
- * with it (they share one `Promise.all`) on every poll, forever, because only
401
- * successes are cached (QA-117 M1).
621
+ * The machine login – the pre-#422 rule word for word: the operator's variable,
622
+ * then `~/.claude/.credentials.json`, then a token this runner captured. A
623
+ * saved account – ONLY its own credentials file: a session under it has the
624
+ * operator's token taken out of its environment, so letting the variable or the
625
+ * captured token answer here would paint a green verdict over a switch that
626
+ * does not work (#121, К14). Nothing here runs a CLI; the file judge and its
627
+ * reasons live in `claude-homes.ts` so the account list reads the same lines.
628
+ *
629
+ * The RUNNER reads these files (its own host user's) — the agent itself is still
630
+ * denied these paths by layer-1 policy.
631
+ *
632
+ * `account: 'machine'` asks about the machine login whatever is active – what the
633
+ * sign-in relay needs after writing `~/.claude`.
402
634
  */
403
- const MAX_TIMESTAMP_MS = 8.64e15;
404
- function asTimestamp(value) {
405
- if (typeof value !== 'number' || !Number.isFinite(value))
406
- return undefined;
407
- return Math.abs(value) <= MAX_TIMESTAMP_MS ? value : undefined;
635
+ export async function claudeAuthStatus(homedir = os.homedir(), options = {}) {
636
+ const id = options.account === 'machine' ? MACHINE_ACCOUNT_ID : activeAccountId();
637
+ const status = id === MACHINE_ACCOUNT_ID ? machineLoginStatus(homedir) : savedLoginStatus(id);
638
+ return { ...status, activeAccount: readActiveAccountSummary(id) };
408
639
  }
640
+ // ─── What the agent actually experienced ─────────────────────────────
641
+ //
642
+ // The memory itself lives in `login-marks.ts` since #422 – keyed by agent AND
643
+ // account, because the list of accounts and this verdict must agree on it.
409
644
  /**
410
- * Claude: the subscription login is recorded in the CLI's own credentials file.
411
- * The RUNNER reads it (its own host user's file) — the agent itself is still
412
- * denied this path by layer-1 policy.
645
+ * A session just failed to authenticate as this agent – under this account.
413
646
  *
414
- * `expiresAt` is NOT the login. It is the expiry of a short-lived access token
415
- * (~8 hours on a live file), and next to it sits `refreshToken` with
416
- * `refreshTokenExpiresAt` ~26 days out, which the CLI spends silently on its
417
- * next run. Judging the login by `expiresAt` alone is why every server nobody
418
- * had touched since the morning reported "login expired — re-login needed"
419
- * over a login that was good for another three weeks (#121). Codex has carried
420
- * exactly this guard since day one (`readCodexCredential`); Claude did not.
421
- *
422
- * Deliberately NOT asking the CLI. `claude auth status --json` looks like an
423
- * arbiter and is not one: measured live (SDK binary 2.1.218), it answers
424
- * `loggedIn: true` for a credential whose access token has expired AND which
425
- * carries no refresh token at all — i.e. for a genuinely dead login. It never
426
- * leaves the machine, so it cannot see a server-side revocation either. It
427
- * would have cost a ~800 ms / ~300 MB subprocess per poll under `MemoryMax=2G`
428
- * (gotcha #100) and echoed the account's e-mail and org name to every member of
429
- * the organization, in exchange for no truth at all. A revoked login is caught
430
- * instead by `noteAgentAuthFailure` below — from a real refusal, not a guess.
647
+ * `accountId` is the account the SESSION started under (R14), not whatever the
648
+ * machine has active now. Nothing is deleted any more (#422 S1 item 3, D2): the
649
+ * old code threw a captured Claude token away on the spot, and that meant
650
+ * erasing the file the saved accounts are listed in. A Claude refusal is kept as
651
+ * a mark on the row instead – it outlives a daemon restart, and a login written
652
+ * after it (a refresh, a new sign-in) outranks it.
431
653
  */
432
- export async function claudeAuthStatus(homedir = os.homedir()) {
433
- if (process.env['CLAUDE_CODE_OAUTH_TOKEN']) {
434
- return { status: 'ok', detail: 'CLAUDE_CODE_OAUTH_TOKEN is configured' };
435
- }
436
- /**
437
- * A token this runner captured itself (the `setup-token` fallback) is the
438
- * LAST word, never the first.
439
- *
440
- * It used to short-circuit ahead of the credentials file, and that was three
441
- * bugs in one line: a real `/login` afterwards could never show through, the
442
- * post-exchange re-probe could not fail (so `confirmSignedIn` always agreed
443
- * with itself), and there was no way to get back to «signed out» short of
444
- * deleting a file nobody documents. Read below, after the file has had its
445
- * say — and read from disk rather than from the environment, so `doctor` (a
446
- * different process, which never applied it) gives the same verdict as the
447
- * daemon.
448
- */
449
- const fallbackToken = () => storedClaudeToken()
450
- ? {
451
- status: 'ok',
452
- detail: 'signed in with a long-lived token stored on this server',
453
- }
454
- : null;
455
- const file = path.join(homedir, '.claude', '.credentials.json');
456
- let raw;
457
- try {
458
- raw = fs.readFileSync(file, 'utf8');
459
- }
460
- catch (error) {
461
- const code = error.code;
462
- // Never signed in here is a different answer from "we could not look".
463
- // EACCES on somebody else's HOME used to read as "not signed in", which
464
- // sends the user re-authenticating a credential that is sitting right
465
- // there (the same mistake #121 is about, one layer down).
466
- if (code === 'ENOENT' || code === 'ENOTDIR') {
467
- return fallbackToken() ?? { status: 'missing', detail: 'No Claude login on this server' };
468
- }
469
- // No `log.warn` here: this probe is on a 60-second timer since #121, and a
470
- // machine with EACCES on that file would write the same line forever.
471
- // `logVerdictChange` already reports the `unknown`, once, when it starts
472
- // (QA-117 L5).
473
- return {
474
- status: 'unknown',
475
- detail: `could not read the login on this server (${code ?? 'unknown error'})`,
476
- };
477
- }
478
- let oauth;
479
- try {
480
- oauth = JSON.parse(raw).claudeAiOauth;
481
- }
482
- catch {
483
- // Truncated or hand-edited file: the CLI cannot use it either, and signing
484
- // in again is the fix — so say `missing` (which offers that button) rather
485
- // than `unknown` (which offers nothing).
486
- return { status: 'missing', detail: 'the stored login could not be read — sign in again' };
487
- }
488
- // A parseable file is not a credential. `{"claudeAiOauth":{}}` is what a
489
- // partial write and a hand-edit both leave behind, and reading it as a
490
- // healthy login puts a green dot and NO way out on the panel — «I cannot
491
- // tell» turned into «all good», which is the rule this ticket exists to
492
- // uphold, upside down (QA-117 M2).
493
- if (!oauth || typeof oauth !== 'object' || Array.isArray(oauth)) {
494
- return { status: 'missing', detail: 'the stored login could not be read — sign in again' };
495
- }
496
- const hasAccess = isNonEmptyString(oauth.accessToken);
497
- const hasRefresh = isNonEmptyString(oauth.refreshToken);
498
- const now = Date.now();
499
- const accessExpiry = asTimestamp(oauth.expiresAt);
500
- const refreshExpiry = asTimestamp(oauth.refreshTokenExpiresAt);
501
- // Nothing recognisable in the blob at all — no token, not even a date. That
502
- // is «never signed in here», and it must offer the button that fixes it.
503
- if (!hasAccess && !hasRefresh && accessExpiry === undefined) {
504
- return fallbackToken() ?? { status: 'missing', detail: 'No subscription login found' };
654
+ export function noteAgentAuthFailure(agent, accountId = MACHINE_ACCOUNT_ID) {
655
+ noteRefusal(agent, accountId);
656
+ log.warn('auth-relay: agent sign-in refused during a session', { agent, account: accountId });
657
+ if (agent !== 'claude')
658
+ return;
659
+ if (accountId !== MACHINE_ACCOUNT_ID) {
660
+ markLoginExpired(accountId);
661
+ return;
505
662
  }
506
- // A date we can read outranks the token beside it (the pre-#121 contract, and
507
- // the reason a dated-but-token-less fixture still reads as expired); with no
508
- // readable date, the presence of the token is all we have.
509
- const accessLive = accessExpiry === undefined ? hasAccess : accessExpiry > now;
510
- // No `refreshTokenExpiresAt` next to a refresh token means the CLI did not
511
- // record one — that is not evidence of death, so we do not read it as death.
512
- const refreshLive = hasRefresh && (refreshExpiry === undefined || refreshExpiry > now);
513
- // Report the date this login actually dies on, not the one that moves every
514
- // eight hours: a panel reading "token until <today>" is alarming and wrong.
515
- // Only while the refresh token is the operative one, though — a live access
516
- // token beside a dead refresh token dies on its OWN date. And when the CLI
517
- // recorded no date for a live refresh token we say NOTHING: printing the
518
- // access token's lapsed date beside the word «signed in» is the very screen
519
- // this function's docblock promises not to draw (QA-117 M3).
520
- const effectiveExpiry = refreshLive ? refreshExpiry : accessExpiry;
521
- const expiresAt = effectiveExpiry === undefined ? undefined : new Date(effectiveExpiry).toISOString();
522
- if (!accessLive && !refreshLive) {
523
- return (fallbackToken() ?? {
524
- status: 'expired',
525
- ...(expiresAt ? { expiresAt } : {}),
526
- detail: 'the stored login has expired',
527
- });
663
+ // The machine row carries a mark on disk only about the token this runner
664
+ // captured, and only when the refused session ran WITH it. The token then
665
+ // leaves this daemon's environment, so the next machine session falls back to
666
+ // the credentials file instead of presenting the same dead token – and a
667
+ // restart does not put it back (`applyStoredClaudeToken` reads the mark).
668
+ if (environmentCarriesStoredToken()) {
669
+ markLoginExpired(MACHINE_ACCOUNT_ID);
670
+ delete process.env['CLAUDE_CODE_OAUTH_TOKEN'];
671
+ log.warn('auth-relay: the stored Claude token was refused – no longer used by new sessions');
528
672
  }
529
- return {
530
- status: 'ok',
531
- ...(expiresAt ? { expiresAt } : {}),
532
- // Only a plain string, and only a short one: this value is read off disk
533
- // and printed in every member's panel.
534
- ...(isNonEmptyString(oauth.subscriptionType)
535
- ? { detail: `subscription ${oauth.subscriptionType.slice(0, 40)}` }
536
- : {}),
537
- };
538
673
  }
539
- // ─── What the agent actually experienced ─────────────────────────────
540
- //
541
- // The credentials file cannot tell us the provider revoked this login: the
542
- // refresh token still sits there, dated. The one authority on that is a real
543
- // refusal from a real run — so a session that failed to authenticate demotes
544
- // the file's verdict for a while, and any successful turn clears it again.
545
- //
546
- // Process memory on purpose: no protocol, no disk. The consequence is worth
547
- // naming — restarting the daemon forgets the refusal and the panel goes back
548
- // to trusting the file until the next session tries.
549
- const AUTH_FAILURE_TTL_MS = 15 * 60_000;
550
- const authFailures = new Map();
551
- /** A session just failed to authenticate as this agent. */
552
- export function noteAgentAuthFailure(agent) {
553
- authFailures.set(agent, Date.now());
554
- log.warn('auth-relay: agent sign-in refused during a session', { agent });
555
- // A refusal is the only authority on a revoked credential, and a token we
556
- // captured ourselves has no other expiry we can see. Keeping it would let a
557
- // dead login outlive the evidence: the failure marker times out after 15
558
- // minutes and the panel would go green again over the same dead token.
559
- if (agent === 'claude' && storedClaudeToken()) {
560
- clearStoredClaudeToken();
561
- log.warn('auth-relay: discarded the stored Claude token after a refusal');
674
+ /** The agent just worked under this account — whatever was wrong with its sign-in is not. */
675
+ export function clearAgentAuthFailure(agent, accountId = MACHINE_ACCOUNT_ID) {
676
+ if (clearRefusal(agent, accountId)) {
677
+ log.info('auth-relay: agent sign-in is working again', { agent, account: accountId });
562
678
  }
563
- }
564
- /** The agent just worked — whatever was wrong with the sign-in is not. */
565
- export function clearAgentAuthFailure(agent) {
566
- if (authFailures.delete(agent)) {
567
- log.info('auth-relay: agent sign-in is working again', { agent });
679
+ if (agent !== 'claude')
680
+ return;
681
+ // A machine session that worked on the credentials FILE says nothing about the
682
+ // captured token: its mark is lifted only by a session that ran with it.
683
+ if (accountId !== MACHINE_ACCOUNT_ID || environmentCarriesStoredToken()) {
684
+ clearLoginExpired(accountId);
568
685
  }
569
686
  }
570
687
  /**
571
- * Is a refusal still being held against this agent?
688
+ * Is a refusal still being held against this agent (and account)?
572
689
  *
573
690
  * Exported so the wiring in `supervisor.ts` can be pinned by a test without
574
691
  * shelling out to the agent CLIs: this predicate IS the mechanism the panel's
575
692
  * demotion reads, and three unguarded lines were carrying it (QA-117 M6).
576
693
  */
577
- export function agentAuthFailureActive(agent) {
578
- return hasRecentAuthFailure(agent);
579
- }
580
- function hasRecentAuthFailure(agent) {
581
- const at = authFailures.get(agent);
582
- if (at === undefined)
583
- return false;
584
- if (Date.now() - at > AUTH_FAILURE_TTL_MS) {
585
- authFailures.delete(agent);
586
- return false;
587
- }
588
- return true;
694
+ export function agentAuthFailureActive(agent, accountId = MACHINE_ACCOUNT_ID) {
695
+ return refusalActive(agent, accountId);
589
696
  }
590
697
  /** Last verdict we published per agent — logged only when it changes. */
591
698
  const lastVerdict = new Map();
@@ -688,18 +795,18 @@ function readCodexCredential(homePath) {
688
795
  * and never the other way round — a file saying `expired` is not made `ok` by
689
796
  * the absence of failures.
690
797
  */
691
- function withObservedFailures(agent, status) {
692
- if (status.status !== 'ok' || !hasRecentAuthFailure(agent))
798
+ function withObservedFailures(agent, status, accountId = MACHINE_ACCOUNT_ID) {
799
+ if (status.status !== 'ok' || !refusalActive(agent, accountId))
693
800
  return status;
694
801
  return {
802
+ ...status,
695
803
  status: 'expired',
696
- ...(status.expiresAt ? { expiresAt: status.expiresAt } : {}),
697
804
  detail: 'the agent was refused with this sign-in — re-login needed',
698
805
  };
699
806
  }
700
807
  export async function agentAuthStatuses() {
701
808
  const [claudeRaw, codexRaw] = await Promise.all([claudeAuthStatus(), codexAuthStatus()]);
702
- const claude = withObservedFailures('claude', claudeRaw);
809
+ const claude = withObservedFailures('claude', claudeRaw, claudeRaw.activeAccount?.id ?? MACHINE_ACCOUNT_ID);
703
810
  const codex = withObservedFailures('codex', codexRaw);
704
811
  logVerdictChange('claude', claude);
705
812
  logVerdictChange('codex', codex);