nterminal 1.3.8 → 1.3.10

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 (28) hide show
  1. package/README.md +8 -8
  2. package/dist/client/assets/MarkdownPreview-8vEkrQtk.js +1 -0
  3. package/dist/client/assets/{TranscriptMarkdownBody-Dd6cp7t2.js → TranscriptMarkdownBody-BAkL6ovK.js} +1 -1
  4. package/dist/client/assets/{index-CWRDvfyP.css → index-D1X7cDQ9.css} +1 -1
  5. package/dist/client/assets/{index-DJwOCZv_.js → index-DLFQwECZ.js} +1 -1
  6. package/dist/client/assets/index-WSS03d_S.js +59 -0
  7. package/dist/client/index.html +2 -2
  8. package/dist/server/accounts/accountService.d.ts +34 -5
  9. package/dist/server/accounts/accountService.js +288 -29
  10. package/dist/server/accounts/accountService.js.map +1 -1
  11. package/dist/server/accounts/loginSession.d.ts +67 -0
  12. package/dist/server/accounts/loginSession.js +333 -0
  13. package/dist/server/accounts/loginSession.js.map +1 -0
  14. package/dist/server/agent/agentRoutes.js +21 -2
  15. package/dist/server/agent/agentRoutes.js.map +1 -1
  16. package/dist/server/routes/accountRoutes.js +30 -1
  17. package/dist/server/routes/accountRoutes.js.map +1 -1
  18. package/dist/server/routes/agentManagementRoutes.js +18 -5
  19. package/dist/server/routes/agentManagementRoutes.js.map +1 -1
  20. package/dist/shared/accountLogin.d.ts +3 -0
  21. package/dist/shared/accountLogin.js +47 -0
  22. package/dist/shared/accountLogin.js.map +1 -0
  23. package/dist/shared/protocol.d.ts +22 -5
  24. package/dist/shared/protocol.js.map +1 -1
  25. package/docs/features.md +3 -0
  26. package/package.json +2 -1
  27. package/dist/client/assets/MarkdownPreview-D3Oa7_1H.js +0 -1
  28. package/dist/client/assets/index-BOvp6zV9.js +0 -58
@@ -12,8 +12,8 @@
12
12
  <link rel="icon" type="image/png" sizes="64x64" href="/icons/favicon-64.png" />
13
13
  <link rel="icon" type="image/png" sizes="96x96" href="/icons/favicon-96.png" />
14
14
  <title>NTerminal</title>
15
- <script type="module" crossorigin src="/assets/index-BOvp6zV9.js"></script>
16
- <link rel="stylesheet" crossorigin href="/assets/index-CWRDvfyP.css">
15
+ <script type="module" crossorigin src="/assets/index-WSS03d_S.js"></script>
16
+ <link rel="stylesheet" crossorigin href="/assets/index-D1X7cDQ9.css">
17
17
  </head>
18
18
  <body>
19
19
  <div id="root"></div>
@@ -1,7 +1,8 @@
1
1
  import type { AppConfig } from '../config.js';
2
2
  import type { TerminalManager } from '../terminal/TerminalManager.js';
3
- import type { TerminalSummary } from '../../shared/types.js';
4
3
  import type { AccountProvider, AccountStatusResponse, AccountRunningProcess, AccountRefreshStatus, AccountUsageResponse } from '../../shared/protocol.js';
4
+ import { type LoginLauncher } from './loginSession.js';
5
+ type CredentialDocument = Record<string, unknown>;
5
6
  export type AccountDiagnostic = {
6
7
  event: 'account.refresh' | 'account.refresh_scan';
7
8
  provider?: AccountProvider;
@@ -49,6 +50,11 @@ export declare class AccountService {
49
50
  private readonly paneProcessResolver;
50
51
  private readonly processTerminator;
51
52
  private readonly processSnapshotReader;
53
+ private readonly loginSessions;
54
+ private readonly loginNotices;
55
+ private readonly loginLauncher;
56
+ private readonly loginTimeoutMs;
57
+ private readonly loginExitWaitMs;
52
58
  constructor(config: AppConfig, terminalManager: TerminalManager, options?: {
53
59
  homeDir?: string;
54
60
  now?: () => Date;
@@ -59,15 +65,17 @@ export declare class AccountService {
59
65
  processSnapshot?: () => Promise<ProcessSnapshotEntry[]>;
60
66
  fetcher?: typeof fetch;
61
67
  onDiagnostic?: (event: AccountDiagnostic) => void;
68
+ loginLauncher?: LoginLauncher;
69
+ loginTimeoutMs?: number;
70
+ loginExitWaitMs?: number;
62
71
  });
63
72
  private mutate;
64
73
  synchronize(): Promise<void>;
65
74
  select(provider: AccountProvider, profileId: string): Promise<AccountStatusResponse>;
66
75
  remove(provider: AccountProvider, profileId: string): Promise<AccountStatusResponse>;
67
- beginLogin(provider: AccountProvider): Promise<{
68
- terminal: TerminalSummary;
69
- command: string;
70
- }>;
76
+ beginLogin(provider: AccountProvider): Promise<AccountStatusResponse>;
77
+ submitLoginCode(provider: AccountProvider, code: unknown): Promise<AccountStatusResponse>;
78
+ rename(provider: AccountProvider, profileId: string, label: string): Promise<AccountStatusResponse>;
71
79
  completeLogin(provider: AccountProvider, label: string): Promise<AccountStatusResponse>;
72
80
  cancelLogin(provider: AccountProvider): Promise<AccountStatusResponse>;
73
81
  refresh(provider: AccountProvider, profileId?: string): Promise<AccountStatusResponse>;
@@ -81,10 +89,16 @@ export declare class AccountService {
81
89
  private removeUnlocked;
82
90
  private beginLoginUnlocked;
83
91
  private completeLoginUnlocked;
92
+ private renameUnlocked;
84
93
  private cancelLoginUnlocked;
85
94
  private refreshUnlocked;
86
95
  start(): void;
87
96
  dispose(): void;
97
+ private loginProcessIds;
98
+ private stopLoginSession;
99
+ private expireLogin;
100
+ private expireLoginNotices;
101
+ private loginState;
88
102
  private refreshExpiringProfiles;
89
103
  private refreshAndRecord;
90
104
  private reportDiagnostic;
@@ -95,6 +109,7 @@ export declare class AccountService {
95
109
  private clearTerminationAcknowledgement;
96
110
  private listProcessSnapshot;
97
111
  private captureActiveCredentials;
112
+ private backfillAccountEmails;
98
113
  private captureActiveCredential;
99
114
  private backupLiveCredential;
100
115
  private deactivateProfile;
@@ -118,4 +133,18 @@ export declare class AccountService {
118
133
  private removeCredentialFile;
119
134
  }
120
135
  export declare function parseProcessSnapshot(stdout: string): ProcessSnapshotEntry[];
136
+ export declare function isAutoLabel(profile: {
137
+ label: string;
138
+ labelCustomized?: boolean;
139
+ }): boolean;
140
+ export declare function accountEmail(provider: AccountProvider, document: CredentialDocument): string | null;
141
+ export declare function plausibleEmail(value: unknown): string | null;
142
+ export type AccountRenameBody = {
143
+ profileId: string;
144
+ label: string;
145
+ };
146
+ export declare const ACCOUNT_LABEL_MAX_INPUT = 256;
147
+ export declare function parseAccountRenameBody(body: unknown): AccountRenameBody | {
148
+ error: string;
149
+ };
121
150
  export {};
@@ -6,6 +6,7 @@ import os from 'node:os';
6
6
  import path from 'node:path';
7
7
  import { accountReloginReason } from '../../shared/accountRelogin.js';
8
8
  import { ProviderRequestError, requestAccountUsage, requestTokenRefresh, unavailableUsage } from './providerRequests.js';
9
+ import { createPtyLoginLauncher, LOGIN_TIMEOUT_MS, LoginSessionError, ProviderLoginSession, validateLoginCode } from './loginSession.js';
9
10
  const VAULT_DIRECTORY = 'credential-vault';
10
11
  const VAULT_INDEX = 'index.json';
11
12
  const VAULT_KEY = 'key';
@@ -23,6 +24,9 @@ const PROVIDER_TERMINATE_GRACE_MS = 2_000;
23
24
  const TERMINATION_ACKNOWLEDGEMENT_TTL_MS = 5 * 60_000;
24
25
  const USAGE_CACHE_TTL_MS = 2 * 60_000;
25
26
  const USAGE_FAILURE_TTL_MS = 30_000;
27
+ const LOGIN_COMPLETE_EXIT_WAIT_MS = 5_000;
28
+ const LOGIN_NOTICE_TTL_MS = 10 * 60_000;
29
+ const LOGIN_EXPIRY_RETRY_MS = 500;
26
30
  export class AccountServiceError extends Error {
27
31
  code;
28
32
  constructor(code, message = code) {
@@ -33,7 +37,7 @@ export class AccountServiceError extends Error {
33
37
  get statusCode() {
34
38
  if (this.code === 'preview_mode')
35
39
  return 403;
36
- if (['provider_panes_running', 'account_operation_busy', 'account_credential_changed', 'account_login_already_pending', 'active_cli_refresh_deferred'].includes(this.code))
40
+ if (['provider_panes_running', 'account_operation_busy', 'account_credential_changed', 'account_login_already_pending', 'active_cli_refresh_deferred', 'account_login_code_not_expected', 'account_login_process_unavailable', 'account_login_code_attempts_exceeded'].includes(this.code))
37
41
  return 409;
38
42
  return 400;
39
43
  }
@@ -64,6 +68,12 @@ export class AccountService {
64
68
  paneProcessResolver;
65
69
  processTerminator;
66
70
  processSnapshotReader;
71
+ // In-popup sign-ins: one private PTY per provider, never a workspace terminal.
72
+ loginSessions = new Map();
73
+ loginNotices = new Map();
74
+ loginLauncher;
75
+ loginTimeoutMs;
76
+ loginExitWaitMs;
67
77
  constructor(config, terminalManager, options = {}) {
68
78
  this.config = config;
69
79
  this.terminalManager = terminalManager;
@@ -75,13 +85,17 @@ export class AccountService {
75
85
  this.now = options.now ?? (() => new Date());
76
86
  this.fetcher = options.fetcher ?? ((...args) => fetch(...args));
77
87
  this.onDiagnostic = options.onDiagnostic ?? (() => undefined);
78
- this.processLister = options.processLister ?? (async (provider) => providerProcesses(provider, await this.listProcessSnapshot()));
88
+ // The in-popup login CLI is NTerminal's own child, not a user session.
89
+ this.processLister = options.processLister ?? (async (provider) => providerProcesses(provider, withoutProcessTrees(await this.listProcessSnapshot(), this.loginProcessIds())));
79
90
  this.paneProcessResolver = options.paneProcessResolver ?? (options.processLister
80
91
  ? async () => new Map()
81
92
  : async () => resolvePaneProcesses(this.config.tmuxSocket, await this.listProcessSnapshot()));
82
93
  this.processProbe = options.processProbe ?? (async (provider) => (await this.processLister(provider)).length > 0);
83
- this.processTerminator = options.processTerminator ?? terminateProviderProcesses;
94
+ this.processTerminator = options.processTerminator ?? ((provider, processes) => terminateProviderProcesses(provider, processes, this.loginProcessIds()));
84
95
  this.processSnapshotReader = options.processSnapshot ?? listUserProcesses;
96
+ this.loginLauncher = options.loginLauncher ?? createPtyLoginLauncher();
97
+ this.loginTimeoutMs = options.loginTimeoutMs ?? LOGIN_TIMEOUT_MS;
98
+ this.loginExitWaitMs = options.loginExitWaitMs ?? LOGIN_COMPLETE_EXIT_WAIT_MS;
85
99
  }
86
100
  // Reject overlapping mutations instead of accumulating requests behind OAuth I/O.
87
101
  async mutate(operation) {
@@ -112,6 +126,37 @@ export class AccountService {
112
126
  beginLogin(provider) {
113
127
  return this.mutate(() => this.beginLoginUnlocked(provider));
114
128
  }
129
+ // Forwards a pasted Claude authorization code to the login CLI. Only a
130
+ // strictly shaped `code#state` value is written, followed by Enter.
131
+ async submitLoginCode(provider, code) {
132
+ if (this.config.previewMode)
133
+ throw new AccountServiceError('preview_mode');
134
+ if (this.disposed)
135
+ throw new AccountServiceError('account_operation_busy');
136
+ const valid = validateLoginCode(provider, code);
137
+ if (!valid)
138
+ throw new AccountServiceError(provider === 'claude' ? 'account_login_code_invalid' : 'account_login_code_not_expected');
139
+ const index = await this.readIndex();
140
+ if (!index.pending[provider])
141
+ throw new AccountServiceError('account_login_not_pending');
142
+ const session = this.loginSessions.get(provider);
143
+ if (!session)
144
+ throw new AccountServiceError('account_login_process_unavailable');
145
+ try {
146
+ session.submitCode(valid);
147
+ }
148
+ catch (error) {
149
+ if (error instanceof LoginSessionError)
150
+ throw new AccountServiceError(error.code);
151
+ throw error;
152
+ }
153
+ return this.status();
154
+ }
155
+ // A non-blank label becomes the profile's custom name. A blank label resets
156
+ // it to the account email (account_label_required if none is known).
157
+ rename(provider, profileId, label) {
158
+ return this.mutate(() => this.renameUnlocked(provider, profileId, label));
159
+ }
115
160
  completeLogin(provider, label) {
116
161
  return this.mutate(() => this.completeLoginUnlocked(provider, label));
117
162
  }
@@ -227,10 +272,12 @@ export class AccountService {
227
272
  }));
228
273
  const pendingReady = new Map();
229
274
  await Promise.all(['codex', 'claude'].map(async (provider) => {
230
- pendingReady.set(provider, Boolean(refreshed.pending[provider] && await this.readCredentialFile(provider).catch(() => null)));
275
+ const credentials = refreshed.pending[provider] ? await this.readCredentialFile(provider).catch(() => null) : null;
276
+ pendingReady.set(provider, { ready: Boolean(credentials), suggestedLabel: credentials ? suggestedLabel(refreshed, provider, credentials) : null });
231
277
  }));
278
+ this.expireLoginNotices();
232
279
  return {
233
- providers: ['codex', 'claude'].map((provider) => this.providerStatus(refreshed, provider, running, processes.get(provider) ?? [], processCheckUnavailable, pendingReady.get(provider) === true))
280
+ providers: ['codex', 'claude'].map((provider) => this.providerStatus(refreshed, provider, running, processes.get(provider) ?? [], processCheckUnavailable, pendingReady.get(provider) ?? { ready: false, suggestedLabel: null }))
234
281
  };
235
282
  }
236
283
  // Providers with at least one saved login that needs a new sign-in. Cheap
@@ -305,28 +352,36 @@ export class AccountService {
305
352
  index.pending[provider] = {
306
353
  provider,
307
354
  previousProfileId: index.active[provider] ?? null,
308
- startedAt: this.now().toISOString(),
309
- terminalId: null
355
+ startedAt: this.now().toISOString()
310
356
  };
311
357
  delete index.active[provider];
312
358
  await this.writeIndex(index);
313
- let terminal;
359
+ let session;
314
360
  try {
361
+ // `codex login` revokes the tokens of an auth.json it finds, so the CLI
362
+ // must start only after the archived live file is gone.
315
363
  if (current)
316
364
  await this.removeCredentialFile(provider);
317
- terminal = await this.terminalManager.createTerminal({ cwd: this.config.workspaceRoot, cols: 100, rows: 30 });
318
- const command = provider === 'codex' ? 'codex login --device-auth' : 'claude auth login';
319
- const wrote = await this.terminalManager.writeToTerminal(terminal.id, `${command}\r`);
320
- if (!wrote)
321
- throw new AccountServiceError('account_login_terminal_unavailable');
322
- index.pending[provider].terminalId = terminal.id;
323
- await this.writeIndex(index);
365
+ await this.stopLoginSession(provider);
366
+ let launched;
367
+ try {
368
+ launched = this.loginLauncher(provider, { homeDir: this.homeDir });
369
+ }
370
+ catch {
371
+ throw new AccountServiceError('account_login_process_unavailable');
372
+ }
373
+ session = new ProviderLoginSession(provider, launched, () => this.now().getTime(), this.loginTimeoutMs, (expired) => void this.expireLogin(provider, expired));
374
+ this.loginSessions.set(provider, session);
375
+ this.loginNotices.delete(provider);
324
376
  this.clearTerminationAcknowledgement(provider);
325
- return { terminal, command };
377
+ return this.status();
326
378
  }
327
379
  catch (error) {
328
- if (terminal)
329
- await this.terminalManager.closeTerminal(terminal.id);
380
+ if (session) {
381
+ if (this.loginSessions.get(provider) === session)
382
+ this.loginSessions.delete(provider);
383
+ await session.terminate();
384
+ }
330
385
  if (current)
331
386
  await this.writeCredentialFile(provider, current);
332
387
  if (index.active[provider] === undefined && index.pending[provider]?.previousProfileId) {
@@ -342,6 +397,12 @@ export class AccountService {
342
397
  const pending = index.pending[provider];
343
398
  if (!pending)
344
399
  throw new AccountServiceError('account_login_not_pending');
400
+ if (!await this.readCredentialFile(provider))
401
+ throw new AccountServiceError('account_login_not_complete');
402
+ // Let the CLI finish its own post-login writes before the file is captured.
403
+ const session = this.loginSessions.get(provider);
404
+ if (session?.running && !await session.waitForExit(this.loginExitWaitMs))
405
+ await session.terminate();
345
406
  const credentials = await this.readCredentialFile(provider);
346
407
  if (!credentials)
347
408
  throw new AccountServiceError('account_login_not_complete');
@@ -352,10 +413,33 @@ export class AccountService {
352
413
  delete index.pending[provider];
353
414
  clearExternalSwitch(index, provider);
354
415
  await this.writeIndex(index);
416
+ if (this.loginSessions.get(provider) === session)
417
+ this.loginSessions.delete(provider);
355
418
  this.clearTerminationAcknowledgement(provider);
356
419
  return this.status();
357
420
  }
421
+ async renameUnlocked(provider, profileId, label) {
422
+ const index = await this.readIndex();
423
+ const profile = index.profiles.find((candidate) => candidate.id === profileId && candidate.provider === provider);
424
+ if (!profile)
425
+ throw new AccountServiceError('account_not_found');
426
+ if (label.trim()) {
427
+ profile.label = sanitizeLabel(label);
428
+ profile.labelCustomized = true;
429
+ }
430
+ else {
431
+ const email = typeof profile.email === 'string' ? profile.email : null;
432
+ if (!email)
433
+ throw new AccountServiceError('account_label_required');
434
+ profile.label = sanitizeLabel(email);
435
+ delete profile.labelCustomized;
436
+ }
437
+ await this.writeIndex(index);
438
+ return this.status();
439
+ }
358
440
  async cancelLoginUnlocked(provider) {
441
+ // Stop the CLI first so it cannot write a credential after the restore.
442
+ await this.stopLoginSession(provider);
359
443
  const index = await this.readIndex();
360
444
  const pending = index.pending[provider];
361
445
  if (!pending)
@@ -395,6 +479,68 @@ export class AccountService {
395
479
  if (this.refreshTimer)
396
480
  clearInterval(this.refreshTimer);
397
481
  this.refreshTimer = null;
482
+ // The pending entry survives in the index; after a restart the dialog
483
+ // reports the missing login process and offers to cancel and restore.
484
+ for (const session of this.loginSessions.values())
485
+ session.dispose();
486
+ this.loginSessions.clear();
487
+ }
488
+ loginProcessIds() {
489
+ return [...this.loginSessions.values()].filter((session) => session.running).map((session) => session.pid);
490
+ }
491
+ async stopLoginSession(provider) {
492
+ const session = this.loginSessions.get(provider);
493
+ if (!session)
494
+ return;
495
+ this.loginSessions.delete(provider);
496
+ await session.terminate();
497
+ }
498
+ // Timeout: stop the CLI and restore the previous credential through the
499
+ // normal cancel path, retrying while another account operation holds the lock.
500
+ async expireLogin(provider, session) {
501
+ await session.terminate();
502
+ for (let attempt = 0; attempt < 120 && !this.disposed; attempt += 1) {
503
+ if (this.loginSessions.get(provider) !== session)
504
+ return;
505
+ try {
506
+ await this.cancelLogin(provider);
507
+ this.loginNotices.set(provider, { code: 'login_timed_out', at: this.now().toISOString() });
508
+ return;
509
+ }
510
+ catch (error) {
511
+ if (!(error instanceof AccountServiceError) || error.code !== 'account_operation_busy')
512
+ return;
513
+ await sleep(LOGIN_EXPIRY_RETRY_MS);
514
+ }
515
+ }
516
+ }
517
+ expireLoginNotices() {
518
+ for (const [provider, notice] of this.loginNotices) {
519
+ if (this.now().getTime() - Date.parse(notice.at) > LOGIN_NOTICE_TTL_MS)
520
+ this.loginNotices.delete(provider);
521
+ }
522
+ }
523
+ loginState(provider, ready) {
524
+ const session = this.loginSessions.get(provider);
525
+ if (!session) {
526
+ return ready ? { phase: 'done' } : {
527
+ phase: 'failed',
528
+ message: 'The sign-in process is no longer running (NTerminal may have restarted). Cancel to restore the previous account, then try again.'
529
+ };
530
+ }
531
+ const state = session.state();
532
+ if (ready) {
533
+ // The credential exists; wait for the CLI to report success or exit.
534
+ if (session.succeeded || !session.running)
535
+ return { phase: 'done' };
536
+ return { ...state, phase: 'completing' };
537
+ }
538
+ if (state.phase === 'done') {
539
+ return session.running ? { ...state, phase: 'completing' } : {
540
+ ...state, phase: 'failed', message: 'The CLI reported success, but no credential file was written. Cancel and try again.'
541
+ };
542
+ }
543
+ return state;
398
544
  }
399
545
  async refreshExpiringProfiles() {
400
546
  if (this.refreshInFlight || this.disposed || this.config.previewMode || this.operationInFlight)
@@ -483,7 +629,8 @@ export class AccountService {
483
629
  }
484
630
  catch { /* Logging cannot interrupt token storage. */ }
485
631
  }
486
- providerStatus(index, provider, running, runningProcesses, processCheckUnavailable, pendingReady) {
632
+ providerStatus(index, provider, running, runningProcesses, processCheckUnavailable, pendingCredential) {
633
+ const pendingReady = pendingCredential.ready;
487
634
  const activeId = index.active[provider] ?? null;
488
635
  const profile = activeId ? index.profiles.find((candidate) => candidate.id === activeId) ?? null : null;
489
636
  const pending = index.pending[provider];
@@ -495,7 +642,11 @@ export class AccountService {
495
642
  runningProcesses,
496
643
  processCheckUnavailable,
497
644
  terminationAcknowledged: this.hasTerminationAcknowledgement(provider),
498
- pendingLogin: pending ? { startedAt: pending.startedAt, ready: pendingReady, terminalId: pending.terminalId ?? null } : null,
645
+ pendingLogin: pending ? {
646
+ startedAt: pending.startedAt, ready: pendingReady, login: this.loginState(provider, pendingReady),
647
+ ...(pendingCredential.suggestedLabel ? { suggestedLabel: pendingCredential.suggestedLabel } : {})
648
+ } : null,
649
+ loginNotice: pending ? null : this.loginNotices.get(provider) ?? null,
499
650
  lastExternalSwitch: index.externalSwitch?.[provider] ?? null,
500
651
  credentialPresent: Boolean(profile || pending || false)
501
652
  };
@@ -557,6 +708,7 @@ export class AccountService {
557
708
  continue;
558
709
  try {
559
710
  await this.captureActiveCredential(index, provider);
711
+ await this.backfillAccountEmails(index, provider);
560
712
  this.diagnosticSignatures.delete(`account.refresh_scan:${provider}:`);
561
713
  }
562
714
  catch (error) {
@@ -568,6 +720,17 @@ export class AccountService {
568
720
  if (JSON.stringify(index) !== before)
569
721
  await this.writeIndex(index);
570
722
  }
723
+ // Profiles saved before emails were recorded: read each vault copy once.
724
+ // An unreadable copy stays unchecked and is retried on a later scan.
725
+ async backfillAccountEmails(index, provider) {
726
+ for (const profile of index.profiles) {
727
+ if (profile.provider !== provider || profile.email !== undefined)
728
+ continue;
729
+ const credentials = await this.readVaultCredentials(profile.id).catch(() => null);
730
+ if (credentials)
731
+ applyAccountIdentity(profile, credentials);
732
+ }
733
+ }
571
734
  // The live CLI file is the source of truth for the active account. Every
572
735
  // newer live credential NTerminal observes is also copied into the active
573
736
  // profile's encrypted vault backup, so an external overwrite (another login,
@@ -596,6 +759,8 @@ export class AccountService {
596
759
  // Vaults written before active backups existed: create it once.
597
760
  await this.writeVaultCredentials(existing.id, credentials);
598
761
  }
762
+ if (existing.email === undefined)
763
+ applyAccountIdentity(existing, credentials);
599
764
  return;
600
765
  }
601
766
  // The live file now holds a different account than the index says.
@@ -678,19 +843,22 @@ export class AccountService {
678
843
  await this.updateProfile(profile, provider, kept);
679
844
  return credentials;
680
845
  }
681
- // A blank label only names new profiles; re-authenticating an existing
682
- // account keeps the label the user gave it.
846
+ // A blank label only names new profiles (the account email when known);
847
+ // re-authenticating an existing account keeps the label the user gave it.
683
848
  async storeProfile(index, provider, credentials, label = '') {
684
849
  const fingerprint = fingerprintFor(provider, credentials);
685
850
  const existing = index.profiles.find((profile) => profile.provider === provider && profile.fingerprint === fingerprint);
686
851
  const now = this.now().toISOString();
687
852
  const timing = credentialTiming(provider, credentials);
688
853
  const profile = existing ?? {
689
- id: randomUUID(), provider, label: sanitizeLabel(label.trim() || defaultLabel(provider, index.profiles)), createdAt: now, updatedAt: now,
854
+ id: randomUUID(), provider, label: sanitizeLabel(accountEmail(provider, credentials) ?? defaultLabel(provider, index.profiles)), createdAt: now, updatedAt: now,
690
855
  expiresAt: timing.expiresAt, refreshTokenExpiresAt: timing.refreshTokenExpiresAt, fingerprint
691
856
  };
692
- if (label.trim())
857
+ if (label.trim()) {
693
858
  profile.label = sanitizeLabel(label);
859
+ profile.labelCustomized = true;
860
+ }
861
+ applyAccountIdentity(profile, credentials);
694
862
  profile.updatedAt = now;
695
863
  profile.expiresAt = timing.expiresAt;
696
864
  profile.refreshTokenExpiresAt = timing.refreshTokenExpiresAt;
@@ -708,6 +876,7 @@ export class AccountService {
708
876
  profile.expiresAt = timing.expiresAt;
709
877
  profile.refreshTokenExpiresAt = timing.refreshTokenExpiresAt;
710
878
  profile.credentialHash = hashCredential(credentials);
879
+ applyAccountIdentity(profile, credentials);
711
880
  delete profile.refresh;
712
881
  this.retryAfter.delete(profile.id);
713
882
  this.usageCache.delete(profile.id);
@@ -1043,8 +1212,8 @@ function displayProcessCommand(command) {
1043
1212
  const basename = path.basename(command);
1044
1213
  return basename.length <= 80 ? basename : basename.slice(0, 80);
1045
1214
  }
1046
- async function terminateProviderProcesses(provider, _processes) {
1047
- const snapshot = await listUserProcesses();
1215
+ async function terminateProviderProcesses(provider, _processes, excludedRoots = []) {
1216
+ const snapshot = withoutProcessTrees(await listUserProcesses(), excludedRoots);
1048
1217
  const daemons = provider === 'codex' ? snapshot.filter(isCodexDaemon) : [];
1049
1218
  // Shared daemon descendants include work for multiple TUI clients. Stop the
1050
1219
  // interactive clients first, then let the daemon's lifecycle command stop it.
@@ -1062,7 +1231,7 @@ async function terminateProviderProcesses(provider, _processes) {
1062
1231
  signalProcess(pid, 'SIGTERM');
1063
1232
  if (targetPids.length > 0) {
1064
1233
  await sleep(PROVIDER_TERMINATE_GRACE_MS);
1065
- const survivors = new Set(providerProcessTree(provider, await listUserProcesses()));
1234
+ const survivors = new Set(providerProcessTree(provider, withoutProcessTrees(await listUserProcesses(), excludedRoots)));
1066
1235
  for (const pid of targetPids)
1067
1236
  if (survivors.has(pid))
1068
1237
  signalProcess(pid, 'SIGKILL');
@@ -1085,6 +1254,21 @@ async function stopCodexDaemon(daemons) {
1085
1254
  }
1086
1255
  throw new AccountServiceError('codex_daemon_stop_failed');
1087
1256
  }
1257
+ // Drops the given processes and all their descendants from a snapshot.
1258
+ function withoutProcessTrees(entries, roots) {
1259
+ if (roots.length === 0)
1260
+ return entries;
1261
+ const excluded = new Set(roots);
1262
+ for (let changed = true; changed;) {
1263
+ changed = false;
1264
+ for (const entry of entries)
1265
+ if (excluded.has(entry.ppid) && !excluded.has(entry.pid)) {
1266
+ excluded.add(entry.pid);
1267
+ changed = true;
1268
+ }
1269
+ }
1270
+ return entries.filter((entry) => !excluded.has(entry.pid));
1271
+ }
1088
1272
  function daemonDescendants(entries) {
1089
1273
  const parents = new Map(entries.map((entry) => [entry.pid, entry.ppid]));
1090
1274
  const daemons = new Set(entries.filter(isCodexDaemon).map((entry) => entry.pid));
@@ -1168,7 +1352,82 @@ function isOlderCredential(provider, candidate, reference) {
1168
1352
  return Number.isFinite(a) && Number.isFinite(b) && a < b;
1169
1353
  }
1170
1354
  function toPublicProfile(profile) {
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 } : {}) };
1355
+ const email = typeof profile.email === 'string' ? plausibleEmail(profile.email) : null;
1356
+ return { id: profile.id, provider: profile.provider, label: profile.label, ...(email ? { email } : {}), createdAt: profile.createdAt, updatedAt: profile.updatedAt, expiresAt: profile.expiresAt, refreshTokenExpiresAt: profile.refreshTokenExpiresAt, ...(profile.refresh ? { refresh: profile.refresh } : {}) };
1357
+ }
1358
+ // Labels NTerminal generated before account emails were known. Only these
1359
+ // (and never a label the user chose) follow the account email.
1360
+ const AUTO_LABEL = /^(Codex|Claude) account \d+$/;
1361
+ export function isAutoLabel(profile) {
1362
+ return profile.labelCustomized !== true && AUTO_LABEL.test(profile.label);
1363
+ }
1364
+ // Records the credential's account email and upgrades an uncustomized auto
1365
+ // label to it. A custom label is never overwritten.
1366
+ function applyAccountIdentity(profile, credentials) {
1367
+ const email = accountEmail(profile.provider, credentials);
1368
+ profile.email = email;
1369
+ if (email && isAutoLabel(profile))
1370
+ profile.label = sanitizeLabel(email);
1371
+ }
1372
+ // The name the login form suggests once the credential exists: the saved name
1373
+ // of a re-authenticated account (a blank name keeps it), else the email.
1374
+ function suggestedLabel(index, provider, credentials) {
1375
+ const email = accountEmail(provider, credentials);
1376
+ const fingerprint = fingerprintFor(provider, credentials);
1377
+ const existing = index.profiles.find((profile) => profile.provider === provider && profile.fingerprint === fingerprint);
1378
+ if (existing)
1379
+ return email && isAutoLabel(existing) ? sanitizeLabel(email) : existing.label;
1380
+ return email ? sanitizeLabel(email) : null;
1381
+ }
1382
+ // Display-only: the signed-in account's email from the credential document.
1383
+ // JWT signatures are not verified (nothing is authorized with this value) and
1384
+ // malformed input yields null. Never log the result.
1385
+ export function accountEmail(provider, document) {
1386
+ if (!isRecord(document))
1387
+ return null;
1388
+ if (provider === 'claude') {
1389
+ const metadata = isRecord(document.nterminalAccountMetadata) ? document.nterminalAccountMetadata : {};
1390
+ return plausibleEmail(metadata.emailAddress);
1391
+ }
1392
+ const tokens = isRecord(document.tokens) ? document.tokens : {};
1393
+ const idToken = decodeJwtPayload(tokens.id_token);
1394
+ const accessToken = decodeJwtPayload(tokens.access_token);
1395
+ const profileClaim = (payload) => payload && isRecord(payload['https://api.openai.com/profile']) ? payload['https://api.openai.com/profile'].email : null;
1396
+ return plausibleEmail(idToken?.email) ?? plausibleEmail(profileClaim(idToken)) ?? plausibleEmail(profileClaim(accessToken)) ?? plausibleEmail(accessToken?.email);
1397
+ }
1398
+ export function plausibleEmail(value) {
1399
+ if (typeof value !== 'string' || value.length > 254)
1400
+ return null;
1401
+ // Controls, format characters (bidi overrides, zero-width) and whitespace
1402
+ // could disguise the displayed name.
1403
+ if (/[\p{Cc}\p{Cf}\p{Z}\s]/u.test(value))
1404
+ return null;
1405
+ const match = /^([^@]{1,64})@([^@.]+(?:\.[^@.]+)+)$/.exec(value);
1406
+ return match ? value : null;
1407
+ }
1408
+ function decodeJwtPayload(value) {
1409
+ if (typeof value !== 'string' || value.length > 64 * 1024)
1410
+ return null;
1411
+ const parts = value.split('.');
1412
+ if (parts.length < 2 || !parts[1] || !/^[A-Za-z0-9_-]+={0,2}$/.test(parts[1]))
1413
+ return null;
1414
+ try {
1415
+ const decoded = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
1416
+ return isRecord(decoded) ? decoded : null;
1417
+ }
1418
+ catch {
1419
+ return null;
1420
+ }
1421
+ }
1422
+ export const ACCOUNT_LABEL_MAX_INPUT = 256;
1423
+ // Shared by the local, agent and proxy routes: only these two fields pass.
1424
+ export function parseAccountRenameBody(body) {
1425
+ const value = isRecord(body) ? body : {};
1426
+ if (typeof value.profileId !== 'string' || !value.profileId || value.profileId.length > 128)
1427
+ return { error: 'profile_id_required' };
1428
+ if (typeof value.label !== 'string' || value.label.length > ACCOUNT_LABEL_MAX_INPUT)
1429
+ return { error: 'account_label_invalid' };
1430
+ return { profileId: value.profileId, label: value.label };
1172
1431
  }
1173
1432
  function shouldRefreshProfile(profile, now) {
1174
1433
  if (profile.refresh?.state === 'reauth_required')
@@ -1254,7 +1513,7 @@ function applyRefresh(provider, document, response, now) {
1254
1513
  function defaultLabel(provider, profiles) {
1255
1514
  return `${provider === 'codex' ? 'Codex' : 'Claude'} account ${profiles.filter((profile) => profile.provider === provider).length + 1}`;
1256
1515
  }
1257
- function sanitizeLabel(value) { return value.trim().replace(/\s+/g, ' ').slice(0, 80) || 'Account'; }
1516
+ function sanitizeLabel(value) { return value.replace(/[\p{Cc}\u200e\u200f\u202a-\u202e\u2066-\u2069]/gu, ' ').trim().replace(/\s+/g, ' ').slice(0, 80).trim() || 'Account'; }
1258
1517
  function fingerprintFor(provider, value) {
1259
1518
  const identity = stableIdentity(provider, value);
1260
1519
  return createHash('sha256').update(identity ?? JSON.stringify(value)).digest('hex');