@ai-workbench/connect 0.7.6 → 0.7.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +52 -5
- package/client/index.cjs +32 -4
- 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 +77 -1
- package/lib/components.d.ts +1 -1
- package/lib/components.js +46 -4
- package/lib/connect.js +43 -17
- package/lib/conversation-detail-settings.d.ts +32 -0
- package/lib/conversation-detail-settings.js +119 -0
- package/lib/conversation-details.d.ts +21 -0
- package/lib/conversation-details.js +381 -0
- package/lib/cordis.d.ts +2 -1
- package/lib/defaults.d.ts +4 -0
- package/lib/defaults.js +1 -0
- package/lib/index.d.ts +5 -1
- package/lib/index.js +26 -17
- package/lib/integration.js +20 -1
- package/lib/login.d.ts +8 -4
- package/lib/login.js +16 -25
- package/lib/needs.js +8 -0
- package/lib/processes.js +28 -2
- package/lib/profile-runtime.js +27 -1
- package/lib/profile-settings.js +3 -3
- package/lib/workspace-bindings.d.ts +4 -1
- package/lib/workspace-bindings.js +51 -1
- package/package.json +1 -1
package/lib/connect.js
CHANGED
|
@@ -14,6 +14,7 @@ export function validEnrollment(e) {
|
|
|
14
14
|
export function createConnect(ports, emit, options = {}) {
|
|
15
15
|
let state = { phase: 'idle' }, generation = 0, disposed = false;
|
|
16
16
|
let flight;
|
|
17
|
+
let trustedTarget;
|
|
17
18
|
const instanceId = randomUUID();
|
|
18
19
|
let connection = { instanceId, phase: 'unavailable', epoch: 0 };
|
|
19
20
|
const connectionListeners = new Set();
|
|
@@ -22,6 +23,8 @@ export function createConnect(ports, emit, options = {}) {
|
|
|
22
23
|
const next = { instanceId, phase, epoch: generation, ...(need ? { need } : {}) };
|
|
23
24
|
if (JSON.stringify(next) === JSON.stringify(connection))
|
|
24
25
|
return;
|
|
26
|
+
if (phase !== 'connected')
|
|
27
|
+
trustedTarget = undefined;
|
|
25
28
|
connection = structuredClone(next);
|
|
26
29
|
startup?.connectionChanged(next);
|
|
27
30
|
options.onConnectionState?.(structuredClone(next));
|
|
@@ -51,18 +54,24 @@ export function createConnect(ports, emit, options = {}) {
|
|
|
51
54
|
const connect = () => {
|
|
52
55
|
if (disposed)
|
|
53
56
|
return Promise.resolve(fail('DISPOSED'));
|
|
54
|
-
if (state.target)
|
|
55
|
-
return Promise.resolve(ok(structuredClone(
|
|
57
|
+
if (state.target && connection.phase === 'connected' && trustedTarget)
|
|
58
|
+
return Promise.resolve(ok(structuredClone(trustedTarget)));
|
|
56
59
|
if (flight)
|
|
57
60
|
return flight;
|
|
61
|
+
// Replacing an established transport is a new trusted generation, even without a network-lost event.
|
|
62
|
+
if (connection.phase === 'connected') {
|
|
63
|
+
generation++;
|
|
64
|
+
controller.abort();
|
|
65
|
+
controller = new AbortController();
|
|
66
|
+
loginFlights.clear();
|
|
67
|
+
}
|
|
58
68
|
const g = generation, signal = controller.signal;
|
|
59
|
-
|
|
60
|
-
setConnection('connecting');
|
|
69
|
+
setConnection('connecting');
|
|
61
70
|
const run = async () => {
|
|
62
71
|
if (!ports.enrollment)
|
|
63
|
-
return transportBlock(dependency('
|
|
72
|
+
return transportBlock(dependency('管理员发放的主机连接资料'));
|
|
64
73
|
if (!ports.network)
|
|
65
|
-
return transportBlock(dependency('
|
|
74
|
+
return transportBlock(dependency('远程网络连接服务'));
|
|
66
75
|
if (!ports.ssh)
|
|
67
76
|
return transportBlock(dependency('系统 SSH 适配器'));
|
|
68
77
|
try {
|
|
@@ -83,7 +92,7 @@ export function createConnect(ports, emit, options = {}) {
|
|
|
83
92
|
if (!n.ok)
|
|
84
93
|
return transportBlock(n);
|
|
85
94
|
if (typeof n.value.proxyCommand !== 'string' || n.value.proxyCommand.length === 0 || /[\r\n\x00]/.test(n.value.proxyCommand))
|
|
86
|
-
return transportBlock(dependency('
|
|
95
|
+
return transportBlock(dependency('有效的远程网络连接通道'));
|
|
87
96
|
const { host, username, port, projectRoot, knownHostsFile, session, hostContext } = e.value;
|
|
88
97
|
const target = { host, username, port, projectRoot, knownHostsFile, session, ...(hostContext ? { hostContext } : {}), proxyCommand: n.value.proxyCommand };
|
|
89
98
|
publish({ phase: 'ssh' });
|
|
@@ -94,11 +103,12 @@ export function createConnect(ports, emit, options = {}) {
|
|
|
94
103
|
if (!verified.ok)
|
|
95
104
|
return transportBlock(verified);
|
|
96
105
|
publish({ phase: 'connected', target });
|
|
106
|
+
trustedTarget = structuredClone(target);
|
|
97
107
|
setConnection('connected');
|
|
98
108
|
return ok(structuredClone(target));
|
|
99
109
|
}
|
|
100
110
|
catch {
|
|
101
|
-
return invalid(g) ?? transportBlock(dependency('
|
|
111
|
+
return invalid(g) ?? transportBlock(dependency('连接服务调用失败,根因未查明;请管理员检查连接资料、网络连接服务和 SSH 通道'));
|
|
102
112
|
}
|
|
103
113
|
};
|
|
104
114
|
const promise = track(run());
|
|
@@ -111,8 +121,8 @@ export function createConnect(ports, emit, options = {}) {
|
|
|
111
121
|
const checkLogin = async (provider) => {
|
|
112
122
|
if (provider !== 'claude' && provider !== 'codex')
|
|
113
123
|
return fail('AUTH_STATUS_INVALID');
|
|
114
|
-
const g = generation;
|
|
115
|
-
const connected = await
|
|
124
|
+
const connecting = connect(), g = generation;
|
|
125
|
+
const connected = await connecting;
|
|
116
126
|
const staleConnection = invalid(g);
|
|
117
127
|
if (staleConnection)
|
|
118
128
|
return staleConnection;
|
|
@@ -143,8 +153,8 @@ export function createConnect(ports, emit, options = {}) {
|
|
|
143
153
|
if (prior)
|
|
144
154
|
return prior;
|
|
145
155
|
const run = async () => {
|
|
146
|
-
const g = generation;
|
|
147
|
-
const connected = await
|
|
156
|
+
const connecting = connect(), g = generation;
|
|
157
|
+
const connected = await connecting;
|
|
148
158
|
const staleConnection = invalid(g);
|
|
149
159
|
if (staleConnection)
|
|
150
160
|
return staleConnection;
|
|
@@ -160,7 +170,9 @@ export function createConnect(ports, emit, options = {}) {
|
|
|
160
170
|
return stale;
|
|
161
171
|
if (!r.ok)
|
|
162
172
|
return ['network', 'ssh', 'enrollment'].includes(r.error.layer) ? transportBlock(r) : block(r);
|
|
163
|
-
const checked = await checkLogin(provider);
|
|
173
|
+
const checked = await checkLogin(provider), staleChecked = invalid(g);
|
|
174
|
+
if (staleChecked)
|
|
175
|
+
return staleChecked;
|
|
164
176
|
return checked.ok && !checked.value.loggedIn ? block(fail('LOGIN_FAILED')) : checked;
|
|
165
177
|
}
|
|
166
178
|
catch {
|
|
@@ -179,7 +191,7 @@ export function createConnect(ports, emit, options = {}) {
|
|
|
179
191
|
return fail('DISPOSED');
|
|
180
192
|
if (operation === 'create' && !isProjectName(name))
|
|
181
193
|
return fail('PROJECT_NAME_INVALID');
|
|
182
|
-
const g = generation, connected = await
|
|
194
|
+
const connecting = connect(), g = generation, connected = await connecting;
|
|
183
195
|
const stale = invalid(g);
|
|
184
196
|
if (stale)
|
|
185
197
|
return stale;
|
|
@@ -215,6 +227,19 @@ export function createConnect(ports, emit, options = {}) {
|
|
|
215
227
|
startup = createStartup(operations, options.providers ?? ['codex', 'claude'], options.onPromptState, options.onPromptResult, instanceId);
|
|
216
228
|
return {
|
|
217
229
|
...operations,
|
|
230
|
+
getModelCatalogTarget: provider => {
|
|
231
|
+
if (disposed)
|
|
232
|
+
return fail('DISPOSED');
|
|
233
|
+
if (provider !== 'claude' && provider !== 'codex')
|
|
234
|
+
return fail('MODEL_CATALOG_CONTEXT_INVALID');
|
|
235
|
+
if (connection.phase !== 'connected' || !trustedTarget)
|
|
236
|
+
return fail('CONNECTION_LOST');
|
|
237
|
+
return ok({ version: 1, purpose: 'model-catalog', scope: 'host-user', instanceId, epoch: connection.epoch, provider, target: structuredClone(trustedTarget) });
|
|
238
|
+
},
|
|
239
|
+
appendRemoteConversationDetail: async () => dependency('官方会话存储与持久诊断记录'),
|
|
240
|
+
getRemoteConversationDetailReceipt: async () => dependency('官方会话存储与持久诊断记录'),
|
|
241
|
+
getRemoteConversationDetailState: () => ({ instanceId, epoch: connection.epoch, revision: 0, phase: disposed ? 'disposed' : 'unavailable', sessions: [], need: need(disposed ? 'DISPOSED' : 'DEPENDENCY_REQUIRED') }),
|
|
242
|
+
onRemoteConversationDetailState: () => () => { }, listRemoteConversationDetails: async () => dependency('官方会话存储与持久诊断记录'),
|
|
218
243
|
getContractInfo: () => ({ apiVersion: '0.7.0', status: 'implemented', workspaceBinding: 'native-local-v1' }),
|
|
219
244
|
getEnrollmentState: () => ({ instanceId, revision: 0, phase: 'blocked', need: need('DEPENDENCY_REQUIRED') }), onEnrollmentState: () => () => { },
|
|
220
245
|
importEnrollment: async () => dependency('官方profileContext与原生目录选择服务'),
|
|
@@ -227,8 +252,8 @@ export function createConnect(ports, emit, options = {}) {
|
|
|
227
252
|
login: async (provider) => { const request = await startup.request(provider, 'automatic'); if (!request.ok)
|
|
228
253
|
return request; return request.value.logins[provider] === true ? ok({ provider, loggedIn: true }) : fail('LOGIN_REQUIRED'); },
|
|
229
254
|
ensureReady: provider => track((async () => {
|
|
230
|
-
const g = generation;
|
|
231
|
-
const r = await
|
|
255
|
+
const checking = checkLogin(provider), g = generation;
|
|
256
|
+
const r = await checking;
|
|
232
257
|
const stale = invalid(g);
|
|
233
258
|
if (stale)
|
|
234
259
|
return stale;
|
|
@@ -238,7 +263,8 @@ export function createConnect(ports, emit, options = {}) {
|
|
|
238
263
|
const request = await startup.request(provider, 'automatic');
|
|
239
264
|
return invalid(g) ?? (!request.ok ? request : fail('LOGIN_REQUIRED'));
|
|
240
265
|
}
|
|
241
|
-
|
|
266
|
+
const connected = await connect();
|
|
267
|
+
return invalid(g) ?? connected;
|
|
242
268
|
})()),
|
|
243
269
|
dispose: () => { if (disposal)
|
|
244
270
|
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,21 @@
|
|
|
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
|
+
getConnectionState: () => ConnectionState;
|
|
7
|
+
context: () => Promise<Result<{
|
|
8
|
+
identity: string;
|
|
9
|
+
}>>;
|
|
10
|
+
scope: (sessionId: string, provider: Provider) => Promise<Result<{
|
|
11
|
+
identity: string;
|
|
12
|
+
}>>;
|
|
13
|
+
onState?: (state: RemoteConversationDetailState) => void;
|
|
14
|
+
}
|
|
15
|
+
export declare function validRemoteConversationDetail(v: any): v is RemoteConversationDetail;
|
|
16
|
+
type DetailsApi = Pick<ConnectApi, 'appendRemoteConversationDetail' | 'getRemoteConversationDetailReceipt' | 'getRemoteConversationDetailState' | 'onRemoteConversationDetailState' | 'listRemoteConversationDetails'>;
|
|
17
|
+
export declare function createConversationDetails(options: ConversationDetailOptions): DetailsApi & {
|
|
18
|
+
observeConnection: (state: ConnectionState) => void;
|
|
19
|
+
dispose: () => Promise<void>;
|
|
20
|
+
};
|
|
21
|
+
export {};
|