@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.
- package/dist/agent-auth.d.ts +29 -0
- package/dist/agent-auth.js +136 -0
- package/dist/auth-relay.d.ts +62 -4
- package/dist/auth-relay.js +423 -25
- package/dist/environment.d.ts +186 -0
- package/dist/environment.js +433 -0
- package/dist/git.d.ts +10 -0
- package/dist/git.js +94 -5
- package/dist/index.js +466 -21
- package/dist/paths.d.ts +9 -0
- package/dist/paths.js +11 -0
- package/dist/protocol.d.ts +7 -7
- package/dist/recipe-schema.d.ts +1 -1
- package/dist/self-update.d.ts +55 -0
- package/dist/self-update.js +164 -25
- package/dist/service-unit.d.ts +13 -1
- package/dist/service-unit.js +41 -10
- package/dist/supervisor.js +35 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/auth-relay.js
CHANGED
|
@@ -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
|
|
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',
|
|
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
|
-
|
|
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
|
|
147
|
-
return {
|
|
148
|
-
|
|
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
|
-
|
|
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
|
|
180
|
-
*
|
|
181
|
-
*
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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: '
|
|
199
|
-
|
|
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
|
-
|
|
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 [
|
|
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
|