nterminal 1.3.7 → 1.3.8

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.
@@ -4,6 +4,7 @@ import { execFile } from 'node:child_process';
4
4
  import { promisify } from 'node:util';
5
5
  import os from 'node:os';
6
6
  import path from 'node:path';
7
+ import { accountReloginReason } from '../../shared/accountRelogin.js';
7
8
  import { ProviderRequestError, requestAccountUsage, requestTokenRefresh, unavailableUsage } from './providerRequests.js';
8
9
  const VAULT_DIRECTORY = 'credential-vault';
9
10
  const VAULT_INDEX = 'index.json';
@@ -210,7 +211,12 @@ export class AccountService {
210
211
  await Promise.all(['codex', 'claude'].map(async (provider) => {
211
212
  try {
212
213
  const representedPanes = new Set(running.filter((pane) => pane.provider === provider).map((pane) => pane.terminalId));
213
- processes.set(provider, (await this.processLister(provider))
214
+ const listed = await this.processLister(provider);
215
+ const daemonHelpers = daemonDescendants(listed);
216
+ processes.set(provider, listed
217
+ // Helpers the shared daemon spawns (e.g. codex-code-mode-host) are part
218
+ // of the daemon entry, not separate sessions.
219
+ .filter((entry) => !daemonHelpers.has(entry.pid))
214
220
  .filter((entry) => isCodexDaemon(entry) || !representedPanes.has(paneProcesses.get(entry.pid) ?? ''))
215
221
  .map((entry) => ({ pid: entry.pid, provider, command: entry.command, ...(isCodexDaemon(entry) ? { role: 'daemon' } : {}) })));
216
222
  }
@@ -227,9 +233,15 @@ export class AccountService {
227
233
  providers: ['codex', 'claude'].map((provider) => this.providerStatus(refreshed, provider, running, processes.get(provider) ?? [], processCheckUnavailable, pendingReady.get(provider) === true))
228
234
  };
229
235
  }
236
+ // Providers with at least one saved login that needs a new sign-in. Cheap
237
+ // (index metadata only), so the server list can poll it with system stats.
238
+ async reloginRequired() {
239
+ const index = await this.readIndex();
240
+ const now = this.now().getTime();
241
+ return ['codex', 'claude'].filter((provider) => index.profiles.some((profile) => profile.provider === provider && accountReloginReason(profile, now) !== null));
242
+ }
230
243
  async selectUnlocked(provider, profileId) {
231
244
  const index = await this.readIndex();
232
- await this.assertNoRunningPanes(provider, { allowTerminationAcknowledgement: true });
233
245
  if (index.pending[provider])
234
246
  throw new AccountServiceError('account_login_already_pending');
235
247
  await this.captureActiveCredentials(index, provider);
@@ -237,6 +249,8 @@ export class AccountService {
237
249
  if (!profile)
238
250
  throw new AccountServiceError('account_not_found');
239
251
  if (index.active[provider] === profile.id) {
252
+ if (clearExternalSwitch(index, provider))
253
+ await this.writeIndex(index);
240
254
  this.clearTerminationAcknowledgement(provider);
241
255
  return this.status();
242
256
  }
@@ -246,6 +260,7 @@ export class AccountService {
246
260
  try {
247
261
  await this.writeCredentialFile(provider, credentials);
248
262
  index.active[provider] = profile.id;
263
+ clearExternalSwitch(index, provider);
249
264
  await this.writeIndex(index);
250
265
  }
251
266
  catch (error) {
@@ -253,11 +268,10 @@ export class AccountService {
253
268
  await this.writeCredentialFile(provider, current);
254
269
  else
255
270
  await this.removeCredentialFile(provider);
256
- if (previousId)
257
- await rm(this.profilePath(previousId), { force: true });
258
271
  throw error;
259
272
  }
260
- await rm(this.profilePath(profile.id), { force: true });
273
+ // The selected profile's vault copy is exactly what was just published, so
274
+ // it already is the active backup. The previous account kept its archive.
261
275
  this.clearTerminationAcknowledgement(provider);
262
276
  return this.status();
263
277
  }
@@ -273,6 +287,8 @@ export class AccountService {
273
287
  throw new AccountServiceError('cannot_remove_active_account');
274
288
  }
275
289
  index.profiles = index.profiles.filter((candidate) => candidate.id !== profile.id);
290
+ if (index.externalSwitch?.[provider]?.fromProfileId === profile.id)
291
+ clearExternalSwitch(index, provider);
276
292
  await this.writeIndex(index);
277
293
  await rm(this.profilePath(profile.id), { force: true });
278
294
  this.retryAfter.delete(profile.id);
@@ -282,7 +298,6 @@ export class AccountService {
282
298
  }
283
299
  async beginLoginUnlocked(provider) {
284
300
  const index = await this.readIndex();
285
- await this.assertNoRunningPanes(provider, { allowTerminationAcknowledgement: true });
286
301
  if (index.pending[provider])
287
302
  throw new AccountServiceError('account_login_already_pending');
288
303
  await this.captureActiveCredentials(index, provider);
@@ -319,9 +334,6 @@ export class AccountService {
319
334
  }
320
335
  delete index.pending[provider];
321
336
  await this.writeIndex(index);
322
- const restoredId = index.active[provider];
323
- if (restoredId)
324
- await rm(this.profilePath(restoredId), { force: true });
325
337
  throw error;
326
338
  }
327
339
  }
@@ -330,15 +342,16 @@ export class AccountService {
330
342
  const pending = index.pending[provider];
331
343
  if (!pending)
332
344
  throw new AccountServiceError('account_login_not_pending');
333
- await this.assertNoRunningPanes(provider, { allowTerminationAcknowledgement: true });
334
345
  const credentials = await this.readCredentialFile(provider);
335
346
  if (!credentials)
336
347
  throw new AccountServiceError('account_login_not_complete');
337
348
  const profile = await this.storeProfile(index, provider, credentials, label);
349
+ // Back up the new active login before committing it as active.
350
+ await this.writeVaultCredentials(profile.id, credentials);
338
351
  index.active[provider] = profile.id;
339
352
  delete index.pending[provider];
353
+ clearExternalSwitch(index, provider);
340
354
  await this.writeIndex(index);
341
- await rm(this.profilePath(profile.id), { force: true });
342
355
  this.clearTerminationAcknowledgement(provider);
343
356
  return this.status();
344
357
  }
@@ -347,7 +360,6 @@ export class AccountService {
347
360
  const pending = index.pending[provider];
348
361
  if (!pending)
349
362
  return this.status();
350
- await this.assertNoRunningPanes(provider, { allowTerminationAcknowledgement: true });
351
363
  if (pending.previousProfileId) {
352
364
  const credentials = await this.readVaultCredentials(pending.previousProfileId);
353
365
  await this.writeCredentialFile(provider, credentials);
@@ -358,9 +370,6 @@ export class AccountService {
358
370
  }
359
371
  delete index.pending[provider];
360
372
  await this.writeIndex(index);
361
- const restoredId = index.active[provider];
362
- if (restoredId)
363
- await rm(this.profilePath(restoredId), { force: true });
364
373
  this.clearTerminationAcknowledgement(provider);
365
374
  return this.status();
366
375
  }
@@ -487,6 +496,7 @@ export class AccountService {
487
496
  processCheckUnavailable,
488
497
  terminationAcknowledged: this.hasTerminationAcknowledgement(provider),
489
498
  pendingLogin: pending ? { startedAt: pending.startedAt, ready: pendingReady, terminalId: pending.terminalId ?? null } : null,
499
+ lastExternalSwitch: index.externalSwitch?.[provider] ?? null,
490
500
  credentialPresent: Boolean(profile || pending || false)
491
501
  };
492
502
  }
@@ -498,12 +508,10 @@ export class AccountService {
498
508
  return provider ? [{ terminalId: terminal.id, provider, title: terminal.title, cwd: terminal.cwd }] : [];
499
509
  });
500
510
  }
501
- async assertNoRunningPanes(provider, options = {}) {
502
- const acknowledged = options.allowTerminationAcknowledgement === true && this.hasTerminationAcknowledgement(provider);
503
- if ((!acknowledged && this.runningPanes().some((pane) => pane.provider === provider)) || await this.processProbe(provider)) {
504
- throw new AccountServiceError('provider_panes_running');
505
- }
506
- }
511
+ // Running provider sessions are advisory for account changes: the dialog lists
512
+ // them, and they keep the account they started with until restarted. Only
513
+ // refreshing the ACTIVE account's credential stays guarded, because rotating
514
+ // its refresh token would invalidate the running CLI's copy.
507
515
  async terminateProviderSessionsUnlocked(provider) {
508
516
  const processes = await this.processLister(provider);
509
517
  this.clearTerminationAcknowledgement(provider);
@@ -559,51 +567,102 @@ export class AccountService {
559
567
  }
560
568
  if (JSON.stringify(index) !== before)
561
569
  await this.writeIndex(index);
562
- // Only metadata is retained for active accounts. A duplicate can exist
563
- // briefly during a switch, until the source file and index are committed.
564
- for (const provider of providers) {
565
- const id = index.active[provider];
566
- if (id)
567
- await rm(this.profilePath(id), { force: true });
568
- }
569
570
  }
571
+ // The live CLI file is the source of truth for the active account. Every
572
+ // newer live credential NTerminal observes is also copied into the active
573
+ // profile's encrypted vault backup, so an external overwrite (another login,
574
+ // or a stale CLI writing its old account back) never loses the account that
575
+ // was active: it stays in the vault and can be selected again.
570
576
  async captureActiveCredential(index, provider) {
571
577
  const credentials = await this.readCredentialFile(provider);
572
578
  const previousId = index.active[provider];
573
579
  if (!credentials) {
580
+ if (!previousId)
581
+ return;
574
582
  delete index.active[provider];
575
- await this.removeUnavailableProfile(index, previousId);
583
+ await this.deactivateProfile(index, provider, previousId, null);
576
584
  return;
577
585
  }
578
586
  const fingerprint = fingerprintFor(provider, credentials);
579
587
  const credentialHash = hashCredential(credentials);
580
588
  const existing = index.profiles.find((profile) => profile.provider === provider && (profile.fingerprint === fingerprint || profile.credentialHash === credentialHash));
589
+ if (existing && existing.id === previousId) {
590
+ if (existing.credentialHash !== credentialHash) {
591
+ // The active account's CLI rotated or re-wrote it: the live file wins.
592
+ await this.updateProfile(existing, provider, credentials);
593
+ await this.backupLiveCredential(existing, credentials, false);
594
+ }
595
+ else if (!await this.hasVaultCredentials(existing.id)) {
596
+ // Vaults written before active backups existed: create it once.
597
+ await this.writeVaultCredentials(existing.id, credentials);
598
+ }
599
+ return;
600
+ }
601
+ // The live file now holds a different account than the index says.
602
+ let profile;
581
603
  if (existing) {
582
604
  if (existing.credentialHash !== credentialHash)
583
605
  await this.updateProfile(existing, provider, credentials);
584
- index.active[provider] = existing.id;
585
- if (previousId !== existing.id)
586
- await this.removeUnavailableProfile(index, previousId);
587
- return;
606
+ profile = existing;
588
607
  }
589
- const profile = await this.storeProfile(index, provider, credentials);
608
+ else {
609
+ profile = await this.storeProfile(index, provider, credentials);
610
+ }
611
+ // A stale CLI may write back tokens older than this profile's vault copy
612
+ // (renewed while it was inactive); keep the newer copy for a later Use.
613
+ await this.backupLiveCredential(profile, credentials, true);
590
614
  index.active[provider] = profile.id;
591
- if (previousId !== profile.id)
592
- await this.removeUnavailableProfile(index, previousId);
593
- }
594
- async removeUnavailableProfile(index, id) {
595
- if (!id)
615
+ if (previousId)
616
+ await this.deactivateProfile(index, provider, previousId, profile.id);
617
+ }
618
+ // Copies a live credential into the profile's vault backup. With
619
+ // keepNewerBackup, a backup that holds a strictly newer credential for the
620
+ // same account is kept instead. The live file itself is never changed here.
621
+ async backupLiveCredential(profile, live, keepNewerBackup) {
622
+ const backup = await this.readVaultCredentials(profile.id).catch(() => null);
623
+ if (backup && keepNewerBackup && stableIdentity(profile.provider, backup) === stableIdentity(profile.provider, live) && isOlderCredential(profile.provider, live, backup)) {
624
+ return backup;
625
+ }
626
+ if (!backup || hashCredential(backup) !== hashCredential(live))
627
+ await this.writeVaultCredentials(profile.id, live);
628
+ return live;
629
+ }
630
+ // A profile stops being active because the live file changed outside
631
+ // NTerminal. Its backup becomes its saved credential. Profiles without a
632
+ // backup (vaults from before active backups) cannot be restored and are
633
+ // dropped, as before.
634
+ async deactivateProfile(index, provider, id, toProfileId) {
635
+ const profile = index.profiles.find((entry) => entry.id === id);
636
+ if (!profile)
596
637
  return;
638
+ let backup;
597
639
  try {
598
- await stat(this.profilePath(id));
640
+ backup = await this.readVaultCredentials(id);
599
641
  }
600
642
  catch (error) {
601
- if (!isNotFound(error))
643
+ if (!(error instanceof AccountServiceError) || await this.hasVaultCredentials(id))
602
644
  throw error;
603
- index.profiles = index.profiles.filter((profile) => profile.id !== id);
645
+ index.profiles = index.profiles.filter((entry) => entry.id !== id);
604
646
  this.retryAfter.delete(id);
647
+ return;
605
648
  }
649
+ if (hashCredential(backup) !== profile.credentialHash)
650
+ await this.updateProfile(profile, provider, backup);
651
+ index.externalSwitch = { ...index.externalSwitch, [provider]: { at: this.now().toISOString(), fromProfileId: id, toProfileId } };
606
652
  }
653
+ async hasVaultCredentials(id) {
654
+ try {
655
+ await stat(this.profilePath(id));
656
+ return true;
657
+ }
658
+ catch (error) {
659
+ if (isNotFound(error))
660
+ return false;
661
+ throw error;
662
+ }
663
+ }
664
+ // Before NTerminal itself replaces the live file (switch or new login), make
665
+ // sure the outgoing account's vault copy holds its newest credential.
607
666
  async archiveActiveCredential(index, provider) {
608
667
  const credentials = await this.readCredentialFile(provider);
609
668
  const profile = index.profiles.find((entry) => entry.id === index.active[provider]);
@@ -611,7 +670,12 @@ export class AccountService {
611
670
  return null;
612
671
  if (!profile || profile.credentialHash !== hashCredential(credentials))
613
672
  throw new AccountServiceError('account_credential_changed');
614
- await this.writeVaultCredentials(profile.id, credentials);
673
+ // The live hash matches the last synchronization, so the backup can only
674
+ // differ if it was kept as newer when this account became live again.
675
+ const kept = await this.backupLiveCredential(profile, credentials, true);
676
+ // Inactive profiles describe their vault copy.
677
+ if (kept !== credentials)
678
+ await this.updateProfile(profile, provider, kept);
615
679
  return credentials;
616
680
  }
617
681
  // A blank label only names new profiles; re-authenticating an existing
@@ -665,7 +729,11 @@ export class AccountService {
665
729
  }
666
730
  // Idle describes a conversation, not the CLI's background auth work. Until
667
731
  // we can delegate to its native auth owner, never compete with a live CLI.
668
- if (active)
732
+ // The same applies to an account an external write just displaced: CLIs
733
+ // started while it was active still hold its refresh token. The active
734
+ // account is always refreshed from the live file, never from its backup.
735
+ const displaced = index.externalSwitch?.[profile.provider]?.fromProfileId === profile.id;
736
+ if (active || displaced)
669
737
  await this.assertNoRefreshOwner(profile.provider);
670
738
  let refreshResponse;
671
739
  try {
@@ -706,8 +774,11 @@ export class AccountService {
706
774
  }
707
775
  // The live credential is shared by every pane for this OS user. The
708
776
  // compare above preserves an external CLI refresh or login that won the
709
- // race; otherwise atomically publish the one rotated credential.
777
+ // race; otherwise atomically publish the one rotated credential, then
778
+ // mirror it into the active backup. If the backup write fails, the next
779
+ // synchronization copies the newer live file.
710
780
  await this.writeCredentialFile(profile.provider, next);
781
+ await this.writeVaultCredentials(profile.id, next);
711
782
  }
712
783
  else {
713
784
  await this.writeVaultCredentials(profile.id, next);
@@ -735,7 +806,8 @@ export class AccountService {
735
806
  version: 1,
736
807
  profiles: Array.isArray(parsed.profiles) ? parsed.profiles.filter(isVaultProfile) : [],
737
808
  active: isRecord(parsed.active) ? parsed.active : {},
738
- pending: isRecord(parsed.pending) ? parsed.pending : {}
809
+ pending: isRecord(parsed.pending) ? parsed.pending : {},
810
+ ...(isRecord(parsed.externalSwitch) ? { externalSwitch: parseExternalSwitches(parsed.externalSwitch) } : {})
739
811
  };
740
812
  }
741
813
  catch (error) {
@@ -929,14 +1001,22 @@ async function listUserProcesses() {
929
1001
  const getuid = process.getuid;
930
1002
  if (!getuid)
931
1003
  throw new Error('unsupported');
932
- const { stdout } = await execFileAsync('ps', ['-u', String(getuid()), '-o', 'pid=,ppid=,comm=,args='], {
1004
+ const { stdout } = await execFileAsync('ps', ['-u', String(getuid()), '-o', 'pid=,ppid=,stat=,comm=,args='], {
933
1005
  timeout: 3_000, maxBuffer: 4 * 1024 * 1024, encoding: 'utf8'
934
1006
  });
1007
+ return parseProcessSnapshot(stdout);
1008
+ }
1009
+ // Zombie (Z) and dead (X) entries have exited and cannot be signalled; only
1010
+ // their parent can reap them (e.g. an old Codex daemon under its updater).
1011
+ // They are not running providers.
1012
+ export function parseProcessSnapshot(stdout) {
935
1013
  return stdout.split('\n').flatMap((line) => {
936
- const match = line.match(/^\s*(\d+)\s+(\d+)\s+(\S+)\s*(.*)$/);
1014
+ const match = line.match(/^\s*(\d+)\s+(\d+)\s+(\S+)\s+(\S+)\s*(.*)$/);
937
1015
  if (!match)
938
1016
  return [];
939
- const [, pidText, ppidText, command, args] = match;
1017
+ const [, pidText, ppidText, stat, command, args] = match;
1018
+ if (/^[ZX]/.test(stat))
1019
+ return [];
940
1020
  const pid = Number.parseInt(pidText, 10);
941
1021
  const ppid = Number.parseInt(ppidText, 10);
942
1022
  return Number.isSafeInteger(pid) && Number.isSafeInteger(ppid) ? [{ pid, ppid, command: command, args: args }] : [];
@@ -1005,6 +1085,24 @@ async function stopCodexDaemon(daemons) {
1005
1085
  }
1006
1086
  throw new AccountServiceError('codex_daemon_stop_failed');
1007
1087
  }
1088
+ function daemonDescendants(entries) {
1089
+ const parents = new Map(entries.map((entry) => [entry.pid, entry.ppid]));
1090
+ const daemons = new Set(entries.filter(isCodexDaemon).map((entry) => entry.pid));
1091
+ const out = new Set();
1092
+ for (const entry of entries) {
1093
+ if (daemons.has(entry.pid))
1094
+ continue;
1095
+ const seen = new Set();
1096
+ for (let pid = entry.ppid; pid > 0 && !seen.has(pid); pid = parents.get(pid) ?? 0) {
1097
+ if (daemons.has(pid)) {
1098
+ out.add(entry.pid);
1099
+ break;
1100
+ }
1101
+ seen.add(pid);
1102
+ }
1103
+ }
1104
+ return out;
1105
+ }
1008
1106
  function isCodexDaemon(entry) {
1009
1107
  const args = entry.args.trim().split(/\s+/);
1010
1108
  return path.basename(args[0] ?? '') === 'codex' && args[1] === 'app-server'
@@ -1044,6 +1142,31 @@ function signalProcess(pid, signal) {
1044
1142
  function sleep(milliseconds) {
1045
1143
  return new Promise((resolve) => setTimeout(resolve, milliseconds));
1046
1144
  }
1145
+ function clearExternalSwitch(index, provider) {
1146
+ if (!index.externalSwitch?.[provider])
1147
+ return false;
1148
+ const { [provider]: _cleared, ...rest } = index.externalSwitch;
1149
+ index.externalSwitch = rest;
1150
+ return true;
1151
+ }
1152
+ function parseExternalSwitches(value) {
1153
+ const result = {};
1154
+ for (const provider of ['codex', 'claude']) {
1155
+ const entry = value[provider];
1156
+ if (isRecord(entry) && typeof entry.at === 'string' && typeof entry.fromProfileId === 'string' && (entry.toProfileId === null || typeof entry.toProfileId === 'string')) {
1157
+ result[provider] = { at: entry.at, fromProfileId: entry.fromProfileId, toProfileId: entry.toProfileId };
1158
+ }
1159
+ }
1160
+ return result;
1161
+ }
1162
+ // True when `candidate` is provably older than `reference`: both carry an
1163
+ // access-token expiry and the candidate's is earlier. Unknown timing is never
1164
+ // treated as older, so the live file wins by default.
1165
+ function isOlderCredential(provider, candidate, reference) {
1166
+ const a = Date.parse(credentialTiming(provider, candidate).expiresAt ?? '');
1167
+ const b = Date.parse(credentialTiming(provider, reference).expiresAt ?? '');
1168
+ return Number.isFinite(a) && Number.isFinite(b) && a < b;
1169
+ }
1047
1170
  function toPublicProfile(profile) {
1048
1171
  return { id: profile.id, provider: profile.provider, label: profile.label, createdAt: profile.createdAt, updatedAt: profile.updatedAt, expiresAt: profile.expiresAt, refreshTokenExpiresAt: profile.refreshTokenExpiresAt, ...(profile.refresh ? { refresh: profile.refresh } : {}) };
1049
1172
  }