@bridge4dev/runner 0.26.0 → 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.
@@ -0,0 +1,29 @@
1
+ export declare function agentAuthPath(): string;
2
+ /** The token we hold for Claude, or null once it is too old to trust. */
3
+ export declare function storedClaudeToken(): string | null;
4
+ export declare function storeClaudeToken(token: string): void;
5
+ export declare function clearStoredClaudeToken(): void;
6
+ /**
7
+ * Put a stored token into this process's environment, unless the operator
8
+ * already set one.
9
+ *
10
+ * Their variable wins on purpose: an explicitly exported
11
+ * `CLAUDE_CODE_OAUTH_TOKEN` in the unit or in `environment.d` is a deliberate
12
+ * choice, and silently overriding it with something we captured months ago is
13
+ * the kind of surprise this file exists to prevent.
14
+ *
15
+ * Returns true when it applied one.
16
+ */
17
+ export declare function applyStoredClaudeToken(): boolean;
18
+ /**
19
+ * The OAuth token `claude setup-token` printed, reassembled out of pty output.
20
+ *
21
+ * Written against how the output actually arrives rather than how it looks in a
22
+ * terminal: the pty wraps at its width, so a ~100-character token is routinely
23
+ * split across lines. Continuation lines are joined only while they consist
24
+ * ENTIRELY of token characters — prose ("Store this token securely") contains
25
+ * spaces or punctuation and stops the join, which is what keeps the reassembly
26
+ * from swallowing the sentence after it.
27
+ */
28
+ export declare function extractOauthToken(text: string): string | null;
29
+ //# sourceMappingURL=agent-auth.d.ts.map
@@ -0,0 +1,136 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { configDir } from './paths.js';
4
+ /**
5
+ * A Claude login this runner owns, for the one CLI that cannot store one.
6
+ *
7
+ * Background, because this file only makes sense with it. The dashboard's
8
+ * sign-in button relays a real OAuth flow to the machine — and until 0.27.0 it
9
+ * relayed `claude setup-token`, which is not a login at all. Measured against
10
+ * the 2.1.220 binary and confirmed by Anthropic's own docs: `setup-token` mints
11
+ * a long-lived INFERENCE-ONLY token, prints it, and «does not save the token
12
+ * anywhere». The runner threw the printed token away, then judged the login by
13
+ * reading `~/.claude/.credentials.json` — a file that command never writes. So
14
+ * the flow completed, the panel re-probed within a second, and answered «No
15
+ * Claude login on this server» underneath a success screen. That is the bug
16
+ * behind every «I signed in and it still says signed out».
17
+ *
18
+ * The main path is now `claude auth login`, which persists properly and needs
19
+ * nothing from this module. This exists for the fallback: a CLI old enough not
20
+ * to have `auth login` still has `setup-token`, and rather than leave that
21
+ * machine with no way in, we capture the token it prints and keep it ourselves.
22
+ * `CLAUDE_CODE_OAUTH_TOKEN` is already on the session env allowlist, so a stored
23
+ * token reaches the agent exactly like an operator-configured one.
24
+ *
25
+ * Stored on the machine, 0600, in the runner's own config directory. DevBridge
26
+ * never receives it: the token travels from the provider into a pty on this
27
+ * host and onto this host's disk.
28
+ */
29
+ const FILE_MODE = 0o600;
30
+ export function agentAuthPath() {
31
+ return path.join(configDir(), 'agent-auth.json');
32
+ }
33
+ /**
34
+ * How long a captured token is believed without further evidence.
35
+ *
36
+ * `claude setup-token` mints a one-year credential and there is no way to ask
37
+ * whether it is still good without spending a request. A stored token that
38
+ * never ages out is a green light nobody can turn off: the panel would keep
39
+ * saying «signed in» about a revoked account for as long as the file exists.
40
+ * Eleven months keeps it inside the token's own life while still expiring.
41
+ */
42
+ const TOKEN_MAX_AGE_MS = 334 * 24 * 60 * 60 * 1000;
43
+ function read() {
44
+ try {
45
+ const parsed = JSON.parse(fs.readFileSync(agentAuthPath(), 'utf8'));
46
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
47
+ return {};
48
+ return parsed;
49
+ }
50
+ catch {
51
+ return {};
52
+ }
53
+ }
54
+ /** The token we hold for Claude, or null once it is too old to trust. */
55
+ export function storedClaudeToken() {
56
+ const file = read();
57
+ const value = file.claudeOauthToken;
58
+ if (typeof value !== 'string' || value.length === 0)
59
+ return null;
60
+ const stamped = typeof file.updatedAt === 'string' ? Date.parse(file.updatedAt) : NaN;
61
+ if (Number.isFinite(stamped) && Date.now() - stamped > TOKEN_MAX_AGE_MS)
62
+ return null;
63
+ return value;
64
+ }
65
+ export function storeClaudeToken(token) {
66
+ const dir = configDir();
67
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
68
+ const file = agentAuthPath();
69
+ // Same write-then-rename as the config: a torn file here reads as «signed
70
+ // out» and sends somebody through the whole login again.
71
+ const tmp = path.join(dir, `.agent-auth.${process.pid}.tmp`);
72
+ fs.writeFileSync(tmp, `${JSON.stringify({ claudeOauthToken: token, updatedAt: new Date().toISOString() }, null, 2)}\n`, { mode: FILE_MODE });
73
+ fs.renameSync(tmp, file);
74
+ fs.chmodSync(file, FILE_MODE);
75
+ }
76
+ export function clearStoredClaudeToken() {
77
+ try {
78
+ fs.rmSync(agentAuthPath(), { force: true });
79
+ }
80
+ catch {
81
+ /* nothing to clear */
82
+ }
83
+ }
84
+ /**
85
+ * Put a stored token into this process's environment, unless the operator
86
+ * already set one.
87
+ *
88
+ * Their variable wins on purpose: an explicitly exported
89
+ * `CLAUDE_CODE_OAUTH_TOKEN` in the unit or in `environment.d` is a deliberate
90
+ * choice, and silently overriding it with something we captured months ago is
91
+ * the kind of surprise this file exists to prevent.
92
+ *
93
+ * Returns true when it applied one.
94
+ */
95
+ export function applyStoredClaudeToken() {
96
+ if (process.env['CLAUDE_CODE_OAUTH_TOKEN'])
97
+ return false;
98
+ const token = storedClaudeToken();
99
+ if (!token)
100
+ return false;
101
+ process.env['CLAUDE_CODE_OAUTH_TOKEN'] = token;
102
+ return true;
103
+ }
104
+ /**
105
+ * The OAuth token `claude setup-token` printed, reassembled out of pty output.
106
+ *
107
+ * Written against how the output actually arrives rather than how it looks in a
108
+ * terminal: the pty wraps at its width, so a ~100-character token is routinely
109
+ * split across lines. Continuation lines are joined only while they consist
110
+ * ENTIRELY of token characters — prose ("Store this token securely") contains
111
+ * spaces or punctuation and stops the join, which is what keeps the reassembly
112
+ * from swallowing the sentence after it.
113
+ */
114
+ export function extractOauthToken(text) {
115
+ const lines = text.split('\n').map((line) => line.trim());
116
+ for (let i = 0; i < lines.length; i++) {
117
+ const line = lines[i] ?? '';
118
+ const start = line.match(/sk-ant-oat[0-9]*-[A-Za-z0-9_-]*$/);
119
+ // The token must run to the end of its line; anything after it on the same
120
+ // line means this is prose mentioning a token, not the token itself.
121
+ if (!start)
122
+ continue;
123
+ let token = start[0];
124
+ for (let j = i + 1; j < lines.length; j++) {
125
+ const next = lines[j] ?? '';
126
+ if (!/^[A-Za-z0-9_-]+$/.test(next))
127
+ break;
128
+ token += next;
129
+ }
130
+ // Long enough to be real, short enough not to be a runaway join.
131
+ if (token.length >= 40 && token.length <= 400)
132
+ return token;
133
+ }
134
+ return null;
135
+ }
136
+ //# sourceMappingURL=agent-auth.js.map
@@ -24,18 +24,46 @@ export interface AgentAuthStatus {
24
24
  expiresAt?: string;
25
25
  detail?: string;
26
26
  }
27
+ /**
28
+ * Rejoin a secret the pty split across lines, so the masker can see it.
29
+ *
30
+ * `maskString` matches `sk-ant-[A-Za-z0-9_-]{8,}`, which a line break inside
31
+ * the token defeats — and the pty wraps at its width, so a ~100-character
32
+ * token arrives in pieces as a matter of course. Measured: the head gets
33
+ * masked and the tail is printed verbatim into an error detail that travels to
34
+ * the dashboard. Rejoining first costs nothing and closes it.
35
+ */
36
+ export declare function rejoinWrappedSecrets(text: string): string;
27
37
  /** Strip ANSI/OSC control sequences so text matching sees plain output. */
28
38
  export declare function stripControl(raw: string): string;
29
39
  export declare function extractLoginUrl(agent: RelayAgent, raw: string): string | null;
30
40
  export declare function extractDeviceCode(raw: string): string | null;
41
+ export interface AuthRelayDeps {
42
+ /**
43
+ * How we find out whether Claude is signed in, after the CLI says it is.
44
+ * Injected so a test can exercise the contract without an OAuth provider.
45
+ */
46
+ claudeStatus?: () => Promise<AgentAuthStatus>;
47
+ }
31
48
  export declare class AuthRelay {
32
49
  private readonly commands;
50
+ private readonly deps;
33
51
  private active;
34
- constructor(commands?: Record<RelayAgent, string>);
52
+ constructor(commands?: Record<RelayAgent, string>, deps?: AuthRelayDeps);
35
53
  /** Start (or restart) a login flow and wait until the sign-in URL appears. */
36
54
  start(agent: RelayAgent): Promise<LoginStartResult>;
55
+ private startWith;
37
56
  /** Paste the confirmation code back into the waiting CLI (Claude flow). */
38
57
  submitCode(agent: RelayAgent, code: string): Promise<LoginCodeResult>;
58
+ /**
59
+ * The CLI exited 0 — but is the machine actually signed in?
60
+ *
61
+ * Asked rather than assumed. Reporting a success the panel then contradicts
62
+ * one second later is worse than reporting a failure: it sends the person
63
+ * looking for a permissions problem that does not exist, which is exactly
64
+ * what happened on axon-prod-01.
65
+ */
66
+ private confirmSignedIn;
39
67
  cancel(): void;
40
68
  }
41
69
  /**
@@ -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();
@@ -121,7 +251,7 @@ export class AuthRelay {
121
251
  return { url, ...(code ? { code } : {}), expectsCode: agent === 'claude' };
122
252
  }
123
253
  if (relay.exited) {
124
- const tail = maskString(stripControl(relay.buffer)).slice(-400);
254
+ const tail = maskString(rejoinWrappedSecrets(stripControl(relay.buffer))).slice(-400);
125
255
  this.cancel();
126
256
  throw new Error(`${agent} login exited before printing a sign-in URL: ${tail}`);
127
257
  }
@@ -148,21 +278,79 @@ export class AuthRelay {
148
278
  while (Date.now() < deadline) {
149
279
  const fresh = stripControl(relay.buffer.slice(bufferMark));
150
280
  if (relay.exited) {
281
+ const raw = relay.buffer;
151
282
  this.cancel();
152
- if (relay.exitCode === 0)
153
- return { ok: true };
154
- 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);
155
293
  }
156
294
  if (/invalid|error|failed|expired/i.test(fresh)) {
157
295
  // The CLI usually re-prompts after a bad code; surface it and keep
158
296
  // the relay alive so the user can retry with a corrected code.
159
- return { ok: false, detail: maskString(fresh).trim().slice(-400) };
297
+ return { ok: false, detail: maskString(rejoinWrappedSecrets(fresh)).trim().slice(-400) };
160
298
  }
161
299
  await sleep(300);
162
300
  }
163
301
  this.cancel();
164
302
  return { ok: false, detail: 'Timed out waiting for the login to complete' };
165
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
+ }
166
354
  cancel() {
167
355
  const relay = this.active;
168
356
  if (!relay)
@@ -224,6 +412,25 @@ export async function claudeAuthStatus(homedir = os.homedir()) {
224
412
  if (process.env['CLAUDE_CODE_OAUTH_TOKEN']) {
225
413
  return { status: 'ok', detail: 'CLAUDE_CODE_OAUTH_TOKEN is configured' };
226
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;
227
434
  const file = path.join(homedir, '.claude', '.credentials.json');
228
435
  let raw;
229
436
  try {
@@ -236,7 +443,7 @@ export async function claudeAuthStatus(homedir = os.homedir()) {
236
443
  // sends the user re-authenticating a credential that is sitting right
237
444
  // there (the same mistake #121 is about, one layer down).
238
445
  if (code === 'ENOENT' || code === 'ENOTDIR') {
239
- return { status: 'missing', detail: 'No Claude login on this server' };
446
+ return fallbackToken() ?? { status: 'missing', detail: 'No Claude login on this server' };
240
447
  }
241
448
  // No `log.warn` here: this probe is on a 60-second timer since #121, and a
242
449
  // machine with EACCES on that file would write the same line forever.
@@ -273,7 +480,7 @@ export async function claudeAuthStatus(homedir = os.homedir()) {
273
480
  // Nothing recognisable in the blob at all — no token, not even a date. That
274
481
  // is «never signed in here», and it must offer the button that fixes it.
275
482
  if (!hasAccess && !hasRefresh && accessExpiry === undefined) {
276
- return { status: 'missing', detail: 'No subscription login found' };
483
+ return fallbackToken() ?? { status: 'missing', detail: 'No subscription login found' };
277
484
  }
278
485
  // A date we can read outranks the token beside it (the pre-#121 contract, and
279
486
  // the reason a dated-but-token-less fixture still reads as expired); with no
@@ -292,11 +499,11 @@ export async function claudeAuthStatus(homedir = os.homedir()) {
292
499
  const effectiveExpiry = refreshLive ? refreshExpiry : accessExpiry;
293
500
  const expiresAt = effectiveExpiry === undefined ? undefined : new Date(effectiveExpiry).toISOString();
294
501
  if (!accessLive && !refreshLive) {
295
- return {
502
+ return (fallbackToken() ?? {
296
503
  status: 'expired',
297
504
  ...(expiresAt ? { expiresAt } : {}),
298
505
  detail: 'the stored login has expired',
299
- };
506
+ });
300
507
  }
301
508
  return {
302
509
  status: 'ok',
@@ -324,6 +531,14 @@ const authFailures = new Map();
324
531
  export function noteAgentAuthFailure(agent) {
325
532
  authFailures.set(agent, Date.now());
326
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
+ }
327
542
  }
328
543
  /** The agent just worked — whatever was wrong with the sign-in is not. */
329
544
  export function clearAgentAuthFailure(agent) {
@@ -77,6 +77,20 @@ export interface AgentConfigContour {
77
77
  plugins: boolean;
78
78
  codexDir: boolean;
79
79
  codexConfig: boolean;
80
+ /**
81
+ * MCP servers an agent session in this home will actually see, and the ones
82
+ * that are configured but invisible to it.
83
+ *
84
+ * Both numbers, because the difference IS the bug. `claude mcp add` defaults
85
+ * to LOCAL scope, which stores the server under
86
+ * `~/.claude.json → projects["<cwd>"].mcpServers` — keyed by the directory it
87
+ * was added from. Runner sessions work in a per-session git worktree, so that
88
+ * key never matches and the servers are simply absent. On a live machine this
89
+ * read as «the agent lost Playwright and Context7», and the only cure was
90
+ * moving them to user scope (the top-level `mcpServers`).
91
+ */
92
+ mcpUserScope: number;
93
+ mcpProjectScope: number;
80
94
  }
81
95
  export declare function agentConfigContour(home?: string): AgentConfigContour;
82
96
  /**
@@ -116,6 +130,7 @@ export interface ToolCheck {
116
130
  /** Set when the tool is there but this user cannot use it. */
117
131
  problem?: string;
118
132
  }
133
+ export declare function whichExecutable(name: string): string | null;
119
134
  /**
120
135
  * Node, as THIS user sees it.
121
136
  *
@@ -114,6 +114,27 @@ export async function addSafeDirectory(repoPath) {
114
114
  timeout: 10_000,
115
115
  });
116
116
  }
117
+ /**
118
+ * Count MCP servers in the CLI's own config file, by scope.
119
+ *
120
+ * Reads `~/.claude.json` directly rather than shelling out to `claude mcp
121
+ * list`: this is a diagnostic that must work when the CLI is missing, and it
122
+ * must not spend ~300 MB and a second of a doctor run to answer.
123
+ */
124
+ function countMcpServers(home) {
125
+ try {
126
+ const parsed = JSON.parse(fs.readFileSync(path.join(home, '.claude.json'), 'utf8'));
127
+ const user = Object.keys(parsed.mcpServers ?? {}).length;
128
+ let project = 0;
129
+ for (const entry of Object.values(parsed.projects ?? {})) {
130
+ project += Object.keys(entry?.mcpServers ?? {}).length;
131
+ }
132
+ return { user, project };
133
+ }
134
+ catch {
135
+ return { user: 0, project: 0 };
136
+ }
137
+ }
117
138
  function countAllowRules(file) {
118
139
  try {
119
140
  const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
@@ -152,6 +173,7 @@ export function agentConfigContour(home = os.homedir()) {
152
173
  const localSettings = path.join(claudeDir, 'settings.local.json');
153
174
  const commandsDir = path.join(claudeDir, 'commands');
154
175
  const codexDir = path.join(home, '.codex');
176
+ const mcp = countMcpServers(home);
155
177
  return {
156
178
  home,
157
179
  claudeDir: fs.existsSync(claudeDir),
@@ -166,6 +188,8 @@ export function agentConfigContour(home = os.homedir()) {
166
188
  plugins: fs.existsSync(path.join(claudeDir, 'plugins')),
167
189
  codexDir: fs.existsSync(codexDir),
168
190
  codexConfig: fs.existsSync(path.join(codexDir, 'config.toml')),
191
+ mcpUserScope: mcp.user,
192
+ mcpProjectScope: mcp.project,
169
193
  };
170
194
  }
171
195
  /**
@@ -266,7 +290,7 @@ export function knownWorkspacePaths() {
266
290
  return [];
267
291
  }
268
292
  }
269
- function whichExecutable(name) {
293
+ export function whichExecutable(name) {
270
294
  for (const dir of (process.env['PATH'] ?? '').split(path.delimiter).filter(Boolean)) {
271
295
  const candidate = path.join(dir, name);
272
296
  try {