@ai-workbench/remote-agent 0.8.10 → 0.8.12
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 +29 -6
- package/dist/acp.js +1 -1
- package/dist/catalog-diagnostics.d.ts +16 -0
- package/dist/catalog-diagnostics.js +108 -0
- package/dist/catalog-ssh.d.ts +51 -0
- package/dist/catalog-ssh.js +165 -0
- package/dist/client.js +41 -1
- package/dist/employee-mcp.js +1 -1
- package/dist/index.d.ts +4 -2
- package/dist/index.js +133 -27
- package/dist/model-catalog.d.ts +24 -5
- package/dist/model-catalog.js +174 -132
- package/dist/model-defaults.d.ts +29 -0
- package/dist/model-defaults.js +62 -0
- package/dist/model-input-retention.d.ts +22 -0
- package/dist/model-input-retention.js +137 -0
- package/dist/preflight-diagnostic.d.ts +22 -0
- package/dist/preflight-diagnostic.js +112 -0
- package/dist/preflight-payload.d.ts +11 -0
- package/dist/preflight-payload.js +11 -0
- package/dist/presentation.js +6 -0
- package/dist/provider.d.ts +5 -1
- package/dist/provider.js +48 -21
- package/dist/remote-details.d.ts +18 -0
- package/dist/remote-details.js +140 -125
- package/dist/workspace-source-confirmation.d.ts +4 -0
- package/dist/workspace-source-confirmation.js +12 -0
- package/dist/workspace-source.d.ts +25 -0
- package/dist/workspace-source.js +136 -0
- package/package.json +5 -5
package/dist/remote-details.js
CHANGED
|
@@ -4,6 +4,7 @@ import { mkdir, open, lstat, realpath, readdir } from 'node:fs/promises';
|
|
|
4
4
|
import { isAbsolute, join, parse } from 'node:path';
|
|
5
5
|
import { isDeepStrictEqual } from 'node:util';
|
|
6
6
|
import { sanitizeObservation, isSanitizedRedaction } from "./conversation-details.js";
|
|
7
|
+
import { validPreflightPayload } from "./preflight-payload.js";
|
|
7
8
|
import { NeedError } from "./remote.js";
|
|
8
9
|
const failure = (code, message = '技术详情未能持久记录,本轮已停止。') => new NeedError(code, message, '可用的远程主机连接技术详情');
|
|
9
10
|
const hash = (value) => createHash('sha256').update(value).digest('hex');
|
|
@@ -90,141 +91,155 @@ export function createRemoteDetails(options) {
|
|
|
90
91
|
const root = join(options.profileDir, 'ai-workbench-remote-agent', 'diagnostics');
|
|
91
92
|
const scopes = new Map(), loginScopes = new Map();
|
|
92
93
|
const initialize = async () => { await privateDirectory(join(options.profileDir, 'ai-workbench-remote-agent')); await privateDirectory(root); };
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
94
|
+
async function recordConnectionDiagnostic(request) {
|
|
95
|
+
request = { ...request, connection: request.connection && structuredClone(request.connection), payload: structuredClone(request.payload), snapshot: structuredClone(request.snapshot), context: request.context && { ...request.context } };
|
|
96
|
+
await initialize();
|
|
97
|
+
const eventId = randomUUID();
|
|
98
|
+
await durableWrite(join(root, eventId + '.json'), { version: 1, eventId, kind: request.kind, code: request.code, provider: request.provider, sessionHash: request.sessionId ? hash(request.sessionId) : null, ...request.snapshot, delivery: 'pending' });
|
|
99
|
+
if (!request.sessionId)
|
|
100
|
+
throw failure('REMOTE_DETAIL_SCOPE_INVALID');
|
|
101
|
+
const sessionId = request.sessionId, provider = request.provider, api = request.binding ? request.binding.api : options.getApi(), connection = request.connection;
|
|
102
|
+
if (!connection)
|
|
103
|
+
throw failure('CONNECTION_LOST');
|
|
104
|
+
const stable = () => { request.assertScope?.(); const now = api?.getConnectionState(); if (!api || options.getApi() !== api || connection.phase !== 'connected' || now?.phase !== 'connected' || now.instanceId !== connection.instanceId || now.epoch !== connection.epoch)
|
|
105
|
+
throw failure('CONNECTION_LOST'); };
|
|
106
|
+
stable();
|
|
107
|
+
const info = api.getContractInfo?.();
|
|
108
|
+
if ((request.kind === 'login-check' ? info?.loginDiagnostics : info?.preflightDiagnostics) !== 1 || !['appendLoginDiagnostic', 'getLoginDiagnosticReceipt', 'listLoginDiagnostics'].every(key => typeof api[key] === 'function'))
|
|
109
|
+
throw failure('TECHNICAL_DETAILS_UNAVAILABLE', '技术详情暂不可用,请更新远程主机连接组件。');
|
|
110
|
+
const parent = join(root, 'login');
|
|
111
|
+
await privateDirectory(parent);
|
|
112
|
+
const key = hash(sessionId + '\0' + provider), dir = join(parent, key);
|
|
113
|
+
await privateDirectory(dir);
|
|
114
|
+
const scope = loginScopes.get(key) ?? { tail: Promise.resolve(), maximum: 0, loaded: false };
|
|
115
|
+
loginScopes.set(key, scope);
|
|
116
|
+
const operation = scope.tail.then(async () => {
|
|
110
117
|
stable();
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
if (!Number.isSafeInteger(sequence))
|
|
130
|
-
throw failure('REMOTE_DETAIL_STORAGE_FAILED');
|
|
131
|
-
scope.maximum = Math.max(scope.maximum, sequence);
|
|
132
|
-
const saved = await readJournal(join(dir, name));
|
|
133
|
-
if (!object(saved) || !object(saved.detail) || saved.detail.sequence !== sequence || saved.detail.eventId !== match[2] || saved.detail.sessionId !== sessionId || saved.detail.provider !== provider)
|
|
118
|
+
const generation = connection.instanceId + '\0' + connection.epoch;
|
|
119
|
+
if (!scope.loaded || scope.generation !== generation) {
|
|
120
|
+
const names = await readdir(dir);
|
|
121
|
+
for (const name of names) {
|
|
122
|
+
const match = /^(\d+)-([0-9a-f-]+)\.json$/.exec(name);
|
|
123
|
+
if (!match)
|
|
124
|
+
continue;
|
|
125
|
+
const sequence = Number(match[1]);
|
|
126
|
+
if (!Number.isSafeInteger(sequence))
|
|
127
|
+
throw failure('REMOTE_DETAIL_STORAGE_FAILED');
|
|
128
|
+
scope.maximum = Math.max(scope.maximum, sequence);
|
|
129
|
+
const saved = await readJournal(join(dir, name));
|
|
130
|
+
if (!object(saved) || !object(saved.detail) || saved.detail.sequence !== sequence || saved.detail.eventId !== match[2] || saved.detail.sessionId !== sessionId || saved.detail.provider !== provider)
|
|
131
|
+
throw failure('REMOTE_DETAIL_STORAGE_FAILED');
|
|
132
|
+
const settled = 'settled-' + match[2] + '.json';
|
|
133
|
+
if (names.includes(settled)) {
|
|
134
|
+
const receipt = await readJournal(join(dir, settled));
|
|
135
|
+
if (!object(receipt) || receipt.eventId !== match[2] || !['recorded', 'rejected'].includes(receipt.state))
|
|
134
136
|
throw failure('REMOTE_DETAIL_STORAGE_FAILED');
|
|
135
|
-
|
|
136
|
-
if (names.includes(settled)) {
|
|
137
|
-
const receipt = await readJournal(join(dir, settled));
|
|
138
|
-
if (!object(receipt) || receipt.eventId !== match[2] || !['recorded', 'rejected'].includes(receipt.state))
|
|
139
|
-
throw failure('REMOTE_DETAIL_STORAGE_FAILED');
|
|
140
|
-
continue;
|
|
141
|
-
}
|
|
142
|
-
stable();
|
|
143
|
-
const receipt = unwrap(await api.getLoginDiagnosticReceipt({ eventId: match[2], sessionId, provider }));
|
|
144
|
-
stable();
|
|
145
|
-
if (receipt.eventId !== match[2] || !receipt.recorded)
|
|
146
|
-
throw failure('REMOTE_DETAIL_DELIVERY_UNKNOWN');
|
|
147
|
-
if (!isDeepStrictEqual(receipt.detail, saved.detail))
|
|
148
|
-
throw failure('REMOTE_DETAIL_RECEIPT_INVALID');
|
|
149
|
-
await durableWrite(join(dir, settled), { eventId: match[2], state: 'recorded' });
|
|
137
|
+
continue;
|
|
150
138
|
}
|
|
151
|
-
let cursor, previous = -1, count = 0, expected;
|
|
152
|
-
const seen = new Set();
|
|
153
|
-
do {
|
|
154
|
-
stable();
|
|
155
|
-
const page = unwrap(await api.listLoginDiagnostics({ sessionId, provider, ...(cursor ? { cursor } : {}) }));
|
|
156
|
-
stable();
|
|
157
|
-
if (!object(page) || !Array.isArray(page.items) || ![page.total, page.unknownCount, page.actionRequiredCount].every(n => Number.isSafeInteger(n) && n >= 0))
|
|
158
|
-
throw failure('REMOTE_DETAIL_RESPONSE_INVALID');
|
|
159
|
-
const counts = { total: page.total, unknownCount: page.unknownCount, actionRequiredCount: page.actionRequiredCount };
|
|
160
|
-
if (expected && !isDeepStrictEqual(expected, counts))
|
|
161
|
-
throw failure('REMOTE_DETAIL_RESPONSE_INVALID');
|
|
162
|
-
expected = counts;
|
|
163
|
-
for (const entry of page.items) {
|
|
164
|
-
if (entry.sessionId !== sessionId || entry.provider !== provider || !Number.isSafeInteger(entry.sequence) || entry.sequence < 0 || entry.sequence <= previous)
|
|
165
|
-
throw failure('REMOTE_DETAIL_RESPONSE_INVALID');
|
|
166
|
-
previous = entry.sequence;
|
|
167
|
-
scope.maximum = Math.max(scope.maximum, entry.sequence);
|
|
168
|
-
count++;
|
|
169
|
-
}
|
|
170
|
-
cursor = page.nextCursor;
|
|
171
|
-
if (cursor !== undefined && (typeof cursor !== 'string' || !cursor || seen.has(cursor)))
|
|
172
|
-
throw failure('REMOTE_DETAIL_CURSOR_INVALID');
|
|
173
|
-
if (cursor)
|
|
174
|
-
seen.add(cursor);
|
|
175
|
-
} while (cursor);
|
|
176
|
-
if (count !== expected?.total)
|
|
177
|
-
throw failure('REMOTE_DETAIL_RESPONSE_INVALID');
|
|
178
|
-
scope.loaded = true;
|
|
179
|
-
scope.generation = generation;
|
|
180
|
-
}
|
|
181
|
-
const mismatch = diagnostic.reason === 'LOGIN_INSTANCE_MISMATCH', unsupported = !!diagnostic.officialReasonRejected;
|
|
182
|
-
const reason = mismatch ? 'LOGIN_INSTANCE_MISMATCH' : unsupported ? 'OFFICIAL_REASON_UNSUPPORTED' : diagnostic.reason;
|
|
183
|
-
// L 0.7.11: observed evidence never changes the current connection scope.
|
|
184
|
-
const login = { phase: diagnostic.login.phase, instanceId: diagnostic.login.instanceId, logins: Object.fromEntries(Object.entries(diagnostic.login.logins).filter(([, v]) => typeof v === 'boolean' || v === 'absent')), queuedProviders: diagnostic.login.queuedProviders, checkingProviders: diagnostic.login.checkingProviders };
|
|
185
|
-
const payload = { reason, connection: { ...connection }, login, ...(mismatch ? { observedLoginInstanceId: diagnostic.login.instanceId } : {}), ...(diagnostic.officialReason ? { officialReason: diagnostic.officialReason } : {}), ...(unsupported && !mismatch ? { officialReasonRejected: diagnostic.officialReasonRejected } : {}) };
|
|
186
|
-
// The two payload reasons are exclusive in L's contract. Preserve both facts
|
|
187
|
-
// using the approved fixed summary and full digest, without inventing a field.
|
|
188
|
-
const rejected = diagnostic.officialReasonRejected;
|
|
189
|
-
const summary = '登录检查:' + diagnostic.reason + (rejected ? '; OFFICIAL_REASON_UNSUPPORTED; bytes=' + rejected.bytes + '; SHA256=' + rejected.sha256 : '');
|
|
190
|
-
const context = request.context ?? { turn: null, step: null };
|
|
191
|
-
if (!identifier(context.turn) || !identifier(context.step))
|
|
192
|
-
throw failure('REMOTE_DETAIL_SCOPE_INVALID');
|
|
193
|
-
const sequence = scope.maximum + 1;
|
|
194
|
-
if (!Number.isSafeInteger(sequence))
|
|
195
|
-
throw failure('REMOTE_DETAIL_SEQUENCE_INVALID');
|
|
196
|
-
scope.maximum = sequence;
|
|
197
|
-
const detail = { version: 1, eventId, instanceId: connection.instanceId, epoch: connection.epoch, sessionId, provider, turn: context.turn, step: context.step, sequence, kind: 'login-check', severity: 'action-required', code: request.code, summary, method: diagnostic.source, payload };
|
|
198
|
-
await durableWrite(join(dir, sequence + '-' + eventId + '.json'), { state: 'pending', detail });
|
|
199
|
-
stable();
|
|
200
|
-
let response;
|
|
201
|
-
try {
|
|
202
|
-
response = await api.appendLoginDiagnostic(structuredClone(detail));
|
|
203
|
-
}
|
|
204
|
-
catch {
|
|
205
139
|
stable();
|
|
206
|
-
const receipt = unwrap(await api.getLoginDiagnosticReceipt({ eventId, sessionId, provider }));
|
|
140
|
+
const receipt = unwrap(await api.getLoginDiagnosticReceipt({ eventId: match[2], sessionId, provider }));
|
|
207
141
|
stable();
|
|
208
|
-
if (receipt.eventId !==
|
|
209
|
-
throw failure('REMOTE_DETAIL_RECEIPT_INVALID');
|
|
210
|
-
if (!receipt.recorded)
|
|
142
|
+
if (receipt.eventId !== match[2] || !receipt.recorded)
|
|
211
143
|
throw failure('REMOTE_DETAIL_DELIVERY_UNKNOWN');
|
|
212
|
-
if (!isDeepStrictEqual(receipt.detail, detail))
|
|
144
|
+
if (!isDeepStrictEqual(receipt.detail, saved.detail))
|
|
213
145
|
throw failure('REMOTE_DETAIL_RECEIPT_INVALID');
|
|
214
|
-
await durableWrite(join(dir,
|
|
215
|
-
return;
|
|
146
|
+
await durableWrite(join(dir, settled), { eventId: match[2], state: 'recorded' });
|
|
216
147
|
}
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
148
|
+
let cursor, previous = -1, count = 0, expected;
|
|
149
|
+
const seen = new Set();
|
|
150
|
+
do {
|
|
151
|
+
stable();
|
|
152
|
+
const page = unwrap(await api.listLoginDiagnostics({ sessionId, provider, ...(cursor ? { cursor } : {}) }));
|
|
153
|
+
stable();
|
|
154
|
+
if (!object(page) || !Array.isArray(page.items) || ![page.total, page.unknownCount, page.actionRequiredCount].every(n => Number.isSafeInteger(n) && n >= 0))
|
|
155
|
+
throw failure('REMOTE_DETAIL_RESPONSE_INVALID');
|
|
156
|
+
const counts = { total: page.total, unknownCount: page.unknownCount, actionRequiredCount: page.actionRequiredCount };
|
|
157
|
+
if (expected && !isDeepStrictEqual(expected, counts))
|
|
158
|
+
throw failure('REMOTE_DETAIL_RESPONSE_INVALID');
|
|
159
|
+
expected = counts;
|
|
160
|
+
for (const entry of page.items) {
|
|
161
|
+
if (entry.sessionId !== sessionId || entry.provider !== provider || !Number.isSafeInteger(entry.sequence) || entry.sequence < 0 || entry.sequence <= previous)
|
|
162
|
+
throw failure('REMOTE_DETAIL_RESPONSE_INVALID');
|
|
163
|
+
previous = entry.sequence;
|
|
164
|
+
scope.maximum = Math.max(scope.maximum, entry.sequence);
|
|
165
|
+
count++;
|
|
166
|
+
}
|
|
167
|
+
cursor = page.nextCursor;
|
|
168
|
+
if (cursor !== undefined && (typeof cursor !== 'string' || !cursor || seen.has(cursor)))
|
|
169
|
+
throw failure('REMOTE_DETAIL_CURSOR_INVALID');
|
|
170
|
+
if (cursor)
|
|
171
|
+
seen.add(cursor);
|
|
172
|
+
} while (cursor);
|
|
173
|
+
if (count !== expected?.total)
|
|
222
174
|
throw failure('REMOTE_DETAIL_RESPONSE_INVALID');
|
|
175
|
+
scope.loaded = true;
|
|
176
|
+
scope.generation = generation;
|
|
177
|
+
}
|
|
178
|
+
const context = request.context ?? { turn: null, step: null };
|
|
179
|
+
if (!identifier(context.turn) || !identifier(context.step))
|
|
180
|
+
throw failure('REMOTE_DETAIL_SCOPE_INVALID');
|
|
181
|
+
const sequence = scope.maximum + 1;
|
|
182
|
+
if (!Number.isSafeInteger(sequence))
|
|
183
|
+
throw failure('REMOTE_DETAIL_SEQUENCE_INVALID');
|
|
184
|
+
scope.maximum = sequence;
|
|
185
|
+
const detail = { version: 1, eventId, instanceId: connection.instanceId, epoch: connection.epoch, sessionId, provider, turn: context.turn, step: context.step, sequence, kind: request.kind, severity: request.severity, code: request.code, summary: request.summary, method: request.method, payload: request.payload };
|
|
186
|
+
await durableWrite(join(dir, sequence + '-' + eventId + '.json'), { state: 'pending', detail });
|
|
187
|
+
stable();
|
|
188
|
+
let response;
|
|
189
|
+
try {
|
|
190
|
+
response = await api.appendLoginDiagnostic(structuredClone(detail));
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
stable();
|
|
194
|
+
const receipt = unwrap(await api.getLoginDiagnosticReceipt({ eventId, sessionId, provider }));
|
|
195
|
+
stable();
|
|
196
|
+
if (receipt.eventId !== eventId)
|
|
197
|
+
throw failure('REMOTE_DETAIL_RECEIPT_INVALID');
|
|
198
|
+
if (!receipt.recorded)
|
|
199
|
+
throw failure('REMOTE_DETAIL_DELIVERY_UNKNOWN');
|
|
200
|
+
if (!isDeepStrictEqual(receipt.detail, detail))
|
|
201
|
+
throw failure('REMOTE_DETAIL_RECEIPT_INVALID');
|
|
223
202
|
await durableWrite(join(dir, 'settled-' + eventId + '.json'), { eventId, state: 'recorded' });
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
stable();
|
|
206
|
+
if (response?.ok === false)
|
|
207
|
+
await durableWrite(join(dir, 'settled-' + eventId + '.json'), { eventId, state: 'rejected', code: response.error.code });
|
|
208
|
+
const ack = unwrap(response);
|
|
209
|
+
if (ack.eventId !== eventId || ack.recorded !== true)
|
|
210
|
+
throw failure('REMOTE_DETAIL_RESPONSE_INVALID');
|
|
211
|
+
await durableWrite(join(dir, 'settled-' + eventId + '.json'), { eventId, state: 'recorded' });
|
|
212
|
+
});
|
|
213
|
+
scope.tail = operation.then(() => { }, () => { scope.loaded = false; });
|
|
214
|
+
await operation;
|
|
215
|
+
return { eventId, recorded: true };
|
|
216
|
+
}
|
|
217
|
+
return {
|
|
218
|
+
async recordLocalFailure(request) {
|
|
219
|
+
await initialize();
|
|
220
|
+
if (!/^[A-Z][A-Z0-9_]*$/.test(request.code))
|
|
221
|
+
throw failure('REMOTE_DETAIL_STORAGE_FAILED');
|
|
222
|
+
await durableWrite(join(root, randomUUID() + '.json'), { version: 1, kind: 'local-admission', code: request.code, provider: request.provider, sessionHash: request.sessionId ? hash(request.sessionId) : null, ...request.observation ? { observation: sanitizeObservation(request.observation, true) } : {} });
|
|
223
|
+
},
|
|
224
|
+
async recordLoginFailure(request) {
|
|
225
|
+
const diagnostic = request.diagnostic, connection = diagnostic.connection;
|
|
226
|
+
const mismatch = diagnostic.reason === 'LOGIN_INSTANCE_MISMATCH', unsupported = !!diagnostic.officialReasonRejected;
|
|
227
|
+
const reason = mismatch ? 'LOGIN_INSTANCE_MISMATCH' : unsupported ? 'OFFICIAL_REASON_UNSUPPORTED' : diagnostic.reason;
|
|
228
|
+
// L 0.7.11: observed evidence never changes the current connection scope.
|
|
229
|
+
const login = { phase: diagnostic.login.phase, instanceId: diagnostic.login.instanceId, logins: Object.fromEntries(Object.entries(diagnostic.login.logins).filter(([, v]) => typeof v === 'boolean' || v === 'absent')), queuedProviders: diagnostic.login.queuedProviders, checkingProviders: diagnostic.login.checkingProviders };
|
|
230
|
+
const payload = { reason, connection: { ...connection }, login, ...(mismatch ? { observedLoginInstanceId: diagnostic.login.instanceId } : {}), ...(diagnostic.officialReason ? { officialReason: diagnostic.officialReason } : {}), ...(unsupported && !mismatch ? { officialReasonRejected: diagnostic.officialReasonRejected } : {}) };
|
|
231
|
+
// The two payload reasons are exclusive in L's contract. Preserve both facts
|
|
232
|
+
// using the approved fixed summary and full digest, without inventing a field.
|
|
233
|
+
const rejected = diagnostic.officialReasonRejected;
|
|
234
|
+
const summary = '登录检查:' + diagnostic.reason + (rejected ? '; OFFICIAL_REASON_UNSUPPORTED; bytes=' + rejected.bytes + '; SHA256=' + rejected.sha256 : '');
|
|
235
|
+
return recordConnectionDiagnostic({ ...request, provider: diagnostic.provider, connection: diagnostic.connection, kind: 'login-check', severity: 'action-required', summary, method: diagnostic.source, payload, snapshot: { diagnostic } });
|
|
236
|
+
},
|
|
237
|
+
async recordPreflightCheck(request) {
|
|
238
|
+
const observation = sanitizeObservation(request.observation, true);
|
|
239
|
+
if (!/^[A-Z][A-Z0-9_]*$/.test(observation.code) || !validPreflightPayload(observation.payload))
|
|
240
|
+
throw failure('REMOTE_DETAIL_SCOPE_INVALID');
|
|
241
|
+
const api = request.binding ? request.binding.api : options.getApi(), connection = request.binding ? request.binding.connection : api?.getConnectionState();
|
|
242
|
+
return recordConnectionDiagnostic({ ...request, connection, binding: { api, connection }, kind: 'preflight-check', code: observation.code, summary: observation.summary, method: observation.payload.stage, severity: observation.severity, payload: observation.payload, snapshot: { observation } });
|
|
228
243
|
},
|
|
229
244
|
async open(request) {
|
|
230
245
|
request = structuredClone(request);
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
2
|
+
import type { WorkspaceSourceOptions } from './workspace-source.ts';
|
|
3
|
+
/** Official same-turn one-shot question; never changes policy or fabricates a tool call. */
|
|
4
|
+
export declare function nativeWorkspaceSourceConfirmation(ctx: Context): WorkspaceSourceOptions['confirm'];
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { SessionId } from '@deepseek-ai/dsh-session';
|
|
2
|
+
/** Official same-turn one-shot question; never changes policy or fabricates a tool call. */
|
|
3
|
+
export function nativeWorkspaceSourceConfirmation(ctx) {
|
|
4
|
+
return async (question, request) => {
|
|
5
|
+
const agent = request.sessionId ? ctx.get('agents')?.get(SessionId(request.sessionId)) : undefined;
|
|
6
|
+
const approval = ctx.get('approval');
|
|
7
|
+
const session = request.sessionId ? ctx.get('sessions')?.get(SessionId(request.sessionId)) : undefined;
|
|
8
|
+
if (!agent || !approval || !session || agent.session !== session)
|
|
9
|
+
return 'unavailable';
|
|
10
|
+
return approval.request({ agent, toolName: question.action, reason: question.question, signal: request.signal });
|
|
11
|
+
};
|
|
12
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { ConnectApi, RuntimeRequest } from './provider.ts';
|
|
2
|
+
import type { DetailObservation } from './conversation-details.ts';
|
|
3
|
+
export type SourceDecision = 'allowed-once' | 'rejected' | 'cancelled' | 'unavailable';
|
|
4
|
+
export type SourceAdmissionStage = 'source-context' | 'source-confirmation' | 'input-acceptance' | 'login-readiness' | 'local-admission' | 'input-prepare' | 'model-catalog' | 'runtime-resolve';
|
|
5
|
+
export interface WorkspaceSourceAdmission {
|
|
6
|
+
assertCurrent(): void;
|
|
7
|
+
recordFailure(error: unknown, stage: SourceAdmissionStage): Promise<void>;
|
|
8
|
+
}
|
|
9
|
+
export interface WorkspaceSourceOptions {
|
|
10
|
+
workspace(request: RuntimeRequest): Promise<{
|
|
11
|
+
id: string;
|
|
12
|
+
path: string;
|
|
13
|
+
}>;
|
|
14
|
+
currentUserTurn(request: RuntimeRequest): {
|
|
15
|
+
turn: number;
|
|
16
|
+
userMessageId: string;
|
|
17
|
+
};
|
|
18
|
+
confirm(question: {
|
|
19
|
+
question: string;
|
|
20
|
+
action: string;
|
|
21
|
+
}, request: RuntimeRequest): Promise<SourceDecision>;
|
|
22
|
+
record(request: RuntimeRequest, observation: DetailObservation): Promise<void>;
|
|
23
|
+
}
|
|
24
|
+
/** Explicit human source transition only. No source selection is inferred from a folder path. */
|
|
25
|
+
export declare function confirmLegacyWorkspaceSource(get: () => ConnectApi | undefined, api: ConnectApi, request: RuntimeRequest, hooks: WorkspaceSourceOptions): Promise<WorkspaceSourceAdmission | undefined>;
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
2
|
+
import { sanitizeDisplayLabel } from "./conversation-details.js";
|
|
3
|
+
import { NeedError } from "./remote.js";
|
|
4
|
+
const fail = (code) => new NeedError(code, '工作区来源尚未确认,本轮未执行;你的原话仍保留。', '当前工作区来源及原始员工输入');
|
|
5
|
+
const codeOf = (value) => value instanceof NeedError ? value.code : 'WORKSPACE_SOURCE_CONFIRMATION_FAILED';
|
|
6
|
+
const diagnosticFailure = (error, diagnostic) => Object.assign(new NeedError('CONVERSATION_DETAILS_FAILED', '工作区来源诊断未能保存,本轮已停止;原话保留。', '原错误代码 ' + codeOf(error) + ';诊断错误代码 ' + codeOf(diagnostic)), { cause: new AggregateError([error, diagnostic], 'WORKSPACE_SOURCE_AND_DIAGNOSTIC_FAILED') });
|
|
7
|
+
/** Explicit human source transition only. No source selection is inferred from a folder path. */
|
|
8
|
+
export async function confirmLegacyWorkspaceSource(get, api, request, hooks) {
|
|
9
|
+
let initialContext = { workspaceId: null, bindingId: null, source: null, status: null };
|
|
10
|
+
let diagnosticAttempted = false;
|
|
11
|
+
try {
|
|
12
|
+
request.signal?.throwIfAborted();
|
|
13
|
+
if (api.getContractInfo?.().workspaceLocations !== 1 || typeof api.getWorkspaceLocationState !== 'function')
|
|
14
|
+
return;
|
|
15
|
+
if (!api.getWorkspaceLocationState().items.some(item => item.source === 'legacy-host' && item.status === 'legacy-confirmation-required'))
|
|
16
|
+
return;
|
|
17
|
+
const workspace = { ...await hooks.workspace(request) };
|
|
18
|
+
request.signal?.throwIfAborted();
|
|
19
|
+
const snapshot = api.getWorkspaceLocationState(), rows = snapshot.items.filter(item => item.workspaceId === workspace.id), row = rows[0];
|
|
20
|
+
if (rows.length !== 1 || row?.status !== 'legacy-confirmation-required' || row.source !== 'legacy-host')
|
|
21
|
+
return;
|
|
22
|
+
const frozen = structuredClone(row);
|
|
23
|
+
initialContext = { workspaceId: frozen.workspaceId, bindingId: frozen.bindingId, source: frozen.source, status: frozen.status };
|
|
24
|
+
const connection = api.getConnectionState(), turn = { ...hooks.currentUserTurn(request) };
|
|
25
|
+
const name = sanitizeDisplayLabel(row.workspaceName).replace(/[\u0000-\u001f\u007f\u202a-\u202e\u2066-\u2069]/g, '').trim();
|
|
26
|
+
if (!name || name.includes('[已脱敏:'))
|
|
27
|
+
throw fail('WORKSPACE_SOURCE_CONFIRMATION_UNAVAILABLE');
|
|
28
|
+
const question = '这个工作区以前用的是主机上的文件夹。要改用本机的『' + name + '』文件夹吗?';
|
|
29
|
+
const record = (code, outcome, current = frozen, stage = 'source-confirmation') => hooks.record(request, { kind: 'need', severity: outcome === 'confirmed' ? 'info' : 'action-required', code, summary: outcome === 'confirmation-required' ? question : outcome === 'confirmed' ? '已确认改用本机文件夹,继续处理本轮原始请求。' : '本轮工作区来源确认未完成;原始请求保留,未执行。', payload: { phase: 'workspace-source-admission', stage, workspaceId: workspace.id, bindingId: current.bindingId, source: current.source, status: current.status, outcome } });
|
|
30
|
+
// Persistence precedes the question; a failed diagnostic is not retried in this path.
|
|
31
|
+
diagnosticAttempted = true;
|
|
32
|
+
await record('WORKSPACE_SOURCE_CONFIRMATION_REQUIRED', 'confirmation-required');
|
|
33
|
+
let terminalDiagnostic = false;
|
|
34
|
+
async function stable(requireOldSource) {
|
|
35
|
+
request.signal?.throwIfAborted();
|
|
36
|
+
if (get() !== api || api.getContractInfo?.().workspaceLocations !== 1 || typeof api.getWorkspaceLocationState !== 'function')
|
|
37
|
+
throw fail('EMPLOYEE_CONTEXT_CHANGED');
|
|
38
|
+
const now = api.getConnectionState(), location = api.getWorkspaceLocationState();
|
|
39
|
+
if (now.phase !== 'connected' || connection.phase !== 'connected' || now.instanceId !== connection.instanceId || now.epoch !== connection.epoch || location.phase !== 'ready' || location.instanceId !== connection.instanceId || location.epoch !== connection.epoch || snapshot.phase !== 'ready' || snapshot.instanceId !== connection.instanceId || snapshot.epoch !== connection.epoch)
|
|
40
|
+
throw fail('EMPLOYEE_CONTEXT_CHANGED');
|
|
41
|
+
const currentWorkspace = await hooks.workspace(request);
|
|
42
|
+
request.signal?.throwIfAborted();
|
|
43
|
+
if (!isDeepStrictEqual(currentWorkspace, workspace))
|
|
44
|
+
throw fail('EMPLOYEE_CONTEXT_CHANGED');
|
|
45
|
+
if (!isDeepStrictEqual(hooks.currentUserTurn(request), turn))
|
|
46
|
+
throw fail('LOCAL_USER_TURN_CHANGED');
|
|
47
|
+
// Re-read after awaits. Only the explicit successful L selection may replace the frozen binding.
|
|
48
|
+
if (get() !== api)
|
|
49
|
+
throw fail('EMPLOYEE_CONTEXT_CHANGED');
|
|
50
|
+
const after = api.getConnectionState(), state = api.getWorkspaceLocationState();
|
|
51
|
+
if (after.phase !== 'connected' || after.instanceId !== connection.instanceId || after.epoch !== connection.epoch || state.phase !== 'ready' || state.instanceId !== connection.instanceId || state.epoch !== connection.epoch)
|
|
52
|
+
throw fail('EMPLOYEE_CONTEXT_CHANGED');
|
|
53
|
+
const current = state.items.filter(item => item.workspaceId === workspace.id);
|
|
54
|
+
if (current.length !== 1 || current[0].localPath !== workspace.path || requireOldSource && (current[0].bindingId !== frozen.bindingId || current[0].source !== frozen.source || current[0].status !== frozen.status))
|
|
55
|
+
throw fail('EMPLOYEE_CONTEXT_CHANGED');
|
|
56
|
+
return current[0];
|
|
57
|
+
}
|
|
58
|
+
try {
|
|
59
|
+
await stable(true);
|
|
60
|
+
if (typeof api.selectWorkspaceLocation !== 'function')
|
|
61
|
+
throw fail('DEPENDENCY_REQUIRED');
|
|
62
|
+
const decision = await hooks.confirm({ question, action: '改用本机文件夹' }, request);
|
|
63
|
+
request.signal?.throwIfAborted();
|
|
64
|
+
if (decision !== 'allowed-once') {
|
|
65
|
+
const code = decision === 'rejected' ? 'WORKSPACE_SOURCE_CONFIRMATION_DECLINED' : decision === 'cancelled' ? 'ABORTED' : 'WORKSPACE_SOURCE_CONFIRMATION_UNAVAILABLE';
|
|
66
|
+
terminalDiagnostic = true;
|
|
67
|
+
await record(code, decision === 'rejected' ? 'declined' : decision === 'cancelled' ? 'cancelled' : 'failed');
|
|
68
|
+
throw fail(code);
|
|
69
|
+
}
|
|
70
|
+
await stable(true);
|
|
71
|
+
// The await continuation itself is a boundary: already-observed cancellation,
|
|
72
|
+
// replacement input or source withdrawal must not start the L mutation.
|
|
73
|
+
request.signal?.throwIfAborted();
|
|
74
|
+
if (get() !== api || typeof api.getWorkspaceLocationState !== 'function')
|
|
75
|
+
throw fail('EMPLOYEE_CONTEXT_CHANGED');
|
|
76
|
+
const atChoice = api.getConnectionState(), choiceState = api.getWorkspaceLocationState(), choiceRows = choiceState.items.filter(item => item.workspaceId === workspace.id);
|
|
77
|
+
if (atChoice.phase !== 'connected' || atChoice.instanceId !== connection.instanceId || atChoice.epoch !== connection.epoch || choiceState.phase !== 'ready' || choiceState.instanceId !== connection.instanceId || choiceState.epoch !== connection.epoch || choiceRows.length !== 1 || choiceRows[0].bindingId !== frozen.bindingId || choiceRows[0].source !== frozen.source || choiceRows[0].status !== frozen.status || choiceRows[0].localPath !== workspace.path)
|
|
78
|
+
throw fail('EMPLOYEE_CONTEXT_CHANGED');
|
|
79
|
+
if (!isDeepStrictEqual(hooks.currentUserTurn(request), turn))
|
|
80
|
+
throw fail('LOCAL_USER_TURN_CHANGED');
|
|
81
|
+
const selected = await api.selectWorkspaceLocation({ instanceId: connection.instanceId, epoch: connection.epoch, workspaceId: workspace.id, expectedBindingId: frozen.bindingId, source: 'local' });
|
|
82
|
+
request.signal?.throwIfAborted();
|
|
83
|
+
if (!selected.ok)
|
|
84
|
+
throw new NeedError(selected.error.code, selected.error.message, selected.error.requires);
|
|
85
|
+
const current = await stable(false), value = selected.value, returned = value.items.filter(item => item.workspaceId === workspace.id);
|
|
86
|
+
if (value.phase !== 'ready' || value.instanceId !== connection.instanceId || value.epoch !== connection.epoch || returned.length !== 1 || returned[0].source !== 'local' || returned[0].status !== 'selected' || !returned[0].bindingId || returned[0].bindingId === frozen.bindingId || returned[0].localPath !== workspace.path || current.source !== 'local' || current.status !== 'selected' || current.bindingId !== returned[0].bindingId)
|
|
87
|
+
throw fail('WORKSPACE_SOURCE_SELECTION_UNCONFIRMED');
|
|
88
|
+
const selectedBinding = returned[0].bindingId;
|
|
89
|
+
const assertSelected = () => {
|
|
90
|
+
request.signal?.throwIfAborted();
|
|
91
|
+
if (get() !== api || typeof api.getWorkspaceLocationState !== 'function')
|
|
92
|
+
throw fail('EMPLOYEE_CONTEXT_CHANGED');
|
|
93
|
+
const c = api.getConnectionState(), s = api.getWorkspaceLocationState(), items = s.items.filter(item => item.workspaceId === workspace.id);
|
|
94
|
+
if (c.phase !== 'connected' || c.instanceId !== connection.instanceId || c.epoch !== connection.epoch || s.phase !== 'ready' || s.instanceId !== connection.instanceId || s.epoch !== connection.epoch || items.length !== 1 || items[0].source !== 'local' || items[0].status !== 'selected' || items[0].bindingId !== selectedBinding || items[0].localPath !== workspace.path)
|
|
95
|
+
throw fail('EMPLOYEE_CONTEXT_CHANGED');
|
|
96
|
+
if (!isDeepStrictEqual(hooks.currentUserTurn(request), turn))
|
|
97
|
+
throw fail('LOCAL_USER_TURN_CHANGED');
|
|
98
|
+
};
|
|
99
|
+
terminalDiagnostic = true;
|
|
100
|
+
await record('WORKSPACE_SOURCE_CONFIRMED', 'confirmed', current);
|
|
101
|
+
terminalDiagnostic = false;
|
|
102
|
+
await stable(false);
|
|
103
|
+
assertSelected();
|
|
104
|
+
return { assertCurrent: assertSelected, async recordFailure(error, stage) {
|
|
105
|
+
try {
|
|
106
|
+
await record(request.signal?.aborted ? 'ABORTED' : codeOf(error), request.signal?.aborted ? 'cancelled' : 'failed', current, stage);
|
|
107
|
+
}
|
|
108
|
+
catch (diagnostic) {
|
|
109
|
+
throw diagnosticFailure(error, diagnostic);
|
|
110
|
+
}
|
|
111
|
+
} };
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
if (!terminalDiagnostic) {
|
|
115
|
+
try {
|
|
116
|
+
await record(request.signal?.aborted ? 'ABORTED' : codeOf(error), request.signal?.aborted ? 'cancelled' : 'failed');
|
|
117
|
+
}
|
|
118
|
+
catch (diagnostic) {
|
|
119
|
+
throw diagnosticFailure(error, diagnostic);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
throw error;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
catch (error) {
|
|
126
|
+
if (!diagnosticAttempted) {
|
|
127
|
+
try {
|
|
128
|
+
await hooks.record(request, { kind: 'need', severity: 'action-required', code: request.signal?.aborted ? 'ABORTED' : codeOf(error), summary: '无法核对本轮工作区来源,已停止;原话保留。', payload: { phase: 'workspace-source-admission', stage: 'source-context', ...initialContext, outcome: request.signal?.aborted ? 'cancelled' : 'failed' } });
|
|
129
|
+
}
|
|
130
|
+
catch (diagnostic) {
|
|
131
|
+
throw diagnosticFailure(error, diagnostic);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
throw error;
|
|
135
|
+
}
|
|
136
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ai-workbench/remote-agent",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.12",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -17,8 +17,7 @@
|
|
|
17
17
|
"dist",
|
|
18
18
|
"locale",
|
|
19
19
|
"cordis.patch.yml",
|
|
20
|
-
"helpers"
|
|
21
|
-
"CANDIDATE-NOT-FOR-INSTALL.json"
|
|
20
|
+
"helpers"
|
|
22
21
|
],
|
|
23
22
|
"scripts": {
|
|
24
23
|
"test": "npm run build && node --test --test-concurrency=1 lib/test/*.test.js",
|
|
@@ -40,7 +39,7 @@
|
|
|
40
39
|
"zod": "4.6.7"
|
|
41
40
|
},
|
|
42
41
|
"devDependencies": {
|
|
43
|
-
"@ai-workbench/connect": "file:test/fixtures/ai-workbench-connect-0.7.
|
|
42
|
+
"@ai-workbench/connect": "file:test/fixtures/ai-workbench-connect-0.7.18.tgz",
|
|
44
43
|
"@ai-workbench/network": "file:test/fixtures/ai-workbench-network-0.4.4.tgz",
|
|
45
44
|
"@deepseek-ai/cordis": "4.0.4",
|
|
46
45
|
"@deepseek-ai/cordis-plugin-group": "1.0.4",
|
|
@@ -48,6 +47,7 @@
|
|
|
48
47
|
"@deepseek-ai/cordis-plugin-loader": "1.0.5",
|
|
49
48
|
"@deepseek-ai/cordis-plugin-timer": "1.1.6",
|
|
50
49
|
"@deepseek-ai/dsh-agent": "0.2.0-rc.2",
|
|
50
|
+
"@deepseek-ai/dsh-agent-loop": "0.2.0-rc.2",
|
|
51
51
|
"@deepseek-ai/dsh-api-gateway": "0.2.0-rc.2",
|
|
52
52
|
"@deepseek-ai/dsh-attachment": "0.2.0-rc.2",
|
|
53
53
|
"@deepseek-ai/dsh-client-connection": "0.2.0-rc.2",
|
|
@@ -68,7 +68,7 @@
|
|
|
68
68
|
"zustand": "4.4.7"
|
|
69
69
|
},
|
|
70
70
|
"peerDependencies": {
|
|
71
|
-
"@ai-workbench/connect": "~0.7.
|
|
71
|
+
"@ai-workbench/connect": "~0.7.18",
|
|
72
72
|
"@ai-workbench/network": "~0.4.4",
|
|
73
73
|
"@deepseek-ai/cordis": "4.0.4",
|
|
74
74
|
"@deepseek-ai/dsh-agent": "0.2.0-rc.2",
|