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
@@ -26,5 +26,11 @@ export interface AccountLaunchOptions {
26
26
  materialize?: boolean;
27
27
  /** Launch even when the stored credential is reported expired. */
28
28
  force?: boolean;
29
+ /**
30
+ * Claude: when the profile home has no credential but its backup does,
31
+ * restore it there first (default true). Keep-alive passes false: it never
32
+ * restores backups.
33
+ */
34
+ restoreBackup?: boolean;
29
35
  }
30
36
  export declare function getAccountLaunchEnv(provider: AccountProviderId, accountId: string, options?: AccountLaunchOptions): AccountLaunchEnv;
@@ -44,6 +44,9 @@ const path = __importStar(require("path"));
44
44
  const accountRegistry_1 = require("./accountRegistry");
45
45
  const accountHealth_1 = require("./accountHealth");
46
46
  const claudeProfiles_1 = require("./claudeProfiles");
47
+ const accounts_1 = require("./accounts");
48
+ const codexCredentialStore_1 = require("./codexCredentialStore");
49
+ const codexProfiles_1 = require("./codexProfiles");
47
50
  const codexPaths_1 = require("./codexPaths");
48
51
  const atomic_1 = require("./writers/atomic");
49
52
  function materializeClaudeHome(home) {
@@ -70,10 +73,11 @@ function materializeClaudeHome(home) {
70
73
  function materializeCodexHome(home) {
71
74
  fs.mkdirSync(home, { recursive: true, mode: 0o700 });
72
75
  const configTarget = path.join(home, 'config.toml');
73
- if (!fs.existsSync(configTarget)) {
74
- const source = (0, codexPaths_1.readFileOrNull)(path.join((0, codexPaths_1.getSystemCodexHome)(), 'config.toml')) ?? '';
75
- (0, atomic_1.atomicWriteFileSync)(configTarget, (0, codexPaths_1.forceFileCredentialStore)(source));
76
- }
76
+ const current = (0, codexPaths_1.readFileOrNull)(configTarget);
77
+ const source = current ?? (0, codexPaths_1.readFileOrNull)(path.join((0, codexPaths_1.getSystemCodexHome)(), 'config.toml')) ?? '';
78
+ const fileMode = (0, codexPaths_1.forceFileCredentialStore)(source);
79
+ if (fileMode !== current)
80
+ (0, atomic_1.atomicWriteFileSync)(configTarget, fileMode);
77
81
  }
78
82
  function getAccountLaunchEnv(provider, accountId, options = {}) {
79
83
  const profile = (0, accountRegistry_1.listSavedAccountProfiles)(provider).find((p) => p.id === accountId);
@@ -98,6 +102,40 @@ function getAccountLaunchEnv(provider, accountId, options = {}) {
98
102
  if (!profile) {
99
103
  return { ...base, error: `${provider} account ${accountId} not found.` };
100
104
  }
105
+ if (provider === 'codex' && (0, codexCredentialStore_1.isSidekickCodexHome)((0, codexPaths_1.getSystemCodexHome)())) {
106
+ return {
107
+ ...base,
108
+ error: 'Unset CODEX_HOME before managing accounts; it names a Sidekick-owned home.',
109
+ };
110
+ }
111
+ if (provider === 'codex' && !['file', 'unknown'].includes((0, codexPaths_1.getCodexCredentialStoreMode)())) {
112
+ return { ...base, error: 'Codex account launches require readable file-mode credentials.' };
113
+ }
114
+ if (provider === 'codex' && (0, codexProfiles_1.isLiveCodexProfile)(profile)) {
115
+ const liveHome = (0, codexPaths_1.getSystemCodexHome)();
116
+ return {
117
+ ...base,
118
+ home: liveHome,
119
+ env: { CODEX_HOME: liveHome },
120
+ warnings: ['This account is the live login, so the session uses the normal Codex home.'],
121
+ };
122
+ }
123
+ if (provider === 'claude-code' && health.isLive) {
124
+ // The live account's only CLI-usable copy is the live store; a session in
125
+ // its profile home would be a second copy racing it over the refresh
126
+ // token. Point the session at the live home instead.
127
+ const liveHome = (0, claudeProfiles_1.getLiveClaudeHome)();
128
+ const customHome = !(0, claudeProfiles_1.isDefaultClaudeHome)();
129
+ return {
130
+ ...base,
131
+ home: liveHome,
132
+ env: customHome ? { CLAUDE_CONFIG_DIR: liveHome } : {},
133
+ envUnset: customHome
134
+ ? [claudeProfiles_1.CLAUDE_SECURESTORAGE_CONFIG_DIR_ENV]
135
+ : ['CLAUDE_CONFIG_DIR', claudeProfiles_1.CLAUDE_SECURESTORAGE_CONFIG_DIR_ENV],
136
+ warnings: ['This account is the live login, so the session uses the normal Claude home.'],
137
+ };
138
+ }
101
139
  if ((health.state === 'expired' || health.state === 'missing') && !options.force) {
102
140
  return {
103
141
  ...base,
@@ -109,6 +147,25 @@ function getAccountLaunchEnv(provider, accountId, options = {}) {
109
147
  }
110
148
  if (health.state === 'expiring' && health.reason)
111
149
  warnings.push(health.reason);
150
+ if (provider === 'claude-code' && options.restoreBackup !== false) {
151
+ if ((0, accounts_1.restoreClaudeProfileFromBackup)(accountId) === 'restored') {
152
+ warnings.push("Restored this account's saved login into its profile home. If a session elsewhere still uses this login, close it first: two copies of one login invalidate each other.");
153
+ }
154
+ }
155
+ if (provider === 'codex') {
156
+ const restored = (0, codexProfiles_1.restoreCodexProfileFromBackup)(accountId, {
157
+ restore: options.restoreBackup !== false,
158
+ });
159
+ if (restored === 'none')
160
+ return { ...base, error: 'No safe inactive Codex credential is available; sign in again.' };
161
+ if (restored === 'live')
162
+ return { ...base, home: (0, codexPaths_1.getSystemCodexHome)(), env: { CODEX_HOME: (0, codexPaths_1.getSystemCodexHome)() } };
163
+ }
164
+ if (provider === 'codex' &&
165
+ options.materialize === false &&
166
+ !['file', 'unknown'].includes((0, codexPaths_1.getCodexCredentialStoreMode)(home))) {
167
+ return { ...base, error: 'The isolated Codex home must use file credential storage.' };
168
+ }
112
169
  if (options.materialize !== false) {
113
170
  try {
114
171
  if (provider === 'codex')
@@ -0,0 +1,5 @@
1
+ import type { AccountProviderId } from './accountRegistry';
2
+ export declare function isAccountLeaseHeld(provider: AccountProviderId, accountId: string, now?: number): boolean;
3
+ /** Take the lease; the caller holds the provider's swap lock. False when held elsewhere. */
4
+ export declare function acquireAccountLease(provider: AccountProviderId, accountId: string, durationMs: number, now?: number): boolean;
5
+ export declare function releaseAccountLease(provider: AccountProviderId, accountId: string): void;
@@ -0,0 +1,98 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.isAccountLeaseHeld = isAccountLeaseHeld;
37
+ exports.acquireAccountLease = acquireAccountLease;
38
+ exports.releaseAccountLease = releaseAccountLease;
39
+ /**
40
+ * Keep-alive leases: while keep-alive runs a provider CLI inside a profile
41
+ * home, that home's credential may be mid-refresh. A switch to that account
42
+ * would copy the pre-refresh token into the live store just as the CLI kills
43
+ * it, so switches refuse a leased target. Leases are acquired under the
44
+ * provider's swap lock (the same lock a switch holds), expire on their own,
45
+ * and are ignored once their process is gone.
46
+ */
47
+ const fs = __importStar(require("fs"));
48
+ const path = __importStar(require("path"));
49
+ const claudeProfiles_1 = require("./claudeProfiles");
50
+ const codexPaths_1 = require("./codexPaths");
51
+ const atomic_1 = require("./writers/atomic");
52
+ function leasePath(provider, accountId) {
53
+ const dir = provider === 'codex' ? (0, codexPaths_1.getCodexProfileDir)(accountId) : (0, claudeProfiles_1.getClaudeProfileDir)(accountId);
54
+ return path.join(dir, 'keepalive.lease');
55
+ }
56
+ function processAlive(pid) {
57
+ if (pid === process.pid)
58
+ return true;
59
+ try {
60
+ process.kill(pid, 0);
61
+ return true;
62
+ }
63
+ catch (error) {
64
+ return error.code === 'EPERM';
65
+ }
66
+ }
67
+ function isAccountLeaseHeld(provider, accountId, now = Date.now()) {
68
+ try {
69
+ const lease = JSON.parse(fs.readFileSync(leasePath(provider, accountId), 'utf8'));
70
+ return (typeof lease.pid === 'number' &&
71
+ typeof lease.expiresAt === 'number' &&
72
+ lease.expiresAt > now &&
73
+ processAlive(lease.pid));
74
+ }
75
+ catch {
76
+ return false;
77
+ }
78
+ }
79
+ /** Take the lease; the caller holds the provider's swap lock. False when held elsewhere. */
80
+ function acquireAccountLease(provider, accountId, durationMs, now = Date.now()) {
81
+ if (isAccountLeaseHeld(provider, accountId, now))
82
+ return false;
83
+ const target = leasePath(provider, accountId);
84
+ fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
85
+ (0, atomic_1.atomicWriteJsonSync)(target, {
86
+ pid: process.pid,
87
+ expiresAt: now + durationMs,
88
+ });
89
+ return true;
90
+ }
91
+ function releaseAccountLease(provider, accountId) {
92
+ try {
93
+ fs.rmSync(leasePath(provider, accountId), { force: true });
94
+ }
95
+ catch {
96
+ /* expires on its own */
97
+ }
98
+ }
@@ -73,6 +73,13 @@ export declare function getAccountLoginStatusAsync(provider: AccountProviderId,
73
73
  export declare function finalizeAccountLogin(provider: AccountProviderId, loginId: string, opts?: FinalizeAccountLoginOptions): AccountManagerResult;
74
74
  /** {@link finalizeAccountLogin} without blocking the event loop on CLI probes. */
75
75
  export declare function finalizeAccountLoginAsync(provider: AccountProviderId, loginId: string, opts?: FinalizeAccountLoginOptions): Promise<AccountManagerResult>;
76
+ /**
77
+ * Abandon a login started with {@link beginAccountLogin} (cancelled, timed
78
+ * out, or failed): removes the temporary home and, for Claude, its Keychain
79
+ * item. Refuses ids that belong to a saved profile. Returns true when
80
+ * something was discarded.
81
+ */
82
+ export declare function discardAccountLogin(provider: AccountProviderId, loginId: string): boolean;
76
83
  export declare function spawnAccountLogin(provider: AccountProviderId, label: string, opts?: SpawnAccountLoginOptions): Promise<AccountManagerResult>;
77
84
  export declare function switchAccount(provider: AccountProviderId, id: string, options?: SwitchAccountOptions): SwitchAccountResult;
78
85
  /**
@@ -39,6 +39,7 @@ exports.getAccountLoginStatus = getAccountLoginStatus;
39
39
  exports.getAccountLoginStatusAsync = getAccountLoginStatusAsync;
40
40
  exports.finalizeAccountLogin = finalizeAccountLogin;
41
41
  exports.finalizeAccountLoginAsync = finalizeAccountLoginAsync;
42
+ exports.discardAccountLogin = discardAccountLogin;
42
43
  exports.spawnAccountLogin = spawnAccountLogin;
43
44
  exports.switchAccount = switchAccount;
44
45
  exports.switchAccountAsync = switchAccountAsync;
@@ -54,7 +55,9 @@ const atomic_1 = require("./writers/atomic");
54
55
  const accounts_1 = require("./accounts");
55
56
  const claudeProfiles_1 = require("./claudeProfiles");
56
57
  const credentialIO_1 = require("./credentialIO");
58
+ const claudeCredentialLedger_1 = require("./claudeCredentialLedger");
57
59
  const codexProfiles_1 = require("./codexProfiles");
60
+ const codexPaths_1 = require("./codexPaths");
58
61
  const accountSwitch_1 = require("./accountSwitch");
59
62
  function getPendingClaudeProfilePath(loginId) {
60
63
  return path.join((0, claudeProfiles_1.getClaudeProfileDir)(loginId), 'profile.json');
@@ -109,7 +112,16 @@ function copyClaudeProfileToCanonicalHome(sourceHome, accountUuid, credentials,
109
112
  fs.mkdirSync(canonicalHome, { recursive: true, mode: 0o700 });
110
113
  if (path.resolve(sourceHome) !== path.resolve(canonicalHome)) {
111
114
  try {
112
- fs.cpSync(sourceHome, canonicalHome, { recursive: true, force: true });
115
+ // Credentials go only through the chokepoint below: when this account is
116
+ // the live one its profile home must not receive a CLI-usable copy.
117
+ fs.cpSync(sourceHome, canonicalHome, {
118
+ recursive: true,
119
+ force: true,
120
+ filter: (src) => {
121
+ const base = path.basename(src);
122
+ return base !== '.credentials.json' && !base.startsWith('.oauth_refresh.lock');
123
+ },
124
+ });
113
125
  }
114
126
  catch {
115
127
  // The credential and identity files are re-written below when possible.
@@ -148,7 +160,7 @@ function beginAccountLogin(provider, label, opts = {}) {
148
160
  const effectiveLabel = existing
149
161
  ? `${existing.label ?? existing.email ?? 'codex'} (re-login ${(0, crypto_1.randomUUID)().slice(0, 8)})`
150
162
  : label.trim() || `Codex ${(0, codexProfiles_1.listCodexAccounts)().length + 1}`;
151
- const prepared = (0, codexProfiles_1.prepareCodexAccount)(effectiveLabel);
163
+ const prepared = (0, codexProfiles_1.prepareCodexAccount)(effectiveLabel, { importCurrent: false });
152
164
  if (!prepared.success || !prepared.profileId || !prepared.codexHome) {
153
165
  return { success: false, error: prepared.error ?? 'Could not prepare Codex account login.' };
154
166
  }
@@ -186,6 +198,9 @@ function beginAccountLogin(provider, label, opts = {}) {
186
198
  existingAccountId: existing?.id,
187
199
  });
188
200
  seedClaudeLoginHome(home);
201
+ // Claude Code creates this home's Keychain item itself; record the home
202
+ // first so the item is provably Sidekick's even if everything else fails.
203
+ (0, claudeCredentialLedger_1.recordClaudeCredentialHome)(home, 'login');
189
204
  const loginCommand = resolveClaudeLoginCommand(opts);
190
205
  return {
191
206
  success: true,
@@ -207,7 +222,7 @@ function getClaudeAccountLoginStatus(loginId) {
207
222
  }
208
223
  function getAccountLoginStatus(provider, loginId) {
209
224
  if (provider === 'codex') {
210
- const codexHome = (0, codexProfiles_1.getCodexProfileHome)(loginId);
225
+ const codexHome = (0, codexPaths_1.getCodexLoginHome)(loginId);
211
226
  if (!(0, codexProfiles_1.isCodexProfileAuthenticated)(codexHome)) {
212
227
  return { state: 'pending' };
213
228
  }
@@ -226,7 +241,7 @@ function getAccountLoginStatus(provider, loginId) {
226
241
  */
227
242
  async function getAccountLoginStatusAsync(provider, loginId) {
228
243
  if (provider === 'codex') {
229
- const codexHome = (0, codexProfiles_1.getCodexProfileHome)(loginId);
244
+ const codexHome = (0, codexPaths_1.getCodexLoginHome)(loginId);
230
245
  if (!(await (0, codexProfiles_1.isCodexProfileAuthenticatedAsync)(codexHome))) {
231
246
  return { state: 'pending' };
232
247
  }
@@ -257,44 +272,55 @@ function finalizeClaudeAccountLogin(loginId, opts = {}) {
257
272
  if (!identity) {
258
273
  return { success: false, error: 'Claude profile is not authenticated yet.' };
259
274
  }
260
- const credentials = (0, credentialIO_1.readActiveCredentials)(home);
261
- if (!credentials) {
262
- return { success: false, error: 'Could not read Claude profile credentials.' };
263
- }
264
- const oauthAccount = readClaudeOauthAccount(home);
265
- if (!oauthAccount) {
266
- return { success: false, error: 'Could not read Claude profile config.' };
275
+ const read = (0, credentialIO_1.readClaudeCredentialStore)(home);
276
+ if (read.status === 'error') {
277
+ // Transient (locked Keychain, timeout): keep the login home for a retry.
278
+ return { success: false, error: 'Could not read the new Claude login yet; try again.' };
267
279
  }
268
- const pending = readPendingClaudeProfile(loginId);
269
- const existing = (0, accountRegistry_1.listSavedAccountProfiles)('claude-code').find((account) => (account.providerAccountId ?? account.id) === identity.uuid);
270
- copyClaudeProfileToCanonicalHome(home, identity.uuid, credentials, oauthAccount);
271
280
  const warnings = [];
272
- if (pending?.existingAccountId && pending.existingAccountId !== identity.uuid) {
273
- warnings.push(`You signed in as ${identity.email}, which is a different account from the one you chose to re-authenticate; it was saved separately.`);
274
- }
275
- (0, accountRegistry_1.upsertSavedAccountProfile)({
276
- id: existing?.id ?? identity.uuid,
277
- providerId: 'claude-code',
278
- providerAccountId: identity.uuid,
279
- email: identity.email,
280
- label: existing?.label ?? (pending?.label || identity.email),
281
- addedAt: existing?.addedAt ?? pending?.addedAt ?? new Date().toISOString(),
282
- metadata: {
283
- ...existing?.metadata,
284
- email: identity.email,
285
- origin: 'login',
286
- },
287
- });
288
- removePendingClaudeProfile(loginId);
289
- if (loginId !== identity.uuid) {
290
- removePendingClaudeProfile(identity.uuid);
291
- // The temporary home's keychain item holds live tokens; nothing references it now.
292
- (0, credentialIO_1.deleteStoredCredentials)((0, claudeProfiles_1.getClaudeProfileHome)(loginId));
293
- try {
294
- fs.rmSync((0, claudeProfiles_1.getClaudeProfileDir)(loginId), { recursive: true, force: true });
281
+ try {
282
+ if (read.status !== 'ok') {
283
+ return {
284
+ success: false,
285
+ error: 'The sign-in finished without a readable credential; sign in again.',
286
+ };
295
287
  }
296
- catch {
297
- /* the temporary login home is disposable */
288
+ const oauthAccount = readClaudeOauthAccount(home);
289
+ if (!oauthAccount) {
290
+ return { success: false, error: 'Could not read Claude profile config.' };
291
+ }
292
+ const pending = readPendingClaudeProfile(loginId);
293
+ const existing = (0, accountRegistry_1.listSavedAccountProfiles)('claude-code').find((account) => (account.providerAccountId ?? account.id) === identity.uuid);
294
+ // The chokepoint writes the private backup first, so a failure below
295
+ // still leaves the new login saved.
296
+ copyClaudeProfileToCanonicalHome(home, identity.uuid, read.blob, oauthAccount);
297
+ if (pending?.existingAccountId && pending.existingAccountId !== identity.uuid) {
298
+ warnings.push(`You signed in as ${identity.email}, which is a different account from the one you chose to re-authenticate; it was saved separately.`);
299
+ }
300
+ (0, accountRegistry_1.upsertSavedAccountProfile)({
301
+ id: existing?.id ?? identity.uuid,
302
+ providerId: 'claude-code',
303
+ providerAccountId: identity.uuid,
304
+ email: identity.email,
305
+ label: existing?.label ?? (pending?.label || identity.email),
306
+ addedAt: existing?.addedAt ?? pending?.addedAt ?? new Date().toISOString(),
307
+ metadata: {
308
+ ...existing?.metadata,
309
+ email: identity.email,
310
+ origin: 'login',
311
+ },
312
+ });
313
+ }
314
+ catch (err) {
315
+ return { success: false, error: `Could not save the new Claude login: ${err}` };
316
+ }
317
+ finally {
318
+ // Every exit after authentication: the temporary home's Keychain item
319
+ // holds a live refresh token that nothing will ever reference again.
320
+ removePendingClaudeProfile(loginId);
321
+ if (loginId !== identity.uuid) {
322
+ removePendingClaudeProfile(identity.uuid);
323
+ discardClaudeLoginHome(loginId);
298
324
  }
299
325
  }
300
326
  if (opts.activate === false) {
@@ -306,6 +332,8 @@ function finalizeClaudeAccountLogin(loginId, opts = {}) {
306
332
  const applied = (0, accounts_1.applyClaudeProfileToLiveHome)(identity.uuid);
307
333
  if (!applied.success)
308
334
  return applied;
335
+ if (applied.warning)
336
+ warnings.push(applied.warning);
309
337
  }
310
338
  const switched = (0, accounts_1.switchToAccount)(identity.uuid);
311
339
  return warnings.length
@@ -356,22 +384,82 @@ function latestHomeActivity(home) {
356
384
  }
357
385
  return latest;
358
386
  }
387
+ /** Delete a temporary Claude login home: its Keychain item first, then the directory. */
388
+ function discardClaudeLoginHome(loginId) {
389
+ const dir = (0, claudeProfiles_1.getClaudeProfileDir)(loginId);
390
+ if (!path.resolve(dir).startsWith(path.resolve((0, accountRegistry_1.getAccountsDir)()) + path.sep))
391
+ return;
392
+ (0, credentialIO_1.deleteStoredCredentials)((0, claudeProfiles_1.getClaudeProfileHome)(loginId));
393
+ try {
394
+ fs.rmSync(dir, { recursive: true, force: true });
395
+ }
396
+ catch {
397
+ /* the temporary login home is disposable */
398
+ }
399
+ }
359
400
  function discardPendingLogin(provider, loginId) {
360
401
  try {
361
- const dir = provider === 'codex'
362
- ? path.dirname((0, codexProfiles_1.getCodexProfileHome)(loginId))
363
- : (0, claudeProfiles_1.getClaudeProfileDir)(loginId);
364
- if (!dir.startsWith((0, accountRegistry_1.getAccountsDir)()))
402
+ if (provider === 'claude-code') {
403
+ discardClaudeLoginHome(loginId);
365
404
  return;
366
- if (provider === 'claude-code')
367
- (0, credentialIO_1.deleteStoredCredentials)((0, claudeProfiles_1.getClaudeProfileHome)(loginId));
368
- fs.rmSync(dir, { recursive: true, force: true });
405
+ }
406
+ (0, codexProfiles_1.discardCodexLogin)(loginId);
369
407
  }
370
408
  catch {
371
409
  /* best effort */
372
410
  }
373
411
  }
412
+ /**
413
+ * Abandon a login started with {@link beginAccountLogin} (cancelled, timed
414
+ * out, or failed): removes the temporary home and, for Claude, its Keychain
415
+ * item. Refuses ids that belong to a saved profile. Returns true when
416
+ * something was discarded.
417
+ */
418
+ function discardAccountLogin(provider, loginId) {
419
+ const registered = provider === 'codex'
420
+ ? (0, codexProfiles_1.listCodexAccounts)().some((account) => account.id === loginId)
421
+ : (0, accountRegistry_1.listSavedAccountProfiles)('claude-code').some((account) => account.id === loginId || account.providerAccountId === loginId);
422
+ if (registered)
423
+ return false;
424
+ if (provider === 'claude-code' && !readPendingClaudeProfile(loginId)) {
425
+ // Not a pending login; still drop a stray item for a home we created.
426
+ (0, credentialIO_1.deleteStoredCredentials)((0, claudeProfiles_1.getClaudeProfileHome)(loginId));
427
+ return false;
428
+ }
429
+ discardPendingLogin(provider, loginId);
430
+ return true;
431
+ }
432
+ class AccountLoginWriterActiveError extends Error {
433
+ }
374
434
  async function spawnAccountLogin(provider, label, opts = {}) {
435
+ let loginId;
436
+ let result;
437
+ let writerActive = false;
438
+ try {
439
+ result = await runAccountLogin(provider, label, opts, (id) => {
440
+ loginId = id;
441
+ });
442
+ return result;
443
+ }
444
+ catch (error) {
445
+ if (error instanceof AccountLoginWriterActiveError) {
446
+ writerActive = true;
447
+ return {
448
+ success: false,
449
+ error: 'The Codex login process could not be stopped. Its temporary home was preserved; close that process before retrying.',
450
+ };
451
+ }
452
+ throw error;
453
+ }
454
+ finally {
455
+ // Every exit that did not end in a saved login (spawn error, early exit,
456
+ // abort, timeout, a throw) removes the temporary home and its Keychain item.
457
+ if (!writerActive && loginId && (provider === 'codex' || !result?.success)) {
458
+ discardPendingLogin(provider, loginId);
459
+ }
460
+ }
461
+ }
462
+ async function runAccountLogin(provider, label, opts, onBegin) {
375
463
  if (opts.signal?.aborted) {
376
464
  return { success: false, error: 'Account login aborted.' };
377
465
  }
@@ -381,6 +469,7 @@ async function spawnAccountLogin(provider, label, opts = {}) {
381
469
  });
382
470
  if (!begin.success)
383
471
  return { success: false, error: begin.error };
472
+ onBegin(begin.loginId);
384
473
  if (begin.alreadyComplete) {
385
474
  return finalizeAccountLoginAsync(provider, begin.loginId, { activate: opts.activate ?? true });
386
475
  }
@@ -419,57 +508,105 @@ async function spawnAccountLogin(provider, label, opts = {}) {
419
508
  childExited = true;
420
509
  childState.spawnError = error;
421
510
  });
422
- const abort = () => {
423
- child.kill();
511
+ const stopCodexChild = async () => {
512
+ if (provider !== 'codex' || childExited)
513
+ return;
514
+ const waitForExit = (ms) => new Promise((resolve) => {
515
+ if (childExited) {
516
+ resolve();
517
+ return;
518
+ }
519
+ const done = () => {
520
+ clearTimeout(timer);
521
+ child.off('exit', done);
522
+ child.off('error', done);
523
+ resolve();
524
+ };
525
+ const timer = setTimeout(done, ms);
526
+ child.once('exit', done);
527
+ child.once('error', done);
528
+ });
529
+ await waitForExit(100);
530
+ try {
531
+ if (!childExited) {
532
+ child.kill();
533
+ await waitForExit(1000);
534
+ }
535
+ if (!childExited) {
536
+ child.kill('SIGKILL');
537
+ await waitForExit(1000);
538
+ }
539
+ }
540
+ catch {
541
+ throw new AccountLoginWriterActiveError();
542
+ }
543
+ if (!childExited)
544
+ throw new AccountLoginWriterActiveError();
545
+ };
546
+ const abort = async () => {
547
+ if (provider === 'codex')
548
+ await stopCodexChild();
549
+ else
550
+ child.kill();
424
551
  discardPendingLogin(provider, begin.loginId);
425
552
  emitStatus(opts, { state: 'failed', error: 'Account login aborted.' });
426
553
  return { success: false, error: 'Account login aborted.' };
427
554
  };
428
- while (true) {
429
- if (opts.signal?.aborted)
430
- return abort();
431
- const status = emitStatus(opts, await getAccountLoginStatusAsync(provider, begin.loginId));
432
- if (status.state === 'authenticated') {
433
- return finalizeAccountLoginAsync(provider, begin.loginId, {
434
- activate: opts.activate ?? true,
435
- });
436
- }
437
- if (childExited) {
438
- if (childState.spawnError) {
439
- const error = `Could not spawn account login: ${childState.spawnError.message}`;
440
- discardPendingLogin(provider, begin.loginId);
441
- emitStatus(opts, { state: 'failed', error });
442
- return { success: false, error };
443
- }
444
- const finalStatus = emitStatus(opts, await getAccountLoginStatusAsync(provider, begin.loginId));
445
- if (finalStatus.state === 'authenticated') {
446
- return finalizeAccountLoginAsync(provider, begin.loginId, {
555
+ try {
556
+ while (true) {
557
+ if (opts.signal?.aborted)
558
+ return await abort();
559
+ const status = emitStatus(opts, await getAccountLoginStatusAsync(provider, begin.loginId));
560
+ if (status.state === 'authenticated') {
561
+ await stopCodexChild();
562
+ return await finalizeAccountLoginAsync(provider, begin.loginId, {
447
563
  activate: opts.activate ?? true,
448
564
  });
449
565
  }
450
- discardPendingLogin(provider, begin.loginId);
451
- emitStatus(opts, {
452
- state: 'failed',
453
- error: `Account login exited before authentication completed${childExitCode === null ? '.' : ` (exit ${childExitCode}).`}`,
454
- });
455
- return { success: false, error: 'Account login did not complete.' };
456
- }
457
- const now = Date.now();
458
- const mtime = latestHomeActivity(begin.configDir);
459
- if (mtime > lastSeenMtime) {
460
- lastSeenMtime = mtime;
461
- lastActivity = now;
462
- }
463
- const remainingMs = Math.min(inactivityMs - (now - lastActivity), maxMs - (now - startedAt));
464
- if (remainingMs <= 0) {
465
- child.kill();
466
- discardPendingLogin(provider, begin.loginId);
467
- emitStatus(opts, { state: 'failed', error: 'Account login timed out.' });
468
- return { success: false, error: 'Account login timed out.' };
566
+ if (childExited) {
567
+ if (childState.spawnError) {
568
+ const error = `Could not spawn account login: ${childState.spawnError.message}`;
569
+ discardPendingLogin(provider, begin.loginId);
570
+ emitStatus(opts, { state: 'failed', error });
571
+ return { success: false, error };
572
+ }
573
+ const finalStatus = emitStatus(opts, await getAccountLoginStatusAsync(provider, begin.loginId));
574
+ if (finalStatus.state === 'authenticated') {
575
+ await stopCodexChild();
576
+ return await finalizeAccountLoginAsync(provider, begin.loginId, {
577
+ activate: opts.activate ?? true,
578
+ });
579
+ }
580
+ discardPendingLogin(provider, begin.loginId);
581
+ emitStatus(opts, {
582
+ state: 'failed',
583
+ error: `Account login exited before authentication completed${childExitCode === null ? '.' : ` (exit ${childExitCode}).`}`,
584
+ });
585
+ return { success: false, error: 'Account login did not complete.' };
586
+ }
587
+ const now = Date.now();
588
+ const mtime = latestHomeActivity(begin.configDir);
589
+ if (mtime > lastSeenMtime) {
590
+ lastSeenMtime = mtime;
591
+ lastActivity = now;
592
+ }
593
+ const remainingMs = Math.min(inactivityMs - (now - lastActivity), maxMs - (now - startedAt));
594
+ if (remainingMs <= 0) {
595
+ if (provider === 'codex')
596
+ await stopCodexChild();
597
+ else
598
+ child.kill();
599
+ discardPendingLogin(provider, begin.loginId);
600
+ emitStatus(opts, { state: 'failed', error: 'Account login timed out.' });
601
+ return { success: false, error: 'Account login timed out.' };
602
+ }
603
+ const shouldContinue = await waitForNextPoll(Math.min(2_000, remainingMs), opts.signal);
604
+ if (!shouldContinue)
605
+ return await abort();
469
606
  }
470
- const shouldContinue = await waitForNextPoll(Math.min(2_000, remainingMs), opts.signal);
471
- if (!shouldContinue)
472
- return abort();
607
+ }
608
+ finally {
609
+ await stopCodexChild();
473
610
  }
474
611
  }
475
612
  function switchAccount(provider, id, options = {}) {
@@ -12,11 +12,6 @@ export interface SyncLiveAccountStateOptions {
12
12
  keychainFoldIntervalMs?: number;
13
13
  now?: number;
14
14
  }
15
- /**
16
- * Reconcile the live logins with the saved profiles for both providers.
17
- * Safe to call from watchers: re-entrant calls return an empty report, and
18
- * `onlyIfChanged` short-circuits when the live files are unchanged.
19
- */
20
15
  export declare function syncLiveAccountStateSync(options?: SyncLiveAccountStateOptions): SyncReport;
21
16
  /** Promise-returning twin of {@link syncLiveAccountStateSync} for async hosts. */
22
17
  export declare function syncLiveAccountState(options?: SyncLiveAccountStateOptions): Promise<SyncReport>;
@@ -28,3 +23,4 @@ export interface CleanupAbandonedLoginsResult {
28
23
  }
29
24
  /** Remove isolated login homes that never authenticated (both providers). */
30
25
  export declare function cleanupAbandonedLogins(olderThanMs?: number, now?: number): CleanupAbandonedLoginsResult;
26
+ export declare function cleanupAbandonedLoginsAsync(olderThanMs?: number, now?: number): Promise<CleanupAbandonedLoginsResult>;