@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/login.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { createRequire } from 'node:module';
|
|
2
2
|
const { isClaudeAuthorizeUrl, validateClaudePaste } = createRequire(import.meta.url)('../helper/claude-callback.cjs');
|
|
3
|
-
import { fail, NeedError
|
|
3
|
+
import { fail, NeedError } from "./needs.js";
|
|
4
4
|
export function extractLoginUrl(provider, text) {
|
|
5
|
-
for (const match of text.matchAll(/https:\/\/[^\s<>"']+/g)) {
|
|
5
|
+
for (const match of text.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '').matchAll(/https:\/\/[^\s<>"']+/g)) {
|
|
6
6
|
try {
|
|
7
7
|
const u = new URL(match[0]);
|
|
8
|
-
if (!u.username && !u.password && u.protocol === 'https:' && ((provider === 'codex' && u.
|
|
8
|
+
if (!u.username && !u.password && u.protocol === 'https:' && ((provider === 'codex' && u.href === 'https://auth.openai.com/codex/device') || (provider === 'claude' && isClaudeAuthorizeUrl(u))))
|
|
9
9
|
return u.href;
|
|
10
10
|
}
|
|
11
11
|
catch { /* not a complete URL yet */ }
|
|
@@ -18,8 +18,8 @@ export async function runLogin(t, p, ports, parentSignal) {
|
|
|
18
18
|
parentSignal.addEventListener('abort', parentAbort, { once: true });
|
|
19
19
|
if (parentSignal.aborted)
|
|
20
20
|
controller.abort();
|
|
21
|
-
let remote,
|
|
22
|
-
let result = fail('LOGIN_FAILED')
|
|
21
|
+
let remote, window, remove = () => { };
|
|
22
|
+
let result = fail('LOGIN_FAILED');
|
|
23
23
|
try {
|
|
24
24
|
if (signal.aborted)
|
|
25
25
|
return result;
|
|
@@ -30,17 +30,19 @@ export async function runLogin(t, p, ports, parentSignal) {
|
|
|
30
30
|
return url;
|
|
31
31
|
if (typeof url.value !== 'string' || extractLoginUrl(p, url.value) !== url.value)
|
|
32
32
|
return result;
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
33
|
+
let deviceCode;
|
|
34
|
+
if (p === 'codex') {
|
|
35
|
+
if (!remote.deviceCode)
|
|
36
|
+
return result;
|
|
37
|
+
const device = await Promise.race([remote.deviceCode, cancelled]);
|
|
38
|
+
if (!device.ok || typeof device.value !== 'string' || !device.value || /[\x00-\x1f\x7f]/.test(device.value))
|
|
39
|
+
return result;
|
|
40
|
+
deviceCode = device.value;
|
|
41
|
+
}
|
|
41
42
|
let codeSent = false, codeError = () => { };
|
|
42
43
|
const failedCode = new Promise(r => codeError = r);
|
|
43
|
-
|
|
44
|
+
const boot = p === 'claude' ? { provider: p, url: url.value } : { provider: p, url: url.value, deviceCode: deviceCode };
|
|
45
|
+
window = await ports.open(boot, async (code) => {
|
|
44
46
|
if (p !== 'claude' || codeSent || signal.aborted || !validateClaudePaste(url.value, code)) {
|
|
45
47
|
codeError(fail('LOGIN_FAILED'));
|
|
46
48
|
return;
|
|
@@ -65,17 +67,6 @@ export async function runLogin(t, p, ports, parentSignal) {
|
|
|
65
67
|
const cleanup = await Promise.allSettled([window?.close(), remote?.close()]);
|
|
66
68
|
if (cleanup.some(r => r.status === 'rejected'))
|
|
67
69
|
result = fail('LOGIN_FAILED');
|
|
68
|
-
releasing = true;
|
|
69
|
-
if (lease) {
|
|
70
|
-
try {
|
|
71
|
-
await lease.release();
|
|
72
|
-
}
|
|
73
|
-
catch (error) {
|
|
74
|
-
result = error instanceof NeedError ? { ok: false, error: error.need } : fail('LOGIN_FAILED');
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
if (leaseLost)
|
|
78
|
-
result = fail('CONNECTION_LOST');
|
|
79
70
|
}
|
|
80
71
|
return result;
|
|
81
72
|
}
|
package/lib/needs.js
CHANGED
|
@@ -1,4 +1,12 @@
|
|
|
1
1
|
const entries = {
|
|
2
|
+
REMOTE_DETAIL_INVALID: { layer: 'integration', message: '诊断内容格式无效或含未脱敏认证材料,已拒绝保存。', requires: '有效且已脱敏的诊断与可信连接', actor: 'plugin', action: { id: 'inspect-remote-details', label: '查看技术详情', available: false } },
|
|
3
|
+
REMOTE_DETAIL_SCOPE_INVALID: { layer: 'integration', message: '诊断内容与当前连接、原生会话或已核验品牌关联不符,已拒绝保存。', requires: '有效且已脱敏的诊断与可信连接', actor: 'plugin', action: { id: 'inspect-remote-details', label: '查看技术详情', available: false } },
|
|
4
|
+
REMOTE_DETAIL_STORAGE_FAILED: { layer: 'integration', message: '诊断记录无法持久保存或读取,结果无法确认,请联系管理员。', requires: '有效且已脱敏的诊断与可信连接', actor: 'plugin', action: { id: 'inspect-remote-details', label: '查看技术详情', available: false } },
|
|
5
|
+
REMOTE_DETAIL_TOO_LARGE: { layer: 'integration', message: '诊断事件超过 conversationDetails.maxEventBytes,已拒绝保存,未裁剪。', requires: '有效且已脱敏的诊断与可信连接', actor: 'plugin', action: { id: 'inspect-remote-details', label: '查看技术详情', available: false } },
|
|
6
|
+
REMOTE_DETAIL_SEQUENCE_INVALID: { layer: 'integration', message: '诊断事件序号没有严格递增,已拒绝保存。', requires: '有效且已脱敏的诊断与可信连接', actor: 'plugin', action: { id: 'inspect-remote-details', label: '查看技术详情', available: false } },
|
|
7
|
+
REMOTE_DETAIL_CONFLICT: { layer: 'integration', message: '此诊断事件编号已有不同内容,已拒绝保存。', requires: '有效且已脱敏的诊断与可信连接', actor: 'plugin', action: { id: 'inspect-remote-details', label: '查看技术详情', available: false } },
|
|
8
|
+
REMOTE_DETAIL_CURSOR_INVALID: { layer: 'integration', message: '详情分页位置与当前主机用户、会话、品牌或快照不符,请刷新详情。', requires: '有效且已脱敏的诊断与可信连接', actor: 'plugin', action: { id: 'inspect-remote-details', label: '查看技术详情', available: false } },
|
|
9
|
+
MODEL_CATALOG_CONTEXT_INVALID: { layer: 'integration', message: '模型目录查询的品牌无效。', requires: '有效且已脱敏的诊断与可信连接', actor: 'plugin', action: { id: 'inspect-remote-details', label: '查看技术详情', available: false } },
|
|
2
10
|
ACTIVATION_CONFLICT: { layer: 'network', message: '已有另一项开通正在进行,已停止重复开通,请联系管理员。', requires: '唯一的开通请求', actor: 'administrator', action: { id: 'inspect-network-enrollment', label: '联系管理员', available: false } },
|
|
3
11
|
ACTIVATION_CANCELLED: { layer: 'network', message: '本次网络开通已取消。', requires: '完成明确的开通操作', actor: 'plugin', action: { id: 'inspect-network-enrollment', label: '联系管理员', available: false } },
|
|
4
12
|
ENROLLMENT_CLEANUP_UNBOUND: { layer: 'network', message: '连接资料与已保存的入网身份不匹配,已停止清理,请联系管理员。', requires: '匹配的入网身份与原始连接资料', actor: 'administrator', action: { id: 'inspect-network-enrollment', label: '联系管理员', available: false } },
|
package/lib/processes.js
CHANGED
|
@@ -58,6 +58,29 @@ export async function createRemoteLogin(command, args, provider, maxBytes, signa
|
|
|
58
58
|
void ownership.catch(() => { });
|
|
59
59
|
let found = () => { };
|
|
60
60
|
const url = new Promise(r => found = r);
|
|
61
|
+
let foundCode = () => { }, deviceUrlSeen = false, awaitingCode = false, deviceSent = false;
|
|
62
|
+
const deviceCode = new Promise(r => foundCode = r);
|
|
63
|
+
const observeDevice = (line, stdout) => {
|
|
64
|
+
if (provider !== 'codex' || !stdout || deviceSent)
|
|
65
|
+
return;
|
|
66
|
+
const text = line.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '').trim();
|
|
67
|
+
if (!deviceUrlSeen) {
|
|
68
|
+
if (extractLoginUrl('codex', text) === text)
|
|
69
|
+
deviceUrlSeen = true;
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (text === '2. Enter this one-time code (expires in 15 minutes)') {
|
|
73
|
+
awaitingCode = true;
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
if (awaitingCode && text) {
|
|
77
|
+
awaitingCode = false;
|
|
78
|
+
if (!/[\x00-\x1f\x7f]/.test(text) && !text.startsWith('AW_LOGIN_')) {
|
|
79
|
+
deviceSent = true;
|
|
80
|
+
foundCode(ok(text));
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
};
|
|
61
84
|
const pending = new Map();
|
|
62
85
|
for (const stream of [child.stdout, child.stderr]) {
|
|
63
86
|
const record = { decoder: new StringDecoder('utf8'), line: '' };
|
|
@@ -73,6 +96,7 @@ export async function createRemoteLogin(command, args, provider, maxBytes, signa
|
|
|
73
96
|
const lines = record.line.split(/\r?\n/);
|
|
74
97
|
record.line = lines.pop();
|
|
75
98
|
for (const line of lines) {
|
|
99
|
+
observeDevice(line, stream === child.stdout);
|
|
76
100
|
if (protocol && line.startsWith('AW_LOGIN_PROCESS ')) {
|
|
77
101
|
try {
|
|
78
102
|
const v = JSON.parse(line.slice(17));
|
|
@@ -123,6 +147,8 @@ export async function createRemoteLogin(command, args, provider, maxBytes, signa
|
|
|
123
147
|
ownReject();
|
|
124
148
|
if (!urlSent)
|
|
125
149
|
found(fail('LOGIN_FAILED'));
|
|
150
|
+
if (!deviceSent)
|
|
151
|
+
foundCode(fail('LOGIN_FAILED'));
|
|
126
152
|
signal.removeEventListener('abort', abort);
|
|
127
153
|
resolve(!invalid && !signal.aborted && code === 0 && (!protocol || (owned && terminal?.exitCode === 0 && !terminal.stopping)) ? ok(undefined) : fail('LOGIN_FAILED'));
|
|
128
154
|
}));
|
|
@@ -131,9 +157,9 @@ export async function createRemoteLogin(command, args, provider, maxBytes, signa
|
|
|
131
157
|
if (signal.aborted)
|
|
132
158
|
abort();
|
|
133
159
|
child.stdin.on('error', () => { invalid = true; });
|
|
134
|
-
return { url, completion, ...(protocol ? { ownership } : {}),
|
|
160
|
+
return { url, completion, ...(provider === 'codex' ? { deviceCode } : {}), ...(protocol ? { ownership } : {}),
|
|
135
161
|
writeCode: async (code) => {
|
|
136
|
-
if (signal.aborted || child.exitCode !== null || child.signalCode !== null || child.stdin.writableEnded || /[\x00-\x20\x7f]/.test(code) || !code)
|
|
162
|
+
if (provider !== 'claude' || signal.aborted || child.exitCode !== null || child.signalCode !== null || child.stdin.writableEnded || /[\x00-\x20\x7f]/.test(code) || !code)
|
|
137
163
|
throw new Error('LOGIN_CODE_REJECTED');
|
|
138
164
|
await new Promise((yes, no) => { const failed = () => no(new Error('LOGIN_STDIN_FAILED')); child.stdin.once('error', failed); const done = () => { child.stdin.removeListener('error', failed); yes(); }; if (protocol)
|
|
139
165
|
child.stdin.write(JSON.stringify({ type: 'code', value: code }) + '\n', done);
|
package/lib/profile-runtime.js
CHANGED
|
@@ -3,7 +3,10 @@ import { createProfileEnrollment } from "./profile-enrollment.js";
|
|
|
3
3
|
import { createWorkspaceBindings } from "./workspace-bindings.js";
|
|
4
4
|
import { NeedError, need, fail } from "./needs.js";
|
|
5
5
|
import { createNativeImportPicker } from "./native-import-picker.js";
|
|
6
|
-
import {
|
|
6
|
+
import { createHash } from 'node:crypto';
|
|
7
|
+
import { readPrivate } from "./private-files.js";
|
|
8
|
+
import { createConversationDetails } from "./conversation-details.js";
|
|
9
|
+
import { CONVERSATION_DETAIL_DEFAULTS, L_DEFAULTS } from "./defaults.js";
|
|
7
10
|
import { createProfileActivation } from "./profile-activation.js";
|
|
8
11
|
// Composition only: consume official services; never provide replacements or alter default entries.
|
|
9
12
|
export function attachProfileRuntime(ctx, api, ports, config, network) {
|
|
@@ -37,6 +40,29 @@ export function attachProfileRuntime(ctx, api, ports, config, network) {
|
|
|
37
40
|
throw new NeedError(need('DEPENDENCY_REQUIRED'));
|
|
38
41
|
return (await persistence.list()).find(s => s.header.id === id)?.header;
|
|
39
42
|
}, nextRevision: () => ++workspaceRevision, onState: state => ctx.emit('ai-workbench/workspace-binding-state', state), diagnostic: error => { lastWorkspaceError = error; ctx.emit('ai-workbench/workspace-diagnostic', error); } });
|
|
43
|
+
const settings = ctx.get('aiWorkbenchConversationDetailSettings');
|
|
44
|
+
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
|
+
} },
|
|
53
|
+
scope: async (sessionId, provider) => { const current = api.getModelCatalogTarget(provider); if (!current.ok)
|
|
54
|
+
return current; return bindings.verifyRemoteConversationScope(sessionId, provider, current.value.target); },
|
|
55
|
+
onState: state => ctx.emit('ai-workbench/remote-conversation-detail-state', state) });
|
|
56
|
+
api.appendRemoteConversationDetail = details.appendRemoteConversationDetail;
|
|
57
|
+
api.getRemoteConversationDetailReceipt = details.getRemoteConversationDetailReceipt;
|
|
58
|
+
api.getRemoteConversationDetailState = details.getRemoteConversationDetailState;
|
|
59
|
+
api.onRemoteConversationDetailState = details.onRemoteConversationDetailState;
|
|
60
|
+
api.listRemoteConversationDetails = details.listRemoteConversationDetails;
|
|
61
|
+
const previousContractInfo = api.getContractInfo, detailContractInfo = () => ({ ...previousContractInfo(), remoteConversationDetails: 1 });
|
|
62
|
+
api.getContractInfo = detailContractInfo;
|
|
63
|
+
native.effect(() => { details.observeConnection(api.getConnectionState()); return api.onConnectionState(details.observeConnection); });
|
|
64
|
+
native.effect(() => () => { if (api.getContractInfo === detailContractInfo)
|
|
65
|
+
api.getContractInfo = previousContractInfo; return details.dispose(); });
|
|
40
66
|
native.effect(() => api.onConnectionState(bindings.observeConnection));
|
|
41
67
|
api.getWorkspaceBindingState = bindings.getWorkspaceBindingState;
|
|
42
68
|
api.onWorkspaceBindingState = bindings.onWorkspaceBindingState;
|
package/lib/profile-settings.js
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import { isAbsolute, join } from 'node:path';
|
|
2
2
|
import { COMPONENT_SOURCES, preset } from "./component-source.js";
|
|
3
|
-
import { L_DEFAULTS, ELECTRON_PIN } from "./defaults.js";
|
|
3
|
+
import { L_DEFAULTS, ELECTRON_PIN, CONVERSATION_DETAIL_DEFAULTS } from "./defaults.js";
|
|
4
4
|
// Owner approved technical values, 2026-10-20 settings decision, part two.
|
|
5
|
-
//
|
|
5
|
+
// 组件直接下载;Claude网页遵循员工系统网络,Codex暂保留N主机代理。
|
|
6
6
|
export function profileSettings(config, profileDir, platform = process.platform, arch = process.arch) {
|
|
7
7
|
if (typeof profileDir !== 'string' || !isAbsolute(profileDir))
|
|
8
8
|
return config;
|
|
9
9
|
const runtime = platform === 'darwin' && arch === 'arm64' ? { ...ELECTRON_PIN, source: COMPONENT_SOURCES.npmmirror.source } : {};
|
|
10
10
|
const chosen = config.helper?.componentDownloadSource;
|
|
11
11
|
const selected = chosen === undefined ? {} : { source: preset(chosen) ? COMPONENT_SOURCES[chosen].source : '' };
|
|
12
|
-
return { ...config, commandOutputMaxBytes: config.commandOutputMaxBytes === undefined ? L_DEFAULTS.commandOutputMaxBytes : config.commandOutputMaxBytes, helper: { ...runtime, componentDownloadMaxRedirects: L_DEFAULTS.componentDownloadMaxRedirects, componentObservationIntervalMs: L_DEFAULTS.componentObservationIntervalMs, maxDownloadBytes: L_DEFAULTS.maxDownloadBytes, maxExtractedBytes: L_DEFAULTS.maxExtractedBytes, loginOutputMaxBytes: L_DEFAULTS.loginOutputMaxBytes, ipcMaxBytes: L_DEFAULTS.ipcMaxBytes, remoteKillGraceMs: L_DEFAULTS.remoteKillGraceMs, ...config.helper, ...selected, cacheDir: config.helper?.cacheDir === undefined ? join(profileDir, 'ai-workbench-connect', 'login-component-cache') : config.helper.cacheDir } };
|
|
12
|
+
return { ...config, conversationDetails: { ...CONVERSATION_DETAIL_DEFAULTS, ...config.conversationDetails }, commandOutputMaxBytes: config.commandOutputMaxBytes === undefined ? L_DEFAULTS.commandOutputMaxBytes : config.commandOutputMaxBytes, helper: { ...runtime, componentDownloadMaxRedirects: L_DEFAULTS.componentDownloadMaxRedirects, componentObservationIntervalMs: L_DEFAULTS.componentObservationIntervalMs, maxDownloadBytes: L_DEFAULTS.maxDownloadBytes, maxExtractedBytes: L_DEFAULTS.maxExtractedBytes, loginOutputMaxBytes: L_DEFAULTS.loginOutputMaxBytes, ipcMaxBytes: L_DEFAULTS.ipcMaxBytes, remoteKillGraceMs: L_DEFAULTS.remoteKillGraceMs, ...config.helper, ...selected, cacheDir: config.helper?.cacheDir === undefined ? join(profileDir, 'ai-workbench-connect', 'login-component-cache') : config.helper.cacheDir } };
|
|
13
13
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ConnectApi, SessionProjectTarget, Result, ConnectionState, WorkspaceBindingState, WorkspaceNoticeReceipt, WorkspaceUse } from './api.ts';
|
|
1
|
+
import type { ConnectApi, SessionProjectTarget, Result, ConnectionState, WorkspaceBindingState, WorkspaceNoticeReceipt, WorkspaceUse, Provider, SshTarget } from './api.ts';
|
|
2
2
|
export interface NativeWorkspace {
|
|
3
3
|
id: string;
|
|
4
4
|
path: string;
|
|
@@ -25,6 +25,9 @@ export interface WorkspaceBindingOptions {
|
|
|
25
25
|
export declare function createWorkspaceBindings(options: WorkspaceBindingOptions): {
|
|
26
26
|
observeConnection(state: ConnectionState): void;
|
|
27
27
|
dispose(): Promise<unknown>;
|
|
28
|
+
verifyRemoteConversationScope: (sessionId: string, provider: Provider, target: SshTarget) => Promise<Result<{
|
|
29
|
+
identity: string;
|
|
30
|
+
}>>;
|
|
28
31
|
getWorkspaceBindingState: () => WorkspaceBindingState;
|
|
29
32
|
onWorkspaceBindingState(listener: (state: WorkspaceBindingState) => void): () => void;
|
|
30
33
|
acknowledgeWorkspaceNotice: (receipt: WorkspaceNoticeReceipt) => Promise<Result<void>>;
|
|
@@ -8,7 +8,7 @@ import { ok, fail, NeedError, need } from "./needs.js";
|
|
|
8
8
|
const queues = new Map();
|
|
9
9
|
const hash = (v) => createHash('sha256').update(JSON.stringify(v)).digest('hex');
|
|
10
10
|
const token = (v) => typeof v === 'string' && v.length > 0 && !/[\x00-\x1f\x7f]/.test(v);
|
|
11
|
-
const integrity = (r) => hash([r.identity, r.workspaceId, r.localPath, r.localDev, r.localIno, r.project, r.bindingId, r.noticeId, r.sessionIds]);
|
|
11
|
+
const integrity = (r) => hash([r.identity, r.workspaceId, r.localPath, r.localDev, r.localIno, r.project, r.bindingId, r.noticeId, r.sessionIds, ...(r.accepted === undefined ? [] : [r.accepted])]);
|
|
12
12
|
export function createWorkspaceBindings(options) {
|
|
13
13
|
const root = join(options.profileDir, 'ai-workbench-connect'), file = join(root, 'workspaces.json'), owner = randomUUID();
|
|
14
14
|
let revision = 0, disposed = false, tail = Promise.resolve(), snapshot = { instanceId: options.api.getConnectionState().instanceId, revision: 0, items: [] };
|
|
@@ -52,6 +52,8 @@ export function createWorkspaceBindings(options) {
|
|
|
52
52
|
throw Error('BINDING_INVALID');
|
|
53
53
|
if (r.provider === 'pending' ? r.claim !== undefined : (!r.claim || !token(r.claim.id) || !token(r.claim.sessionId) || !['claude', 'codex'].includes(r.claim.provider) || !token(r.claim.owner) || (r.provider === 'delivered' && !token(r.claim.deliveryId))))
|
|
54
54
|
throw Error('BINDING_CLAIM_INVALID');
|
|
55
|
+
if (r.accepted !== undefined && (!Array.isArray(r.accepted) || r.accepted.some(a => !r.sessionIds.includes(a.sessionId) || !['claude', 'codex'].includes(a.provider)) || new Set(r.accepted.map(a => a.sessionId + '\0' + a.provider)).size !== r.accepted.length))
|
|
56
|
+
throw Error('BINDING_ACCEPTANCE_INVALID');
|
|
55
57
|
ids.add(r.workspaceId);
|
|
56
58
|
paths.add(r.identity + '\0' + r.project.path);
|
|
57
59
|
notices.add(r.noticeId);
|
|
@@ -249,6 +251,12 @@ export function createWorkspaceBindings(options) {
|
|
|
249
251
|
return stale();
|
|
250
252
|
if (!sameNative(current, await native(sessionId)))
|
|
251
253
|
throw new NeedError(need('PROJECT_BINDING_INVALID'));
|
|
254
|
+
if (!same(before))
|
|
255
|
+
return stale();
|
|
256
|
+
binding.accepted ??= [];
|
|
257
|
+
if (!binding.accepted.some(a => a.sessionId === sessionId && a.provider === use.provider))
|
|
258
|
+
binding.accepted.push({ sessionId, provider: use.provider });
|
|
259
|
+
await save(stored);
|
|
252
260
|
if (!same(before))
|
|
253
261
|
return stale();
|
|
254
262
|
publish(stored);
|
|
@@ -306,6 +314,47 @@ export function createWorkspaceBindings(options) {
|
|
|
306
314
|
return fail('PROJECT_BINDING_INVALID');
|
|
307
315
|
}
|
|
308
316
|
}
|
|
317
|
+
// Pure verification: never resolves a project, checks login, creates or claims a notice.
|
|
318
|
+
async function verifyRemoteConversationScope(sessionId, provider, target) {
|
|
319
|
+
const before = options.api.getConnectionState();
|
|
320
|
+
if (!same(before))
|
|
321
|
+
return stale();
|
|
322
|
+
if (!token(sessionId) || !['claude', 'codex'].includes(provider))
|
|
323
|
+
return fail('REMOTE_DETAIL_SCOPE_INVALID');
|
|
324
|
+
try {
|
|
325
|
+
if (!isAbsolute(options.profileDir) || await realpath(options.profileDir) !== options.profileDir)
|
|
326
|
+
return fail('REMOTE_DETAIL_SCOPE_INVALID');
|
|
327
|
+
if (!same(before))
|
|
328
|
+
return stale();
|
|
329
|
+
const directory = await lstat(root);
|
|
330
|
+
if (!same(before))
|
|
331
|
+
return stale();
|
|
332
|
+
if (!directory.isDirectory() || directory.isSymbolicLink() || await realpath(root) !== root)
|
|
333
|
+
return fail('REMOTE_DETAIL_SCOPE_INVALID');
|
|
334
|
+
if (!same(before))
|
|
335
|
+
return stale();
|
|
336
|
+
const stored = await records();
|
|
337
|
+
if (!same(before))
|
|
338
|
+
return stale();
|
|
339
|
+
const binding = stored.find(r => r.sessionIds.includes(sessionId));
|
|
340
|
+
if (!binding || binding.phase !== 'bound' || statuses.get(binding.bindingId) === 'invalid' || !binding.accepted?.some(a => a.sessionId === sessionId && a.provider === provider))
|
|
341
|
+
return fail('REMOTE_DETAIL_SCOPE_INVALID');
|
|
342
|
+
const trust = await readPrivate(target.knownHostsFile, options.metadataMaxBytes);
|
|
343
|
+
if (!same(before))
|
|
344
|
+
return stale();
|
|
345
|
+
if (binding.identity !== hash([target.host, target.username, target.port, target.projectRoot, target.session, trust]))
|
|
346
|
+
return fail('REMOTE_DETAIL_SCOPE_INVALID');
|
|
347
|
+
const current = await native(sessionId);
|
|
348
|
+
if (!same(before))
|
|
349
|
+
return stale();
|
|
350
|
+
if (binding.workspaceId !== current.workspaceId || binding.localPath !== current.localPath || binding.localDev !== current.localDev || binding.localIno !== current.localIno || binding.project.name !== current.name || binding.project.path !== target.projectRoot + '/' + current.name)
|
|
351
|
+
return fail('REMOTE_DETAIL_SCOPE_INVALID');
|
|
352
|
+
return ok({ identity: hash([target.host, target.username, target.port, trust]) });
|
|
353
|
+
}
|
|
354
|
+
catch {
|
|
355
|
+
return same(before) ? fail('REMOTE_DETAIL_SCOPE_INVALID') : stale();
|
|
356
|
+
}
|
|
357
|
+
}
|
|
309
358
|
// Loading metadata never resolves a remote project or creates either a local or remote directory.
|
|
310
359
|
void serialized(async () => { try {
|
|
311
360
|
await prepare();
|
|
@@ -320,6 +369,7 @@ export function createWorkspaceBindings(options) {
|
|
|
320
369
|
if (status === 'ready')
|
|
321
370
|
statuses.set(id, 'unavailable'); publish(latest); },
|
|
322
371
|
dispose() { disposed = true; listeners.clear(); return tail; },
|
|
372
|
+
verifyRemoteConversationScope: (sessionId, provider, target) => serialized(() => verifyRemoteConversationScope(sessionId, provider, target)),
|
|
323
373
|
getWorkspaceBindingState: () => structuredClone(snapshot),
|
|
324
374
|
onWorkspaceBindingState(listener) { listeners.add(listener); return () => { listeners.delete(listener); }; },
|
|
325
375
|
acknowledgeWorkspaceNotice: (receipt) => serialized(() => acknowledge(receipt)),
|