@ai-workbench/remote-agent 0.8.0 → 0.8.2
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 +11 -1
- package/dist/acp.d.ts +8 -0
- package/dist/acp.js +59 -21
- package/dist/auth-reason.d.ts +1 -0
- package/dist/auth-reason.js +27 -0
- package/dist/connect-contract.d.ts +2 -1
- package/dist/conversation-details.d.ts +47 -0
- package/dist/conversation-details.js +319 -0
- package/dist/index.d.ts +8 -2
- package/dist/index.js +110 -10
- package/dist/input.js +2 -2
- package/dist/login-diagnostic.d.ts +38 -0
- package/dist/login-diagnostic.js +29 -0
- package/dist/mapping.d.ts +11 -5
- package/dist/mapping.js +105 -141
- package/dist/presentation.js +4 -0
- package/dist/provider.d.ts +13 -4
- package/dist/provider.js +87 -17
- package/dist/readiness.js +7 -5
- package/dist/remote-details.d.ts +39 -0
- package/dist/remote-details.js +381 -0
- package/dist/remote.d.ts +3 -0
- package/dist/remote.js +2 -0
- package/dist/workspace-notice.d.ts +22 -1
- package/dist/workspace-notice.js +148 -129
- package/package.json +3 -3
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
const redactionMarkers = new WeakSet();
|
|
3
|
+
function redaction(fields) {
|
|
4
|
+
const marker = { redacted: true, ...fields };
|
|
5
|
+
redactionMarkers.add(marker);
|
|
6
|
+
return marker;
|
|
7
|
+
}
|
|
8
|
+
/** Only objects created by this module are trusted as redaction markers. */
|
|
9
|
+
export function isSanitizedRedaction(value) {
|
|
10
|
+
return value !== null && typeof value === 'object' && redactionMarkers.has(value);
|
|
11
|
+
}
|
|
12
|
+
const sensitiveWords = new Set(['auth', 'token', 'tokens', 'password', 'passwords', 'passwd', 'secret', 'secrets', 'authorization', 'cookie', 'cookies', 'credential', 'credentials', 'key', 'keys']);
|
|
13
|
+
const sensitiveCompounds = new Set([
|
|
14
|
+
'apikey', 'privatekey', 'publickey', 'accesskey', 'secretkey', 'sshkey', 'encryptionkey', 'signingkey',
|
|
15
|
+
'devicecode', 'usercode', 'onetimecode', 'verificationcode', 'codeverifier', 'authcode', 'authorizationcode', 'authpayload', 'authorizationpayload',
|
|
16
|
+
'accesstoken', 'refreshtoken', 'idtoken', 'apitoken', 'authtoken', 'sessiontoken', 'bearertoken',
|
|
17
|
+
'clientsecret', 'tokenpayload', 'tokenvalue', 'passwordpayload', 'passwordvalue', 'secretpayload', 'secretvalue',
|
|
18
|
+
'credentialpayload', 'credentialspayload', 'cookiepayload', 'keypayload',
|
|
19
|
+
]);
|
|
20
|
+
// These whole words are safe despite containing a credential-like substring.
|
|
21
|
+
// redactedKeys is the recorder's explicit entry-wrapper marker and is stable
|
|
22
|
+
// across repeated sanitization. It is not an exception for arbitrary values.
|
|
23
|
+
const safeWholeFieldWords = new Set(['author', 'monkey', 'keyboard', 'secretary', 'keyword', 'keywords', 'tokenizer', 'tokenization', 'redactedkeys']);
|
|
24
|
+
// A recognized suffix supplies a field boundary after case information is lost.
|
|
25
|
+
// Prefixes remain unrestricted: employeePassword and customApiKey must not depend
|
|
26
|
+
// on an inventory of employee/customer/application-specific field prefixes.
|
|
27
|
+
const sensitiveFieldSuffixes = new Set([
|
|
28
|
+
'', 'code', 'payload', 'value', 'values', 'data', 'header', 'headers', 'config', 'configuration',
|
|
29
|
+
'option', 'options', 'detail', 'details', 'info',
|
|
30
|
+
]);
|
|
31
|
+
const unsafeKeys = new Set(['__proto__', 'constructor', 'prototype']);
|
|
32
|
+
function isSensitiveFieldWord(word) {
|
|
33
|
+
if (safeWholeFieldWords.has(word))
|
|
34
|
+
return false;
|
|
35
|
+
if (sensitiveWords.has(word) || sensitiveCompounds.has(word))
|
|
36
|
+
return true;
|
|
37
|
+
for (const root of sensitiveWords) {
|
|
38
|
+
for (let index = word.indexOf(root); index !== -1; index = word.indexOf(root, index + 1)) {
|
|
39
|
+
// Match roots at the start, or roots ending a field (possibly followed by
|
|
40
|
+
// a known suffix), rather than fuzzy containment at any word position.
|
|
41
|
+
if (index === 0 || sensitiveFieldSuffixes.has(word.slice(index + root.length)))
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
function isSensitiveKey(key) {
|
|
48
|
+
if (safeWholeFieldWords.has(key.toLowerCase()))
|
|
49
|
+
return false;
|
|
50
|
+
// Preserve the original camel/delimiter rule. Anchored field-shape matching
|
|
51
|
+
// additionally recognizes the same common fields after case normalization.
|
|
52
|
+
const camelWords = key.replace(/([a-z0-9])([A-Z])/g, '$1 $2').toLowerCase().split(/[^a-z0-9]+/);
|
|
53
|
+
const words = key.toLowerCase().split(/[^a-z0-9]+/);
|
|
54
|
+
return camelWords.some(word => sensitiveWords.has(word)) || words.some(isSensitiveFieldWord)
|
|
55
|
+
|| sensitiveCompounds.has(key.replace(/[^a-z0-9]/gi, '').toLowerCase());
|
|
56
|
+
}
|
|
57
|
+
function unsupported(type, reason) {
|
|
58
|
+
return { unsupported: true, type, reason };
|
|
59
|
+
}
|
|
60
|
+
function credentialText(value) {
|
|
61
|
+
return /\bBearer\s+\S+/i.test(value)
|
|
62
|
+
|| /\bsk-[a-z\d_-]+/i.test(value)
|
|
63
|
+
|| /-----BEGIN (?:[A-Z\d]+ )*PRIVATE KEY-----/i.test(value)
|
|
64
|
+
|| /\b(?:gh[pousr]_|github_pat_|xox[baprs]-)[a-z\d_-]+/i.test(value);
|
|
65
|
+
}
|
|
66
|
+
function cleanUrls(value) {
|
|
67
|
+
let changed = false;
|
|
68
|
+
const safe = value.replace(/\b[a-z][a-z\d+.-]*:\/\/[^\s<>"'`]+/gi, raw => {
|
|
69
|
+
let url;
|
|
70
|
+
try {
|
|
71
|
+
url = new URL(raw);
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
changed = true;
|
|
75
|
+
return '[已脱敏:无法解析URL]';
|
|
76
|
+
}
|
|
77
|
+
const userinfo = /^[a-z][a-z\d+.-]*:\/\/[^/?#]*@/i.test(raw);
|
|
78
|
+
if (!userinfo && !raw.includes('?') && !raw.includes('#'))
|
|
79
|
+
return raw;
|
|
80
|
+
url.username = '';
|
|
81
|
+
url.password = '';
|
|
82
|
+
url.search = '';
|
|
83
|
+
url.hash = '';
|
|
84
|
+
changed = true;
|
|
85
|
+
return url.toString();
|
|
86
|
+
});
|
|
87
|
+
return { value: safe, changed };
|
|
88
|
+
}
|
|
89
|
+
function safeText(value) {
|
|
90
|
+
if (credentialText(value))
|
|
91
|
+
return redaction({ reason: '凭据文本' });
|
|
92
|
+
const url = cleanUrls(value);
|
|
93
|
+
return url.changed ? redaction({ reason: 'URL敏感部分', value: url.value }) : value;
|
|
94
|
+
}
|
|
95
|
+
function safeLabel(value) {
|
|
96
|
+
if (typeof value !== 'string')
|
|
97
|
+
throw new TypeError('Detail observation labels must be strings');
|
|
98
|
+
// Inspect credentials before removing control characters, so a bearer newline cannot evade detection.
|
|
99
|
+
if (credentialText(value))
|
|
100
|
+
return '[已脱敏:凭据文本]';
|
|
101
|
+
const cleaned = value.replace(/[\u0000-\u001f\u007f-\u009f]/g, '');
|
|
102
|
+
if (credentialText(cleaned))
|
|
103
|
+
return '[已脱敏:凭据文本]';
|
|
104
|
+
const url = cleanUrls(cleaned);
|
|
105
|
+
return url.changed ? `[已脱敏:URL敏感部分] ${url.value}` : cleaned;
|
|
106
|
+
}
|
|
107
|
+
/** JSON-only snapshot. Non-JSON input is represented, never coerced or executed. */
|
|
108
|
+
function snapshotPayload(value, ancestors = new Set(), protectModelMaterial = false) {
|
|
109
|
+
if (isSanitizedRedaction(value))
|
|
110
|
+
return value;
|
|
111
|
+
if (value === null || typeof value === 'boolean')
|
|
112
|
+
return value;
|
|
113
|
+
if (typeof value === 'string')
|
|
114
|
+
return safeText(value);
|
|
115
|
+
if (typeof value === 'number')
|
|
116
|
+
return Number.isFinite(value) ? value : unsupported('number', '非有限数值不能表示为JSON');
|
|
117
|
+
if (typeof value !== 'object')
|
|
118
|
+
return unsupported(typeof value, '非JSON值');
|
|
119
|
+
if (ancestors.has(value))
|
|
120
|
+
return unsupported('object', '循环引用');
|
|
121
|
+
let descriptors;
|
|
122
|
+
let isArray;
|
|
123
|
+
try {
|
|
124
|
+
isArray = Array.isArray(value);
|
|
125
|
+
const prototype = Object.getPrototypeOf(value);
|
|
126
|
+
if (!isArray && prototype !== Object.prototype && prototype !== null)
|
|
127
|
+
return unsupported('object', '非JSON普通对象');
|
|
128
|
+
if (Object.getOwnPropertySymbols(value).some(symbol => Object.getOwnPropertyDescriptor(value, symbol)?.enumerable)) {
|
|
129
|
+
return unsupported('object', 'JSON不能表示符号字段');
|
|
130
|
+
}
|
|
131
|
+
descriptors = Object.getOwnPropertyDescriptors(value);
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
return unsupported('object', '无法读取对象结构');
|
|
135
|
+
}
|
|
136
|
+
if (Object.keys(descriptors).some(key => descriptors[key].enumerable && unsafeKeys.has(key))) {
|
|
137
|
+
return unsupported('object', '不允许的原型字段');
|
|
138
|
+
}
|
|
139
|
+
ancestors.add(value);
|
|
140
|
+
try {
|
|
141
|
+
if (isArray) {
|
|
142
|
+
// Array metadata beyond indexed items is not representable in a JSON array.
|
|
143
|
+
const length = descriptors.length?.value;
|
|
144
|
+
if (Object.keys(descriptors).some(key => descriptors[key].enumerable && (!/^(0|[1-9]\d*)$/.test(key) || Number(key) >= length))) {
|
|
145
|
+
return unsupported('array', 'JSON数组不能表示额外字段');
|
|
146
|
+
}
|
|
147
|
+
return Array.from({ length }, (_, index) => {
|
|
148
|
+
const descriptor = descriptors[String(index)];
|
|
149
|
+
return !descriptor ? unsupported('undefined', '数组空位')
|
|
150
|
+
: 'value' in descriptor ? snapshotPayload(descriptor.value, ancestors, protectModelMaterial)
|
|
151
|
+
: unsupported('accessor', '不执行访问器');
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
const result = {};
|
|
155
|
+
const entries = [];
|
|
156
|
+
let redactedKeys = false;
|
|
157
|
+
for (const [key, descriptor] of Object.entries(descriptors)) {
|
|
158
|
+
if (!descriptor.enumerable)
|
|
159
|
+
continue;
|
|
160
|
+
const name = safeText(key);
|
|
161
|
+
const value = 'value' in descriptor ? descriptor.value : undefined;
|
|
162
|
+
const normalized = key.replace(/[^a-z0-9]/gi, '').toLowerCase();
|
|
163
|
+
const counterNames = new Set(['inputtokens', 'outputtokens', 'cachedinputtokens', 'cachedreadtokens', 'cachedwritetokens', 'thoughtTokens'.toLowerCase(), 'totaltokens', 'cachewriteinputtokens', 'cachereadinputtokens', 'reasoningoutputtokens']);
|
|
164
|
+
const validCounter = (v) => typeof v === 'number' && Number.isSafeInteger(v) && v >= 0;
|
|
165
|
+
const counter = counterNames.has(normalized) && validCounter(value)
|
|
166
|
+
|| normalized === 'tokencount' && value !== null && typeof value === 'object' && Object.values(Object.getOwnPropertyDescriptors(value)).every(d => 'value' in d && validCounter(d.value)) && Object.keys(value).every(k => counterNames.has(k.replace(/[^a-z0-9]/gi, '').toLowerCase()));
|
|
167
|
+
const material = protectModelMaterial && (/(?:headers?|body|oldtext|newtext|aggregatedoutput|stdout|stderr)$/.test(normalized)
|
|
168
|
+
|| ['text', 'content', 'prompt', 'input', 'output', 'rawinput', 'rawoutput', 'messages', 'modelinput', 'modeloutput', 'patch', 'diff', 'arguments', 'command', 'commandline', 'filecontent', 'data', 'message', 'usercode', 'devicecode', 'onetimecode'].includes(normalized));
|
|
169
|
+
const child = ((!counter && isSensitiveKey(key)) || material) ? redaction({ reason: '敏感字段' })
|
|
170
|
+
: 'value' in descriptor ? snapshotPayload(descriptor.value, ancestors, protectModelMaterial)
|
|
171
|
+
: unsupported('accessor', '不执行访问器');
|
|
172
|
+
if (typeof name !== 'string')
|
|
173
|
+
redactedKeys = true;
|
|
174
|
+
// Only safe property names may become object keys. The entry form keeps
|
|
175
|
+
// distinct original fields even when multiple names redact identically.
|
|
176
|
+
if (typeof name === 'string')
|
|
177
|
+
result[name] = child;
|
|
178
|
+
entries.push({ name, value: child });
|
|
179
|
+
}
|
|
180
|
+
return redactedKeys ? { redactedKeys: true, entries } : result;
|
|
181
|
+
}
|
|
182
|
+
finally {
|
|
183
|
+
ancestors.delete(value);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
function freezeDeep(value) {
|
|
187
|
+
if (value !== null && typeof value === 'object') {
|
|
188
|
+
for (const child of Object.values(value))
|
|
189
|
+
freezeDeep(child);
|
|
190
|
+
Object.freeze(value);
|
|
191
|
+
}
|
|
192
|
+
return value;
|
|
193
|
+
}
|
|
194
|
+
function cloneFrozen(value) { return freezeDeep(structuredClone(value)); }
|
|
195
|
+
/** No raw values from unclassified events or tool I/O are approved. The
|
|
196
|
+
* structure and original JSON types remain inspectable, including unknown keys. */
|
|
197
|
+
function opaqueStructure(value) {
|
|
198
|
+
if (Array.isArray(value))
|
|
199
|
+
return value.map(opaqueStructure);
|
|
200
|
+
if (value !== null && typeof value === 'object') {
|
|
201
|
+
if (isSanitizedRedaction(value))
|
|
202
|
+
return value;
|
|
203
|
+
return Object.fromEntries(Object.entries(value).map(([name, child]) => [name, opaqueStructure(child)]));
|
|
204
|
+
}
|
|
205
|
+
return redaction({ valueType: value === null ? 'null' : typeof value });
|
|
206
|
+
}
|
|
207
|
+
/** Structural identifiers are approved only at the SDK/ACP envelope and its
|
|
208
|
+
* immediate item/update. Arbitrary result subtrees never inherit that trust. */
|
|
209
|
+
function toolStructure(value, level = 'envelope') {
|
|
210
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
211
|
+
return opaqueStructure(value);
|
|
212
|
+
if (isSanitizedRedaction(value))
|
|
213
|
+
return value;
|
|
214
|
+
return Object.fromEntries(Object.entries(value).map(([key, child]) => {
|
|
215
|
+
const field = key.replace(/[^a-z0-9]/gi, '').toLowerCase();
|
|
216
|
+
if (typeof child === 'string' && ['id', 'type', 'kind', 'status', 'code', 'name', 'toolcallid', 'optionid', 'sessionid', 'sessionupdate'].includes(field))
|
|
217
|
+
return [key, child];
|
|
218
|
+
if (typeof child === 'number' && ['exitcode', 'durationms'].includes(field))
|
|
219
|
+
return [key, child];
|
|
220
|
+
if (level === 'envelope' && ['item', 'update', 'toolCall'].includes(key))
|
|
221
|
+
return [key, toolStructure(child, 'item')];
|
|
222
|
+
return [key, opaqueStructure(child)];
|
|
223
|
+
}));
|
|
224
|
+
}
|
|
225
|
+
function catalogStructure(value, level = 'root') {
|
|
226
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
227
|
+
return opaqueStructure(value);
|
|
228
|
+
if (isSanitizedRedaction(value))
|
|
229
|
+
return value;
|
|
230
|
+
return Object.fromEntries(Object.entries(value).map(([key, child]) => {
|
|
231
|
+
if (typeof child === 'string' && ((level === 'entry' && ['name', 'description'].includes(key)) || (level === 'input' && key === 'hint') || (level === 'root' && ['sessionId', 'sessionUpdate'].includes(key))))
|
|
232
|
+
return [key, child];
|
|
233
|
+
if (level === 'root' && key === 'update')
|
|
234
|
+
return [key, catalogStructure(child)];
|
|
235
|
+
if (level === 'root' && key === 'availableCommands' && Array.isArray(child))
|
|
236
|
+
return [key, child.map(v => catalogStructure(v, 'entry'))];
|
|
237
|
+
if (level === 'entry' && key === 'input')
|
|
238
|
+
return [key, child === null ? null : catalogStructure(child, 'input')];
|
|
239
|
+
return [key, opaqueStructure(child)];
|
|
240
|
+
}));
|
|
241
|
+
}
|
|
242
|
+
function usageStructure(value, key = '') {
|
|
243
|
+
if (Array.isArray(value))
|
|
244
|
+
return value.map(child => usageStructure(child));
|
|
245
|
+
if (value !== null && typeof value === 'object') {
|
|
246
|
+
if (isSanitizedRedaction(value))
|
|
247
|
+
return value;
|
|
248
|
+
return Object.fromEntries(Object.entries(value).map(([name, child]) => [name, usageStructure(child, name)]));
|
|
249
|
+
}
|
|
250
|
+
const field = key.replace(/[^a-z0-9]/gi, '').toLowerCase();
|
|
251
|
+
if (typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 && ['inputtokens', 'outputtokens', 'cachedinputtokens', 'cachedreadtokens', 'cachedwritetokens', 'thoughttokens', 'totaltokens', 'cachewriteinputtokens', 'cachereadinputtokens', 'reasoningoutputtokens'].includes(field))
|
|
252
|
+
return value;
|
|
253
|
+
if (key === 'stopReason' && typeof value === 'string' && ['end_turn', 'max_tokens', 'max_turn_requests', 'refusal', 'cancelled'].includes(value))
|
|
254
|
+
return value;
|
|
255
|
+
return opaqueStructure(value);
|
|
256
|
+
}
|
|
257
|
+
/** Sanitize an internal observation without attaching any L wire semantics. */
|
|
258
|
+
export function sanitizeObservation(observation, protectModelMaterial = false) {
|
|
259
|
+
const payload = snapshotPayload(observation.payload, new Set(), protectModelMaterial && observation.kind !== 'commands');
|
|
260
|
+
return freezeDeep({
|
|
261
|
+
kind: observation.kind, severity: observation.severity,
|
|
262
|
+
code: safeLabel(observation.code), summary: safeLabel(observation.summary),
|
|
263
|
+
...(observation.method === undefined ? {} : { method: safeLabel(observation.method) }),
|
|
264
|
+
payload: !protectModelMaterial ? payload
|
|
265
|
+
: observation.kind === 'commands' ? catalogStructure(payload)
|
|
266
|
+
: observation.kind === 'usage' ? usageStructure(payload)
|
|
267
|
+
: observation.kind === 'need' ? payload
|
|
268
|
+
: observation.kind === 'tool' || ['SDK_ITEM', 'SDK_TOOL', 'ACP_SESSION_UPDATE', 'ACP_IDLE_UPDATE'].includes(observation.code) ? toolStructure(payload)
|
|
269
|
+
: opaqueStructure(payload),
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
function validatedScope(scope) {
|
|
273
|
+
for (const name of ['instanceId', 'sessionId']) {
|
|
274
|
+
if (typeof scope[name] !== 'string' || scope[name].trim().length === 0)
|
|
275
|
+
throw new TypeError(`Invalid detail scope ${name}`);
|
|
276
|
+
}
|
|
277
|
+
for (const name of ['epoch', 'turn', 'step']) {
|
|
278
|
+
if (!Number.isInteger(scope[name]) || scope[name] < (name === 'epoch' ? 0 : 1))
|
|
279
|
+
throw new TypeError(`Invalid detail scope ${name}`);
|
|
280
|
+
}
|
|
281
|
+
if (scope.provider !== 'claude' && scope.provider !== 'codex')
|
|
282
|
+
throw new TypeError('Invalid detail scope provider');
|
|
283
|
+
return { instanceId: scope.instanceId, epoch: scope.epoch, sessionId: scope.sessionId, provider: scope.provider, turn: scope.turn, step: scope.step };
|
|
284
|
+
}
|
|
285
|
+
/** Internal diagnostic recorder; a sink is not an assertion that an L wire API exists. */
|
|
286
|
+
export class ConversationDetails {
|
|
287
|
+
scope;
|
|
288
|
+
sink;
|
|
289
|
+
records = [];
|
|
290
|
+
tail = Promise.resolve();
|
|
291
|
+
firstFailure;
|
|
292
|
+
constructor(scope, sink) {
|
|
293
|
+
this.scope = freezeDeep(validatedScope(scope));
|
|
294
|
+
this.sink = sink;
|
|
295
|
+
}
|
|
296
|
+
record(observation) {
|
|
297
|
+
const record = freezeDeep({
|
|
298
|
+
...this.scope, version: 1, eventId: randomUUID(), sequence: this.records.length + 1,
|
|
299
|
+
...sanitizeObservation(observation),
|
|
300
|
+
});
|
|
301
|
+
this.records.push(record);
|
|
302
|
+
this.tail = this.tail.then(async () => {
|
|
303
|
+
try {
|
|
304
|
+
await this.sink.append(cloneFrozen(record));
|
|
305
|
+
}
|
|
306
|
+
catch (error) {
|
|
307
|
+
// Keep processing later entries in order, but never turn a failed flush green.
|
|
308
|
+
if (!this.firstFailure)
|
|
309
|
+
this.firstFailure = { error };
|
|
310
|
+
}
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
async flush() {
|
|
314
|
+
await this.tail;
|
|
315
|
+
if (this.firstFailure)
|
|
316
|
+
throw this.firstFailure.error;
|
|
317
|
+
}
|
|
318
|
+
snapshot() { return cloneFrozen(this.records); }
|
|
319
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { type AcpHooks } from './acp.ts';
|
|
|
5
5
|
import { type RuntimeProvider } from './provider.ts';
|
|
6
6
|
import { type AgentDefinition } from './agents.ts';
|
|
7
7
|
import { type ImageReader } from './input.ts';
|
|
8
|
+
import type { LoginDiagnostic } from './login-diagnostic.ts';
|
|
8
9
|
export declare const name = "ai-workbench-remote-agent";
|
|
9
10
|
export declare const settingsNamespace = "ai-workbench-codex";
|
|
10
11
|
export declare const inject: string[];
|
|
@@ -47,17 +48,22 @@ type LiveConfig = {
|
|
|
47
48
|
};
|
|
48
49
|
/** DSH volatile settings are handles; adapters freeze their values once per invocation. */
|
|
49
50
|
export declare function readConfig(config: LiveConfig): PluginConfig;
|
|
51
|
+
export type LoginFailureReporter = (sessionId: string | undefined, code: string, diagnostic: LoginDiagnostic) => Promise<{
|
|
52
|
+
eventId: string;
|
|
53
|
+
recorded: true;
|
|
54
|
+
}>;
|
|
50
55
|
export declare class CodexRemoteAdapter extends LlmAdapter {
|
|
51
56
|
private resolveRuntime;
|
|
52
57
|
private approve?;
|
|
53
58
|
private readonly providerName;
|
|
54
59
|
private readonly agent;
|
|
55
60
|
private readonly readImage?;
|
|
61
|
+
private readonly reportLoginFailure?;
|
|
56
62
|
private config;
|
|
57
63
|
private sessions;
|
|
58
64
|
private turns;
|
|
59
65
|
private lifetime;
|
|
60
|
-
constructor(config: PluginConfig | (() => PluginConfig), resolveRuntime: RuntimeProvider, approve?: InvocationApproval | undefined, providerName?: () => string, agent?: AgentDefinition, readImage?: ImageReader | undefined);
|
|
66
|
+
constructor(config: PluginConfig | (() => PluginConfig), resolveRuntime: RuntimeProvider, approve?: InvocationApproval | undefined, providerName?: () => string, agent?: AgentDefinition, readImage?: ImageReader | undefined, reportLoginFailure?: LoginFailureReporter | undefined);
|
|
61
67
|
providerInfo(provider: string): {
|
|
62
68
|
id: string;
|
|
63
69
|
name: string;
|
|
@@ -84,7 +90,7 @@ export declare class CodexRemoteAdapter extends LlmAdapter {
|
|
|
84
90
|
/** One official registration owns both routes, preserving opaque replay ownership. */
|
|
85
91
|
export declare class RemoteAgentsAdapter extends LlmAdapter {
|
|
86
92
|
private adapters;
|
|
87
|
-
constructor(config: () => PluginConfig, makeRuntime: (agent: AgentDefinition) => RuntimeProvider, approve: InvocationApproval, nameFor: (agent: AgentDefinition) => string, readImage?: ImageReader);
|
|
93
|
+
constructor(config: () => PluginConfig, makeRuntime: (agent: AgentDefinition) => RuntimeProvider, approve: InvocationApproval, nameFor: (agent: AgentDefinition) => string, readImage?: ImageReader, reportLoginFailure?: LoginFailureReporter);
|
|
88
94
|
private adapter;
|
|
89
95
|
providerInfo(provider: string): {
|
|
90
96
|
id: string;
|
package/dist/index.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { SessionId } from '@deepseek-ai/dsh-session';
|
|
1
2
|
import Schema from '@deepseek-ai/schemastery';
|
|
2
3
|
import { LlmAdapter, LlmError } from '@deepseek-ai/dsh-llm';
|
|
3
4
|
import { fileURLToPath } from 'node:url';
|
|
@@ -14,6 +15,7 @@ import { prepareInput } from "./input.js";
|
|
|
14
15
|
import { createWorkspaceNoticeConsumer, isWorkspaceNoticeMessage } from "./workspace-notice.js";
|
|
15
16
|
import { NeedError } from "./remote.js";
|
|
16
17
|
import { employeeFailure } from "./presentation.js";
|
|
18
|
+
import { createRemoteDetails } from "./remote-details.js";
|
|
17
19
|
import { PROVIDER_NAMES, ProviderLocaleRemote } from "./provider-locale.js";
|
|
18
20
|
export const name = 'ai-workbench-remote-agent';
|
|
19
21
|
export const settingsNamespace = 'ai-workbench-codex';
|
|
@@ -53,17 +55,19 @@ export class CodexRemoteAdapter extends LlmAdapter {
|
|
|
53
55
|
providerName;
|
|
54
56
|
agent;
|
|
55
57
|
readImage;
|
|
58
|
+
reportLoginFailure;
|
|
56
59
|
config;
|
|
57
60
|
sessions = new Map();
|
|
58
61
|
turns = new Map();
|
|
59
62
|
lifetime = new AbortController();
|
|
60
|
-
constructor(config, resolveRuntime, approve, providerName = () => PROVIDER_NAMES.en, agent = CODEX_AGENT, readImage) {
|
|
63
|
+
constructor(config, resolveRuntime, approve, providerName = () => PROVIDER_NAMES.en, agent = CODEX_AGENT, readImage, reportLoginFailure) {
|
|
61
64
|
super();
|
|
62
65
|
this.resolveRuntime = resolveRuntime;
|
|
63
66
|
this.approve = approve;
|
|
64
67
|
this.providerName = providerName;
|
|
65
68
|
this.agent = agent;
|
|
66
69
|
this.readImage = readImage;
|
|
70
|
+
this.reportLoginFailure = reportLoginFailure;
|
|
67
71
|
this.config = typeof config === 'function' ? config : () => config;
|
|
68
72
|
}
|
|
69
73
|
providerInfo(provider) { return { id: provider, name: this.providerName() }; }
|
|
@@ -93,7 +97,17 @@ export class CodexRemoteAdapter extends LlmAdapter {
|
|
|
93
97
|
if (error instanceof NeedError) {
|
|
94
98
|
const rpc = /RPC 错误码 (-?\d+)/u.exec(error.requires)?.[1];
|
|
95
99
|
const message = employeeFailure(error.code) + (rpc ? `(技术详情:RPC 错误码 ${rpc})` : '');
|
|
96
|
-
|
|
100
|
+
let diagnostic = error.technical ? JSON.stringify(error.technical) : '';
|
|
101
|
+
if (error.technical && this.reportLoginFailure) {
|
|
102
|
+
try {
|
|
103
|
+
const receipt = await this.reportLoginFailure(options.sessionId, error.code, error.technical);
|
|
104
|
+
diagnostic += ';诊断 eventId=' + receipt.eventId;
|
|
105
|
+
}
|
|
106
|
+
catch (failure) {
|
|
107
|
+
diagnostic += ';诊断保存失败:' + (failure instanceof NeedError ? failure.code : 'DIAGNOSTIC_WRITE_FAILED');
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
throw new LlmError(message, error.code, { cause: new Error(`诊断代码:${error.code};${error.requires}` + (diagnostic ? ';' + diagnostic : '')) });
|
|
97
111
|
}
|
|
98
112
|
throw error;
|
|
99
113
|
}
|
|
@@ -116,6 +130,7 @@ export class CodexRemoteAdapter extends LlmAdapter {
|
|
|
116
130
|
let live;
|
|
117
131
|
let succeeded = false;
|
|
118
132
|
let remoteAttempted = false;
|
|
133
|
+
let activeMapper;
|
|
119
134
|
const config = this.config();
|
|
120
135
|
const mode = this.agent.oneShot ? config.mode : 'session';
|
|
121
136
|
try {
|
|
@@ -150,9 +165,17 @@ export class CodexRemoteAdapter extends LlmAdapter {
|
|
|
150
165
|
signal.throwIfAborted();
|
|
151
166
|
if (previous && (previous.identity !== runtime.identity || previous.cwd !== runtime.cwd || previous.bindingId && previous.bindingId !== runtime.binding?.bindingId || previous.workspaceId && previous.workspaceId !== runtime.binding?.workspaceId))
|
|
152
167
|
throw new NeedError('SESSION_CONTEXT_MISMATCH', '原会话的员工主机或工作区已改变,请新建会话', '原会话的可信目标和工作区');
|
|
153
|
-
const
|
|
168
|
+
const details = runtime.details;
|
|
169
|
+
if (!details)
|
|
170
|
+
throw new NeedError('TECHNICAL_DETAILS_UNAVAILABLE', '技术详情暂不可用,请更新远程主机连接组件。', 'L技术详情持久接收能力');
|
|
171
|
+
const mapper = new StreamMapper(mode === 'session' ? (live ? live.usageSnapshot : previous ? (previous.usageSnapshot ?? null) : undefined) : undefined, observation => details.record(observation));
|
|
172
|
+
activeMapper = mapper;
|
|
154
173
|
let id;
|
|
155
174
|
let stop;
|
|
175
|
+
if (runtime.workspaceNotice) {
|
|
176
|
+
yield* mapper.text(runtime.workspaceNotice.text);
|
|
177
|
+
yield* mapper.flush();
|
|
178
|
+
}
|
|
156
179
|
if (mode === 'session') {
|
|
157
180
|
if (live && live.runtime !== runtime)
|
|
158
181
|
throw new NeedError('SESSION_TRANSPORT_MISMATCH', '原会话的主机通道已更换,不能自动切换进程', '原主机会话通道');
|
|
@@ -169,7 +192,7 @@ export class CodexRemoteAdapter extends LlmAdapter {
|
|
|
169
192
|
catch (error) {
|
|
170
193
|
failure = error;
|
|
171
194
|
session?.close();
|
|
172
|
-
} }, onResponse: response => push(mapper.acpResponse(response)),
|
|
195
|
+
} }, onResponse: response => push(mapper.acpResponse(response)), onDetail: detail => details.record(detail) };
|
|
173
196
|
const fresh = !live;
|
|
174
197
|
if (fresh) {
|
|
175
198
|
const transport = runtime.acpTransport instanceof acp.AgentApp || runtime.acpTransport instanceof unstable.AgentApp ? runtime.acpTransport : acp.ndJsonStream(runtime.acpTransport.writable, runtime.acpTransport.readable, { maxMessageBytes: config.acpMaxMessageBytes });
|
|
@@ -193,8 +216,15 @@ export class CodexRemoteAdapter extends LlmAdapter {
|
|
|
193
216
|
if (updated && this.agent.loginProvider === 'claude')
|
|
194
217
|
throw new NeedError('SYSTEM_PROMPT_UPDATE_PENDING', 'Claude原会话的系统或开发者要求已变化;主机仅承诺下次上下文整理后生效,本轮未发送,请新建会话应用新要求', '新建Claude会话以立即应用当前系统与开发者要求');
|
|
195
218
|
}
|
|
219
|
+
await details.flush();
|
|
220
|
+
details.beginTurn(runtime.detailContext?.() ?? { turn: null, step: null });
|
|
196
221
|
id = session.id;
|
|
197
|
-
|
|
222
|
+
try {
|
|
223
|
+
return await session.prompt(input.prompt, signal);
|
|
224
|
+
}
|
|
225
|
+
finally {
|
|
226
|
+
details.endTurn();
|
|
227
|
+
}
|
|
198
228
|
})();
|
|
199
229
|
void task.then(value => { stop = value; }, error => { failure ??= runtime.broker?.error ?? error; }).finally(() => { done = true; wake?.(); wake = undefined; });
|
|
200
230
|
while (!done || queue.length) {
|
|
@@ -214,6 +244,7 @@ export class CodexRemoteAdapter extends LlmAdapter {
|
|
|
214
244
|
else {
|
|
215
245
|
if (!runtime.oneShot)
|
|
216
246
|
throw new NeedError('DEPENDENCY_REQUIRED', '我需要主机单次运行助手', 'S安装的aw-codex-once');
|
|
247
|
+
details.beginTurn(runtime.detailContext?.() ?? { turn: null, step: null });
|
|
217
248
|
const text = input.prompt.map(block => block.type === 'text' ? block.text : '').join('\n');
|
|
218
249
|
for await (const event of runOneShot(runtime.oneShot, { wrapperPath: fileURLToPath(new URL('./sdk-ssh-wrapper.js', import.meta.url)), sandboxMode: config.oneShotSandbox, approvalPolicy: config.oneShotApproval }, text, previous?.sessionId, signal)) {
|
|
219
250
|
if (event.type === 'thread.started')
|
|
@@ -222,17 +253,31 @@ export class CodexRemoteAdapter extends LlmAdapter {
|
|
|
222
253
|
yield chunk;
|
|
223
254
|
}
|
|
224
255
|
}
|
|
256
|
+
details.endTurn();
|
|
257
|
+
await details.flush();
|
|
225
258
|
if (!id)
|
|
226
259
|
throw new NeedError('SESSION_ID_MISSING', '远端没有返回会话标识', '有效的远端会话标识');
|
|
227
260
|
if (live)
|
|
228
261
|
live.usageSnapshot = mapper.usageSnapshot;
|
|
229
|
-
|
|
262
|
+
yield* mapper.flush();
|
|
263
|
+
const pointer = { ...(runtime.workspaceNotice ? { presentation: { workspaceNotice: runtime.workspaceNotice } } : {}), version: 2, agent: this.agent.id, contextSha256, ...(mode === 'session' ? { usageSnapshot: mapper.usageSnapshot } : {}), ...(options.sessionId ? { dshSessionId: options.sessionId } : {}), mode, identity: runtime.identity, cwd: runtime.cwd, sessionId: id, ...(runtime.binding ? { bindingId: runtime.binding.bindingId, workspaceId: runtime.binding.workspaceId } : {}) };
|
|
230
264
|
succeeded = true;
|
|
231
265
|
yield { type: 'finish', reason: { kind: stop === 'max_tokens' || stop === 'max_turn_requests' ? 'max-tokens' : 'stop' }, replayState: { response: pointer } };
|
|
232
266
|
}
|
|
233
267
|
catch (error) {
|
|
234
268
|
if (live && (remoteAttempted || runtime === live.runtime))
|
|
235
|
-
live.failure = error;
|
|
269
|
+
live.failure = { cause: error };
|
|
270
|
+
let reportingFailure;
|
|
271
|
+
try {
|
|
272
|
+
await runtime?.details?.flush();
|
|
273
|
+
}
|
|
274
|
+
catch (failure) {
|
|
275
|
+
reportingFailure = { cause: failure };
|
|
276
|
+
}
|
|
277
|
+
if (activeMapper)
|
|
278
|
+
yield* activeMapper.flush();
|
|
279
|
+
if (reportingFailure !== undefined)
|
|
280
|
+
throw reportingFailure.cause;
|
|
236
281
|
throw error;
|
|
237
282
|
}
|
|
238
283
|
finally {
|
|
@@ -248,8 +293,8 @@ export class CodexRemoteAdapter extends LlmAdapter {
|
|
|
248
293
|
/** One official registration owns both routes, preserving opaque replay ownership. */
|
|
249
294
|
export class RemoteAgentsAdapter extends LlmAdapter {
|
|
250
295
|
adapters = new Map();
|
|
251
|
-
constructor(config, makeRuntime, approve, nameFor, readImage) { super(); for (const agent of AGENTS)
|
|
252
|
-
this.adapters.set(agent.id, new CodexRemoteAdapter(config, makeRuntime(agent), approve, () => nameFor(agent), agent, readImage)); }
|
|
296
|
+
constructor(config, makeRuntime, approve, nameFor, readImage, reportLoginFailure) { super(); for (const agent of AGENTS)
|
|
297
|
+
this.adapters.set(agent.id, new CodexRemoteAdapter(config, makeRuntime(agent), approve, () => nameFor(agent), agent, readImage, reportLoginFailure)); }
|
|
253
298
|
adapter(provider) { const adapter = this.adapters.get(provider); if (!adapter)
|
|
254
299
|
throw new LlmError('远程模型未注册', 'PROVIDER_NOT_FOUND'); return adapter; }
|
|
255
300
|
providerInfo(provider) { return this.adapter(provider).providerInfo(provider); }
|
|
@@ -269,7 +314,62 @@ export function apply(ctx, live) {
|
|
|
269
314
|
throw new NeedError('ATTACHMENT_REQUIRED', '我需要官方附件组件读取这张图片', '官方attachments.readImage');
|
|
270
315
|
return attachments.readImage(ref, signal);
|
|
271
316
|
};
|
|
272
|
-
const
|
|
317
|
+
const openByAgent = new Map(AGENTS.map(agent => [agent.id, brokerFactory(get, undefined, config)]));
|
|
318
|
+
const detailManagers = new Map();
|
|
319
|
+
const managerForProfile = () => {
|
|
320
|
+
const profile = ctx.get('profileContext');
|
|
321
|
+
if (typeof profile?.dir !== 'string')
|
|
322
|
+
throw new NeedError('TECHNICAL_DETAILS_UNAVAILABLE', '技术详情暂不可用:缺少官方profile目录。', '官方profileContext.dir');
|
|
323
|
+
let manager = detailManagers.get(profile.dir);
|
|
324
|
+
if (!manager) {
|
|
325
|
+
manager = createRemoteDetails({ profileDir: profile.dir, getApi: get });
|
|
326
|
+
detailManagers.set(profile.dir, manager);
|
|
327
|
+
}
|
|
328
|
+
return manager;
|
|
329
|
+
};
|
|
330
|
+
const adapter = new RemoteAgentsAdapter(config, agent => connectProvider(get, { agent, notice: createWorkspaceNoticeConsumer(ctx, agent), details: async (request, api, connection, target) => {
|
|
331
|
+
return managerForProfile().open({ sessionId: request.sessionId, provider: agent.loginProvider, identity: `${target.username}@${target.host}:${target.port}`, connection });
|
|
332
|
+
}, open: async (...args) => {
|
|
333
|
+
const runtime = await openByAgent.get(agent.id)(...args);
|
|
334
|
+
runtime.detailContext = () => {
|
|
335
|
+
const events = args[2].sessionId ? ctx.get('agents')?.get(SessionId(args[2].sessionId))?.session.snapshotEvents() : undefined;
|
|
336
|
+
let turn = null, step = null;
|
|
337
|
+
for (const event of events ?? []) {
|
|
338
|
+
if (event.type === 'turn/start') {
|
|
339
|
+
turn = event.data.turn;
|
|
340
|
+
step = null;
|
|
341
|
+
}
|
|
342
|
+
else if (event.type === 'turn/end') {
|
|
343
|
+
turn = null;
|
|
344
|
+
step = null;
|
|
345
|
+
}
|
|
346
|
+
else if (event.type === 'step/start' && event.data.turn === turn)
|
|
347
|
+
step = event.data.step;
|
|
348
|
+
else if (event.type === 'step/end')
|
|
349
|
+
step = null;
|
|
350
|
+
}
|
|
351
|
+
return { turn, step };
|
|
352
|
+
};
|
|
353
|
+
return runtime;
|
|
354
|
+
} }), dshApproval(ctx), agent => agent.name[language], readImage, (sessionId, code, diagnostic) => {
|
|
355
|
+
const events = sessionId ? ctx.get('agents')?.get(SessionId(sessionId))?.session.snapshotEvents() : undefined;
|
|
356
|
+
let turn = null, step = null;
|
|
357
|
+
for (const event of events ?? []) {
|
|
358
|
+
if (event.type === 'turn/start') {
|
|
359
|
+
turn = event.data.turn;
|
|
360
|
+
step = null;
|
|
361
|
+
}
|
|
362
|
+
else if (event.type === 'turn/end') {
|
|
363
|
+
turn = null;
|
|
364
|
+
step = null;
|
|
365
|
+
}
|
|
366
|
+
else if (event.type === 'step/start' && event.data.turn === turn)
|
|
367
|
+
step = event.data.step;
|
|
368
|
+
else if (event.type === 'step/end')
|
|
369
|
+
step = null;
|
|
370
|
+
}
|
|
371
|
+
return managerForProfile().recordLoginFailure({ sessionId, code, diagnostic, context: { turn, step } });
|
|
372
|
+
});
|
|
273
373
|
const ids = AGENTS.map(agent => agent.id);
|
|
274
374
|
const entries = () => AGENTS.map(agent => ({ provider: agent.id, displayName: agent.name[language], settingsNs: settingsNamespace, settingsPath: [] }));
|
|
275
375
|
const directory = ctx.llm.registerConfigurableProviders(entries());
|
package/dist/input.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { NeedError } from "./remote.js";
|
|
2
|
-
import { isWorkspaceNoticeMessage } from "./workspace-notice.js";
|
|
2
|
+
import { isWorkspaceNoticeMessage, withoutWorkspaceNotice } from "./workspace-notice.js";
|
|
3
3
|
/** The attachment service supplies verified bytes; client paths never cross SSH. */
|
|
4
4
|
export async function prepareInput(options, readImage) {
|
|
5
5
|
options.signal?.throwIfAborted();
|
|
6
|
-
const messages = options.messages.filter(message => !isWorkspaceNoticeMessage(message));
|
|
6
|
+
const messages = options.messages.filter(message => !isWorkspaceNoticeMessage(message)).map(withoutWorkspaceNotice);
|
|
7
7
|
const context = [];
|
|
8
8
|
if (options.system)
|
|
9
9
|
context.push('系统上下文:\n' + options.system);
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { ConnectionState, LoginPromptState } from '@ai-workbench/connect/api';
|
|
2
|
+
export type LoginReason = 'LOGIN_INSTANCE_MISMATCH' | 'LOGIN_PHASE_BLOCKED' | 'LOGIN_PHASE_DISPOSED' | 'LOGIN_CHECKING' | 'LOGIN_AUTHORIZING' | 'LOGIN_PROVIDER_NEED' | 'LOGIN_VALUE_INVALID' | 'LOGIN_REQUIRED' | 'L_ENSURE_READY_FAILED' | 'LOGIN_QUEUED';
|
|
3
|
+
/** Allowlisted protocol facts only: never messages, requires, host details or status command output. */
|
|
4
|
+
export interface LoginDiagnostic {
|
|
5
|
+
version: 1;
|
|
6
|
+
source: 'verified-login' | 'readiness' | 'ensure-ready';
|
|
7
|
+
reason: LoginReason;
|
|
8
|
+
provider: 'claude' | 'codex';
|
|
9
|
+
officialReason?: string;
|
|
10
|
+
officialReasonRejected?: {
|
|
11
|
+
bytes: number;
|
|
12
|
+
sha256: string;
|
|
13
|
+
};
|
|
14
|
+
connection: {
|
|
15
|
+
instanceId: string;
|
|
16
|
+
phase: string;
|
|
17
|
+
epoch: number;
|
|
18
|
+
};
|
|
19
|
+
login: {
|
|
20
|
+
instanceId: string;
|
|
21
|
+
phase: string;
|
|
22
|
+
revision: number;
|
|
23
|
+
provider: 'claude' | 'codex' | null;
|
|
24
|
+
logins: {
|
|
25
|
+
claude: boolean | 'absent' | 'invalid';
|
|
26
|
+
codex: boolean | 'absent' | 'invalid';
|
|
27
|
+
};
|
|
28
|
+
queuedProviders: ('claude' | 'codex')[];
|
|
29
|
+
checkingProviders: ('claude' | 'codex')[];
|
|
30
|
+
providerNeeds: {
|
|
31
|
+
claude: string | null;
|
|
32
|
+
codex: string | null;
|
|
33
|
+
};
|
|
34
|
+
code: string | null;
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
export declare function loginDiagnostic(source: LoginDiagnostic['source'], reason: LoginReason, provider: 'claude' | 'codex', connection: ConnectionState, state: LoginPromptState, officialReason?: string): LoginDiagnostic;
|
|
38
|
+
export declare function loginReason(state: LoginPromptState, connection: ConnectionState, provider: 'claude' | 'codex'): LoginReason | undefined;
|