@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.
Files changed (42) hide show
  1. package/README.md +62 -5
  2. package/client/index.cjs +33 -5
  3. package/helper/auth-status.cjs +6 -6
  4. package/helper/device-panel.cjs +4 -0
  5. package/helper/device-panel.css +1 -0
  6. package/helper/device-panel.html +1 -0
  7. package/helper/device-preload.cjs +5 -0
  8. package/helper/device-window.cjs +60 -0
  9. package/helper/remote-command.cjs +3 -2
  10. package/helper/remote-login.cjs +1 -1
  11. package/helper/window.cjs +8 -14
  12. package/lib/api.d.ts +98 -1
  13. package/lib/auth-diagnostics.d.ts +22 -0
  14. package/lib/auth-diagnostics.js +45 -0
  15. package/lib/auth-status.js +21 -8
  16. package/lib/components.d.ts +1 -1
  17. package/lib/components.js +72 -4
  18. package/lib/connect.js +93 -27
  19. package/lib/conversation-detail-settings.d.ts +32 -0
  20. package/lib/conversation-detail-settings.js +119 -0
  21. package/lib/conversation-details.d.ts +24 -0
  22. package/lib/conversation-details.js +382 -0
  23. package/lib/cordis.d.ts +3 -1
  24. package/lib/defaults.d.ts +4 -0
  25. package/lib/defaults.js +1 -0
  26. package/lib/index.d.ts +4 -0
  27. package/lib/index.js +23 -15
  28. package/lib/integration.js +21 -1
  29. package/lib/login-diagnostics.d.ts +26 -0
  30. package/lib/login-diagnostics.js +42 -0
  31. package/lib/login.d.ts +8 -4
  32. package/lib/login.js +16 -25
  33. package/lib/needs.js +8 -0
  34. package/lib/processes.js +34 -7
  35. package/lib/profile-runtime.js +51 -11
  36. package/lib/profile-settings.js +3 -3
  37. package/lib/ssh.js +37 -14
  38. package/lib/startup.d.ts +22 -1
  39. package/lib/startup.js +124 -33
  40. package/lib/workspace-bindings.d.ts +4 -1
  41. package/lib/workspace-bindings.js +51 -1
  42. package/package.json +1 -1
package/lib/startup.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { fail, need, ok } from "./needs.js";
3
+ import { authFailure } from "./auth-diagnostics.js";
3
4
  export function employeeMessage(code) {
4
5
  if (code === 'ENROLLMENT_RESULT_UNKNOWN' || code === 'ENROLLMENT_REJECTED')
5
6
  return need(code).message;
@@ -19,19 +20,76 @@ export function createStartup(api, providers, emit = () => { }, result = () => {
19
20
  let disposed = false, revision = 0, serial = 0, generation = 0, checkSerial = 0, connectionPhase = 'unavailable', observedConnected = false;
20
21
  let flight, authorization, authorizationId;
21
22
  const pending = new Set(), listeners = new Set(), deferred = new Set();
22
- let state = { instanceId, revision: 0, phase: 'checking', message: '正在连接远程主机…', logins: {}, checkingProviders: [], providerNeeds: {}, technicalDetails: {} };
23
+ const providerSerial = new Map();
24
+ const resultTickets = new WeakMap();
25
+ let connectionEpoch, connectionInstance;
26
+ let state = { instanceId, revision: 0, phase: 'checking', message: '正在连接远程主机…', logins: {}, queuedProviders: [...providers], checkingProviders: [], providerNeeds: {}, technicalDetails: {} };
23
27
  const publish = (next) => { state = { ...next, instanceId, revision: ++revision }; emit(structuredClone(state)); for (const listener of listeners)
24
28
  listener(structuredClone(state)); };
25
29
  const track = (p) => { pending.add(p); void p.then(() => pending.delete(p), () => pending.delete(p)); return p; };
26
- const close = (outcome, code) => { if (state.requestId && state.provider)
27
- result({ requestId: state.requestId, provider: state.provider, outcome, ...(code ? { need: need(code) } : {}) }); };
28
- const blocked = (code) => { close('invalidated', code); publish({ phase: 'blocked', message: employeeMessage(code), logins: {}, checkingProviders: [], providerNeeds: {}, technicalDetails: { ...state.technicalDetails, code } }); };
30
+ const close = (outcome, code) => { if (state.requestId && state.provider) {
31
+ const terminal = { requestId: state.requestId, provider: state.provider, outcome, ...(code ? { need: need(code) } : {}) };
32
+ publish({ ...state, requestId: undefined, provider: undefined });
33
+ result(terminal);
34
+ } };
35
+ const blocked = (code) => { const mine = generation; close('invalidated', code); if (disposed || mine !== generation)
36
+ return; publish({ phase: 'blocked', message: employeeMessage(code), logins: {}, queuedProviders: [], checkingProviders: [], providerNeeds: {}, technicalDetails: { ...state.technicalDetails, code } }); };
29
37
  const snapshot = () => state.phase === 'disposed' ? fail('DISPOSED') : state.phase === 'blocked' ? fail(state.technicalDetails.code ?? 'AUTH_STATUS_INVALID') : ok(structuredClone(state));
30
38
  const ask = () => {
39
+ if (disposed || state.phase === 'blocked' || state.phase === 'disposed')
40
+ return;
41
+ if (state.checkingProviders.length) {
42
+ publish({ ...state, ...(!state.requestId ? { phase: 'checking', message: '正在检查登录状态…' } : {}) });
43
+ return;
44
+ }
31
45
  const provider = state.requestId && state.provider && state.logins[state.provider] === false && !deferred.has(state.provider) ? state.provider : providers.find(p => state.logins[p] === false && !deferred.has(p));
32
46
  const same = provider && state.provider === provider && state.requestId && state.phase === 'login-required';
33
47
  publish({ ...state, phase: provider ? 'login-required' : 'idle', provider, requestId: provider ? (same ? state.requestId : 'login-' + instanceId + '-' + (++serial)) : undefined, message: provider ? '需要登录,请到设置 → 远程主机连接 点登录' : Object.keys(state.providerNeeds).length ? Object.keys(state.providerNeeds).map(p => p === 'claude' ? 'Claude' : 'Codex').join('、') + ' 登录状态无法确认,请联系管理员。' : '远程主机连接检查完成。' });
34
48
  };
49
+ const beginCheck = (provider) => {
50
+ const serial = (providerSerial.get(provider) ?? 0) + 1;
51
+ providerSerial.set(provider, serial);
52
+ publish({ ...state, ...(!authorization && !state.requestId ? { phase: 'checking', message: '正在检查登录状态…' } : {}), queuedProviders: (state.queuedProviders ?? []).filter(p => p !== provider), checkingProviders: [...new Set([...state.checkingProviders, provider])] });
53
+ return { provider, generation, serial };
54
+ };
55
+ const currentTicket = (ticket) => !disposed && ticket.generation === generation && (providerSerial.get(ticket.provider) ?? 0) === ticket.serial;
56
+ const finishCheck = (ticket, status) => {
57
+ const provider = ticket.provider;
58
+ if (!currentTicket(ticket))
59
+ return false;
60
+ const logins = { ...state.logins }, providerNeeds = { ...state.providerNeeds };
61
+ if (status.ok) {
62
+ logins[provider] = status.value.loggedIn;
63
+ delete providerNeeds[provider];
64
+ }
65
+ else {
66
+ delete logins[provider];
67
+ providerNeeds[provider] = structuredClone(status.error);
68
+ }
69
+ const clearTicket = !authorization && state.requestId && state.provider === provider && logins[provider] !== false;
70
+ const terminal = clearTicket ? { requestId: state.requestId, provider, outcome: logins[provider] === true ? 'logged-in' : 'invalidated', ...(providerNeeds[provider] ? { need: structuredClone(providerNeeds[provider]) } : {}) } : undefined;
71
+ publish({ ...state, ...(clearTicket ? { requestId: undefined, provider: undefined } : {}), logins, providerNeeds, checkingProviders: state.checkingProviders.filter(p => p !== provider) });
72
+ if (terminal)
73
+ result(currentTicket(ticket) ? terminal : { ...terminal, outcome: 'invalidated', need: need(disposed ? 'DISPOSED' : 'CONNECTION_LOST') });
74
+ if (!currentTicket(ticket))
75
+ return false;
76
+ if (!flight && !authorization)
77
+ ask();
78
+ if (!currentTicket(ticket))
79
+ return false;
80
+ resultTickets.set(status, ticket);
81
+ return true;
82
+ };
83
+ const observeCheck = async (provider, operation) => {
84
+ const ticket = beginCheck(provider);
85
+ if (!currentTicket(ticket))
86
+ return { status: fail(disposed ? 'DISPOSED' : 'CONNECTION_LOST'), current: false };
87
+ const status = await operation();
88
+ if (currentTicket(ticket))
89
+ finishCheck(ticket, status);
90
+ const observed = resultTickets.get(status);
91
+ return { status, current: !!observed && currentTicket(observed) };
92
+ };
35
93
  const check = () => {
36
94
  if (disposed)
37
95
  return Promise.resolve();
@@ -43,7 +101,8 @@ export function createStartup(api, providers, emit = () => { }, result = () => {
43
101
  const run = async () => {
44
102
  try {
45
103
  // Preserve an existing question while gathering fresh evidence, so duplicate requests keep its ticket.
46
- publish({ ...state, ...(!state.requestId ? { phase: 'checking', message: '正在检查登录状态…' } : {}), checkingProviders: [] });
104
+ const planned = new Map(providers.map(p => [p, providerSerial.get(p) ?? 0]));
105
+ publish({ ...state, ...(!state.requestId ? { phase: 'checking', message: '正在检查登录状态…' } : {}), queuedProviders: providers.filter(p => !state.checkingProviders.includes(p)) });
47
106
  const connected = await api.connect();
48
107
  if (disposed || mine !== generation || checkMine !== checkSerial)
49
108
  return;
@@ -51,35 +110,33 @@ export function createStartup(api, providers, emit = () => { }, result = () => {
51
110
  blocked(connected.error.code);
52
111
  return;
53
112
  }
54
- const logins = { ...state.logins }, providerNeeds = { ...state.providerNeeds };
55
113
  for (const provider of providers) {
56
- publish({ ...state, checkingProviders: [provider] });
114
+ const pendingProvider = state.checkingProviders.includes(provider);
115
+ if (pendingProvider)
116
+ await new Promise(resolve => { const done = () => { if (disposed || mine !== generation || !state.checkingProviders.includes(provider)) {
117
+ listeners.delete(done);
118
+ resolve();
119
+ } }; listeners.add(done); done(); });
120
+ if (disposed || mine !== generation || checkMine !== checkSerial)
121
+ return;
122
+ if (pendingProvider || (providerSerial.get(provider) ?? 0) !== planned.get(provider)) {
123
+ publish({ ...state, queuedProviders: (state.queuedProviders ?? []).filter(p => p !== provider) });
124
+ continue;
125
+ }
126
+ const ticket = beginCheck(provider);
57
127
  const status = await api.checkLogin(provider);
58
128
  if (disposed || mine !== generation || checkMine !== checkSerial)
59
129
  return;
130
+ // createConnect publishes its own newer ticket; standalone coordinators use this one.
131
+ finishCheck(ticket, status);
60
132
  if (!status.ok) {
61
133
  if (status.error.layer !== 'auth' && !['AUTH_STATUS_INVALID', 'LOGIN_FAILED', 'POLICY_DENIED'].includes(status.error.code)) {
62
134
  blocked(status.error.code);
63
135
  return;
64
136
  }
65
- delete logins[provider];
66
- providerNeeds[provider] = status.error;
67
- }
68
- else {
69
- logins[provider] = status.value.loggedIn;
70
- delete providerNeeds[provider];
71
137
  }
72
- if (state.requestId && state.provider === provider && logins[provider] !== false) {
73
- close(logins[provider] === true ? 'logged-in' : 'invalidated', providerNeeds[provider]?.code);
74
- publish({ ...state, requestId: undefined, provider: undefined, phase: 'checking', message: '正在检查登录状态…' });
75
- }
76
- publish({ ...state, logins: { ...logins }, providerNeeds: { ...providerNeeds }, checkingProviders: state.checkingProviders.filter(p => p !== provider) });
77
- }
78
- if (state.requestId && state.provider && logins[state.provider] === true) {
79
- close('logged-in');
80
- publish({ ...state, requestId: undefined, provider: undefined, phase: 'checking', message: '正在检查登录状态…' });
81
138
  }
82
- publish({ ...state, logins, providerNeeds, checkingProviders: [], technicalDetails: connected.value.hostContext ? { hostContext: structuredClone(connected.value.hostContext) } : {} });
139
+ publish({ ...state, queuedProviders: [], technicalDetails: connected.value.hostContext ? { hostContext: structuredClone(connected.value.hostContext) } : {} });
83
140
  ask();
84
141
  }
85
142
  catch {
@@ -96,7 +153,7 @@ export function createStartup(api, providers, emit = () => { }, result = () => {
96
153
  const providerFailed = (provider, code, sourceNeed = need(code)) => {
97
154
  const logins = { ...state.logins };
98
155
  delete logins[provider];
99
- publish({ ...state, phase: 'idle', requestId: undefined, provider: undefined, logins, checkingProviders: [], providerNeeds: { ...state.providerNeeds, [provider]: structuredClone(sourceNeed) } });
156
+ publish({ ...state, phase: 'idle', requestId: undefined, provider: undefined, logins, checkingProviders: state.checkingProviders.filter(p => p !== provider), providerNeeds: { ...state.providerNeeds, [provider]: structuredClone(sourceNeed) } });
100
157
  ask();
101
158
  };
102
159
  const respond = (requestId, action) => {
@@ -108,7 +165,10 @@ export function createStartup(api, providers, emit = () => { }, result = () => {
108
165
  return Promise.resolve(fail('LOGIN_PROMPT_INVALID'));
109
166
  const provider = state.provider;
110
167
  if (action === 'defer') {
168
+ const mine = generation;
111
169
  close('deferred');
170
+ if (disposed || mine !== generation)
171
+ return Promise.resolve(fail(disposed ? 'DISPOSED' : 'CONNECTION_LOST'));
112
172
  deferred.add(provider);
113
173
  publish({ ...state, requestId: undefined, provider: undefined });
114
174
  ask();
@@ -119,29 +179,44 @@ export function createStartup(api, providers, emit = () => { }, result = () => {
119
179
  const mine = generation;
120
180
  checkSerial++;
121
181
  authorizationId = requestId;
122
- publish({ ...state, phase: 'authorizing', checkingProviders: [provider], message: '请在授权窗口完成本人登录。' });
182
+ publish({ ...state, phase: 'authorizing', queuedProviders: [], checkingProviders: [...new Set([...state.checkingProviders, provider])], message: '请在授权窗口完成本人登录。' });
183
+ const superseded = () => { const failure = authFailure('connect-check-login', 'STATUS_SUPERSEDED'); if (state.requestId === requestId)
184
+ publish({ ...state, phase: 'idle', requestId: undefined, provider: undefined }); result({ requestId, provider, outcome: 'invalidated', need: failure.error }); ask(); return failure; };
123
185
  const run = async () => {
124
186
  try {
125
187
  await flight;
126
188
  if (disposed || mine !== generation)
127
189
  return fail(disposed ? 'DISPOSED' : 'CONNECTION_LOST');
128
- const current = await api.checkLogin(provider);
190
+ const observation = await observeCheck(provider, () => api.checkLogin(provider));
129
191
  if (disposed || mine !== generation)
130
192
  return fail(disposed ? 'DISPOSED' : 'CONNECTION_LOST');
131
- const verified = current.ok && current.value.loggedIn ? current : current.ok ? await api.login(provider) : current;
193
+ if (!observation.current)
194
+ return superseded();
195
+ const current = observation.status;
196
+ const login = current.ok && !current.value.loggedIn ? await observeCheck(provider, () => api.login(provider)) : observation;
197
+ const verified = login.status;
132
198
  if (disposed || mine !== generation)
133
199
  return fail(disposed ? 'DISPOSED' : 'CONNECTION_LOST');
200
+ const finalTicket = resultTickets.get(verified);
201
+ if (!login.current || !finalTicket || !currentTicket(finalTicket))
202
+ return superseded();
134
203
  if (!verified.ok || !verified.value.loggedIn) {
135
204
  const code = verified.ok ? 'LOGIN_FAILED' : verified.error.code;
136
- result({ requestId, provider, outcome: 'failed', need: need(code) });
137
205
  providerFailed(provider, code, verified.ok ? need(code) : verified.error);
138
- return verified.ok ? fail(code) : verified;
206
+ result({ requestId, provider, outcome: 'failed', need: verified.ok ? need(code) : verified.error });
207
+ return disposed || mine !== generation ? fail(disposed ? 'DISPOSED' : 'CONNECTION_LOST') : verified.ok ? fail(code) : verified;
139
208
  }
140
- result({ requestId, provider, outcome: 'logged-in' });
141
209
  deferred.delete(provider);
142
210
  const providerNeeds = { ...state.providerNeeds };
143
211
  delete providerNeeds[provider];
144
- publish({ ...state, requestId: undefined, provider: undefined, logins: { ...state.logins, [provider]: true }, providerNeeds, checkingProviders: [], phase: 'idle' });
212
+ publish({ ...state, requestId: undefined, provider: undefined, logins: { ...state.logins, [provider]: true }, providerNeeds, checkingProviders: state.checkingProviders.filter(p => p !== provider), phase: 'idle' });
213
+ if (disposed || mine !== generation) {
214
+ result({ requestId, provider, outcome: 'invalidated', need: need(disposed ? 'DISPOSED' : 'CONNECTION_LOST') });
215
+ return fail(disposed ? 'DISPOSED' : 'CONNECTION_LOST');
216
+ }
217
+ result({ requestId, provider, outcome: 'logged-in' });
218
+ if (disposed || mine !== generation)
219
+ return fail(disposed ? 'DISPOSED' : 'CONNECTION_LOST');
145
220
  ask();
146
221
  return snapshot();
147
222
  }
@@ -161,7 +236,9 @@ export function createStartup(api, providers, emit = () => { }, result = () => {
161
236
  } });
162
237
  return promise;
163
238
  };
164
- return { getState: () => structuredClone(state), check,
239
+ return { getState: () => structuredClone(state), check, beginCheck, finishCheck, currentTicket, captureCheck: (provider) => ({ provider, generation, serial: providerSerial.get(provider) ?? 0 }),
240
+ inheritCheck(source, derived) { const ticket = resultTickets.get(source); if (ticket && currentTicket(ticket))
241
+ resultTickets.set(derived, ticket); },
165
242
  subscribe(listener) { listeners.add(listener); return () => { listeners.delete(listener); }; },
166
243
  async request(provider, intent) {
167
244
  if (!providers.includes(provider) || !['automatic', 'user'].includes(intent))
@@ -176,6 +253,8 @@ export function createStartup(api, providers, emit = () => { }, result = () => {
176
253
  return fail(disposed ? 'DISPOSED' : 'CONNECTION_LOST');
177
254
  if (intent === 'user' && !authorization && state.logins[provider] === false && !state.providerNeeds[provider] && !state.checkingProviders.includes(provider) && state.phase !== 'blocked' && state.phase !== 'disposed' && state.provider !== provider) {
178
255
  close('invalidated', 'LOGIN_PROMPT_INVALID');
256
+ if (disposed || mine !== generation)
257
+ return fail(disposed ? 'DISPOSED' : 'CONNECTION_LOST');
179
258
  publish({ ...state, phase: 'login-required', provider, requestId: 'login-' + instanceId + '-' + (++serial), message: '需要登录,请到设置 → 远程主机连接 点登录' });
180
259
  }
181
260
  return state.providerNeeds[provider] ? { ok: false, error: structuredClone(state.providerNeeds[provider]) } : snapshot();
@@ -190,6 +269,18 @@ export function createStartup(api, providers, emit = () => { }, result = () => {
190
269
  return;
191
270
  const prior = connectionPhase;
192
271
  connectionPhase = next.phase;
272
+ const changed = connectionEpoch !== undefined && (connectionEpoch !== next.epoch || connectionInstance !== next.instanceId);
273
+ connectionEpoch = next.epoch;
274
+ connectionInstance = next.instanceId;
275
+ if (changed) {
276
+ generation++;
277
+ flight = undefined;
278
+ const mine = generation;
279
+ close('invalidated', 'CONNECTION_LOST');
280
+ if (disposed || mine !== generation)
281
+ return;
282
+ publish({ phase: 'checking', message: '正在连接远程主机…', logins: {}, queuedProviders: [], checkingProviders: [], providerNeeds: {}, technicalDetails: {} });
283
+ }
193
284
  if (next.phase === 'unavailable' || next.phase === 'disposed') {
194
285
  generation++;
195
286
  flight = undefined;
@@ -206,6 +297,6 @@ export function createStartup(api, providers, emit = () => { }, result = () => {
206
297
  }
207
298
  },
208
299
  stop() { if (disposed)
209
- return; disposed = true; generation++; close('invalidated', 'DISPOSED'); publish({ phase: 'disposed', message: '远程主机连接已关闭。', logins: {}, checkingProviders: [], providerNeeds: {}, technicalDetails: { code: 'DISPOSED' } }); listeners.clear(); },
300
+ return; disposed = true; generation++; close('invalidated', 'DISPOSED'); publish({ phase: 'disposed', message: '远程主机连接已关闭。', logins: {}, queuedProviders: [], checkingProviders: [], providerNeeds: {}, technicalDetails: { code: 'DISPOSED' } }); listeners.clear(); },
210
301
  async dispose() { this.stop(); await Promise.allSettled([...pending]); }, };
211
302
  }
@@ -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.11",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=24.13.1"