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.
- package/README.md +8 -8
- package/dist/client/assets/MarkdownPreview-8vEkrQtk.js +1 -0
- package/dist/client/assets/{TranscriptMarkdownBody-Dd6cp7t2.js → TranscriptMarkdownBody-BAkL6ovK.js} +1 -1
- package/dist/client/assets/{index-CWRDvfyP.css → index-D1X7cDQ9.css} +1 -1
- package/dist/client/assets/{index-DJwOCZv_.js → index-DLFQwECZ.js} +1 -1
- package/dist/client/assets/index-WSS03d_S.js +59 -0
- package/dist/client/index.html +2 -2
- package/dist/server/accounts/accountService.d.ts +34 -5
- package/dist/server/accounts/accountService.js +288 -29
- package/dist/server/accounts/accountService.js.map +1 -1
- package/dist/server/accounts/loginSession.d.ts +67 -0
- package/dist/server/accounts/loginSession.js +333 -0
- package/dist/server/accounts/loginSession.js.map +1 -0
- package/dist/server/agent/agentRoutes.js +21 -2
- package/dist/server/agent/agentRoutes.js.map +1 -1
- package/dist/server/routes/accountRoutes.js +30 -1
- package/dist/server/routes/accountRoutes.js.map +1 -1
- package/dist/server/routes/agentManagementRoutes.js +18 -5
- package/dist/server/routes/agentManagementRoutes.js.map +1 -1
- package/dist/shared/accountLogin.d.ts +3 -0
- package/dist/shared/accountLogin.js +47 -0
- package/dist/shared/accountLogin.js.map +1 -0
- package/dist/shared/protocol.d.ts +22 -5
- package/dist/shared/protocol.js.map +1 -1
- package/docs/features.md +3 -0
- package/package.json +2 -1
- package/dist/client/assets/MarkdownPreview-D3Oa7_1H.js +0 -1
- package/dist/client/assets/index-BOvp6zV9.js +0 -58
package/dist/client/index.html
CHANGED
|
@@ -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-
|
|
16
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
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
|
-
|
|
69
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
|
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
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
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
|
|
377
|
+
return this.status();
|
|
326
378
|
}
|
|
327
379
|
catch (error) {
|
|
328
|
-
if (
|
|
329
|
-
|
|
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,
|
|
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 ? {
|
|
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
|
|
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(
|
|
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
|
-
|
|
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');
|