@ai-workbench/connect 0.7.7 → 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/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, need } from "./needs.js";
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.origin === 'https://auth.openai.com' && u.pathname === '/oauth/authorize') || (provider === 'claude' && isClaudeAuthorizeUrl(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, lease, window, remove = () => { };
22
- let result = fail('LOGIN_FAILED'), leaseLost = false, releasing = false;
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
- lease = await ports.acquireProxy(t, signal);
34
- const lost = () => { if (!releasing) {
35
- leaseLost = true;
36
- controller.abort();
37
- } };
38
- void lease.closed?.then(lost, lost);
39
- if (signal.aborted)
40
- throw new NeedError(need(leaseLost ? 'CONNECTION_LOST' : 'LOGIN_FAILED'));
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
- window = await ports.open({ provider: p, url: url.value, proxy: { url: lease.url, username: lease.username, password: lease.password } }, async (code) => {
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);
@@ -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 { L_DEFAULTS } from "./defaults.js";
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;
@@ -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
- // 组件直接来自自己的官方地址;OAuth网页仍通过N主机出口。
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)),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ai-workbench/connect",
3
- "version": "0.7.7",
3
+ "version": "0.7.9",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=24.13.1"