sidekick-shared 0.26.5 → 0.26.6

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.
Files changed (55) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.md +56 -51
  3. package/dist/accountChanges.js +21 -2
  4. package/dist/accountHealth.d.ts +72 -0
  5. package/dist/accountHealth.js +286 -0
  6. package/dist/accountLaunch.d.ts +30 -0
  7. package/dist/accountLaunch.js +124 -0
  8. package/dist/accountManager.d.ts +33 -8
  9. package/dist/accountManager.js +183 -50
  10. package/dist/accountRegistry.d.ts +10 -0
  11. package/dist/accountRegistry.js +42 -22
  12. package/dist/accountSwitch.d.ts +43 -0
  13. package/dist/accountSwitch.js +115 -0
  14. package/dist/accountSync.d.ts +30 -0
  15. package/dist/accountSync.js +180 -0
  16. package/dist/accountSyncTypes.d.ts +25 -0
  17. package/dist/accountSyncTypes.js +7 -0
  18. package/dist/accounts.d.ts +58 -6
  19. package/dist/accounts.js +408 -189
  20. package/dist/claudeCredentials.d.ts +38 -0
  21. package/dist/claudeCredentials.js +74 -0
  22. package/dist/claudeProfiles.d.ts +40 -0
  23. package/dist/claudeProfiles.js +102 -10
  24. package/dist/codexAuth.d.ts +32 -0
  25. package/dist/codexAuth.js +127 -0
  26. package/dist/codexPaths.d.ts +21 -0
  27. package/dist/codexPaths.js +117 -0
  28. package/dist/codexProfiles.d.ts +58 -5
  29. package/dist/codexProfiles.js +574 -289
  30. package/dist/credentialIO.d.ts +12 -5
  31. package/dist/credentialIO.js +63 -29
  32. package/dist/credentials.d.ts +8 -0
  33. package/dist/credentials.js +28 -32
  34. package/dist/ensureDefaultAccounts.d.ts +8 -0
  35. package/dist/ensureDefaultAccounts.js +42 -82
  36. package/dist/index.d.ts +27 -10
  37. package/dist/index.js +61 -9
  38. package/dist/keepAlive.d.ts +44 -0
  39. package/dist/keepAlive.js +106 -0
  40. package/dist/processDetection.d.ts +32 -0
  41. package/dist/processDetection.js +210 -0
  42. package/dist/schemas/accountManager.d.ts +283 -1
  43. package/dist/schemas/accountManager.js +122 -1
  44. package/dist/schemas/assistantTurn.d.ts +2 -2
  45. package/dist/schemas/index.d.ts +6 -1
  46. package/dist/schemas/index.js +14 -1
  47. package/dist/schemas/observedSessionV1.d.ts +21 -21
  48. package/dist/schemas/quota.d.ts +18 -18
  49. package/dist/schemas/quotaHistory.d.ts +1 -1
  50. package/dist/schemas/stateFile.d.ts +5 -5
  51. package/dist/terminalSync.d.ts +5 -5
  52. package/dist/terminalSync.js +7 -112
  53. package/dist/writers/atomic.d.ts +2 -0
  54. package/dist/writers/atomic.js +49 -2
  55. package/package.json +1 -1
@@ -33,10 +33,12 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.getSystemCodexHome = getSystemCodexHome;
36
+ exports.CODEX_KEYRING_FIX_HINT = exports.getCodexCredentialStoreMode = exports.getSystemCodexHome = exports.getCodexProfileHome = exports.getCodexProfilesDir = void 0;
37
37
  exports.getCodexMonitoringHomes = getCodexMonitoringHomes;
38
- exports.getCodexProfilesDir = getCodexProfilesDir;
39
- exports.getCodexProfileHome = getCodexProfileHome;
38
+ exports.storeCodexProfileAuth = storeCodexProfileAuth;
39
+ exports.parseCodexLoginStatusOutput = parseCodexLoginStatusOutput;
40
+ exports.getCodexLoginStatus = getCodexLoginStatus;
41
+ exports.getCodexLoginStatusAsync = getCodexLoginStatusAsync;
40
42
  exports.readCodexAccountMetadata = readCodexAccountMetadata;
41
43
  exports.readCodexAccountMetadataAsync = readCodexAccountMetadataAsync;
42
44
  exports.isCodexProfileAuthenticated = isCodexProfileAuthenticated;
@@ -50,13 +52,17 @@ exports.prepareCodexAccount = prepareCodexAccount;
50
52
  exports.prepareCodexAccountAsync = prepareCodexAccountAsync;
51
53
  exports.finalizeCodexAccount = finalizeCodexAccount;
52
54
  exports.finalizeCodexAccountAsync = finalizeCodexAccountAsync;
55
+ exports.withCodexAuthSwapLock = withCodexAuthSwapLock;
56
+ exports.dedupeCodexProfiles = dedupeCodexProfiles;
57
+ exports.syncCodexLiveStateUnlocked = syncCodexLiveStateUnlocked;
58
+ exports.syncCodexLiveState = syncCodexLiveState;
59
+ exports.cleanupAbandonedCodexLogins = cleanupAbandonedCodexLogins;
53
60
  exports.switchToCodexAccount = switchToCodexAccount;
54
61
  exports.switchToCodexAccountAsync = switchToCodexAccountAsync;
55
62
  exports.reconcileCodexAuthState = reconcileCodexAuthState;
56
63
  exports.reconcileCodexAuthStateAsync = reconcileCodexAuthStateAsync;
57
64
  exports.removeCodexAccount = removeCodexAccount;
58
65
  const fs = __importStar(require("fs"));
59
- const os = __importStar(require("os"));
60
66
  const path = __importStar(require("path"));
61
67
  const child_process_1 = require("child_process");
62
68
  const crypto_1 = require("crypto");
@@ -65,16 +71,19 @@ const accountRegistry_1 = require("./accountRegistry");
65
71
  // re-serializing would drop fields added by newer codex versions, and the
66
72
  // rotated refresh token inside is only valid in its freshest form.
67
73
  const atomic_1 = require("./writers/atomic");
68
- // Codex refreshes OAuth tokens at most every 8 days; a stored refresh token
69
- // older than that may already be rejected by the auth server.
70
- const STALE_AUTH_THRESHOLD_MS = 8 * 24 * 60 * 60 * 1000;
71
- function getDefaultSystemCodexHome() {
72
- return path.join(os.homedir(), '.codex');
73
- }
74
- function getExplicitCodexHome() {
75
- const explicitHome = process.env.CODEX_HOME?.trim();
76
- return explicitHome ? explicitHome : null;
77
- }
74
+ const codexAuth_1 = require("./codexAuth");
75
+ const codexPaths_1 = require("./codexPaths");
76
+ const accountHealth_1 = require("./accountHealth");
77
+ const accountSwitch_1 = require("./accountSwitch");
78
+ const accountSyncTypes_1 = require("./accountSyncTypes");
79
+ const processDetection_1 = require("./processDetection");
80
+ const quotaSnapshots_1 = require("./quotaSnapshots");
81
+ var codexPaths_2 = require("./codexPaths");
82
+ Object.defineProperty(exports, "getCodexProfilesDir", { enumerable: true, get: function () { return codexPaths_2.getCodexProfilesDir; } });
83
+ Object.defineProperty(exports, "getCodexProfileHome", { enumerable: true, get: function () { return codexPaths_2.getCodexProfileHome; } });
84
+ Object.defineProperty(exports, "getSystemCodexHome", { enumerable: true, get: function () { return codexPaths_2.getSystemCodexHome; } });
85
+ Object.defineProperty(exports, "getCodexCredentialStoreMode", { enumerable: true, get: function () { return codexPaths_2.getCodexCredentialStoreMode; } });
86
+ exports.CODEX_KEYRING_FIX_HINT = 'Set `cli_auth_credentials_store = "file"` in ~/.codex/config.toml and run `codex login` again.';
78
87
  function dedupePaths(paths) {
79
88
  const seen = new Set();
80
89
  const unique = [];
@@ -87,58 +96,32 @@ function dedupePaths(paths) {
87
96
  }
88
97
  return unique;
89
98
  }
90
- function getSystemCodexHome() {
91
- return getExplicitCodexHome() ?? getDefaultSystemCodexHome();
92
- }
93
99
  function getCodexMonitoringHomes() {
94
- const explicitHome = getExplicitCodexHome();
100
+ const explicitHome = (0, codexPaths_1.getExplicitCodexHome)();
95
101
  if (explicitHome)
96
102
  return [explicitHome];
97
103
  // The system home is the single live home; profile homes only matter for
98
104
  // sessions recorded back when they doubled as live CODEX_HOMEs.
99
- const homes = [getDefaultSystemCodexHome()];
105
+ const homes = [(0, codexPaths_1.getDefaultSystemCodexHome)()];
100
106
  for (const profile of listCodexAccounts()) {
101
- const profileHome = getCodexProfileHome(profile.id);
107
+ const profileHome = (0, codexPaths_1.getCodexProfileHome)(profile.id);
102
108
  if (fs.existsSync(path.join(profileHome, 'sessions'))) {
103
109
  homes.push(profileHome);
104
110
  }
105
111
  }
106
112
  return dedupePaths(homes);
107
113
  }
108
- function getCodexProfilesDir() {
109
- return path.join((0, accountRegistry_1.getAccountsDir)(), 'codex', 'profiles');
110
- }
111
- function getCodexProfileDir(profileId) {
112
- return path.join(getCodexProfilesDir(), profileId);
113
- }
114
- function getCodexProfileHome(profileId) {
115
- return path.join(getCodexProfileDir(profileId), 'codex-home');
116
- }
117
- function getCodexProfileStatePath(profileId) {
118
- return path.join(getCodexProfileDir(profileId), 'profile.json');
119
- }
120
- function ensureCodexProfileDirs(profileId) {
121
- fs.mkdirSync(getCodexProfileHome(profileId), { recursive: true, mode: 0o700 });
122
- }
123
- function readFileOrNull(filePath) {
124
- try {
125
- return fs.readFileSync(filePath, 'utf8');
126
- }
127
- catch {
128
- return null;
129
- }
130
- }
131
114
  function readPendingProfile(profileId) {
132
115
  try {
133
- return JSON.parse(fs.readFileSync(getCodexProfileStatePath(profileId), 'utf8'));
116
+ return JSON.parse(fs.readFileSync((0, codexPaths_1.getCodexProfileStatePath)(profileId), 'utf8'));
134
117
  }
135
118
  catch {
136
119
  return null;
137
120
  }
138
121
  }
139
122
  function writePendingProfile(profileId, pending) {
140
- ensureCodexProfileDirs(profileId);
141
- (0, atomic_1.atomicWriteJsonSync)(getCodexProfileStatePath(profileId), pending);
123
+ (0, codexPaths_1.ensureCodexProfileDirs)(profileId);
124
+ (0, atomic_1.atomicWriteJsonSync)((0, codexPaths_1.getCodexProfileStatePath)(profileId), pending);
142
125
  }
143
126
  function copyIfExists(source, destination) {
144
127
  if (!fs.existsSync(source))
@@ -147,75 +130,36 @@ function copyIfExists(source, destination) {
147
130
  fs.copyFileSync(source, destination);
148
131
  return true;
149
132
  }
133
+ /**
134
+ * Copy the live `config.toml` into a profile home, forcing file-based
135
+ * credential storage so an isolated `codex login` writes `auth.json` (a
136
+ * keyring entry keyed to the profile home could never be swapped).
137
+ */
150
138
  function copySourceCodexConfig(sourceHome, targetHome) {
151
- copyIfExists(path.join(sourceHome, 'config.toml'), path.join(targetHome, 'config.toml'));
139
+ const source = (0, codexPaths_1.readFileOrNull)(path.join(sourceHome, 'config.toml')) ?? '';
140
+ (0, atomic_1.atomicWriteFileSync)(path.join(targetHome, 'config.toml'), (0, codexPaths_1.forceFileCredentialStore)(source));
152
141
  }
153
142
  function importCurrentCodexAuth(sourceHome, targetHome) {
154
143
  const authCopied = copyIfExists(path.join(sourceHome, 'auth.json'), path.join(targetHome, 'auth.json'));
155
144
  const legacyCredsCopied = copyIfExists(path.join(sourceHome, '.credentials.json'), path.join(targetHome, '.credentials.json'));
156
145
  return authCopied || legacyCredsCopied;
157
146
  }
158
- function parseJwtPayload(jwt) {
159
- const parts = jwt.split('.');
160
- if (parts.length < 2)
161
- return null;
162
- try {
163
- const payload = Buffer.from(parts[1], 'base64url').toString('utf8');
164
- return JSON.parse(payload);
165
- }
166
- catch {
167
- return null;
168
- }
169
- }
170
- function parseAuthJson(raw) {
171
- if (!raw)
172
- return null;
173
- try {
174
- return JSON.parse(raw);
175
- }
176
- catch {
177
- return null;
178
- }
179
- }
180
- function readAuthIdentityFromRaw(raw) {
181
- const parsed = parseAuthJson(raw);
182
- if (!parsed)
183
- return null;
184
- const idToken = parsed.tokens?.id_token;
185
- const claims = idToken ? parseJwtPayload(idToken) : null;
186
- const profileClaims = claims?.['https://api.openai.com/profile'];
187
- const authClaims = claims?.['https://api.openai.com/auth'];
188
- const email = typeof claims?.email === 'string'
189
- ? claims.email
190
- : typeof profileClaims?.email === 'string'
191
- ? profileClaims.email
192
- : undefined;
193
- const workspaceId = typeof authClaims?.chatgpt_account_id === 'string'
194
- ? authClaims.chatgpt_account_id
195
- : parsed.tokens?.account_id;
196
- const planType = typeof authClaims?.chatgpt_plan_type === 'string' ? authClaims.chatgpt_plan_type : undefined;
197
- const authMode = parsed.OPENAI_API_KEY || parsed.auth_mode === 'api_key' ? 'api-key' : 'chatgpt';
198
- return { email, workspaceId, planType, authMode };
199
- }
200
- function readLastRefresh(raw, fallbackPath) {
201
- const parsed = parseAuthJson(raw);
202
- if (parsed?.last_refresh) {
203
- const ts = Date.parse(parsed.last_refresh);
204
- if (!Number.isNaN(ts))
205
- return ts;
206
- }
207
- if (fallbackPath) {
208
- try {
209
- return fs.statSync(fallbackPath).mtimeMs;
210
- }
211
- catch {
212
- /* fall through */
213
- }
214
- }
215
- return null;
147
+ /**
148
+ * The single chokepoint for storing a Codex credential in a profile: the raw
149
+ * `auth.json` bytes (never re-serialized), the legacy `.credentials.json`
150
+ * when present, and the secret-free health sidecar.
151
+ */
152
+ function storeCodexProfileAuth(profileId, authRaw, legacyRaw, source) {
153
+ const profileHome = (0, codexPaths_1.getCodexProfileHome)(profileId);
154
+ if (authRaw)
155
+ (0, atomic_1.atomicWriteFileSync)(path.join(profileHome, 'auth.json'), authRaw);
156
+ if (legacyRaw)
157
+ (0, atomic_1.atomicWriteFileSync)(path.join(profileHome, '.credentials.json'), legacyRaw);
158
+ if (authRaw)
159
+ (0, accountHealth_1.writeCodexHealthSidecar)(profileId, authRaw, source);
216
160
  }
217
161
  function readMetadataFromAuthJson(codexHome) {
218
- const identity = readAuthIdentityFromRaw(readFileOrNull(path.join(codexHome, 'auth.json')));
162
+ const identity = (0, codexAuth_1.readAuthIdentityFromRaw)((0, codexPaths_1.readFileOrNull)(path.join(codexHome, 'auth.json')));
219
163
  if (!identity)
220
164
  return {};
221
165
  return {
@@ -289,33 +233,6 @@ function getCodexLoginStatusAsync(codexHome) {
289
233
  }
290
234
  });
291
235
  }
292
- /** Sync sibling of {@link detectRunningCodexProcessAsync}; see the event-loop caveat above. */
293
- function detectRunningCodexProcess() {
294
- if (process.platform === 'win32')
295
- return false;
296
- try {
297
- return ((0, child_process_1.spawnSync)('pgrep', ['-x', 'codex'], {
298
- encoding: 'utf8',
299
- timeout: PROBE_TIMEOUT_MS,
300
- killSignal: 'SIGKILL',
301
- }).status === 0);
302
- }
303
- catch {
304
- return false;
305
- }
306
- }
307
- function detectRunningCodexProcessAsync() {
308
- if (process.platform === 'win32')
309
- return Promise.resolve(false);
310
- return new Promise((resolve) => {
311
- try {
312
- (0, child_process_1.execFile)('pgrep', ['-x', 'codex'], { encoding: 'utf8', timeout: PROBE_TIMEOUT_MS, killSignal: 'SIGKILL' }, (error) => resolve(!error));
313
- }
314
- catch {
315
- resolve(false);
316
- }
317
- });
318
- }
319
236
  /** File-based metadata only — never spawns the codex CLI. */
320
237
  function readCodexAccountMetadataFromFiles(codexHome) {
321
238
  const fromAuth = readMetadataFromAuthJson(codexHome);
@@ -365,7 +282,13 @@ function getActiveCodexAccount() {
365
282
  function resolveSidekickCodexHome() {
366
283
  // Account switching swaps auth.json inside the system home, so the system
367
284
  // home (or an explicit CODEX_HOME) is always the single live home.
368
- return getSystemCodexHome();
285
+ return (0, codexPaths_1.getSystemCodexHome)();
286
+ }
287
+ function profileIdentity(profile) {
288
+ return {
289
+ email: profile.email ?? profile.metadata?.email,
290
+ workspaceId: profile.metadata?.workspaceId,
291
+ };
369
292
  }
370
293
  /**
371
294
  * Resolves the *currently logged-in* Codex account for display, preferring the
@@ -382,7 +305,7 @@ function resolveSidekickCodexHome() {
382
305
  */
383
306
  function resolveActiveCodexAccount(options = {}) {
384
307
  const selfHeal = options.selfHeal ?? true;
385
- const identity = readAuthIdentityFromRaw(readFileOrNull(path.join(resolveSidekickCodexHome(), 'auth.json')));
308
+ const identity = (0, codexAuth_1.readAuthIdentityFromRaw)((0, codexPaths_1.readFileOrNull)(path.join(resolveSidekickCodexHome(), 'auth.json')));
386
309
  if (identity && (identity.email || identity.workspaceId)) {
387
310
  const match = findProfileForIdentity(identity);
388
311
  if (match) {
@@ -393,7 +316,7 @@ function resolveActiveCodexAccount(options = {}) {
393
316
  // disk) must never break display, extension activation, or the quota
394
317
  // watcher's hot path. We still return the correct live identity below.
395
318
  try {
396
- (0, accountRegistry_1.setActiveSavedAccount)('codex', match.id);
319
+ (0, accountRegistry_1.setActiveSavedAccount)('codex', match.id, { silent: true });
397
320
  }
398
321
  catch {
399
322
  /* keep going with the live identity */
@@ -427,6 +350,13 @@ function getCodexExecutionEnv(baseEnv = process.env) {
427
350
  CODEX_HOME: resolveSidekickCodexHome(),
428
351
  };
429
352
  }
353
+ function keyringRefusal(mode) {
354
+ return {
355
+ success: false,
356
+ needsLogin: false,
357
+ error: `Codex stores credentials in the OS keyring (cli_auth_credentials_store = "${mode}"); sidekick can only switch file-based logins. ${exports.CODEX_KEYRING_FIX_HINT}`,
358
+ };
359
+ }
430
360
  function prepareCodexAccountCore(label) {
431
361
  const trimmedLabel = label.trim();
432
362
  if (!trimmedLabel) {
@@ -436,17 +366,60 @@ function prepareCodexAccountCore(label) {
436
366
  if (labelError) {
437
367
  return { result: { success: false, error: labelError } };
438
368
  }
369
+ const sourceHome = (0, codexPaths_1.getSystemCodexHome)();
370
+ const storeMode = (0, codexPaths_1.getCodexCredentialStoreMode)(sourceHome);
371
+ const liveAuthPresent = hasCodexCredentialFiles(sourceHome);
372
+ if (storeMode === 'keyring' || (storeMode === 'auto' && !liveAuthPresent)) {
373
+ return { result: keyringRefusal(storeMode) };
374
+ }
439
375
  const profileId = (0, crypto_1.randomUUID)();
440
- const codexHome = getCodexProfileHome(profileId);
441
- ensureCodexProfileDirs(profileId);
376
+ const codexHome = (0, codexPaths_1.getCodexProfileHome)(profileId);
377
+ (0, codexPaths_1.ensureCodexProfileDirs)(profileId);
442
378
  writePendingProfile(profileId, {
443
379
  label: trimmedLabel,
444
380
  addedAt: new Date().toISOString(),
445
381
  });
446
- const sourceHome = getSystemCodexHome();
447
382
  copySourceCodexConfig(sourceHome, codexHome);
448
383
  const imported = importCurrentCodexAuth(sourceHome, codexHome);
449
384
  if (imported) {
385
+ // The live login may already be saved under another profile: fold into it
386
+ // instead of creating a duplicate seat.
387
+ const identity = (0, codexAuth_1.readAuthIdentityFromRaw)((0, codexPaths_1.readFileOrNull)(path.join(codexHome, 'auth.json')));
388
+ const existing = identity
389
+ ? findProfileForIdentity(identity, { fallbackToActive: false })
390
+ : null;
391
+ if (existing) {
392
+ try {
393
+ const liveRefresh = (0, codexAuth_1.readLastRefresh)((0, codexPaths_1.readFileOrNull)(path.join(codexHome, 'auth.json')));
394
+ const storedRefresh = (0, codexAuth_1.readLastRefresh)((0, codexPaths_1.readFileOrNull)(path.join((0, codexPaths_1.getCodexProfileHome)(existing.id), 'auth.json')));
395
+ const storedIsNewer = storedRefresh !== null && liveRefresh !== null && storedRefresh > liveRefresh;
396
+ if (!storedIsNewer) {
397
+ storeCodexProfileAuth(existing.id, (0, codexPaths_1.readFileOrNull)(path.join(codexHome, 'auth.json')), (0, codexPaths_1.readFileOrNull)(path.join(codexHome, '.credentials.json')), 'login');
398
+ }
399
+ }
400
+ catch {
401
+ /* the existing backup stays as it was */
402
+ }
403
+ fs.rmSync((0, codexPaths_1.getCodexProfileDir)(profileId), { recursive: true, force: true });
404
+ const relabel = existing.metadata?.origin === 'live-sync' && existing.label === existing.email
405
+ ? {
406
+ ...existing,
407
+ label: trimmedLabel,
408
+ metadata: { ...existing.metadata, origin: 'manual' },
409
+ }
410
+ : existing;
411
+ if (relabel !== existing)
412
+ (0, accountRegistry_1.upsertSavedAccountProfile)(relabel);
413
+ return {
414
+ result: {
415
+ success: true,
416
+ profileId: existing.id,
417
+ codexHome: (0, codexPaths_1.getCodexProfileHome)(existing.id),
418
+ needsLogin: false,
419
+ warning: `This Codex login is already saved as "${relabel.label ?? existing.email ?? existing.id}".`,
420
+ },
421
+ };
422
+ }
450
423
  return { finalize: { profileId, codexHome } };
451
424
  }
452
425
  return { result: { success: true, profileId, codexHome, needsLogin: true } };
@@ -471,23 +444,59 @@ async function prepareCodexAccountAsync(label) {
471
444
  function finalizeCodexAccountCore(profileId, opts, probes) {
472
445
  const pending = readPendingProfile(profileId);
473
446
  if (!pending) {
474
- return { result: { success: false, error: `Codex profile ${profileId} was not prepared.` } };
447
+ // Already finalized: `prepareCodexAccount` folds a live login that is
448
+ // already saved into its profile and reports `needsLogin: false`, and the
449
+ // login flow then finalizes that profile id. Activate it if asked.
450
+ const saved = listCodexAccounts().find((profile) => profile.id === profileId);
451
+ if (!saved) {
452
+ return { result: { success: false, error: `Codex profile ${profileId} was not prepared.` } };
453
+ }
454
+ if (opts.activate === false)
455
+ return { result: { success: true, profileId } };
456
+ return { swap: saved };
475
457
  }
476
- const codexHome = getCodexProfileHome(profileId);
458
+ const codexHome = (0, codexPaths_1.getCodexProfileHome)(profileId);
477
459
  if (!probes.authenticated) {
478
460
  return { result: { success: false, error: 'Codex profile is not authenticated yet.' } };
479
461
  }
462
+ // A fresh isolated login for an account that is already saved: fold into the
463
+ // existing profile rather than registering a second seat.
464
+ const authRaw = (0, codexPaths_1.readFileOrNull)(path.join(codexHome, 'auth.json'));
465
+ const identity = (0, codexAuth_1.readAuthIdentityFromRaw)(authRaw);
466
+ const existing = identity ? findProfileForIdentity(identity, { fallbackToActive: false }) : null;
467
+ if (existing && existing.id !== profileId) {
468
+ storeCodexProfileAuth(existing.id, authRaw, (0, codexPaths_1.readFileOrNull)(path.join(codexHome, '.credentials.json')), 'login');
469
+ fs.rmSync((0, codexPaths_1.getCodexProfileDir)(profileId), { recursive: true, force: true });
470
+ const merged = {
471
+ ...existing,
472
+ label: existing.metadata?.origin === 'live-sync' ? pending.label : existing.label,
473
+ email: probes.metadata.email ?? existing.email,
474
+ metadata: { ...existing.metadata, ...probes.metadata, origin: 'login' },
475
+ };
476
+ (0, accountRegistry_1.upsertSavedAccountProfile)(merged);
477
+ if (opts.activate === false)
478
+ return { result: { success: true, profileId: existing.id } };
479
+ return { swap: merged };
480
+ }
480
481
  const profile = {
481
482
  id: profileId,
482
483
  providerId: 'codex',
483
484
  label: pending.label,
484
485
  email: probes.metadata.email,
485
486
  addedAt: pending.addedAt,
486
- metadata: probes.metadata,
487
+ metadata: { ...probes.metadata, origin: 'login' },
487
488
  };
488
489
  (0, accountRegistry_1.upsertSavedAccountProfile)(profile);
490
+ if (authRaw)
491
+ (0, accountHealth_1.writeCodexHealthSidecar)(profileId, authRaw, 'login');
492
+ try {
493
+ fs.rmSync((0, codexPaths_1.getCodexProfileStatePath)(profileId), { force: true });
494
+ }
495
+ catch {
496
+ /* the pending marker is advisory */
497
+ }
489
498
  if (opts.activate === false) {
490
- return { result: { success: true } };
499
+ return { result: { success: true, profileId } };
491
500
  }
492
501
  if (!hasCodexCredentialFiles(codexHome)) {
493
502
  // Authenticated via the OS keyring — there are no credential files to
@@ -496,7 +505,8 @@ function finalizeCodexAccountCore(profileId, opts, probes) {
496
505
  return {
497
506
  result: {
498
507
  success: true,
499
- warning: 'Codex stores credentials in the OS keyring; sidekick cannot swap them per account, so `codex` keeps using the keyring credentials.',
508
+ profileId,
509
+ warning: `Codex stores credentials in the OS keyring; sidekick cannot swap them per account, so \`codex\` keeps using the keyring credentials. ${exports.CODEX_KEYRING_FIX_HINT}`,
500
510
  },
501
511
  };
502
512
  }
@@ -504,29 +514,34 @@ function finalizeCodexAccountCore(profileId, opts, probes) {
504
514
  }
505
515
  function finalizeCodexAccount(profileId, opts = {}) {
506
516
  if (!readPendingProfile(profileId)) {
507
- return { success: false, error: `Codex profile ${profileId} was not prepared.` };
517
+ // Already finalized: the core activates a saved profile without probing.
518
+ const step = finalizeCodexAccountCore(profileId, opts, { authenticated: false, metadata: {} });
519
+ return 'swap' in step ? performCodexAuthSwap(step.swap, {}) : step.result;
508
520
  }
509
- const codexHome = getCodexProfileHome(profileId);
521
+ const codexHome = (0, codexPaths_1.getCodexProfileHome)(profileId);
510
522
  const authenticated = isCodexProfileAuthenticated(codexHome);
511
523
  const step = finalizeCodexAccountCore(profileId, opts, {
512
524
  authenticated,
513
525
  metadata: authenticated ? readCodexAccountMetadata(codexHome) : {},
514
526
  });
515
- return 'swap' in step ? performCodexAuthSwap(step.swap) : step.result;
527
+ return 'swap' in step ? performCodexAuthSwap(step.swap, {}) : step.result;
516
528
  }
517
529
  /** {@link finalizeCodexAccount} without blocking the event loop on CLI probes. */
518
530
  async function finalizeCodexAccountAsync(profileId, opts = {}) {
519
531
  if (!readPendingProfile(profileId)) {
520
- return { success: false, error: `Codex profile ${profileId} was not prepared.` };
532
+ // Already finalized: the core activates a saved profile without probing.
533
+ const step = finalizeCodexAccountCore(profileId, opts, { authenticated: false, metadata: {} });
534
+ return 'swap' in step ? performCodexAuthSwapAsync(step.swap, {}) : step.result;
521
535
  }
522
- const codexHome = getCodexProfileHome(profileId);
536
+ const codexHome = (0, codexPaths_1.getCodexProfileHome)(profileId);
523
537
  const authenticated = await isCodexProfileAuthenticatedAsync(codexHome);
524
538
  const step = finalizeCodexAccountCore(profileId, opts, {
525
539
  authenticated,
526
540
  metadata: authenticated ? await readCodexAccountMetadataAsync(codexHome) : {},
527
541
  });
528
- return 'swap' in step ? performCodexAuthSwapAsync(step.swap) : step.result;
542
+ return 'swap' in step ? performCodexAuthSwapAsync(step.swap, {}) : step.result;
529
543
  }
544
+ // ── Stash / identity matching ────────────────────────────────────────────
530
545
  function getCodexStashDir() {
531
546
  return path.join((0, accountRegistry_1.getAccountsDir)(), 'codex', 'stash');
532
547
  }
@@ -549,7 +564,7 @@ function stashLiveCodexAuth(liveAuthRaw, liveLegacyRaw) {
549
564
  return null;
550
565
  }
551
566
  }
552
- function findProfileForIdentity(identity) {
567
+ function findProfileForIdentity(identity, options = {}) {
553
568
  const profiles = listCodexAccounts();
554
569
  if (identity?.workspaceId) {
555
570
  const byWorkspace = profiles.find((profile) => profile.metadata?.workspaceId === identity.workspaceId);
@@ -561,67 +576,238 @@ function findProfileForIdentity(identity) {
561
576
  if (byEmail)
562
577
  return byEmail;
563
578
  }
564
- if (!identity?.workspaceId && !identity?.email) {
579
+ if (!identity?.workspaceId && !identity?.email && options.fallbackToActive !== false) {
565
580
  // API-key auth or unparseable tokens carry no identity; assume the live
566
581
  // file belongs to whichever account the registry says is active.
567
582
  return getActiveCodexAccount();
568
583
  }
569
584
  return null;
570
585
  }
571
- // Codex rotates the refresh token whenever it refreshes auth.json, so the
572
- // live file is always the freshest copy of its account. Before replacing it,
573
- // preserve it in the matching profile's backup — or stash it if it belongs to
574
- // no saved account. Best-effort: never throws.
575
- function syncBackLiveCodexAuth(liveAuthRaw, liveLegacyRaw) {
576
- if (!liveAuthRaw && !liveLegacyRaw)
577
- return {};
586
+ // ── Live-state sync ──────────────────────────────────────────────────────
587
+ /**
588
+ * Serializes live-auth mutations across processes. Codex rotates refresh
589
+ * tokens, so two interleaved swaps stashing and restoring auth.json can
590
+ * resurrect a stale token and permanently invalidate the login. Lock ordering:
591
+ * this lock is taken first and the registry lock (inside setActiveSavedAccount
592
+ * / upsertSavedAccountProfile) inside it — never the reverse. Never spawn a
593
+ * child process while holding it.
594
+ */
595
+ function withCodexAuthSwapLock(operation) {
596
+ const lockDir = path.join((0, accountRegistry_1.getAccountsDir)(), 'codex');
597
+ fs.mkdirSync(lockDir, { recursive: true, mode: 0o700 });
598
+ return (0, atomic_1.withFileLockSync)(path.join(lockDir, 'auth-swap.lock'), operation);
599
+ }
600
+ /**
601
+ * Merge duplicate Codex profiles (same workspace, else same email). The oldest
602
+ * profile keeps its id and label, receives the freshest `auth.json` and the
603
+ * quota snapshot, and the active pointer; losers are stashed, never deleted.
604
+ * Returns the removed ids. Caller holds the swap lock.
605
+ */
606
+ function dedupeCodexProfiles() {
607
+ const removed = [];
608
+ const profiles = listCodexAccounts();
609
+ const groups = new Map();
610
+ for (const profile of profiles) {
611
+ const key = profile.metadata?.workspaceId
612
+ ? `ws:${profile.metadata.workspaceId}`
613
+ : (profile.email ?? profile.metadata?.email)
614
+ ? `email:${profile.email ?? profile.metadata?.email}`
615
+ : `id:${profile.id}`;
616
+ groups.set(key, [...(groups.get(key) ?? []), profile]);
617
+ }
618
+ for (const group of groups.values()) {
619
+ if (group.length < 2)
620
+ continue;
621
+ const sorted = [...group].sort((a, b) => Date.parse(a.addedAt) - Date.parse(b.addedAt));
622
+ const keeper = sorted[0];
623
+ const keeperAuthPath = path.join((0, codexPaths_1.getCodexProfileHome)(keeper.id), 'auth.json');
624
+ let freshestRaw = (0, codexPaths_1.readFileOrNull)(keeperAuthPath);
625
+ let freshestAt = (0, codexAuth_1.readLastRefresh)(freshestRaw, keeperAuthPath) ?? -1;
626
+ const activeId = (0, accountRegistry_1.getActiveSavedAccount)('codex')?.id;
627
+ for (const loser of sorted.slice(1)) {
628
+ const loserAuthPath = path.join((0, codexPaths_1.getCodexProfileHome)(loser.id), 'auth.json');
629
+ const loserRaw = (0, codexPaths_1.readFileOrNull)(loserAuthPath);
630
+ const loserAt = (0, codexAuth_1.readLastRefresh)(loserRaw, loserAuthPath) ?? -1;
631
+ if (loserRaw && loserAt > freshestAt) {
632
+ freshestRaw = loserRaw;
633
+ freshestAt = loserAt;
634
+ }
635
+ try {
636
+ if (!(0, quotaSnapshots_1.readQuotaSnapshot)('codex', keeper.id)) {
637
+ const snapshot = (0, quotaSnapshots_1.readQuotaSnapshot)('codex', loser.id);
638
+ if (snapshot)
639
+ (0, quotaSnapshots_1.writeQuotaSnapshot)('codex', keeper.id, snapshot);
640
+ }
641
+ }
642
+ catch {
643
+ /* quota re-key is best-effort */
644
+ }
645
+ try {
646
+ const stashDir = path.join(getCodexStashDir(), `dup-${loser.id}-${new Date().toISOString().replace(/[:.]/g, '-')}`);
647
+ fs.mkdirSync(path.dirname(stashDir), { recursive: true, mode: 0o700 });
648
+ fs.renameSync((0, codexPaths_1.getCodexProfileDir)(loser.id), stashDir);
649
+ }
650
+ catch {
651
+ fs.rmSync((0, codexPaths_1.getCodexProfileDir)(loser.id), { recursive: true, force: true });
652
+ }
653
+ (0, accountRegistry_1.removeSavedAccountProfile)('codex', loser.id);
654
+ removed.push(loser.id);
655
+ if (activeId === loser.id)
656
+ (0, accountRegistry_1.setActiveSavedAccount)('codex', keeper.id, { silent: true });
657
+ }
658
+ if (freshestRaw && freshestRaw !== (0, codexPaths_1.readFileOrNull)(keeperAuthPath)) {
659
+ storeCodexProfileAuth(keeper.id, freshestRaw, null, 'migration');
660
+ }
661
+ const metadata = readCodexAccountMetadataFromFiles((0, codexPaths_1.getCodexProfileHome)(keeper.id)) ?? {};
662
+ (0, accountRegistry_1.upsertSavedAccountProfile)({
663
+ ...keeper,
664
+ email: metadata.email ?? keeper.email,
665
+ metadata: { ...keeper.metadata, ...metadata },
666
+ });
667
+ }
668
+ return removed;
669
+ }
670
+ /**
671
+ * Fold the live `auth.json` into the saved profiles: register an unknown login
672
+ * (label = email), refresh the matching profile's backup when the live file is
673
+ * newer, and re-point the active pointer at it. Caller holds the swap lock.
674
+ * Never throws; problems land in `warnings`.
675
+ */
676
+ function syncCodexLiveStateUnlocked(source = 'live-sync', probes = {}) {
677
+ const report = (0, accountSyncTypes_1.emptyProviderSyncReport)();
578
678
  try {
579
- const identity = readAuthIdentityFromRaw(liveAuthRaw);
580
- const profile = findProfileForIdentity(identity);
581
- if (!profile) {
582
- const stashPath = stashLiveCodexAuth(liveAuthRaw, liveLegacyRaw);
583
- return {
584
- stashPath: stashPath ?? undefined,
585
- warning: stashPath
586
- ? `Live Codex credentials did not match any saved account; stashed at ${stashPath}.`
587
- : 'Live Codex credentials did not match any saved account and could not be stashed.',
679
+ const merged = dedupeCodexProfiles();
680
+ if (merged.length)
681
+ report.merged = merged;
682
+ const systemHome = (0, codexPaths_1.getSystemCodexHome)();
683
+ const liveAuthPath = path.join(systemHome, 'auth.json');
684
+ const liveAuthRaw = (0, codexPaths_1.readFileOrNull)(liveAuthPath);
685
+ const liveLegacyRaw = (0, codexPaths_1.readFileOrNull)(path.join(systemHome, '.credentials.json'));
686
+ if (!liveAuthRaw && !liveLegacyRaw) {
687
+ const mode = (0, codexPaths_1.getCodexCredentialStoreMode)(systemHome);
688
+ if (probes.systemKeyringLoggedIn || mode === 'keyring' || mode === 'auto') {
689
+ report.skipped = 'keyring';
690
+ report.warnings.push(`Codex keeps its login in the OS keyring; sidekick cannot switch it. ${exports.CODEX_KEYRING_FIX_HINT}`);
691
+ }
692
+ else {
693
+ report.skipped = 'logged-out';
694
+ }
695
+ return report;
696
+ }
697
+ const identity = (0, codexAuth_1.readAuthIdentityFromRaw)(liveAuthRaw);
698
+ let profile = findProfileForIdentity(identity, { fallbackToActive: false });
699
+ if (!profile && identity && (identity.email || identity.workspaceId)) {
700
+ const profileId = (0, crypto_1.randomUUID)();
701
+ profile = {
702
+ id: profileId,
703
+ providerId: 'codex',
704
+ label: identity.email ?? identity.workspaceId,
705
+ email: identity.email,
706
+ addedAt: new Date().toISOString(),
707
+ metadata: {
708
+ email: identity.email,
709
+ workspaceId: identity.workspaceId,
710
+ planType: identity.planType,
711
+ authMode: identity.authMode,
712
+ origin: 'live-sync',
713
+ },
588
714
  };
715
+ (0, codexPaths_1.ensureCodexProfileDirs)(profileId);
716
+ copySourceCodexConfig(systemHome, (0, codexPaths_1.getCodexProfileHome)(profileId));
717
+ (0, accountRegistry_1.upsertSavedAccountProfile)(profile);
718
+ report.registered = { id: profileId, email: identity.email };
589
719
  }
590
- const profileHome = getCodexProfileHome(profile.id);
591
- if (liveAuthRaw)
592
- (0, atomic_1.atomicWriteFileSync)(path.join(profileHome, 'auth.json'), liveAuthRaw);
593
- if (liveLegacyRaw)
594
- (0, atomic_1.atomicWriteFileSync)(path.join(profileHome, '.credentials.json'), liveLegacyRaw);
595
- try {
596
- // File-based metadata only: this runs with auth files just written, and
597
- // the CLI-probe fallback must never spawn while the swap lock is held.
598
- const metadata = readCodexAccountMetadataFromFiles(profileHome) ?? {};
599
- (0, accountRegistry_1.upsertSavedAccountProfile)({
600
- ...profile,
601
- email: metadata.email ?? profile.email,
602
- metadata: { ...profile.metadata, ...metadata },
603
- });
720
+ else if (!profile) {
721
+ // API-key auth or an unparseable token: fold into the active profile
722
+ // only when it is itself an api-key login; otherwise stash.
723
+ const active = getActiveCodexAccount();
724
+ if (active && active.metadata?.authMode === 'api-key') {
725
+ profile = active;
726
+ }
727
+ else {
728
+ report.skipped = 'no-identity';
729
+ return report;
730
+ }
604
731
  }
605
- catch {
606
- /* metadata refresh is best-effort */
732
+ // Codex rotates the refresh token in place, so for the same identity the
733
+ // live file is the freshest copy unless the profile was refreshed on its
734
+ // own (keep-alive, isolated launch) and both files say when.
735
+ const profileAuthPath = path.join((0, codexPaths_1.getCodexProfileHome)(profile.id), 'auth.json');
736
+ const storedRaw = (0, codexPaths_1.readFileOrNull)(profileAuthPath);
737
+ const storedRefresh = (0, codexAuth_1.readLastRefresh)(storedRaw);
738
+ const liveRefresh = (0, codexAuth_1.readLastRefresh)(liveAuthRaw);
739
+ const storedIsNewer = storedRefresh !== null && liveRefresh !== null && storedRefresh > liveRefresh;
740
+ const newer = liveAuthRaw !== storedRaw && !storedIsNewer;
741
+ if (newer) {
742
+ try {
743
+ storeCodexProfileAuth(profile.id, liveAuthRaw, liveLegacyRaw, source);
744
+ const metadata = readCodexAccountMetadataFromFiles((0, codexPaths_1.getCodexProfileHome)(profile.id)) ?? {};
745
+ (0, accountRegistry_1.upsertSavedAccountProfile)({
746
+ ...profile,
747
+ email: metadata.email ?? profile.email,
748
+ metadata: { ...profile.metadata, ...metadata },
749
+ });
750
+ report.folded = profile.id;
751
+ }
752
+ catch (err) {
753
+ report.warnings.push(`Could not back up the live Codex credentials: ${err}`);
754
+ }
755
+ }
756
+ const active = (0, accountRegistry_1.getActiveSavedAccount)('codex');
757
+ if (!active || active.id !== profile.id) {
758
+ (0, accountRegistry_1.setActiveSavedAccount)('codex', profile.id, { silent: true });
759
+ report.repointed = profile.id;
607
760
  }
608
- return { syncedProfileId: profile.id };
609
761
  }
610
762
  catch (err) {
611
- return { warning: `Could not back up live Codex credentials: ${err}` };
763
+ report.warnings.push(`Codex account sync failed: ${err}`);
764
+ }
765
+ return report;
766
+ }
767
+ /** {@link syncCodexLiveStateUnlocked} under the swap lock. */
768
+ function syncCodexLiveState(source = 'live-sync') {
769
+ try {
770
+ return withCodexAuthSwapLock(() => syncCodexLiveStateUnlocked(source));
771
+ }
772
+ catch (err) {
773
+ return { warnings: [`Could not acquire the account-switch lock: ${err}`] };
612
774
  }
613
775
  }
614
776
  /**
615
- * Serializes live-auth mutations across processes. Codex rotates refresh
616
- * tokens, so two interleaved swaps stashing and restoring auth.json can
617
- * resurrect a stale token and permanently invalidate the login. Lock ordering:
618
- * this lock is taken first and the registry lock (inside setActiveSavedAccount
619
- * / upsertSavedAccountProfile) inside it — never the reverse.
777
+ * Remove isolated login homes that never authenticated once they are older
778
+ * than `olderThanMs`. Returns the removed profile ids.
620
779
  */
621
- function withCodexAuthSwapLock(operation) {
622
- const lockDir = path.join((0, accountRegistry_1.getAccountsDir)(), 'codex');
623
- fs.mkdirSync(lockDir, { recursive: true, mode: 0o700 });
624
- return (0, atomic_1.withFileLockSync)(path.join(lockDir, 'auth-swap.lock'), operation);
780
+ function cleanupAbandonedCodexLogins(olderThanMs = 6 * 60 * 60 * 1000, now = Date.now()) {
781
+ const removed = [];
782
+ let entries = [];
783
+ try {
784
+ entries = fs.readdirSync((0, codexPaths_1.getCodexProfilesDir)());
785
+ }
786
+ catch {
787
+ return removed;
788
+ }
789
+ const registered = new Set(listCodexAccounts().map((p) => p.id));
790
+ for (const profileId of entries) {
791
+ if (registered.has(profileId))
792
+ continue;
793
+ const pending = readPendingProfile(profileId);
794
+ if (!pending)
795
+ continue;
796
+ if (hasCodexCredentialFiles((0, codexPaths_1.getCodexProfileHome)(profileId)))
797
+ continue;
798
+ const addedAt = Date.parse(pending.addedAt);
799
+ const age = Number.isNaN(addedAt) ? Number.POSITIVE_INFINITY : now - addedAt;
800
+ if (age < olderThanMs)
801
+ continue;
802
+ try {
803
+ fs.rmSync((0, codexPaths_1.getCodexProfileDir)(profileId), { recursive: true, force: true });
804
+ removed.push(profileId);
805
+ }
806
+ catch {
807
+ /* best effort */
808
+ }
809
+ }
810
+ return removed;
625
811
  }
626
812
  /**
627
813
  * CLI probes are resolved before the auth-swap lock is taken — never spawn a
@@ -630,118 +816,170 @@ function withCodexAuthSwapLock(operation) {
630
816
  * absence.
631
817
  */
632
818
  function resolveCodexSwapProbesSync() {
633
- const systemHome = getSystemCodexHome();
819
+ const systemHome = (0, codexPaths_1.getSystemCodexHome)();
634
820
  return {
635
- codexRunning: detectRunningCodexProcess(),
821
+ consumers: (0, processDetection_1.detectRunningAccountConsumersSync)('codex'),
636
822
  systemKeyringLoggedIn: hasCodexCredentialFiles(systemHome)
637
823
  ? false
638
824
  : getCodexLoginStatus(systemHome).loggedIn,
639
825
  };
640
826
  }
641
827
  async function resolveCodexSwapProbesAsync() {
642
- const systemHome = getSystemCodexHome();
828
+ const systemHome = (0, codexPaths_1.getSystemCodexHome)();
643
829
  return {
644
- codexRunning: await detectRunningCodexProcessAsync(),
830
+ consumers: await (0, processDetection_1.detectRunningAccountConsumers)('codex'),
645
831
  systemKeyringLoggedIn: hasCodexCredentialFiles(systemHome)
646
832
  ? false
647
833
  : (await getCodexLoginStatusAsync(systemHome)).loggedIn,
648
834
  };
649
835
  }
650
- function performCodexAuthSwap(target) {
836
+ function performCodexAuthSwap(target, options) {
651
837
  const probes = resolveCodexSwapProbesSync();
652
838
  try {
653
- return withCodexAuthSwapLock(() => performCodexAuthSwapCore(target, probes));
839
+ return withCodexAuthSwapLock(() => performCodexAuthSwapCore(target, options, probes));
654
840
  }
655
841
  catch (err) {
656
842
  // Keep the non-throwing result contract when the lock cannot be acquired.
657
- return { success: false, error: `Could not acquire the account-switch lock: ${err}` };
843
+ return (0, accountSwitch_1.switchFailure)('codex', target.id, `Could not acquire the account-switch lock: ${err}`);
658
844
  }
659
845
  }
660
- async function performCodexAuthSwapAsync(target) {
846
+ async function performCodexAuthSwapAsync(target, options) {
661
847
  const probes = await resolveCodexSwapProbesAsync();
848
+ let result;
662
849
  try {
663
850
  // The sync lock is fine here: probes are pre-resolved, so the critical
664
851
  // section is only fast local-filesystem work.
665
- return withCodexAuthSwapLock(() => performCodexAuthSwapCore(target, probes));
852
+ result = withCodexAuthSwapLock(() => performCodexAuthSwapCore(target, options, probes));
666
853
  }
667
854
  catch (err) {
668
- return { success: false, error: `Could not acquire the account-switch lock: ${err}` };
855
+ return (0, accountSwitch_1.switchFailure)('codex', target.id, `Could not acquire the account-switch lock: ${err}`);
856
+ }
857
+ if (result.success && options.verifyWithCli && !result.alreadyActive) {
858
+ const status = await getCodexLoginStatusAsync((0, codexPaths_1.getSystemCodexHome)());
859
+ result = status.loggedIn
860
+ ? { ...result, verification: 'cli' }
861
+ : (0, accountSwitch_1.finishSwitchResult)({
862
+ ...result,
863
+ warnings: [
864
+ ...result.warnings,
865
+ '`codex login status` reports "Not logged in" after the switch; the stored token may have been revoked. Sign in again if codex prompts you.',
866
+ ],
867
+ });
669
868
  }
869
+ return result;
670
870
  }
671
- function performCodexAuthSwapCore(target, probes) {
672
- const systemHome = getSystemCodexHome();
871
+ function performCodexAuthSwapCore(target, options, probes) {
872
+ const systemHome = (0, codexPaths_1.getSystemCodexHome)();
673
873
  const liveAuthPath = path.join(systemHome, 'auth.json');
674
874
  const liveLegacyPath = path.join(systemHome, '.credentials.json');
675
- const liveAuthRaw = readFileOrNull(liveAuthPath);
676
- const liveLegacyRaw = readFileOrNull(liveLegacyPath);
677
- if (!liveAuthRaw && !liveLegacyRaw && probes.systemKeyringLoggedIn) {
678
- return {
875
+ const targetName = target.label ?? target.email ?? target.id;
876
+ let previousAccountId = (0, accountRegistry_1.getActiveSavedAccount)('codex')?.id ?? null;
877
+ const base = {
878
+ success: true,
879
+ provider: 'codex',
880
+ accountId: target.id,
881
+ previousAccountId,
882
+ verified: false,
883
+ verification: 'none',
884
+ warnings: [],
885
+ hints: [],
886
+ runningConsumers: probes.consumers,
887
+ email: target.email ?? target.metadata?.email,
888
+ };
889
+ const profilesDir = path.resolve((0, codexPaths_1.getCodexProfilesDir)());
890
+ const resolvedHome = path.resolve(systemHome);
891
+ if (resolvedHome === profilesDir || resolvedHome.startsWith(profilesDir + path.sep)) {
892
+ return (0, accountSwitch_1.finishSwitchResult)({
893
+ ...base,
679
894
  success: false,
680
- error: 'Codex stores credentials in the OS keyring; file-based account switching is not supported. Set `cli_auth_credentials_store = "file"` in ~/.codex/config.toml and run `codex login` again.',
681
- };
895
+ error: `CODEX_HOME points at a sidekick profile home (${systemHome}); unset it before switching accounts.`,
896
+ });
682
897
  }
683
- const profileHome = getCodexProfileHome(target.id);
898
+ const liveBefore = (0, codexPaths_1.readFileOrNull)(liveAuthPath);
899
+ const liveLegacyBefore = (0, codexPaths_1.readFileOrNull)(liveLegacyPath);
900
+ if (!liveBefore && !liveLegacyBefore && probes.systemKeyringLoggedIn) {
901
+ return (0, accountSwitch_1.finishSwitchResult)({
902
+ ...base,
903
+ success: false,
904
+ error: `Codex stores credentials in the OS keyring; file-based account switching is not supported. ${exports.CODEX_KEYRING_FIX_HINT}`,
905
+ });
906
+ }
907
+ // Phase 1: fold the live (freshest, rotated) credentials back into their
908
+ // profile, registering the login if sidekick has never seen it.
909
+ const sync = syncCodexLiveStateUnlocked('switch', {
910
+ systemKeyringLoggedIn: probes.systemKeyringLoggedIn,
911
+ });
912
+ base.warnings.push(...sync.warnings);
913
+ // Only now is the "previous" account known: the sync may have re-pointed or
914
+ // registered the login that was really live, and undo must return to it.
915
+ previousAccountId = (0, accountRegistry_1.getActiveSavedAccount)('codex')?.id ?? null;
916
+ base.previousAccountId = previousAccountId;
917
+ if (sync.skipped === 'no-identity' && liveBefore) {
918
+ const stashPath = stashLiveCodexAuth(liveBefore, liveLegacyBefore);
919
+ base.warnings.push(stashPath
920
+ ? `Live Codex credentials did not match any saved account; stashed at ${stashPath}.`
921
+ : 'Live Codex credentials did not match any saved account and could not be stashed.');
922
+ }
923
+ // Re-read after the sync: the target may have received the live file.
924
+ const liveAuthRaw = (0, codexPaths_1.readFileOrNull)(liveAuthPath);
925
+ const liveLegacyRaw = (0, codexPaths_1.readFileOrNull)(liveLegacyPath);
926
+ const profileHome = (0, codexPaths_1.getCodexProfileHome)(target.id);
684
927
  const targetAuthPath = path.join(profileHome, 'auth.json');
685
- const targetAuthRaw = readFileOrNull(targetAuthPath);
686
- const targetLegacyRaw = readFileOrNull(path.join(profileHome, '.credentials.json'));
687
- const targetName = target.label ?? target.email ?? target.id;
928
+ const targetAuthRaw = (0, codexPaths_1.readFileOrNull)(targetAuthPath);
929
+ const targetLegacyRaw = (0, codexPaths_1.readFileOrNull)(path.join(profileHome, '.credentials.json'));
688
930
  if (!targetAuthRaw && !targetLegacyRaw) {
689
- return {
931
+ return (0, accountSwitch_1.finishSwitchResult)({
932
+ ...base,
690
933
  success: false,
691
- error: `No stored credentials for "${targetName}". Remove and re-add this account.`,
692
- };
934
+ needsLogin: true,
935
+ error: `No stored credentials for "${targetName}". Sign in again to this account.`,
936
+ });
693
937
  }
694
- if (targetAuthRaw && !parseAuthJson(targetAuthRaw)) {
695
- return {
938
+ if (targetAuthRaw && !(0, codexAuth_1.parseAuthJson)(targetAuthRaw)) {
939
+ return (0, accountSwitch_1.finishSwitchResult)({
940
+ ...base,
696
941
  success: false,
697
- error: `Stored credentials for "${targetName}" are corrupted. Remove and re-add this account.`,
698
- };
699
- }
700
- const warnings = [];
701
- if (probes.codexRunning) {
702
- warnings.push('A codex process appears to be running; restart codex sessions so they pick up the switched account.');
942
+ needsLogin: true,
943
+ error: `Stored credentials for "${targetName}" are corrupted. Sign in again to this account.`,
944
+ });
703
945
  }
704
946
  // If the live file already belongs to the target account it is the freshest
705
947
  // copy (rotated refresh token included) — never replace it with a staler
706
- // backup, which would permanently invalidate the login. Just refresh the
707
- // backup and the registry pointer.
708
- const liveIdentity = readAuthIdentityFromRaw(liveAuthRaw);
709
- const targetIdentity = readAuthIdentityFromRaw(targetAuthRaw);
710
- const targetWorkspaceId = target.metadata?.workspaceId ?? targetIdentity?.workspaceId;
711
- const targetEmail = target.email ?? target.metadata?.email ?? targetIdentity?.email;
712
- const liveMatchesTarget = Boolean((liveIdentity?.workspaceId &&
713
- targetWorkspaceId &&
714
- liveIdentity.workspaceId === targetWorkspaceId) ||
715
- (liveIdentity?.email && targetEmail && liveIdentity.email === targetEmail) ||
948
+ // backup, which would permanently invalidate the login.
949
+ const liveIdentity = (0, codexAuth_1.readAuthIdentityFromRaw)(liveAuthRaw);
950
+ const liveMatchesTarget = Boolean((0, codexAuth_1.identitiesMatch)(liveIdentity, {
951
+ ...profileIdentity(target),
952
+ ...((0, codexAuth_1.readAuthIdentityFromRaw)(targetAuthRaw) ?? {}),
953
+ }) ||
716
954
  (liveAuthRaw !== null && liveAuthRaw === targetAuthRaw) ||
717
955
  (!liveAuthRaw && !targetAuthRaw && liveLegacyRaw !== null && liveLegacyRaw === targetLegacyRaw));
718
956
  if (liveMatchesTarget) {
719
- try {
720
- if (liveAuthRaw)
721
- (0, atomic_1.atomicWriteFileSync)(targetAuthPath, liveAuthRaw);
722
- if (liveLegacyRaw)
723
- (0, atomic_1.atomicWriteFileSync)(path.join(profileHome, '.credentials.json'), liveLegacyRaw);
724
- // File-based metadata only — see the swap-lock note above.
725
- const metadata = readCodexAccountMetadataFromFiles(profileHome) ?? {};
726
- (0, accountRegistry_1.upsertSavedAccountProfile)({
727
- ...target,
728
- email: metadata.email ?? target.email,
729
- metadata: { ...target.metadata, ...metadata },
730
- });
731
- }
732
- catch {
733
- /* backup refresh is best-effort */
734
- }
735
- (0, accountRegistry_1.setActiveSavedAccount)('codex', target.id);
736
- return { success: true, warning: warnings.length ? warnings.join(' ') : undefined };
957
+ if (previousAccountId !== target.id)
958
+ (0, accountRegistry_1.setActiveSavedAccount)('codex', target.id);
959
+ return (0, accountSwitch_1.finishSwitchResult)({
960
+ ...base,
961
+ alreadyActive: true,
962
+ verified: true,
963
+ verification: 'store',
964
+ health: (0, accountHealth_1.getAccountHealth)('codex', target.id),
965
+ hints: [`${targetName} is already the active Codex account.`],
966
+ });
967
+ }
968
+ // Preflight: warn about stale backups; refuse ones known to be dead.
969
+ const health = (0, accountHealth_1.getAccountHealth)('codex', target.id, { probe: 'store' });
970
+ base.health = health;
971
+ if ((health.state === 'expired' || health.state === 'missing') && !options.force) {
972
+ return (0, accountSwitch_1.finishSwitchResult)({
973
+ ...base,
974
+ success: false,
975
+ needsLogin: true,
976
+ error: `Stored credentials for "${targetName}" have expired; sign in again.`,
977
+ });
737
978
  }
738
- const targetLastRefresh = readLastRefresh(targetAuthRaw, targetAuthPath);
739
- if (targetLastRefresh !== null && Date.now() - targetLastRefresh > STALE_AUTH_THRESHOLD_MS) {
740
- warnings.push(`Stored credentials for "${targetName}" have not been refreshed in over 8 days; codex may ask you to log in again.`);
979
+ const targetLastRefresh = (0, codexAuth_1.readLastRefresh)(targetAuthRaw, targetAuthPath);
980
+ if (targetLastRefresh !== null && Date.now() - targetLastRefresh > codexAuth_1.STALE_AUTH_THRESHOLD_MS) {
981
+ base.warnings.push(`Stored credentials for "${targetName}" have not been refreshed in over 8 days; codex may ask you to log in again.`);
741
982
  }
742
- const syncBack = syncBackLiveCodexAuth(liveAuthRaw, liveLegacyRaw);
743
- if (syncBack.warning)
744
- warnings.push(syncBack.warning);
745
983
  const restoreLiveFiles = () => {
746
984
  try {
747
985
  if (liveAuthRaw)
@@ -756,7 +994,14 @@ function performCodexAuthSwapCore(target, probes) {
756
994
  catch {
757
995
  /* rollback is best-effort */
758
996
  }
997
+ try {
998
+ (0, accountRegistry_1.setActiveSavedAccount)('codex', previousAccountId, { silent: true });
999
+ }
1000
+ catch {
1001
+ /* rollback is best-effort */
1002
+ }
759
1003
  };
1004
+ // Install.
760
1005
  try {
761
1006
  if (targetAuthRaw) {
762
1007
  (0, atomic_1.atomicWriteFileSync)(liveAuthPath, targetAuthRaw);
@@ -773,32 +1018,75 @@ function performCodexAuthSwapCore(target, probes) {
773
1018
  }
774
1019
  catch (err) {
775
1020
  restoreLiveFiles();
776
- return { success: false, error: `Failed to write Codex credentials: ${err}` };
1021
+ return (0, accountSwitch_1.finishSwitchResult)({
1022
+ ...base,
1023
+ success: false,
1024
+ error: `Failed to write Codex credentials: ${err}`,
1025
+ });
1026
+ }
1027
+ // Verify by re-reading the live file.
1028
+ const installed = (0, codexPaths_1.readFileOrNull)(targetAuthRaw ? liveAuthPath : liveLegacyPath);
1029
+ const expected = targetAuthRaw ?? targetLegacyRaw;
1030
+ const installedIdentity = targetAuthRaw ? (0, codexAuth_1.readAuthIdentityFromRaw)(installed) : null;
1031
+ const verified = installed === expected &&
1032
+ (!targetAuthRaw ||
1033
+ (0, codexAuth_1.identitiesMatch)(installedIdentity, {
1034
+ ...profileIdentity(target),
1035
+ ...((0, codexAuth_1.readAuthIdentityFromRaw)(targetAuthRaw) ?? {}),
1036
+ }) ||
1037
+ installedIdentity?.authMode === 'api-key');
1038
+ if (!verified) {
1039
+ restoreLiveFiles();
1040
+ return (0, accountSwitch_1.finishSwitchResult)({
1041
+ ...base,
1042
+ success: false,
1043
+ verification: 'failed',
1044
+ error: `The live Codex credential file did not reflect the switch to "${targetName}"; the previous login was restored.`,
1045
+ });
777
1046
  }
1047
+ // Pointer last.
778
1048
  try {
779
1049
  (0, accountRegistry_1.setActiveSavedAccount)('codex', target.id);
780
1050
  }
781
1051
  catch (err) {
782
1052
  restoreLiveFiles();
783
- return { success: false, error: `Failed to update account registry: ${err}` };
1053
+ return (0, accountSwitch_1.finishSwitchResult)({
1054
+ ...base,
1055
+ success: false,
1056
+ error: `Failed to update account registry: ${err}`,
1057
+ });
784
1058
  }
785
- return { success: true, warning: warnings.length ? warnings.join(' ') : undefined };
1059
+ let undoToken;
1060
+ try {
1061
+ undoToken = (0, accountSwitch_1.writeLastSwitch)('codex', previousAccountId, target.id).token;
1062
+ }
1063
+ catch {
1064
+ base.warnings.push('The switch succeeded but could not be recorded for undo.');
1065
+ }
1066
+ return (0, accountSwitch_1.finishSwitchResult)({
1067
+ ...base,
1068
+ verified: true,
1069
+ verification: 'store',
1070
+ undoToken,
1071
+ hints: [`New codex sessions use ${target.email ?? targetName}.`],
1072
+ });
786
1073
  }
787
- function switchToCodexAccount(profileId) {
1074
+ function switchToCodexAccount(profileId, options = {}) {
788
1075
  const target = listCodexAccounts().find((account) => account.id === profileId);
789
1076
  if (!target) {
790
- return { success: false, error: `Codex account ${profileId} not found.` };
1077
+ return (0, accountSwitch_1.switchFailure)('codex', profileId, `Codex account ${profileId} not found.`);
791
1078
  }
792
- return performCodexAuthSwap(target);
1079
+ return performCodexAuthSwap(target, options);
793
1080
  }
794
1081
  /** {@link switchToCodexAccount} without blocking the event loop on CLI probes. */
795
- async function switchToCodexAccountAsync(profileId) {
1082
+ async function switchToCodexAccountAsync(profileId, options = {}) {
796
1083
  const target = listCodexAccounts().find((account) => account.id === profileId);
797
1084
  if (!target) {
798
- return { success: false, error: `Codex account ${profileId} not found.` };
1085
+ return (0, accountSwitch_1.switchFailure)('codex', profileId, `Codex account ${profileId} not found.`);
799
1086
  }
800
- return performCodexAuthSwapAsync(target);
1087
+ return performCodexAuthSwapAsync(target, options);
801
1088
  }
1089
+ // ── One-time migration ───────────────────────────────────────────────────
802
1090
  /**
803
1091
  * The reconcile flow consults the keyring probe only when the migration has
804
1092
  * not run yet and the system home has no auth.json; skip the spawn otherwise.
@@ -807,7 +1095,7 @@ function needsReconcileKeyringProbe() {
807
1095
  const markerPath = path.join((0, accountRegistry_1.getAccountsDir)(), 'codex', '.live-auth-migrated-v1');
808
1096
  if (fs.existsSync(markerPath))
809
1097
  return false;
810
- return !fs.existsSync(path.join(getSystemCodexHome(), 'auth.json'));
1098
+ return !fs.existsSync(path.join((0, codexPaths_1.getSystemCodexHome)(), 'auth.json'));
811
1099
  }
812
1100
  // One-time migration for installs created when profile homes doubled as live
813
1101
  // CODEX_HOMEs: the active profile's auth.json may hold a fresher rotated
@@ -815,7 +1103,7 @@ function needsReconcileKeyringProbe() {
815
1103
  function reconcileCodexAuthState() {
816
1104
  try {
817
1105
  const systemKeyringLoggedIn = needsReconcileKeyringProbe()
818
- ? getCodexLoginStatus(getSystemCodexHome()).loggedIn
1106
+ ? getCodexLoginStatus((0, codexPaths_1.getSystemCodexHome)()).loggedIn
819
1107
  : false;
820
1108
  withCodexAuthSwapLock(() => reconcileCodexAuthStateCore({ systemKeyringLoggedIn }));
821
1109
  }
@@ -827,7 +1115,7 @@ function reconcileCodexAuthState() {
827
1115
  async function reconcileCodexAuthStateAsync() {
828
1116
  try {
829
1117
  const systemKeyringLoggedIn = needsReconcileKeyringProbe()
830
- ? (await getCodexLoginStatusAsync(getSystemCodexHome())).loggedIn
1118
+ ? (await getCodexLoginStatusAsync((0, codexPaths_1.getSystemCodexHome)())).loggedIn
831
1119
  : false;
832
1120
  withCodexAuthSwapLock(() => reconcileCodexAuthStateCore({ systemKeyringLoggedIn }));
833
1121
  }
@@ -846,16 +1134,16 @@ function reconcileCodexAuthStateCore(probes) {
846
1134
  writeMarker();
847
1135
  return;
848
1136
  }
849
- const profileHome = getCodexProfileHome(active.id);
1137
+ const profileHome = (0, codexPaths_1.getCodexProfileHome)(active.id);
850
1138
  const profileAuthPath = path.join(profileHome, 'auth.json');
851
- const profileAuthRaw = readFileOrNull(profileAuthPath);
1139
+ const profileAuthRaw = (0, codexPaths_1.readFileOrNull)(profileAuthPath);
852
1140
  if (!profileAuthRaw) {
853
1141
  writeMarker();
854
1142
  return;
855
1143
  }
856
- const systemHome = getSystemCodexHome();
1144
+ const systemHome = (0, codexPaths_1.getSystemCodexHome)();
857
1145
  const liveAuthPath = path.join(systemHome, 'auth.json');
858
- const liveAuthRaw = readFileOrNull(liveAuthPath);
1146
+ const liveAuthRaw = (0, codexPaths_1.readFileOrNull)(liveAuthPath);
859
1147
  if (!liveAuthRaw) {
860
1148
  // No live credentials (account was added via isolated login and never
861
1149
  // promoted). Promote the active profile's copy unless codex is logged
@@ -867,15 +1155,12 @@ function reconcileCodexAuthStateCore(probes) {
867
1155
  writeMarker();
868
1156
  return;
869
1157
  }
870
- const liveIdentity = readAuthIdentityFromRaw(liveAuthRaw);
871
- const profileIdentity = readAuthIdentityFromRaw(profileAuthRaw);
872
- const sameIdentity = Boolean((liveIdentity?.workspaceId &&
873
- profileIdentity?.workspaceId &&
874
- liveIdentity.workspaceId === profileIdentity.workspaceId) ||
875
- (liveIdentity?.email && liveIdentity.email === profileIdentity?.email));
1158
+ const liveIdentity = (0, codexAuth_1.readAuthIdentityFromRaw)(liveAuthRaw);
1159
+ const profileIdentityValue = (0, codexAuth_1.readAuthIdentityFromRaw)(profileAuthRaw);
1160
+ const sameIdentity = (0, codexAuth_1.identitiesMatch)(liveIdentity, profileIdentityValue);
876
1161
  if (sameIdentity) {
877
- const liveRefresh = readLastRefresh(liveAuthRaw, liveAuthPath);
878
- const profileRefresh = readLastRefresh(profileAuthRaw, profileAuthPath);
1162
+ const liveRefresh = (0, codexAuth_1.readLastRefresh)(liveAuthRaw, liveAuthPath);
1163
+ const profileRefresh = (0, codexAuth_1.readLastRefresh)(profileAuthRaw, profileAuthPath);
879
1164
  if (profileRefresh !== null && (liveRefresh === null || profileRefresh > liveRefresh)) {
880
1165
  // The profile copy was the live home under the old model and holds
881
1166
  // the valid rotated refresh token — promote it.
@@ -883,7 +1168,7 @@ function reconcileCodexAuthStateCore(probes) {
883
1168
  (0, atomic_1.atomicWriteFileSync)(liveAuthPath, profileAuthRaw);
884
1169
  }
885
1170
  else {
886
- (0, atomic_1.atomicWriteFileSync)(profileAuthPath, liveAuthRaw);
1171
+ storeCodexProfileAuth(active.id, liveAuthRaw, null, 'migration');
887
1172
  }
888
1173
  }
889
1174
  else {
@@ -892,7 +1177,7 @@ function reconcileCodexAuthStateCore(probes) {
892
1177
  // one, and refresh its backup.
893
1178
  const matching = findProfileForIdentity(liveIdentity);
894
1179
  if (matching && matching.id !== active.id) {
895
- (0, atomic_1.atomicWriteFileSync)(path.join(getCodexProfileHome(matching.id), 'auth.json'), liveAuthRaw);
1180
+ storeCodexProfileAuth(matching.id, liveAuthRaw, null, 'migration');
896
1181
  (0, accountRegistry_1.setActiveSavedAccount)('codex', matching.id);
897
1182
  }
898
1183
  }
@@ -907,6 +1192,6 @@ function removeCodexAccount(profileId) {
907
1192
  if (!removed) {
908
1193
  return { success: false, error: `Codex account ${profileId} not found.` };
909
1194
  }
910
- fs.rmSync(getCodexProfileDir(profileId), { recursive: true, force: true });
1195
+ fs.rmSync((0, codexPaths_1.getCodexProfileDir)(profileId), { recursive: true, force: true });
911
1196
  return { success: true };
912
1197
  }