@ai-workbench/connect 0.7.9 → 0.7.11
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 +13 -3
- package/client/index.cjs +8 -8
- package/helper/remote-command.cjs +1 -1
- package/lib/api.d.ts +21 -0
- package/lib/auth-diagnostics.d.ts +22 -0
- package/lib/auth-diagnostics.js +45 -0
- package/lib/auth-status.js +21 -8
- package/lib/components.js +29 -3
- package/lib/connect.js +52 -12
- package/lib/conversation-details.d.ts +3 -0
- package/lib/conversation-details.js +8 -7
- package/lib/cordis.d.ts +1 -0
- package/lib/integration.js +4 -3
- package/lib/login-diagnostics.d.ts +26 -0
- package/lib/login-diagnostics.js +42 -0
- package/lib/processes.js +6 -5
- package/lib/profile-runtime.js +34 -20
- package/lib/ssh.js +37 -14
- package/lib/startup.d.ts +22 -1
- package/lib/startup.js +124 -33
- package/package.json +1 -1
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { RemoteConversationDetail } from './api.ts';
|
|
2
|
+
import { type ConversationDetailOptions } from './conversation-details.ts';
|
|
3
|
+
export declare function validLoginDiagnostic(detail: RemoteConversationDetail): boolean;
|
|
4
|
+
export declare function createLoginDiagnostics(options: Omit<ConversationDetailOptions, 'storageName' | 'validate' | 'knownKinds'>): {
|
|
5
|
+
appendLoginDiagnostic: (detail: RemoteConversationDetail) => Promise<import("./api.ts").Result<{
|
|
6
|
+
eventId: string;
|
|
7
|
+
recorded: true;
|
|
8
|
+
}>>;
|
|
9
|
+
getLoginDiagnosticReceipt: (query: {
|
|
10
|
+
eventId: string;
|
|
11
|
+
sessionId: string;
|
|
12
|
+
provider: import("./api.ts").Provider;
|
|
13
|
+
}) => Promise<import("./api.ts").Result<{
|
|
14
|
+
eventId: string;
|
|
15
|
+
recorded: false;
|
|
16
|
+
} | {
|
|
17
|
+
eventId: string;
|
|
18
|
+
recorded: true;
|
|
19
|
+
detail: RemoteConversationDetail;
|
|
20
|
+
}>>;
|
|
21
|
+
listLoginDiagnostics: (query: import("./api.ts").RemoteDetailQuery) => Promise<import("./api.ts").Result<import("./api.ts").RemoteDetailPage>>;
|
|
22
|
+
getLoginDiagnosticState: () => import("./api.ts").RemoteConversationDetailState;
|
|
23
|
+
onLoginDiagnosticState: (listener: (state: import("./api.ts").RemoteConversationDetailState) => void) => () => void;
|
|
24
|
+
observeConnection: (state: import("./api.ts").ConnectionState) => void;
|
|
25
|
+
dispose: () => Promise<void>;
|
|
26
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { createConversationDetails, validRemoteConversationDetail } from "./conversation-details.js";
|
|
2
|
+
import { validAuthDiagnosticReason } from "./auth-diagnostics.js";
|
|
3
|
+
const object = (value) => !!value && typeof value === 'object' && !Array.isArray(value);
|
|
4
|
+
const keys = (value, required, optional = []) => required.every(key => Object.hasOwn(value, key)) && Object.keys(value).every(key => required.includes(key) || optional.includes(key));
|
|
5
|
+
const providers = (value) => Array.isArray(value) && value.every(provider => provider === 'claude' || provider === 'codex') && new Set(value).size === value.length;
|
|
6
|
+
// Only structured status facts belong here. Ordinary user-turn payloads retain D19's scope.
|
|
7
|
+
export function validLoginDiagnostic(detail) {
|
|
8
|
+
if (!validRemoteConversationDetail(detail) || detail.kind !== 'login-check')
|
|
9
|
+
return false;
|
|
10
|
+
const p = detail.payload;
|
|
11
|
+
if (!object(p) || !keys(p, ['reason', 'connection', 'login'], ['observedLoginInstanceId', 'officialReason', 'officialReasonRejected']))
|
|
12
|
+
return false;
|
|
13
|
+
const c = p.connection, l = p.login;
|
|
14
|
+
if (typeof p.reason !== 'string' || !/^[A-Z][A-Z0-9_]*$/.test(p.reason) || !object(c) || !object(l))
|
|
15
|
+
return false;
|
|
16
|
+
if (!keys(c, ['phase', 'instanceId', 'epoch']) || !['connecting', 'connected', 'unavailable', 'disposed'].includes(c.phase) || c.instanceId !== detail.instanceId || c.epoch !== detail.epoch)
|
|
17
|
+
return false;
|
|
18
|
+
if (!keys(l, ['phase', 'instanceId', 'logins', 'queuedProviders', 'checkingProviders']) || !['checking', 'blocked', 'login-required', 'authorizing', 'idle', 'disposed'].includes(l.phase) || !object(l.logins))
|
|
19
|
+
return false;
|
|
20
|
+
// The observed ID is diagnostic evidence only. Event/connection scope remains current.
|
|
21
|
+
if (Object.hasOwn(p, 'observedLoginInstanceId')) {
|
|
22
|
+
const observed = p.observedLoginInstanceId;
|
|
23
|
+
if (p.reason !== 'LOGIN_INSTANCE_MISMATCH' || typeof observed !== 'string' || !observed.length || /[\x00-\x1f\x7f]/.test(observed) || observed === detail.instanceId || l.instanceId !== observed)
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
else if (l.instanceId !== detail.instanceId)
|
|
27
|
+
return false;
|
|
28
|
+
if (!keys(l.logins, [], ['claude', 'codex']) || !Object.values(l.logins).every(value => typeof value === 'boolean' || value === 'absent') || !providers(l.queuedProviders) || !providers(l.checkingProviders))
|
|
29
|
+
return false;
|
|
30
|
+
if (p.officialReasonRejected !== undefined) {
|
|
31
|
+
const rejected = p.officialReasonRejected;
|
|
32
|
+
if (p.reason !== 'OFFICIAL_REASON_UNSUPPORTED' || p.officialReason !== undefined || !object(rejected) || !keys(rejected, ['bytes', 'sha256']) || !Number.isSafeInteger(rejected.bytes) || Number(rejected.bytes) < 0 || typeof rejected.sha256 !== 'string' || !/^[a-f0-9]{64}$/.test(rejected.sha256))
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
if (p.reason === 'OFFICIAL_REASON_UNSUPPORTED' && p.officialReasonRejected === undefined)
|
|
36
|
+
return false;
|
|
37
|
+
return p.officialReason === undefined || validAuthDiagnosticReason(p.officialReason);
|
|
38
|
+
}
|
|
39
|
+
export function createLoginDiagnostics(options) {
|
|
40
|
+
const details = createConversationDetails({ ...options, get maxEventBytes() { return options.maxEventBytes; }, get pageSize() { return options.pageSize; }, context: () => options.context(), scope: (sessionId, provider) => options.scope(sessionId, provider), storageName: 'login-diagnostics', knownKinds: ['login-check'], validate: validLoginDiagnostic });
|
|
41
|
+
return { appendLoginDiagnostic: details.appendRemoteConversationDetail, getLoginDiagnosticReceipt: details.getRemoteConversationDetailReceipt, listLoginDiagnostics: details.listRemoteConversationDetails, getLoginDiagnosticState: details.getRemoteConversationDetailState, onLoginDiagnosticState: details.onRemoteConversationDetailState, observeConnection: details.observeConnection, dispose: details.dispose };
|
|
42
|
+
}
|
package/lib/processes.js
CHANGED
|
@@ -2,6 +2,7 @@ import { spawn } from 'node:child_process';
|
|
|
2
2
|
import { StringDecoder } from 'node:string_decoder';
|
|
3
3
|
import { extractLoginUrl } from "./login.js";
|
|
4
4
|
import { fail, ok } from "./needs.js";
|
|
5
|
+
import { AuthDiagnosticError, exceptionSummary, outputSummary } from "./auth-diagnostics.js";
|
|
5
6
|
function limit(n) { if (!Number.isSafeInteger(n) || n < 1)
|
|
6
7
|
throw new Error('OUTPUT_SETTING_REQUIRED'); }
|
|
7
8
|
function start(command, args, signal) {
|
|
@@ -18,15 +19,15 @@ export function createRunner(maxBytes) {
|
|
|
18
19
|
limit(maxBytes);
|
|
19
20
|
return { run: async (command, args, signal, input) => {
|
|
20
21
|
const child = start(command, args, signal);
|
|
21
|
-
let stdout = '', stderr = '', size = 0,
|
|
22
|
-
child.stdin.on('error',
|
|
22
|
+
let stdout = '', stderr = '', size = 0, failure;
|
|
23
|
+
child.stdin.on('error', error => { failure ??= { stage: 'process-stdin', reason: 'STDIN_FAILED', ...exceptionSummary(error) }; child.kill('SIGTERM'); });
|
|
23
24
|
child.stdin.end(input);
|
|
24
25
|
const finished = new Promise((resolve, reject) => {
|
|
25
|
-
child.once('error',
|
|
26
|
+
child.once('error', error => { failure = { stage: 'process-spawn', reason: 'SPAWN_FAILED', ...exceptionSummary(error) }; });
|
|
26
27
|
for (const [stream, channel] of [[child.stdout, 'out'], [child.stderr, 'err']]) {
|
|
27
28
|
const decoder = new StringDecoder('utf8');
|
|
28
29
|
stream.on('data', (chunk) => { size += chunk.length; if (size > maxBytes) {
|
|
29
|
-
|
|
30
|
+
failure ??= { stage: 'process-output', reason: 'OUTPUT_LIMIT_EXCEEDED', maxOutputBytes: maxBytes, observedBytes: size };
|
|
30
31
|
child.kill('SIGTERM');
|
|
31
32
|
return;
|
|
32
33
|
} if (channel === 'out')
|
|
@@ -34,7 +35,7 @@ export function createRunner(maxBytes) {
|
|
|
34
35
|
else
|
|
35
36
|
stderr += decoder.write(chunk); });
|
|
36
37
|
}
|
|
37
|
-
child.once('close', code =>
|
|
38
|
+
child.once('close', (code, processSignal) => failure || signal.aborted || processSignal !== null ? reject(new AuthDiagnosticError(failure ?? { stage: 'process-runner', reason: signal.aborted ? 'CANCELLED' : 'PROCESS_SIGNAL', exitCode: code, signal: ['SIGTERM', 'SIGKILL', 'SIGABRT', 'SIGHUP', 'SIGINT', 'SIGSEGV'].includes(processSignal ?? '') ? processSignal : 'OTHER', stderr: outputSummary(stderr) }, 'COMMAND_FAILED')) : resolve({ exitCode: code, stdout, stderr }));
|
|
38
39
|
});
|
|
39
40
|
const abort = () => child.kill('SIGTERM');
|
|
40
41
|
signal.addEventListener('abort', abort, { once: true });
|
package/lib/profile-runtime.js
CHANGED
|
@@ -6,6 +6,7 @@ import { createNativeImportPicker } from "./native-import-picker.js";
|
|
|
6
6
|
import { createHash } from 'node:crypto';
|
|
7
7
|
import { readPrivate } from "./private-files.js";
|
|
8
8
|
import { createConversationDetails } from "./conversation-details.js";
|
|
9
|
+
import { createLoginDiagnostics } from "./login-diagnostics.js";
|
|
9
10
|
import { CONVERSATION_DETAIL_DEFAULTS, L_DEFAULTS } from "./defaults.js";
|
|
10
11
|
import { createProfileActivation } from "./profile-activation.js";
|
|
11
12
|
// Composition only: consume official services; never provide replacements or alter default entries.
|
|
@@ -29,27 +30,29 @@ export function attachProfileRuntime(ctx, api, ports, config, network) {
|
|
|
29
30
|
ctx.provide('aiWorkbenchWorkspaceDiagnostics', { getLatest: () => lastWorkspaceError });
|
|
30
31
|
ctx.inject(['workspaceRegistry', 'sessions', 'sessionPersistence'], native => {
|
|
31
32
|
const registry = native.get('workspaceRegistry');
|
|
33
|
+
const readHeader = async (id) => {
|
|
34
|
+
const live = native.get('sessions');
|
|
35
|
+
const session = live?.get(id);
|
|
36
|
+
if (session)
|
|
37
|
+
return session.header;
|
|
38
|
+
const persistence = native.get('sessionPersistence');
|
|
39
|
+
if (!persistence)
|
|
40
|
+
throw new NeedError(need('DEPENDENCY_REQUIRED'));
|
|
41
|
+
return (await persistence.list()).find(s => s.header.id === id)?.header;
|
|
42
|
+
};
|
|
32
43
|
const bindings = createWorkspaceBindings({ profileDir: profile.dir, metadataMaxBytes: config.enrollmentMetadataMaxBytes ?? L_DEFAULTS.enrollmentMetadataMaxBytes, api, registry,
|
|
33
|
-
readHeader:
|
|
34
|
-
const live = native.get('sessions');
|
|
35
|
-
const session = live?.get(id);
|
|
36
|
-
if (session)
|
|
37
|
-
return session.header;
|
|
38
|
-
const persistence = native.get('sessionPersistence');
|
|
39
|
-
if (!persistence)
|
|
40
|
-
throw new NeedError(need('DEPENDENCY_REQUIRED'));
|
|
41
|
-
return (await persistence.list()).find(s => s.header.id === id)?.header;
|
|
42
|
-
}, nextRevision: () => ++workspaceRevision, onState: state => ctx.emit('ai-workbench/workspace-binding-state', state), diagnostic: error => { lastWorkspaceError = error; ctx.emit('ai-workbench/workspace-diagnostic', error); } });
|
|
44
|
+
readHeader, nextRevision: () => ++workspaceRevision, onState: state => ctx.emit('ai-workbench/workspace-binding-state', state), diagnostic: error => { lastWorkspaceError = error; ctx.emit('ai-workbench/workspace-diagnostic', error); } });
|
|
43
45
|
const settings = ctx.get('aiWorkbenchConversationDetailSettings');
|
|
46
|
+
const context = async () => { const current = api.getModelCatalogTarget('codex'); if (!current.ok)
|
|
47
|
+
return current; const t = current.value.target; try {
|
|
48
|
+
const trust = await readPrivate(t.knownHostsFile, config.enrollmentMetadataMaxBytes ?? L_DEFAULTS.enrollmentMetadataMaxBytes);
|
|
49
|
+
return { ok: true, value: { identity: createHash('sha256').update(JSON.stringify([t.host, t.username, t.port, trust])).digest('hex') } };
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return fail('REMOTE_DETAIL_SCOPE_INVALID');
|
|
53
|
+
} };
|
|
44
54
|
const details = createConversationDetails({ profileDir: profile.dir, get maxEventBytes() { return settings ? settings.values()?.maxEventBytes ?? 0 : config.conversationDetails?.maxEventBytes ?? CONVERSATION_DETAIL_DEFAULTS.maxEventBytes; }, get pageSize() { return settings ? settings.values()?.pageSize ?? 0 : config.conversationDetails?.pageSize ?? CONVERSATION_DETAIL_DEFAULTS.pageSize; }, getConnectionState: api.getConnectionState,
|
|
45
|
-
context
|
|
46
|
-
return current; const t = current.value.target; try {
|
|
47
|
-
const trust = await readPrivate(t.knownHostsFile, config.enrollmentMetadataMaxBytes ?? L_DEFAULTS.enrollmentMetadataMaxBytes);
|
|
48
|
-
return { ok: true, value: { identity: createHash('sha256').update(JSON.stringify([t.host, t.username, t.port, trust])).digest('hex') } };
|
|
49
|
-
}
|
|
50
|
-
catch {
|
|
51
|
-
return fail('REMOTE_DETAIL_SCOPE_INVALID');
|
|
52
|
-
} },
|
|
55
|
+
context,
|
|
53
56
|
scope: async (sessionId, provider) => { const current = api.getModelCatalogTarget(provider); if (!current.ok)
|
|
54
57
|
return current; return bindings.verifyRemoteConversationScope(sessionId, provider, current.value.target); },
|
|
55
58
|
onState: state => ctx.emit('ai-workbench/remote-conversation-detail-state', state) });
|
|
@@ -58,11 +61,22 @@ export function attachProfileRuntime(ctx, api, ports, config, network) {
|
|
|
58
61
|
api.getRemoteConversationDetailState = details.getRemoteConversationDetailState;
|
|
59
62
|
api.onRemoteConversationDetailState = details.onRemoteConversationDetailState;
|
|
60
63
|
api.listRemoteConversationDetails = details.listRemoteConversationDetails;
|
|
61
|
-
const
|
|
64
|
+
const loginDiagnostics = createLoginDiagnostics({ profileDir: profile.dir, get maxEventBytes() { return settings ? settings.values()?.maxEventBytes ?? 0 : config.conversationDetails?.maxEventBytes ?? CONVERSATION_DETAIL_DEFAULTS.maxEventBytes; }, get pageSize() { return settings ? settings.values()?.pageSize ?? 0 : config.conversationDetails?.pageSize ?? CONVERSATION_DETAIL_DEFAULTS.pageSize; }, getConnectionState: api.getConnectionState, context,
|
|
65
|
+
scope: async (sessionId, provider) => { const current = api.getModelCatalogTarget(provider); if (!current.ok)
|
|
66
|
+
return current; const header = await readHeader(sessionId); if (!header || header.id !== sessionId)
|
|
67
|
+
return fail('REMOTE_DETAIL_SCOPE_INVALID'); return context(); },
|
|
68
|
+
onState: state => ctx.emit('ai-workbench/login-diagnostic-state', state) });
|
|
69
|
+
api.appendLoginDiagnostic = loginDiagnostics.appendLoginDiagnostic;
|
|
70
|
+
api.getLoginDiagnosticReceipt = loginDiagnostics.getLoginDiagnosticReceipt;
|
|
71
|
+
api.listLoginDiagnostics = loginDiagnostics.listLoginDiagnostics;
|
|
72
|
+
api.getLoginDiagnosticState = loginDiagnostics.getLoginDiagnosticState;
|
|
73
|
+
api.onLoginDiagnosticState = loginDiagnostics.onLoginDiagnosticState;
|
|
74
|
+
const previousContractInfo = api.getContractInfo, detailContractInfo = () => ({ ...previousContractInfo(), remoteConversationDetails: 1, loginDiagnostics: 1 });
|
|
62
75
|
api.getContractInfo = detailContractInfo;
|
|
63
76
|
native.effect(() => { details.observeConnection(api.getConnectionState()); return api.onConnectionState(details.observeConnection); });
|
|
77
|
+
native.effect(() => { loginDiagnostics.observeConnection(api.getConnectionState()); return api.onConnectionState(loginDiagnostics.observeConnection); });
|
|
64
78
|
native.effect(() => () => { if (api.getContractInfo === detailContractInfo)
|
|
65
|
-
api.getContractInfo = previousContractInfo; return details.dispose(); });
|
|
79
|
+
api.getContractInfo = previousContractInfo; return Promise.all([details.dispose(), loginDiagnostics.dispose()]); });
|
|
66
80
|
native.effect(() => api.onConnectionState(bindings.observeConnection));
|
|
67
81
|
api.getWorkspaceBindingState = bindings.getWorkspaceBindingState;
|
|
68
82
|
api.onWorkspaceBindingState = bindings.onWorkspaceBindingState;
|
package/lib/ssh.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { readFile } from 'node:fs/promises';
|
|
2
2
|
import { randomUUID } from 'node:crypto';
|
|
3
3
|
import { fail, ok } from "./needs.js";
|
|
4
|
+
import { AuthDiagnosticError, authFailure, outputSummary } from "./auth-diagnostics.js";
|
|
4
5
|
export function shellQuote(s) {
|
|
5
6
|
if (/[\x00-\x1f\x7f%]/.test(s))
|
|
6
7
|
throw new Error('INVALID_PROXY_PATH');
|
|
@@ -18,28 +19,46 @@ export async function runRemote(runner, t, command, signal, input) {
|
|
|
18
19
|
const nonce = randomUUID(), source = await readFile(new URL('../helper/remote-command.cjs', import.meta.url), 'utf8');
|
|
19
20
|
const remote = 'node -e ' + "'" + source.replaceAll("'", "'\\''") + "'";
|
|
20
21
|
const raw = await runner.run('ssh', [...sshArgs(t), remote], signal, JSON.stringify({ protocolVersion: 1, nonce, command, ...input === undefined ? {} : { input } }));
|
|
21
|
-
|
|
22
|
-
|
|
22
|
+
const bad = (stage, reason, details = {}) => { throw new AuthDiagnosticError({ stage, reason, ...details }); };
|
|
23
|
+
if (signal.aborted)
|
|
24
|
+
bad('ssh-result', 'CANCELLED');
|
|
25
|
+
if (raw.exitCode !== 0 || raw.stderr !== '')
|
|
26
|
+
bad('ssh-result', raw.exitCode !== 0 ? 'SSH_EXIT_NONZERO' : 'SSH_STDERR_NONEMPTY', { exitCode: raw.exitCode, stderr: outputSummary(raw.stderr) });
|
|
23
27
|
const lines = raw.stdout.split('\n');
|
|
24
28
|
if (lines.pop() !== '')
|
|
25
|
-
|
|
29
|
+
bad('remote-frame', 'FRAME_TRUNCATED');
|
|
26
30
|
let started = false, ended = false, exitCode = null;
|
|
27
31
|
const out = [], err = [];
|
|
28
|
-
for (const line of lines) {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
+
for (const [index, line] of lines.entries()) {
|
|
33
|
+
let v;
|
|
34
|
+
try {
|
|
35
|
+
v = JSON.parse(line);
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
bad('remote-frame', 'FRAME_JSON_INVALID', { index, errorName: 'SyntaxError', output: outputSummary(line) });
|
|
39
|
+
}
|
|
40
|
+
if (!v || typeof v !== 'object' || Array.isArray(v))
|
|
41
|
+
bad('remote-frame', 'FRAME_OBJECT_INVALID', { index });
|
|
42
|
+
if (v.protocolVersion !== 1)
|
|
43
|
+
bad('remote-frame', 'FRAME_VERSION_INVALID', { index });
|
|
44
|
+
if (v.nonce !== nonce)
|
|
45
|
+
bad('remote-frame', 'FRAME_NONCE_INVALID', { index });
|
|
46
|
+
if (ended)
|
|
47
|
+
bad('remote-frame', 'FRAME_AFTER_END', { index });
|
|
32
48
|
const keys = Object.keys(v).sort().join(',');
|
|
33
49
|
if (v.type === 'start' && !started && keys === 'nonce,protocolVersion,type') {
|
|
34
50
|
started = true;
|
|
35
51
|
continue;
|
|
36
52
|
}
|
|
53
|
+
const expected = v.type === 'start' ? 'nonce,protocolVersion,type' : v.type === 'end' ? 'exitCode,nonce,protocolVersion,signal,type' : v.type === 'stdout' || v.type === 'stderr' ? 'data,nonce,protocolVersion,type' : undefined;
|
|
54
|
+
if (expected && keys !== expected)
|
|
55
|
+
bad('remote-frame', 'FRAME_KEYS_INVALID', { index, missing: expected.split(',').filter(key => !Object.hasOwn(v, key)), unexpectedCount: Object.keys(v).filter(key => !expected.split(',').includes(key)).length });
|
|
37
56
|
if (!started)
|
|
38
|
-
|
|
57
|
+
bad('remote-frame', 'FRAME_START_MISSING', { index });
|
|
39
58
|
if ((v.type === 'stdout' || v.type === 'stderr') && keys === 'data,nonce,protocolVersion,type' && typeof v.data === 'string') {
|
|
40
59
|
const bytes = Buffer.from(v.data, 'base64');
|
|
41
60
|
if (bytes.toString('base64') !== v.data)
|
|
42
|
-
|
|
61
|
+
bad('remote-frame', 'FRAME_BASE64_INVALID', { index });
|
|
43
62
|
(v.type === 'stdout' ? out : err).push(bytes);
|
|
44
63
|
continue;
|
|
45
64
|
}
|
|
@@ -48,10 +67,14 @@ export async function runRemote(runner, t, command, signal, input) {
|
|
|
48
67
|
exitCode = v.exitCode;
|
|
49
68
|
continue;
|
|
50
69
|
}
|
|
51
|
-
|
|
70
|
+
if (v.type === 'end' && v.signal !== null)
|
|
71
|
+
bad('remote-frame', 'REMOTE_COMMAND_SIGNAL', { index, signal: ['SIGTERM', 'SIGKILL', 'SIGABRT', 'SIGHUP', 'SIGINT', 'SIGSEGV'].includes(v.signal) ? v.signal : 'OTHER' });
|
|
72
|
+
if (v.type === 'end')
|
|
73
|
+
bad('remote-frame', 'FRAME_END_EXIT_INVALID', { index, exitCodeType: typeof v.exitCode, ...(typeof v.exitCode === 'number' && Number.isFinite(v.exitCode) && Number.isInteger(v.exitCode) ? { exitCode: v.exitCode } : {}) });
|
|
74
|
+
bad('remote-frame', 'FRAME_SEQUENCE_OR_VALUE_INVALID', { index });
|
|
52
75
|
}
|
|
53
76
|
if (!ended || exitCode === null)
|
|
54
|
-
|
|
77
|
+
bad('remote-frame', 'FRAME_END_MISSING');
|
|
55
78
|
return { exitCode, stdout: Buffer.concat(out).toString('utf8'), stderr: Buffer.concat(err).toString('utf8') };
|
|
56
79
|
}
|
|
57
80
|
export function createSsh(runner, maxOutputBytes) {
|
|
@@ -66,12 +89,12 @@ export function createSsh(runner, maxOutputBytes) {
|
|
|
66
89
|
status: async (t, p, signal) => {
|
|
67
90
|
try {
|
|
68
91
|
if (!['codex', 'claude'].includes(p) || !Number.isSafeInteger(maxOutputBytes) || maxOutputBytes < 1)
|
|
69
|
-
return
|
|
92
|
+
return authFailure('ssh-status', 'STATUS_ARGUMENT_INVALID');
|
|
70
93
|
const source = await readFile(new URL('../helper/auth-status.cjs', import.meta.url), 'utf8');
|
|
71
94
|
return ok(await runRemote(runner, t, 'node -e ' + "'" + source.replaceAll("'", "'\\''") + "' -- " + p + ' ' + maxOutputBytes, signal));
|
|
72
95
|
}
|
|
73
|
-
catch {
|
|
74
|
-
return
|
|
96
|
+
catch (error) {
|
|
97
|
+
return authFailure('ssh-status', 'STATUS_EXECUTION_FAILED', {}, error);
|
|
75
98
|
}
|
|
76
99
|
},
|
|
77
100
|
};
|
package/lib/startup.d.ts
CHANGED
|
@@ -1,10 +1,31 @@
|
|
|
1
|
-
import type { ConnectApi, ConnectionState, LoginPromptState, LoginPromptResult, Provider, NeedCode, Result } from './api.ts';
|
|
1
|
+
import type { ConnectApi, ConnectionState, LoginPromptState, LoginPromptResult, LoginState, Provider, NeedCode, Result } from './api.ts';
|
|
2
2
|
export type StartupState = LoginPromptState;
|
|
3
3
|
export declare function employeeMessage(code?: NeedCode): string;
|
|
4
4
|
type Operations = Pick<ConnectApi, 'connect' | 'checkLogin' | 'login' | 'getState'>;
|
|
5
5
|
export declare function createStartup(api: Operations, providers: Provider[], emit?: (s: LoginPromptState) => void, result?: (r: LoginPromptResult) => void, instanceId?: string): {
|
|
6
6
|
getState: () => LoginPromptState;
|
|
7
7
|
check: () => Promise<void>;
|
|
8
|
+
beginCheck: (provider: Provider) => {
|
|
9
|
+
provider: Provider;
|
|
10
|
+
generation: number;
|
|
11
|
+
serial: number;
|
|
12
|
+
};
|
|
13
|
+
finishCheck: (ticket: ReturnType<(provider: Provider) => {
|
|
14
|
+
provider: Provider;
|
|
15
|
+
generation: number;
|
|
16
|
+
serial: number;
|
|
17
|
+
}>, status: Result<LoginState>) => boolean;
|
|
18
|
+
currentTicket: (ticket: ReturnType<(provider: Provider) => {
|
|
19
|
+
provider: Provider;
|
|
20
|
+
generation: number;
|
|
21
|
+
serial: number;
|
|
22
|
+
}>) => boolean;
|
|
23
|
+
captureCheck: (provider: Provider) => {
|
|
24
|
+
provider: Provider;
|
|
25
|
+
generation: number;
|
|
26
|
+
serial: number;
|
|
27
|
+
};
|
|
28
|
+
inheritCheck(source: Result<LoginState>, derived: Result<LoginState>): void;
|
|
8
29
|
subscribe(listener: (s: LoginPromptState) => void): () => void;
|
|
9
30
|
request(provider: Provider, intent: "automatic" | "user"): Promise<Result<LoginPromptState>>;
|
|
10
31
|
respond: (requestId: string, action: "authorize" | "defer") => Promise<Result<LoginPromptState>>;
|
package/lib/startup.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
import { fail, need, ok } from "./needs.js";
|
|
3
|
+
import { authFailure } from "./auth-diagnostics.js";
|
|
3
4
|
export function employeeMessage(code) {
|
|
4
5
|
if (code === 'ENROLLMENT_RESULT_UNKNOWN' || code === 'ENROLLMENT_REJECTED')
|
|
5
6
|
return need(code).message;
|
|
@@ -19,19 +20,76 @@ export function createStartup(api, providers, emit = () => { }, result = () => {
|
|
|
19
20
|
let disposed = false, revision = 0, serial = 0, generation = 0, checkSerial = 0, connectionPhase = 'unavailable', observedConnected = false;
|
|
20
21
|
let flight, authorization, authorizationId;
|
|
21
22
|
const pending = new Set(), listeners = new Set(), deferred = new Set();
|
|
22
|
-
|
|
23
|
+
const providerSerial = new Map();
|
|
24
|
+
const resultTickets = new WeakMap();
|
|
25
|
+
let connectionEpoch, connectionInstance;
|
|
26
|
+
let state = { instanceId, revision: 0, phase: 'checking', message: '正在连接远程主机…', logins: {}, queuedProviders: [...providers], checkingProviders: [], providerNeeds: {}, technicalDetails: {} };
|
|
23
27
|
const publish = (next) => { state = { ...next, instanceId, revision: ++revision }; emit(structuredClone(state)); for (const listener of listeners)
|
|
24
28
|
listener(structuredClone(state)); };
|
|
25
29
|
const track = (p) => { pending.add(p); void p.then(() => pending.delete(p), () => pending.delete(p)); return p; };
|
|
26
|
-
const close = (outcome, code) => { if (state.requestId && state.provider)
|
|
27
|
-
|
|
28
|
-
|
|
30
|
+
const close = (outcome, code) => { if (state.requestId && state.provider) {
|
|
31
|
+
const terminal = { requestId: state.requestId, provider: state.provider, outcome, ...(code ? { need: need(code) } : {}) };
|
|
32
|
+
publish({ ...state, requestId: undefined, provider: undefined });
|
|
33
|
+
result(terminal);
|
|
34
|
+
} };
|
|
35
|
+
const blocked = (code) => { const mine = generation; close('invalidated', code); if (disposed || mine !== generation)
|
|
36
|
+
return; publish({ phase: 'blocked', message: employeeMessage(code), logins: {}, queuedProviders: [], checkingProviders: [], providerNeeds: {}, technicalDetails: { ...state.technicalDetails, code } }); };
|
|
29
37
|
const snapshot = () => state.phase === 'disposed' ? fail('DISPOSED') : state.phase === 'blocked' ? fail(state.technicalDetails.code ?? 'AUTH_STATUS_INVALID') : ok(structuredClone(state));
|
|
30
38
|
const ask = () => {
|
|
39
|
+
if (disposed || state.phase === 'blocked' || state.phase === 'disposed')
|
|
40
|
+
return;
|
|
41
|
+
if (state.checkingProviders.length) {
|
|
42
|
+
publish({ ...state, ...(!state.requestId ? { phase: 'checking', message: '正在检查登录状态…' } : {}) });
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
31
45
|
const provider = state.requestId && state.provider && state.logins[state.provider] === false && !deferred.has(state.provider) ? state.provider : providers.find(p => state.logins[p] === false && !deferred.has(p));
|
|
32
46
|
const same = provider && state.provider === provider && state.requestId && state.phase === 'login-required';
|
|
33
47
|
publish({ ...state, phase: provider ? 'login-required' : 'idle', provider, requestId: provider ? (same ? state.requestId : 'login-' + instanceId + '-' + (++serial)) : undefined, message: provider ? '需要登录,请到设置 → 远程主机连接 点登录' : Object.keys(state.providerNeeds).length ? Object.keys(state.providerNeeds).map(p => p === 'claude' ? 'Claude' : 'Codex').join('、') + ' 登录状态无法确认,请联系管理员。' : '远程主机连接检查完成。' });
|
|
34
48
|
};
|
|
49
|
+
const beginCheck = (provider) => {
|
|
50
|
+
const serial = (providerSerial.get(provider) ?? 0) + 1;
|
|
51
|
+
providerSerial.set(provider, serial);
|
|
52
|
+
publish({ ...state, ...(!authorization && !state.requestId ? { phase: 'checking', message: '正在检查登录状态…' } : {}), queuedProviders: (state.queuedProviders ?? []).filter(p => p !== provider), checkingProviders: [...new Set([...state.checkingProviders, provider])] });
|
|
53
|
+
return { provider, generation, serial };
|
|
54
|
+
};
|
|
55
|
+
const currentTicket = (ticket) => !disposed && ticket.generation === generation && (providerSerial.get(ticket.provider) ?? 0) === ticket.serial;
|
|
56
|
+
const finishCheck = (ticket, status) => {
|
|
57
|
+
const provider = ticket.provider;
|
|
58
|
+
if (!currentTicket(ticket))
|
|
59
|
+
return false;
|
|
60
|
+
const logins = { ...state.logins }, providerNeeds = { ...state.providerNeeds };
|
|
61
|
+
if (status.ok) {
|
|
62
|
+
logins[provider] = status.value.loggedIn;
|
|
63
|
+
delete providerNeeds[provider];
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
delete logins[provider];
|
|
67
|
+
providerNeeds[provider] = structuredClone(status.error);
|
|
68
|
+
}
|
|
69
|
+
const clearTicket = !authorization && state.requestId && state.provider === provider && logins[provider] !== false;
|
|
70
|
+
const terminal = clearTicket ? { requestId: state.requestId, provider, outcome: logins[provider] === true ? 'logged-in' : 'invalidated', ...(providerNeeds[provider] ? { need: structuredClone(providerNeeds[provider]) } : {}) } : undefined;
|
|
71
|
+
publish({ ...state, ...(clearTicket ? { requestId: undefined, provider: undefined } : {}), logins, providerNeeds, checkingProviders: state.checkingProviders.filter(p => p !== provider) });
|
|
72
|
+
if (terminal)
|
|
73
|
+
result(currentTicket(ticket) ? terminal : { ...terminal, outcome: 'invalidated', need: need(disposed ? 'DISPOSED' : 'CONNECTION_LOST') });
|
|
74
|
+
if (!currentTicket(ticket))
|
|
75
|
+
return false;
|
|
76
|
+
if (!flight && !authorization)
|
|
77
|
+
ask();
|
|
78
|
+
if (!currentTicket(ticket))
|
|
79
|
+
return false;
|
|
80
|
+
resultTickets.set(status, ticket);
|
|
81
|
+
return true;
|
|
82
|
+
};
|
|
83
|
+
const observeCheck = async (provider, operation) => {
|
|
84
|
+
const ticket = beginCheck(provider);
|
|
85
|
+
if (!currentTicket(ticket))
|
|
86
|
+
return { status: fail(disposed ? 'DISPOSED' : 'CONNECTION_LOST'), current: false };
|
|
87
|
+
const status = await operation();
|
|
88
|
+
if (currentTicket(ticket))
|
|
89
|
+
finishCheck(ticket, status);
|
|
90
|
+
const observed = resultTickets.get(status);
|
|
91
|
+
return { status, current: !!observed && currentTicket(observed) };
|
|
92
|
+
};
|
|
35
93
|
const check = () => {
|
|
36
94
|
if (disposed)
|
|
37
95
|
return Promise.resolve();
|
|
@@ -43,7 +101,8 @@ export function createStartup(api, providers, emit = () => { }, result = () => {
|
|
|
43
101
|
const run = async () => {
|
|
44
102
|
try {
|
|
45
103
|
// Preserve an existing question while gathering fresh evidence, so duplicate requests keep its ticket.
|
|
46
|
-
|
|
104
|
+
const planned = new Map(providers.map(p => [p, providerSerial.get(p) ?? 0]));
|
|
105
|
+
publish({ ...state, ...(!state.requestId ? { phase: 'checking', message: '正在检查登录状态…' } : {}), queuedProviders: providers.filter(p => !state.checkingProviders.includes(p)) });
|
|
47
106
|
const connected = await api.connect();
|
|
48
107
|
if (disposed || mine !== generation || checkMine !== checkSerial)
|
|
49
108
|
return;
|
|
@@ -51,35 +110,33 @@ export function createStartup(api, providers, emit = () => { }, result = () => {
|
|
|
51
110
|
blocked(connected.error.code);
|
|
52
111
|
return;
|
|
53
112
|
}
|
|
54
|
-
const logins = { ...state.logins }, providerNeeds = { ...state.providerNeeds };
|
|
55
113
|
for (const provider of providers) {
|
|
56
|
-
|
|
114
|
+
const pendingProvider = state.checkingProviders.includes(provider);
|
|
115
|
+
if (pendingProvider)
|
|
116
|
+
await new Promise(resolve => { const done = () => { if (disposed || mine !== generation || !state.checkingProviders.includes(provider)) {
|
|
117
|
+
listeners.delete(done);
|
|
118
|
+
resolve();
|
|
119
|
+
} }; listeners.add(done); done(); });
|
|
120
|
+
if (disposed || mine !== generation || checkMine !== checkSerial)
|
|
121
|
+
return;
|
|
122
|
+
if (pendingProvider || (providerSerial.get(provider) ?? 0) !== planned.get(provider)) {
|
|
123
|
+
publish({ ...state, queuedProviders: (state.queuedProviders ?? []).filter(p => p !== provider) });
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
const ticket = beginCheck(provider);
|
|
57
127
|
const status = await api.checkLogin(provider);
|
|
58
128
|
if (disposed || mine !== generation || checkMine !== checkSerial)
|
|
59
129
|
return;
|
|
130
|
+
// createConnect publishes its own newer ticket; standalone coordinators use this one.
|
|
131
|
+
finishCheck(ticket, status);
|
|
60
132
|
if (!status.ok) {
|
|
61
133
|
if (status.error.layer !== 'auth' && !['AUTH_STATUS_INVALID', 'LOGIN_FAILED', 'POLICY_DENIED'].includes(status.error.code)) {
|
|
62
134
|
blocked(status.error.code);
|
|
63
135
|
return;
|
|
64
136
|
}
|
|
65
|
-
delete logins[provider];
|
|
66
|
-
providerNeeds[provider] = status.error;
|
|
67
|
-
}
|
|
68
|
-
else {
|
|
69
|
-
logins[provider] = status.value.loggedIn;
|
|
70
|
-
delete providerNeeds[provider];
|
|
71
137
|
}
|
|
72
|
-
if (state.requestId && state.provider === provider && logins[provider] !== false) {
|
|
73
|
-
close(logins[provider] === true ? 'logged-in' : 'invalidated', providerNeeds[provider]?.code);
|
|
74
|
-
publish({ ...state, requestId: undefined, provider: undefined, phase: 'checking', message: '正在检查登录状态…' });
|
|
75
|
-
}
|
|
76
|
-
publish({ ...state, logins: { ...logins }, providerNeeds: { ...providerNeeds }, checkingProviders: state.checkingProviders.filter(p => p !== provider) });
|
|
77
|
-
}
|
|
78
|
-
if (state.requestId && state.provider && logins[state.provider] === true) {
|
|
79
|
-
close('logged-in');
|
|
80
|
-
publish({ ...state, requestId: undefined, provider: undefined, phase: 'checking', message: '正在检查登录状态…' });
|
|
81
138
|
}
|
|
82
|
-
publish({ ...state,
|
|
139
|
+
publish({ ...state, queuedProviders: [], technicalDetails: connected.value.hostContext ? { hostContext: structuredClone(connected.value.hostContext) } : {} });
|
|
83
140
|
ask();
|
|
84
141
|
}
|
|
85
142
|
catch {
|
|
@@ -96,7 +153,7 @@ export function createStartup(api, providers, emit = () => { }, result = () => {
|
|
|
96
153
|
const providerFailed = (provider, code, sourceNeed = need(code)) => {
|
|
97
154
|
const logins = { ...state.logins };
|
|
98
155
|
delete logins[provider];
|
|
99
|
-
publish({ ...state, phase: 'idle', requestId: undefined, provider: undefined, logins, checkingProviders:
|
|
156
|
+
publish({ ...state, phase: 'idle', requestId: undefined, provider: undefined, logins, checkingProviders: state.checkingProviders.filter(p => p !== provider), providerNeeds: { ...state.providerNeeds, [provider]: structuredClone(sourceNeed) } });
|
|
100
157
|
ask();
|
|
101
158
|
};
|
|
102
159
|
const respond = (requestId, action) => {
|
|
@@ -108,7 +165,10 @@ export function createStartup(api, providers, emit = () => { }, result = () => {
|
|
|
108
165
|
return Promise.resolve(fail('LOGIN_PROMPT_INVALID'));
|
|
109
166
|
const provider = state.provider;
|
|
110
167
|
if (action === 'defer') {
|
|
168
|
+
const mine = generation;
|
|
111
169
|
close('deferred');
|
|
170
|
+
if (disposed || mine !== generation)
|
|
171
|
+
return Promise.resolve(fail(disposed ? 'DISPOSED' : 'CONNECTION_LOST'));
|
|
112
172
|
deferred.add(provider);
|
|
113
173
|
publish({ ...state, requestId: undefined, provider: undefined });
|
|
114
174
|
ask();
|
|
@@ -119,29 +179,44 @@ export function createStartup(api, providers, emit = () => { }, result = () => {
|
|
|
119
179
|
const mine = generation;
|
|
120
180
|
checkSerial++;
|
|
121
181
|
authorizationId = requestId;
|
|
122
|
-
publish({ ...state, phase: 'authorizing', checkingProviders: [provider], message: '请在授权窗口完成本人登录。' });
|
|
182
|
+
publish({ ...state, phase: 'authorizing', queuedProviders: [], checkingProviders: [...new Set([...state.checkingProviders, provider])], message: '请在授权窗口完成本人登录。' });
|
|
183
|
+
const superseded = () => { const failure = authFailure('connect-check-login', 'STATUS_SUPERSEDED'); if (state.requestId === requestId)
|
|
184
|
+
publish({ ...state, phase: 'idle', requestId: undefined, provider: undefined }); result({ requestId, provider, outcome: 'invalidated', need: failure.error }); ask(); return failure; };
|
|
123
185
|
const run = async () => {
|
|
124
186
|
try {
|
|
125
187
|
await flight;
|
|
126
188
|
if (disposed || mine !== generation)
|
|
127
189
|
return fail(disposed ? 'DISPOSED' : 'CONNECTION_LOST');
|
|
128
|
-
const
|
|
190
|
+
const observation = await observeCheck(provider, () => api.checkLogin(provider));
|
|
129
191
|
if (disposed || mine !== generation)
|
|
130
192
|
return fail(disposed ? 'DISPOSED' : 'CONNECTION_LOST');
|
|
131
|
-
|
|
193
|
+
if (!observation.current)
|
|
194
|
+
return superseded();
|
|
195
|
+
const current = observation.status;
|
|
196
|
+
const login = current.ok && !current.value.loggedIn ? await observeCheck(provider, () => api.login(provider)) : observation;
|
|
197
|
+
const verified = login.status;
|
|
132
198
|
if (disposed || mine !== generation)
|
|
133
199
|
return fail(disposed ? 'DISPOSED' : 'CONNECTION_LOST');
|
|
200
|
+
const finalTicket = resultTickets.get(verified);
|
|
201
|
+
if (!login.current || !finalTicket || !currentTicket(finalTicket))
|
|
202
|
+
return superseded();
|
|
134
203
|
if (!verified.ok || !verified.value.loggedIn) {
|
|
135
204
|
const code = verified.ok ? 'LOGIN_FAILED' : verified.error.code;
|
|
136
|
-
result({ requestId, provider, outcome: 'failed', need: need(code) });
|
|
137
205
|
providerFailed(provider, code, verified.ok ? need(code) : verified.error);
|
|
138
|
-
|
|
206
|
+
result({ requestId, provider, outcome: 'failed', need: verified.ok ? need(code) : verified.error });
|
|
207
|
+
return disposed || mine !== generation ? fail(disposed ? 'DISPOSED' : 'CONNECTION_LOST') : verified.ok ? fail(code) : verified;
|
|
139
208
|
}
|
|
140
|
-
result({ requestId, provider, outcome: 'logged-in' });
|
|
141
209
|
deferred.delete(provider);
|
|
142
210
|
const providerNeeds = { ...state.providerNeeds };
|
|
143
211
|
delete providerNeeds[provider];
|
|
144
|
-
publish({ ...state, requestId: undefined, provider: undefined, logins: { ...state.logins, [provider]: true }, providerNeeds, checkingProviders:
|
|
212
|
+
publish({ ...state, requestId: undefined, provider: undefined, logins: { ...state.logins, [provider]: true }, providerNeeds, checkingProviders: state.checkingProviders.filter(p => p !== provider), phase: 'idle' });
|
|
213
|
+
if (disposed || mine !== generation) {
|
|
214
|
+
result({ requestId, provider, outcome: 'invalidated', need: need(disposed ? 'DISPOSED' : 'CONNECTION_LOST') });
|
|
215
|
+
return fail(disposed ? 'DISPOSED' : 'CONNECTION_LOST');
|
|
216
|
+
}
|
|
217
|
+
result({ requestId, provider, outcome: 'logged-in' });
|
|
218
|
+
if (disposed || mine !== generation)
|
|
219
|
+
return fail(disposed ? 'DISPOSED' : 'CONNECTION_LOST');
|
|
145
220
|
ask();
|
|
146
221
|
return snapshot();
|
|
147
222
|
}
|
|
@@ -161,7 +236,9 @@ export function createStartup(api, providers, emit = () => { }, result = () => {
|
|
|
161
236
|
} });
|
|
162
237
|
return promise;
|
|
163
238
|
};
|
|
164
|
-
return { getState: () => structuredClone(state), check,
|
|
239
|
+
return { getState: () => structuredClone(state), check, beginCheck, finishCheck, currentTicket, captureCheck: (provider) => ({ provider, generation, serial: providerSerial.get(provider) ?? 0 }),
|
|
240
|
+
inheritCheck(source, derived) { const ticket = resultTickets.get(source); if (ticket && currentTicket(ticket))
|
|
241
|
+
resultTickets.set(derived, ticket); },
|
|
165
242
|
subscribe(listener) { listeners.add(listener); return () => { listeners.delete(listener); }; },
|
|
166
243
|
async request(provider, intent) {
|
|
167
244
|
if (!providers.includes(provider) || !['automatic', 'user'].includes(intent))
|
|
@@ -176,6 +253,8 @@ export function createStartup(api, providers, emit = () => { }, result = () => {
|
|
|
176
253
|
return fail(disposed ? 'DISPOSED' : 'CONNECTION_LOST');
|
|
177
254
|
if (intent === 'user' && !authorization && state.logins[provider] === false && !state.providerNeeds[provider] && !state.checkingProviders.includes(provider) && state.phase !== 'blocked' && state.phase !== 'disposed' && state.provider !== provider) {
|
|
178
255
|
close('invalidated', 'LOGIN_PROMPT_INVALID');
|
|
256
|
+
if (disposed || mine !== generation)
|
|
257
|
+
return fail(disposed ? 'DISPOSED' : 'CONNECTION_LOST');
|
|
179
258
|
publish({ ...state, phase: 'login-required', provider, requestId: 'login-' + instanceId + '-' + (++serial), message: '需要登录,请到设置 → 远程主机连接 点登录' });
|
|
180
259
|
}
|
|
181
260
|
return state.providerNeeds[provider] ? { ok: false, error: structuredClone(state.providerNeeds[provider]) } : snapshot();
|
|
@@ -190,6 +269,18 @@ export function createStartup(api, providers, emit = () => { }, result = () => {
|
|
|
190
269
|
return;
|
|
191
270
|
const prior = connectionPhase;
|
|
192
271
|
connectionPhase = next.phase;
|
|
272
|
+
const changed = connectionEpoch !== undefined && (connectionEpoch !== next.epoch || connectionInstance !== next.instanceId);
|
|
273
|
+
connectionEpoch = next.epoch;
|
|
274
|
+
connectionInstance = next.instanceId;
|
|
275
|
+
if (changed) {
|
|
276
|
+
generation++;
|
|
277
|
+
flight = undefined;
|
|
278
|
+
const mine = generation;
|
|
279
|
+
close('invalidated', 'CONNECTION_LOST');
|
|
280
|
+
if (disposed || mine !== generation)
|
|
281
|
+
return;
|
|
282
|
+
publish({ phase: 'checking', message: '正在连接远程主机…', logins: {}, queuedProviders: [], checkingProviders: [], providerNeeds: {}, technicalDetails: {} });
|
|
283
|
+
}
|
|
193
284
|
if (next.phase === 'unavailable' || next.phase === 'disposed') {
|
|
194
285
|
generation++;
|
|
195
286
|
flight = undefined;
|
|
@@ -206,6 +297,6 @@ export function createStartup(api, providers, emit = () => { }, result = () => {
|
|
|
206
297
|
}
|
|
207
298
|
},
|
|
208
299
|
stop() { if (disposed)
|
|
209
|
-
return; disposed = true; generation++; close('invalidated', 'DISPOSED'); publish({ phase: 'disposed', message: '远程主机连接已关闭。', logins: {}, checkingProviders: [], providerNeeds: {}, technicalDetails: { code: 'DISPOSED' } }); listeners.clear(); },
|
|
300
|
+
return; disposed = true; generation++; close('invalidated', 'DISPOSED'); publish({ phase: 'disposed', message: '远程主机连接已关闭。', logins: {}, queuedProviders: [], checkingProviders: [], providerNeeds: {}, technicalDetails: { code: 'DISPOSED' } }); listeners.clear(); },
|
|
210
301
|
async dispose() { this.stop(); await Promise.allSettled([...pending]); }, };
|
|
211
302
|
}
|