@ai-workbench/connect 0.7.7 → 0.7.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +62 -5
- package/client/index.cjs +33 -5
- package/helper/auth-status.cjs +6 -6
- package/helper/device-panel.cjs +4 -0
- package/helper/device-panel.css +1 -0
- package/helper/device-panel.html +1 -0
- package/helper/device-preload.cjs +5 -0
- package/helper/device-window.cjs +60 -0
- package/helper/remote-command.cjs +3 -2
- package/helper/remote-login.cjs +1 -1
- package/helper/window.cjs +8 -14
- package/lib/api.d.ts +98 -1
- package/lib/auth-diagnostics.d.ts +22 -0
- package/lib/auth-diagnostics.js +45 -0
- package/lib/auth-status.js +21 -8
- package/lib/components.d.ts +1 -1
- package/lib/components.js +72 -4
- package/lib/connect.js +93 -27
- package/lib/conversation-detail-settings.d.ts +32 -0
- package/lib/conversation-detail-settings.js +119 -0
- package/lib/conversation-details.d.ts +24 -0
- package/lib/conversation-details.js +382 -0
- package/lib/cordis.d.ts +3 -1
- package/lib/defaults.d.ts +4 -0
- package/lib/defaults.js +1 -0
- package/lib/index.d.ts +4 -0
- package/lib/index.js +23 -15
- package/lib/integration.js +21 -1
- package/lib/login-diagnostics.d.ts +26 -0
- package/lib/login-diagnostics.js +42 -0
- package/lib/login.d.ts +8 -4
- package/lib/login.js +16 -25
- package/lib/needs.js +8 -0
- package/lib/processes.js +34 -7
- package/lib/profile-runtime.js +51 -11
- package/lib/profile-settings.js +3 -3
- package/lib/ssh.js +37 -14
- package/lib/startup.d.ts +22 -1
- package/lib/startup.js +124 -33
- package/lib/workspace-bindings.d.ts +4 -1
- package/lib/workspace-bindings.js +51 -1
- package/package.json +1 -1
package/lib/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
|
-
|
|
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
|
-
|
|
28
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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:
|
|
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
|
|
190
|
+
const observation = await observeCheck(provider, () => api.checkLogin(provider));
|
|
129
191
|
if (disposed || mine !== generation)
|
|
130
192
|
return fail(disposed ? 'DISPOSED' : 'CONNECTION_LOST');
|
|
131
|
-
|
|
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
|
-
|
|
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:
|
|
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)),
|