@ai-workbench/remote-agent 0.8.0 → 0.8.1
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 +6 -1
- package/dist/acp.d.ts +8 -0
- package/dist/acp.js +59 -21
- package/dist/conversation-details.d.ts +47 -0
- package/dist/conversation-details.js +319 -0
- package/dist/index.js +73 -6
- package/dist/input.js +2 -2
- package/dist/mapping.d.ts +11 -5
- package/dist/mapping.js +105 -141
- package/dist/presentation.js +4 -0
- package/dist/provider.d.ts +11 -2
- package/dist/provider.js +7 -1
- package/dist/remote-details.d.ts +26 -0
- package/dist/remote-details.js +250 -0
- package/dist/workspace-notice.d.ts +22 -1
- package/dist/workspace-notice.js +148 -129
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
在 DSH「插件 → 添加插件」填 `@ai-workbench/remote-agent@0.8.
|
|
1
|
+
在 DSH「插件 → 添加插件」填 `@ai-workbench/remote-agent@0.8.1`(由编排发布后可用),或运行 `dsh plugin --profile desktop add @ai-workbench/remote-agent@0.8.1`。
|
|
2
2
|
|
|
3
3
|
# 远程ACP插件
|
|
4
4
|
|
|
@@ -52,3 +52,8 @@ Codex单次模式首版仅文本,不转接中途审批;图片或系统/开
|
|
|
52
52
|
## 验证范围
|
|
53
53
|
|
|
54
54
|
当前准备结果与限制记入源码仓`docs/evidence/2026-10-21-X-npm-prep/`(不进npm包)。先前真实emp-a协议诊断及固定三件套GUI是不同轮次,不能代替本次新包安装门。L完整绑定/登录、真实模型、物理断网、HanaMesh及leo E以实际已执行证据为准。主机命令安装来自S,X不修改S仓/安装脚本或登录状态。
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
D19(0.8.1):相邻正文增量合成连续文本块,工作区首次说明合入真实回复,按官方持久记录核对后才回报“已记入可核对回复”。这不代表界面已经显示。
|
|
58
|
+
|
|
59
|
+
技术详情由 L 0.7.9 的唯一“远程主机连接”设置节展示,正文只放模型文本。命令目录保留完整描述;未知事件和工具输入输出保留字段、JSON类型及处理结果,未获准原文明确脱敏,数字用量可核对。没有 `remoteConversationDetails:1` 时明确提示“技术详情暂不可用”,本轮不发送模型请求。X 在官方当前 profile 的 `ai-workbench-remote-agent/diagnostics/` 保存私有诊断日志(目录700、文件600、落盘同步);写入/归属/顺序/容量失败中止本轮,不自动重发、换代重投、过期或淘汰。结果未知时只读核对原事件完整回执,未找到仍保持未知。X本轮无新增设置/阈值;L的单事件容量和分页设置在L设置节可见。
|
package/dist/acp.d.ts
CHANGED
|
@@ -2,12 +2,14 @@ import * as stable from '@agentclientprotocol/sdk';
|
|
|
2
2
|
import * as acp from '@agentclientprotocol/sdk/unstable';
|
|
3
3
|
import { NeedError, type Need } from './remote.ts';
|
|
4
4
|
import type { BrokerTransport } from './broker.ts';
|
|
5
|
+
import { type SanitizedObservation } from './conversation-details.ts';
|
|
5
6
|
export type ApprovalOutcome = 'allowed-once' | 'rejected' | 'cancelled' | 'unavailable';
|
|
6
7
|
export interface AcpHooks {
|
|
7
8
|
onUpdate?: (notification: acp.SessionNotification) => void;
|
|
8
9
|
onResponse?: (response: acp.PromptResponse) => void;
|
|
9
10
|
approve?: (request: acp.RequestPermissionRequest, signal?: AbortSignal) => Promise<ApprovalOutcome>;
|
|
10
11
|
onNeed?: (need: Need) => void;
|
|
12
|
+
onDetail?: (detail: Readonly<SanitizedObservation>) => void;
|
|
11
13
|
broker?: BrokerTransport;
|
|
12
14
|
}
|
|
13
15
|
export interface AcpStartContext {
|
|
@@ -29,6 +31,12 @@ export declare class AcpSession {
|
|
|
29
31
|
private updating;
|
|
30
32
|
private cwd?;
|
|
31
33
|
private systemPrompt;
|
|
34
|
+
private reports;
|
|
35
|
+
private reportingFailure;
|
|
36
|
+
get diagnostics(): readonly Readonly<SanitizedObservation>[];
|
|
37
|
+
private detail;
|
|
38
|
+
private need;
|
|
39
|
+
private checkReporting;
|
|
32
40
|
constructor(transport: acp.Stream | acp.AgentApp | stable.AgentApp, hooks: AcpHooks);
|
|
33
41
|
private observe;
|
|
34
42
|
get id(): string | undefined;
|
package/dist/acp.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import * as acp from '@agentclientprotocol/sdk/unstable';
|
|
2
2
|
import { NeedError } from "./remote.js";
|
|
3
|
+
import { sanitizeObservation } from "./conversation-details.js";
|
|
3
4
|
/** ACP v1 with explicitly negotiated SDK 2.0 lifecycle/context extensions. */
|
|
4
5
|
export class AcpSession {
|
|
5
6
|
connection;
|
|
@@ -15,19 +16,45 @@ export class AcpSession {
|
|
|
15
16
|
updating = false;
|
|
16
17
|
cwd;
|
|
17
18
|
systemPrompt = '';
|
|
19
|
+
reports = [];
|
|
20
|
+
reportingFailure;
|
|
21
|
+
get diagnostics() { return this.reports.map(entry => sanitizeObservation(entry)); }
|
|
22
|
+
detail(observation) {
|
|
23
|
+
const safe = sanitizeObservation(observation);
|
|
24
|
+
this.reports.push(safe);
|
|
25
|
+
try {
|
|
26
|
+
this.hooks.onDetail?.(safe);
|
|
27
|
+
}
|
|
28
|
+
catch (cause) {
|
|
29
|
+
this.reportingFailure ??= { cause };
|
|
30
|
+
this.connection?.close();
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
need(need) {
|
|
34
|
+
this.detail({ kind: 'need', severity: 'action-required', code: need.code, summary: need.message, payload: { requires: need.requires } });
|
|
35
|
+
this.hooks.onNeed?.(need);
|
|
36
|
+
}
|
|
37
|
+
checkReporting() { if (this.reportingFailure)
|
|
38
|
+
throw new NeedError('CONVERSATION_DETAILS_FAILED', '技术详情未能记录;本轮未继续发送', '可用的技术详情记录渠道'); }
|
|
18
39
|
constructor(transport, hooks) {
|
|
19
40
|
this.hooks = hooks;
|
|
20
41
|
const app = acp.client({ name: '@ai-workbench/remote-agent' })
|
|
21
42
|
.onNotification(acp.methods.client.session.update, ({ params: notification }) => {
|
|
22
|
-
if (this.
|
|
23
|
-
|
|
43
|
+
if (this.sessionId && notification.sessionId !== this.sessionId) {
|
|
44
|
+
this.detail({ kind: 'session', severity: 'warning', code: 'ACP_FOREIGN_SESSION_UPDATE', summary: '收到不属于当前远端会话的通知,未作为正文处理', method: 'session/update', payload: notification });
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
const meta = record(notification._meta);
|
|
48
|
+
if (meta && Object.keys(meta).length)
|
|
49
|
+
this.detail({ kind: 'extension', severity: 'info', code: 'ACP_NOTIFICATION_META', summary: '远端通知扩展', method: 'session/update', payload: notification._meta });
|
|
50
|
+
if (this.busy) {
|
|
51
|
+
// Only actual turn output acknowledges prompt acceptance.
|
|
24
52
|
if (['agent_message_chunk', 'agent_thought_chunk', 'tool_call', 'tool_call_update'].includes(notification.update.sessionUpdate))
|
|
25
53
|
this.hooks.broker?.acknowledge(acp.methods.agent.session.prompt);
|
|
26
|
-
const meta = record(notification._meta);
|
|
27
|
-
if (meta && Object.keys(meta).length)
|
|
28
|
-
this.hooks.onNeed?.({ code: 'ACP_EXTENSION_UNSUPPORTED', message: `首版不支持:远端通知扩展字段 ${Object.keys(meta).map(safeLabel).join('、')};扩展值未展开`, requires: '对应的公开扩展映射' });
|
|
29
54
|
this.hooks.onUpdate?.(notification);
|
|
30
55
|
}
|
|
56
|
+
else
|
|
57
|
+
this.detail({ kind: notification.update.sessionUpdate === 'available_commands_update' ? 'commands' : 'session', severity: 'info', code: 'ACP_IDLE_UPDATE', summary: '远端会话建立、恢复或空闲时的轨迹', method: 'session/update', payload: notification });
|
|
31
58
|
})
|
|
32
59
|
.onRequest(acp.methods.client.session.requestPermission, async ({ params: request, signal: rpcSignal, requestId }) => {
|
|
33
60
|
if (request.sessionId !== this.sessionId || !this.busy || this.turnSignal?.aborted)
|
|
@@ -35,7 +62,7 @@ export class AcpSession {
|
|
|
35
62
|
this.hooks.broker?.acknowledge(acp.methods.agent.session.prompt);
|
|
36
63
|
const toolName = request.toolCall.name ?? record(request.toolCall._meta?.claudeCode)?.toolName;
|
|
37
64
|
if (toolName === 'AskUserQuestion') {
|
|
38
|
-
this.
|
|
65
|
+
this.need({ code: 'INTERACTION_UNSUPPORTED', message: '首版不支持:远端提问;已取消,未提交答案', requires: '可承载此提问的官方交互接口' });
|
|
39
66
|
return { outcome: { outcome: 'cancelled' } };
|
|
40
67
|
}
|
|
41
68
|
const eligible = request.options.filter(option => {
|
|
@@ -43,11 +70,13 @@ export class AcpSession {
|
|
|
43
70
|
const sameKind = request.options.filter(candidate => candidate.kind === option.kind);
|
|
44
71
|
const unique = single && sameKind.length === 1 && request.options.filter(candidate => candidate.optionId === option.optionId).length === 1;
|
|
45
72
|
if (!unique)
|
|
46
|
-
this.
|
|
73
|
+
this.detail({ kind: 'tool', severity: 'info', code: 'APPROVAL_OPTION_NOT_OFFERED', summary: '此远端审批选项未映射到单次审批', method: 'session/request_permission', payload: option });
|
|
47
74
|
return unique;
|
|
48
75
|
});
|
|
49
|
-
if (!eligible.length)
|
|
76
|
+
if (!eligible.length) {
|
|
77
|
+
this.need({ code: 'APPROVAL_OPTION_UNSUPPORTED', message: '远端没有可唯一映射的单次审批选项,此动作未获授权', requires: '唯一对应的允许一次或拒绝一次选项' });
|
|
50
78
|
return { outcome: { outcome: 'cancelled' } };
|
|
79
|
+
}
|
|
51
80
|
const presented = { ...request, options: eligible };
|
|
52
81
|
const token = this.turnToken;
|
|
53
82
|
const id = this.sessionId;
|
|
@@ -78,7 +107,7 @@ export class AcpSession {
|
|
|
78
107
|
}
|
|
79
108
|
}
|
|
80
109
|
catch {
|
|
81
|
-
this.
|
|
110
|
+
this.need({ code: 'APPROVAL_FAILED', message: '审批渠道失败或连接已结束;此动作未获授权', requires: '可用的 DSH 审批渠道和原连接' });
|
|
82
111
|
}
|
|
83
112
|
const kind = outcome === 'allowed-once' ? 'allow_once' : outcome === 'rejected' ? 'reject_once' : undefined;
|
|
84
113
|
const option = kind && eligible.find(item => item.kind === kind);
|
|
@@ -87,23 +116,24 @@ export class AcpSession {
|
|
|
87
116
|
broker.authorizeResponse(requestId, generation);
|
|
88
117
|
return { outcome: { outcome: 'selected', optionId: option.optionId } };
|
|
89
118
|
}
|
|
90
|
-
this.
|
|
119
|
+
this.need({ code: outcome === 'allowed-once' ? 'APPROVAL_OPTION_UNSUPPORTED' : 'APPROVAL_UNAVAILABLE',
|
|
91
120
|
message: '此动作未获授权;审批已取消、不可用或没有可映射的单次选项', requires: '本次动作的有效审批' });
|
|
92
121
|
return { outcome: { outcome: 'cancelled' } };
|
|
93
122
|
})
|
|
94
123
|
.onRequest(acp.methods.client.elicitation.create, () => {
|
|
95
|
-
this.
|
|
124
|
+
this.need({ code: 'INTERACTION_UNSUPPORTED', message: '首版不支持:远端提问或信息收集;已取消,未提交答案', requires: '可承载此提问的官方交互接口' });
|
|
96
125
|
return { action: 'cancel' };
|
|
97
126
|
})
|
|
98
127
|
.onNotification(acp.methods.client.elicitation.complete, () => {
|
|
99
|
-
this.
|
|
128
|
+
this.need({ code: 'INTERACTION_UNSUPPORTED', message: '首版不支持:远端提问完成通知;未提交答案', requires: '可承载此提问的官方交互接口' });
|
|
100
129
|
})
|
|
101
|
-
.onRequest('*', () => {
|
|
102
|
-
this.
|
|
130
|
+
.onRequest('*', ({ method, params }) => {
|
|
131
|
+
this.detail({ kind: 'extension', severity: 'action-required', code: 'ACP_UNKNOWN_REQUEST', summary: '未知远端交互请求未获授权', method, payload: params });
|
|
132
|
+
this.need({ code: 'ACP_EXTENSION_UNSUPPORTED', message: '首版不支持:远端请求的扩展交互;未授权此动作', requires: '受支持的公开 ACP 交互接口' });
|
|
103
133
|
throw new acp.RequestError(-32601, '不支持此扩展交互');
|
|
104
134
|
})
|
|
105
|
-
.onNotification('*', () => {
|
|
106
|
-
this.
|
|
135
|
+
.onNotification('*', ({ method, params }) => {
|
|
136
|
+
this.detail({ kind: 'extension', severity: 'warning', code: 'ACP_UNKNOWN_NOTIFICATION', summary: '收到未知远端扩展通知,已保留技术记录', method, payload: params });
|
|
107
137
|
});
|
|
108
138
|
if ('readable' in transport && 'writable' in transport)
|
|
109
139
|
this.connection = app.connect(this.observe(transport));
|
|
@@ -126,8 +156,7 @@ export class AcpSession {
|
|
|
126
156
|
const params = record(raw?.params);
|
|
127
157
|
const update = record(params?.update);
|
|
128
158
|
if (raw?.method === acp.methods.client.session.update && typeof update?.sessionUpdate === 'string' && !knownUpdates.has(update.sessionUpdate)) {
|
|
129
|
-
|
|
130
|
-
this.hooks.onNeed?.({ code: 'ACP_UPDATE_UNSUPPORTED', message: `首版不支持:远端事件 ${safeLabel(update.sessionUpdate)};未展开未知事件内容`, requires: '对应的公开 ACP 显示映射' });
|
|
159
|
+
this.detail({ kind: 'extension', severity: 'warning', code: 'ACP_UNKNOWN_UPDATE', summary: '收到未知远端会话事件,已保留结构', method: 'session/update', payload: params });
|
|
131
160
|
return;
|
|
132
161
|
}
|
|
133
162
|
controller.enqueue(message);
|
|
@@ -166,12 +195,13 @@ export class AcpSession {
|
|
|
166
195
|
signal?.addEventListener('abort', abort, { once: true });
|
|
167
196
|
try {
|
|
168
197
|
const hello = await this.connection.agent.request(acp.methods.agent.initialize, {
|
|
169
|
-
protocolVersion: acp.PROTOCOL_VERSION, clientInfo: { name: '@ai-workbench/remote-agent', version: '0.8.
|
|
198
|
+
protocolVersion: acp.PROTOCOL_VERSION, clientInfo: { name: '@ai-workbench/remote-agent', version: '0.8.1' },
|
|
170
199
|
clientCapabilities: { fs: { readTextFile: false, writeTextFile: false }, terminal: false, session: { notices: {} } },
|
|
171
200
|
}, { cancellationSignal: signal });
|
|
172
201
|
this.hooks.broker?.acknowledge(acp.methods.agent.initialize);
|
|
173
202
|
if (hello.protocolVersion !== acp.PROTOCOL_VERSION)
|
|
174
203
|
throw new NeedError('ACP_VERSION_MISMATCH', 'ACP 协议版本不兼容', '支持 ACP v1 的远端 agent');
|
|
204
|
+
this.checkReporting();
|
|
175
205
|
this.negotiated = hello.agentCapabilities ?? {};
|
|
176
206
|
const systemPrompt = context?.systemPrompt;
|
|
177
207
|
if (systemPrompt && !this.negotiated.sessionCapabilities?.systemPrompt) {
|
|
@@ -197,6 +227,7 @@ export class AcpSession {
|
|
|
197
227
|
this.method = acp.methods.agent.session.new;
|
|
198
228
|
}
|
|
199
229
|
this.hooks.broker?.acknowledge(this.method);
|
|
230
|
+
this.checkReporting();
|
|
200
231
|
this.ready = true;
|
|
201
232
|
this.cwd = cwd;
|
|
202
233
|
this.systemPrompt = systemPrompt ?? '';
|
|
@@ -205,6 +236,7 @@ export class AcpSession {
|
|
|
205
236
|
catch (error) {
|
|
206
237
|
if (signal?.aborted)
|
|
207
238
|
throw new NeedError('ABORTED', '会话建立已取消;尚无本轮输出', '重新连接主机');
|
|
239
|
+
this.checkReporting();
|
|
208
240
|
throw acpNeed(error);
|
|
209
241
|
}
|
|
210
242
|
finally {
|
|
@@ -212,6 +244,7 @@ export class AcpSession {
|
|
|
212
244
|
}
|
|
213
245
|
}
|
|
214
246
|
async updateContext(context, signal) {
|
|
247
|
+
this.checkReporting();
|
|
215
248
|
if (!this.ready || !this.sessionId || !this.cwd)
|
|
216
249
|
throw new NeedError('SESSION_REQUIRED', '我需要先建立远端会话', 'ACP 会话');
|
|
217
250
|
if (this.busy || this.updating)
|
|
@@ -233,12 +266,14 @@ export class AcpSession {
|
|
|
233
266
|
this.hooks.broker?.acknowledge(method);
|
|
234
267
|
this.systemPrompt = text;
|
|
235
268
|
this.ready = true;
|
|
236
|
-
this.
|
|
269
|
+
this.need({ code: 'SYSTEM_PROMPT_UPDATE_DEFERRED', message: '系统与开发者上下文变更已通过原会话恢复接口传送。Claude 恢复会话沿用首次系统提示,新要求在下次上下文整理后生效;不能据此认定本轮已立即生效。', requires: '远端实际应用新要求;Claude 须完成下一次上下文整理' });
|
|
270
|
+
this.checkReporting();
|
|
237
271
|
return method;
|
|
238
272
|
}
|
|
239
273
|
catch (error) {
|
|
240
274
|
if (signal?.aborted)
|
|
241
275
|
throw new NeedError('ABORTED', '上下文更新已取消;原会话状态需确认', '重新连接原会话');
|
|
276
|
+
this.checkReporting();
|
|
242
277
|
throw acpNeed(error);
|
|
243
278
|
}
|
|
244
279
|
finally {
|
|
@@ -254,6 +289,7 @@ export class AcpSession {
|
|
|
254
289
|
signal?.throwIfAborted();
|
|
255
290
|
const prompt = typeof input === 'string' ? [{ type: 'text', text: input }] : input;
|
|
256
291
|
this.validatePrompt(prompt);
|
|
292
|
+
this.checkReporting();
|
|
257
293
|
this.busy = true;
|
|
258
294
|
this.turnSignal = signal;
|
|
259
295
|
this.turnToken = Symbol('turn');
|
|
@@ -262,7 +298,7 @@ export class AcpSession {
|
|
|
262
298
|
const cancel = () => {
|
|
263
299
|
rejectAbort?.(new NeedError('ABORTED', '已请求取消本轮,远端状态需确认', '确认主机取消状态'));
|
|
264
300
|
void this.connection.agent.notify(acp.methods.agent.session.cancel, { sessionId: this.sessionId }).catch(() => {
|
|
265
|
-
this.
|
|
301
|
+
this.need({ code: 'CANCEL_UNCONFIRMED', message: '连接中断,无法确认主机已取消本轮', requires: '恢复连接以确认远端状态' });
|
|
266
302
|
});
|
|
267
303
|
};
|
|
268
304
|
signal?.addEventListener('abort', cancel, { once: true });
|
|
@@ -272,12 +308,14 @@ export class AcpSession {
|
|
|
272
308
|
this.hooks.broker?.acknowledge(acp.methods.agent.session.prompt);
|
|
273
309
|
signal?.throwIfAborted();
|
|
274
310
|
this.hooks.broker?.completePrompt();
|
|
311
|
+
this.checkReporting();
|
|
275
312
|
this.hooks.onResponse?.(result);
|
|
276
313
|
return result.stopReason;
|
|
277
314
|
}
|
|
278
315
|
catch (error) {
|
|
279
316
|
if (signal?.aborted)
|
|
280
317
|
throw new NeedError('ABORTED', '本轮已取消;已请求主机停止', '确认主机取消状态');
|
|
318
|
+
this.checkReporting();
|
|
281
319
|
throw acpNeed(error);
|
|
282
320
|
}
|
|
283
321
|
finally {
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
export type JsonValue = null | boolean | number | string | JsonValue[] | {
|
|
2
|
+
[key: string]: JsonValue;
|
|
3
|
+
};
|
|
4
|
+
export interface DetailScope {
|
|
5
|
+
instanceId: string;
|
|
6
|
+
epoch: number;
|
|
7
|
+
sessionId: string;
|
|
8
|
+
provider: 'claude' | 'codex';
|
|
9
|
+
turn: number;
|
|
10
|
+
step: number;
|
|
11
|
+
}
|
|
12
|
+
export interface DetailObservation {
|
|
13
|
+
kind: 'extension' | 'commands' | 'tool' | 'usage' | 'notice' | 'need' | 'session';
|
|
14
|
+
severity: 'info' | 'warning' | 'action-required';
|
|
15
|
+
code: string;
|
|
16
|
+
summary: string;
|
|
17
|
+
method?: string;
|
|
18
|
+
payload?: unknown;
|
|
19
|
+
}
|
|
20
|
+
export type SanitizedObservation = Omit<DetailObservation, 'payload'> & {
|
|
21
|
+
payload: JsonValue;
|
|
22
|
+
};
|
|
23
|
+
export interface DetailRecord extends Omit<DetailObservation, 'payload'>, DetailScope {
|
|
24
|
+
version: 1;
|
|
25
|
+
eventId: string;
|
|
26
|
+
sequence: number;
|
|
27
|
+
payload: JsonValue;
|
|
28
|
+
}
|
|
29
|
+
export type DetailSink = {
|
|
30
|
+
append(record: Readonly<DetailRecord>): Promise<void>;
|
|
31
|
+
};
|
|
32
|
+
/** Only objects created by this module are trusted as redaction markers. */
|
|
33
|
+
export declare function isSanitizedRedaction(value: unknown): boolean;
|
|
34
|
+
/** Sanitize an internal observation without attaching any L wire semantics. */
|
|
35
|
+
export declare function sanitizeObservation(observation: DetailObservation, protectModelMaterial?: boolean): Readonly<SanitizedObservation>;
|
|
36
|
+
/** Internal diagnostic recorder; a sink is not an assertion that an L wire API exists. */
|
|
37
|
+
export declare class ConversationDetails {
|
|
38
|
+
private readonly scope;
|
|
39
|
+
private readonly sink;
|
|
40
|
+
private readonly records;
|
|
41
|
+
private tail;
|
|
42
|
+
private firstFailure;
|
|
43
|
+
constructor(scope: DetailScope, sink: DetailSink);
|
|
44
|
+
record(observation: DetailObservation): void;
|
|
45
|
+
flush(): Promise<void>;
|
|
46
|
+
snapshot(): readonly DetailRecord[];
|
|
47
|
+
}
|
|
@@ -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
|
+
}
|