@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.
@@ -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, failed = false;
22
- child.stdin.on('error', () => { failed = true; child.kill('SIGTERM'); });
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', () => { failed = true; });
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
- failed = true;
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 => failed || signal.aborted ? reject(new Error('COMMAND_FAILED')) : resolve({ exitCode: code, stdout, stderr }));
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 });
@@ -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: 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
- }, 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: async () => { const current = api.getModelCatalogTarget('codex'); if (!current.ok)
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 previousContractInfo = api.getContractInfo, detailContractInfo = () => ({ ...previousContractInfo(), remoteConversationDetails: 1 });
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
- if (signal.aborted || raw.exitCode !== 0 || raw.stderr !== '')
22
- throw Error('REMOTE_RESULT_INVALID');
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
- throw Error('REMOTE_RESULT_INVALID');
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
- const v = JSON.parse(line);
30
- if (!v || typeof v !== 'object' || v.protocolVersion !== 1 || v.nonce !== nonce || ended)
31
- throw Error('REMOTE_RESULT_INVALID');
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
- throw Error('REMOTE_RESULT_INVALID');
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
- throw Error('REMOTE_RESULT_INVALID');
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
- throw Error('REMOTE_RESULT_INVALID');
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
- throw Error('REMOTE_RESULT_INVALID');
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 fail('AUTH_STATUS_INVALID');
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 fail('AUTH_STATUS_INVALID');
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
- let state = { instanceId, revision: 0, phase: 'checking', message: '正在连接远程主机…', logins: {}, checkingProviders: [], providerNeeds: {}, technicalDetails: {} };
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
- result({ requestId: state.requestId, provider: state.provider, outcome, ...(code ? { need: need(code) } : {}) }); };
28
- const blocked = (code) => { close('invalidated', code); publish({ phase: 'blocked', message: employeeMessage(code), logins: {}, checkingProviders: [], providerNeeds: {}, technicalDetails: { ...state.technicalDetails, code } }); };
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
- publish({ ...state, ...(!state.requestId ? { phase: 'checking', message: '正在检查登录状态…' } : {}), checkingProviders: [] });
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
- publish({ ...state, checkingProviders: [provider] });
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, logins, providerNeeds, checkingProviders: [], technicalDetails: connected.value.hostContext ? { hostContext: structuredClone(connected.value.hostContext) } : {} });
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: [], providerNeeds: { ...state.providerNeeds, [provider]: structuredClone(sourceNeed) } });
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 current = await api.checkLogin(provider);
190
+ const observation = await observeCheck(provider, () => api.checkLogin(provider));
129
191
  if (disposed || mine !== generation)
130
192
  return fail(disposed ? 'DISPOSED' : 'CONNECTION_LOST');
131
- const verified = current.ok && current.value.loggedIn ? current : current.ok ? await api.login(provider) : current;
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
- return verified.ok ? fail(code) : verified;
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: [], phase: 'idle' });
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ai-workbench/connect",
3
- "version": "0.7.9",
3
+ "version": "0.7.11",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=24.13.1"