nterminal 1.3.7 → 1.3.9
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 +1 -1
- package/dist/client/assets/MarkdownPreview-OPnFTPId.js +1 -0
- package/dist/client/assets/{TranscriptMarkdownBody-CWvFo7OT.js → TranscriptMarkdownBody-CwtPlXXA.js} +1 -1
- package/dist/client/assets/{index-Cc-QI7Ho.js → index-BeEACkVn.js} +1 -1
- package/dist/client/assets/index-CFEvEJOV.css +1 -0
- package/dist/client/assets/index-D7fYqfyY.js +59 -0
- package/dist/client/index.html +2 -2
- package/dist/server/accounts/accountService.d.ts +21 -7
- package/dist/server/accounts/accountService.js +329 -69
- 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 +13 -3
- package/dist/server/agent/agentRoutes.js.map +1 -1
- package/dist/server/routes/accountRoutes.js +15 -0
- package/dist/server/routes/accountRoutes.js.map +1 -1
- package/dist/server/routes/agentManagementRoutes.d.ts +2 -0
- package/dist/server/routes/agentManagementRoutes.js +15 -7
- package/dist/server/routes/agentManagementRoutes.js.map +1 -1
- package/dist/server/system/stats.d.ts +4 -1
- package/dist/server/system/stats.js +6 -0
- package/dist/server/system/stats.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/accountRelogin.d.ts +3 -0
- package/dist/shared/accountRelogin.js +21 -0
- package/dist/shared/accountRelogin.js.map +1 -0
- package/dist/shared/protocol.d.ts +27 -5
- package/dist/shared/protocol.js.map +1 -1
- package/docs/features.md +5 -2
- package/package.json +1 -1
- package/dist/client/assets/MarkdownPreview-Bx3a_UAM.js +0 -1
- package/dist/client/assets/index-BdHPdvab.js +0 -58
- package/dist/client/assets/index-W-zGfY1a.css +0 -1
|
@@ -4,7 +4,9 @@ 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';
|
|
9
|
+
import { createPtyLoginLauncher, LOGIN_TIMEOUT_MS, LoginSessionError, ProviderLoginSession, validateLoginCode } from './loginSession.js';
|
|
8
10
|
const VAULT_DIRECTORY = 'credential-vault';
|
|
9
11
|
const VAULT_INDEX = 'index.json';
|
|
10
12
|
const VAULT_KEY = 'key';
|
|
@@ -22,6 +24,9 @@ const PROVIDER_TERMINATE_GRACE_MS = 2_000;
|
|
|
22
24
|
const TERMINATION_ACKNOWLEDGEMENT_TTL_MS = 5 * 60_000;
|
|
23
25
|
const USAGE_CACHE_TTL_MS = 2 * 60_000;
|
|
24
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;
|
|
25
30
|
export class AccountServiceError extends Error {
|
|
26
31
|
code;
|
|
27
32
|
constructor(code, message = code) {
|
|
@@ -32,7 +37,7 @@ export class AccountServiceError extends Error {
|
|
|
32
37
|
get statusCode() {
|
|
33
38
|
if (this.code === 'preview_mode')
|
|
34
39
|
return 403;
|
|
35
|
-
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))
|
|
36
41
|
return 409;
|
|
37
42
|
return 400;
|
|
38
43
|
}
|
|
@@ -63,6 +68,12 @@ export class AccountService {
|
|
|
63
68
|
paneProcessResolver;
|
|
64
69
|
processTerminator;
|
|
65
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;
|
|
66
77
|
constructor(config, terminalManager, options = {}) {
|
|
67
78
|
this.config = config;
|
|
68
79
|
this.terminalManager = terminalManager;
|
|
@@ -74,13 +85,17 @@ export class AccountService {
|
|
|
74
85
|
this.now = options.now ?? (() => new Date());
|
|
75
86
|
this.fetcher = options.fetcher ?? ((...args) => fetch(...args));
|
|
76
87
|
this.onDiagnostic = options.onDiagnostic ?? (() => undefined);
|
|
77
|
-
|
|
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())));
|
|
78
90
|
this.paneProcessResolver = options.paneProcessResolver ?? (options.processLister
|
|
79
91
|
? async () => new Map()
|
|
80
92
|
: async () => resolvePaneProcesses(this.config.tmuxSocket, await this.listProcessSnapshot()));
|
|
81
93
|
this.processProbe = options.processProbe ?? (async (provider) => (await this.processLister(provider)).length > 0);
|
|
82
|
-
this.processTerminator = options.processTerminator ?? terminateProviderProcesses;
|
|
94
|
+
this.processTerminator = options.processTerminator ?? ((provider, processes) => terminateProviderProcesses(provider, processes, this.loginProcessIds()));
|
|
83
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;
|
|
84
99
|
}
|
|
85
100
|
// Reject overlapping mutations instead of accumulating requests behind OAuth I/O.
|
|
86
101
|
async mutate(operation) {
|
|
@@ -111,6 +126,32 @@ export class AccountService {
|
|
|
111
126
|
beginLogin(provider) {
|
|
112
127
|
return this.mutate(() => this.beginLoginUnlocked(provider));
|
|
113
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
|
+
}
|
|
114
155
|
completeLogin(provider, label) {
|
|
115
156
|
return this.mutate(() => this.completeLoginUnlocked(provider, label));
|
|
116
157
|
}
|
|
@@ -210,7 +251,12 @@ export class AccountService {
|
|
|
210
251
|
await Promise.all(['codex', 'claude'].map(async (provider) => {
|
|
211
252
|
try {
|
|
212
253
|
const representedPanes = new Set(running.filter((pane) => pane.provider === provider).map((pane) => pane.terminalId));
|
|
213
|
-
|
|
254
|
+
const listed = await this.processLister(provider);
|
|
255
|
+
const daemonHelpers = daemonDescendants(listed);
|
|
256
|
+
processes.set(provider, listed
|
|
257
|
+
// Helpers the shared daemon spawns (e.g. codex-code-mode-host) are part
|
|
258
|
+
// of the daemon entry, not separate sessions.
|
|
259
|
+
.filter((entry) => !daemonHelpers.has(entry.pid))
|
|
214
260
|
.filter((entry) => isCodexDaemon(entry) || !representedPanes.has(paneProcesses.get(entry.pid) ?? ''))
|
|
215
261
|
.map((entry) => ({ pid: entry.pid, provider, command: entry.command, ...(isCodexDaemon(entry) ? { role: 'daemon' } : {}) })));
|
|
216
262
|
}
|
|
@@ -223,13 +269,20 @@ export class AccountService {
|
|
|
223
269
|
await Promise.all(['codex', 'claude'].map(async (provider) => {
|
|
224
270
|
pendingReady.set(provider, Boolean(refreshed.pending[provider] && await this.readCredentialFile(provider).catch(() => null)));
|
|
225
271
|
}));
|
|
272
|
+
this.expireLoginNotices();
|
|
226
273
|
return {
|
|
227
274
|
providers: ['codex', 'claude'].map((provider) => this.providerStatus(refreshed, provider, running, processes.get(provider) ?? [], processCheckUnavailable, pendingReady.get(provider) === true))
|
|
228
275
|
};
|
|
229
276
|
}
|
|
277
|
+
// Providers with at least one saved login that needs a new sign-in. Cheap
|
|
278
|
+
// (index metadata only), so the server list can poll it with system stats.
|
|
279
|
+
async reloginRequired() {
|
|
280
|
+
const index = await this.readIndex();
|
|
281
|
+
const now = this.now().getTime();
|
|
282
|
+
return ['codex', 'claude'].filter((provider) => index.profiles.some((profile) => profile.provider === provider && accountReloginReason(profile, now) !== null));
|
|
283
|
+
}
|
|
230
284
|
async selectUnlocked(provider, profileId) {
|
|
231
285
|
const index = await this.readIndex();
|
|
232
|
-
await this.assertNoRunningPanes(provider, { allowTerminationAcknowledgement: true });
|
|
233
286
|
if (index.pending[provider])
|
|
234
287
|
throw new AccountServiceError('account_login_already_pending');
|
|
235
288
|
await this.captureActiveCredentials(index, provider);
|
|
@@ -237,6 +290,8 @@ export class AccountService {
|
|
|
237
290
|
if (!profile)
|
|
238
291
|
throw new AccountServiceError('account_not_found');
|
|
239
292
|
if (index.active[provider] === profile.id) {
|
|
293
|
+
if (clearExternalSwitch(index, provider))
|
|
294
|
+
await this.writeIndex(index);
|
|
240
295
|
this.clearTerminationAcknowledgement(provider);
|
|
241
296
|
return this.status();
|
|
242
297
|
}
|
|
@@ -246,6 +301,7 @@ export class AccountService {
|
|
|
246
301
|
try {
|
|
247
302
|
await this.writeCredentialFile(provider, credentials);
|
|
248
303
|
index.active[provider] = profile.id;
|
|
304
|
+
clearExternalSwitch(index, provider);
|
|
249
305
|
await this.writeIndex(index);
|
|
250
306
|
}
|
|
251
307
|
catch (error) {
|
|
@@ -253,11 +309,10 @@ export class AccountService {
|
|
|
253
309
|
await this.writeCredentialFile(provider, current);
|
|
254
310
|
else
|
|
255
311
|
await this.removeCredentialFile(provider);
|
|
256
|
-
if (previousId)
|
|
257
|
-
await rm(this.profilePath(previousId), { force: true });
|
|
258
312
|
throw error;
|
|
259
313
|
}
|
|
260
|
-
|
|
314
|
+
// The selected profile's vault copy is exactly what was just published, so
|
|
315
|
+
// it already is the active backup. The previous account kept its archive.
|
|
261
316
|
this.clearTerminationAcknowledgement(provider);
|
|
262
317
|
return this.status();
|
|
263
318
|
}
|
|
@@ -273,6 +328,8 @@ export class AccountService {
|
|
|
273
328
|
throw new AccountServiceError('cannot_remove_active_account');
|
|
274
329
|
}
|
|
275
330
|
index.profiles = index.profiles.filter((candidate) => candidate.id !== profile.id);
|
|
331
|
+
if (index.externalSwitch?.[provider]?.fromProfileId === profile.id)
|
|
332
|
+
clearExternalSwitch(index, provider);
|
|
276
333
|
await this.writeIndex(index);
|
|
277
334
|
await rm(this.profilePath(profile.id), { force: true });
|
|
278
335
|
this.retryAfter.delete(profile.id);
|
|
@@ -282,7 +339,6 @@ export class AccountService {
|
|
|
282
339
|
}
|
|
283
340
|
async beginLoginUnlocked(provider) {
|
|
284
341
|
const index = await this.readIndex();
|
|
285
|
-
await this.assertNoRunningPanes(provider, { allowTerminationAcknowledgement: true });
|
|
286
342
|
if (index.pending[provider])
|
|
287
343
|
throw new AccountServiceError('account_login_already_pending');
|
|
288
344
|
await this.captureActiveCredentials(index, provider);
|
|
@@ -290,28 +346,36 @@ export class AccountService {
|
|
|
290
346
|
index.pending[provider] = {
|
|
291
347
|
provider,
|
|
292
348
|
previousProfileId: index.active[provider] ?? null,
|
|
293
|
-
startedAt: this.now().toISOString()
|
|
294
|
-
terminalId: null
|
|
349
|
+
startedAt: this.now().toISOString()
|
|
295
350
|
};
|
|
296
351
|
delete index.active[provider];
|
|
297
352
|
await this.writeIndex(index);
|
|
298
|
-
let
|
|
353
|
+
let session;
|
|
299
354
|
try {
|
|
355
|
+
// `codex login` revokes the tokens of an auth.json it finds, so the CLI
|
|
356
|
+
// must start only after the archived live file is gone.
|
|
300
357
|
if (current)
|
|
301
358
|
await this.removeCredentialFile(provider);
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
359
|
+
await this.stopLoginSession(provider);
|
|
360
|
+
let launched;
|
|
361
|
+
try {
|
|
362
|
+
launched = this.loginLauncher(provider, { homeDir: this.homeDir });
|
|
363
|
+
}
|
|
364
|
+
catch {
|
|
365
|
+
throw new AccountServiceError('account_login_process_unavailable');
|
|
366
|
+
}
|
|
367
|
+
session = new ProviderLoginSession(provider, launched, () => this.now().getTime(), this.loginTimeoutMs, (expired) => void this.expireLogin(provider, expired));
|
|
368
|
+
this.loginSessions.set(provider, session);
|
|
369
|
+
this.loginNotices.delete(provider);
|
|
309
370
|
this.clearTerminationAcknowledgement(provider);
|
|
310
|
-
return
|
|
371
|
+
return this.status();
|
|
311
372
|
}
|
|
312
373
|
catch (error) {
|
|
313
|
-
if (
|
|
314
|
-
|
|
374
|
+
if (session) {
|
|
375
|
+
if (this.loginSessions.get(provider) === session)
|
|
376
|
+
this.loginSessions.delete(provider);
|
|
377
|
+
await session.terminate();
|
|
378
|
+
}
|
|
315
379
|
if (current)
|
|
316
380
|
await this.writeCredentialFile(provider, current);
|
|
317
381
|
if (index.active[provider] === undefined && index.pending[provider]?.previousProfileId) {
|
|
@@ -319,9 +383,6 @@ export class AccountService {
|
|
|
319
383
|
}
|
|
320
384
|
delete index.pending[provider];
|
|
321
385
|
await this.writeIndex(index);
|
|
322
|
-
const restoredId = index.active[provider];
|
|
323
|
-
if (restoredId)
|
|
324
|
-
await rm(this.profilePath(restoredId), { force: true });
|
|
325
386
|
throw error;
|
|
326
387
|
}
|
|
327
388
|
}
|
|
@@ -330,24 +391,34 @@ export class AccountService {
|
|
|
330
391
|
const pending = index.pending[provider];
|
|
331
392
|
if (!pending)
|
|
332
393
|
throw new AccountServiceError('account_login_not_pending');
|
|
333
|
-
await this.
|
|
394
|
+
if (!await this.readCredentialFile(provider))
|
|
395
|
+
throw new AccountServiceError('account_login_not_complete');
|
|
396
|
+
// Let the CLI finish its own post-login writes before the file is captured.
|
|
397
|
+
const session = this.loginSessions.get(provider);
|
|
398
|
+
if (session?.running && !await session.waitForExit(this.loginExitWaitMs))
|
|
399
|
+
await session.terminate();
|
|
334
400
|
const credentials = await this.readCredentialFile(provider);
|
|
335
401
|
if (!credentials)
|
|
336
402
|
throw new AccountServiceError('account_login_not_complete');
|
|
337
403
|
const profile = await this.storeProfile(index, provider, credentials, label);
|
|
404
|
+
// Back up the new active login before committing it as active.
|
|
405
|
+
await this.writeVaultCredentials(profile.id, credentials);
|
|
338
406
|
index.active[provider] = profile.id;
|
|
339
407
|
delete index.pending[provider];
|
|
408
|
+
clearExternalSwitch(index, provider);
|
|
340
409
|
await this.writeIndex(index);
|
|
341
|
-
|
|
410
|
+
if (this.loginSessions.get(provider) === session)
|
|
411
|
+
this.loginSessions.delete(provider);
|
|
342
412
|
this.clearTerminationAcknowledgement(provider);
|
|
343
413
|
return this.status();
|
|
344
414
|
}
|
|
345
415
|
async cancelLoginUnlocked(provider) {
|
|
416
|
+
// Stop the CLI first so it cannot write a credential after the restore.
|
|
417
|
+
await this.stopLoginSession(provider);
|
|
346
418
|
const index = await this.readIndex();
|
|
347
419
|
const pending = index.pending[provider];
|
|
348
420
|
if (!pending)
|
|
349
421
|
return this.status();
|
|
350
|
-
await this.assertNoRunningPanes(provider, { allowTerminationAcknowledgement: true });
|
|
351
422
|
if (pending.previousProfileId) {
|
|
352
423
|
const credentials = await this.readVaultCredentials(pending.previousProfileId);
|
|
353
424
|
await this.writeCredentialFile(provider, credentials);
|
|
@@ -358,9 +429,6 @@ export class AccountService {
|
|
|
358
429
|
}
|
|
359
430
|
delete index.pending[provider];
|
|
360
431
|
await this.writeIndex(index);
|
|
361
|
-
const restoredId = index.active[provider];
|
|
362
|
-
if (restoredId)
|
|
363
|
-
await rm(this.profilePath(restoredId), { force: true });
|
|
364
432
|
this.clearTerminationAcknowledgement(provider);
|
|
365
433
|
return this.status();
|
|
366
434
|
}
|
|
@@ -386,6 +454,68 @@ export class AccountService {
|
|
|
386
454
|
if (this.refreshTimer)
|
|
387
455
|
clearInterval(this.refreshTimer);
|
|
388
456
|
this.refreshTimer = null;
|
|
457
|
+
// The pending entry survives in the index; after a restart the dialog
|
|
458
|
+
// reports the missing login process and offers to cancel and restore.
|
|
459
|
+
for (const session of this.loginSessions.values())
|
|
460
|
+
session.dispose();
|
|
461
|
+
this.loginSessions.clear();
|
|
462
|
+
}
|
|
463
|
+
loginProcessIds() {
|
|
464
|
+
return [...this.loginSessions.values()].filter((session) => session.running).map((session) => session.pid);
|
|
465
|
+
}
|
|
466
|
+
async stopLoginSession(provider) {
|
|
467
|
+
const session = this.loginSessions.get(provider);
|
|
468
|
+
if (!session)
|
|
469
|
+
return;
|
|
470
|
+
this.loginSessions.delete(provider);
|
|
471
|
+
await session.terminate();
|
|
472
|
+
}
|
|
473
|
+
// Timeout: stop the CLI and restore the previous credential through the
|
|
474
|
+
// normal cancel path, retrying while another account operation holds the lock.
|
|
475
|
+
async expireLogin(provider, session) {
|
|
476
|
+
await session.terminate();
|
|
477
|
+
for (let attempt = 0; attempt < 120 && !this.disposed; attempt += 1) {
|
|
478
|
+
if (this.loginSessions.get(provider) !== session)
|
|
479
|
+
return;
|
|
480
|
+
try {
|
|
481
|
+
await this.cancelLogin(provider);
|
|
482
|
+
this.loginNotices.set(provider, { code: 'login_timed_out', at: this.now().toISOString() });
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
catch (error) {
|
|
486
|
+
if (!(error instanceof AccountServiceError) || error.code !== 'account_operation_busy')
|
|
487
|
+
return;
|
|
488
|
+
await sleep(LOGIN_EXPIRY_RETRY_MS);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
expireLoginNotices() {
|
|
493
|
+
for (const [provider, notice] of this.loginNotices) {
|
|
494
|
+
if (this.now().getTime() - Date.parse(notice.at) > LOGIN_NOTICE_TTL_MS)
|
|
495
|
+
this.loginNotices.delete(provider);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
loginState(provider, ready) {
|
|
499
|
+
const session = this.loginSessions.get(provider);
|
|
500
|
+
if (!session) {
|
|
501
|
+
return ready ? { phase: 'done' } : {
|
|
502
|
+
phase: 'failed',
|
|
503
|
+
message: 'The sign-in process is no longer running (NTerminal may have restarted). Cancel to restore the previous account, then try again.'
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
const state = session.state();
|
|
507
|
+
if (ready) {
|
|
508
|
+
// The credential exists; wait for the CLI to report success or exit.
|
|
509
|
+
if (session.succeeded || !session.running)
|
|
510
|
+
return { phase: 'done' };
|
|
511
|
+
return { ...state, phase: 'completing' };
|
|
512
|
+
}
|
|
513
|
+
if (state.phase === 'done') {
|
|
514
|
+
return session.running ? { ...state, phase: 'completing' } : {
|
|
515
|
+
...state, phase: 'failed', message: 'The CLI reported success, but no credential file was written. Cancel and try again.'
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
return state;
|
|
389
519
|
}
|
|
390
520
|
async refreshExpiringProfiles() {
|
|
391
521
|
if (this.refreshInFlight || this.disposed || this.config.previewMode || this.operationInFlight)
|
|
@@ -486,7 +616,9 @@ export class AccountService {
|
|
|
486
616
|
runningProcesses,
|
|
487
617
|
processCheckUnavailable,
|
|
488
618
|
terminationAcknowledged: this.hasTerminationAcknowledgement(provider),
|
|
489
|
-
pendingLogin: pending ? { startedAt: pending.startedAt, ready: pendingReady,
|
|
619
|
+
pendingLogin: pending ? { startedAt: pending.startedAt, ready: pendingReady, login: this.loginState(provider, pendingReady) } : null,
|
|
620
|
+
loginNotice: pending ? null : this.loginNotices.get(provider) ?? null,
|
|
621
|
+
lastExternalSwitch: index.externalSwitch?.[provider] ?? null,
|
|
490
622
|
credentialPresent: Boolean(profile || pending || false)
|
|
491
623
|
};
|
|
492
624
|
}
|
|
@@ -498,12 +630,10 @@ export class AccountService {
|
|
|
498
630
|
return provider ? [{ terminalId: terminal.id, provider, title: terminal.title, cwd: terminal.cwd }] : [];
|
|
499
631
|
});
|
|
500
632
|
}
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
}
|
|
506
|
-
}
|
|
633
|
+
// Running provider sessions are advisory for account changes: the dialog lists
|
|
634
|
+
// them, and they keep the account they started with until restarted. Only
|
|
635
|
+
// refreshing the ACTIVE account's credential stays guarded, because rotating
|
|
636
|
+
// its refresh token would invalidate the running CLI's copy.
|
|
507
637
|
async terminateProviderSessionsUnlocked(provider) {
|
|
508
638
|
const processes = await this.processLister(provider);
|
|
509
639
|
this.clearTerminationAcknowledgement(provider);
|
|
@@ -559,51 +689,102 @@ export class AccountService {
|
|
|
559
689
|
}
|
|
560
690
|
if (JSON.stringify(index) !== before)
|
|
561
691
|
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
692
|
}
|
|
693
|
+
// The live CLI file is the source of truth for the active account. Every
|
|
694
|
+
// newer live credential NTerminal observes is also copied into the active
|
|
695
|
+
// profile's encrypted vault backup, so an external overwrite (another login,
|
|
696
|
+
// or a stale CLI writing its old account back) never loses the account that
|
|
697
|
+
// was active: it stays in the vault and can be selected again.
|
|
570
698
|
async captureActiveCredential(index, provider) {
|
|
571
699
|
const credentials = await this.readCredentialFile(provider);
|
|
572
700
|
const previousId = index.active[provider];
|
|
573
701
|
if (!credentials) {
|
|
702
|
+
if (!previousId)
|
|
703
|
+
return;
|
|
574
704
|
delete index.active[provider];
|
|
575
|
-
await this.
|
|
705
|
+
await this.deactivateProfile(index, provider, previousId, null);
|
|
576
706
|
return;
|
|
577
707
|
}
|
|
578
708
|
const fingerprint = fingerprintFor(provider, credentials);
|
|
579
709
|
const credentialHash = hashCredential(credentials);
|
|
580
710
|
const existing = index.profiles.find((profile) => profile.provider === provider && (profile.fingerprint === fingerprint || profile.credentialHash === credentialHash));
|
|
711
|
+
if (existing && existing.id === previousId) {
|
|
712
|
+
if (existing.credentialHash !== credentialHash) {
|
|
713
|
+
// The active account's CLI rotated or re-wrote it: the live file wins.
|
|
714
|
+
await this.updateProfile(existing, provider, credentials);
|
|
715
|
+
await this.backupLiveCredential(existing, credentials, false);
|
|
716
|
+
}
|
|
717
|
+
else if (!await this.hasVaultCredentials(existing.id)) {
|
|
718
|
+
// Vaults written before active backups existed: create it once.
|
|
719
|
+
await this.writeVaultCredentials(existing.id, credentials);
|
|
720
|
+
}
|
|
721
|
+
return;
|
|
722
|
+
}
|
|
723
|
+
// The live file now holds a different account than the index says.
|
|
724
|
+
let profile;
|
|
581
725
|
if (existing) {
|
|
582
726
|
if (existing.credentialHash !== credentialHash)
|
|
583
727
|
await this.updateProfile(existing, provider, credentials);
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
728
|
+
profile = existing;
|
|
729
|
+
}
|
|
730
|
+
else {
|
|
731
|
+
profile = await this.storeProfile(index, provider, credentials);
|
|
588
732
|
}
|
|
589
|
-
|
|
733
|
+
// A stale CLI may write back tokens older than this profile's vault copy
|
|
734
|
+
// (renewed while it was inactive); keep the newer copy for a later Use.
|
|
735
|
+
await this.backupLiveCredential(profile, credentials, true);
|
|
590
736
|
index.active[provider] = profile.id;
|
|
591
|
-
if (previousId
|
|
592
|
-
await this.
|
|
593
|
-
}
|
|
594
|
-
|
|
595
|
-
|
|
737
|
+
if (previousId)
|
|
738
|
+
await this.deactivateProfile(index, provider, previousId, profile.id);
|
|
739
|
+
}
|
|
740
|
+
// Copies a live credential into the profile's vault backup. With
|
|
741
|
+
// keepNewerBackup, a backup that holds a strictly newer credential for the
|
|
742
|
+
// same account is kept instead. The live file itself is never changed here.
|
|
743
|
+
async backupLiveCredential(profile, live, keepNewerBackup) {
|
|
744
|
+
const backup = await this.readVaultCredentials(profile.id).catch(() => null);
|
|
745
|
+
if (backup && keepNewerBackup && stableIdentity(profile.provider, backup) === stableIdentity(profile.provider, live) && isOlderCredential(profile.provider, live, backup)) {
|
|
746
|
+
return backup;
|
|
747
|
+
}
|
|
748
|
+
if (!backup || hashCredential(backup) !== hashCredential(live))
|
|
749
|
+
await this.writeVaultCredentials(profile.id, live);
|
|
750
|
+
return live;
|
|
751
|
+
}
|
|
752
|
+
// A profile stops being active because the live file changed outside
|
|
753
|
+
// NTerminal. Its backup becomes its saved credential. Profiles without a
|
|
754
|
+
// backup (vaults from before active backups) cannot be restored and are
|
|
755
|
+
// dropped, as before.
|
|
756
|
+
async deactivateProfile(index, provider, id, toProfileId) {
|
|
757
|
+
const profile = index.profiles.find((entry) => entry.id === id);
|
|
758
|
+
if (!profile)
|
|
596
759
|
return;
|
|
760
|
+
let backup;
|
|
597
761
|
try {
|
|
598
|
-
await
|
|
762
|
+
backup = await this.readVaultCredentials(id);
|
|
599
763
|
}
|
|
600
764
|
catch (error) {
|
|
601
|
-
if (!
|
|
765
|
+
if (!(error instanceof AccountServiceError) || await this.hasVaultCredentials(id))
|
|
602
766
|
throw error;
|
|
603
|
-
index.profiles = index.profiles.filter((
|
|
767
|
+
index.profiles = index.profiles.filter((entry) => entry.id !== id);
|
|
604
768
|
this.retryAfter.delete(id);
|
|
769
|
+
return;
|
|
605
770
|
}
|
|
771
|
+
if (hashCredential(backup) !== profile.credentialHash)
|
|
772
|
+
await this.updateProfile(profile, provider, backup);
|
|
773
|
+
index.externalSwitch = { ...index.externalSwitch, [provider]: { at: this.now().toISOString(), fromProfileId: id, toProfileId } };
|
|
606
774
|
}
|
|
775
|
+
async hasVaultCredentials(id) {
|
|
776
|
+
try {
|
|
777
|
+
await stat(this.profilePath(id));
|
|
778
|
+
return true;
|
|
779
|
+
}
|
|
780
|
+
catch (error) {
|
|
781
|
+
if (isNotFound(error))
|
|
782
|
+
return false;
|
|
783
|
+
throw error;
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
// Before NTerminal itself replaces the live file (switch or new login), make
|
|
787
|
+
// sure the outgoing account's vault copy holds its newest credential.
|
|
607
788
|
async archiveActiveCredential(index, provider) {
|
|
608
789
|
const credentials = await this.readCredentialFile(provider);
|
|
609
790
|
const profile = index.profiles.find((entry) => entry.id === index.active[provider]);
|
|
@@ -611,7 +792,12 @@ export class AccountService {
|
|
|
611
792
|
return null;
|
|
612
793
|
if (!profile || profile.credentialHash !== hashCredential(credentials))
|
|
613
794
|
throw new AccountServiceError('account_credential_changed');
|
|
614
|
-
|
|
795
|
+
// The live hash matches the last synchronization, so the backup can only
|
|
796
|
+
// differ if it was kept as newer when this account became live again.
|
|
797
|
+
const kept = await this.backupLiveCredential(profile, credentials, true);
|
|
798
|
+
// Inactive profiles describe their vault copy.
|
|
799
|
+
if (kept !== credentials)
|
|
800
|
+
await this.updateProfile(profile, provider, kept);
|
|
615
801
|
return credentials;
|
|
616
802
|
}
|
|
617
803
|
// A blank label only names new profiles; re-authenticating an existing
|
|
@@ -665,7 +851,11 @@ export class AccountService {
|
|
|
665
851
|
}
|
|
666
852
|
// Idle describes a conversation, not the CLI's background auth work. Until
|
|
667
853
|
// we can delegate to its native auth owner, never compete with a live CLI.
|
|
668
|
-
|
|
854
|
+
// The same applies to an account an external write just displaced: CLIs
|
|
855
|
+
// started while it was active still hold its refresh token. The active
|
|
856
|
+
// account is always refreshed from the live file, never from its backup.
|
|
857
|
+
const displaced = index.externalSwitch?.[profile.provider]?.fromProfileId === profile.id;
|
|
858
|
+
if (active || displaced)
|
|
669
859
|
await this.assertNoRefreshOwner(profile.provider);
|
|
670
860
|
let refreshResponse;
|
|
671
861
|
try {
|
|
@@ -706,8 +896,11 @@ export class AccountService {
|
|
|
706
896
|
}
|
|
707
897
|
// The live credential is shared by every pane for this OS user. The
|
|
708
898
|
// compare above preserves an external CLI refresh or login that won the
|
|
709
|
-
// race; otherwise atomically publish the one rotated credential
|
|
899
|
+
// race; otherwise atomically publish the one rotated credential, then
|
|
900
|
+
// mirror it into the active backup. If the backup write fails, the next
|
|
901
|
+
// synchronization copies the newer live file.
|
|
710
902
|
await this.writeCredentialFile(profile.provider, next);
|
|
903
|
+
await this.writeVaultCredentials(profile.id, next);
|
|
711
904
|
}
|
|
712
905
|
else {
|
|
713
906
|
await this.writeVaultCredentials(profile.id, next);
|
|
@@ -735,7 +928,8 @@ export class AccountService {
|
|
|
735
928
|
version: 1,
|
|
736
929
|
profiles: Array.isArray(parsed.profiles) ? parsed.profiles.filter(isVaultProfile) : [],
|
|
737
930
|
active: isRecord(parsed.active) ? parsed.active : {},
|
|
738
|
-
pending: isRecord(parsed.pending) ? parsed.pending : {}
|
|
931
|
+
pending: isRecord(parsed.pending) ? parsed.pending : {},
|
|
932
|
+
...(isRecord(parsed.externalSwitch) ? { externalSwitch: parseExternalSwitches(parsed.externalSwitch) } : {})
|
|
739
933
|
};
|
|
740
934
|
}
|
|
741
935
|
catch (error) {
|
|
@@ -929,14 +1123,22 @@ async function listUserProcesses() {
|
|
|
929
1123
|
const getuid = process.getuid;
|
|
930
1124
|
if (!getuid)
|
|
931
1125
|
throw new Error('unsupported');
|
|
932
|
-
const { stdout } = await execFileAsync('ps', ['-u', String(getuid()), '-o', 'pid=,ppid=,comm=,args='], {
|
|
1126
|
+
const { stdout } = await execFileAsync('ps', ['-u', String(getuid()), '-o', 'pid=,ppid=,stat=,comm=,args='], {
|
|
933
1127
|
timeout: 3_000, maxBuffer: 4 * 1024 * 1024, encoding: 'utf8'
|
|
934
1128
|
});
|
|
1129
|
+
return parseProcessSnapshot(stdout);
|
|
1130
|
+
}
|
|
1131
|
+
// Zombie (Z) and dead (X) entries have exited and cannot be signalled; only
|
|
1132
|
+
// their parent can reap them (e.g. an old Codex daemon under its updater).
|
|
1133
|
+
// They are not running providers.
|
|
1134
|
+
export function parseProcessSnapshot(stdout) {
|
|
935
1135
|
return stdout.split('\n').flatMap((line) => {
|
|
936
|
-
const match = line.match(/^\s*(\d+)\s+(\d+)\s+(\S+)\s*(.*)$/);
|
|
1136
|
+
const match = line.match(/^\s*(\d+)\s+(\d+)\s+(\S+)\s+(\S+)\s*(.*)$/);
|
|
937
1137
|
if (!match)
|
|
938
1138
|
return [];
|
|
939
|
-
const [, pidText, ppidText, command, args] = match;
|
|
1139
|
+
const [, pidText, ppidText, stat, command, args] = match;
|
|
1140
|
+
if (/^[ZX]/.test(stat))
|
|
1141
|
+
return [];
|
|
940
1142
|
const pid = Number.parseInt(pidText, 10);
|
|
941
1143
|
const ppid = Number.parseInt(ppidText, 10);
|
|
942
1144
|
return Number.isSafeInteger(pid) && Number.isSafeInteger(ppid) ? [{ pid, ppid, command: command, args: args }] : [];
|
|
@@ -963,8 +1165,8 @@ function displayProcessCommand(command) {
|
|
|
963
1165
|
const basename = path.basename(command);
|
|
964
1166
|
return basename.length <= 80 ? basename : basename.slice(0, 80);
|
|
965
1167
|
}
|
|
966
|
-
async function terminateProviderProcesses(provider, _processes) {
|
|
967
|
-
const snapshot = await listUserProcesses();
|
|
1168
|
+
async function terminateProviderProcesses(provider, _processes, excludedRoots = []) {
|
|
1169
|
+
const snapshot = withoutProcessTrees(await listUserProcesses(), excludedRoots);
|
|
968
1170
|
const daemons = provider === 'codex' ? snapshot.filter(isCodexDaemon) : [];
|
|
969
1171
|
// Shared daemon descendants include work for multiple TUI clients. Stop the
|
|
970
1172
|
// interactive clients first, then let the daemon's lifecycle command stop it.
|
|
@@ -982,7 +1184,7 @@ async function terminateProviderProcesses(provider, _processes) {
|
|
|
982
1184
|
signalProcess(pid, 'SIGTERM');
|
|
983
1185
|
if (targetPids.length > 0) {
|
|
984
1186
|
await sleep(PROVIDER_TERMINATE_GRACE_MS);
|
|
985
|
-
const survivors = new Set(providerProcessTree(provider, await listUserProcesses()));
|
|
1187
|
+
const survivors = new Set(providerProcessTree(provider, withoutProcessTrees(await listUserProcesses(), excludedRoots)));
|
|
986
1188
|
for (const pid of targetPids)
|
|
987
1189
|
if (survivors.has(pid))
|
|
988
1190
|
signalProcess(pid, 'SIGKILL');
|
|
@@ -1005,6 +1207,39 @@ async function stopCodexDaemon(daemons) {
|
|
|
1005
1207
|
}
|
|
1006
1208
|
throw new AccountServiceError('codex_daemon_stop_failed');
|
|
1007
1209
|
}
|
|
1210
|
+
// Drops the given processes and all their descendants from a snapshot.
|
|
1211
|
+
function withoutProcessTrees(entries, roots) {
|
|
1212
|
+
if (roots.length === 0)
|
|
1213
|
+
return entries;
|
|
1214
|
+
const excluded = new Set(roots);
|
|
1215
|
+
for (let changed = true; changed;) {
|
|
1216
|
+
changed = false;
|
|
1217
|
+
for (const entry of entries)
|
|
1218
|
+
if (excluded.has(entry.ppid) && !excluded.has(entry.pid)) {
|
|
1219
|
+
excluded.add(entry.pid);
|
|
1220
|
+
changed = true;
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
return entries.filter((entry) => !excluded.has(entry.pid));
|
|
1224
|
+
}
|
|
1225
|
+
function daemonDescendants(entries) {
|
|
1226
|
+
const parents = new Map(entries.map((entry) => [entry.pid, entry.ppid]));
|
|
1227
|
+
const daemons = new Set(entries.filter(isCodexDaemon).map((entry) => entry.pid));
|
|
1228
|
+
const out = new Set();
|
|
1229
|
+
for (const entry of entries) {
|
|
1230
|
+
if (daemons.has(entry.pid))
|
|
1231
|
+
continue;
|
|
1232
|
+
const seen = new Set();
|
|
1233
|
+
for (let pid = entry.ppid; pid > 0 && !seen.has(pid); pid = parents.get(pid) ?? 0) {
|
|
1234
|
+
if (daemons.has(pid)) {
|
|
1235
|
+
out.add(entry.pid);
|
|
1236
|
+
break;
|
|
1237
|
+
}
|
|
1238
|
+
seen.add(pid);
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
return out;
|
|
1242
|
+
}
|
|
1008
1243
|
function isCodexDaemon(entry) {
|
|
1009
1244
|
const args = entry.args.trim().split(/\s+/);
|
|
1010
1245
|
return path.basename(args[0] ?? '') === 'codex' && args[1] === 'app-server'
|
|
@@ -1044,6 +1279,31 @@ function signalProcess(pid, signal) {
|
|
|
1044
1279
|
function sleep(milliseconds) {
|
|
1045
1280
|
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
1046
1281
|
}
|
|
1282
|
+
function clearExternalSwitch(index, provider) {
|
|
1283
|
+
if (!index.externalSwitch?.[provider])
|
|
1284
|
+
return false;
|
|
1285
|
+
const { [provider]: _cleared, ...rest } = index.externalSwitch;
|
|
1286
|
+
index.externalSwitch = rest;
|
|
1287
|
+
return true;
|
|
1288
|
+
}
|
|
1289
|
+
function parseExternalSwitches(value) {
|
|
1290
|
+
const result = {};
|
|
1291
|
+
for (const provider of ['codex', 'claude']) {
|
|
1292
|
+
const entry = value[provider];
|
|
1293
|
+
if (isRecord(entry) && typeof entry.at === 'string' && typeof entry.fromProfileId === 'string' && (entry.toProfileId === null || typeof entry.toProfileId === 'string')) {
|
|
1294
|
+
result[provider] = { at: entry.at, fromProfileId: entry.fromProfileId, toProfileId: entry.toProfileId };
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
return result;
|
|
1298
|
+
}
|
|
1299
|
+
// True when `candidate` is provably older than `reference`: both carry an
|
|
1300
|
+
// access-token expiry and the candidate's is earlier. Unknown timing is never
|
|
1301
|
+
// treated as older, so the live file wins by default.
|
|
1302
|
+
function isOlderCredential(provider, candidate, reference) {
|
|
1303
|
+
const a = Date.parse(credentialTiming(provider, candidate).expiresAt ?? '');
|
|
1304
|
+
const b = Date.parse(credentialTiming(provider, reference).expiresAt ?? '');
|
|
1305
|
+
return Number.isFinite(a) && Number.isFinite(b) && a < b;
|
|
1306
|
+
}
|
|
1047
1307
|
function toPublicProfile(profile) {
|
|
1048
1308
|
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
1309
|
}
|