@ai-workbench/connect 0.7.7 → 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 +62 -5
- package/client/index.cjs +33 -5
- package/helper/auth-status.cjs +6 -6
- package/helper/device-panel.cjs +4 -0
- package/helper/device-panel.css +1 -0
- package/helper/device-panel.html +1 -0
- package/helper/device-preload.cjs +5 -0
- package/helper/device-window.cjs +60 -0
- package/helper/remote-command.cjs +3 -2
- package/helper/remote-login.cjs +1 -1
- package/helper/window.cjs +8 -14
- package/lib/api.d.ts +98 -1
- 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.d.ts +1 -1
- package/lib/components.js +72 -4
- package/lib/connect.js +93 -27
- package/lib/conversation-detail-settings.d.ts +32 -0
- package/lib/conversation-detail-settings.js +119 -0
- package/lib/conversation-details.d.ts +24 -0
- package/lib/conversation-details.js +382 -0
- package/lib/cordis.d.ts +3 -1
- package/lib/defaults.d.ts +4 -0
- package/lib/defaults.js +1 -0
- package/lib/index.d.ts +4 -0
- package/lib/index.js +23 -15
- package/lib/integration.js +21 -1
- package/lib/login-diagnostics.d.ts +26 -0
- package/lib/login-diagnostics.js +42 -0
- package/lib/login.d.ts +8 -4
- package/lib/login.js +16 -25
- package/lib/needs.js +8 -0
- package/lib/processes.js +34 -7
- package/lib/profile-runtime.js +51 -11
- package/lib/profile-settings.js +3 -3
- package/lib/ssh.js +37 -14
- package/lib/startup.d.ts +22 -1
- package/lib/startup.js +124 -33
- package/lib/workspace-bindings.d.ts +4 -1
- package/lib/workspace-bindings.js +51 -1
- package/package.json +1 -1
package/lib/auth-status.js
CHANGED
|
@@ -1,18 +1,31 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { ok } from "./needs.js";
|
|
2
|
+
import { authFailure, outputSummary } from "./auth-diagnostics.js";
|
|
2
3
|
export function parseLoginStatus(provider, result) {
|
|
4
|
+
const invalid = (reason, details = {}) => authFailure('auth-status', reason, { exitCode: result.exitCode, ...(result.stderr !== '' ? { stderr: outputSummary(result.stderr) } : {}), ...details });
|
|
3
5
|
let v;
|
|
4
6
|
try {
|
|
5
7
|
v = JSON.parse(result.stdout);
|
|
6
8
|
}
|
|
7
|
-
catch {
|
|
8
|
-
return
|
|
9
|
+
catch (error) {
|
|
10
|
+
return invalid('STATUS_JSON_INVALID', { output: outputSummary(result.stdout), errorName: error instanceof SyntaxError ? 'SyntaxError' : 'UnknownError' });
|
|
9
11
|
}
|
|
10
|
-
if (!v || typeof v !== 'object' || Array.isArray(v)
|
|
11
|
-
return
|
|
12
|
+
if (!v || typeof v !== 'object' || Array.isArray(v))
|
|
13
|
+
return invalid('STATUS_OBJECT_INVALID');
|
|
14
|
+
const expected = ['exitCode', 'loggedIn', 'protocolVersion', 'provider'];
|
|
15
|
+
if (Object.keys(v).sort().join(',') !== expected.join(','))
|
|
16
|
+
return invalid('STATUS_KEYS_INVALID', { missing: expected.filter(key => !Object.hasOwn(v, key)), unexpectedCount: Object.keys(v).filter(key => !expected.includes(key)).length });
|
|
12
17
|
const status = v;
|
|
13
|
-
if (status.protocolVersion !== 1
|
|
14
|
-
return
|
|
18
|
+
if (status.protocolVersion !== 1)
|
|
19
|
+
return invalid('STATUS_VERSION_INVALID');
|
|
20
|
+
if (status.provider !== provider)
|
|
21
|
+
return invalid('STATUS_PROVIDER_INVALID');
|
|
22
|
+
if (status.exitCode !== result.exitCode)
|
|
23
|
+
return invalid('STATUS_EXIT_MISMATCH');
|
|
24
|
+
if (typeof status.loggedIn !== 'boolean')
|
|
25
|
+
return invalid('STATUS_LOGGED_IN_INVALID', { loggedInType: status.loggedIn === null ? 'null' : typeof status.loggedIn });
|
|
26
|
+
if (result.stderr !== '')
|
|
27
|
+
return invalid('STATUS_STDERR_NONEMPTY', { stderr: outputSummary(result.stderr) });
|
|
15
28
|
if (status.loggedIn ? result.exitCode !== 0 : provider === 'codex' ? result.exitCode !== 1 : result.exitCode !== 0 && result.exitCode !== 1)
|
|
16
|
-
return
|
|
29
|
+
return invalid('STATUS_EXIT_INVALID');
|
|
17
30
|
return ok({ provider, loggedIn: status.loggedIn });
|
|
18
31
|
}
|
package/lib/components.d.ts
CHANGED
|
@@ -7,4 +7,4 @@ export interface ComponentInventory {
|
|
|
7
7
|
claude: string;
|
|
8
8
|
codex?: string;
|
|
9
9
|
}
|
|
10
|
-
export declare function registerComponents(ctx: Context, inventory?: ComponentInventory, temporaryDataParent?: string, component?: ComponentProgressStore, sources?: ComponentSources, observationIntervalMs?: number): void;
|
|
10
|
+
export declare function registerComponents(ctx: Context, inventory?: ComponentInventory, temporaryDataParent?: string, component?: ComponentProgressStore, sources?: ComponentSources, observationIntervalMs?: number, metadataMaxBytes?: number): void;
|
package/lib/components.js
CHANGED
|
@@ -2,11 +2,12 @@ import { ComponentDownloadError } from "./component-download.js";
|
|
|
2
2
|
import { L_DEFAULTS } from "./defaults.js";
|
|
3
3
|
import { fail } from "./needs.js";
|
|
4
4
|
import { employeeMessage } from "./startup.js";
|
|
5
|
-
export function registerComponents(ctx, inventory, temporaryDataParent, component, sources, observationIntervalMs = L_DEFAULTS.componentObservationIntervalMs) {
|
|
5
|
+
export function registerComponents(ctx, inventory, temporaryDataParent, component, sources, observationIntervalMs = L_DEFAULTS.componentObservationIntervalMs, metadataMaxBytes = L_DEFAULTS.enrollmentMetadataMaxBytes) {
|
|
6
|
+
const detailSettings = () => ctx.get('aiWorkbenchConversationDetailSettings');
|
|
6
7
|
const api = () => ctx.get('aiWorkbenchConnect');
|
|
7
8
|
const getState = () => {
|
|
8
9
|
const network = ctx.get('aiWorkbenchNetwork'), connect = api(), state = connect?.getState(), networkState = network?.getState();
|
|
9
|
-
return { workspaceBindings: connect?.getWorkspaceBindingState?.() ?? null, loginComponent: { temporaryDataParent: temporaryDataParent ?? null, download: component?.getState() ?? null, source: sources?.getState() ?? null, observationIntervalMs }, startup: connect?.getLoginPromptState?.() ?? null, inventory: inventory ?? null, network: { serviceProvided: !!network, phase: networkState?.phase ?? 'missing', needCode: networkState?.error?.code ?? networkState?.need?.code }, enrollment: connect?.getEnrollmentState?.() ?? null, connection: connect?.getConnectionState?.() ?? null, connect: { serviceProvided: !!connect, phase: state?.phase ?? 'missing', message: state?.need ? employeeMessage(state.need.code) : '连接服务等待就绪' }, codex: inventory?.codex ? { installed: true, message: 'Codex 组件已安装' } : { installed: null, message: '组件清单未装配' } };
|
|
10
|
+
return { loginDiagnostics: connect?.getLoginDiagnosticState?.() ?? null, conversationDetails: connect?.getRemoteConversationDetailState?.() ?? null, conversationDetailSettings: detailSettings() ? { ...detailSettings().getState(), metadataMaxBytes } : null, workspaceBindings: connect?.getWorkspaceBindingState?.() ?? null, loginComponent: { temporaryDataParent: temporaryDataParent ?? null, download: component?.getState() ?? null, source: sources?.getState() ?? null, observationIntervalMs }, startup: connect?.getLoginPromptState?.() ?? null, inventory: inventory ?? null, network: { serviceProvided: !!network, phase: networkState?.phase ?? 'missing', needCode: networkState?.error?.code ?? networkState?.need?.code }, enrollment: connect?.getEnrollmentState?.() ?? null, connection: connect?.getConnectionState?.() ?? null, connect: { serviceProvided: !!connect, phase: state?.phase ?? 'missing', message: state?.need ? employeeMessage(state.need.code) : '连接服务等待就绪' }, codex: inventory?.codex ? { installed: true, message: 'Codex 组件已安装' } : { installed: null, message: '组件清单未装配' } };
|
|
10
11
|
};
|
|
11
12
|
ctx.provide('aiWorkbenchComponents', { getState });
|
|
12
13
|
ctx.inject(['webServer', 'connection'], host => {
|
|
@@ -27,9 +28,9 @@ export function registerComponents(ctx, inventory, temporaryDataParent, componen
|
|
|
27
28
|
res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
|
|
28
29
|
res.statusCode = 200;
|
|
29
30
|
let closed = false;
|
|
30
|
-
let unprompt = () => { }, unconnection = () => { }, unenrollment = () => { }, unnative = () => { }, undownload = () => { }, unsource = () => { };
|
|
31
|
+
let unprompt = () => { }, unconnection = () => { }, unenrollment = () => { }, unnative = () => { }, undownload = () => { }, unsource = () => { }, undetails = () => { }, unloginDetails = () => { }, undetailsettings = () => { };
|
|
31
32
|
const close = () => { if (closed)
|
|
32
|
-
return; closed = true; unprompt(); unconnection(); unenrollment(); unnative(); undownload(); unsource(); streams.delete(close); res.end(); };
|
|
33
|
+
return; closed = true; unprompt(); unconnection(); unenrollment(); unnative(); undownload(); unsource(); undetails(); unloginDetails(); undetailsettings(); streams.delete(close); res.end(); };
|
|
33
34
|
const send = () => { if (closed)
|
|
34
35
|
return; try {
|
|
35
36
|
if (!res.write('data: ' + JSON.stringify(getState()) + '\n\n'))
|
|
@@ -44,11 +45,78 @@ export function registerComponents(ctx, inventory, temporaryDataParent, componen
|
|
|
44
45
|
unnative = service.onWorkspaceBindingState?.(send) ?? (() => { });
|
|
45
46
|
undownload = component?.onState(send) ?? (() => { });
|
|
46
47
|
unsource = sources?.onState(send) ?? (() => { });
|
|
48
|
+
undetails = service.onRemoteConversationDetailState?.(send) ?? (() => { });
|
|
49
|
+
unloginDetails = service.onLoginDiagnosticState?.(send) ?? (() => { });
|
|
50
|
+
undetailsettings = detailSettings()?.onState(send) ?? (() => { });
|
|
47
51
|
streams.add(close);
|
|
48
52
|
res.once('close', close);
|
|
49
53
|
send();
|
|
50
54
|
return;
|
|
51
55
|
}
|
|
56
|
+
if (req.method === 'GET' && ['/ai-workbench-connect/login-diagnostics', '/ai-workbench-connect/login-diagnostic-receipt'].includes(path)) {
|
|
57
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
58
|
+
const unavailable = () => { res.statusCode = 503; res.end(JSON.stringify(fail('DEPENDENCY_REQUIRED'))); };
|
|
59
|
+
try {
|
|
60
|
+
const isReceipt = path.endsWith('-receipt');
|
|
61
|
+
if (!service || typeof service.getContractInfo !== 'function' || service.getContractInfo()?.loginDiagnostics !== 1 || typeof (isReceipt ? service.getLoginDiagnosticReceipt : service.listLoginDiagnostics) !== 'function') {
|
|
62
|
+
unavailable();
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
const params = new URL(req.url ?? '/', 'http://dsh').searchParams, allowed = isReceipt ? ['sessionId', 'provider', 'eventId'] : ['sessionId', 'provider', 'cursor'];
|
|
66
|
+
if ([...params.keys()].some(k => !allowed.includes(k) || params.getAll(k).length !== 1)) {
|
|
67
|
+
res.statusCode = 400;
|
|
68
|
+
res.end(JSON.stringify(fail('REMOTE_DETAIL_INVALID')));
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
const query = { sessionId: params.get('sessionId') ?? '', provider: params.get('provider') };
|
|
72
|
+
const result = isReceipt ? await service.getLoginDiagnosticReceipt({ ...query, eventId: params.get('eventId') ?? '' }) : await service.listLoginDiagnostics({ ...query, ...(params.has('cursor') ? { cursor: params.get('cursor') } : {}) });
|
|
73
|
+
res.statusCode = result.ok ? 200 : 409;
|
|
74
|
+
res.end(JSON.stringify(result));
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
unavailable();
|
|
78
|
+
}
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
if (req.method === 'GET' && ['/ai-workbench-connect/conversation-details', '/ai-workbench-connect/conversation-detail-receipt'].includes(path) && service) {
|
|
82
|
+
const params = new URL(req.url ?? '/', 'http://dsh').searchParams, isReceipt = path.endsWith('/conversation-detail-receipt'), allowed = isReceipt ? ['sessionId', 'provider', 'eventId'] : ['sessionId', 'provider', 'cursor'];
|
|
83
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
84
|
+
if ([...params.keys()].some(k => !allowed.includes(k) || params.getAll(k).length !== 1)) {
|
|
85
|
+
res.statusCode = 400;
|
|
86
|
+
res.end(JSON.stringify(fail('REMOTE_DETAIL_INVALID')));
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
const query = { sessionId: params.get('sessionId') ?? '', provider: params.get('provider') };
|
|
90
|
+
const result = isReceipt ? await service.getRemoteConversationDetailReceipt({ ...query, eventId: params.get('eventId') ?? '' }) : await service.listRemoteConversationDetails({ ...query, ...(params.has('cursor') ? { cursor: params.get('cursor') } : {}) });
|
|
91
|
+
res.statusCode = result.ok ? 200 : 409;
|
|
92
|
+
res.end(JSON.stringify(result));
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
if (req.method === 'POST' && path === '/ai-workbench-connect/conversation-detail-settings' && detailSettings()) {
|
|
96
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
97
|
+
try {
|
|
98
|
+
const chunks = [];
|
|
99
|
+
let bytes = 0;
|
|
100
|
+
for await (const chunk of req) {
|
|
101
|
+
const data = Buffer.from(chunk);
|
|
102
|
+
bytes += data.length;
|
|
103
|
+
if (!Number.isSafeInteger(metadataMaxBytes) || metadataMaxBytes < 1 || bytes > metadataMaxBytes) {
|
|
104
|
+
res.statusCode = 400;
|
|
105
|
+
res.end(JSON.stringify({ ...getState(), actionErrorCode: 'SETTINGS_REQUIRED', actionError: '设置未保存:输入超出 enrollmentMetadataMaxBytes,请仅提交两项正安全整数。' }));
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
chunks.push(data);
|
|
109
|
+
}
|
|
110
|
+
const result = await detailSettings().save(JSON.parse(Buffer.concat(chunks).toString('utf8')));
|
|
111
|
+
res.statusCode = result.ok ? 200 : 409;
|
|
112
|
+
res.end(JSON.stringify({ ...getState(), ...(!result.ok ? { actionErrorCode: result.error.code, actionError: result.error.message } : {}) }));
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
res.statusCode = 400;
|
|
116
|
+
res.end(JSON.stringify({ ...getState(), actionErrorCode: 'SETTINGS_REQUIRED', actionError: '设置未保存:请输入两项正安全整数。' }));
|
|
117
|
+
}
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
52
120
|
if (req.method === 'POST' && /^\/ai-workbench-connect\/component-download\/cancel\/[a-zA-Z0-9-]+$/.test(path) && component) {
|
|
53
121
|
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
54
122
|
if (Number(req.headers?.['content-length'] ?? 0) > 0 || req.headers?.['transfer-encoding']) {
|
package/lib/connect.js
CHANGED
|
@@ -3,6 +3,7 @@ import { dependency, fail, ok, need } from "./needs.js";
|
|
|
3
3
|
import { isProjectName } from "./projects.js";
|
|
4
4
|
import { createStartup } from "./startup.js";
|
|
5
5
|
import { parseLoginStatus } from "./auth-status.js";
|
|
6
|
+
import { authFailure } from "./auth-diagnostics.js";
|
|
6
7
|
export function validEnrollment(e) {
|
|
7
8
|
return typeof e === 'object' && e !== null && typeof e.host === 'string' && /^[a-zA-Z0-9][a-zA-Z0-9.-]*$/.test(e.host)
|
|
8
9
|
&& typeof e.username === 'string' && /^emp-[a-z0-9][a-z0-9-]*$/.test(e.username)
|
|
@@ -14,6 +15,7 @@ export function validEnrollment(e) {
|
|
|
14
15
|
export function createConnect(ports, emit, options = {}) {
|
|
15
16
|
let state = { phase: 'idle' }, generation = 0, disposed = false;
|
|
16
17
|
let flight;
|
|
18
|
+
let trustedTarget;
|
|
17
19
|
const instanceId = randomUUID();
|
|
18
20
|
let connection = { instanceId, phase: 'unavailable', epoch: 0 };
|
|
19
21
|
const connectionListeners = new Set();
|
|
@@ -22,6 +24,8 @@ export function createConnect(ports, emit, options = {}) {
|
|
|
22
24
|
const next = { instanceId, phase, epoch: generation, ...(need ? { need } : {}) };
|
|
23
25
|
if (JSON.stringify(next) === JSON.stringify(connection))
|
|
24
26
|
return;
|
|
27
|
+
if (phase !== 'connected')
|
|
28
|
+
trustedTarget = undefined;
|
|
25
29
|
connection = structuredClone(next);
|
|
26
30
|
startup?.connectionChanged(next);
|
|
27
31
|
options.onConnectionState?.(structuredClone(next));
|
|
@@ -48,21 +52,27 @@ export function createConnect(ports, emit, options = {}) {
|
|
|
48
52
|
loginFlights.clear();
|
|
49
53
|
} const value = block(r); if (!r.ok && !disposed)
|
|
50
54
|
setConnection('unavailable', r.error); return value; };
|
|
51
|
-
const connect = () => {
|
|
55
|
+
const connect = (reuseTrusted = false) => {
|
|
52
56
|
if (disposed)
|
|
53
57
|
return Promise.resolve(fail('DISPOSED'));
|
|
54
|
-
if (state.target)
|
|
55
|
-
return Promise.resolve(ok(structuredClone(
|
|
58
|
+
if ((state.target || reuseTrusted) && connection.phase === 'connected' && trustedTarget)
|
|
59
|
+
return Promise.resolve(ok(structuredClone(trustedTarget)));
|
|
56
60
|
if (flight)
|
|
57
61
|
return flight;
|
|
62
|
+
// Replacing an established transport is a new trusted generation, even without a network-lost event.
|
|
63
|
+
if (connection.phase === 'connected') {
|
|
64
|
+
generation++;
|
|
65
|
+
controller.abort();
|
|
66
|
+
controller = new AbortController();
|
|
67
|
+
loginFlights.clear();
|
|
68
|
+
}
|
|
58
69
|
const g = generation, signal = controller.signal;
|
|
59
|
-
|
|
60
|
-
setConnection('connecting');
|
|
70
|
+
setConnection('connecting');
|
|
61
71
|
const run = async () => {
|
|
62
72
|
if (!ports.enrollment)
|
|
63
|
-
return transportBlock(dependency('
|
|
73
|
+
return transportBlock(dependency('管理员发放的主机连接资料'));
|
|
64
74
|
if (!ports.network)
|
|
65
|
-
return transportBlock(dependency('
|
|
75
|
+
return transportBlock(dependency('远程网络连接服务'));
|
|
66
76
|
if (!ports.ssh)
|
|
67
77
|
return transportBlock(dependency('系统 SSH 适配器'));
|
|
68
78
|
try {
|
|
@@ -83,7 +93,7 @@ export function createConnect(ports, emit, options = {}) {
|
|
|
83
93
|
if (!n.ok)
|
|
84
94
|
return transportBlock(n);
|
|
85
95
|
if (typeof n.value.proxyCommand !== 'string' || n.value.proxyCommand.length === 0 || /[\r\n\x00]/.test(n.value.proxyCommand))
|
|
86
|
-
return transportBlock(dependency('
|
|
96
|
+
return transportBlock(dependency('有效的远程网络连接通道'));
|
|
87
97
|
const { host, username, port, projectRoot, knownHostsFile, session, hostContext } = e.value;
|
|
88
98
|
const target = { host, username, port, projectRoot, knownHostsFile, session, ...(hostContext ? { hostContext } : {}), proxyCommand: n.value.proxyCommand };
|
|
89
99
|
publish({ phase: 'ssh' });
|
|
@@ -94,11 +104,12 @@ export function createConnect(ports, emit, options = {}) {
|
|
|
94
104
|
if (!verified.ok)
|
|
95
105
|
return transportBlock(verified);
|
|
96
106
|
publish({ phase: 'connected', target });
|
|
107
|
+
trustedTarget = structuredClone(target);
|
|
97
108
|
setConnection('connected');
|
|
98
109
|
return ok(structuredClone(target));
|
|
99
110
|
}
|
|
100
111
|
catch {
|
|
101
|
-
return invalid(g) ?? transportBlock(dependency('
|
|
112
|
+
return invalid(g) ?? transportBlock(dependency('连接服务调用失败,根因未查明;请管理员检查连接资料、网络连接服务和 SSH 通道'));
|
|
102
113
|
}
|
|
103
114
|
};
|
|
104
115
|
const promise = track(run());
|
|
@@ -108,30 +119,46 @@ export function createConnect(ports, emit, options = {}) {
|
|
|
108
119
|
void promise.then(clear, clear);
|
|
109
120
|
return promise;
|
|
110
121
|
};
|
|
111
|
-
const checkLogin = async (provider) => {
|
|
122
|
+
const checkLogin = async (provider, reuseTrusted = false) => {
|
|
112
123
|
if (provider !== 'claude' && provider !== 'codex')
|
|
113
124
|
return fail('AUTH_STATUS_INVALID');
|
|
114
|
-
const g = generation;
|
|
115
|
-
const connected = await
|
|
125
|
+
const connecting = connect(reuseTrusted), g = generation;
|
|
126
|
+
const connected = await connecting;
|
|
116
127
|
const staleConnection = invalid(g);
|
|
117
128
|
if (staleConnection)
|
|
118
129
|
return staleConnection;
|
|
119
130
|
if (!connected.ok)
|
|
120
131
|
return connected;
|
|
132
|
+
const signal = controller.signal;
|
|
121
133
|
publish({ phase: 'checking-login', target: connected.value, provider });
|
|
134
|
+
const stalePublish = invalid(g);
|
|
135
|
+
if (stalePublish)
|
|
136
|
+
return stalePublish;
|
|
137
|
+
const ticket = startup.beginCheck(provider);
|
|
138
|
+
const staleTicket = invalid(g);
|
|
139
|
+
if (staleTicket)
|
|
140
|
+
return staleTicket;
|
|
122
141
|
try {
|
|
123
|
-
const raw = await ports.ssh.status(connected.value, provider,
|
|
142
|
+
const raw = await ports.ssh.status(connected.value, provider, signal);
|
|
124
143
|
const stale = invalid(g);
|
|
125
144
|
if (stale)
|
|
126
145
|
return stale;
|
|
127
146
|
const status = raw.ok ? parseLoginStatus(provider, raw.value) : raw;
|
|
147
|
+
if (!startup.finishCheck(ticket, status))
|
|
148
|
+
return invalid(g) ?? authFailure('connect-check-login', 'STATUS_SUPERSEDED');
|
|
128
149
|
if (!status.ok)
|
|
129
150
|
return ['network', 'ssh', 'enrollment'].includes(status.error.layer) ? transportBlock(status) : block(status);
|
|
130
151
|
publish(status.value.loggedIn ? { phase: 'ready', target: connected.value, provider } : { phase: 'connected', target: connected.value, provider });
|
|
131
|
-
return status;
|
|
152
|
+
return invalid(g) ?? status;
|
|
132
153
|
}
|
|
133
|
-
catch {
|
|
134
|
-
|
|
154
|
+
catch (error) {
|
|
155
|
+
const stale = invalid(g);
|
|
156
|
+
if (stale)
|
|
157
|
+
return stale;
|
|
158
|
+
const failure = authFailure('connect-check-login', 'STATUS_EXCEPTION', {}, error);
|
|
159
|
+
if (!startup.finishCheck(ticket, failure))
|
|
160
|
+
return authFailure('connect-check-login', 'STATUS_SUPERSEDED');
|
|
161
|
+
return block(failure);
|
|
135
162
|
}
|
|
136
163
|
};
|
|
137
164
|
const login = (provider) => {
|
|
@@ -143,8 +170,8 @@ export function createConnect(ports, emit, options = {}) {
|
|
|
143
170
|
if (prior)
|
|
144
171
|
return prior;
|
|
145
172
|
const run = async () => {
|
|
146
|
-
const g = generation;
|
|
147
|
-
const connected = await
|
|
173
|
+
const connecting = connect(), g = generation;
|
|
174
|
+
const connected = await connecting;
|
|
148
175
|
const staleConnection = invalid(g);
|
|
149
176
|
if (staleConnection)
|
|
150
177
|
return staleConnection;
|
|
@@ -152,19 +179,39 @@ export function createConnect(ports, emit, options = {}) {
|
|
|
152
179
|
return connected;
|
|
153
180
|
if (!ports.login)
|
|
154
181
|
return block(dependency('Electron 登录助手及已确认设置'));
|
|
182
|
+
const owner = startup.captureCheck(provider), signal = controller.signal;
|
|
155
183
|
publish({ phase: 'login', target: connected.value, provider });
|
|
184
|
+
const stalePublish = invalid(g);
|
|
185
|
+
if (stalePublish)
|
|
186
|
+
return stalePublish;
|
|
187
|
+
if (!startup.currentTicket(owner))
|
|
188
|
+
return authFailure('connect-check-login', 'STATUS_SUPERSEDED');
|
|
156
189
|
try {
|
|
157
|
-
const r = await ports.login.run(connected.value, provider,
|
|
190
|
+
const r = await ports.login.run(connected.value, provider, signal);
|
|
158
191
|
const stale = invalid(g);
|
|
159
192
|
if (stale)
|
|
160
193
|
return stale;
|
|
194
|
+
if (!startup.currentTicket(owner))
|
|
195
|
+
return authFailure('connect-check-login', 'STATUS_SUPERSEDED');
|
|
161
196
|
if (!r.ok)
|
|
162
197
|
return ['network', 'ssh', 'enrollment'].includes(r.error.layer) ? transportBlock(r) : block(r);
|
|
163
|
-
const checked = await checkLogin(provider);
|
|
164
|
-
|
|
198
|
+
const checked = await checkLogin(provider), staleChecked = invalid(g);
|
|
199
|
+
if (staleChecked)
|
|
200
|
+
return staleChecked;
|
|
201
|
+
if (checked.ok && !checked.value.loggedIn) {
|
|
202
|
+
const failed = block(fail('LOGIN_FAILED'));
|
|
203
|
+
startup.inheritCheck(checked, failed);
|
|
204
|
+
return failed;
|
|
205
|
+
}
|
|
206
|
+
return checked;
|
|
165
207
|
}
|
|
166
208
|
catch {
|
|
167
|
-
|
|
209
|
+
const stale = invalid(g);
|
|
210
|
+
if (stale)
|
|
211
|
+
return stale;
|
|
212
|
+
if (!startup.currentTicket(owner))
|
|
213
|
+
return authFailure('connect-check-login', 'STATUS_SUPERSEDED');
|
|
214
|
+
return block(fail('LOGIN_FAILED'));
|
|
168
215
|
}
|
|
169
216
|
};
|
|
170
217
|
const promise = track(run());
|
|
@@ -179,7 +226,7 @@ export function createConnect(ports, emit, options = {}) {
|
|
|
179
226
|
return fail('DISPOSED');
|
|
180
227
|
if (operation === 'create' && !isProjectName(name))
|
|
181
228
|
return fail('PROJECT_NAME_INVALID');
|
|
182
|
-
const g = generation, connected = await
|
|
229
|
+
const connecting = connect(), g = generation, connected = await connecting;
|
|
183
230
|
const stale = invalid(g);
|
|
184
231
|
if (stale)
|
|
185
232
|
return stale;
|
|
@@ -212,9 +259,27 @@ export function createConnect(ports, emit, options = {}) {
|
|
|
212
259
|
}
|
|
213
260
|
});
|
|
214
261
|
const operations = { getState: () => structuredClone(state), connect, checkLogin: provider => track(checkLogin(provider)), login };
|
|
215
|
-
startup = createStartup(operations, options.providers ?? ['codex', 'claude'], options.onPromptState, options.onPromptResult, instanceId);
|
|
262
|
+
startup = createStartup({ ...operations, connect: () => connect(true), checkLogin: provider => track(checkLogin(provider, true)) }, options.providers ?? ['codex', 'claude'], options.onPromptState, options.onPromptResult, instanceId);
|
|
216
263
|
return {
|
|
217
264
|
...operations,
|
|
265
|
+
getModelCatalogTarget: provider => {
|
|
266
|
+
if (disposed)
|
|
267
|
+
return fail('DISPOSED');
|
|
268
|
+
if (provider !== 'claude' && provider !== 'codex')
|
|
269
|
+
return fail('MODEL_CATALOG_CONTEXT_INVALID');
|
|
270
|
+
if (connection.phase !== 'connected' || !trustedTarget)
|
|
271
|
+
return fail('CONNECTION_LOST');
|
|
272
|
+
return ok({ version: 1, purpose: 'model-catalog', scope: 'host-user', instanceId, epoch: connection.epoch, provider, target: structuredClone(trustedTarget) });
|
|
273
|
+
},
|
|
274
|
+
appendRemoteConversationDetail: async () => dependency('官方会话存储与持久诊断记录'),
|
|
275
|
+
appendLoginDiagnostic: async () => dependency('官方会话存储与持久登录诊断记录'),
|
|
276
|
+
getLoginDiagnosticReceipt: async () => dependency('官方会话存储与持久登录诊断记录'),
|
|
277
|
+
listLoginDiagnostics: async () => dependency('官方会话存储与持久登录诊断记录'),
|
|
278
|
+
getLoginDiagnosticState: () => ({ instanceId, epoch: connection.epoch, revision: 0, phase: disposed ? 'disposed' : 'unavailable', sessions: [], need: need(disposed ? 'DISPOSED' : 'DEPENDENCY_REQUIRED') }),
|
|
279
|
+
onLoginDiagnosticState: () => () => { },
|
|
280
|
+
getRemoteConversationDetailReceipt: async () => dependency('官方会话存储与持久诊断记录'),
|
|
281
|
+
getRemoteConversationDetailState: () => ({ instanceId, epoch: connection.epoch, revision: 0, phase: disposed ? 'disposed' : 'unavailable', sessions: [], need: need(disposed ? 'DISPOSED' : 'DEPENDENCY_REQUIRED') }),
|
|
282
|
+
onRemoteConversationDetailState: () => () => { }, listRemoteConversationDetails: async () => dependency('官方会话存储与持久诊断记录'),
|
|
218
283
|
getContractInfo: () => ({ apiVersion: '0.7.0', status: 'implemented', workspaceBinding: 'native-local-v1' }),
|
|
219
284
|
getEnrollmentState: () => ({ instanceId, revision: 0, phase: 'blocked', need: need('DEPENDENCY_REQUIRED') }), onEnrollmentState: () => () => { },
|
|
220
285
|
importEnrollment: async () => dependency('官方profileContext与原生目录选择服务'),
|
|
@@ -227,8 +292,8 @@ export function createConnect(ports, emit, options = {}) {
|
|
|
227
292
|
login: async (provider) => { const request = await startup.request(provider, 'automatic'); if (!request.ok)
|
|
228
293
|
return request; return request.value.logins[provider] === true ? ok({ provider, loggedIn: true }) : fail('LOGIN_REQUIRED'); },
|
|
229
294
|
ensureReady: provider => track((async () => {
|
|
230
|
-
const g = generation;
|
|
231
|
-
const r = await
|
|
295
|
+
const checking = checkLogin(provider), g = generation;
|
|
296
|
+
const r = await checking;
|
|
232
297
|
const stale = invalid(g);
|
|
233
298
|
if (stale)
|
|
234
299
|
return stale;
|
|
@@ -238,7 +303,8 @@ export function createConnect(ports, emit, options = {}) {
|
|
|
238
303
|
const request = await startup.request(provider, 'automatic');
|
|
239
304
|
return invalid(g) ?? (!request.ok ? request : fail('LOGIN_REQUIRED'));
|
|
240
305
|
}
|
|
241
|
-
|
|
306
|
+
const connected = await connect();
|
|
307
|
+
return invalid(g) ?? connected;
|
|
242
308
|
})()),
|
|
243
309
|
dispose: () => { if (disposal)
|
|
244
310
|
return disposal; startup.stop(); disposed = true; generation++; controller.abort(); unsubscribe?.(); publish({ phase: 'disposed' }); setConnection('disposed'); connectionListeners.clear(); disposal = Promise.allSettled([...pending]).then(() => startup.dispose()); return disposal; },
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export interface ConversationDetailSettingsValues {
|
|
2
|
+
maxEventBytes: number;
|
|
3
|
+
pageSize: number;
|
|
4
|
+
}
|
|
5
|
+
export interface ConversationDetailSettingsState {
|
|
6
|
+
instanceId: string;
|
|
7
|
+
revision: number;
|
|
8
|
+
phase: 'loading' | 'ready' | 'saving' | 'failed' | 'disposed';
|
|
9
|
+
values?: ConversationDetailSettingsValues;
|
|
10
|
+
source: 'config' | 'saved';
|
|
11
|
+
errorCode?: 'SETTINGS_REQUIRED' | 'REMOTE_DETAIL_STORAGE_FAILED';
|
|
12
|
+
}
|
|
13
|
+
export declare function validDetailSettings(v: any): v is ConversationDetailSettingsValues;
|
|
14
|
+
export declare function createConversationDetailSettings(options: {
|
|
15
|
+
profileDir?: string;
|
|
16
|
+
initial?: Partial<ConversationDetailSettingsValues>;
|
|
17
|
+
metadataMaxBytes?: number;
|
|
18
|
+
}): {
|
|
19
|
+
ready: Promise<void>;
|
|
20
|
+
getState: () => ConversationDetailSettingsState;
|
|
21
|
+
values: () => ConversationDetailSettingsValues | undefined;
|
|
22
|
+
onState(fn: () => void): () => void;
|
|
23
|
+
save: (input: ConversationDetailSettingsValues) => Promise<{
|
|
24
|
+
ok: false;
|
|
25
|
+
error: import("./api.ts").ConnectNeed;
|
|
26
|
+
} | {
|
|
27
|
+
ok: true;
|
|
28
|
+
value: undefined;
|
|
29
|
+
}>;
|
|
30
|
+
dispose(): Promise<void>;
|
|
31
|
+
};
|
|
32
|
+
export type ConversationDetailSettings = ReturnType<typeof createConversationDetailSettings>;
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { join, isAbsolute } from 'node:path';
|
|
2
|
+
import { lstat, open, realpath, rename, rm } from 'node:fs/promises';
|
|
3
|
+
import { constants } from 'node:fs';
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
import { CONVERSATION_DETAIL_DEFAULTS, L_DEFAULTS } from "./defaults.js";
|
|
6
|
+
import { privateDirectory, readPrivate } from "./private-files.js";
|
|
7
|
+
import { fail, ok } from "./needs.js";
|
|
8
|
+
const queues = new Map();
|
|
9
|
+
export function validDetailSettings(v) { return !!v && typeof v === 'object' && Object.keys(v).sort().join(',') === 'maxEventBytes,pageSize' && [v.maxEventBytes, v.pageSize].every(n => Number.isSafeInteger(n) && n > 0); }
|
|
10
|
+
export function createConversationDetailSettings(options) {
|
|
11
|
+
const root = options.profileDir ? join(options.profileDir, 'ai-workbench-connect') : undefined, file = root ? join(root, 'conversation-detail-settings.json') : undefined;
|
|
12
|
+
let state = { instanceId: randomUUID(), revision: 0, phase: 'loading', source: 'config' }, disposed = false, tail = Promise.resolve();
|
|
13
|
+
const listeners = new Set();
|
|
14
|
+
function publish(next) { state = { ...state, ...next, revision: state.revision + 1 }; for (const fn of listeners)
|
|
15
|
+
fn(); }
|
|
16
|
+
async function directory() { if (!options.profileDir || !root || !isAbsolute(options.profileDir) || await realpath(options.profileDir) !== options.profileDir)
|
|
17
|
+
throw Error('SETTINGS_PROFILE_INVALID'); await privateDirectory(root); if (await realpath(root) !== root)
|
|
18
|
+
throw Error('SETTINGS_PATH_INVALID'); const st = await lstat(root); if ((st.mode & 0o777) !== 0o700)
|
|
19
|
+
throw Error('SETTINGS_PATH_INVALID'); const parent = await open(options.profileDir, constants.O_RDONLY | constants.O_NOFOLLOW); try {
|
|
20
|
+
await parent.sync();
|
|
21
|
+
}
|
|
22
|
+
finally {
|
|
23
|
+
await parent.close();
|
|
24
|
+
} }
|
|
25
|
+
async function existing() { const st = await lstat(file); if (!st.isFile() || st.isSymbolicLink() || st.nlink !== 1 || (st.mode & 0o777) !== 0o600)
|
|
26
|
+
throw Error('SETTINGS_PATH_INVALID'); }
|
|
27
|
+
const ready = (async () => {
|
|
28
|
+
try {
|
|
29
|
+
await directory();
|
|
30
|
+
let values = { ...CONVERSATION_DETAIL_DEFAULTS, ...options.initial }, source = 'config';
|
|
31
|
+
try {
|
|
32
|
+
await existing();
|
|
33
|
+
const saved = JSON.parse(await readPrivate(file, options.metadataMaxBytes ?? L_DEFAULTS.enrollmentMetadataMaxBytes));
|
|
34
|
+
if (saved.version !== 1 || !validDetailSettings(saved.values))
|
|
35
|
+
throw Error('SETTINGS_INVALID');
|
|
36
|
+
values = saved.values;
|
|
37
|
+
source = 'saved';
|
|
38
|
+
}
|
|
39
|
+
catch (e) {
|
|
40
|
+
if (e.code !== 'ENOENT')
|
|
41
|
+
throw e;
|
|
42
|
+
}
|
|
43
|
+
if (!validDetailSettings(values))
|
|
44
|
+
throw Error('SETTINGS_INVALID');
|
|
45
|
+
if (!disposed)
|
|
46
|
+
publish({ phase: 'ready', values, source });
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
if (!disposed)
|
|
50
|
+
publish({ phase: 'failed', values: undefined, errorCode: 'REMOTE_DETAIL_STORAGE_FAILED' });
|
|
51
|
+
}
|
|
52
|
+
})();
|
|
53
|
+
function save(input) {
|
|
54
|
+
if (!validDetailSettings(input))
|
|
55
|
+
return Promise.resolve(fail('SETTINGS_REQUIRED'));
|
|
56
|
+
const values = structuredClone(input);
|
|
57
|
+
const task = (queues.get(file ?? '') ?? Promise.resolve()).then(async () => {
|
|
58
|
+
await ready;
|
|
59
|
+
if (disposed)
|
|
60
|
+
return fail('DISPOSED');
|
|
61
|
+
if (state.phase === 'failed')
|
|
62
|
+
return fail('REMOTE_DETAIL_STORAGE_FAILED');
|
|
63
|
+
publish({ phase: 'saving', errorCode: undefined });
|
|
64
|
+
let temp;
|
|
65
|
+
try {
|
|
66
|
+
await directory();
|
|
67
|
+
try {
|
|
68
|
+
await existing();
|
|
69
|
+
}
|
|
70
|
+
catch (e) {
|
|
71
|
+
if (e.code !== 'ENOENT')
|
|
72
|
+
throw e;
|
|
73
|
+
}
|
|
74
|
+
const bytes = JSON.stringify({ version: 1, values }) + '\n';
|
|
75
|
+
if (Buffer.byteLength(bytes) > (options.metadataMaxBytes ?? L_DEFAULTS.enrollmentMetadataMaxBytes))
|
|
76
|
+
throw Error('SETTINGS_LIMIT');
|
|
77
|
+
temp = join(root, '.conversation-detail-settings-' + randomUUID());
|
|
78
|
+
const h = await open(temp, 'wx', 0o600);
|
|
79
|
+
try {
|
|
80
|
+
await h.writeFile(bytes);
|
|
81
|
+
await h.sync();
|
|
82
|
+
}
|
|
83
|
+
finally {
|
|
84
|
+
await h.close();
|
|
85
|
+
}
|
|
86
|
+
if (disposed)
|
|
87
|
+
return fail('DISPOSED');
|
|
88
|
+
await rename(temp, file);
|
|
89
|
+
temp = undefined;
|
|
90
|
+
const dir = await open(root, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
91
|
+
try {
|
|
92
|
+
await dir.sync();
|
|
93
|
+
}
|
|
94
|
+
finally {
|
|
95
|
+
await dir.close();
|
|
96
|
+
}
|
|
97
|
+
if (disposed)
|
|
98
|
+
return fail('DISPOSED');
|
|
99
|
+
publish({ phase: 'ready', values, source: 'saved', errorCode: undefined });
|
|
100
|
+
return ok(undefined);
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
publish({ phase: 'failed', values: undefined, errorCode: 'REMOTE_DETAIL_STORAGE_FAILED' });
|
|
104
|
+
return fail('REMOTE_DETAIL_STORAGE_FAILED');
|
|
105
|
+
}
|
|
106
|
+
finally {
|
|
107
|
+
if (temp)
|
|
108
|
+
await rm(temp, { force: true });
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
const done = task.then(() => { }, () => { });
|
|
112
|
+
queues.set(file ?? '', done);
|
|
113
|
+
tail = done;
|
|
114
|
+
void done.then(() => { if (queues.get(file ?? '') === done)
|
|
115
|
+
queues.delete(file ?? ''); });
|
|
116
|
+
return task;
|
|
117
|
+
}
|
|
118
|
+
return { ready, getState: () => structuredClone(state), values: () => state.phase === 'ready' ? structuredClone(state.values) : undefined, onState(fn) { listeners.add(fn); return () => { listeners.delete(fn); }; }, save, async dispose() { disposed = true; await ready; await tail; publish({ phase: 'disposed', values: undefined }); listeners.clear(); } };
|
|
119
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { ConnectApi, ConnectionState, Provider, RemoteConversationDetail, RemoteConversationDetailState, Result } from './api.ts';
|
|
2
|
+
export interface ConversationDetailOptions {
|
|
3
|
+
profileDir: string;
|
|
4
|
+
maxEventBytes: number;
|
|
5
|
+
pageSize: number;
|
|
6
|
+
storageName?: 'conversation-details' | 'login-diagnostics';
|
|
7
|
+
validate?: (detail: RemoteConversationDetail) => boolean;
|
|
8
|
+
knownKinds?: readonly string[];
|
|
9
|
+
getConnectionState: () => ConnectionState;
|
|
10
|
+
context: () => Promise<Result<{
|
|
11
|
+
identity: string;
|
|
12
|
+
}>>;
|
|
13
|
+
scope: (sessionId: string, provider: Provider) => Promise<Result<{
|
|
14
|
+
identity: string;
|
|
15
|
+
}>>;
|
|
16
|
+
onState?: (state: RemoteConversationDetailState) => void;
|
|
17
|
+
}
|
|
18
|
+
export declare function validRemoteConversationDetail(v: any): v is RemoteConversationDetail;
|
|
19
|
+
type DetailsApi = Pick<ConnectApi, 'appendRemoteConversationDetail' | 'getRemoteConversationDetailReceipt' | 'getRemoteConversationDetailState' | 'onRemoteConversationDetailState' | 'listRemoteConversationDetails'>;
|
|
20
|
+
export declare function createConversationDetails(options: ConversationDetailOptions): DetailsApi & {
|
|
21
|
+
observeConnection: (state: ConnectionState) => void;
|
|
22
|
+
dispose: () => Promise<void>;
|
|
23
|
+
};
|
|
24
|
+
export {};
|