@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.
@@ -1,6 +1,8 @@
1
1
  import fs from 'node:fs';
2
2
  import os from 'node:os';
3
3
  import path from 'node:path';
4
+ import { readAgentAuth, updateAgentAuth } from '../agent-auth.js';
5
+ import { AccountError, MACHINE_ACCOUNT_ID, clearRefusal, isAccountId, newAccountId, } from '../login-marks.js';
4
6
  import { stateDir } from '../paths.js';
5
7
  import { log } from '../log.js';
6
8
  // A dedicated CODEX_HOME for the runner. This is a security control, not tidiness.
@@ -15,15 +17,40 @@ import { log } from '../log.js';
15
17
  // Verified live: with CODEX_HOME pointed here, `config/read` reports exactly two
16
18
  // layers (ours + the always-present system file) and the user's config is not
17
19
  // among them.
20
+ //
21
+ // Several Codex logins (#422 S4). The home stays ONE – its `sessions/` (the
22
+ // history every resume reads), its logs and caches are shared by every account –
23
+ // and only the login differs: each saved login is a file of its own,
24
+ // `accounts/<id>/auth.json` (0600), and the account in use is the one the home's
25
+ // `auth.json` LINK points at. Switching is repointing that link. Nothing is ever
26
+ // copied: a login moves into the store by `rename`, and a rename that would have
27
+ // to cross filesystems is refused instead of falling back to a copy – two copies
28
+ // of one refresh token invalidate each other (the Claude side lost 47 agents to
29
+ // exactly that on 08.09.2026, gotcha 460).
30
+ //
31
+ // Measured on codex-cli 0.154.0 (17.09.2026) before a line of this was written:
32
+ // - the CLI writes `auth.json` THROUGH a symlink (open + truncate), so a token
33
+ // refresh lands in the store file the link points at and the link survives;
34
+ // - before it refreshes it re-reads the file, and a file of a different
35
+ // `account_id` is not written over («Skipping token refresh because auth
36
+ // changed after guarded reload»). A session still running under A after the
37
+ // machine was switched to B therefore never spends A's tokens into B's file;
38
+ // at its next refresh it stops with «signed in to another account» instead.
18
39
  const CONFIG_FILENAME = 'config.toml';
19
40
  const AUTH_FILENAME = 'auth.json';
20
41
  /**
21
42
  * Records how this home is meant to be authenticated. Without it a plain
22
43
  * auth.json is ambiguous — it could be our own device login, or codex having
23
- * replaced our symlink. A MISSING marker always reads as `own`: treating an
24
- * unknown file as a link would let us move somebody's real credential around.
44
+ * replaced our symlink. Three states since #422 S4:
45
+ * - `link` – the machine login: the host user's `~/.codex/auth.json`;
46
+ * - `own` – no link on purpose (`[codex] auth = "own"` waiting for a login);
47
+ * - `account:<id>` – a saved login, `accounts/<id>/auth.json`.
48
+ * A runner from before S4 reads `account:<id>` as nothing, i.e. as `link`, and
49
+ * re-links the host login (К8): the machine login is intact, the switch is lost.
25
50
  */
26
51
  const MODE_FILENAME = '.devbridge-auth-mode';
52
+ const ACCOUNT_MARK_PREFIX = 'account:';
53
+ const ACCOUNTS_DIRNAME = 'accounts';
27
54
  /**
28
55
  * Minimal config. Deliberately has NO `[mcp_servers]` section: a per-thread
29
56
  * config overlay MERGES with the home config at the map-key level (verified
@@ -41,39 +68,194 @@ const CONFIG_BODY = [
41
68
  export function codexHomePath() {
42
69
  return path.join(stateDir(), 'codex-home');
43
70
  }
71
+ /** Where the saved Codex logins live – inside the one home, beside what they share. */
72
+ export function codexAccountsDir(home = codexHomePath()) {
73
+ return path.join(home, ACCOUNTS_DIRNAME);
74
+ }
75
+ /** The directory of one saved login. Throws on anything that is not an account id. */
76
+ export function codexAccountDir(id, home = codexHomePath()) {
77
+ if (!isAccountId(id))
78
+ throw new AccountError('no such account on this server');
79
+ return path.join(codexAccountsDir(home), id);
80
+ }
81
+ /** The login file of one saved account. */
82
+ export function codexAccountAuthFile(id, home = codexHomePath()) {
83
+ return path.join(codexAccountDir(id, home), AUTH_FILENAME);
84
+ }
85
+ /** The machine login of Codex – the host user's own file, never written by DevBridge (R13). */
86
+ export function hostCodexAuthFile(homedir = os.homedir()) {
87
+ return path.join(homedir, '.codex', AUTH_FILENAME);
88
+ }
44
89
  /**
45
- * Throwaway home for a device-code login. The flow runs here and is promoted
46
- * into the real home only on success, so an abandoned or timed-out sign-in
47
- * cannot destroy a credential that was working.
90
+ * Throwaway homes for device-code logins, one per attempt. The flow runs there
91
+ * and its login is moved into the store only on success, so an abandoned or
92
+ * timed-out sign-in cannot destroy a credential that was working.
48
93
  */
49
94
  export function stagingCodexHomePath() {
50
95
  return path.join(stateDir(), 'codex-login');
51
96
  }
52
- function readMode(dir) {
97
+ // ─── The configured mode ─────────────────────────────────────────────
98
+ let configuredAuth;
99
+ /**
100
+ * `[codex] auth` as the machine's owner WROTE it – `undefined` when the key is
101
+ * not in `config.toml`. Set once at daemon start.
102
+ *
103
+ * Only a mode that is really written may be forced, and even then not over a
104
+ * saved login (S4 item 2): the runner used to pass `link` by default from three
105
+ * places – the daemon start, every session start and the minute probe – and each
106
+ * of them would have undone a switch of account within a minute (К4).
107
+ */
108
+ export function configureCodexAuth(mode) {
109
+ configuredAuth = mode;
110
+ }
111
+ export function configuredCodexAuth() {
112
+ return configuredAuth;
113
+ }
114
+ // ─── The mark ────────────────────────────────────────────────────────
115
+ function readMark(dir) {
116
+ let value;
53
117
  try {
54
- const value = fs.readFileSync(path.join(dir, MODE_FILENAME), 'utf8').trim();
55
- return value === 'link' || value === 'own' ? value : null;
118
+ value = fs.readFileSync(path.join(dir, MODE_FILENAME), 'utf8').trim();
56
119
  }
57
120
  catch {
58
121
  return null;
59
122
  }
123
+ if (value === 'link' || value === 'own')
124
+ return { kind: value };
125
+ if (value.startsWith(ACCOUNT_MARK_PREFIX)) {
126
+ const id = value.slice(ACCOUNT_MARK_PREFIX.length);
127
+ if (isAccountId(id))
128
+ return { kind: 'account', id };
129
+ }
130
+ return null;
131
+ }
132
+ function markText(mark) {
133
+ return mark.kind === 'account' ? `${ACCOUNT_MARK_PREFIX}${mark.id}` : mark.kind;
60
134
  }
61
- function writeMode(dir, mode) {
135
+ /**
136
+ * Write-then-rename: a torn mark reads as «no mark», which reads as `link` – a
137
+ * half-written `account:` would quietly put the machine login back.
138
+ */
139
+ function writeMark(dir, mark) {
140
+ const target = path.join(dir, MODE_FILENAME);
62
141
  try {
63
- fs.writeFileSync(path.join(dir, MODE_FILENAME), mode, { mode: 0o600 });
142
+ const current = readMark(dir);
143
+ if (current && markText(current) === markText(mark))
144
+ return;
145
+ const tmp = `${target}.${process.pid}.tmp`;
146
+ fs.writeFileSync(tmp, markText(mark), { mode: 0o600 });
147
+ fs.renameSync(tmp, target);
64
148
  }
65
149
  catch (error) {
66
150
  log.warn('codex: could not record the auth mode', { error: String(error) });
67
151
  }
68
152
  }
153
+ /** Which saved login the home is set to, if any – the mark, read only. */
154
+ export function markedCodexAccount(home = codexHomePath()) {
155
+ const mark = readMark(home);
156
+ return mark?.kind === 'account' ? mark.id : null;
157
+ }
158
+ const isObject = (value) => !!value && typeof value === 'object' && !Array.isArray(value);
159
+ const text = (value, max) => typeof value === 'string' && value.trim().length > 0 && value.trim().length <= max
160
+ ? value.trim()
161
+ : undefined;
162
+ function jwtClaims(token) {
163
+ if (typeof token !== 'string')
164
+ return null;
165
+ const payload = token.split('.')[1];
166
+ if (!payload)
167
+ return null;
168
+ try {
169
+ const claims = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
170
+ return isObject(claims) ? claims : null;
171
+ }
172
+ catch {
173
+ return null;
174
+ }
175
+ }
176
+ /**
177
+ * Read a Codex login file without letting a token out of this function.
178
+ *
179
+ * The identity comes from the claims of `id_token`, decoded locally (S4 item 8):
180
+ * `email`, and under `https://api.openai.com/auth` the plan and the ChatGPT
181
+ * account; `tokens.account_id` first for the key, the claim when it is absent. An
182
+ * internal file of the CLI (§11): whatever cannot be read is simply not there –
183
+ * no identity is invented, and a row without a key merges with nothing.
184
+ *
185
+ * The verdict is the pre-S4 one (`readCodexCredential`): a refresh token means
186
+ * the CLI renews the access token on its own, so only a lone access token past
187
+ * its `exp` is `expired`.
188
+ */
189
+ export function readCodexCredentialFile(file) {
190
+ let raw;
191
+ let writtenMs;
192
+ try {
193
+ raw = fs.readFileSync(file, 'utf8');
194
+ writtenMs = Math.floor(fs.statSync(file).mtimeMs);
195
+ }
196
+ catch (error) {
197
+ const code = error.code;
198
+ return {
199
+ status: code === 'ENOENT' || code === 'ENOTDIR' ? 'missing' : 'unknown',
200
+ identity: {},
201
+ };
202
+ }
203
+ let parsed;
204
+ try {
205
+ parsed = JSON.parse(raw);
206
+ }
207
+ catch {
208
+ return { status: 'unreadable', identity: {}, writtenMs };
209
+ }
210
+ if (!isObject(parsed))
211
+ return { status: 'unreadable', identity: {}, writtenMs };
212
+ const tokens = isObject(parsed['tokens']) ? parsed['tokens'] : null;
213
+ const access = text(tokens?.['access_token'], 16_000);
214
+ const apiKey = text(parsed['OPENAI_API_KEY'], 1_000);
215
+ if (!access) {
216
+ return apiKey
217
+ ? { status: 'ok', kind: 'apikey', identity: {}, writtenMs }
218
+ : { status: 'unreadable', identity: {}, writtenMs };
219
+ }
220
+ const idClaims = jwtClaims(tokens?.['id_token']);
221
+ const auth = isObject(idClaims?.['https://api.openai.com/auth'])
222
+ ? idClaims['https://api.openai.com/auth']
223
+ : null;
224
+ const orgId = text(tokens?.['account_id'], 100) ?? text(auth?.['chatgpt_account_id'], 100);
225
+ const email = text(idClaims?.['email'], 320);
226
+ const plan = text(auth?.['chatgpt_plan_type'], 60);
227
+ const identity = {
228
+ ...(email ? { email } : {}),
229
+ ...(orgId ? { orgId } : {}),
230
+ ...(plan ? { plan } : {}),
231
+ };
232
+ if (text(tokens?.['refresh_token'], 16_000)) {
233
+ return { status: 'ok', kind: 'chatgpt', identity, writtenMs };
234
+ }
235
+ const exp = jwtClaims(access)?.['exp'];
236
+ if (typeof exp !== 'number' || !Number.isFinite(exp) || Math.abs(exp) > 8.64e12) {
237
+ return { status: 'ok', kind: 'chatgpt', identity, writtenMs };
238
+ }
239
+ const expiresAt = new Date(exp * 1000).toISOString();
240
+ return {
241
+ status: exp * 1000 < Date.now() ? 'expired' : 'ok',
242
+ kind: 'chatgpt',
243
+ identity,
244
+ expiresAt,
245
+ writtenMs,
246
+ };
247
+ }
248
+ // ─── The home ────────────────────────────────────────────────────────
69
249
  /**
70
250
  * Create (or refresh) the runner's CODEX_HOME and return it.
71
251
  *
72
- * `auth: 'link'` (default) symlinks the host user's `~/.codex/auth.json` so the
73
- * runner uses their ChatGPT subscription and — importantly — shares one
74
- * credential store with their own CLI, so a token refresh on either side keeps
75
- * both working. `auth: 'own'` leaves the home unauthenticated until a
76
- * device-code login writes into it, which is the choice for full isolation.
252
+ * `link` (the default when nothing else is decided) symlinks the host user's
253
+ * `~/.codex/auth.json` so the runner uses their ChatGPT subscription and —
254
+ * importantly — shares one credential store with their own CLI, so a token
255
+ * refresh on either side keeps both working. `own` leaves the home
256
+ * unauthenticated until a device-code login is stored for it, which is the
257
+ * choice for full isolation. A saved login (`account:<id>`) is kept whatever
258
+ * the configured mode says (S4 item 2).
77
259
  *
78
260
  * A home under the OS temp dir still works, but codex then refuses to install
79
261
  * its helper binaries ("Refusing to create helper binaries under temporary
@@ -99,64 +281,136 @@ export function ensureCodexHome(options = {}) {
99
281
  // old, possibly wider permissions (QA-100 MINOR-8).
100
282
  fs.chmodSync(configFile, 0o600);
101
283
  }
102
- return { path: dir, auth: ensureAuth(dir, options) };
284
+ return ensureAuth(dir, options);
285
+ }
286
+ function ensureAccountsRoot(dir) {
287
+ const root = codexAccountsDir(dir);
288
+ fs.mkdirSync(root, { recursive: true, mode: 0o700 });
289
+ fs.chmodSync(root, 0o700);
290
+ return root;
291
+ }
292
+ function lstatOrNull(file) {
293
+ try {
294
+ return fs.lstatSync(file);
295
+ }
296
+ catch {
297
+ return null;
298
+ }
299
+ }
300
+ function accountDirExists(id, dir) {
301
+ return lstatOrNull(codexAccountDir(id, dir))?.isDirectory() === true;
302
+ }
303
+ /**
304
+ * Point `auth.json` at `target`, atomically: a new link is made beside it and
305
+ * renamed over the old one, so a CLI opening the file never finds it missing.
306
+ * Never over a real file – the caller has moved that into the store first.
307
+ */
308
+ function pointLink(dir, target) {
309
+ const link = path.join(dir, AUTH_FILENAME);
310
+ const existing = lstatOrNull(link);
311
+ if (existing?.isSymbolicLink() && fs.readlinkSync(link) === target)
312
+ return;
313
+ if (existing && !existing.isSymbolicLink()) {
314
+ throw new AccountError('a login file stands where the link belongs – try again');
315
+ }
316
+ const tmp = `${link}.${process.pid}.link-tmp`;
317
+ fs.rmSync(tmp, { force: true });
318
+ fs.symlinkSync(target, tmp);
319
+ fs.renameSync(tmp, link);
103
320
  }
104
321
  function ensureAuth(dir, options) {
105
322
  const target = path.join(dir, AUTH_FILENAME);
106
- const userAuth = path.join(options.homedir ?? os.homedir(), '.codex', AUTH_FILENAME);
107
- const recorded = readMode(dir);
108
- // The marker is the persisted decision. Callers that do not name a mode —
109
- // the per-session repair, the health probe — must inherit it, or an
110
- // isolated home would be re-linked to the host credential by the next probe
111
- // that happens to run.
112
- const mode = options.auth ?? recorded ?? 'link';
113
- const existing = lstatOrNull(target);
114
- // A real file is a usable credential, full stop. We do NOT try to be clever
115
- // and push it back into the host user's ~/.codex: that file is theirs, we
116
- // cannot know which copy is newer, and getting it wrong costs them their
117
- // login. If codex replaced our symlink with its own atomic write, the
118
- // credential it wrote is right here — keep using it, and record that this
119
- // home now owns its login so we never overwrite it later.
120
- if (existing?.isFile()) {
121
- if (recorded === 'link') {
122
- log.warn("codex: auth.json is no longer a shared link — keeping it as this runner's own", {
123
- path: target,
124
- });
323
+ const userAuth = hostCodexAuthFile(options.homedir);
324
+ const explicit = options.auth ?? configuredAuth;
325
+ let mark = readMark(dir);
326
+ // A real file where our link belongs is a usable credential, full stop. It is
327
+ // never pushed into the host user's ~/.codex: that file is theirs, and
328
+ // guessing which copy is newer costs them their login.
329
+ if (lstatOrNull(target)?.isFile()) {
330
+ if (mark?.kind === 'account' && accountDirExists(mark.id, dir)) {
331
+ // S4 item 3: the CLI left its fresh token here instead of in the store –
332
+ // back into the store it goes, or the store would keep a rotated-out one.
333
+ returnRealFile(dir, mark.id);
334
+ }
335
+ else {
336
+ // D18, R9: a login this home owned as a file (a dashboard sign-in of an
337
+ // older runner, or codex replacing our link) becomes the first saved
338
+ // account – and stays the one in use.
339
+ const adopted = moveHomeFileIntoStore(dir, mark);
340
+ if (!adopted) {
341
+ // The file is still here and is being used where it is; or it moved
342
+ // under us and the mark on disk – ours or another process's – decides.
343
+ if (lstatOrNull(target)?.isFile())
344
+ return { path: dir, auth: 'own' };
345
+ mark = readMark(dir);
346
+ if (mark?.kind !== 'account')
347
+ return { path: dir, auth: 'missing' };
348
+ }
349
+ else {
350
+ mark = { kind: 'account', id: adopted };
351
+ }
125
352
  }
126
- writeMode(dir, 'own');
127
- return 'own';
128
353
  }
354
+ if (mark?.kind === 'account') {
355
+ if (accountDirExists(mark.id, dir)) {
356
+ const store = codexAccountAuthFile(mark.id, dir);
357
+ try {
358
+ pointLink(dir, store);
359
+ }
360
+ catch (error) {
361
+ log.warn('codex: could not point auth.json at the saved login', {
362
+ account: mark.id,
363
+ error: String(error),
364
+ });
365
+ return { path: dir, auth: 'missing', accountId: mark.id };
366
+ }
367
+ return {
368
+ path: dir,
369
+ auth: fs.existsSync(store) ? 'account' : 'missing',
370
+ accountId: mark.id,
371
+ };
372
+ }
373
+ // The login the home was set to is gone from the disk altogether (removed by
374
+ // hand): sessions and the verdict must agree on SOME login, and the machine's
375
+ // is the one that is always there. Said once, in the journal.
376
+ log.warn('codex: the saved login in use is gone from this machine – back to the machine login', {
377
+ account: mark.id,
378
+ });
379
+ mark = null;
380
+ }
381
+ const mode = explicit ?? (mark?.kind === 'own' ? 'own' : 'link');
382
+ const existing = lstatOrNull(target);
129
383
  if (mode === 'own') {
130
384
  if (existing?.isSymbolicLink())
131
385
  fs.rmSync(target, { force: true });
132
- writeMode(dir, 'own');
133
- return 'missing';
386
+ writeMark(dir, { kind: 'own' });
387
+ return { path: dir, auth: 'missing' };
134
388
  }
135
389
  if (existing?.isSymbolicLink()) {
136
390
  // Repoint if the user's file moved or the link went stale.
137
391
  const resolved = fs.readlinkSync(target);
138
392
  if (resolved === userAuth && fs.existsSync(userAuth)) {
139
- writeMode(dir, 'link');
140
- return 'linked';
393
+ writeMark(dir, { kind: 'link' });
394
+ return { path: dir, auth: 'linked' };
141
395
  }
142
396
  fs.rmSync(target, { force: true });
143
397
  }
398
+ writeMark(dir, { kind: 'link' });
144
399
  if (!fs.existsSync(userAuth))
145
- return 'missing';
400
+ return { path: dir, auth: 'missing' };
146
401
  try {
147
402
  fs.symlinkSync(userAuth, target);
148
- writeMode(dir, 'link');
149
403
  // Loud on purpose: the link going missing under a live daemon is exactly
150
404
  // the failure that made the panel report "login expired" about a perfectly
151
405
  // valid credential, and nothing recorded it. Now it does.
152
406
  if (!existing) {
153
407
  log.info('codex: linked the host auth.json into the isolated home', { path: target });
154
408
  }
155
- return 'linked';
409
+ return { path: dir, auth: 'linked' };
156
410
  }
157
411
  catch (error) {
158
412
  log.warn('codex: could not link the host auth.json', { error: String(error) });
159
- return 'missing';
413
+ return { path: dir, auth: 'missing' };
160
414
  }
161
415
  }
162
416
  /**
@@ -165,12 +419,15 @@ function ensureAuth(dir, options) {
165
419
  * it only answers "is the credential still where we left it, and if not, can we
166
420
  * put it back". This is what makes a credential disappearing under a running
167
421
  * daemon self-healing instead of permanent.
422
+ *
423
+ * Forces nothing it was not told to by the machine's owner (S4 item 2): with no
424
+ * `[codex] auth` in `config.toml` the mark decides, and a saved login stays.
168
425
  */
169
426
  export function repairCodexAuth(options = {}) {
170
427
  const dir = codexHomePath();
171
428
  if (!fs.existsSync(dir))
172
429
  return ensureCodexHome(options);
173
- return { path: dir, auth: ensureAuth(dir, options) };
430
+ return ensureAuth(dir, options);
174
431
  }
175
432
  function isUnderTempDir(dir) {
176
433
  try {
@@ -181,54 +438,343 @@ function isUnderTempDir(dir) {
181
438
  return false;
182
439
  }
183
440
  }
184
- function lstatOrNull(file) {
441
+ /**
442
+ * Drop a linked auth.json so a device-code login writes our own file instead.
443
+ * Never a real credential: only a symlink is removed.
444
+ */
445
+ export function detachLinkedAuth(dir = codexHomePath()) {
446
+ const target = path.join(dir, AUTH_FILENAME);
447
+ if (lstatOrNull(target)?.isSymbolicLink())
448
+ fs.rmSync(target, { force: true });
449
+ }
450
+ // ─── The store ───────────────────────────────────────────────────────
451
+ function stampedIdentity(identity) {
452
+ return { ...identity, at: new Date().toISOString() };
453
+ }
454
+ /** The key a saved row is known by – its file first, what was last read from it second. */
455
+ function storedKeyOf(id, dir) {
456
+ const fromFile = readCodexCredentialFile(codexAccountAuthFile(id, dir)).identity.orgId;
457
+ if (fromFile)
458
+ return fromFile;
459
+ return readAgentAuth().codexAccounts?.find((record) => record.id === id)?.lastSeenIdentity?.orgId;
460
+ }
461
+ /** A rename that refuses to become a copy (EXDEV), and leaves the file 0600. */
462
+ function moveLogin(source, destination) {
185
463
  try {
186
- return fs.lstatSync(file);
464
+ fs.renameSync(source, destination);
187
465
  }
188
- catch {
466
+ catch (error) {
467
+ if (error.code === 'EXDEV') {
468
+ throw new AccountError('the login is on another filesystem – it is moved, never copied');
469
+ }
470
+ throw error;
471
+ }
472
+ fs.chmodSync(destination, 0o600);
473
+ }
474
+ /** A file that is not a login is set aside under a dated name – never over a login that works. */
475
+ function setAside(file) {
476
+ const aside = `${file}.displaced-${Date.now()}`;
477
+ fs.renameSync(file, aside);
478
+ log.warn('codex: a file that is not a login stood in place of auth.json – moved aside', {
479
+ aside,
480
+ });
481
+ return aside;
482
+ }
483
+ /**
484
+ * Move a login file into the store (§8 `agent_account_login_code` for Codex, S4
485
+ * items 4 and 5) – by rename, and one subscription, one row.
486
+ *
487
+ * A known `account_id` that a saved row already has REPLACES that row's login:
488
+ * the row keeps its id (refusal marks and a live session are keyed by it, R15)
489
+ * and its `addedAt` becomes now – the date is that of the login the row holds,
490
+ * and a second sign-in of the same subscription must change something the
491
+ * window can see (S3 hands this over: `deviceSignInResult`). An unknown key
492
+ * merges with nothing. The machine row takes no part: it is the host's own file.
493
+ *
494
+ * The record is written BEFORE the file moves, so a failure never leaves a login
495
+ * nobody can list or forget; a move that fails takes its new record back.
496
+ */
497
+ export function storeCodexLogin(source, options) {
498
+ const dir = options.home ?? codexHomePath();
499
+ // A real file standing where the link belongs is settled BEFORE anything is
500
+ // moved in: otherwise this sign-in lands in the row's store and the file is
501
+ // then written over it as «the refreshed login of that row» – the new login
502
+ // would be gone (found by the independent check of S4).
503
+ if (path.resolve(source) !== path.resolve(dir, AUTH_FILENAME))
504
+ settleRealFile(dir);
505
+ const credential = readCodexCredentialFile(source);
506
+ if (credential.status === 'missing' || credential.status === 'unreadable') {
507
+ throw new AccountError('the sign-in finished but no usable login was stored – start again');
508
+ }
509
+ if (credential.status === 'unknown') {
510
+ throw new AccountError('the new login could not be read on this server – start again');
511
+ }
512
+ ensureAccountsRoot(dir);
513
+ const key = credential.identity.orgId;
514
+ const identity = stampedIdentity(credential.identity);
515
+ const records = readAgentAuth().codexAccounts ?? [];
516
+ const sameSubscription = key
517
+ ? records.find((record) => isAccountId(record.id) &&
518
+ record.lastSeenIdentity?.orgId === key &&
519
+ accountDirExists(record.id, dir))
520
+ : undefined;
521
+ // `replaceActive` is the one-login window's door (D27: with the organization's
522
+ // option off, a sign-in REPLACES the login in use rather than adding a row).
523
+ // The subscription still wins when it is already saved elsewhere, or two rows
524
+ // would share one key and the list could no longer tell them apart.
525
+ const inUse = options.replaceActive ? markedCodexAccount(dir) : null;
526
+ const same = sameSubscription ??
527
+ (inUse && isAccountId(inUse) && accountDirExists(inUse, dir)
528
+ ? records.find((record) => record.id === inUse)
529
+ : undefined);
530
+ let stored;
531
+ if (same) {
532
+ moveLogin(source, codexAccountAuthFile(same.id, dir));
533
+ if (options.activate)
534
+ markActiveCodexLogin(same.id, dir);
535
+ updateAgentAuth((file) => {
536
+ const record = file.codexAccounts?.find((entry) => entry.id === same.id);
537
+ if (!record)
538
+ return;
539
+ record.lastSeenIdentity = identity;
540
+ record.addedAt = new Date().toISOString();
541
+ delete record.loginExpiredAt;
542
+ });
543
+ // A fresh login outranks anything remembered about the one it replaced.
544
+ clearRefusal('codex', same.id);
545
+ stored = { id: same.id, replaced: same.id };
546
+ log.info('codex: a sign-in replaced the saved login of the same subscription', {
547
+ account: same.id,
548
+ });
549
+ }
550
+ else {
551
+ const id = newAccountId();
552
+ updateAgentAuth((file) => {
553
+ file.codexAccounts = [
554
+ ...(file.codexAccounts ?? []),
555
+ { id, addedAt: new Date().toISOString(), lastSeenIdentity: identity },
556
+ ];
557
+ });
558
+ try {
559
+ fs.mkdirSync(codexAccountDir(id, dir), { mode: 0o700 });
560
+ moveLogin(source, codexAccountAuthFile(id, dir));
561
+ // The mark right after the move, before anything else can throw: a login
562
+ // already in the store under a mark that still says `own` reads as «not
563
+ // signed in» on the next repair (found by the independent check of S4).
564
+ if (options.activate)
565
+ markActiveCodexLogin(id, dir);
566
+ }
567
+ catch (error) {
568
+ fs.rmSync(codexAccountDir(id, dir), { recursive: true, force: true });
569
+ updateAgentAuth((file) => {
570
+ file.codexAccounts = (file.codexAccounts ?? []).filter((entry) => entry.id !== id);
571
+ if (file.codexAccounts.length === 0)
572
+ delete file.codexAccounts;
573
+ });
574
+ throw error;
575
+ }
576
+ stored = { id };
577
+ log.info('codex: a login was added to the saved accounts', { account: id });
578
+ }
579
+ if (options.activate)
580
+ setCodexActiveLogin(stored.id, dir);
581
+ return stored;
582
+ }
583
+ /**
584
+ * The CLI left a real `auth.json` where the link to `activeId` was (S4 item 3).
585
+ *
586
+ * Normally it is the active login, refreshed: it replaces the store file (the
587
+ * store's copy is the older of the two). But the home is shared by every
588
+ * session, and the file of a DIFFERENT subscription must never be written over
589
+ * the active login – that would erase it. Such a file goes where its own key
590
+ * says (its row, or a new one), and a file that is not a login at all is set
591
+ * aside. The link is not touched here; the caller points it.
592
+ */
593
+ function returnRealFile(dir, activeId) {
594
+ const source = path.join(dir, AUTH_FILENAME);
595
+ const credential = readCodexCredentialFile(source);
596
+ if (credential.status === 'unreadable') {
597
+ setAside(source);
598
+ return;
599
+ }
600
+ if (credential.status === 'missing' || credential.status === 'unknown')
601
+ return;
602
+ const key = credential.identity.orgId;
603
+ const activeKey = storedKeyOf(activeId, dir);
604
+ if (key && activeKey && key !== activeKey) {
605
+ log.warn('codex: a login of another subscription stood in place of the link – stored as its own', {
606
+ account: activeId,
607
+ });
608
+ storeCodexLogin(source, { activate: false, home: dir });
609
+ return;
610
+ }
611
+ moveLogin(source, codexAccountAuthFile(activeId, dir));
612
+ updateAgentAuth((file) => {
613
+ const record = file.codexAccounts?.find((entry) => entry.id === activeId);
614
+ if (record && Object.keys(credential.identity).length > 0) {
615
+ record.lastSeenIdentity = stampedIdentity(credential.identity);
616
+ }
617
+ });
618
+ log.info('codex: the refreshed login was returned to its saved account', { account: activeId });
619
+ }
620
+ /** D18, R9: the home's own login file becomes a saved account, in use. Null – it stays a file. */
621
+ function moveHomeFileIntoStore(dir, mark) {
622
+ const source = path.join(dir, AUTH_FILENAME);
623
+ if (readCodexCredentialFile(source).status === 'unreadable') {
624
+ try {
625
+ setAside(source);
626
+ }
627
+ catch (error) {
628
+ log.warn('codex: could not set aside an unreadable auth.json', { error: String(error) });
629
+ return null;
630
+ }
631
+ return null;
632
+ }
633
+ try {
634
+ const stored = storeCodexLogin(source, { activate: true, home: dir });
635
+ log.info("codex: this runner's own login became the first saved account", {
636
+ account: stored.id,
637
+ was: mark ? markText(mark) : 'unmarked',
638
+ });
639
+ return stored.id;
640
+ }
641
+ catch (error) {
642
+ // Only when the login is still here. It may be gone because the move went
643
+ // through and something after it failed, or because another process – a
644
+ // `doctor` beside the daemon – moved it first: writing `own` then would say
645
+ // «no login here» about a login that is in the store and in use (found by
646
+ // the independent check of S4). The caller re-reads the mark instead.
647
+ if (lstatOrNull(source)?.isFile()) {
648
+ log.warn('codex: could not move the home login into the saved accounts – using it in place', {
649
+ error: String(error),
650
+ });
651
+ writeMark(dir, { kind: 'own' });
652
+ }
653
+ else {
654
+ log.warn('codex: the home login was moved by somebody else while we were storing it', {
655
+ error: String(error),
656
+ });
657
+ }
189
658
  return null;
190
659
  }
191
660
  }
192
661
  /**
193
- * Drop a linked auth.json so a device-code login writes our own file instead.
662
+ * Make a row the one the home uses: the mark first, then the link.
663
+ *
664
+ * In that order on purpose – a daemon that dies between the two leaves a mark
665
+ * the next repair finishes (it points the link where the mark says), never a
666
+ * link that the next repair would undo.
194
667
  *
195
- * Only ever called AFTER a login has actually succeeded in the staging home —
196
- * never as a pre-step. Detaching first meant an abandoned or timed-out sign-in
197
- * left the server permanently "not signed in", recoverable only by restarting
198
- * the daemon.
668
+ * A real file in place of the link is dealt with FIRST (§8 `activate`, S4 item
669
+ * 7): the CLI may have refreshed the login in use a moment ago, and repointing
670
+ * over it would throw that token away.
199
671
  */
200
- export function detachLinkedAuth(dir = codexHomePath()) {
672
+ export function markActiveCodexLogin(id, dir = codexHomePath()) {
673
+ writeMark(dir, id === MACHINE_ACCOUNT_ID ? { kind: 'link' } : { kind: 'account', id });
674
+ }
675
+ export function setCodexActiveLogin(id, dir = codexHomePath()) {
201
676
  const target = path.join(dir, AUTH_FILENAME);
202
- if (lstatOrNull(target)?.isSymbolicLink())
203
- fs.rmSync(target, { force: true });
677
+ settleRealFile(dir);
678
+ if (id === MACHINE_ACCOUNT_ID) {
679
+ writeMark(dir, { kind: 'link' });
680
+ const existing = lstatOrNull(target);
681
+ // The link to a saved login goes; `ensureAuth` puts the host's in its place.
682
+ if (existing?.isSymbolicLink() && fs.readlinkSync(target) !== hostCodexAuthFile()) {
683
+ fs.rmSync(target, { force: true });
684
+ }
685
+ }
686
+ else {
687
+ if (!accountDirExists(id, dir)) {
688
+ throw new AccountError('the login of this account is gone from this server – forget it');
689
+ }
690
+ writeMark(dir, { kind: 'account', id });
691
+ }
692
+ return ensureAuth(dir, {});
204
693
  }
205
694
  /**
206
- * Promote a credential produced by a staging login into the real home, and
207
- * record that this home now owns its own login.
695
+ * A real `auth.json` standing where the link belongs goes back where it belongs –
696
+ * the store of the row it is the login of – and nothing else moves until it has.
697
+ * Called before a sign-in is stored and before the link is repointed (S4 items 3
698
+ * and 7).
208
699
  */
209
- export function adoptLoginResult(stagingDir, dir = codexHomePath()) {
210
- const source = path.join(stagingDir, AUTH_FILENAME);
211
- if (!lstatOrNull(source)?.isFile())
212
- return false;
213
- fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
214
- detachLinkedAuth(dir);
700
+ function settleRealFile(dir) {
215
701
  const target = path.join(dir, AUTH_FILENAME);
216
- fs.copyFileSync(source, target);
217
- fs.chmodSync(target, 0o600);
218
- writeMode(dir, 'own');
219
- fs.rmSync(stagingDir, { recursive: true, force: true });
220
- return true;
702
+ if (!lstatOrNull(target)?.isFile())
703
+ return;
704
+ const current = readMark(dir);
705
+ if (current?.kind === 'account' && accountDirExists(current.id, dir)) {
706
+ returnRealFile(dir, current.id);
707
+ return;
708
+ }
709
+ storeCodexLogin(target, { activate: false, home: dir });
221
710
  }
222
- /** Seed a throwaway home for the device-code flow (config only, no credential). */
711
+ // ─── Signing in ──────────────────────────────────────────────────────
712
+ /**
713
+ * A fresh throwaway home for one device-code sign-in (config only, no credential).
714
+ *
715
+ * One directory per attempt, and every other one is removed first: only one
716
+ * sign-in runs on a machine at a time (the relay cancels the previous one before
717
+ * it gets here), and the pre-S4 runner kept a single fixed staging home that was
718
+ * never cleaned up – on this machine it had lain there since 01.08.2026.
719
+ */
223
720
  export function prepareStagingHome() {
224
- const dir = stagingCodexHomePath();
225
- fs.rmSync(dir, { recursive: true, force: true });
226
- fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
721
+ const root = stagingCodexHomePath();
722
+ discardAbandonedCodexStagingHomes();
723
+ fs.mkdirSync(root, { recursive: true, mode: 0o700 });
724
+ fs.chmodSync(root, 0o700);
725
+ const dir = path.join(root, newAccountId());
726
+ fs.mkdirSync(dir, { mode: 0o700 });
227
727
  fs.writeFileSync(path.join(dir, CONFIG_FILENAME), CONFIG_BODY, { mode: 0o600 });
228
728
  return dir;
229
729
  }
230
- /** Remove the staging home whatever the outcome — it may hold a credential. */
231
- export function discardStagingHome() {
232
- fs.rmSync(stagingCodexHomePath(), { recursive: true, force: true });
730
+ function isStagingDir(dir) {
731
+ return (path.dirname(path.resolve(dir)) === path.resolve(stagingCodexHomePath()) &&
732
+ isAccountId(path.basename(dir)));
733
+ }
734
+ /** Remove one sign-in's home whatever the outcome — it may hold a credential. */
735
+ export function discardStagingHome(dir) {
736
+ if (!isStagingDir(dir))
737
+ throw new AccountError('not a staging home');
738
+ fs.rmSync(dir, { recursive: true, force: true });
739
+ }
740
+ /**
741
+ * Remove every sign-in home – at daemon start (nothing can be signing in yet)
742
+ * and before a new sign-in. Includes the fixed-path staging home of runners
743
+ * before S4.
744
+ */
745
+ export function discardAbandonedCodexStagingHomes() {
746
+ const root = stagingCodexHomePath();
747
+ let names;
748
+ try {
749
+ names = fs.readdirSync(root);
750
+ }
751
+ catch {
752
+ return 0;
753
+ }
754
+ for (const name of names)
755
+ fs.rmSync(path.join(root, name), { recursive: true, force: true });
756
+ return names.length;
757
+ }
758
+ /**
759
+ * A device-code sign-in in `stagingDir` finished: its login becomes a saved
760
+ * account – by rename, deduplicated by subscription – and the one in use (R15).
761
+ * The staging home is removed either way.
762
+ *
763
+ * `replaceActive` – the sign-in came through the one-login window, where it has
764
+ * always meant «this login from now on»: it takes the place of the login in use
765
+ * rather than adding a row (D27, for an organization without several logins).
766
+ */
767
+ export function adoptLoginResult(stagingDir, options = {}) {
768
+ if (!isStagingDir(stagingDir))
769
+ throw new AccountError('no sign-in in progress – start again');
770
+ try {
771
+ return storeCodexLogin(path.join(stagingDir, AUTH_FILENAME), {
772
+ activate: true,
773
+ ...(options.replaceActive ? { replaceActive: true } : {}),
774
+ });
775
+ }
776
+ finally {
777
+ fs.rmSync(stagingDir, { recursive: true, force: true });
778
+ }
233
779
  }
234
780
  //# sourceMappingURL=codex-home.js.map