@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.
@@ -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
- throw new LlmError(message, error.code, { cause: new Error(`诊断代码:${error.code};${error.requires}`) });
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 mapper = new StreamMapper(mode === 'session' ? (live ? live.usageSnapshot : previous ? (previous.usageSnapshot ?? null) : undefined) : undefined);
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)), onNeed: need => push(mapper.text(`\n[需要处理] ${need.message}\n`)) };
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
- return await session.prompt(input.prompt, signal);
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
- const pointer = { 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 } : {}) };
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 adapter = new RemoteAgentsAdapter(config, agent => connectProvider(get, { agent, notice: createWorkspaceNoticeConsumer(ctx, agent), open: brokerFactory(get, undefined, config) }), dshApproval(ctx), agent => agent.name[language], readImage);
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;