sidekick-shared 0.27.4 → 0.27.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 (64) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/README.md +92 -45
  3. package/dist/accountHealth.d.ts +14 -0
  4. package/dist/accountHealth.js +61 -14
  5. package/dist/accountLaunch.d.ts +6 -0
  6. package/dist/accountLaunch.js +61 -4
  7. package/dist/accountLease.d.ts +5 -0
  8. package/dist/accountLease.js +98 -0
  9. package/dist/accountManager.d.ts +7 -0
  10. package/dist/accountManager.js +226 -89
  11. package/dist/accountSync.d.ts +1 -5
  12. package/dist/accountSync.js +22 -6
  13. package/dist/accountSyncTypes.d.ts +9 -0
  14. package/dist/accounts.d.ts +75 -18
  15. package/dist/accounts.js +712 -189
  16. package/dist/claudeCredentialDoctor.d.ts +50 -0
  17. package/dist/claudeCredentialDoctor.js +277 -0
  18. package/dist/claudeCredentialLedger.d.ts +15 -0
  19. package/dist/claudeCredentialLedger.js +130 -0
  20. package/dist/claudeCredentials.d.ts +20 -3
  21. package/dist/claudeCredentials.js +50 -3
  22. package/dist/claudeLiveGuard.d.ts +33 -0
  23. package/dist/claudeLiveGuard.js +46 -0
  24. package/dist/claudeProfiles.d.ts +14 -0
  25. package/dist/claudeProfiles.js +22 -0
  26. package/dist/claudeRefreshLock.d.ts +15 -0
  27. package/dist/claudeRefreshLock.js +145 -0
  28. package/dist/codexAuth.d.ts +4 -0
  29. package/dist/codexAuth.js +18 -0
  30. package/dist/codexCredentialDoctor.d.ts +33 -0
  31. package/dist/codexCredentialDoctor.js +302 -0
  32. package/dist/codexCredentialStore.d.ts +29 -0
  33. package/dist/codexCredentialStore.js +213 -0
  34. package/dist/codexKeepAlive.d.ts +2 -0
  35. package/dist/codexKeepAlive.js +116 -0
  36. package/dist/codexPaths.d.ts +8 -2
  37. package/dist/codexPaths.js +51 -7
  38. package/dist/codexProfiles.d.ts +37 -31
  39. package/dist/codexProfiles.js +842 -671
  40. package/dist/credentialIO.d.ts +67 -7
  41. package/dist/credentialIO.js +211 -47
  42. package/dist/ensureDefaultAccounts.js +1 -8
  43. package/dist/index.d.ts +17 -4
  44. package/dist/index.js +36 -11
  45. package/dist/keepAlive.d.ts +2 -1
  46. package/dist/keepAlive.js +116 -29
  47. package/dist/liveCredentialGuard.d.ts +28 -0
  48. package/dist/liveCredentialGuard.js +36 -0
  49. package/dist/node.d.ts +3 -0
  50. package/dist/node.js +4 -2
  51. package/dist/processDetection.d.ts +10 -3
  52. package/dist/processDetection.js +44 -11
  53. package/dist/promptHistory.d.ts +66 -0
  54. package/dist/promptHistory.js +37 -18
  55. package/dist/schemas/accountManager.d.ts +22 -0
  56. package/dist/schemas/accountManager.js +4 -0
  57. package/dist/schemas/assistantTurn.d.ts +2 -2
  58. package/dist/sessionPromptHistory.d.ts +120 -0
  59. package/dist/sessionPromptHistory.js +278 -0
  60. package/dist/sessionPromptSignals.d.ts +56 -0
  61. package/dist/sessionPromptSignals.js +355 -0
  62. package/dist/terminalSync.d.ts +12 -2
  63. package/dist/terminalSync.js +27 -2
  64. package/package.json +1 -1
@@ -35,6 +35,9 @@ var __importStar = (this && this.__importStar) || (function () {
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.CODEX_KEYRING_FIX_HINT = exports.getCodexCredentialStoreMode = exports.getSystemCodexHome = exports.getCodexProfileHome = exports.getCodexProfilesDir = void 0;
37
37
  exports.getCodexMonitoringHomes = getCodexMonitoringHomes;
38
+ exports.readStoredCodexAccount = readStoredCodexAccount;
39
+ exports.isLiveCodexProfile = isLiveCodexProfile;
40
+ exports.storeCodexProfileAuthUnlocked = storeCodexProfileAuthUnlocked;
38
41
  exports.storeCodexProfileAuth = storeCodexProfileAuth;
39
42
  exports.parseCodexLoginStatusOutput = parseCodexLoginStatusOutput;
40
43
  exports.getCodexLoginStatus = getCodexLoginStatus;
@@ -50,18 +53,26 @@ exports.resolveActiveCodexAccount = resolveActiveCodexAccount;
50
53
  exports.getCodexExecutionEnv = getCodexExecutionEnv;
51
54
  exports.prepareCodexAccount = prepareCodexAccount;
52
55
  exports.prepareCodexAccountAsync = prepareCodexAccountAsync;
56
+ exports.discardCodexLogin = discardCodexLogin;
53
57
  exports.finalizeCodexAccount = finalizeCodexAccount;
54
58
  exports.finalizeCodexAccountAsync = finalizeCodexAccountAsync;
55
59
  exports.withCodexAuthSwapLock = withCodexAuthSwapLock;
56
60
  exports.dedupeCodexProfiles = dedupeCodexProfiles;
61
+ exports.hasPendingCodexRepair = hasPendingCodexRepair;
57
62
  exports.syncCodexLiveStateUnlocked = syncCodexLiveStateUnlocked;
58
63
  exports.syncCodexLiveState = syncCodexLiveState;
64
+ exports.syncCodexLiveStateAsync = syncCodexLiveStateAsync;
59
65
  exports.cleanupAbandonedCodexLogins = cleanupAbandonedCodexLogins;
60
66
  exports.switchToCodexAccount = switchToCodexAccount;
61
67
  exports.switchToCodexAccountAsync = switchToCodexAccountAsync;
62
68
  exports.reconcileCodexAuthState = reconcileCodexAuthState;
63
69
  exports.reconcileCodexAuthStateAsync = reconcileCodexAuthStateAsync;
70
+ exports.getCodexKeepAliveBlocker = getCodexKeepAliveBlocker;
71
+ exports.beginCodexKeepAlive = beginCodexKeepAlive;
72
+ exports.finishCodexKeepAlive = finishCodexKeepAlive;
73
+ exports.restoreCodexProfileFromBackup = restoreCodexProfileFromBackup;
64
74
  exports.removeCodexAccount = removeCodexAccount;
75
+ exports.cleanupAbandonedCodexLoginsAsync = cleanupAbandonedCodexLoginsAsync;
65
76
  const fs = __importStar(require("fs"));
66
77
  const path = __importStar(require("path"));
67
78
  const child_process_1 = require("child_process");
@@ -78,6 +89,9 @@ const accountSwitch_1 = require("./accountSwitch");
78
89
  const accountSyncTypes_1 = require("./accountSyncTypes");
79
90
  const processDetection_1 = require("./processDetection");
80
91
  const quotaSnapshots_1 = require("./quotaSnapshots");
92
+ const accountLease_1 = require("./accountLease");
93
+ const liveCredentialGuard_1 = require("./liveCredentialGuard");
94
+ const codexCredentialStore_1 = require("./codexCredentialStore");
81
95
  var codexPaths_2 = require("./codexPaths");
82
96
  Object.defineProperty(exports, "getCodexProfilesDir", { enumerable: true, get: function () { return codexPaths_2.getCodexProfilesDir; } });
83
97
  Object.defineProperty(exports, "getCodexProfileHome", { enumerable: true, get: function () { return codexPaths_2.getCodexProfileHome; } });
@@ -113,22 +127,16 @@ function getCodexMonitoringHomes() {
113
127
  }
114
128
  function readPendingProfile(profileId) {
115
129
  try {
116
- return JSON.parse(fs.readFileSync((0, codexPaths_1.getCodexProfileStatePath)(profileId), 'utf8'));
130
+ return JSON.parse(fs.readFileSync((0, codexPaths_1.getCodexLoginStatePath)(profileId), 'utf8'));
117
131
  }
118
132
  catch {
119
133
  return null;
120
134
  }
121
135
  }
122
136
  function writePendingProfile(profileId, pending) {
123
- (0, codexPaths_1.ensureCodexProfileDirs)(profileId);
124
- (0, atomic_1.atomicWriteJsonSync)((0, codexPaths_1.getCodexProfileStatePath)(profileId), pending);
125
- }
126
- function copyIfExists(source, destination) {
127
- if (!fs.existsSync(source))
128
- return false;
129
- fs.mkdirSync(path.dirname(destination), { recursive: true, mode: 0o700 });
130
- fs.copyFileSync(source, destination);
131
- return true;
137
+ const dir = (0, codexPaths_1.getCodexLoginDir)(profileId);
138
+ fs.mkdirSync(path.join(dir, 'codex-home'), { recursive: true, mode: 0o700 });
139
+ (0, atomic_1.atomicWriteJsonSync)(path.join(dir, 'profile.json'), pending);
132
140
  }
133
141
  /**
134
142
  * Copy the live `config.toml` into a profile home, forcing file-based
@@ -139,24 +147,143 @@ function copySourceCodexConfig(sourceHome, targetHome) {
139
147
  const source = (0, codexPaths_1.readFileOrNull)(path.join(sourceHome, 'config.toml')) ?? '';
140
148
  (0, atomic_1.atomicWriteFileSync)(path.join(targetHome, 'config.toml'), (0, codexPaths_1.forceFileCredentialStore)(source));
141
149
  }
142
- function importCurrentCodexAuth(sourceHome, targetHome) {
143
- const authCopied = copyIfExists(path.join(sourceHome, 'auth.json'), path.join(targetHome, 'auth.json'));
144
- const legacyCredsCopied = copyIfExists(path.join(sourceHome, '.credentials.json'), path.join(targetHome, '.credentials.json'));
145
- return authCopied || legacyCredsCopied;
150
+ /** Read the freshest saved login without creating a CLI-usable copy. */
151
+ function readStoredCodexAccount(profileId) {
152
+ const home = (0, codexCredentialStore_1.readCodexCredentialStore)((0, codexPaths_1.getCodexProfileHome)(profileId));
153
+ const backup = (0, codexCredentialStore_1.readCodexBackup)(profileId);
154
+ if (home.status === 'ok' && backup.status === 'ok') {
155
+ return (0, codexCredentialStore_1.codexLoginOnly)((0, codexAuth_1.compareCodexAuthFreshness)(backup.snapshot.authRaw, home.snapshot.authRaw) > 0
156
+ ? backup.snapshot
157
+ : home.snapshot);
158
+ }
159
+ return home.status === 'ok'
160
+ ? (0, codexCredentialStore_1.codexLoginOnly)(home.snapshot)
161
+ : backup.status === 'ok'
162
+ ? backup.snapshot
163
+ : null;
164
+ }
165
+ function profileMatchesSnapshot(profile, snapshot) {
166
+ const identity = (0, codexAuth_1.readAuthIdentityFromRaw)(snapshot.authRaw);
167
+ if ((0, codexAuth_1.identitiesMatch)(identity, profileIdentity(profile)))
168
+ return true;
169
+ // API keys have no account claims. Match their complete credential bytes, never the active pointer.
170
+ if (identity?.authMode === 'api-key' ||
171
+ (!snapshot.authRaw && (0, codexCredentialStore_1.isLegacyCodexLogin)(snapshot.legacyRaw))) {
172
+ const saved = readStoredCodexAccount(profile.id);
173
+ return (saved !== null &&
174
+ (0, codexCredentialStore_1.codexSnapshotFingerprint)((0, codexCredentialStore_1.codexLoginOnly)(snapshot)) === (0, codexCredentialStore_1.codexSnapshotFingerprint)(saved));
175
+ }
176
+ return false;
177
+ }
178
+ function isLiveCodexProfile(profile) {
179
+ const live = (0, codexCredentialStore_1.readCodexCredentialStore)();
180
+ return live.status === 'ok' && profileMatchesSnapshot(profile, live.snapshot);
181
+ }
182
+ /** Caller holds the swap lock. Live-account credentials only enter private storage. */
183
+ function storeCodexProfileAuthUnlocked(profileId, authRaw, legacyRaw, source) {
184
+ const candidate = (0, codexCredentialStore_1.codexLoginOnly)({ authRaw, legacyRaw });
185
+ const profile = listCodexAccounts().find((p) => p.id === profileId);
186
+ const identity = (0, codexAuth_1.readAuthIdentityFromRaw)(authRaw);
187
+ if (profile &&
188
+ identity?.authMode === 'chatgpt' &&
189
+ (identity.email || identity.workspaceId) &&
190
+ (profile.email || profile.metadata?.workspaceId) &&
191
+ !(0, codexAuth_1.identitiesMatch)(identity, profileIdentity(profile))) {
192
+ throw new Error('The Codex credential belongs to a different saved account.');
193
+ }
194
+ const old = (0, codexCredentialStore_1.readCodexBackup)(profileId);
195
+ const chosen = old.status === 'ok' && (0, codexAuth_1.compareCodexAuthFreshness)(old.snapshot.authRaw, authRaw) > 0
196
+ ? old.snapshot
197
+ : candidate;
198
+ (0, codexCredentialStore_1.writeCodexBackup)(profileId, chosen);
199
+ const live = (0, codexCredentialStore_1.readCodexCredentialStore)();
200
+ const sameLink = live.status === 'ok' &&
201
+ (0, codexAuth_1.codexRefreshFingerprint)(chosen.authRaw) !== null &&
202
+ (0, codexAuth_1.codexRefreshFingerprint)(chosen.authRaw) === (0, codexAuth_1.codexRefreshFingerprint)(live.snapshot.authRaw);
203
+ const isLive = live.status === 'ok' &&
204
+ (profile
205
+ ? profileMatchesSnapshot(profile, live.snapshot)
206
+ : (0, codexAuth_1.identitiesMatch)((0, codexAuth_1.readAuthIdentityFromRaw)(chosen.authRaw), (0, codexAuth_1.readAuthIdentityFromRaw)(live.snapshot.authRaw)));
207
+ if (!(0, codexCredentialStore_1.isSidekickCodexHome)((0, codexPaths_1.getSystemCodexHome)()) &&
208
+ live.status !== 'error' &&
209
+ live.status !== 'corrupt' &&
210
+ !isLive &&
211
+ !sameLink) {
212
+ (0, codexCredentialStore_1.writeInactiveCodexCredentialStore)((0, codexPaths_1.getCodexProfileHome)(profileId), chosen);
213
+ }
214
+ if (chosen.authRaw)
215
+ (0, accountHealth_1.writeCodexHealthSidecar)(profileId, chosen.authRaw, source);
146
216
  }
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
217
  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);
218
+ withCodexAuthSwapLock(() => storeCodexProfileAuthUnlocked(profileId, authRaw, legacyRaw, source));
219
+ }
220
+ function consumerBlocker(scan) {
221
+ if (!scan?.ok)
222
+ return 'Could not verify that Codex consumers are stopped; retry after closing Codex.';
223
+ if (scan.consumers.length)
224
+ return 'Close running Codex CLI, app, and IDE sessions before changing credentials. --force cannot bypass this check.';
225
+ return null;
226
+ }
227
+ /** The sole installer of live Codex credentials, including rollback and repair. */
228
+ function installIntoLiveCodexStore(candidate, accountId, intent, context) {
229
+ if ((0, accountLease_1.isAccountLeaseHeld)('codex', accountId))
230
+ return { ok: false, reason: 'Keep-alive holds this account; retry when it finishes.' };
231
+ const blocker = consumerBlocker(context.scan);
232
+ if (blocker)
233
+ return { ok: false, reason: blocker };
234
+ const mode = (0, codexPaths_1.getCodexCredentialStoreMode)();
235
+ if (mode !== 'file' && mode !== 'unknown')
236
+ return { ok: false, reason: exports.CODEX_KEYRING_FIX_HINT };
237
+ if ((0, codexCredentialStore_1.isSidekickCodexHome)((0, codexPaths_1.getSystemCodexHome)()))
238
+ return { ok: false, reason: 'Unset CODEX_HOME; it points at a Sidekick-owned home.' };
239
+ const current = (0, codexCredentialStore_1.readCodexCredentialStore)();
240
+ if (context.before.status === 'error' || !(0, codexCredentialStore_1.codexStoreMatches)(current, context.before.snapshot)) {
241
+ return { ok: false, reason: 'The live Codex credential changed; nothing was overwritten.' };
242
+ }
243
+ const raw = current.status === 'ok' ? current.snapshot.authRaw : null;
244
+ const fp = (0, codexAuth_1.codexRefreshFingerprint)(raw);
245
+ const candidateIdentity = (0, codexAuth_1.readAuthIdentityFromRaw)(candidate.authRaw);
246
+ const candidateProfile = listCodexAccounts().find((p) => p.id === accountId);
247
+ const validIdentity = candidateIdentity?.authMode !== 'chatgpt' ||
248
+ (candidateProfile !== undefined &&
249
+ (0, codexAuth_1.identitiesMatch)(candidateIdentity, profileIdentity(candidateProfile)));
250
+ const decision = (0, liveCredentialGuard_1.decideLiveCredentialWrite)({
251
+ provider: 'Codex',
252
+ intent,
253
+ liveStatus: current.status,
254
+ candidateValid: validIdentity && ((0, codexCredentialStore_1.isCodexLogin)(candidate.authRaw) || (0, codexCredentialStore_1.isLegacyCodexLogin)(candidate.legacyRaw)),
255
+ liveHasCredential: current.status === 'ok',
256
+ liveAccountId: context.liveAccountId,
257
+ candidateAccountId: accountId,
258
+ sameLink: fp !== null && fp === (0, codexAuth_1.codexRefreshFingerprint)(candidate.authRaw),
259
+ strictlyNewer: (0, codexAuth_1.compareCodexAuthFreshness)(candidate.authRaw, raw) > 0,
260
+ capturedOutgoing: context.capturedOutgoing,
261
+ matchesWritten: context.written !== undefined && (0, codexCredentialStore_1.codexStoreMatches)(current, context.written),
262
+ rejectCorrupt: true,
263
+ });
264
+ if (!decision.allow)
265
+ return { ok: false, reason: decision.reason };
266
+ if (current.status === 'error')
267
+ return { ok: false, reason: 'The live Codex login could not be read.' };
268
+ let expected;
269
+ try {
270
+ expected = (0, codexCredentialStore_1.codexInstalledSnapshot)(current.snapshot, candidate);
271
+ }
272
+ catch {
273
+ return {
274
+ ok: false,
275
+ reason: 'Legacy credentials would replace unrelated MCP data; sign in again.',
276
+ };
277
+ }
278
+ try {
279
+ (0, codexCredentialStore_1.writeLiveCodexCredentialStoreUnguarded)(expected);
280
+ if (!(0, codexCredentialStore_1.codexStoreMatches)((0, codexCredentialStore_1.readCodexCredentialStore)(), expected))
281
+ throw new Error('verification failed');
282
+ return { ok: true, installed: expected };
283
+ }
284
+ catch {
285
+ return { ok: false, reason: 'The live Codex write did not verify.', attempted: expected };
286
+ }
160
287
  }
161
288
  function readMetadataFromAuthJson(codexHome) {
162
289
  const identity = (0, codexAuth_1.readAuthIdentityFromRaw)((0, codexPaths_1.readFileOrNull)(path.join(codexHome, 'auth.json')));
@@ -212,7 +339,7 @@ function getCodexLoginStatus(codexHome) {
212
339
  timeout: PROBE_TIMEOUT_MS,
213
340
  killSignal: 'SIGKILL',
214
341
  });
215
- return parseCodexLoginStatusOutput(result.status, String(result.stdout ?? ''));
342
+ return parseCodexLoginStatusOutput(result.status, `${String(result.stdout ?? '')}\n${String(result.stderr ?? '')}`);
216
343
  }
217
344
  catch {
218
345
  // Ignore missing CLI or spawn errors.
@@ -223,9 +350,11 @@ function getCodexLoginStatusAsync(codexHome) {
223
350
  return new Promise((resolve) => {
224
351
  try {
225
352
  const env = { ...process.env, CODEX_HOME: codexHome };
226
- (0, child_process_1.execFile)('codex', ['login', 'status'], { encoding: 'utf8', env, timeout: PROBE_TIMEOUT_MS, killSignal: 'SIGKILL' }, (error, stdout) => {
353
+ (0, child_process_1.execFile)('codex', ['login', 'status'], { encoding: 'utf8', env, timeout: PROBE_TIMEOUT_MS, killSignal: 'SIGKILL' }, (error, stdout, stderr) => {
227
354
  // A missing CLI, non-zero exit, or timeout all mean "not logged in".
228
- resolve(error ? { loggedIn: false } : parseCodexLoginStatusOutput(0, String(stdout ?? '')));
355
+ resolve(error
356
+ ? { loggedIn: false }
357
+ : parseCodexLoginStatusOutput(0, `${String(stdout ?? '')}\n${String(stderr ?? '')}`));
229
358
  });
230
359
  }
231
360
  catch {
@@ -258,15 +387,13 @@ async function readCodexAccountMetadataAsync(codexHome) {
258
387
  metadataFromLoginStatus(await getCodexLoginStatusAsync(codexHome)));
259
388
  }
260
389
  function hasCodexCredentialFiles(codexHome) {
261
- return (fs.existsSync(path.join(codexHome, 'auth.json')) ||
262
- fs.existsSync(path.join(codexHome, '.credentials.json')));
390
+ return (0, codexCredentialStore_1.readCodexCredentialStore)(codexHome).status === 'ok';
263
391
  }
264
392
  function isCodexProfileAuthenticated(codexHome) {
265
- return hasCodexCredentialFiles(codexHome) || getCodexLoginStatus(codexHome).loggedIn;
393
+ return hasCodexCredentialFiles(codexHome);
266
394
  }
267
- /** {@link isCodexProfileAuthenticated} without blocking the event loop on the CLI probe. */
268
395
  async function isCodexProfileAuthenticatedAsync(codexHome) {
269
- return hasCodexCredentialFiles(codexHome) || (await getCodexLoginStatusAsync(codexHome)).loggedIn;
396
+ return isCodexProfileAuthenticated(codexHome);
270
397
  }
271
398
  function ensureUniqueCodexLabel(label, excludeId) {
272
399
  const normalized = label.trim().toLowerCase();
@@ -350,220 +477,190 @@ function getCodexExecutionEnv(baseEnv = process.env) {
350
477
  CODEX_HOME: resolveSidekickCodexHome(),
351
478
  };
352
479
  }
480
+ // ── Prepare / finalize (add account) ─────────────────────────────────────
353
481
  function keyringRefusal(mode) {
354
482
  return {
355
483
  success: false,
356
484
  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}`,
485
+ error: `Codex credential storage (${mode}) is not file-based. ${exports.CODEX_KEYRING_FIX_HINT}`,
358
486
  };
359
487
  }
360
- function prepareCodexAccountCore(label) {
488
+ function prepareCodexAccountCore(label, importCurrent, scan) {
361
489
  const trimmedLabel = label.trim();
362
- if (!trimmedLabel) {
363
- return { result: { success: false, error: 'Codex accounts require a non-empty label.' } };
364
- }
490
+ if (!trimmedLabel)
491
+ return { success: false, error: 'Codex accounts require a non-empty label.' };
365
492
  const labelError = ensureUniqueCodexLabel(trimmedLabel);
366
- if (labelError) {
367
- return { result: { success: false, error: labelError } };
368
- }
493
+ if (labelError)
494
+ return { success: false, error: labelError };
369
495
  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
- }
375
- const profileId = (0, crypto_1.randomUUID)();
376
- const codexHome = (0, codexPaths_1.getCodexProfileHome)(profileId);
377
- (0, codexPaths_1.ensureCodexProfileDirs)(profileId);
378
- writePendingProfile(profileId, {
379
- label: trimmedLabel,
380
- addedAt: new Date().toISOString(),
381
- });
382
- copySourceCodexConfig(sourceHome, codexHome);
383
- const imported = importCurrentCodexAuth(sourceHome, codexHome);
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);
496
+ const mode = (0, codexPaths_1.getCodexCredentialStoreMode)(sourceHome);
497
+ if (mode !== 'file' && mode !== 'unknown')
498
+ return keyringRefusal(mode);
499
+ if ((0, codexCredentialStore_1.isSidekickCodexHome)(sourceHome))
500
+ return {
501
+ success: false,
502
+ error: 'Unset CODEX_HOME before adding an account; it names a Sidekick-owned home.',
503
+ };
504
+ const live = (0, codexCredentialStore_1.readCodexCredentialStore)();
505
+ if (importCurrent && live.status === 'ok') {
506
+ const sync = syncCodexLiveStateUnlocked('live-sync', { scan });
507
+ const profile = listCodexAccounts().find((p) => profileMatchesSnapshot(p, live.snapshot));
508
+ if (!profile)
413
509
  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
- },
510
+ success: false,
511
+ error: sync.warnings[0] ?? 'The live Codex login has no identifiable account.',
421
512
  };
422
- }
423
- return { finalize: { profileId, codexHome } };
513
+ const manual = profile.metadata?.origin === 'live-sync' || profile.label === profile.email;
514
+ if (manual)
515
+ (0, accountRegistry_1.upsertSavedAccountProfile)({
516
+ ...profile,
517
+ label: trimmedLabel,
518
+ metadata: { ...profile.metadata, origin: 'manual' },
519
+ });
520
+ return {
521
+ success: true,
522
+ profileId: profile.id,
523
+ codexHome: sourceHome,
524
+ needsLogin: false,
525
+ warning: sync.warnings.join(' ') ||
526
+ (!sync.registered
527
+ ? `This Codex login is already saved as ${profile.label ?? profile.id}.`
528
+ : undefined),
529
+ };
530
+ }
531
+ if (importCurrent && (live.status === 'corrupt' || live.status === 'error')) {
532
+ return {
533
+ success: false,
534
+ error: 'The live Codex credential could not be read; retry after Codex finishes writing it.',
535
+ };
536
+ }
537
+ const profileId = (0, crypto_1.randomUUID)();
538
+ try {
539
+ writePendingProfile(profileId, { label: trimmedLabel, addedAt: new Date().toISOString() });
540
+ const codexHome = (0, codexPaths_1.getCodexLoginHome)(profileId);
541
+ copySourceCodexConfig(sourceHome, codexHome);
542
+ return { success: true, profileId, codexHome, needsLogin: true };
543
+ }
544
+ catch {
545
+ discardCodexLogin(profileId);
546
+ return { success: false, error: 'Could not prepare the isolated Codex login.' };
424
547
  }
425
- return { result: { success: true, profileId, codexHome, needsLogin: true } };
426
548
  }
427
- function prepareCodexAccount(label) {
428
- const step = prepareCodexAccountCore(label);
429
- if ('finalize' in step) {
430
- const finalized = finalizeCodexAccount(step.finalize.profileId);
431
- return { ...finalized, ...step.finalize, needsLogin: false };
549
+ /** Legacy callers can import current; explicit sign-in passes importCurrent: false. */
550
+ function prepareCodexAccount(label, options = {}) {
551
+ const scan = options.importCurrent === false ? undefined : (0, processDetection_1.inspectRunningAccountConsumersSync)('codex');
552
+ try {
553
+ return withCodexAuthSwapLock(() => prepareCodexAccountCore(label, options.importCurrent ?? true, scan));
554
+ }
555
+ catch {
556
+ return { success: false, error: 'Could not acquire the Codex account-switch lock.' };
557
+ }
558
+ }
559
+ async function prepareCodexAccountAsync(label, options = {}) {
560
+ const scan = options.importCurrent === false ? undefined : await (0, processDetection_1.inspectRunningAccountConsumers)('codex');
561
+ try {
562
+ return withCodexAuthSwapLock(() => prepareCodexAccountCore(label, options.importCurrent ?? true, scan));
563
+ }
564
+ catch {
565
+ return { success: false, error: 'Could not acquire the Codex account-switch lock.' };
432
566
  }
433
- return step.result;
434
567
  }
435
- /** {@link prepareCodexAccount} without blocking the event loop on CLI probes. */
436
- async function prepareCodexAccountAsync(label) {
437
- const step = prepareCodexAccountCore(label);
438
- if ('finalize' in step) {
439
- const finalized = await finalizeCodexAccountAsync(step.finalize.profileId);
440
- return { ...finalized, ...step.finalize, needsLogin: false };
568
+ /** Removes only a pending login, never a registered canonical profile. */
569
+ function discardCodexLogin(id) {
570
+ fs.rmSync((0, codexPaths_1.getCodexLoginDir)(id), { recursive: true, force: true });
571
+ if (!listCodexAccounts().some((p) => p.id === id) &&
572
+ fs.existsSync((0, codexPaths_1.getCodexProfileStatePath)(id))) {
573
+ fs.rmSync((0, codexPaths_1.getCodexProfileDir)(id), { recursive: true, force: true });
441
574
  }
442
- return step.result;
443
575
  }
444
- function finalizeCodexAccountCore(profileId, opts, probes) {
576
+ function finalizeCodexAccountCore(profileId, opts, scan) {
445
577
  const pending = readPendingProfile(profileId);
446
578
  if (!pending) {
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 };
457
- }
458
- const codexHome = (0, codexPaths_1.getCodexProfileHome)(profileId);
459
- if (!probes.authenticated) {
460
- return { result: { success: false, error: 'Codex profile is not authenticated yet.' } };
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' },
579
+ const saved = listCodexAccounts().find((p) => p.id === profileId);
580
+ if (!saved)
581
+ return { success: false, error: `Codex profile ${profileId} was not prepared.` };
582
+ return opts.activate === false
583
+ ? { success: true, profileId }
584
+ : performCodexAuthSwapCore(saved, {}, { scan });
585
+ }
586
+ const home = (0, codexPaths_1.getCodexLoginHome)(profileId);
587
+ const read = (0, codexCredentialStore_1.readCodexCredentialStore)(home);
588
+ if (read.status !== 'ok')
589
+ return {
590
+ success: false,
591
+ error: 'Codex profile is not authenticated with readable file credentials yet.',
475
592
  };
476
- (0, accountRegistry_1.upsertSavedAccountProfile)(merged);
477
- if (opts.activate === false)
478
- return { result: { success: true, profileId: existing.id } };
479
- return { swap: merged };
480
- }
593
+ const identity = (0, codexAuth_1.readAuthIdentityFromRaw)(read.snapshot.authRaw);
594
+ const metadata = readCodexAccountMetadataFromFiles(home) ?? {};
595
+ const existing = identity ? findProfileForIdentity(identity, { fallbackToActive: false }) : null;
596
+ // Legacy pending homes must not become a saved profile before their credential is moved out.
597
+ const id = existing?.id ?? (home === (0, codexPaths_1.getCodexProfileHome)(profileId) ? (0, crypto_1.randomUUID)() : profileId);
481
598
  const profile = {
482
- id: profileId,
599
+ ...existing,
600
+ id,
483
601
  providerId: 'codex',
484
- label: pending.label,
485
- email: probes.metadata.email,
486
- addedAt: pending.addedAt,
487
- metadata: { ...probes.metadata, origin: 'login' },
602
+ label: existing?.label ?? pending.label,
603
+ email: metadata.email ?? existing?.email,
604
+ addedAt: existing?.addedAt ?? pending.addedAt,
605
+ metadata: { ...existing?.metadata, ...metadata, origin: 'login' },
488
606
  };
489
- (0, accountRegistry_1.upsertSavedAccountProfile)(profile);
490
- if (authRaw)
491
- (0, accountHealth_1.writeCodexHealthSidecar)(profileId, authRaw, 'login');
492
607
  try {
493
- fs.rmSync((0, codexPaths_1.getCodexProfileStatePath)(profileId), { force: true });
608
+ // Preserve first. No successful sign-in is lost if activation is blocked.
609
+ const previous = (0, codexCredentialStore_1.readCodexBackup)(id);
610
+ (0, codexCredentialStore_1.writeCodexBackup)(id, previous.status === 'ok' &&
611
+ (0, codexAuth_1.compareCodexAuthFreshness)(previous.snapshot.authRaw, read.snapshot.authRaw) > 0
612
+ ? previous.snapshot
613
+ : read.snapshot);
614
+ (0, codexCredentialStore_1.removeCodexProfileCredentials)(home, read.snapshot);
615
+ (0, accountRegistry_1.upsertSavedAccountProfile)(profile);
616
+ (0, codexPaths_1.ensureCodexProfileDirs)(id);
617
+ copySourceCodexConfig(home, (0, codexPaths_1.getCodexProfileHome)(id));
618
+ storeCodexProfileAuthUnlocked(id, read.snapshot.authRaw, read.snapshot.legacyRaw, 'login');
494
619
  }
495
620
  catch {
496
- /* the pending marker is advisory */
497
- }
498
- if (opts.activate === false) {
499
- return { result: { success: true, profileId } };
500
- }
501
- if (!hasCodexCredentialFiles(codexHome)) {
502
- // Authenticated via the OS keyring — there are no credential files to
503
- // swap, so the registry pointer is all we can update.
504
- (0, accountRegistry_1.setActiveSavedAccount)('codex', profileId);
505
621
  return {
506
- result: {
507
- success: true,
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}`,
510
- },
622
+ success: false,
623
+ profileId: id,
624
+ error: 'Could not finish saving the Codex login; any verified private backup was kept.',
511
625
  };
512
626
  }
513
- return { swap: profile };
627
+ finally {
628
+ discardCodexLogin(profileId);
629
+ }
630
+ if (opts.activate === false)
631
+ return { success: true, profileId: id };
632
+ const result = performCodexAuthSwapCore(profile, {}, { scan });
633
+ return result.success
634
+ ? { ...result, profileId: id }
635
+ : {
636
+ ...result,
637
+ profileId: id,
638
+ needsLogin: false,
639
+ error: `The login was saved, but could not be activated: ${result.error}`,
640
+ };
514
641
  }
515
642
  function finalizeCodexAccount(profileId, opts = {}) {
516
- if (!readPendingProfile(profileId)) {
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;
520
- }
521
- const codexHome = (0, codexPaths_1.getCodexProfileHome)(profileId);
522
- const authenticated = isCodexProfileAuthenticated(codexHome);
523
- const step = finalizeCodexAccountCore(profileId, opts, {
524
- authenticated,
525
- metadata: authenticated ? readCodexAccountMetadata(codexHome) : {},
526
- });
527
- return 'swap' in step ? performCodexAuthSwap(step.swap, {}) : step.result;
643
+ const scan = (0, processDetection_1.inspectRunningAccountConsumersSync)('codex');
644
+ try {
645
+ return withCodexAuthSwapLock(() => finalizeCodexAccountCore(profileId, opts, scan));
646
+ }
647
+ catch {
648
+ return { success: false, error: 'Could not finalize the Codex login.' };
649
+ }
528
650
  }
529
- /** {@link finalizeCodexAccount} without blocking the event loop on CLI probes. */
530
651
  async function finalizeCodexAccountAsync(profileId, opts = {}) {
531
- if (!readPendingProfile(profileId)) {
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;
535
- }
536
- const codexHome = (0, codexPaths_1.getCodexProfileHome)(profileId);
537
- const authenticated = await isCodexProfileAuthenticatedAsync(codexHome);
538
- const step = finalizeCodexAccountCore(profileId, opts, {
539
- authenticated,
540
- metadata: authenticated ? await readCodexAccountMetadataAsync(codexHome) : {},
541
- });
542
- return 'swap' in step ? performCodexAuthSwapAsync(step.swap, {}) : step.result;
543
- }
544
- // ── Stash / identity matching ────────────────────────────────────────────
545
- function getCodexStashDir() {
546
- return path.join((0, accountRegistry_1.getAccountsDir)(), 'codex', 'stash');
547
- }
548
- function stashLiveCodexAuth(liveAuthRaw, liveLegacyRaw) {
652
+ const scan = await (0, processDetection_1.inspectRunningAccountConsumers)('codex');
549
653
  try {
550
- const stamp = new Date().toISOString().replace(/[:.]/g, '-');
551
- let stashPath = null;
552
- if (liveAuthRaw) {
553
- stashPath = path.join(getCodexStashDir(), `auth-${stamp}.json`);
554
- (0, atomic_1.atomicWriteFileSync)(stashPath, liveAuthRaw);
555
- }
556
- if (liveLegacyRaw) {
557
- const legacyStashPath = path.join(getCodexStashDir(), `credentials-${stamp}.json`);
558
- (0, atomic_1.atomicWriteFileSync)(legacyStashPath, liveLegacyRaw);
559
- stashPath = stashPath ?? legacyStashPath;
560
- }
561
- return stashPath;
654
+ return withCodexAuthSwapLock(() => finalizeCodexAccountCore(profileId, opts, scan));
562
655
  }
563
656
  catch {
564
- return null;
657
+ return { success: false, error: 'Could not finalize the Codex login.' };
565
658
  }
566
659
  }
660
+ // ── Stash / identity matching ────────────────────────────────────────────
661
+ function getCodexStashDir() {
662
+ return path.join((0, accountRegistry_1.getAccountsDir)(), 'codex', 'stash');
663
+ }
567
664
  function findProfileForIdentity(identity, options = {}) {
568
665
  const profiles = listCodexAccounts();
569
666
  if (identity?.workspaceId) {
@@ -572,7 +669,8 @@ function findProfileForIdentity(identity, options = {}) {
572
669
  return byWorkspace;
573
670
  }
574
671
  if (identity?.email) {
575
- const byEmail = profiles.find((profile) => (profile.email ?? profile.metadata?.email) === identity.email);
672
+ const byEmail = profiles.find((profile) => (profile.email ?? profile.metadata?.email) === identity.email &&
673
+ (!identity.workspaceId || !profile.metadata?.workspaceId));
576
674
  if (byEmail)
577
675
  return byEmail;
578
676
  }
@@ -597,468 +695,485 @@ function withCodexAuthSwapLock(operation) {
597
695
  fs.mkdirSync(lockDir, { recursive: true, mode: 0o700 });
598
696
  return (0, atomic_1.withFileLockSync)(path.join(lockDir, 'auth-swap.lock'), operation);
599
697
  }
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() {
698
+ /** Merge owned profiles only after preserving their newest login in a verified backup. */
699
+ function dedupeCodexProfiles(scan) {
700
+ if (consumerBlocker(scan))
701
+ return [];
607
702
  const removed = [];
608
- const profiles = listCodexAccounts();
609
703
  const groups = new Map();
610
- for (const profile of profiles) {
704
+ for (const profile of listCodexAccounts()) {
611
705
  const key = profile.metadata?.workspaceId
612
706
  ? `ws:${profile.metadata.workspaceId}`
613
- : (profile.email ?? profile.metadata?.email)
614
- ? `email:${profile.email ?? profile.metadata?.email}`
707
+ : profile.email
708
+ ? `email:${profile.email}`
615
709
  : `id:${profile.id}`;
616
710
  groups.set(key, [...(groups.get(key) ?? []), profile]);
617
711
  }
618
712
  for (const group of groups.values()) {
619
- if (group.length < 2)
713
+ if (group.length < 2 || group.some((p) => (0, accountLease_1.isAccountLeaseHeld)('codex', p.id)))
620
714
  continue;
621
715
  const sorted = [...group].sort((a, b) => Date.parse(a.addedAt) - Date.parse(b.addedAt));
622
716
  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;
717
+ let freshest = readStoredCodexAccount(keeper.id);
718
+ for (const p of sorted.slice(1)) {
719
+ const candidate = readStoredCodexAccount(p.id);
720
+ if (candidate &&
721
+ (!freshest || (0, codexAuth_1.compareCodexAuthFreshness)(candidate.authRaw, freshest.authRaw) > 0))
722
+ freshest = candidate;
723
+ }
724
+ if (!freshest)
725
+ continue;
726
+ (0, codexCredentialStore_1.writeCodexBackup)(keeper.id, freshest);
627
727
  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
- }
728
+ const store = (0, codexCredentialStore_1.readCodexCredentialStore)((0, codexPaths_1.getCodexProfileHome)(loser.id));
729
+ if (store.status === 'error' || store.status === 'corrupt')
730
+ continue;
731
+ if (store.status === 'ok')
732
+ (0, codexCredentialStore_1.removeCodexProfileCredentials)((0, codexPaths_1.getCodexProfileHome)(loser.id), store.snapshot);
733
+ const snapshot = (0, quotaSnapshots_1.readQuotaSnapshot)('codex', loser.id);
734
+ if (snapshot && !(0, quotaSnapshots_1.readQuotaSnapshot)('codex', keeper.id))
735
+ (0, quotaSnapshots_1.writeQuotaSnapshot)('codex', keeper.id, snapshot);
736
+ const archive = path.join(getCodexStashDir(), `dup-${loser.id}`);
737
+ fs.mkdirSync(path.dirname(archive), { recursive: true, mode: 0o700 });
738
+ if (fs.existsSync((0, codexPaths_1.getCodexProfileDir)(loser.id)))
739
+ fs.renameSync((0, codexPaths_1.getCodexProfileDir)(loser.id), archive);
740
+ if ((0, accountRegistry_1.getActiveSavedAccount)('codex')?.id === loser.id)
741
+ (0, accountRegistry_1.setActiveSavedAccount)('codex', keeper.id, { silent: true });
653
742
  (0, accountRegistry_1.removeSavedAccountProfile)('codex', loser.id);
743
+ fs.rmSync((0, codexPaths_1.getCodexBackupPath)(loser.id), { force: true });
744
+ fs.rmSync((0, codexPaths_1.getCodexBackupPath)(loser.id, true), { force: true });
654
745
  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
746
  }
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
- });
747
+ storeCodexProfileAuthUnlocked(keeper.id, freshest.authRaw, freshest.legacyRaw, 'migration');
667
748
  }
668
749
  return removed;
669
750
  }
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
- */
751
+ function codexJournalPath() {
752
+ return path.join((0, accountRegistry_1.getAccountsDir)(), 'codex', 'switch-journal.json');
753
+ }
754
+ function loginHash(snapshot) {
755
+ return (0, codexCredentialStore_1.codexSnapshotFingerprint)((0, codexCredentialStore_1.codexLoginOnly)(snapshot));
756
+ }
757
+ function clearCodexJournal() {
758
+ fs.rmSync(codexJournalPath(), { force: true });
759
+ }
760
+ function hasPendingCodexRepair() {
761
+ if (fs.existsSync(codexJournalPath()))
762
+ return true;
763
+ const live = (0, codexCredentialStore_1.readCodexCredentialStore)();
764
+ if (live.status === 'error' || live.status === 'corrupt')
765
+ return true;
766
+ if (live.status !== 'ok')
767
+ return false;
768
+ return listCodexAccounts().some((p) => {
769
+ if (!profileMatchesSnapshot(p, live.snapshot))
770
+ return false;
771
+ const home = (0, codexCredentialStore_1.readCodexCredentialStore)((0, codexPaths_1.getCodexProfileHome)(p.id));
772
+ const backup = (0, codexCredentialStore_1.readCodexBackup)(p.id);
773
+ return (home.status !== 'absent' ||
774
+ (backup.status === 'ok' &&
775
+ (0, codexAuth_1.compareCodexAuthFreshness)(backup.snapshot.authRaw, live.snapshot.authRaw) > 0));
776
+ });
777
+ }
778
+ function resolveCodexJournal(report, scan) {
779
+ if (!fs.existsSync(codexJournalPath()))
780
+ return;
781
+ let journal;
782
+ try {
783
+ journal = JSON.parse(fs.readFileSync(codexJournalPath(), 'utf8'));
784
+ if (!journal.to || !journal.fromHash || !journal.toHash)
785
+ throw new Error();
786
+ }
787
+ catch {
788
+ report.warnings.push('The Codex switch journal is unreadable; run accounts doctor.');
789
+ return;
790
+ }
791
+ const live = (0, codexCredentialStore_1.readCodexCredentialStore)();
792
+ if (live.status === 'error' || live.status === 'corrupt') {
793
+ report.warnings.push('An interrupted Codex switch is pending; the live login could not be read.');
794
+ return;
795
+ }
796
+ const blocker = consumerBlocker(scan);
797
+ if (blocker) {
798
+ report.warnings.push(blocker);
799
+ return;
800
+ }
801
+ const hash = loginHash(live.snapshot);
802
+ const to = listCodexAccounts().find((p) => p.id === journal.to);
803
+ if (hash === journal.toHash ||
804
+ (live.status === 'ok' && to && profileMatchesSnapshot(to, live.snapshot))) {
805
+ if (live.status !== 'ok')
806
+ return;
807
+ (0, codexCredentialStore_1.writeCodexBackup)(journal.to, live.snapshot);
808
+ const target = (0, codexCredentialStore_1.readCodexCredentialStore)((0, codexPaths_1.getCodexProfileHome)(journal.to));
809
+ if (target.status === 'error' || target.status === 'corrupt')
810
+ throw new Error('The interrupted switch target is unreadable.');
811
+ if (target.status === 'ok')
812
+ (0, codexCredentialStore_1.removeCodexProfileCredentials)((0, codexPaths_1.getCodexProfileHome)(journal.to), target.snapshot);
813
+ if (journal.from) {
814
+ const backup = (0, codexCredentialStore_1.readCodexBackup)(journal.from);
815
+ if (backup.status === 'ok' && loginHash(backup.snapshot) === journal.fromHash) {
816
+ if ((0, codexCredentialStore_1.readCodexCredentialStore)((0, codexPaths_1.getCodexProfileHome)(journal.from)).status === 'absent')
817
+ (0, codexCredentialStore_1.writeInactiveCodexCredentialStore)((0, codexPaths_1.getCodexProfileHome)(journal.from), backup.snapshot);
818
+ (0, accountHealth_1.writeCodexHealthSidecar)(journal.from, backup.snapshot.authRaw, 'switch');
819
+ }
820
+ }
821
+ (0, accountRegistry_1.setActiveSavedAccount)('codex', journal.to, { silent: true });
822
+ report.journal = 'finished';
823
+ }
824
+ else if (hash === journal.fromHash) {
825
+ const target = (0, codexCredentialStore_1.readCodexBackup)(journal.to);
826
+ if (target.status === 'ok' && loginHash(target.snapshot) === journal.toHash) {
827
+ if ((0, codexCredentialStore_1.readCodexCredentialStore)((0, codexPaths_1.getCodexProfileHome)(journal.to)).status === 'absent')
828
+ (0, codexCredentialStore_1.writeInactiveCodexCredentialStore)((0, codexPaths_1.getCodexProfileHome)(journal.to), target.snapshot);
829
+ }
830
+ if (journal.from) {
831
+ const outgoing = (0, codexCredentialStore_1.readCodexCredentialStore)((0, codexPaths_1.getCodexProfileHome)(journal.from));
832
+ if (outgoing.status === 'ok')
833
+ (0, codexCredentialStore_1.removeCodexProfileCredentials)((0, codexPaths_1.getCodexProfileHome)(journal.from), outgoing.snapshot);
834
+ }
835
+ (0, accountRegistry_1.setActiveSavedAccount)('codex', journal.from, { silent: true });
836
+ report.journal = 'rolled-back';
837
+ }
838
+ else {
839
+ report.journal = 'cleared';
840
+ report.warnings.push('The live Codex login matches neither side of the interrupted switch; it was kept. Private backups are available for inspection.');
841
+ }
842
+ clearCodexJournal();
843
+ }
844
+ /** No subprocesses inside this function: hosts resolve process scans before taking the lock. */
676
845
  function syncCodexLiveStateUnlocked(source = 'live-sync', probes = {}) {
677
846
  const report = (0, accountSyncTypes_1.emptyProviderSyncReport)();
678
847
  try {
679
- const merged = dedupeCodexProfiles();
848
+ const mode = (0, codexPaths_1.getCodexCredentialStoreMode)();
849
+ if (mode !== 'file' && mode !== 'unknown') {
850
+ report.skipped = 'keyring';
851
+ report.warnings.push(exports.CODEX_KEYRING_FIX_HINT);
852
+ return report;
853
+ }
854
+ if ((0, codexCredentialStore_1.isSidekickCodexHome)((0, codexPaths_1.getSystemCodexHome)())) {
855
+ report.skipped = 'managed-home';
856
+ report.warnings.push('Unset CODEX_HOME before syncing; it points at a Sidekick-owned home.');
857
+ return report;
858
+ }
859
+ resolveCodexJournal(report, probes.scan);
860
+ let live = (0, codexCredentialStore_1.readCodexCredentialStore)();
861
+ if (live.status === 'error' || live.status === 'corrupt') {
862
+ report.skipped = 'unreadable';
863
+ report.warnings.push('The live Codex login could not be read; nothing was replaced.');
864
+ return report;
865
+ }
866
+ if (live.status === 'absent') {
867
+ report.skipped = 'logged-out';
868
+ return report;
869
+ }
870
+ const merged = dedupeCodexProfiles(probes.scan);
680
871
  if (merged.length)
681
872
  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';
873
+ const identity = (0, codexAuth_1.readAuthIdentityFromRaw)(live.snapshot.authRaw);
874
+ const initialSnapshot = live.snapshot;
875
+ let profile = listCodexAccounts().find((p) => profileMatchesSnapshot(p, initialSnapshot));
876
+ if (!profile) {
877
+ if (!identity?.email &&
878
+ !identity?.workspaceId &&
879
+ identity?.authMode !== 'api-key' &&
880
+ !(0, codexCredentialStore_1.isLegacyCodexLogin)(live.snapshot.legacyRaw)) {
881
+ report.skipped = 'no-identity';
882
+ return report;
694
883
  }
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
884
  profile = {
702
- id: profileId,
885
+ id: (0, crypto_1.randomUUID)(),
703
886
  providerId: 'codex',
704
- label: identity.email ?? identity.workspaceId,
705
- email: identity.email,
887
+ label: identity?.email ?? identity?.workspaceId ?? 'Codex API key',
888
+ email: identity?.email,
706
889
  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
- },
890
+ metadata: { ...identity, authMode: identity?.authMode ?? 'api-key', origin: 'live-sync' },
714
891
  };
715
- (0, codexPaths_1.ensureCodexProfileDirs)(profileId);
716
- copySourceCodexConfig(systemHome, (0, codexPaths_1.getCodexProfileHome)(profileId));
892
+ // API-key identities require their verified bytes before registry matching can work.
893
+ (0, codexCredentialStore_1.writeCodexBackup)(profile.id, live.snapshot);
717
894
  (0, accountRegistry_1.upsertSavedAccountProfile)(profile);
718
- report.registered = { id: profileId, email: identity.email };
895
+ report.registered = { id: profile.id, email: profile.email };
719
896
  }
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;
897
+ const home = (0, codexPaths_1.getCodexProfileHome)(profile.id);
898
+ (0, codexPaths_1.ensureCodexProfileDirs)(profile.id);
899
+ if (!fs.existsSync(path.join(home, 'config.toml')))
900
+ copySourceCodexConfig((0, codexPaths_1.getSystemCodexHome)(), home);
901
+ const duplicate = (0, codexCredentialStore_1.readCodexCredentialStore)(home);
902
+ const stored = readStoredCodexAccount(profile.id);
903
+ if (stored && (0, codexAuth_1.compareCodexAuthFreshness)(stored.authRaw, live.snapshot.authRaw) > 0) {
904
+ const install = installIntoLiveCodexStore(stored, profile.id, 'repair', {
905
+ before: live,
906
+ liveAccountId: profile.id,
907
+ scan: probes.scan,
908
+ });
909
+ if (install.ok) {
910
+ live = { status: 'ok', snapshot: install.installed };
911
+ report.repaired = profile.id;
726
912
  }
727
913
  else {
728
- report.skipped = 'no-identity';
729
- return report;
914
+ report.warnings.push(`A newer Codex login is saved privately; repair is pending. ${install.reason}`);
730
915
  }
731
916
  }
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}`);
917
+ const backup = (0, codexCredentialStore_1.readCodexBackup)(profile.id);
918
+ if (backup.status !== 'ok' ||
919
+ (0, codexAuth_1.compareCodexAuthFreshness)(backup.snapshot.authRaw, live.snapshot.authRaw) <= 0) {
920
+ (0, codexCredentialStore_1.writeCodexBackup)(profile.id, live.snapshot);
921
+ report.folded = profile.id;
922
+ }
923
+ (0, accountHealth_1.writeCodexHealthSidecar)(profile.id, live.snapshot.authRaw, source);
924
+ if (duplicate.status === 'ok') {
925
+ const blocker = consumerBlocker(probes.scan);
926
+ // Preserve a newer copy even when its promotion must wait.
927
+ if ((0, codexAuth_1.compareCodexAuthFreshness)(duplicate.snapshot.authRaw, live.snapshot.authRaw) > 0)
928
+ (0, codexCredentialStore_1.writeCodexBackup)(profile.id, duplicate.snapshot);
929
+ if (blocker || (0, accountLease_1.isAccountLeaseHeld)('codex', profile.id))
930
+ report.warnings.push(blocker ?? 'The profile is leased; duplicate cleanup is pending.');
931
+ else {
932
+ (0, codexCredentialStore_1.removeCodexProfileCredentials)(home, duplicate.snapshot);
933
+ report.demoted = profile.id;
754
934
  }
755
935
  }
756
- const active = (0, accountRegistry_1.getActiveSavedAccount)('codex');
757
- if (!active || active.id !== profile.id) {
936
+ else if (duplicate.status === 'corrupt' || duplicate.status === 'error') {
937
+ report.warnings.push('The live account has an unreadable profile credential; run accounts doctor.');
938
+ }
939
+ if ((0, accountRegistry_1.getActiveSavedAccount)('codex')?.id !== profile.id) {
758
940
  (0, accountRegistry_1.setActiveSavedAccount)('codex', profile.id, { silent: true });
759
941
  report.repointed = profile.id;
760
942
  }
761
943
  }
762
- catch (err) {
763
- report.warnings.push(`Codex account sync failed: ${err}`);
944
+ catch {
945
+ report.warnings.push('Codex account sync could not finish; verified credentials were preserved for the next retry.');
764
946
  }
765
947
  return report;
766
948
  }
767
- /** {@link syncCodexLiveStateUnlocked} under the swap lock. */
768
949
  function syncCodexLiveState(source = 'live-sync') {
950
+ const scan = (0, processDetection_1.inspectRunningAccountConsumersSync)('codex');
769
951
  try {
770
- return withCodexAuthSwapLock(() => syncCodexLiveStateUnlocked(source));
952
+ return withCodexAuthSwapLock(() => syncCodexLiveStateUnlocked(source, { scan }));
771
953
  }
772
- catch (err) {
773
- return { warnings: [`Could not acquire the account-switch lock: ${err}`] };
954
+ catch {
955
+ return { warnings: ['Could not acquire the Codex account-switch lock.'] };
774
956
  }
775
957
  }
776
- /**
777
- * Remove isolated login homes that never authenticated once they are older
778
- * than `olderThanMs`. Returns the removed profile ids.
779
- */
780
- function cleanupAbandonedCodexLogins(olderThanMs = 6 * 60 * 60 * 1000, now = Date.now()) {
781
- const removed = [];
782
- let entries = [];
958
+ async function syncCodexLiveStateAsync(source = 'live-sync') {
959
+ const scan = await (0, processDetection_1.inspectRunningAccountConsumers)('codex');
783
960
  try {
784
- entries = fs.readdirSync((0, codexPaths_1.getCodexProfilesDir)());
961
+ return withCodexAuthSwapLock(() => syncCodexLiveStateUnlocked(source, { scan }));
785
962
  }
786
963
  catch {
787
- return removed;
964
+ return { warnings: ['Could not acquire the Codex account-switch lock.'] };
788
965
  }
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 */
966
+ }
967
+ /** Only abandoned Sidekick-owned login homes; authenticated copies are preserved privately first. */
968
+ function cleanupAbandonedCodexLogins(olderThanMs = 24 * 60 * 60 * 1000, now = Date.now(), scan) {
969
+ const removed = [];
970
+ if (consumerBlocker(scan ?? (0, processDetection_1.inspectRunningAccountConsumersSync)('codex')))
971
+ return removed;
972
+ withCodexAuthSwapLock(() => {
973
+ for (const root of [(0, codexPaths_1.getCodexLoginsDir)(), (0, codexPaths_1.getCodexProfilesDir)()]) {
974
+ if (!fs.existsSync(root))
975
+ continue;
976
+ for (const id of fs.readdirSync(root)) {
977
+ if (listCodexAccounts().some((p) => p.id === id))
978
+ continue;
979
+ const pending = readPendingProfile(id);
980
+ if (!pending || now - Date.parse(pending.addedAt) < olderThanMs)
981
+ continue;
982
+ const home = (0, codexPaths_1.getCodexLoginHome)(id);
983
+ const read = (0, codexCredentialStore_1.readCodexCredentialStore)(home);
984
+ if (read.status === 'error' || read.status === 'corrupt')
985
+ continue;
986
+ if (read.status === 'ok') {
987
+ const identity = (0, codexAuth_1.readAuthIdentityFromRaw)(read.snapshot.authRaw);
988
+ const owner = identity
989
+ ? findProfileForIdentity(identity, { fallbackToActive: false })
990
+ : null;
991
+ if (!owner)
992
+ continue; // An unregistered authenticated login needs a human decision.
993
+ const saved = readStoredCodexAccount(owner.id);
994
+ (0, codexCredentialStore_1.writeCodexBackup)(owner.id, saved && (0, codexAuth_1.compareCodexAuthFreshness)(saved.authRaw, read.snapshot.authRaw) >= 0
995
+ ? saved
996
+ : read.snapshot);
997
+ }
998
+ discardCodexLogin(id);
999
+ removed.push(id);
1000
+ }
808
1001
  }
809
- }
1002
+ });
810
1003
  return removed;
811
1004
  }
812
- /**
813
- * CLI probes are resolved before the auth-swap lock is taken — never spawn a
814
- * child process while holding it. The keyring probe is skipped (false) when
815
- * live credential files exist, because the swap only consults it in their
816
- * absence.
817
- */
818
- function resolveCodexSwapProbesSync() {
819
- const systemHome = (0, codexPaths_1.getSystemCodexHome)();
820
- return {
821
- consumers: (0, processDetection_1.detectRunningAccountConsumersSync)('codex'),
822
- systemKeyringLoggedIn: hasCodexCredentialFiles(systemHome)
823
- ? false
824
- : getCodexLoginStatus(systemHome).loggedIn,
825
- };
826
- }
827
- async function resolveCodexSwapProbesAsync() {
828
- const systemHome = (0, codexPaths_1.getSystemCodexHome)();
829
- return {
830
- consumers: await (0, processDetection_1.detectRunningAccountConsumers)('codex'),
831
- systemKeyringLoggedIn: hasCodexCredentialFiles(systemHome)
832
- ? false
833
- : (await getCodexLoginStatusAsync(systemHome)).loggedIn,
834
- };
835
- }
836
- function performCodexAuthSwap(target, options) {
837
- const probes = resolveCodexSwapProbesSync();
838
- try {
839
- return withCodexAuthSwapLock(() => performCodexAuthSwapCore(target, options, probes));
840
- }
841
- catch (err) {
842
- // Keep the non-throwing result contract when the lock cannot be acquired.
843
- return (0, accountSwitch_1.switchFailure)('codex', target.id, `Could not acquire the account-switch lock: ${err}`);
844
- }
845
- }
846
- async function performCodexAuthSwapAsync(target, options) {
847
- const probes = await resolveCodexSwapProbesAsync();
848
- let result;
849
- try {
850
- // The sync lock is fine here: probes are pre-resolved, so the critical
851
- // section is only fast local-filesystem work.
852
- result = withCodexAuthSwapLock(() => performCodexAuthSwapCore(target, options, probes));
853
- }
854
- catch (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
- });
868
- }
869
- return result;
870
- }
871
1005
  function performCodexAuthSwapCore(target, options, probes) {
872
- const systemHome = (0, codexPaths_1.getSystemCodexHome)();
873
- const liveAuthPath = path.join(systemHome, 'auth.json');
874
- const liveLegacyPath = path.join(systemHome, '.credentials.json');
875
- const targetName = target.label ?? target.email ?? target.id;
876
- let previousAccountId = (0, accountRegistry_1.getActiveSavedAccount)('codex')?.id ?? null;
877
1006
  const base = {
878
1007
  success: true,
879
1008
  provider: 'codex',
880
1009
  accountId: target.id,
881
- previousAccountId,
1010
+ previousAccountId: (0, accountRegistry_1.getActiveSavedAccount)('codex')?.id ?? null,
882
1011
  verified: false,
883
1012
  verification: 'none',
884
1013
  warnings: [],
885
1014
  hints: [],
886
- runningConsumers: probes.consumers,
1015
+ runningConsumers: probes.scan.consumers,
887
1016
  email: target.email ?? target.metadata?.email,
888
1017
  };
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,
894
- success: false,
895
- error: `CODEX_HOME points at a sidekick profile home (${systemHome}); unset it before switching accounts.`,
896
- });
897
- }
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);
927
- const targetAuthPath = path.join(profileHome, 'auth.json');
928
- const targetAuthRaw = (0, codexPaths_1.readFileOrNull)(targetAuthPath);
929
- const targetLegacyRaw = (0, codexPaths_1.readFileOrNull)(path.join(profileHome, '.credentials.json'));
930
- if (!targetAuthRaw && !targetLegacyRaw) {
931
- return (0, accountSwitch_1.finishSwitchResult)({
932
- ...base,
933
- success: false,
934
- needsLogin: true,
935
- error: `No stored credentials for "${targetName}". Sign in again to this account.`,
936
- });
937
- }
938
- if (targetAuthRaw && !(0, codexAuth_1.parseAuthJson)(targetAuthRaw)) {
939
- return (0, accountSwitch_1.finishSwitchResult)({
940
- ...base,
941
- success: false,
942
- needsLogin: true,
943
- error: `Stored credentials for "${targetName}" are corrupted. Sign in again to this account.`,
944
- });
945
- }
946
- // If the live file already belongs to the target account it is the freshest
947
- // copy (rotated refresh token included) — never replace it with a staler
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
- }) ||
954
- (liveAuthRaw !== null && liveAuthRaw === targetAuthRaw) ||
955
- (!liveAuthRaw && !targetAuthRaw && liveLegacyRaw !== null && liveLegacyRaw === targetLegacyRaw));
956
- if (liveMatchesTarget) {
957
- if (previousAccountId !== target.id)
958
- (0, accountRegistry_1.setActiveSavedAccount)('codex', target.id);
1018
+ const fail = (error, extra = {}) => (0, accountSwitch_1.finishSwitchResult)({ ...base, success: false, error, ...extra });
1019
+ const mode = (0, codexPaths_1.getCodexCredentialStoreMode)();
1020
+ if (mode !== 'file' && mode !== 'unknown')
1021
+ return fail(exports.CODEX_KEYRING_FIX_HINT);
1022
+ if ((0, codexCredentialStore_1.isSidekickCodexHome)((0, codexPaths_1.getSystemCodexHome)()))
1023
+ return fail('CODEX_HOME points at a Sidekick-owned home; unset it before switching.');
1024
+ let before = (0, codexCredentialStore_1.readCodexCredentialStore)();
1025
+ if (before.status === 'error' || before.status === 'corrupt')
1026
+ return fail('The live Codex login could not be read; it was left untouched.');
1027
+ const saved = readStoredCodexAccount(target.id);
1028
+ if (before.status === 'ok' && profileMatchesSnapshot(target, before.snapshot)) {
1029
+ if (saved && (0, codexAuth_1.compareCodexAuthFreshness)(saved.authRaw, before.snapshot.authRaw) > 0) {
1030
+ const result = installIntoLiveCodexStore(saved, target.id, 'relogin', {
1031
+ before,
1032
+ liveAccountId: target.id,
1033
+ scan: probes.scan,
1034
+ });
1035
+ if (!result.ok)
1036
+ return fail(result.reason);
1037
+ }
1038
+ const sync = syncCodexLiveStateUnlocked('switch', probes);
959
1039
  return (0, accountSwitch_1.finishSwitchResult)({
960
1040
  ...base,
961
1041
  alreadyActive: true,
962
1042
  verified: true,
963
1043
  verification: 'store',
1044
+ warnings: sync.warnings,
964
1045
  health: (0, accountHealth_1.getAccountHealth)('codex', target.id),
965
- hints: [`${targetName} is already the active Codex account.`],
1046
+ hints: ['That account is already the live Codex login.'],
966
1047
  });
967
1048
  }
968
- // Preflight: warn about stale backups; refuse ones known to be dead.
1049
+ const blocker = consumerBlocker(probes.scan);
1050
+ if (blocker)
1051
+ return fail(blocker);
1052
+ if ((0, accountLease_1.isAccountLeaseHeld)('codex', target.id))
1053
+ return fail('Keep-alive is refreshing this account; retry after it finishes.');
1054
+ const sync = syncCodexLiveStateUnlocked('switch', probes);
1055
+ base.warnings.push(...sync.warnings);
1056
+ if (fs.existsSync(codexJournalPath()))
1057
+ return fail('An interrupted Codex switch still needs recovery; run accounts doctor.');
1058
+ before = (0, codexCredentialStore_1.readCodexCredentialStore)();
1059
+ if (before.status === 'error' || before.status === 'corrupt')
1060
+ return fail('The live Codex credential could not be read.');
1061
+ const beforeSnapshot = before.snapshot;
1062
+ const outgoing = before.status === 'ok'
1063
+ ? listCodexAccounts().find((p) => profileMatchesSnapshot(p, beforeSnapshot))
1064
+ : null;
1065
+ if (before.status === 'ok' && !outgoing)
1066
+ return fail('The live login has no unambiguous account identity to save it under; sign in with Codex and retry.');
1067
+ base.previousAccountId = outgoing?.id ?? null;
1068
+ if (outgoing?.id === target.id)
1069
+ return fail('The live account changed during the switch; retry.');
1070
+ const targetHome = (0, codexPaths_1.getCodexProfileHome)(target.id);
1071
+ const targetBefore = (0, codexCredentialStore_1.readCodexCredentialStore)(targetHome);
1072
+ if (targetBefore.status === 'error' || targetBefore.status === 'corrupt')
1073
+ return fail('The target profile is unreadable; the switch was deferred.');
1074
+ const candidate = readStoredCodexAccount(target.id);
1075
+ if (!candidate)
1076
+ return fail('No readable stored credentials for this account; sign in again.', {
1077
+ needsLogin: true,
1078
+ });
1079
+ const candidateIdentity = (0, codexAuth_1.readAuthIdentityFromRaw)(candidate.authRaw);
1080
+ if (candidateIdentity?.authMode === 'chatgpt' &&
1081
+ !(0, codexAuth_1.identitiesMatch)(candidateIdentity, profileIdentity(target))) {
1082
+ return fail('The saved Codex credential does not belong to the selected account.');
1083
+ }
1084
+ const liveFp = (0, codexAuth_1.codexRefreshFingerprint)(beforeSnapshot.authRaw);
1085
+ if (liveFp && liveFp === (0, codexAuth_1.codexRefreshFingerprint)(candidate.authRaw))
1086
+ return fail('The target is another copy of the live refresh token; sign in again.');
969
1087
  const health = (0, accountHealth_1.getAccountHealth)('codex', target.id, { probe: 'store' });
970
1088
  base.health = health;
971
- if ((health.state === 'expired' || health.state === 'missing') && !options.force) {
972
- return (0, accountSwitch_1.finishSwitchResult)({
973
- ...base,
974
- success: false,
1089
+ if ((health.state === 'expired' || health.state === 'missing') && !options.force)
1090
+ return fail('Stored credentials have expired or are missing; sign in again.', {
975
1091
  needsLogin: true,
976
- error: `Stored credentials for "${targetName}" have expired; sign in again.`,
1092
+ });
1093
+ const refreshed = (0, codexAuth_1.readLastRefresh)(candidate.authRaw);
1094
+ if (refreshed !== null && Date.now() - refreshed > codexAuth_1.STALE_AUTH_THRESHOLD_MS)
1095
+ base.warnings.push('The stored Codex login has not been refreshed in over 8 days.');
1096
+ // Capture and verify both sides before removing either usable copy. Mandatory even with force.
1097
+ try {
1098
+ if (outgoing)
1099
+ (0, codexCredentialStore_1.writeCodexBackup)(outgoing.id, beforeSnapshot);
1100
+ (0, codexCredentialStore_1.writeCodexBackup)(target.id, candidate);
1101
+ (0, atomic_1.atomicWriteJsonSync)(codexJournalPath(), {
1102
+ from: outgoing?.id ?? null,
1103
+ to: target.id,
1104
+ fromHash: loginHash(beforeSnapshot),
1105
+ toHash: loginHash(candidate),
1106
+ at: new Date().toISOString(),
977
1107
  });
978
1108
  }
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.`);
1109
+ catch {
1110
+ return fail('The outgoing login or switch journal could not be saved and verified; live was left untouched.');
1111
+ }
1112
+ if (!(0, codexCredentialStore_1.codexStoreMatches)((0, codexCredentialStore_1.readCodexCredentialStore)(), beforeSnapshot))
1113
+ return fail('The live login changed after capture; the switch was deferred.');
1114
+ const profileStore = (0, codexCredentialStore_1.readCodexCredentialStore)(targetHome);
1115
+ if (!(0, codexCredentialStore_1.codexStoreMatches)(profileStore, targetBefore.snapshot))
1116
+ return fail('The target credential changed after selection; the switch was deferred.');
1117
+ if (profileStore.status === 'error' || profileStore.status === 'corrupt')
1118
+ return fail('The target profile is unreadable; the switch was deferred.');
1119
+ try {
1120
+ if (profileStore.status === 'ok')
1121
+ (0, codexCredentialStore_1.removeCodexProfileCredentials)(targetHome, profileStore.snapshot);
982
1122
  }
983
- const restoreLiveFiles = () => {
984
- try {
985
- if (liveAuthRaw)
986
- (0, atomic_1.atomicWriteFileSync)(liveAuthPath, liveAuthRaw);
987
- else
988
- fs.rmSync(liveAuthPath, { force: true });
989
- if (liveLegacyRaw)
990
- (0, atomic_1.atomicWriteFileSync)(liveLegacyPath, liveLegacyRaw);
991
- else
992
- fs.rmSync(liveLegacyPath, { force: true });
993
- }
994
- catch {
995
- /* rollback is best-effort */
1123
+ catch {
1124
+ return fail('The target copy could not be removed safely; the switch was deferred.');
1125
+ }
1126
+ const install = installIntoLiveCodexStore(candidate, target.id, 'switch', {
1127
+ before,
1128
+ liveAccountId: outgoing?.id ?? null,
1129
+ capturedOutgoing: Boolean(outgoing),
1130
+ scan: probes.scan,
1131
+ });
1132
+ const recover = (written) => {
1133
+ if (written) {
1134
+ installIntoLiveCodexStore(beforeSnapshot, outgoing?.id ?? target.id, 'rollback', {
1135
+ before: (0, codexCredentialStore_1.readCodexCredentialStore)(),
1136
+ liveAccountId: target.id,
1137
+ written,
1138
+ scan: probes.scan,
1139
+ });
996
1140
  }
1141
+ // Journal reconciliation never overwrites live and never restores a copy of its identity.
1142
+ resolveCodexJournal((0, accountSyncTypes_1.emptyProviderSyncReport)(), probes.scan);
1143
+ };
1144
+ if (!install.ok) {
997
1145
  try {
998
- (0, accountRegistry_1.setActiveSavedAccount)('codex', previousAccountId, { silent: true });
1146
+ recover(install.attempted);
999
1147
  }
1000
1148
  catch {
1001
- /* rollback is best-effort */
1149
+ /* journal and private backups survive */
1002
1150
  }
1003
- };
1004
- // Install.
1151
+ return fail(`${install.reason} Verified private backups were kept; run accounts doctor if recovery is pending.`, { verification: 'failed' });
1152
+ }
1005
1153
  try {
1006
- if (targetAuthRaw) {
1007
- (0, atomic_1.atomicWriteFileSync)(liveAuthPath, targetAuthRaw);
1008
- if (targetLegacyRaw)
1009
- (0, atomic_1.atomicWriteFileSync)(liveLegacyPath, targetLegacyRaw);
1010
- else if (liveLegacyRaw)
1011
- fs.rmSync(liveLegacyPath, { force: true });
1154
+ (0, accountRegistry_1.setActiveSavedAccount)('codex', target.id);
1155
+ }
1156
+ catch {
1157
+ try {
1158
+ recover(install.installed);
1012
1159
  }
1013
- else {
1014
- (0, atomic_1.atomicWriteFileSync)(liveLegacyPath, targetLegacyRaw);
1015
- if (liveAuthRaw)
1016
- fs.rmSync(liveAuthPath, { force: true });
1160
+ catch {
1161
+ /* journal survives */
1017
1162
  }
1163
+ return fail('The account registry could not be updated; recovery retained the verified backups.');
1018
1164
  }
1019
- catch (err) {
1020
- restoreLiveFiles();
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
- });
1046
- }
1047
- // Pointer last.
1048
1165
  try {
1049
- (0, accountRegistry_1.setActiveSavedAccount)('codex', target.id);
1166
+ if (outgoing)
1167
+ storeCodexProfileAuthUnlocked(outgoing.id, beforeSnapshot.authRaw, beforeSnapshot.legacyRaw, 'switch');
1168
+ (0, accountHealth_1.writeCodexHealthSidecar)(target.id, candidate.authRaw, 'switch');
1169
+ clearCodexJournal();
1050
1170
  }
1051
- catch (err) {
1052
- restoreLiveFiles();
1053
- return (0, accountSwitch_1.finishSwitchResult)({
1054
- ...base,
1055
- success: false,
1056
- error: `Failed to update account registry: ${err}`,
1057
- });
1171
+ catch {
1172
+ base.warnings.push('The live switch succeeded; profile cleanup is pending and will retry on sync.');
1058
1173
  }
1059
1174
  let undoToken;
1060
1175
  try {
1061
- undoToken = (0, accountSwitch_1.writeLastSwitch)('codex', previousAccountId, target.id).token;
1176
+ undoToken = (0, accountSwitch_1.writeLastSwitch)('codex', outgoing?.id ?? null, target.id).token;
1062
1177
  }
1063
1178
  catch {
1064
1179
  base.warnings.push('The switch succeeded but could not be recorded for undo.');
@@ -1068,123 +1183,174 @@ function performCodexAuthSwapCore(target, options, probes) {
1068
1183
  verified: true,
1069
1184
  verification: 'store',
1070
1185
  undoToken,
1071
- hints: [`New codex sessions use ${target.email ?? targetName}.`],
1186
+ hints: [`New Codex sessions use ${target.label ?? target.email ?? target.id}.`],
1072
1187
  });
1073
1188
  }
1074
1189
  function switchToCodexAccount(profileId, options = {}) {
1075
- const target = listCodexAccounts().find((account) => account.id === profileId);
1076
- if (!target) {
1190
+ const target = listCodexAccounts().find((p) => p.id === profileId);
1191
+ if (!target)
1077
1192
  return (0, accountSwitch_1.switchFailure)('codex', profileId, `Codex account ${profileId} not found.`);
1193
+ const scan = (0, processDetection_1.inspectRunningAccountConsumersSync)('codex');
1194
+ try {
1195
+ return withCodexAuthSwapLock(() => performCodexAuthSwapCore(target, options, { scan }));
1196
+ }
1197
+ catch {
1198
+ return (0, accountSwitch_1.switchFailure)('codex', profileId, 'The Codex switch could not finish; private backups and any pending journal were kept.');
1078
1199
  }
1079
- return performCodexAuthSwap(target, options);
1080
1200
  }
1081
- /** {@link switchToCodexAccount} without blocking the event loop on CLI probes. */
1082
1201
  async function switchToCodexAccountAsync(profileId, options = {}) {
1083
- const target = listCodexAccounts().find((account) => account.id === profileId);
1084
- if (!target) {
1202
+ const target = listCodexAccounts().find((p) => p.id === profileId);
1203
+ if (!target)
1085
1204
  return (0, accountSwitch_1.switchFailure)('codex', profileId, `Codex account ${profileId} not found.`);
1205
+ const scan = await (0, processDetection_1.inspectRunningAccountConsumers)('codex');
1206
+ let result;
1207
+ try {
1208
+ result = withCodexAuthSwapLock(() => performCodexAuthSwapCore(target, options, { scan }));
1086
1209
  }
1087
- return performCodexAuthSwapAsync(target, options);
1088
- }
1089
- // ── One-time migration ───────────────────────────────────────────────────
1090
- /**
1091
- * The reconcile flow consults the keyring probe only when the migration has
1092
- * not run yet and the system home has no auth.json; skip the spawn otherwise.
1093
- */
1094
- function needsReconcileKeyringProbe() {
1095
- const markerPath = path.join((0, accountRegistry_1.getAccountsDir)(), 'codex', '.live-auth-migrated-v1');
1096
- if (fs.existsSync(markerPath))
1097
- return false;
1098
- return !fs.existsSync(path.join((0, codexPaths_1.getSystemCodexHome)(), 'auth.json'));
1210
+ catch {
1211
+ return (0, accountSwitch_1.switchFailure)('codex', profileId, 'The Codex switch could not finish; private backups and any pending journal were kept.');
1212
+ }
1213
+ if (result.success && options.verifyWithCli && !result.alreadyActive) {
1214
+ const status = await getCodexLoginStatusAsync((0, codexPaths_1.getSystemCodexHome)());
1215
+ if (status.loggedIn)
1216
+ result.verification = 'cli';
1217
+ else
1218
+ result = (0, accountSwitch_1.finishSwitchResult)({
1219
+ ...result,
1220
+ warnings: [
1221
+ ...result.warnings,
1222
+ 'Codex could not read the installed login; sign in again if prompted.',
1223
+ ],
1224
+ });
1225
+ }
1226
+ return result;
1099
1227
  }
1100
- // One-time migration for installs created when profile homes doubled as live
1101
- // CODEX_HOMEs: the active profile's auth.json may hold a fresher rotated
1102
- // refresh token than the system home. Best-effort: never throws.
1228
+ /** Legacy migration entry points now use the same guarded reconciliation as ordinary sync. */
1103
1229
  function reconcileCodexAuthState() {
1230
+ syncCodexLiveState('migration');
1231
+ }
1232
+ async function reconcileCodexAuthStateAsync() {
1233
+ await syncCodexLiveStateAsync('migration');
1234
+ }
1235
+ function codexKeepAliveBlockerUnlocked(id) {
1236
+ if ((0, codexCredentialStore_1.isSidekickCodexHome)((0, codexPaths_1.getSystemCodexHome)()))
1237
+ return 'unset CODEX_HOME before managing accounts; it names a Sidekick-owned home';
1238
+ const mode = (0, codexPaths_1.getCodexCredentialStoreMode)();
1239
+ if (mode !== 'file' && mode !== 'unknown')
1240
+ return 'live credential storage is not readable file mode';
1241
+ const profile = listCodexAccounts().find((p) => p.id === id);
1242
+ if (!profile)
1243
+ return 'account not found';
1244
+ const live = (0, codexCredentialStore_1.readCodexCredentialStore)();
1245
+ if (live.status === 'error' || live.status === 'corrupt')
1246
+ return 'the live login could not be read';
1247
+ if (live.status === 'ok' && profileMatchesSnapshot(profile, live.snapshot))
1248
+ return 'live account; the CLI refreshes it in normal use';
1249
+ const store = (0, codexCredentialStore_1.readCodexCredentialStore)((0, codexPaths_1.getCodexProfileHome)(id));
1250
+ if (store.status !== 'ok')
1251
+ return 'no readable profile copy to refresh (keep-alive never restores backups)';
1252
+ const fp = (0, codexAuth_1.codexRefreshFingerprint)(store.snapshot.authRaw);
1253
+ if (!fp)
1254
+ return 'no refreshable ChatGPT login';
1255
+ if (live.status === 'ok' && fp === (0, codexAuth_1.codexRefreshFingerprint)(live.snapshot.authRaw))
1256
+ return 'same refresh token as the live login';
1257
+ if ((0, accountHealth_1.readHealthSidecar)('codex', id)?.source === 'live-sync')
1258
+ return 'copied from the live login; waits for a switch away from it';
1259
+ const backup = (0, codexCredentialStore_1.readCodexBackup)(id);
1260
+ if (backup.status === 'error' || backup.status === 'corrupt')
1261
+ return 'its private backup could not be read';
1262
+ if (backup.status === 'ok' &&
1263
+ (0, codexAuth_1.compareCodexAuthFreshness)(backup.snapshot.authRaw, store.snapshot.authRaw) > 0)
1264
+ return 'its saved backup is newer than the profile copy';
1265
+ return null;
1266
+ }
1267
+ function getCodexKeepAliveBlocker(id) {
1104
1268
  try {
1105
- const systemKeyringLoggedIn = needsReconcileKeyringProbe()
1106
- ? getCodexLoginStatus((0, codexPaths_1.getSystemCodexHome)()).loggedIn
1107
- : false;
1108
- withCodexAuthSwapLock(() => reconcileCodexAuthStateCore({ systemKeyringLoggedIn }));
1269
+ return withCodexAuthSwapLock(() => codexKeepAliveBlockerUnlocked(id));
1109
1270
  }
1110
1271
  catch {
1111
- // Reconciliation must never break startup — including a lock timeout.
1272
+ return 'could not acquire the account-switch lock';
1112
1273
  }
1113
1274
  }
1114
- /** {@link reconcileCodexAuthState} without blocking the event loop on CLI probes. */
1115
- async function reconcileCodexAuthStateAsync() {
1275
+ function beginCodexKeepAlive(id, leaseMs) {
1116
1276
  try {
1117
- const systemKeyringLoggedIn = needsReconcileKeyringProbe()
1118
- ? (await getCodexLoginStatusAsync((0, codexPaths_1.getSystemCodexHome)())).loggedIn
1119
- : false;
1120
- withCodexAuthSwapLock(() => reconcileCodexAuthStateCore({ systemKeyringLoggedIn }));
1277
+ return withCodexAuthSwapLock(() => {
1278
+ const reason = codexKeepAliveBlockerUnlocked(id);
1279
+ if (reason)
1280
+ return { ok: false, reason };
1281
+ return (0, accountLease_1.acquireAccountLease)('codex', id, leaseMs)
1282
+ ? { ok: true }
1283
+ : { ok: false, reason: 'another keep-alive run holds this account' };
1284
+ });
1121
1285
  }
1122
1286
  catch {
1123
- // Reconciliation must never break startup — including a lock timeout.
1287
+ return { ok: false, reason: 'could not acquire the account-switch lock' };
1124
1288
  }
1125
1289
  }
1126
- function reconcileCodexAuthStateCore(probes) {
1290
+ function finishCodexKeepAlive(id) {
1127
1291
  try {
1128
- const markerPath = path.join((0, accountRegistry_1.getAccountsDir)(), 'codex', '.live-auth-migrated-v1');
1129
- if (fs.existsSync(markerPath))
1130
- return;
1131
- const writeMarker = () => (0, atomic_1.atomicWriteFileSync)(markerPath, new Date().toISOString() + '\n');
1132
- const active = getActiveCodexAccount();
1133
- if (!active) {
1134
- writeMarker();
1135
- return;
1136
- }
1137
- const profileHome = (0, codexPaths_1.getCodexProfileHome)(active.id);
1138
- const profileAuthPath = path.join(profileHome, 'auth.json');
1139
- const profileAuthRaw = (0, codexPaths_1.readFileOrNull)(profileAuthPath);
1140
- if (!profileAuthRaw) {
1141
- writeMarker();
1142
- return;
1143
- }
1144
- const systemHome = (0, codexPaths_1.getSystemCodexHome)();
1145
- const liveAuthPath = path.join(systemHome, 'auth.json');
1146
- const liveAuthRaw = (0, codexPaths_1.readFileOrNull)(liveAuthPath);
1147
- if (!liveAuthRaw) {
1148
- // No live credentials (account was added via isolated login and never
1149
- // promoted). Promote the active profile's copy unless codex is logged
1150
- // in through the OS keyring. The probe was resolved before the swap
1151
- // lock was taken — never spawn while holding it.
1152
- if (!probes.systemKeyringLoggedIn) {
1153
- (0, atomic_1.atomicWriteFileSync)(liveAuthPath, profileAuthRaw);
1154
- }
1155
- writeMarker();
1156
- return;
1157
- }
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);
1161
- if (sameIdentity) {
1162
- const liveRefresh = (0, codexAuth_1.readLastRefresh)(liveAuthRaw, liveAuthPath);
1163
- const profileRefresh = (0, codexAuth_1.readLastRefresh)(profileAuthRaw, profileAuthPath);
1164
- if (profileRefresh !== null && (liveRefresh === null || profileRefresh > liveRefresh)) {
1165
- // The profile copy was the live home under the old model and holds
1166
- // the valid rotated refresh token — promote it.
1167
- stashLiveCodexAuth(liveAuthRaw, null);
1168
- (0, atomic_1.atomicWriteFileSync)(liveAuthPath, profileAuthRaw);
1169
- }
1170
- else {
1171
- storeCodexProfileAuth(active.id, liveAuthRaw, null, 'migration');
1172
- }
1173
- }
1174
- else {
1175
- // The live credentials belong to a different account; the live state
1176
- // wins — point the registry at the matching saved profile if there is
1177
- // one, and refresh its backup.
1178
- const matching = findProfileForIdentity(liveIdentity);
1179
- if (matching && matching.id !== active.id) {
1180
- storeCodexProfileAuth(matching.id, liveAuthRaw, null, 'migration');
1181
- (0, accountRegistry_1.setActiveSavedAccount)('codex', matching.id);
1292
+ withCodexAuthSwapLock(() => {
1293
+ const profile = listCodexAccounts().find((p) => p.id === id);
1294
+ const store = (0, codexCredentialStore_1.readCodexCredentialStore)((0, codexPaths_1.getCodexProfileHome)(id));
1295
+ if (!profile || isLiveCodexProfile(profile) || store.status !== 'ok')
1296
+ return;
1297
+ if (!(0, codexAuth_1.identitiesMatch)((0, codexAuth_1.readAuthIdentityFromRaw)(store.snapshot.authRaw), profileIdentity(profile)))
1298
+ return;
1299
+ const backup = (0, codexCredentialStore_1.readCodexBackup)(id);
1300
+ if (backup.status === 'ok' &&
1301
+ (0, codexAuth_1.compareCodexAuthFreshness)(backup.snapshot.authRaw, store.snapshot.authRaw) > 0)
1302
+ return;
1303
+ (0, codexCredentialStore_1.writeCodexBackup)(id, store.snapshot);
1304
+ (0, accountHealth_1.writeCodexHealthSidecar)(id, store.snapshot.authRaw, 'keepalive');
1305
+ });
1306
+ }
1307
+ finally {
1308
+ (0, accountLease_1.releaseAccountLease)('codex', id);
1309
+ }
1310
+ }
1311
+ function restoreCodexProfileFromBackup(id, options = {}) {
1312
+ try {
1313
+ return withCodexAuthSwapLock(() => {
1314
+ if ((0, codexCredentialStore_1.isSidekickCodexHome)((0, codexPaths_1.getSystemCodexHome)()))
1315
+ return 'none';
1316
+ const profile = listCodexAccounts().find((p) => p.id === id);
1317
+ if (!profile)
1318
+ return 'none';
1319
+ const live = (0, codexCredentialStore_1.readCodexCredentialStore)();
1320
+ if (live.status === 'error' || live.status === 'corrupt')
1321
+ return 'none';
1322
+ if (isLiveCodexProfile(profile))
1323
+ return 'live';
1324
+ const home = (0, codexPaths_1.getCodexProfileHome)(id);
1325
+ const store = (0, codexCredentialStore_1.readCodexCredentialStore)(home);
1326
+ if (store.status === 'ok') {
1327
+ const fp = (0, codexAuth_1.codexRefreshFingerprint)(store.snapshot.authRaw);
1328
+ if (fp && live.status === 'ok' && fp === (0, codexAuth_1.codexRefreshFingerprint)(live.snapshot.authRaw))
1329
+ return 'none';
1330
+ if (!(0, codexAuth_1.identitiesMatch)((0, codexAuth_1.readAuthIdentityFromRaw)(store.snapshot.authRaw), profileIdentity(profile)) &&
1331
+ (0, codexAuth_1.readAuthIdentityFromRaw)(store.snapshot.authRaw)?.authMode !== 'api-key' &&
1332
+ !(0, codexCredentialStore_1.isLegacyCodexLogin)(store.snapshot.legacyRaw))
1333
+ return 'none';
1334
+ const backup = (0, codexCredentialStore_1.readCodexBackup)(id);
1335
+ if (backup.status === 'ok' &&
1336
+ (0, codexAuth_1.compareCodexAuthFreshness)(backup.snapshot.authRaw, store.snapshot.authRaw) > 0)
1337
+ return 'none';
1338
+ return 'present';
1182
1339
  }
1183
- }
1184
- writeMarker();
1340
+ if (store.status !== 'absent' || options.restore === false)
1341
+ return 'none';
1342
+ const backup = (0, codexCredentialStore_1.readCodexBackup)(id);
1343
+ if (backup.status !== 'ok')
1344
+ return 'none';
1345
+ const fp = (0, codexAuth_1.codexRefreshFingerprint)(backup.snapshot.authRaw);
1346
+ if (fp && live.status === 'ok' && fp === (0, codexAuth_1.codexRefreshFingerprint)(live.snapshot.authRaw))
1347
+ return 'none';
1348
+ (0, codexCredentialStore_1.writeInactiveCodexCredentialStore)(home, backup.snapshot);
1349
+ return 'restored';
1350
+ });
1185
1351
  }
1186
1352
  catch {
1187
- // Reconciliation must never break startup.
1353
+ return 'none';
1188
1354
  }
1189
1355
  }
1190
1356
  function removeCodexAccount(profileId) {
@@ -1193,5 +1359,10 @@ function removeCodexAccount(profileId) {
1193
1359
  return { success: false, error: `Codex account ${profileId} not found.` };
1194
1360
  }
1195
1361
  fs.rmSync((0, codexPaths_1.getCodexProfileDir)(profileId), { recursive: true, force: true });
1362
+ fs.rmSync((0, codexPaths_1.getCodexBackupPath)(profileId), { force: true });
1363
+ fs.rmSync((0, codexPaths_1.getCodexBackupPath)(profileId, true), { force: true });
1196
1364
  return { success: true };
1197
1365
  }
1366
+ async function cleanupAbandonedCodexLoginsAsync(olderThanMs = 24 * 60 * 60 * 1000, now = Date.now()) {
1367
+ return cleanupAbandonedCodexLogins(olderThanMs, now, await (0, processDetection_1.inspectRunningAccountConsumers)('codex'));
1368
+ }