@bridge4dev/runner 0.65.1 → 0.67.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/account-commands.d.ts +43 -0
- package/dist/account-commands.js +142 -0
- package/dist/adapters/claude-usage.d.ts +79 -15
- package/dist/adapters/claude-usage.js +143 -67
- package/dist/adapters/claude.d.ts +30 -1
- package/dist/adapters/claude.js +171 -11
- package/dist/adapters/codex-home.d.ts +143 -21
- package/dist/adapters/codex-home.js +623 -77
- package/dist/adapters/codex.d.ts +25 -3
- package/dist/adapters/codex.js +149 -11
- package/dist/adapters/types.d.ts +22 -0
- package/dist/agent-auth.d.ts +100 -0
- package/dist/agent-auth.js +273 -15
- package/dist/auth-relay.d.ts +114 -26
- package/dist/auth-relay.js +464 -278
- package/dist/claude-homes.d.ts +433 -0
- package/dist/claude-homes.js +1550 -0
- package/dist/codex-accounts.d.ts +79 -0
- package/dist/codex-accounts.js +386 -0
- package/dist/commit-message.js +4 -1
- package/dist/config.d.ts +16 -16
- package/dist/config.js +4 -1
- package/dist/index.js +78 -10
- package/dist/login-marks.d.ts +42 -0
- package/dist/login-marks.js +70 -0
- package/dist/protocol.d.ts +38 -38
- package/dist/protocol.js +3 -1
- package/dist/supervisor.js +17 -4
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/auth-relay.js
CHANGED
|
@@ -6,9 +6,12 @@ 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,
|
|
9
|
+
import { applyStoredClaudeToken, environmentCarriesStoredToken, extractOauthToken, readAgentAuth, storeClaudeToken, } from './agent-auth.js';
|
|
10
10
|
import { invalidateUsageCache } from './adapters/claude-usage.js';
|
|
11
|
-
import { adoptLoginResult, discardStagingHome, prepareStagingHome,
|
|
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';
|
|
13
|
+
import { adoptLoginResult as adoptCodexLogin, discardStagingHome as discardCodexStagingHome, prepareStagingHome as prepareCodexStagingHome, readCodexCredentialFile, repairCodexAuth, } from './adapters/codex-home.js';
|
|
14
|
+
import { activeCodexAccountId, clearCodexLoginExpired, codexActiveAccountSummary, codexRowRefused, markCodexLoginExpired, } from './codex-accounts.js';
|
|
12
15
|
const execFileAsync = promisify(execFile);
|
|
13
16
|
/* eslint-disable no-control-regex -- this module parses raw pty output, so
|
|
14
17
|
matching ANSI/OSC escape bytes (\x1b, \x07) is exactly the point. */
|
|
@@ -19,7 +22,13 @@ const CODE_EXCHANGE_TIMEOUT_MS = 60_000;
|
|
|
19
22
|
const RELAY_MAX_LIFETIME_MS = 16 * 60_000;
|
|
20
23
|
const URL_PATTERNS = {
|
|
21
24
|
claude: /https:\/\/(?:claude\.com|claude\.ai)\/[^\s\x07\x1b"']+/,
|
|
22
|
-
|
|
25
|
+
// A page a person opens, never an endpoint of the provider's API (D26, S4 item
|
|
26
|
+
// 5a). When codex fails to get a device code it prints «Error logging in with
|
|
27
|
+
// device code: error sending request for url
|
|
28
|
+
// (https://auth.openai.com/api/accounts/deviceauth/usercode)», and the old
|
|
29
|
+
// any-https pattern handed that address to the window as the sign-in link –
|
|
30
|
+
// without a code, onto a page that answers «Authentication Error».
|
|
31
|
+
codex: /https:\/\/(?![^\s\x07\x1b"']*\/api\/)[^\s\x07\x1b"']+/,
|
|
23
32
|
};
|
|
24
33
|
/**
|
|
25
34
|
* Rejoin a secret the pty split across lines, so the masker can see it.
|
|
@@ -63,7 +72,16 @@ export function extractLoginUrl(agent, raw) {
|
|
|
63
72
|
* outside DevBridge) — that one corrects itself within the cache interval.
|
|
64
73
|
*/
|
|
65
74
|
function forgetClaudeUsage() {
|
|
66
|
-
|
|
75
|
+
// The machine's slots only (#422 R16): the slot of the subscription the
|
|
76
|
+
// machine WAS, and the one of a machine nobody has identified yet. A saved
|
|
77
|
+
// account's figures are not touched by a sign-in to the machine.
|
|
78
|
+
// The identity ON FILE, not the checked one: by now the new login has
|
|
79
|
+
// rewritten the home, so the checked identity is already «contradicted» and
|
|
80
|
+
// would name nothing – and the slot to drop is the one of the login replaced.
|
|
81
|
+
const known = readAgentAuth().claudeMachine?.lastSeenIdentity?.orgId;
|
|
82
|
+
if (known)
|
|
83
|
+
invalidateUsageCache({ id: MACHINE_ACCOUNT_ID, orgId: known });
|
|
84
|
+
invalidateUsageCache({ id: MACHINE_ACCOUNT_ID });
|
|
67
85
|
}
|
|
68
86
|
export function extractDeviceCode(raw) {
|
|
69
87
|
// Device-auth user codes look like XXXX-XXXX (letters/digits).
|
|
@@ -172,6 +190,75 @@ export class AuthRelay {
|
|
|
172
190
|
/** Start (or restart) a login flow and wait until the sign-in URL appears. */
|
|
173
191
|
async start(agent) {
|
|
174
192
|
this.cancel();
|
|
193
|
+
this.assertCanRun(agent);
|
|
194
|
+
try {
|
|
195
|
+
return await this.startWith(agent, this.commands[agent] ?? '', false, {
|
|
196
|
+
flow: 'legacy',
|
|
197
|
+
target: 'machine',
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
catch (error) {
|
|
201
|
+
// Only the one recoverable shape: a `claude` too old to have `auth
|
|
202
|
+
// login`. Everything else is the answer, not a reason to try again.
|
|
203
|
+
if (agent !== 'claude' || !looksLikeUnsupportedSubcommand(String(error)))
|
|
204
|
+
throw error;
|
|
205
|
+
log.warn('auth-relay: this claude has no `auth login` — falling back to setup-token');
|
|
206
|
+
return this.startWith(agent, CLAUDE_LEGACY_LOGIN, true, {
|
|
207
|
+
flow: 'legacy',
|
|
208
|
+
target: 'machine',
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* `agent_account_login_start` for Claude (#422 §8, R2).
|
|
214
|
+
*
|
|
215
|
+
* `saved` signs in inside a fresh staging home (`CLAUDE_CONFIG_DIR`), which
|
|
216
|
+
* becomes a saved account only when the code is accepted – the machine's own
|
|
217
|
+
* login is not touched by it at all. `machine` is today's sign-in into
|
|
218
|
+
* `~/.claude`, the one legitimate way to write that file, pressed by a person.
|
|
219
|
+
*
|
|
220
|
+
* Either way the CLI runs WITHOUT the operator's `CLAUDE_CODE_OAUTH_TOKEN`
|
|
221
|
+
* (and API keys): the CLI puts that variable above every login file, so a
|
|
222
|
+
* sign-in started with it could report the token's blind identity instead of
|
|
223
|
+
* the account that just signed in.
|
|
224
|
+
*
|
|
225
|
+
* No `setup-token` fallback for `saved`: that command stores nothing in a home
|
|
226
|
+
* (it prints an inference-only token, D1), so a «saved account» made from it
|
|
227
|
+
* would be a row with no login. `machine` keeps the fallback it always had.
|
|
228
|
+
*/
|
|
229
|
+
async startAccountLogin(target, agent = 'claude') {
|
|
230
|
+
if (agent === 'codex') {
|
|
231
|
+
// R13: the machine row of Codex is the host user's own `~/.codex/auth.json`,
|
|
232
|
+
// which DevBridge has never written and does not start writing. A sign-in
|
|
233
|
+
// from the window always adds (or renews) a saved login.
|
|
234
|
+
if (target === 'machine') {
|
|
235
|
+
throw new AccountError('the login of this machine is the Codex login of its own user – sign in again in the terminal on the machine (`codex login`)');
|
|
236
|
+
}
|
|
237
|
+
this.cancel();
|
|
238
|
+
this.assertCanRun('codex');
|
|
239
|
+
return this.startWith('codex', this.commands.codex ?? '', false, {
|
|
240
|
+
flow: 'account',
|
|
241
|
+
target: 'saved',
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
this.cancel();
|
|
245
|
+
this.assertCanRun('claude');
|
|
246
|
+
const command = this.commands.claude ?? '';
|
|
247
|
+
try {
|
|
248
|
+
return await this.startWith('claude', command, false, { flow: 'account', target });
|
|
249
|
+
}
|
|
250
|
+
catch (error) {
|
|
251
|
+
if (!looksLikeUnsupportedSubcommand(String(error)))
|
|
252
|
+
throw error;
|
|
253
|
+
if (target === 'saved') {
|
|
254
|
+
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 });
|
|
255
|
+
}
|
|
256
|
+
log.warn('auth-relay: this claude has no `auth login` — falling back to setup-token');
|
|
257
|
+
return this.startWith('claude', CLAUDE_LEGACY_LOGIN, true, { flow: 'account', target });
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
/** The CLI and the pty helper are both there – or a sentence saying which is not. */
|
|
261
|
+
assertCanRun(agent) {
|
|
175
262
|
const command = this.commands[agent] ?? '';
|
|
176
263
|
if (!commandExists(command)) {
|
|
177
264
|
const binary = command.trim().split(/\s+/)[0] ?? agent;
|
|
@@ -184,30 +271,34 @@ export class AuthRelay {
|
|
|
184
271
|
// util-linux, and the only reason a pty exists here at all.
|
|
185
272
|
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
273
|
}
|
|
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
274
|
}
|
|
199
|
-
async startWith(agent, command, captureToken) {
|
|
275
|
+
async startWith(agent, command, captureToken, door) {
|
|
200
276
|
this.cancel();
|
|
201
277
|
// Codex logs into a THROWAWAY home and is promoted only on success. The
|
|
202
278
|
// old flow deleted the shared credential link up front, so abandoning the
|
|
203
279
|
// sign-in (or letting it time out) left the server permanently signed out
|
|
204
280
|
// with no way back except restarting the daemon — and writing through the
|
|
205
281
|
// link would have overwritten the host user's own account (QA-100 MINOR-5).
|
|
206
|
-
|
|
282
|
+
// Whichever door started it, the result is a saved login (#422 S4): Codex
|
|
283
|
+
// has no «machine login» DevBridge may write.
|
|
284
|
+
const stagingHome = agent === 'codex' ? prepareCodexStagingHome() : null;
|
|
285
|
+
// A saved Claude account signs in inside its own staging home (#422 R2).
|
|
286
|
+
// `captureToken` never reaches here with `saved` – see `startAccountLogin`.
|
|
287
|
+
const claudeStaging = agent === 'claude' && door.flow === 'account' && door.target === 'saved'
|
|
288
|
+
? prepareClaudeStagingHome()
|
|
289
|
+
: null;
|
|
207
290
|
const proc = spawn('script', ['-qec', command, '/dev/null'], {
|
|
208
291
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
209
|
-
|
|
210
|
-
|
|
292
|
+
env: stagingHome
|
|
293
|
+
? // Codex must log in to a home WE control, never the host user's ~/.codex –
|
|
294
|
+
// whichever door started it (the account door once ran it without this
|
|
295
|
+
// variable, and the CLI wrote its login into the user's own home).
|
|
296
|
+
relayEnv({ CODEX_HOME: stagingHome })
|
|
297
|
+
: door.flow === 'account'
|
|
298
|
+
? // The staging home, or the machine's own `~/.claude` (no variable);
|
|
299
|
+
// and in both, no operator token and no API key (§8).
|
|
300
|
+
identityProbeEnv(claudeStaging, relayEnv())
|
|
301
|
+
: relayEnv(),
|
|
211
302
|
});
|
|
212
303
|
const relay = {
|
|
213
304
|
agent,
|
|
@@ -217,7 +308,15 @@ export class AuthRelay {
|
|
|
217
308
|
exitCode: null,
|
|
218
309
|
command,
|
|
219
310
|
captureToken,
|
|
220
|
-
|
|
311
|
+
flow: door.flow,
|
|
312
|
+
target: door.target,
|
|
313
|
+
claudeStaging,
|
|
314
|
+
codexStaging: stagingHome,
|
|
315
|
+
exchanging: false,
|
|
316
|
+
killTimer: setTimeout(() => {
|
|
317
|
+
if (this.active === relay)
|
|
318
|
+
this.cancel();
|
|
319
|
+
}, RELAY_MAX_LIFETIME_MS),
|
|
221
320
|
};
|
|
222
321
|
relay.killTimer.unref();
|
|
223
322
|
this.active = relay;
|
|
@@ -233,28 +332,41 @@ export class AuthRelay {
|
|
|
233
332
|
// exits once the browser side is confirmed. That exit IS the completion
|
|
234
333
|
// signal — promote the staging credential, or throw it away.
|
|
235
334
|
//
|
|
236
|
-
// Only when this relay is still the current one:
|
|
237
|
-
//
|
|
238
|
-
//
|
|
239
|
-
if (relay.agent !== 'codex' || this.active !== relay)
|
|
335
|
+
// Only when this relay is still the current one: a cancelled login was
|
|
336
|
+
// abandoned (its home removed) by whoever took the slot, and its late exit
|
|
337
|
+
// must not store anything in its name.
|
|
338
|
+
if (relay.agent !== 'codex' || this.active !== relay || !relay.codexStaging)
|
|
240
339
|
return;
|
|
340
|
+
if (code !== 0) {
|
|
341
|
+
this.discardStaging(relay);
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
const staging = relay.codexStaging;
|
|
345
|
+
relay.codexStaging = null;
|
|
241
346
|
try {
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
}
|
|
347
|
+
// By rename, one subscription – one row, and the signed-in row is the one
|
|
348
|
+
// new sessions start under (S4 item 5, R15). The window learns it from the
|
|
349
|
+
// list (§8: device sign-in finishes without `login_code`).
|
|
350
|
+
// The one-login window's door (`flow: 'legacy'`) replaces the login in
|
|
351
|
+
// use instead of adding a row beside it: with the organization's option
|
|
352
|
+
// off, «a token overwrites the token» is what a sign-in has always meant
|
|
353
|
+
// (D27), and DevBridge writes no machine login for Codex to overwrite.
|
|
354
|
+
const stored = adoptCodexLogin(staging, { replaceActive: relay.flow === 'legacy' });
|
|
355
|
+
log.info('codex: device login completed — credential stored', {
|
|
356
|
+
account: stored.id,
|
|
357
|
+
replaced: Boolean(stored.replaced),
|
|
358
|
+
});
|
|
359
|
+
// The ONLY place a Codex sign-in can be reported as finished. Its
|
|
360
|
+
// flow never sends `login_code` (device auth needs no paste-back),
|
|
361
|
+
// so without this line a refusal we recorded earlier would keep the
|
|
362
|
+
// panel demanding a re-login the user has just done — #121's own
|
|
363
|
+
// complaint, reproduced on the other half of the panel (QA-117 H1).
|
|
364
|
+
clearAgentAuthFailure('codex', stored.id);
|
|
254
365
|
}
|
|
255
366
|
catch (error) {
|
|
256
|
-
log.warn('codex: could not finish the device login', {
|
|
257
|
-
|
|
367
|
+
log.warn('codex: could not finish the device login', {
|
|
368
|
+
error: maskString(String(error)).slice(0, 300),
|
|
369
|
+
});
|
|
258
370
|
}
|
|
259
371
|
});
|
|
260
372
|
proc.on('error', (error) => {
|
|
@@ -265,18 +377,32 @@ export class AuthRelay {
|
|
|
265
377
|
const deadline = Date.now() + URL_START_TIMEOUT_MS;
|
|
266
378
|
for (;;) {
|
|
267
379
|
const url = extractLoginUrl(agent, relay.buffer);
|
|
268
|
-
|
|
269
|
-
|
|
380
|
+
// Codex: a link is half of the sign-in, the one-time code the other half,
|
|
381
|
+
// and the CLI prints them on two lines that can arrive in two reads. A link
|
|
382
|
+
// without its code sends the person to a page that asks for something the
|
|
383
|
+
// window never showed (D26) – so both, or the CLI's own words about why not.
|
|
384
|
+
const code = agent === 'codex' ? extractDeviceCode(relay.buffer) : null;
|
|
385
|
+
const ready = url !== null && (agent !== 'codex' || code !== null);
|
|
386
|
+
if (ready && this.active !== relay) {
|
|
387
|
+
// A newer start took the slot while this one waited: its CLI is killed,
|
|
388
|
+
// and a link to it would lead nowhere – a code pasted for it would reach
|
|
389
|
+
// the newer CLI (found by the tests of the S2 check).
|
|
390
|
+
this.abandon(relay);
|
|
391
|
+
throw new Error('a newer sign-in was started on this server – use its link');
|
|
392
|
+
}
|
|
393
|
+
if (ready && url) {
|
|
270
394
|
return { url, ...(code ? { code } : {}), expectsCode: agent === 'claude' };
|
|
271
395
|
}
|
|
272
396
|
if (relay.exited) {
|
|
273
397
|
const tail = maskString(rejoinWrappedSecrets(stripControl(relay.buffer))).slice(-400);
|
|
274
|
-
this.
|
|
398
|
+
this.abandon(relay);
|
|
275
399
|
throw new Error(`${agent} login exited before printing a sign-in URL: ${tail}`);
|
|
276
400
|
}
|
|
277
401
|
if (Date.now() > deadline) {
|
|
278
|
-
this.
|
|
279
|
-
throw new Error(
|
|
402
|
+
this.abandon(relay);
|
|
403
|
+
throw new Error(url
|
|
404
|
+
? `${agent} login printed a link but no one-time code in time – start again`
|
|
405
|
+
: `${agent} login did not print a sign-in URL in time`);
|
|
280
406
|
}
|
|
281
407
|
await sleep(200);
|
|
282
408
|
}
|
|
@@ -284,41 +410,137 @@ export class AuthRelay {
|
|
|
284
410
|
/** Paste the confirmation code back into the waiting CLI (Claude flow). */
|
|
285
411
|
async submitCode(agent, code) {
|
|
286
412
|
const relay = this.active;
|
|
287
|
-
|
|
413
|
+
// A sign-in started through the account commands is finished only by them.
|
|
414
|
+
if (!relay || relay.agent !== agent || relay.flow !== 'legacy') {
|
|
288
415
|
return { ok: false, detail: 'No login in progress — start again' };
|
|
289
416
|
}
|
|
417
|
+
const exchanged = await this.exchangeCode(relay, code);
|
|
418
|
+
if (!('raw' in exchanged))
|
|
419
|
+
return exchanged;
|
|
420
|
+
// Exit 0 is the CLI's opinion. Ours has to be «is this machine signed
|
|
421
|
+
// in now», because those two came apart before and nobody noticed for
|
|
422
|
+
// weeks: `setup-token` exits 0 over a credential it never stored.
|
|
423
|
+
return this.confirmSignedIn(relay, exchanged.raw);
|
|
424
|
+
}
|
|
425
|
+
/**
|
|
426
|
+
* `agent_account_login_code` – finish a sign-in the account commands started.
|
|
427
|
+
*
|
|
428
|
+
* `saved`: the staging home is adopted by rename – one subscription, one row,
|
|
429
|
+
* the signed-in row active (`adoptLoginResult`, R15). `machine`: the same
|
|
430
|
+
* check the old door makes, then the machine login becomes active and its
|
|
431
|
+
* identity is asked once, so the answer names who ACTUALLY signed in (§2:
|
|
432
|
+
* «Signed in as B, not A») rather than who the machine was before.
|
|
433
|
+
*/
|
|
434
|
+
async submitAccountCode(code) {
|
|
435
|
+
const relay = this.active;
|
|
436
|
+
if (!relay || relay.agent !== 'claude' || relay.flow !== 'account') {
|
|
437
|
+
return { ok: false, detail: 'No login in progress – start again' };
|
|
438
|
+
}
|
|
439
|
+
const exchanged = await this.exchangeCode(relay, code);
|
|
440
|
+
if (!('raw' in exchanged)) {
|
|
441
|
+
return { ok: false, detail: exchanged.detail ?? 'Login failed' };
|
|
442
|
+
}
|
|
443
|
+
// The door and the target that STARTED the sign-in decide how it ends (§8,
|
|
444
|
+
// R2): a saved sign-in is never finished as the machine's, even when its
|
|
445
|
+
// staging home was removed in the moment between the CLI's exit and this
|
|
446
|
+
// line (found by the independent check of S2).
|
|
447
|
+
if (relay.target === 'saved') {
|
|
448
|
+
if (!relay.claudeStaging) {
|
|
449
|
+
return {
|
|
450
|
+
ok: false,
|
|
451
|
+
detail: 'the sign-in was abandoned before it could be saved – start again',
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
try {
|
|
455
|
+
const adopted = await adoptClaudeLogin(relay.claudeStaging);
|
|
456
|
+
// Adopted: the staging path no longer exists, nothing left to remove.
|
|
457
|
+
relay.claudeStaging = null;
|
|
458
|
+
clearAgentAuthFailure('claude', adopted.account.id);
|
|
459
|
+
return {
|
|
460
|
+
ok: true,
|
|
461
|
+
account: adopted.account,
|
|
462
|
+
active: adopted.active,
|
|
463
|
+
...(adopted.replaced ? { replaced: adopted.replaced } : {}),
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
catch (error) {
|
|
467
|
+
this.discardStaging(relay);
|
|
468
|
+
return {
|
|
469
|
+
ok: false,
|
|
470
|
+
detail: error instanceof AccountError
|
|
471
|
+
? error.message
|
|
472
|
+
: maskString(String(error instanceof Error ? error.message : error)).slice(0, 400),
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
const confirmed = await this.confirmSignedIn(relay, exchanged.raw);
|
|
477
|
+
if (!confirmed.ok)
|
|
478
|
+
return { ok: false, detail: confirmed.detail ?? 'Login failed' };
|
|
479
|
+
// R14 (b): one ask, at the adoption of a sign-in – the login file was just
|
|
480
|
+
// rewritten, and the list must not name the previous subscription.
|
|
481
|
+
await refreshAccountIdentity(MACHINE_ACCOUNT_ID, { force: true }).catch(() => null);
|
|
482
|
+
const list = buildAccountList();
|
|
483
|
+
const account = list.accounts.find((row) => row.id === MACHINE_ACCOUNT_ID);
|
|
484
|
+
if (!account)
|
|
485
|
+
return { ok: false, detail: 'the machine login is missing from the list' };
|
|
486
|
+
return { ok: true, account, active: list.active };
|
|
487
|
+
}
|
|
488
|
+
/**
|
|
489
|
+
* Write the code, wait for the CLI's answer.
|
|
490
|
+
*
|
|
491
|
+
* `{ raw }` – the CLI exited 0 and the relay is released (its staging home, if
|
|
492
|
+
* any, is kept for the caller to adopt). Anything else is the answer to hand
|
|
493
|
+
* back: a non-zero exit or a timeout ends the sign-in; a line saying «invalid»
|
|
494
|
+
* keeps it alive, because the CLI usually asks again.
|
|
495
|
+
*/
|
|
496
|
+
async exchangeCode(relay, code) {
|
|
497
|
+
// Checked before «exited», and in the same tick as the flag is set below: a
|
|
498
|
+
// second code arriving in the up to 300 ms between the CLI's exit and the
|
|
499
|
+
// running loop's next look used to take that exit as ITS cue to abandon the
|
|
500
|
+
// sign-in – and removed the staging home with the login the CLI had just
|
|
501
|
+
// written (found by the second independent check of S2).
|
|
502
|
+
if (relay.exchanging) {
|
|
503
|
+
return {
|
|
504
|
+
ok: false,
|
|
505
|
+
detail: 'A code for this sign-in is already being checked – wait for its answer',
|
|
506
|
+
};
|
|
507
|
+
}
|
|
290
508
|
if (relay.exited) {
|
|
291
|
-
this.
|
|
509
|
+
this.abandon(relay);
|
|
292
510
|
return { ok: false, detail: 'The login process has already exited — start again' };
|
|
293
511
|
}
|
|
294
|
-
relay.
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
const
|
|
299
|
-
|
|
300
|
-
const
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
512
|
+
relay.exchanging = true;
|
|
513
|
+
try {
|
|
514
|
+
relay.proc.stdin?.write(`${code.trim()}\n`);
|
|
515
|
+
const deadline = Date.now() + CODE_EXCHANGE_TIMEOUT_MS;
|
|
516
|
+
const bufferMark = relay.buffer.length;
|
|
517
|
+
while (Date.now() < deadline) {
|
|
518
|
+
const fresh = stripControl(relay.buffer.slice(bufferMark));
|
|
519
|
+
if (relay.exited) {
|
|
520
|
+
const raw = relay.buffer;
|
|
521
|
+
if (relay.exitCode !== 0) {
|
|
522
|
+
this.abandon(relay);
|
|
523
|
+
return {
|
|
524
|
+
ok: false,
|
|
525
|
+
detail: maskString(rejoinWrappedSecrets(fresh)).slice(-400) || 'Login failed',
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
this.release(relay);
|
|
529
|
+
return { raw };
|
|
307
530
|
}
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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) };
|
|
531
|
+
if (/invalid|error|failed|expired/i.test(fresh)) {
|
|
532
|
+
// The CLI usually re-prompts after a bad code; surface it and keep
|
|
533
|
+
// the relay alive so the user can retry with a corrected code.
|
|
534
|
+
return { ok: false, detail: maskString(rejoinWrappedSecrets(fresh)).trim().slice(-400) };
|
|
535
|
+
}
|
|
536
|
+
await sleep(300);
|
|
317
537
|
}
|
|
318
|
-
|
|
538
|
+
this.abandon(relay);
|
|
539
|
+
return { ok: false, detail: 'Timed out waiting for the login to complete' };
|
|
540
|
+
}
|
|
541
|
+
finally {
|
|
542
|
+
relay.exchanging = false;
|
|
319
543
|
}
|
|
320
|
-
this.cancel();
|
|
321
|
-
return { ok: false, detail: 'Timed out waiting for the login to complete' };
|
|
322
544
|
}
|
|
323
545
|
/**
|
|
324
546
|
* The CLI exited 0 — but is the machine actually signed in?
|
|
@@ -352,16 +574,21 @@ export class AuthRelay {
|
|
|
352
574
|
log.info('auth-relay: stored a long-lived Claude token for this runner');
|
|
353
575
|
clearAgentAuthFailure('claude');
|
|
354
576
|
forgetClaudeUsage();
|
|
577
|
+
activateMachineLogin();
|
|
355
578
|
return { ok: true, detail: 'signed in with a long-lived token stored on this server' };
|
|
356
579
|
}
|
|
357
580
|
// `claude auth login` writes the credential just before it exits; give the
|
|
358
581
|
// filesystem a couple of beats rather than racing it.
|
|
359
|
-
|
|
582
|
+
// The MACHINE login, explicitly: this relay signs in to `~/.claude`, and with
|
|
583
|
+
// a saved account active «the active verdict» would be about a different
|
|
584
|
+
// home than the one this sign-in just wrote (#422).
|
|
585
|
+
const probe = this.deps.claudeStatus ?? (() => claudeAuthStatus(undefined, { account: 'machine' }));
|
|
360
586
|
for (let attempt = 0; attempt < 4; attempt++) {
|
|
361
587
|
const status = await probe();
|
|
362
588
|
if (status.status === 'ok') {
|
|
363
589
|
clearAgentAuthFailure('claude');
|
|
364
590
|
forgetClaudeUsage();
|
|
591
|
+
activateMachineLogin();
|
|
365
592
|
return { ok: true };
|
|
366
593
|
}
|
|
367
594
|
await sleep(300);
|
|
@@ -374,9 +601,24 @@ export class AuthRelay {
|
|
|
374
601
|
}
|
|
375
602
|
cancel() {
|
|
376
603
|
const relay = this.active;
|
|
377
|
-
if (
|
|
378
|
-
|
|
379
|
-
|
|
604
|
+
if (relay)
|
|
605
|
+
this.abandon(relay);
|
|
606
|
+
}
|
|
607
|
+
/**
|
|
608
|
+
* End THIS sign-in: its process, its slot if it still holds it, its staging
|
|
609
|
+
* home. A loop driving an older sign-in must never end a newer one that took
|
|
610
|
+
* the slot meanwhile – with `cancel()` in its place, two starts close together
|
|
611
|
+
* killed each other, and a stale code exchange killed the newer start (found by
|
|
612
|
+
* the independent check of S2).
|
|
613
|
+
*/
|
|
614
|
+
abandon(relay) {
|
|
615
|
+
this.release(relay);
|
|
616
|
+
this.discardStaging(relay);
|
|
617
|
+
}
|
|
618
|
+
/** Stop driving a relay: its process, its timer, its slot. Its staging home stays. */
|
|
619
|
+
release(relay) {
|
|
620
|
+
if (this.active === relay)
|
|
621
|
+
this.active = null;
|
|
380
622
|
clearTimeout(relay.killTimer);
|
|
381
623
|
try {
|
|
382
624
|
relay.proc.kill('SIGKILL');
|
|
@@ -385,207 +627,137 @@ export class AuthRelay {
|
|
|
385
627
|
// already gone
|
|
386
628
|
}
|
|
387
629
|
}
|
|
630
|
+
/** A sign-in that will not be adopted leaves nothing behind (§8, S2 item 2). */
|
|
631
|
+
discardStaging(relay) {
|
|
632
|
+
const claude = relay.claudeStaging;
|
|
633
|
+
const codex = relay.codexStaging;
|
|
634
|
+
relay.claudeStaging = null;
|
|
635
|
+
relay.codexStaging = null;
|
|
636
|
+
try {
|
|
637
|
+
if (claude)
|
|
638
|
+
discardClaudeStagingHome(claude);
|
|
639
|
+
if (codex)
|
|
640
|
+
discardCodexStagingHome(codex);
|
|
641
|
+
}
|
|
642
|
+
catch (error) {
|
|
643
|
+
log.warn('auth-relay: could not remove an abandoned sign-in home', {
|
|
644
|
+
error: String(error),
|
|
645
|
+
});
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
/**
|
|
650
|
+
* A sign-in into the machine's own login makes that login active (#422 R15).
|
|
651
|
+
*
|
|
652
|
+
* The old window's sign-in means «this login from now on» – with several logins
|
|
653
|
+
* switched off for the organization, «a token replaces the token» (D27). A saved
|
|
654
|
+
* account left active over it would keep every new session on the previous
|
|
655
|
+
* subscription while the window said the machine was signed in again.
|
|
656
|
+
*/
|
|
657
|
+
function activateMachineLogin() {
|
|
658
|
+
try {
|
|
659
|
+
setActiveAccount(MACHINE_ACCOUNT_ID);
|
|
660
|
+
}
|
|
661
|
+
catch (error) {
|
|
662
|
+
log.warn('auth-relay: could not make the machine login active', { error: String(error) });
|
|
663
|
+
}
|
|
388
664
|
}
|
|
389
665
|
function sleep(ms) {
|
|
390
666
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
391
667
|
}
|
|
392
|
-
|
|
393
|
-
return typeof value === 'string' && value.length > 0;
|
|
394
|
-
}
|
|
668
|
+
// ─── Auth health probe ───────────────────────────────────────────────
|
|
395
669
|
/**
|
|
396
|
-
*
|
|
670
|
+
* Claude: the verdict on the account the next session starts under (#422 R14).
|
|
671
|
+
*
|
|
672
|
+
* The machine login – the pre-#422 rule word for word: the operator's variable,
|
|
673
|
+
* then `~/.claude/.credentials.json`, then a token this runner captured. A
|
|
674
|
+
* saved account – ONLY its own credentials file: a session under it has the
|
|
675
|
+
* operator's token taken out of its environment, so letting the variable or the
|
|
676
|
+
* captured token answer here would paint a green verdict over a switch that
|
|
677
|
+
* does not work (#121, К14). Nothing here runs a CLI; the file judge and its
|
|
678
|
+
* reasons live in `claude-homes.ts` so the account list reads the same lines.
|
|
397
679
|
*
|
|
398
|
-
* The
|
|
399
|
-
*
|
|
400
|
-
*
|
|
401
|
-
*
|
|
680
|
+
* The RUNNER reads these files (its own host user's) — the agent itself is still
|
|
681
|
+
* denied these paths by layer-1 policy.
|
|
682
|
+
*
|
|
683
|
+
* `account: 'machine'` asks about the machine login whatever is active – what the
|
|
684
|
+
* sign-in relay needs after writing `~/.claude`.
|
|
402
685
|
*/
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
return Math.abs(value) <= MAX_TIMESTAMP_MS ? value : undefined;
|
|
686
|
+
export async function claudeAuthStatus(homedir = os.homedir(), options = {}) {
|
|
687
|
+
const id = options.account === 'machine' ? MACHINE_ACCOUNT_ID : activeAccountId();
|
|
688
|
+
const status = id === MACHINE_ACCOUNT_ID ? machineLoginStatus(homedir) : savedLoginStatus(id);
|
|
689
|
+
return { ...status, activeAccount: readActiveAccountSummary(id) };
|
|
408
690
|
}
|
|
691
|
+
// ─── What the agent actually experienced ─────────────────────────────
|
|
692
|
+
//
|
|
693
|
+
// The memory itself lives in `login-marks.ts` since #422 – keyed by agent AND
|
|
694
|
+
// account, because the list of accounts and this verdict must agree on it.
|
|
409
695
|
/**
|
|
410
|
-
*
|
|
411
|
-
* The RUNNER reads it (its own host user's file) — the agent itself is still
|
|
412
|
-
* denied this path by layer-1 policy.
|
|
413
|
-
*
|
|
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.
|
|
696
|
+
* A session just failed to authenticate as this agent – under this account.
|
|
421
697
|
*
|
|
422
|
-
*
|
|
423
|
-
*
|
|
424
|
-
*
|
|
425
|
-
*
|
|
426
|
-
*
|
|
427
|
-
*
|
|
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.
|
|
698
|
+
* `accountId` is the account the SESSION started under (R14), not whatever the
|
|
699
|
+
* machine has active now. Nothing is deleted any more (#422 S1 item 3, D2): the
|
|
700
|
+
* old code threw a captured Claude token away on the spot, and that meant
|
|
701
|
+
* erasing the file the saved accounts are listed in. A Claude refusal is kept as
|
|
702
|
+
* a mark on the row instead – it outlives a daemon restart, and a login written
|
|
703
|
+
* after it (a refresh, a new sign-in) outranks it.
|
|
431
704
|
*/
|
|
432
|
-
export
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
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' };
|
|
705
|
+
export function noteAgentAuthFailure(agent, accountId = MACHINE_ACCOUNT_ID) {
|
|
706
|
+
noteRefusal(agent, accountId);
|
|
707
|
+
log.warn('auth-relay: agent sign-in refused during a session', { agent, account: accountId });
|
|
708
|
+
if (agent === 'codex') {
|
|
709
|
+
// A saved Codex login keeps the mark on disk, as a Claude one does (D2) –
|
|
710
|
+
// but only while it is the login in use. Codex refuses to refresh a session
|
|
711
|
+
// whose account is no longer the one `auth.json` points at («you have since
|
|
712
|
+
// signed in to another account»), and that refusal says nothing about the
|
|
713
|
+
// login of the account the session started under: marking it would put
|
|
714
|
+
// «sign in again» on a login that is perfectly good (found by the
|
|
715
|
+
// independent check of S4).
|
|
716
|
+
if (accountId !== MACHINE_ACCOUNT_ID && accountId === activeCodexAccountId()) {
|
|
717
|
+
markCodexLoginExpired(accountId);
|
|
468
718
|
}
|
|
469
|
-
|
|
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' };
|
|
719
|
+
return;
|
|
495
720
|
}
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
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' };
|
|
721
|
+
if (accountId !== MACHINE_ACCOUNT_ID) {
|
|
722
|
+
markLoginExpired(accountId);
|
|
723
|
+
return;
|
|
505
724
|
}
|
|
506
|
-
//
|
|
507
|
-
//
|
|
508
|
-
//
|
|
509
|
-
|
|
510
|
-
//
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
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
|
-
});
|
|
725
|
+
// The machine row carries a mark on disk only about the token this runner
|
|
726
|
+
// captured, and only when the refused session ran WITH it. The token then
|
|
727
|
+
// leaves this daemon's environment, so the next machine session falls back to
|
|
728
|
+
// the credentials file instead of presenting the same dead token – and a
|
|
729
|
+
// restart does not put it back (`applyStoredClaudeToken` reads the mark).
|
|
730
|
+
if (environmentCarriesStoredToken()) {
|
|
731
|
+
markLoginExpired(MACHINE_ACCOUNT_ID);
|
|
732
|
+
delete process.env['CLAUDE_CODE_OAUTH_TOKEN'];
|
|
733
|
+
log.warn('auth-relay: the stored Claude token was refused – no longer used by new sessions');
|
|
528
734
|
}
|
|
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
735
|
}
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
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');
|
|
736
|
+
/** The agent just worked under this account — whatever was wrong with its sign-in is not. */
|
|
737
|
+
export function clearAgentAuthFailure(agent, accountId = MACHINE_ACCOUNT_ID) {
|
|
738
|
+
if (clearRefusal(agent, accountId)) {
|
|
739
|
+
log.info('auth-relay: agent sign-in is working again', { agent, account: accountId });
|
|
562
740
|
}
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
741
|
+
if (agent === 'codex') {
|
|
742
|
+
if (accountId !== MACHINE_ACCOUNT_ID)
|
|
743
|
+
clearCodexLoginExpired(accountId);
|
|
744
|
+
return;
|
|
745
|
+
}
|
|
746
|
+
// A machine session that worked on the credentials FILE says nothing about the
|
|
747
|
+
// captured token: its mark is lifted only by a session that ran with it.
|
|
748
|
+
if (accountId !== MACHINE_ACCOUNT_ID || environmentCarriesStoredToken()) {
|
|
749
|
+
clearLoginExpired(accountId);
|
|
568
750
|
}
|
|
569
751
|
}
|
|
570
752
|
/**
|
|
571
|
-
* Is a refusal still being held against this agent?
|
|
753
|
+
* Is a refusal still being held against this agent (and account)?
|
|
572
754
|
*
|
|
573
755
|
* Exported so the wiring in `supervisor.ts` can be pinned by a test without
|
|
574
756
|
* shelling out to the agent CLIs: this predicate IS the mechanism the panel's
|
|
575
757
|
* demotion reads, and three unguarded lines were carrying it (QA-117 M6).
|
|
576
758
|
*/
|
|
577
|
-
export function agentAuthFailureActive(agent) {
|
|
578
|
-
return
|
|
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;
|
|
759
|
+
export function agentAuthFailureActive(agent, accountId = MACHINE_ACCOUNT_ID) {
|
|
760
|
+
return refusalActive(agent, accountId);
|
|
589
761
|
}
|
|
590
762
|
/** Last verdict we published per agent — logged only when it changes. */
|
|
591
763
|
const lastVerdict = new Map();
|
|
@@ -607,14 +779,35 @@ function logVerdictChange(agent, status) {
|
|
|
607
779
|
* Codex reports its own login state via an exit code (0 signed in / 1 not).
|
|
608
780
|
* Probed against the RUNNER's home: the host user can be signed in while our
|
|
609
781
|
* isolated home is not, and it is ours that sessions use.
|
|
782
|
+
*
|
|
783
|
+
* About the login new sessions start under (#422 S4): the machine login through
|
|
784
|
+
* the link, or the saved login the link points at – named in `activeAccount`,
|
|
785
|
+
* read locally from the file, no CLI for it (R14).
|
|
610
786
|
*/
|
|
611
787
|
export async function codexAuthStatus() {
|
|
612
788
|
// Re-assert the credential before judging it. The link into the shared store
|
|
613
789
|
// can disappear under a live daemon (codex has its own auth.json removal
|
|
614
790
|
// path), and reporting "login expired" about a credential that is merely
|
|
615
791
|
// unlinked — while the user's own file is valid for another week — is the
|
|
616
|
-
// exact complaint this fixes.
|
|
792
|
+
// exact complaint this fixes. Forcing nothing (S4 item 2): a saved login in
|
|
793
|
+
// use stays in use.
|
|
617
794
|
const home = repairCodexAuth();
|
|
795
|
+
const accountId = home.accountId ?? MACHINE_ACCOUNT_ID;
|
|
796
|
+
const activeAccount = codexActiveAccountSummary(accountId);
|
|
797
|
+
const verdict = await codexHomeVerdict(home);
|
|
798
|
+
// A saved login a session was refused with stays refused until its file is
|
|
799
|
+
// written again (D2) – the same rule as a saved Claude login.
|
|
800
|
+
if (verdict.status === 'ok' && codexRowRefused(accountId)) {
|
|
801
|
+
return {
|
|
802
|
+
...verdict,
|
|
803
|
+
status: 'expired',
|
|
804
|
+
detail: 'the agent was refused with this account – sign in again',
|
|
805
|
+
activeAccount,
|
|
806
|
+
};
|
|
807
|
+
}
|
|
808
|
+
return { ...verdict, activeAccount };
|
|
809
|
+
}
|
|
810
|
+
async function codexHomeVerdict(home) {
|
|
618
811
|
const local = readCodexCredential(home.path);
|
|
619
812
|
if (home.auth === 'missing' && !local) {
|
|
620
813
|
return { status: 'missing', detail: 'not signed in on this server' };
|
|
@@ -626,7 +819,11 @@ export async function codexAuthStatus() {
|
|
|
626
819
|
});
|
|
627
820
|
return {
|
|
628
821
|
status: 'ok',
|
|
629
|
-
detail: home.auth === 'linked'
|
|
822
|
+
detail: home.auth === 'linked'
|
|
823
|
+
? 'signed in with ChatGPT (shared login)'
|
|
824
|
+
: home.auth === 'account'
|
|
825
|
+
? 'signed in with ChatGPT (saved login)'
|
|
826
|
+
: 'signed in with ChatGPT',
|
|
630
827
|
...(local?.expiresAt ? { expiresAt: local.expiresAt } : {}),
|
|
631
828
|
};
|
|
632
829
|
}
|
|
@@ -656,30 +853,19 @@ export async function codexAuthStatus() {
|
|
|
656
853
|
/**
|
|
657
854
|
* Read what the runner's own auth.json says, without ever logging a token.
|
|
658
855
|
* Used only to tell "no credential" apart from "credential past its date" —
|
|
659
|
-
* the CLI probe is local and cannot detect a server-side revocation.
|
|
856
|
+
* the CLI probe is local and cannot detect a server-side revocation. The
|
|
857
|
+
* reading itself lives in `codex-home.ts`, where the saved logins are judged by
|
|
858
|
+
* the same lines.
|
|
660
859
|
*/
|
|
661
860
|
function readCodexCredential(homePath) {
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
if (!access)
|
|
666
|
-
return null;
|
|
667
|
-
// A refresh token means the access token's own expiry is not the whole
|
|
668
|
-
// story — codex renews it on its own.
|
|
669
|
-
if (raw.tokens?.refresh_token)
|
|
670
|
-
return { expired: false };
|
|
671
|
-
const payload = access.split('.')[1];
|
|
672
|
-
if (!payload)
|
|
673
|
-
return { expired: false };
|
|
674
|
-
const claims = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
|
|
675
|
-
if (typeof claims.exp !== 'number')
|
|
676
|
-
return { expired: false };
|
|
677
|
-
const expiresAt = new Date(claims.exp * 1000).toISOString();
|
|
678
|
-
return { expiresAt, expired: claims.exp * 1000 < Date.now() };
|
|
679
|
-
}
|
|
680
|
-
catch {
|
|
861
|
+
const credential = readCodexCredentialFile(path.join(homePath, 'auth.json'));
|
|
862
|
+
// Only a ChatGPT login has tokens to date; an API key is left to `codex login status`.
|
|
863
|
+
if (credential.kind !== 'chatgpt')
|
|
681
864
|
return null;
|
|
682
|
-
|
|
865
|
+
return {
|
|
866
|
+
...(credential.expiresAt ? { expiresAt: credential.expiresAt } : {}),
|
|
867
|
+
expired: credential.status === 'expired',
|
|
868
|
+
};
|
|
683
869
|
}
|
|
684
870
|
/**
|
|
685
871
|
* A verdict read off disk, overruled by what a real session experienced.
|
|
@@ -688,19 +874,19 @@ function readCodexCredential(homePath) {
|
|
|
688
874
|
* and never the other way round — a file saying `expired` is not made `ok` by
|
|
689
875
|
* the absence of failures.
|
|
690
876
|
*/
|
|
691
|
-
function withObservedFailures(agent, status) {
|
|
692
|
-
if (status.status !== 'ok' || !
|
|
877
|
+
function withObservedFailures(agent, status, accountId = MACHINE_ACCOUNT_ID) {
|
|
878
|
+
if (status.status !== 'ok' || !refusalActive(agent, accountId))
|
|
693
879
|
return status;
|
|
694
880
|
return {
|
|
881
|
+
...status,
|
|
695
882
|
status: 'expired',
|
|
696
|
-
...(status.expiresAt ? { expiresAt: status.expiresAt } : {}),
|
|
697
883
|
detail: 'the agent was refused with this sign-in — re-login needed',
|
|
698
884
|
};
|
|
699
885
|
}
|
|
700
886
|
export async function agentAuthStatuses() {
|
|
701
887
|
const [claudeRaw, codexRaw] = await Promise.all([claudeAuthStatus(), codexAuthStatus()]);
|
|
702
|
-
const claude = withObservedFailures('claude', claudeRaw);
|
|
703
|
-
const codex = withObservedFailures('codex', codexRaw);
|
|
888
|
+
const claude = withObservedFailures('claude', claudeRaw, claudeRaw.activeAccount?.id ?? MACHINE_ACCOUNT_ID);
|
|
889
|
+
const codex = withObservedFailures('codex', codexRaw, codexRaw.activeAccount?.id ?? MACHINE_ACCOUNT_ID);
|
|
704
890
|
logVerdictChange('claude', claude);
|
|
705
891
|
logVerdictChange('codex', codex);
|
|
706
892
|
return { claude, codex };
|