SaylorTwift HF Staff commited on
Commit
34810d2
·
verified ·
1 Parent(s): 5c2a829

Add files using upload-large-folder tool

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. src/audit/audit-config.test.ts +44 -0
  2. src/audit/audit-config.ts +23 -0
  3. src/audit/audit-event-store.message.test.ts +465 -0
  4. src/audit/audit-event-store.ts +741 -0
  5. src/audit/audit-event-types.ts +357 -0
  6. src/audit/audit-recorder.test.ts +89 -0
  7. src/audit/audit-recorder.ts +69 -0
  8. src/audit/execution-decision-facts.test.ts +1038 -0
  9. src/audit/execution-decision-receipts.ts +492 -0
  10. src/audit/execution-decision-work.test.ts +251 -0
  11. src/audit/execution-identity-context-build.ts +195 -0
  12. src/audit/execution-identity-context.test.ts +1065 -0
  13. src/audit/execution-owner-lifecycle-binding-store.ts +121 -0
  14. src/audit/execution-owner-lifecycle-receipts.ts +445 -0
  15. src/audit/message-audit-events.ts +37 -0
  16. src/audit/message-delivery-audit-store.ts +347 -0
  17. src/audit/message-delivery-progress-store.ts +481 -0
  18. src/audit/message-delivery-receipts.ts +175 -0
  19. src/audit/message-execution-binding.ts +156 -0
  20. src/canvas/config.ts +18 -0
  21. src/canvas/constants.test.ts +31 -0
  22. src/canvas/constants.ts +34 -0
  23. src/canvas/documents.test.ts +270 -0
  24. src/canvas/documents.ts +379 -0
  25. src/canvas/serve.runtime.test.ts +194 -0
  26. src/canvas/serve.runtime.ts +122 -0
  27. src/canvas/widget-script-syntax.test.ts +243 -0
  28. src/canvas/widget-script-syntax.ts +220 -0
  29. src/canvas/widget-tool.content-kinds.test-support.ts +30 -0
  30. src/canvas/widget-tool.grants.test.ts +43 -0
  31. src/canvas/widget-tool.presenter.test.ts +78 -0
  32. src/canvas/widget-tool.prompt.test.ts +132 -0
  33. src/canvas/widget-tool.report.test.ts +256 -0
  34. src/canvas/widget-tool.scheduled.test.ts +78 -0
  35. src/canvas/widget-tool.script-syntax.test.ts +92 -0
  36. src/canvas/widget-tool.test-support.ts +29 -0
  37. src/canvas/widget-tool.test.ts +1027 -0
  38. src/canvas/widget-tool.ts +640 -0
  39. src/canvas/wrap.test.ts +123 -0
  40. src/canvas/wrap.ts +296 -0
  41. src/cli/account-selector-boundary.test.ts +212 -0
  42. src/cli/acp-cli-exit.process.test.ts +219 -0
  43. src/cli/acp-cli.option-collisions.test.ts +202 -0
  44. src/cli/acp-cli.ts +81 -0
  45. src/cli/agent-cli-recovery.process.test.ts +250 -0
  46. src/cli/argv-invocation.test.ts +196 -0
  47. src/cli/argv-invocation.ts +31 -0
  48. src/cli/argv.test.ts +583 -0
  49. src/cli/argv.ts +478 -0
  50. src/cli/attach-cli.action.test.ts +425 -0
src/audit/audit-config.test.ts ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ isAuditLedgerEnabled,
4
+ isExecutionIdentityCollectionEnabled,
5
+ resolveAuditMessageMode,
6
+ } from "./audit-config.js";
7
+
8
+ describe("isAuditLedgerEnabled", () => {
9
+ it("defaults to enabled without config or audit section", () => {
10
+ expect(isAuditLedgerEnabled(undefined)).toBe(true);
11
+ expect(isAuditLedgerEnabled({})).toBe(true);
12
+ expect(isAuditLedgerEnabled({ logging: { audit: {} } })).toBe(true);
13
+ });
14
+
15
+ it("stays enabled on explicit true", () => {
16
+ expect(isAuditLedgerEnabled({ logging: { audit: { enabled: true } } })).toBe(true);
17
+ });
18
+
19
+ it("disables only on explicit false", () => {
20
+ expect(isAuditLedgerEnabled({ logging: { audit: { enabled: false } } })).toBe(false);
21
+ });
22
+
23
+ it("keeps message metadata off until explicitly enabled", () => {
24
+ expect(resolveAuditMessageMode(undefined)).toBe("off");
25
+ expect(resolveAuditMessageMode({ logging: { audit: {} } })).toBe("off");
26
+ expect(resolveAuditMessageMode({ logging: { audit: { messages: "direct" } } })).toBe("direct");
27
+ expect(resolveAuditMessageMode({ logging: { audit: { messages: "all" } } })).toBe("all");
28
+ });
29
+
30
+ it("keeps execution identity off until both switches are explicitly enabled", () => {
31
+ expect(isExecutionIdentityCollectionEnabled(undefined)).toBe(false);
32
+ expect(isExecutionIdentityCollectionEnabled({ logging: { audit: {} } })).toBe(false);
33
+ expect(
34
+ isExecutionIdentityCollectionEnabled({
35
+ logging: { audit: { enabled: true, executionIdentity: true } },
36
+ }),
37
+ ).toBe(true);
38
+ expect(
39
+ isExecutionIdentityCollectionEnabled({
40
+ logging: { audit: { enabled: false, executionIdentity: true } },
41
+ }),
42
+ ).toBe(false);
43
+ });
44
+ });
src/audit/audit-config.ts ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** Resolves whether the metadata-only audit ledger records new events. */
2
+ import type { OpenClawConfig } from "../config/types.openclaw.js";
3
+
4
+ export type AuditMessageMode = "off" | "direct" | "all";
5
+
6
+ /**
7
+ * The ledger is on by default: an audit trail enabled only after an incident
8
+ * cannot explain the incident. `logging.audit.enabled: false` stops new event inserts after
9
+ * restart; audit queries still serve retained rows until they expire.
10
+ */
11
+ export function isAuditLedgerEnabled(cfg: OpenClawConfig | undefined): boolean {
12
+ return cfg?.logging?.audit?.enabled !== false;
13
+ }
14
+
15
+ /** Execution identity is retained only after an explicit startup-scoped opt-in. */
16
+ export function isExecutionIdentityCollectionEnabled(cfg: OpenClawConfig | undefined): boolean {
17
+ return isAuditLedgerEnabled(cfg) && cfg?.logging?.audit?.executionIdentity === true;
18
+ }
19
+
20
+ /** Message metadata remains an explicit opt-in inside the default-on ledger. */
21
+ export function resolveAuditMessageMode(cfg: OpenClawConfig | undefined): AuditMessageMode {
22
+ return cfg?.logging?.audit?.messages ?? "off";
23
+ }
src/audit/audit-event-store.message.test.ts ADDED
@@ -0,0 +1,465 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { afterAll, afterEach, describe, expect, it } from "vitest";
2
+ import { cleanupTempDirs, makeTempDir } from "../../test/helpers/temp-dir.js";
3
+ import {
4
+ closeOpenClawStateDatabaseForTest,
5
+ openOpenClawStateDatabase,
6
+ } from "../state/openclaw-state-db.js";
7
+ import { listAuditEvents, recordAuditEvent } from "./audit-event-store.js";
8
+ import type { AuditEventInput, MessageAuditEventInput } from "./audit-event-types.js";
9
+
10
+ const tempDirs: string[] = [];
11
+ const AUDIT_REF_RE = /^hmac-sha256:v1:[a-f0-9]{32}:[a-f0-9]{64}$/u;
12
+
13
+ type AgentRunAuditEventInput = Extract<AuditEventInput, { kind: "agent_run" }>;
14
+ type InboundMessageAuditEventInput = Extract<MessageAuditEventInput, { direction: "inbound" }>;
15
+ type OutboundMessageAuditEventInput = Extract<MessageAuditEventInput, { direction: "outbound" }>;
16
+ type OutboundMessageAuditTerminalInput = Extract<
17
+ OutboundMessageAuditEventInput,
18
+ { action: "message.outbound.finished" }
19
+ >;
20
+ type OutboundTerminalFields =
21
+ | "deliveryKind"
22
+ | "errorCode"
23
+ | "failureStage"
24
+ | "outcome"
25
+ | "reasonCode"
26
+ | "status";
27
+ type OutboundMessageAuditTerminal = {
28
+ [Status in OutboundMessageAuditTerminalInput["status"]]: Pick<
29
+ Extract<OutboundMessageAuditTerminalInput, { status: Status }>,
30
+ OutboundTerminalFields
31
+ >;
32
+ }[OutboundMessageAuditTerminalInput["status"]];
33
+
34
+ function createDatabaseOptions() {
35
+ return { env: { OPENCLAW_STATE_DIR: makeTempDir(tempDirs, "openclaw-message-audit-") } };
36
+ }
37
+
38
+ type InboundMessageOverrides = Partial<
39
+ Pick<
40
+ InboundMessageAuditEventInput,
41
+ | "sourceId"
42
+ | "sourceSequence"
43
+ | "occurredAt"
44
+ | "actorId"
45
+ | "agentId"
46
+ | "runId"
47
+ | "channel"
48
+ | "conversationKind"
49
+ | "durationMs"
50
+ | "resultCount"
51
+ | "accountId"
52
+ | "conversationId"
53
+ | "messageId"
54
+ | "targetId"
55
+ >
56
+ >;
57
+
58
+ function messageInput(overrides: InboundMessageOverrides = {}): InboundMessageAuditEventInput {
59
+ return {
60
+ sourceId: "message-source-1",
61
+ sourceSequence: 1,
62
+ occurredAt: Date.now(),
63
+ kind: "message",
64
+ action: "message.inbound.processed",
65
+ status: "succeeded",
66
+ actorType: "channel_sender",
67
+ actorId: "sender:+15551234567",
68
+ agentId: "main",
69
+ runId: "run-1",
70
+ direction: "inbound",
71
+ channel: "telegram",
72
+ conversationKind: "direct",
73
+ outcome: "completed",
74
+ durationMs: 12,
75
+ resultCount: 1,
76
+ accountId: "operator@example.test",
77
+ conversationId: "chat-123",
78
+ messageId: "message-456",
79
+ targetId: "target-789",
80
+ ...overrides,
81
+ };
82
+ }
83
+
84
+ type OutboundMessageOverrides = Partial<
85
+ Pick<
86
+ OutboundMessageAuditEventInput,
87
+ | "sourceId"
88
+ | "sourceSequence"
89
+ | "occurredAt"
90
+ | "channel"
91
+ | "conversationKind"
92
+ | "durationMs"
93
+ | "resultCount"
94
+ | "accountId"
95
+ | "conversationId"
96
+ | "messageId"
97
+ | "targetId"
98
+ >
99
+ >;
100
+
101
+ function outboundMessageInput(
102
+ terminal: OutboundMessageAuditTerminal,
103
+ overrides: OutboundMessageOverrides = {},
104
+ ): OutboundMessageAuditEventInput {
105
+ return {
106
+ sourceId: "outbound-message-source-1",
107
+ sourceSequence: 1,
108
+ occurredAt: Date.now(),
109
+ kind: "message",
110
+ action: "message.outbound.finished",
111
+ actorType: "agent",
112
+ actorId: "main",
113
+ agentId: "main",
114
+ runId: "run-1",
115
+ direction: "outbound",
116
+ channel: "telegram",
117
+ conversationKind: "direct",
118
+ durationMs: 12,
119
+ resultCount: 1,
120
+ accountId: "operator@example.test",
121
+ conversationId: "chat-123",
122
+ messageId: "message-456",
123
+ targetId: "target-789",
124
+ ...terminal,
125
+ ...overrides,
126
+ };
127
+ }
128
+
129
+ function runInput(
130
+ overrides: Partial<
131
+ Pick<AgentRunAuditEventInput, "sourceId" | "sourceSequence" | "occurredAt" | "runId">
132
+ > = {},
133
+ ): AgentRunAuditEventInput {
134
+ return {
135
+ sourceId: "run-source-1",
136
+ sourceSequence: 1,
137
+ occurredAt: Date.now(),
138
+ kind: "agent_run",
139
+ action: "agent.run.started",
140
+ status: "started",
141
+ actorType: "agent",
142
+ actorId: "main",
143
+ agentId: "main",
144
+ runId: "run-1",
145
+ ...overrides,
146
+ };
147
+ }
148
+
149
+ afterEach(() => {
150
+ closeOpenClawStateDatabaseForTest();
151
+ });
152
+
153
+ afterAll(() => {
154
+ cleanupTempDirs(tempDirs);
155
+ });
156
+
157
+ describe("message audit persistence", () => {
158
+ it("stores stable domain-separated pseudonyms without raw transport identities", () => {
159
+ const database = createDatabaseOptions();
160
+ const rawIdentity = "same-raw-identity";
161
+ const first = recordAuditEvent(
162
+ messageInput({
163
+ actorId: rawIdentity,
164
+ accountId: rawIdentity,
165
+ conversationId: rawIdentity,
166
+ messageId: rawIdentity,
167
+ targetId: rawIdentity,
168
+ }),
169
+ database,
170
+ );
171
+
172
+ expect(first).toMatchObject({
173
+ schemaVersion: 1,
174
+ kind: "message",
175
+ direction: "inbound",
176
+ channel: "telegram",
177
+ conversationKind: "direct",
178
+ outcome: "completed",
179
+ durationMs: 12,
180
+ resultCount: 1,
181
+ redaction: "metadata_only",
182
+ });
183
+ expect(first).toHaveProperty("sourceSequence", 1);
184
+ const refs = [
185
+ first?.actorId,
186
+ first?.accountRef,
187
+ first?.conversationRef,
188
+ first?.messageRef,
189
+ first?.targetRef,
190
+ ];
191
+ expect(refs).toEqual(refs.map(() => expect.stringMatching(AUDIT_REF_RE)));
192
+ expect(new Set(refs).size).toBe(refs.length);
193
+ expect(JSON.stringify(first)).not.toContain(rawIdentity);
194
+
195
+ const { db } = openOpenClawStateDatabase(database);
196
+ const stored = db
197
+ .prepare(
198
+ `SELECT actor_id, account_ref, conversation_ref, message_ref, target_ref,
199
+ session_key, session_id
200
+ FROM audit_events`,
201
+ )
202
+ .get() as Record<string, unknown>;
203
+ expect(JSON.stringify(stored)).not.toContain(rawIdentity);
204
+ expect(stored.session_key).toBeNull();
205
+ expect(stored.session_id).toBeNull();
206
+
207
+ const keyRow = db.prepare("SELECT key_id, key FROM audit_identity_keys WHERE id = 1").get() as {
208
+ key_id: string;
209
+ key: Uint8Array;
210
+ };
211
+ expect(keyRow.key_id).toMatch(/^[a-f0-9]{32}$/u);
212
+ expect(keyRow.key.byteLength).toBe(32);
213
+
214
+ closeOpenClawStateDatabaseForTest();
215
+ const second = recordAuditEvent(
216
+ messageInput({
217
+ sourceId: "message-source-2",
218
+ actorId: rawIdentity,
219
+ accountId: rawIdentity,
220
+ conversationId: rawIdentity,
221
+ messageId: rawIdentity,
222
+ targetId: rawIdentity,
223
+ }),
224
+ database,
225
+ );
226
+ expect(second).toMatchObject({
227
+ actorId: first?.actorId,
228
+ accountRef: first?.accountRef,
229
+ conversationRef: first?.conversationRef,
230
+ messageRef: first?.messageRef,
231
+ targetRef: first?.targetRef,
232
+ });
233
+ });
234
+
235
+ it("fails closed instead of replacing corrupt identity key material", () => {
236
+ const database = createDatabaseOptions();
237
+ const { db } = openOpenClawStateDatabase(database);
238
+ db.prepare(
239
+ "INSERT INTO audit_identity_keys (id, key_id, key, created_at) VALUES (?, ?, ?, ?)",
240
+ ).run(1, "a".repeat(32), Buffer.alloc(31), Date.now());
241
+
242
+ expect(() => recordAuditEvent(messageInput(), database)).toThrow(
243
+ "audit identity key is corrupt",
244
+ );
245
+ const count = db.prepare("SELECT COUNT(*) AS count FROM audit_events").get() as {
246
+ count: number;
247
+ };
248
+ expect(count.count).toBe(0);
249
+ expect(
250
+ (
251
+ db.prepare("SELECT length(key) AS length FROM audit_identity_keys WHERE id = 1").get() as {
252
+ length: number;
253
+ }
254
+ ).length,
255
+ ).toBe(31);
256
+ });
257
+
258
+ it("fails closed when retained message references lose their identity key", () => {
259
+ const database = createDatabaseOptions();
260
+ const first = recordAuditEvent(messageInput(), database);
261
+ expect(first?.messageRef).toMatch(AUDIT_REF_RE);
262
+
263
+ closeOpenClawStateDatabaseForTest();
264
+ const { db } = openOpenClawStateDatabase(database);
265
+ db.prepare("DELETE FROM audit_identity_keys WHERE id = 1").run();
266
+ closeOpenClawStateDatabaseForTest();
267
+
268
+ expect(() =>
269
+ recordAuditEvent(messageInput({ sourceId: "message-after-key-loss" }), database),
270
+ ).toThrow("audit identity key is missing");
271
+ const reopened = openOpenClawStateDatabase(database).db;
272
+ expect(
273
+ (
274
+ reopened.prepare("SELECT COUNT(*) AS count FROM audit_identity_keys").get() as {
275
+ count: number;
276
+ }
277
+ ).count,
278
+ ).toBe(0);
279
+ });
280
+
281
+ it("forgets a transaction-local identity key after a rolled-back event insert", () => {
282
+ const database = createDatabaseOptions();
283
+ const { db } = openOpenClawStateDatabase(database);
284
+ db.exec(`
285
+ CREATE TABLE audit_rollback_parent (id INTEGER PRIMARY KEY);
286
+ CREATE TABLE audit_rollback_child (
287
+ parent_id INTEGER NOT NULL,
288
+ FOREIGN KEY (parent_id) REFERENCES audit_rollback_parent(id)
289
+ DEFERRABLE INITIALLY DEFERRED
290
+ );
291
+ CREATE TRIGGER reject_audit_event
292
+ AFTER INSERT ON audit_events
293
+ BEGIN
294
+ INSERT INTO audit_rollback_child (parent_id) VALUES (1);
295
+ END;
296
+ `);
297
+
298
+ expect(() => recordAuditEvent(messageInput(), database)).toThrow(/FOREIGN KEY/u);
299
+ expect(
300
+ (db.prepare("SELECT COUNT(*) AS count FROM audit_identity_keys").get() as { count: number })
301
+ .count,
302
+ ).toBe(0);
303
+ db.exec(`
304
+ DROP TRIGGER reject_audit_event;
305
+ DROP TABLE audit_rollback_child;
306
+ DROP TABLE audit_rollback_parent;
307
+ `);
308
+
309
+ const committed = recordAuditEvent(
310
+ messageInput({ sourceId: "message-after-rollback", messageId: "stable-message" }),
311
+ database,
312
+ );
313
+ expect(committed?.messageRef).toMatch(AUDIT_REF_RE);
314
+ expect(
315
+ (db.prepare("SELECT COUNT(*) AS count FROM audit_identity_keys").get() as { count: number })
316
+ .count,
317
+ ).toBe(1);
318
+
319
+ closeOpenClawStateDatabaseForTest();
320
+ const reopened = recordAuditEvent(
321
+ messageInput({ sourceId: "message-after-reopen", messageId: "stable-message" }),
322
+ database,
323
+ );
324
+ expect(reopened?.messageRef).toBe(committed?.messageRef);
325
+ });
326
+
327
+ it("scopes platform message references to their conversation", () => {
328
+ const database = createDatabaseOptions();
329
+ const first = recordAuditEvent(
330
+ messageInput({
331
+ sourceId: "message-conversation-a-1",
332
+ conversationId: "conversation-a",
333
+ messageId: "42",
334
+ }),
335
+ database,
336
+ );
337
+ const otherConversation = recordAuditEvent(
338
+ messageInput({
339
+ sourceId: "message-conversation-b-1",
340
+ conversationId: "conversation-b",
341
+ messageId: "42",
342
+ }),
343
+ database,
344
+ );
345
+ const sameConversation = recordAuditEvent(
346
+ messageInput({
347
+ sourceId: "message-conversation-a-2",
348
+ conversationId: "conversation-a",
349
+ messageId: "42",
350
+ }),
351
+ database,
352
+ );
353
+
354
+ expect(first?.messageRef).toMatch(AUDIT_REF_RE);
355
+ expect(otherConversation?.messageRef).toMatch(AUDIT_REF_RE);
356
+ expect(otherConversation?.messageRef).not.toBe(first?.messageRef);
357
+ expect(sameConversation?.messageRef).toBe(first?.messageRef);
358
+ });
359
+
360
+ it("uses the outbound target as the conversation correlation fallback", () => {
361
+ const database = createDatabaseOptions();
362
+ const event = recordAuditEvent(
363
+ outboundMessageInput(
364
+ {
365
+ status: "failed",
366
+ outcome: "failed",
367
+ errorCode: "message_delivery_failed",
368
+ failureStage: "platform_send",
369
+ },
370
+ {
371
+ sourceId: "message-outbound-target-fallback",
372
+ conversationId: undefined,
373
+ targetId: "target-conversation",
374
+ },
375
+ ),
376
+ database,
377
+ );
378
+
379
+ expect(event?.conversationRef).toMatch(AUDIT_REF_RE);
380
+ expect(event?.targetRef).toMatch(AUDIT_REF_RE);
381
+ expect(event?.conversationRef).not.toBe(event?.targetRef);
382
+ });
383
+
384
+ it("rejects persisted message terminal combinations outside the closed contract", () => {
385
+ const database = createDatabaseOptions();
386
+ recordAuditEvent(messageInput(), database);
387
+ const { db } = openOpenClawStateDatabase(database);
388
+ db.prepare("UPDATE audit_events SET error_code = ? WHERE kind = 'message'").run(
389
+ "message_processing_failed",
390
+ );
391
+
392
+ expect(() =>
393
+ listAuditEvents({ database, limit: 10, filters: { includeMessages: true } }),
394
+ ).toThrow("corrupt audit event row 1: unexpected error_code");
395
+ });
396
+
397
+ it("rejects delivery kind on terminals where no payload was proven delivered", () => {
398
+ const database = createDatabaseOptions();
399
+ recordAuditEvent(
400
+ outboundMessageInput({
401
+ status: "blocked",
402
+ outcome: "suppressed",
403
+ reasonCode: "no_visible_payload",
404
+ }),
405
+ database,
406
+ );
407
+ const { db } = openOpenClawStateDatabase(database);
408
+ db.prepare("UPDATE audit_events SET delivery_kind = 'text' WHERE kind = 'message'").run();
409
+
410
+ expect(() =>
411
+ listAuditEvents({ database, limit: 10, filters: { includeMessages: true } }),
412
+ ).toThrow("corrupt audit event row 1: unexpected delivery_kind");
413
+ });
414
+
415
+ it("rejects a persisted channel-sender actor without a keyed reference", () => {
416
+ const database = createDatabaseOptions();
417
+ recordAuditEvent(messageInput(), database);
418
+ const { db } = openOpenClawStateDatabase(database);
419
+ db.prepare("UPDATE audit_events SET actor_id = ? WHERE kind = 'message'").run("raw-sender");
420
+
421
+ expect(() =>
422
+ listAuditEvents({ database, limit: 10, filters: { includeMessages: true } }),
423
+ ).toThrow("corrupt audit event row 1: invalid actorId");
424
+ });
425
+
426
+ it("keeps message rows opt-in while supporting message filters", () => {
427
+ const database = createDatabaseOptions();
428
+ const now = Date.now();
429
+ recordAuditEvent(runInput({ occurredAt: now }), database);
430
+ recordAuditEvent(messageInput({ occurredAt: now + 1 }), database);
431
+ recordAuditEvent(
432
+ outboundMessageInput(
433
+ { status: "succeeded", outcome: "sent" },
434
+ {
435
+ sourceId: "message-source-2",
436
+ occurredAt: now + 2,
437
+ channel: "slack",
438
+ conversationKind: "channel",
439
+ },
440
+ ),
441
+ database,
442
+ );
443
+
444
+ expect(listAuditEvents({ database, limit: 10 }).events.map((event) => event.kind)).toEqual([
445
+ "agent_run",
446
+ ]);
447
+ expect(
448
+ listAuditEvents({ database, limit: 10, filters: { includeMessages: true } }).events.map(
449
+ (event) => event.kind,
450
+ ),
451
+ ).toEqual(["message", "message", "agent_run"]);
452
+ expect(
453
+ listAuditEvents({
454
+ database,
455
+ limit: 10,
456
+ filters: {
457
+ kind: "message",
458
+ includeMessages: false,
459
+ direction: "inbound",
460
+ channel: "telegram",
461
+ },
462
+ }).events,
463
+ ).toEqual([expect.objectContaining({ direction: "inbound", channel: "telegram" })]);
464
+ });
465
+ });
src/audit/audit-event-store.ts ADDED
@@ -0,0 +1,741 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** SQLite persistence and stable cursor queries for metadata-only audit events. */
2
+ import { randomUUID } from "node:crypto";
3
+ import type { DatabaseSync } from "node:sqlite";
4
+ import type { Insertable, Selectable } from "kysely";
5
+ import { AUDIT_ACTIVITY_MESSAGE_KIND } from "../../packages/gateway-protocol/src/schema/audit-activity.js";
6
+ import {
7
+ executeSqliteQuerySync,
8
+ executeSqliteQueryTakeFirstSync,
9
+ getNodeSqliteKysely,
10
+ } from "../infra/kysely-sync.js";
11
+ import { normalizeSqliteNumber } from "../infra/sqlite-number.js";
12
+ import type { DB as OpenClawStateKyselyDatabase } from "../state/openclaw-state-db.generated.js";
13
+ import {
14
+ openOpenClawStateDatabase,
15
+ runOpenClawStateWriteTransaction,
16
+ type OpenClawStateDatabaseOptions,
17
+ } from "../state/openclaw-state-db.js";
18
+ import {
19
+ AUDIT_EVENT_SCHEMA_VERSION,
20
+ AUDIT_INBOUND_MESSAGE_COMPLETED_REASONS,
21
+ AUDIT_INBOUND_MESSAGE_SKIPPED_REASONS,
22
+ AUDIT_OUTBOUND_MESSAGE_SUPPRESSED_REASONS,
23
+ isOutboundMessageProgressInput,
24
+ type AgentRunAuditEventRecord,
25
+ type AuditEventInput,
26
+ type AuditEventListFilters,
27
+ type AuditEventListPage,
28
+ type AuditEventRecord,
29
+ type InboundMessageAuditEventRecord,
30
+ type MessageAuditEventInput,
31
+ type OutboundMessageAuditEventRecord,
32
+ type ToolActionAuditEventRecord,
33
+ } from "./audit-event-types.js";
34
+ import {
35
+ clearAuditIdentityKeyCacheForDatabase,
36
+ loadOrCreateAuditIdentityKey,
37
+ pseudonymizeAuditIdentity,
38
+ } from "./audit-identity.js";
39
+ import {
40
+ ensureTerminalMessageExecutionBindingSchema,
41
+ planMessageExecutionBinding,
42
+ recordConfirmedTerminalMessageExecutionBinding,
43
+ } from "./message-execution-binding.js";
44
+
45
+ type AuditEventsTable = OpenClawStateKyselyDatabase["audit_events"];
46
+ type AuditDatabase = Pick<OpenClawStateKyselyDatabase, "audit_events">;
47
+ type AuditEventRow = Selectable<AuditEventsTable>;
48
+
49
+ export const AUDIT_EVENT_RETENTION_MS = 30 * 24 * 60 * 60_000;
50
+ const AUDIT_EVENT_MAX_ROWS = 100_000;
51
+ const AUDIT_EVENT_PRUNE_BATCH_ROWS = 1_024;
52
+ // The single audit writer owns one DB handle. Invalidate on out-of-band
53
+ // maintenance or rollback so the hot path avoids a 100k-row scan per message.
54
+ const auditEventRowCounts = new WeakMap<DatabaseSync, number>();
55
+
56
+ function getAuditKysely(db: DatabaseSync) {
57
+ return getNodeSqliteKysely<AuditDatabase>(db);
58
+ }
59
+
60
+ const RUN_ACTIONS = ["agent.run.started", "agent.run.finished"] as const;
61
+ const TOOL_ACTIONS = ["tool.action.started", "tool.action.finished"] as const;
62
+ const CONVERSATION_KINDS = ["direct", "group", "channel", "unknown"] as const;
63
+ const DELIVERY_KINDS = ["text", "media", "other"] as const;
64
+ const FAILURE_STAGES = ["platform_send", "queue", "unknown"] as const;
65
+ const AUDIT_HMAC_REF_RE = /^hmac-sha256:v1:[a-f0-9]{32}:[a-f0-9]{64}$/u;
66
+
67
+ const MESSAGE_COLUMNS = [
68
+ "direction",
69
+ "channel",
70
+ "conversation_kind",
71
+ "message_outcome",
72
+ "reason_code",
73
+ "delivery_kind",
74
+ "failure_stage",
75
+ "duration_ms",
76
+ "result_count",
77
+ "account_ref",
78
+ "conversation_ref",
79
+ "message_ref",
80
+ "target_ref",
81
+ ] as const satisfies readonly (keyof AuditEventRow)[];
82
+
83
+ function corruptAuditRow(row: AuditEventRow, problem: string): never {
84
+ const sequence = normalizeSqliteNumber(row.sequence);
85
+ const location = sequence === undefined ? "" : ` ${sequence}`;
86
+ throw new Error(`corrupt audit event row${location}: ${problem}`);
87
+ }
88
+
89
+ function requiredInteger(
90
+ row: AuditEventRow,
91
+ value: number | bigint | null,
92
+ field: string,
93
+ minimum: number,
94
+ ): number {
95
+ const normalized = normalizeSqliteNumber(value);
96
+ if (normalized === undefined || !Number.isSafeInteger(normalized) || normalized < minimum) {
97
+ corruptAuditRow(row, `invalid ${field}`);
98
+ }
99
+ return normalized;
100
+ }
101
+
102
+ function optionalInteger(
103
+ row: AuditEventRow,
104
+ value: number | bigint | null,
105
+ field: string,
106
+ minimum: number,
107
+ ): number | undefined {
108
+ if (value === null) {
109
+ return undefined;
110
+ }
111
+ return requiredInteger(row, value, field, minimum);
112
+ }
113
+
114
+ function requiredText(row: AuditEventRow, value: unknown, field: string): string {
115
+ if (typeof value !== "string" || value.length === 0) {
116
+ corruptAuditRow(row, `invalid ${field}`);
117
+ }
118
+ return value;
119
+ }
120
+
121
+ function optionalText(row: AuditEventRow, value: unknown, field: string): string | undefined {
122
+ if (value === null || value === undefined) {
123
+ return undefined;
124
+ }
125
+ return requiredText(row, value, field);
126
+ }
127
+
128
+ function requiredEnum<const Value extends string>(
129
+ row: AuditEventRow,
130
+ value: unknown,
131
+ field: string,
132
+ allowed: readonly Value[],
133
+ ): Value {
134
+ for (const candidate of allowed) {
135
+ if (value === candidate) {
136
+ return candidate;
137
+ }
138
+ }
139
+ return corruptAuditRow(row, `invalid ${field}`);
140
+ }
141
+
142
+ function optionalEnum<const Value extends string>(
143
+ row: AuditEventRow,
144
+ value: unknown,
145
+ field: string,
146
+ allowed: readonly Value[],
147
+ ): Value | undefined {
148
+ if (value === null || value === undefined) {
149
+ return undefined;
150
+ }
151
+ return requiredEnum(row, value, field, allowed);
152
+ }
153
+
154
+ function requiredHmacRef(row: AuditEventRow, value: unknown, field: string): string {
155
+ const ref = requiredText(row, value, field);
156
+ if (!AUDIT_HMAC_REF_RE.test(ref)) {
157
+ corruptAuditRow(row, `invalid ${field}`);
158
+ }
159
+ return ref;
160
+ }
161
+
162
+ function optionalHmacRef(row: AuditEventRow, value: unknown, field: string): string | undefined {
163
+ if (value === null || value === undefined) {
164
+ return undefined;
165
+ }
166
+ return requiredHmacRef(row, value, field);
167
+ }
168
+
169
+ function requireNull(row: AuditEventRow, field: keyof AuditEventRow): void {
170
+ if (row[field] !== null) {
171
+ corruptAuditRow(row, `unexpected ${field}`);
172
+ }
173
+ }
174
+
175
+ function requireNullColumns(row: AuditEventRow, fields: readonly (keyof AuditEventRow)[]): void {
176
+ for (const field of fields) {
177
+ requireNull(row, field);
178
+ }
179
+ }
180
+
181
+ function parseAuditRecordBase(row: AuditEventRow) {
182
+ const schemaVersion = requiredInteger(row, row.schema_version, "schemaVersion", 1);
183
+ if (schemaVersion !== AUDIT_EVENT_SCHEMA_VERSION) {
184
+ corruptAuditRow(row, `unsupported schemaVersion ${schemaVersion}`);
185
+ }
186
+ return {
187
+ schemaVersion,
188
+ sequence: requiredInteger(row, row.sequence, "sequence", 1),
189
+ eventId: requiredText(row, row.event_id, "eventId"),
190
+ sourceSequence: requiredInteger(row, row.source_sequence, "sourceSequence", 1),
191
+ occurredAt: requiredInteger(row, row.occurred_at, "occurredAt", 0),
192
+ redaction: "metadata_only" as const,
193
+ };
194
+ }
195
+
196
+ function parseAgentRecordFields(row: AuditEventRow) {
197
+ requireNullColumns(row, MESSAGE_COLUMNS);
198
+ return {
199
+ ...parseAuditRecordBase(row),
200
+ actorType: requiredEnum(row, row.actor_type, "actorType", ["agent", "system"]),
201
+ actorId: requiredText(row, row.actor_id, "actorId"),
202
+ agentId: requiredText(row, row.agent_id, "agentId"),
203
+ ...(optionalText(row, row.session_key, "sessionKey") !== undefined
204
+ ? { sessionKey: requiredText(row, row.session_key, "sessionKey") }
205
+ : {}),
206
+ ...(optionalText(row, row.session_id, "sessionId") !== undefined
207
+ ? { sessionId: requiredText(row, row.session_id, "sessionId") }
208
+ : {}),
209
+ runId: requiredText(row, row.run_id, "runId"),
210
+ };
211
+ }
212
+
213
+ function parseAgentRunRow(row: AuditEventRow): AgentRunAuditEventRecord {
214
+ requireNull(row, "tool_call_id");
215
+ requireNull(row, "tool_name");
216
+ const common = { ...parseAgentRecordFields(row), kind: "agent_run" as const };
217
+ const action = requiredEnum(row, row.action, "action", RUN_ACTIONS);
218
+ if (action === "agent.run.started") {
219
+ requiredEnum(row, row.status, "status", ["started"]);
220
+ requireNull(row, "error_code");
221
+ return { ...common, action, status: "started" };
222
+ }
223
+ if (row.status === "succeeded") {
224
+ requireNull(row, "error_code");
225
+ return { ...common, action, status: "succeeded" };
226
+ }
227
+ const terminal =
228
+ row.status === "failed"
229
+ ? { status: "failed" as const, errorCode: "run_failed" as const }
230
+ : row.status === "cancelled"
231
+ ? { status: "cancelled" as const, errorCode: "run_cancelled" as const }
232
+ : row.status === "timed_out"
233
+ ? { status: "timed_out" as const, errorCode: "run_timed_out" as const }
234
+ : row.status === "blocked"
235
+ ? { status: "blocked" as const, errorCode: "run_blocked" as const }
236
+ : corruptAuditRow(row, "invalid run terminal status");
237
+ requiredEnum(row, row.error_code, "errorCode", [terminal.errorCode]);
238
+ return { ...common, action, ...terminal };
239
+ }
240
+
241
+ function parseToolActionRow(row: AuditEventRow): ToolActionAuditEventRecord {
242
+ const toolCallId = optionalText(row, row.tool_call_id, "toolCallId");
243
+ const toolName = optionalText(row, row.tool_name, "toolName");
244
+ const common = {
245
+ ...parseAgentRecordFields(row),
246
+ kind: "tool_action" as const,
247
+ ...(toolCallId ? { toolCallId } : {}),
248
+ ...(toolName ? { toolName } : {}),
249
+ };
250
+ const action = requiredEnum(row, row.action, "action", TOOL_ACTIONS);
251
+ if (action === "tool.action.started") {
252
+ requiredEnum(row, row.status, "status", ["started"]);
253
+ requireNull(row, "error_code");
254
+ return { ...common, action, status: "started" };
255
+ }
256
+ if (row.status === "succeeded") {
257
+ requireNull(row, "error_code");
258
+ return { ...common, action, status: "succeeded" };
259
+ }
260
+ const terminal =
261
+ row.status === "failed"
262
+ ? { status: "failed" as const, errorCode: "tool_failed" as const }
263
+ : row.status === "cancelled"
264
+ ? { status: "cancelled" as const, errorCode: "tool_cancelled" as const }
265
+ : row.status === "timed_out"
266
+ ? { status: "timed_out" as const, errorCode: "tool_timed_out" as const }
267
+ : row.status === "blocked"
268
+ ? { status: "blocked" as const, errorCode: "tool_blocked" as const }
269
+ : row.status === "unknown"
270
+ ? { status: "unknown" as const, errorCode: "tool_outcome_unknown" as const }
271
+ : corruptAuditRow(row, "invalid tool terminal status");
272
+ requiredEnum(row, row.error_code, "errorCode", [terminal.errorCode]);
273
+ return { ...common, action, ...terminal };
274
+ }
275
+
276
+ function parseMessageRecordFields(row: AuditEventRow) {
277
+ requireNullColumns(row, ["session_key", "session_id", "tool_call_id", "tool_name"]);
278
+ const agentId = optionalText(row, row.agent_id, "agentId");
279
+ const runId = optionalText(row, row.run_id, "runId");
280
+ const durationMs = optionalInteger(row, row.duration_ms, "durationMs", 0);
281
+ const resultCount = optionalInteger(row, row.result_count, "resultCount", 0);
282
+ const accountRef = optionalHmacRef(row, row.account_ref, "accountRef");
283
+ const conversationRef = optionalHmacRef(row, row.conversation_ref, "conversationRef");
284
+ const messageRef = optionalHmacRef(row, row.message_ref, "messageRef");
285
+ const targetRef = optionalHmacRef(row, row.target_ref, "targetRef");
286
+ return {
287
+ ...parseAuditRecordBase(row),
288
+ kind: "message" as const,
289
+ channel: requiredText(row, row.channel, "channel"),
290
+ conversationKind: requiredEnum(
291
+ row,
292
+ row.conversation_kind,
293
+ "conversationKind",
294
+ CONVERSATION_KINDS,
295
+ ),
296
+ ...(agentId ? { agentId } : {}),
297
+ ...(runId ? { runId } : {}),
298
+ ...(durationMs !== undefined ? { durationMs } : {}),
299
+ ...(resultCount !== undefined ? { resultCount } : {}),
300
+ ...(accountRef ? { accountRef } : {}),
301
+ ...(conversationRef ? { conversationRef } : {}),
302
+ ...(messageRef ? { messageRef } : {}),
303
+ ...(targetRef ? { targetRef } : {}),
304
+ };
305
+ }
306
+
307
+ function parseInboundMessageRow(row: AuditEventRow): InboundMessageAuditEventRecord {
308
+ requiredEnum(row, row.action, "action", ["message.inbound.processed"]);
309
+ requiredEnum(row, row.direction, "direction", ["inbound"]);
310
+ requireNull(row, "delivery_kind");
311
+ requireNull(row, "failure_stage");
312
+ const actorType = requiredEnum(row, row.actor_type, "actorType", ["channel_sender", "system"]);
313
+ const actorId =
314
+ actorType === "channel_sender"
315
+ ? requiredHmacRef(row, row.actor_id, "actorId")
316
+ : requiredText(row, row.actor_id, "actorId");
317
+ const common = {
318
+ ...parseMessageRecordFields(row),
319
+ action: "message.inbound.processed" as const,
320
+ direction: "inbound" as const,
321
+ actorType,
322
+ actorId,
323
+ };
324
+ if (row.status === "succeeded") {
325
+ requiredEnum(row, row.message_outcome, "outcome", ["completed"]);
326
+ requireNull(row, "error_code");
327
+ const reasonCode = optionalEnum(
328
+ row,
329
+ row.reason_code,
330
+ "reasonCode",
331
+ AUDIT_INBOUND_MESSAGE_COMPLETED_REASONS,
332
+ );
333
+ return {
334
+ ...common,
335
+ status: "succeeded",
336
+ outcome: "completed",
337
+ ...(reasonCode ? { reasonCode } : {}),
338
+ };
339
+ }
340
+ if (row.status === "blocked") {
341
+ requiredEnum(row, row.message_outcome, "outcome", ["skipped"]);
342
+ requireNull(row, "error_code");
343
+ const reasonCode = optionalEnum(
344
+ row,
345
+ row.reason_code,
346
+ "reasonCode",
347
+ AUDIT_INBOUND_MESSAGE_SKIPPED_REASONS,
348
+ );
349
+ return {
350
+ ...common,
351
+ status: "blocked",
352
+ outcome: "skipped",
353
+ ...(reasonCode ? { reasonCode } : {}),
354
+ };
355
+ }
356
+ if (row.status === "failed") {
357
+ requiredEnum(row, row.message_outcome, "outcome", ["failed"]);
358
+ requiredEnum(row, row.error_code, "errorCode", ["message_processing_failed"]);
359
+ const reasonCode = optionalEnum(row, row.reason_code, "reasonCode", [
360
+ "acp_dispatch_failed",
361
+ "plugin_bound_error",
362
+ ]);
363
+ return {
364
+ ...common,
365
+ status: "failed",
366
+ outcome: "failed",
367
+ errorCode: "message_processing_failed",
368
+ ...(reasonCode ? { reasonCode } : {}),
369
+ };
370
+ }
371
+ return corruptAuditRow(row, "invalid inbound status");
372
+ }
373
+
374
+ function parseOutboundMessageRow(row: AuditEventRow): OutboundMessageAuditEventRecord {
375
+ const action = requiredEnum(row, row.action, "action", [
376
+ "message.outbound.queued",
377
+ "message.outbound.platform-started",
378
+ "message.outbound.finished",
379
+ ]);
380
+ requiredEnum(row, row.direction, "direction", ["outbound"]);
381
+ const actorType = requiredEnum(row, row.actor_type, "actorType", ["agent", "system"]);
382
+ const actorId = requiredText(row, row.actor_id, "actorId");
383
+ const commonFields = parseMessageRecordFields(row);
384
+ const common = {
385
+ ...commonFields,
386
+ action,
387
+ direction: "outbound" as const,
388
+ actorType,
389
+ actorId,
390
+ };
391
+ if (row.status === "started") {
392
+ requireNull(row, "delivery_kind");
393
+ requireNullColumns(row, ["error_code", "reason_code", "failure_stage"]);
394
+ if (action === "message.outbound.queued") {
395
+ requiredEnum(row, row.message_outcome, "outcome", ["queued"]);
396
+ return { ...common, action, status: "started", outcome: "queued" };
397
+ }
398
+ if (action === "message.outbound.platform-started") {
399
+ requiredEnum(row, row.message_outcome, "outcome", ["platform_started"]);
400
+ return { ...common, action, status: "started", outcome: "platform_started" };
401
+ }
402
+ return corruptAuditRow(row, "invalid outbound lifecycle action");
403
+ }
404
+ requiredEnum(row, action, "action", ["message.outbound.finished"]);
405
+ const terminalCommon = { ...common, action: "message.outbound.finished" as const };
406
+ if (row.status === "succeeded") {
407
+ const deliveryKind = optionalEnum(row, row.delivery_kind, "deliveryKind", DELIVERY_KINDS);
408
+ requiredEnum(row, row.message_outcome, "outcome", ["sent"]);
409
+ requireNullColumns(row, ["error_code", "reason_code", "failure_stage"]);
410
+ return {
411
+ ...terminalCommon,
412
+ status: "succeeded",
413
+ outcome: "sent",
414
+ ...(deliveryKind ? { deliveryKind } : {}),
415
+ };
416
+ }
417
+ if (row.status === "blocked") {
418
+ requireNull(row, "delivery_kind");
419
+ requiredEnum(row, row.message_outcome, "outcome", ["suppressed"]);
420
+ requireNullColumns(row, ["error_code", "failure_stage"]);
421
+ const reasonCode = requiredEnum(
422
+ row,
423
+ row.reason_code,
424
+ "reasonCode",
425
+ AUDIT_OUTBOUND_MESSAGE_SUPPRESSED_REASONS,
426
+ );
427
+ return {
428
+ ...terminalCommon,
429
+ status: "blocked",
430
+ outcome: "suppressed",
431
+ reasonCode,
432
+ };
433
+ }
434
+ if (row.status === "failed") {
435
+ const deliveryKind = optionalEnum(row, row.delivery_kind, "deliveryKind", DELIVERY_KINDS);
436
+ requiredEnum(row, row.message_outcome, "outcome", ["failed"]);
437
+ requireNull(row, "reason_code");
438
+ const errorCode = requiredEnum(row, row.error_code, "errorCode", [
439
+ "message_delivery_failed",
440
+ "message_delivery_partial_failure",
441
+ ]);
442
+ const failureStage = requiredEnum(row, row.failure_stage, "failureStage", FAILURE_STAGES);
443
+ return {
444
+ ...terminalCommon,
445
+ status: "failed",
446
+ outcome: "failed",
447
+ errorCode,
448
+ failureStage,
449
+ ...(deliveryKind ? { deliveryKind } : {}),
450
+ };
451
+ }
452
+ if (row.status === "unknown") {
453
+ requireNull(row, "delivery_kind");
454
+ requiredEnum(row, row.message_outcome, "outcome", ["unknown"]);
455
+ requireNullColumns(row, ["error_code", "reason_code"]);
456
+ const failureStage = requiredEnum(row, row.failure_stage, "failureStage", FAILURE_STAGES);
457
+ return {
458
+ ...terminalCommon,
459
+ status: "unknown",
460
+ outcome: "unknown",
461
+ failureStage,
462
+ };
463
+ }
464
+ return corruptAuditRow(row, "invalid outbound status");
465
+ }
466
+
467
+ export function rowToAuditEvent(row: AuditEventRow): AuditEventRecord {
468
+ if (row.kind === "agent_run") {
469
+ return parseAgentRunRow(row);
470
+ }
471
+ if (row.kind === "tool_action") {
472
+ return parseToolActionRow(row);
473
+ }
474
+ if (row.kind !== "message") {
475
+ corruptAuditRow(row, "invalid kind");
476
+ }
477
+ if (row.direction === "inbound") {
478
+ return parseInboundMessageRow(row);
479
+ }
480
+ if (row.direction === "outbound") {
481
+ return parseOutboundMessageRow(row);
482
+ }
483
+ return corruptAuditRow(row, "invalid message direction");
484
+ }
485
+
486
+ function projectMessageIdentities(db: DatabaseSync, input: MessageAuditEventInput) {
487
+ const identity = loadOrCreateAuditIdentityKey(db);
488
+ const conversationId =
489
+ input.conversationId ?? (input.direction === "outbound" ? input.targetId : undefined);
490
+ const ref = (
491
+ kind: Parameters<typeof pseudonymizeAuditIdentity>[0]["kind"],
492
+ value: string | undefined,
493
+ ) =>
494
+ pseudonymizeAuditIdentity({
495
+ identity,
496
+ kind,
497
+ channel: input.channel,
498
+ ...(kind !== "account" && input.accountId !== undefined
499
+ ? { accountId: input.accountId }
500
+ : {}),
501
+ ...(kind === "message" && conversationId !== undefined ? { conversationId } : {}),
502
+ value,
503
+ });
504
+ return {
505
+ actorId: input.actorType === "channel_sender" ? ref("actor", input.actorId) : input.actorId,
506
+ accountRef: ref("account", input.accountId),
507
+ conversationRef: ref("conversation", conversationId),
508
+ messageRef: ref("message", input.messageId),
509
+ targetRef: ref("target", input.targetId),
510
+ };
511
+ }
512
+
513
+ function bindAuditEvent(db: DatabaseSync, input: AuditEventInput): Insertable<AuditEventsTable> {
514
+ const message =
515
+ input.kind === AUDIT_ACTIVITY_MESSAGE_KIND ? projectMessageIdentities(db, input) : undefined;
516
+ return {
517
+ event_id: randomUUID(),
518
+ source_id: input.sourceId,
519
+ source_sequence: input.sourceSequence,
520
+ schema_version: AUDIT_EVENT_SCHEMA_VERSION,
521
+ occurred_at: input.occurredAt,
522
+ kind: input.kind,
523
+ action: input.action,
524
+ status: input.status,
525
+ error_code: input.errorCode ?? null,
526
+ actor_type: input.actorType,
527
+ actor_id: message?.actorId ?? input.actorId,
528
+ agent_id: input.agentId ?? null,
529
+ session_key: input.kind === AUDIT_ACTIVITY_MESSAGE_KIND ? null : (input.sessionKey ?? null),
530
+ session_id: input.kind === AUDIT_ACTIVITY_MESSAGE_KIND ? null : (input.sessionId ?? null),
531
+ run_id: input.runId ?? null,
532
+ tool_call_id: input.kind === "tool_action" ? (input.toolCallId ?? null) : null,
533
+ tool_name: input.kind === "tool_action" ? input.toolName : null,
534
+ direction: input.kind === AUDIT_ACTIVITY_MESSAGE_KIND ? input.direction : null,
535
+ channel: input.kind === AUDIT_ACTIVITY_MESSAGE_KIND ? input.channel : null,
536
+ conversation_kind: input.kind === "message" ? input.conversationKind : null,
537
+ message_outcome: input.kind === "message" ? input.outcome : null,
538
+ reason_code: input.kind === "message" ? (input.reasonCode ?? null) : null,
539
+ delivery_kind: input.kind === "message" ? (input.deliveryKind ?? null) : null,
540
+ failure_stage: input.kind === "message" ? (input.failureStage ?? null) : null,
541
+ duration_ms: input.kind === "message" ? (input.durationMs ?? null) : null,
542
+ result_count: input.kind === "message" ? (input.resultCount ?? null) : null,
543
+ account_ref: message?.accountRef ?? null,
544
+ conversation_ref: message?.conversationRef ?? null,
545
+ message_ref: message?.messageRef ?? null,
546
+ target_ref: message?.targetRef ?? null,
547
+ };
548
+ }
549
+
550
+ function countAuditEvents(db: DatabaseSync): number {
551
+ const row = executeSqliteQueryTakeFirstSync(
552
+ db,
553
+ getAuditKysely(db)
554
+ .selectFrom("audit_events")
555
+ .select((expression) => expression.fn.countAll<number>().as("count")),
556
+ );
557
+ return normalizeSqliteNumber(row?.count ?? null) ?? 0;
558
+ }
559
+
560
+ function deleteExpiredAuditEvents(db: DatabaseSync, now: number) {
561
+ const kysely = getAuditKysely(db);
562
+ const expiredSequences = kysely
563
+ .selectFrom("audit_events")
564
+ .select("sequence")
565
+ .where("occurred_at", "<", now - AUDIT_EVENT_RETENTION_MS)
566
+ .orderBy("occurred_at", "asc")
567
+ .orderBy("sequence", "asc")
568
+ .limit(AUDIT_EVENT_PRUNE_BATCH_ROWS);
569
+ return executeSqliteQuerySync(
570
+ db,
571
+ kysely.deleteFrom("audit_events").where("sequence", "in", expiredSequences),
572
+ );
573
+ }
574
+
575
+ function pruneAuditEventsAfterInsert(db: DatabaseSync, now: number): void {
576
+ const kysely = getAuditKysely(db);
577
+ const expired = deleteExpiredAuditEvents(db, now);
578
+ const cachedCount = auditEventRowCounts.get(db);
579
+ let rowCount =
580
+ cachedCount === undefined
581
+ ? countAuditEvents(db)
582
+ : Math.max(0, cachedCount + 1 - Number(expired.numAffectedRows ?? 0n));
583
+ if (rowCount <= AUDIT_EVENT_MAX_ROWS) {
584
+ auditEventRowCounts.set(db, rowCount);
585
+ return;
586
+ }
587
+ const retainedRows = Math.max(0, AUDIT_EVENT_MAX_ROWS - AUDIT_EVENT_PRUNE_BATCH_ROWS);
588
+ const overflowRow = executeSqliteQueryTakeFirstSync(
589
+ db,
590
+ kysely
591
+ .selectFrom("audit_events")
592
+ .select("sequence")
593
+ .orderBy("sequence", "desc")
594
+ .offset(retainedRows)
595
+ .limit(1),
596
+ );
597
+ const sequenceCutoff = overflowRow ? normalizeSqliteNumber(overflowRow.sequence) : undefined;
598
+ if (sequenceCutoff !== undefined) {
599
+ const pruned = executeSqliteQuerySync(
600
+ db,
601
+ kysely.deleteFrom("audit_events").where("sequence", "<=", sequenceCutoff),
602
+ );
603
+ rowCount = Math.max(0, rowCount - Number(pruned.numAffectedRows ?? 0n));
604
+ }
605
+ auditEventRowCounts.set(db, rowCount);
606
+ }
607
+
608
+ /** Persist one projected event idempotently and prune fixed retention bounds. */
609
+ export function recordAuditEvent(
610
+ input: AuditEventInput,
611
+ options: OpenClawStateDatabaseOptions = {},
612
+ ): AuditEventRecord | undefined {
613
+ if (isOutboundMessageProgressInput(input)) {
614
+ throw new Error("outbound message progress belongs to its companion store");
615
+ }
616
+ const executionToken =
617
+ input.kind === "message" && input.direction === "outbound"
618
+ ? planMessageExecutionBinding(input.executionIdentityToken, input.runId)
619
+ : undefined;
620
+ if (executionToken) {
621
+ ensureTerminalMessageExecutionBindingSchema(options);
622
+ }
623
+ let countCacheDatabase: DatabaseSync | undefined;
624
+ try {
625
+ return runOpenClawStateWriteTransaction(({ db }) => {
626
+ countCacheDatabase = db;
627
+ // Read losslessly so Node's rowid decoding cannot preempt the safe-integer guard.
628
+ const insert = executeSqliteQueryTakeFirstSync(
629
+ db,
630
+ getAuditKysely(db)
631
+ .insertInto("audit_events")
632
+ .values(bindAuditEvent(db, input))
633
+ .onConflict((conflict) => conflict.column("source_id").doNothing())
634
+ .returning((eb) => eb.cast<string>("sequence", "text").as("sequence")),
635
+ );
636
+ if (insert === undefined) {
637
+ return undefined;
638
+ }
639
+ const insertedSequence = Number(insert.sequence);
640
+ if (!Number.isSafeInteger(insertedSequence) || insertedSequence < 1) {
641
+ throw new Error("audit event sequence is outside the supported integer range");
642
+ }
643
+ pruneAuditEventsAfterInsert(db, Date.now());
644
+ const row = executeSqliteQueryTakeFirstSync(
645
+ db,
646
+ getAuditKysely(db)
647
+ .selectFrom("audit_events")
648
+ .selectAll()
649
+ .where("sequence", "=", insertedSequence),
650
+ );
651
+ recordConfirmedTerminalMessageExecutionBinding(db, {
652
+ eventId: row?.event_id,
653
+ token: executionToken,
654
+ });
655
+ return row ? rowToAuditEvent(row) : undefined;
656
+ }, options);
657
+ } catch (error) {
658
+ if (countCacheDatabase) {
659
+ auditEventRowCounts.delete(countCacheDatabase);
660
+ clearAuditIdentityKeyCacheForDatabase(countCacheDatabase);
661
+ }
662
+ throw error;
663
+ }
664
+ }
665
+
666
+ /** List newest-first records using a stable sequence cursor. */
667
+ export function listAuditEvents(params: {
668
+ filters?: AuditEventListFilters;
669
+ cursor?: number;
670
+ limit: number;
671
+ now?: number;
672
+ database?: OpenClawStateDatabaseOptions;
673
+ }): AuditEventListPage {
674
+ const { db } = openOpenClawStateDatabase(params.database);
675
+ const filters = params.filters ?? {};
676
+ const retainedAfter = (params.now ?? Date.now()) - AUDIT_EVENT_RETENTION_MS;
677
+ let query = getAuditKysely(db)
678
+ .selectFrom("audit_events")
679
+ .selectAll()
680
+ .where("occurred_at", ">=", retainedAfter)
681
+ // Nonterminal outbound facts belong to the lazy progress owner. Excluding
682
+ // transitional rows keeps the released activity contract terminal-only.
683
+ .where("action", "not in", ["message.outbound.queued", "message.outbound.platform-started"]);
684
+ if (params.cursor !== undefined) {
685
+ query = query.where("sequence", "<", params.cursor);
686
+ }
687
+ if (filters.agentId) {
688
+ query = query.where("agent_id", "=", filters.agentId);
689
+ }
690
+ if (filters.sessionKey) {
691
+ query = query.where("session_key", "=", filters.sessionKey);
692
+ }
693
+ if (filters.runId) {
694
+ query = query.where("run_id", "=", filters.runId);
695
+ }
696
+ if (filters.kind) {
697
+ query = query.where("kind", "=", filters.kind);
698
+ } else if (filters.includeMessages !== true) {
699
+ query = query.where("kind", "!=", "message");
700
+ }
701
+ if (filters.status) {
702
+ query = query.where("status", "=", filters.status);
703
+ }
704
+ if (filters.direction) {
705
+ query = query.where("direction", "=", filters.direction);
706
+ }
707
+ if (filters.channel) {
708
+ query = query.where("channel", "=", filters.channel);
709
+ }
710
+ if (filters.after !== undefined) {
711
+ query = query.where("occurred_at", ">=", filters.after);
712
+ }
713
+ if (filters.before !== undefined) {
714
+ query = query.where("occurred_at", "<=", filters.before);
715
+ }
716
+ const rows = executeSqliteQuerySync(
717
+ db,
718
+ query.orderBy("sequence", "desc").limit(params.limit + 1),
719
+ ).rows;
720
+ const hasMore = rows.length > params.limit;
721
+ const pageRows = hasMore ? rows.slice(0, params.limit) : rows;
722
+ const events = pageRows.map(rowToAuditEvent);
723
+ return {
724
+ events,
725
+ ...(hasMore && events.length > 0 ? { nextCursor: events[events.length - 1]?.sequence } : {}),
726
+ };
727
+ }
728
+
729
+ /** Delete one bounded batch during Gateway startup and periodic audit maintenance. */
730
+ export function pruneExpiredAuditEvents(
731
+ params: {
732
+ now?: number;
733
+ database?: OpenClawStateDatabaseOptions;
734
+ } = {},
735
+ ): number {
736
+ return runOpenClawStateWriteTransaction(({ db }) => {
737
+ const deleted = deleteExpiredAuditEvents(db, params.now ?? Date.now());
738
+ auditEventRowCounts.delete(db);
739
+ return Number(deleted.numAffectedRows ?? 0n);
740
+ }, params.database);
741
+ }
src/audit/audit-event-types.ts ADDED
@@ -0,0 +1,357 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** Versioned metadata-only durable audit contract. */
2
+ import type { ExecutionIdentityAdmissionToken } from "./execution-identity-admission.js";
3
+
4
+ export const AUDIT_EVENT_SCHEMA_VERSION = 1 as const;
5
+
6
+ type AuditEventKind = "agent_run" | "tool_action" | "message";
7
+
8
+ type AuditEventStatus =
9
+ | "started"
10
+ | "succeeded"
11
+ | "failed"
12
+ | "cancelled"
13
+ | "timed_out"
14
+ | "blocked"
15
+ | "unknown";
16
+
17
+ type AuditMessageDirection = "inbound" | "outbound";
18
+ export type AuditMessageConversationKind = "direct" | "group" | "channel" | "unknown";
19
+ export type AuditMessageDeliveryKind = "text" | "media" | "other";
20
+ export type AuditMessageFailureStage = "platform_send" | "queue" | "unknown";
21
+
22
+ export const AUDIT_INBOUND_MESSAGE_COMPLETED_REASONS = [
23
+ "fast_abort",
24
+ "plugin_bound_handled",
25
+ "plugin_bound_unavailable",
26
+ "plugin_bound_declined",
27
+ "before_dispatch_handled",
28
+ "acp_dispatch_completed",
29
+ "acp_dispatch_empty",
30
+ "active_run_injected",
31
+ ] as const;
32
+
33
+ export type AuditInboundMessageCompletedReasonCode =
34
+ (typeof AUDIT_INBOUND_MESSAGE_COMPLETED_REASONS)[number];
35
+
36
+ export const AUDIT_INBOUND_MESSAGE_SKIPPED_REASONS = [
37
+ "duplicate",
38
+ "reply_operation_active",
39
+ "reply_operation_aborted",
40
+ "acp_dispatch_aborted",
41
+ ] as const;
42
+
43
+ export type AuditInboundMessageSkippedReasonCode =
44
+ (typeof AUDIT_INBOUND_MESSAGE_SKIPPED_REASONS)[number];
45
+
46
+ type AuditInboundMessageFailureReasonCode = "acp_dispatch_failed" | "plugin_bound_error";
47
+
48
+ export const AUDIT_OUTBOUND_MESSAGE_SUPPRESSED_REASONS = [
49
+ "cancelled_by_message_sending_hook",
50
+ "cancelled_by_reply_payload_sending_hook",
51
+ "empty_after_message_sending_hook",
52
+ "empty_after_reply_payload_sending_hook",
53
+ "no_visible_payload",
54
+ ] as const;
55
+
56
+ export type AuditOutboundMessageSuppressedReasonCode =
57
+ (typeof AUDIT_OUTBOUND_MESSAGE_SUPPRESSED_REASONS)[number];
58
+
59
+ type AuditEventInputBase = {
60
+ /** Stable trusted-source identity used only for local replay deduplication. */
61
+ sourceId: string;
62
+ sourceSequence: number;
63
+ occurredAt: number;
64
+ };
65
+
66
+ type AgentAuditAttribution = {
67
+ actorType: "agent" | "system";
68
+ actorId: string;
69
+ agentId: string;
70
+ sessionKey?: string;
71
+ sessionId?: string;
72
+ runId: string;
73
+ };
74
+
75
+ export type AgentRunFinishedAuditTerminal =
76
+ | { status: "succeeded"; errorCode?: never }
77
+ | { status: "failed"; errorCode: "run_failed" }
78
+ | { status: "cancelled"; errorCode: "run_cancelled" }
79
+ | { status: "timed_out"; errorCode: "run_timed_out" }
80
+ | { status: "blocked"; errorCode: "run_blocked" };
81
+
82
+ type AgentRunAuditLifecycle =
83
+ | { action: "agent.run.started"; status: "started"; errorCode?: never }
84
+ | ({ action: "agent.run.finished" } & AgentRunFinishedAuditTerminal);
85
+
86
+ type AgentRunAuditEventInput = AuditEventInputBase &
87
+ AgentAuditAttribution &
88
+ AgentRunAuditLifecycle & { kind: "agent_run" };
89
+
90
+ type ToolActionFinishedAuditTerminal =
91
+ | { status: "succeeded"; errorCode?: never }
92
+ | { status: "failed"; errorCode: "tool_failed" }
93
+ | { status: "cancelled"; errorCode: "tool_cancelled" }
94
+ | { status: "timed_out"; errorCode: "tool_timed_out" }
95
+ | { status: "blocked"; errorCode: "tool_blocked" }
96
+ | { status: "unknown"; errorCode: "tool_outcome_unknown" };
97
+
98
+ type ToolActionAuditLifecycle =
99
+ | { action: "tool.action.started"; status: "started"; errorCode?: never }
100
+ | ({ action: "tool.action.finished" } & ToolActionFinishedAuditTerminal);
101
+
102
+ export type ToolActionAuditEventInput = AuditEventInputBase &
103
+ AgentAuditAttribution &
104
+ ToolActionAuditLifecycle & {
105
+ kind: "tool_action";
106
+ toolCallId?: string;
107
+ toolName: string;
108
+ };
109
+
110
+ type MessageAuditEventInputBase = {
111
+ sourceId: string;
112
+ sourceSequence: number;
113
+ occurredAt: number;
114
+ kind: "message";
115
+ actorId: string;
116
+ agentId?: string;
117
+ runId?: string;
118
+ channel: string;
119
+ conversationKind: AuditMessageConversationKind;
120
+ durationMs?: number;
121
+ resultCount?: number;
122
+ accountId?: string;
123
+ conversationId?: string;
124
+ messageId?: string;
125
+ targetId?: string;
126
+ };
127
+
128
+ type InboundMessageAuditAttribution = {
129
+ actorType: "channel_sender" | "system";
130
+ actorId: string;
131
+ };
132
+
133
+ type OutboundMessageAuditAttribution = {
134
+ actorType: "agent" | "system";
135
+ actorId: string;
136
+ };
137
+
138
+ export type InboundMessageAuditTerminal =
139
+ | {
140
+ status: "succeeded";
141
+ outcome: "completed";
142
+ reasonCode?: AuditInboundMessageCompletedReasonCode;
143
+ errorCode?: never;
144
+ }
145
+ | {
146
+ status: "blocked";
147
+ outcome: "skipped";
148
+ reasonCode?: AuditInboundMessageSkippedReasonCode;
149
+ errorCode?: never;
150
+ }
151
+ | {
152
+ status: "failed";
153
+ outcome: "failed";
154
+ errorCode: "message_processing_failed";
155
+ reasonCode?: AuditInboundMessageFailureReasonCode;
156
+ };
157
+
158
+ type OutboundMessageAuditLifecycle =
159
+ | {
160
+ action: "message.outbound.queued";
161
+ status: "started";
162
+ outcome: "queued";
163
+ errorCode?: never;
164
+ reasonCode?: never;
165
+ failureStage?: never;
166
+ deliveryKind?: never;
167
+ }
168
+ | {
169
+ action: "message.outbound.platform-started";
170
+ status: "started";
171
+ outcome: "platform_started";
172
+ errorCode?: never;
173
+ reasonCode?: never;
174
+ failureStage?: never;
175
+ deliveryKind?: never;
176
+ }
177
+ | {
178
+ action: "message.outbound.finished";
179
+ status: "succeeded";
180
+ outcome: "sent";
181
+ errorCode?: never;
182
+ reasonCode?: never;
183
+ failureStage?: never;
184
+ deliveryKind?: AuditMessageDeliveryKind;
185
+ }
186
+ | {
187
+ action: "message.outbound.finished";
188
+ status: "blocked";
189
+ outcome: "suppressed";
190
+ reasonCode: AuditOutboundMessageSuppressedReasonCode;
191
+ errorCode?: never;
192
+ failureStage?: never;
193
+ deliveryKind?: never;
194
+ }
195
+ | {
196
+ action: "message.outbound.finished";
197
+ status: "failed";
198
+ outcome: "failed";
199
+ errorCode: "message_delivery_failed" | "message_delivery_partial_failure";
200
+ failureStage: AuditMessageFailureStage;
201
+ reasonCode?: never;
202
+ deliveryKind?: AuditMessageDeliveryKind;
203
+ }
204
+ | {
205
+ action: "message.outbound.finished";
206
+ status: "unknown";
207
+ outcome: "unknown";
208
+ failureStage: AuditMessageFailureStage;
209
+ errorCode?: never;
210
+ reasonCode?: never;
211
+ deliveryKind?: never;
212
+ };
213
+
214
+ export type OutboundMessageProgressInput = MessageAuditEventInputBase &
215
+ OutboundMessageAuditAttribution &
216
+ Extract<
217
+ OutboundMessageAuditLifecycle,
218
+ { action: "message.outbound.queued" | "message.outbound.platform-started" }
219
+ > & {
220
+ direction: "outbound";
221
+ executionIdentityToken?: ExecutionIdentityAdmissionToken;
222
+ };
223
+
224
+ /** Raw identifiers exist only on the trusted producer-to-writer boundary. */
225
+ type InboundMessageAuditEventInput = MessageAuditEventInputBase &
226
+ InboundMessageAuditAttribution &
227
+ InboundMessageAuditTerminal & {
228
+ action: "message.inbound.processed";
229
+ direction: "inbound";
230
+ deliveryKind?: never;
231
+ failureStage?: never;
232
+ };
233
+
234
+ /** Raw identifiers exist only on the trusted producer-to-writer boundary. */
235
+ type OutboundMessageAuditEventInput = MessageAuditEventInputBase &
236
+ OutboundMessageAuditAttribution &
237
+ OutboundMessageAuditLifecycle & {
238
+ direction: "outbound";
239
+ executionIdentityToken?: ExecutionIdentityAdmissionToken;
240
+ };
241
+
242
+ export type MessageAuditEventInput = InboundMessageAuditEventInput | OutboundMessageAuditEventInput;
243
+
244
+ /** Durable columns accepted from trusted lifecycle projections. */
245
+ export type AuditEventInput =
246
+ | AgentRunAuditEventInput
247
+ | ToolActionAuditEventInput
248
+ | MessageAuditEventInput;
249
+
250
+ export function isOutboundMessageProgressInput(
251
+ input: AuditEventInput,
252
+ ): input is OutboundMessageProgressInput {
253
+ return (
254
+ input.kind === "message" &&
255
+ input.direction === "outbound" &&
256
+ (input.action === "message.outbound.queued" ||
257
+ input.action === "message.outbound.platform-started")
258
+ );
259
+ }
260
+
261
+ type AuditEventRecordBase = {
262
+ schemaVersion: typeof AUDIT_EVENT_SCHEMA_VERSION;
263
+ sequence: number;
264
+ eventId: string;
265
+ sourceSequence: number;
266
+ occurredAt: number;
267
+ redaction: "metadata_only";
268
+ };
269
+
270
+ type AgentAuditEventRecordBase = AuditEventRecordBase & {
271
+ actorType: "agent" | "system";
272
+ actorId: string;
273
+ agentId: string;
274
+ sessionKey?: string;
275
+ sessionId?: string;
276
+ runId: string;
277
+ toolCallId?: string;
278
+ toolName?: string;
279
+ direction?: never;
280
+ channel?: never;
281
+ conversationKind?: never;
282
+ outcome?: never;
283
+ reasonCode?: never;
284
+ deliveryKind?: never;
285
+ failureStage?: never;
286
+ durationMs?: never;
287
+ resultCount?: never;
288
+ accountRef?: never;
289
+ conversationRef?: never;
290
+ messageRef?: never;
291
+ targetRef?: never;
292
+ };
293
+
294
+ export type AgentRunAuditEventRecord = AgentAuditEventRecordBase & {
295
+ kind: "agent_run";
296
+ } & AgentRunAuditLifecycle;
297
+
298
+ export type ToolActionAuditEventRecord = AgentAuditEventRecordBase & {
299
+ kind: "tool_action";
300
+ } & ToolActionAuditLifecycle;
301
+
302
+ type MessageAuditEventRecordBase = AuditEventRecordBase & {
303
+ kind: "message";
304
+ actorId: string;
305
+ agentId?: string;
306
+ runId?: string;
307
+ sessionKey?: never;
308
+ sessionId?: never;
309
+ toolCallId?: never;
310
+ toolName?: never;
311
+ channel: string;
312
+ conversationKind: AuditMessageConversationKind;
313
+ durationMs?: number;
314
+ resultCount?: number;
315
+ accountRef?: string;
316
+ conversationRef?: string;
317
+ messageRef?: string;
318
+ targetRef?: string;
319
+ };
320
+
321
+ export type InboundMessageAuditEventRecord = MessageAuditEventRecordBase &
322
+ InboundMessageAuditAttribution &
323
+ InboundMessageAuditTerminal & {
324
+ action: "message.inbound.processed";
325
+ direction: "inbound";
326
+ };
327
+
328
+ export type OutboundMessageAuditEventRecord = MessageAuditEventRecordBase &
329
+ OutboundMessageAuditAttribution &
330
+ OutboundMessageAuditLifecycle & {
331
+ direction: "outbound";
332
+ };
333
+
334
+ /** Public record returned by the bounded operator read surface. */
335
+ export type AuditEventRecord =
336
+ | AgentRunAuditEventRecord
337
+ | ToolActionAuditEventRecord
338
+ | InboundMessageAuditEventRecord
339
+ | OutboundMessageAuditEventRecord;
340
+
341
+ export type AuditEventListFilters = {
342
+ agentId?: string;
343
+ sessionKey?: string;
344
+ runId?: string;
345
+ kind?: AuditEventKind;
346
+ status?: AuditEventStatus;
347
+ direction?: AuditMessageDirection;
348
+ channel?: string;
349
+ includeMessages?: boolean;
350
+ after?: number;
351
+ before?: number;
352
+ };
353
+
354
+ export type AuditEventListPage = {
355
+ events: AuditEventRecord[];
356
+ nextCursor?: number;
357
+ };
src/audit/audit-recorder.test.ts ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, expect, it } from "vitest";
2
+ import type { AuditEventInput } from "./audit-event-types.js";
3
+ import type { AuditEventWriter } from "./audit-event-writer.js";
4
+ import { createAuditEventRecorder } from "./audit-recorder.js";
5
+ import { emitTrustedMessageAuditEvent } from "./message-audit-events.js";
6
+ import { onTrustedMessageAuditEventForTest as onTrustedMessageAuditEvent } from "./message-audit-events.test-support.js";
7
+
8
+ function captureWriter(inputs: AuditEventInput[]): AuditEventWriter {
9
+ return {
10
+ ready: Promise.resolve(),
11
+ record: (input) => {
12
+ inputs.push(input);
13
+ return true;
14
+ },
15
+ recordExecutionIdentity: () => true,
16
+ recordExecutionDecision: () => true,
17
+ recordExecutionDecisionWork: () => true,
18
+ stop: async () => {},
19
+ };
20
+ }
21
+
22
+ function emitMessage(conversationKind: "direct" | "group") {
23
+ emitTrustedMessageAuditEvent({
24
+ occurredAt: 1,
25
+ kind: "message",
26
+ action: "message.inbound.processed",
27
+ status: "succeeded",
28
+ actorType: "channel_sender",
29
+ actorId: "sender-raw",
30
+ direction: "inbound",
31
+ channel: "telegram",
32
+ conversationKind,
33
+ outcome: "completed",
34
+ });
35
+ }
36
+
37
+ describe("message audit recorder", () => {
38
+ it("keeps message events off by default policy", async () => {
39
+ const inputs: AuditEventInput[] = [];
40
+ const recorder = createAuditEventRecorder({
41
+ messageMode: "off",
42
+ writer: captureWriter(inputs),
43
+ });
44
+ const unsubscribe = onTrustedMessageAuditEvent(recorder.recordMessage);
45
+
46
+ emitMessage("direct");
47
+
48
+ unsubscribe();
49
+ await recorder.stop();
50
+ expect(inputs).toEqual([]);
51
+ });
52
+
53
+ it("records only known direct conversations in direct mode", async () => {
54
+ const inputs: AuditEventInput[] = [];
55
+ const recorder = createAuditEventRecorder({
56
+ messageMode: "direct",
57
+ writer: captureWriter(inputs),
58
+ });
59
+ const unsubscribe = onTrustedMessageAuditEvent(recorder.recordMessage);
60
+
61
+ emitMessage("group");
62
+ emitMessage("direct");
63
+
64
+ unsubscribe();
65
+ await recorder.stop();
66
+ expect(inputs).toHaveLength(1);
67
+ expect(inputs[0]).toMatchObject({
68
+ kind: "message",
69
+ conversationKind: "direct",
70
+ sourceId: expect.stringMatching(/^message:/u),
71
+ sourceSequence: 1,
72
+ });
73
+ });
74
+
75
+ it("records group metadata only in all mode", async () => {
76
+ const inputs: AuditEventInput[] = [];
77
+ const recorder = createAuditEventRecorder({
78
+ messageMode: "all",
79
+ writer: captureWriter(inputs),
80
+ });
81
+ const unsubscribe = onTrustedMessageAuditEvent(recorder.recordMessage);
82
+
83
+ emitMessage("group");
84
+
85
+ unsubscribe();
86
+ await recorder.stop();
87
+ expect(inputs[0]).toMatchObject({ kind: "message", conversationKind: "group" });
88
+ });
89
+ });
src/audit/audit-recorder.ts ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** Gateway-owned recorder joining trusted run, tool, and message lifecycle streams. */
2
+ import { randomUUID } from "node:crypto";
3
+ import { createSubsystemLogger } from "../logging/subsystem.js";
4
+ import {
5
+ createAgentEventAuditRecorder,
6
+ type AgentEventAuditRecorder,
7
+ } from "./agent-event-audit.js";
8
+ import type { AuditMessageMode } from "./audit-config.js";
9
+ import { createAuditEventWriter, type AuditEventWriter } from "./audit-event-writer.js";
10
+ import type { ExecutionIdentityAdmissionWork } from "./execution-identity-admission.js";
11
+ import type { TrustedMessageAuditEvent } from "./message-audit-events.js";
12
+
13
+ const log = createSubsystemLogger("audit/events");
14
+ let persistenceFailureWarned = false;
15
+
16
+ type AuditEventRecorder = AgentEventAuditRecorder & {
17
+ recordMessage: (event: TrustedMessageAuditEvent) => void;
18
+ recordExecutionIdentity: (work: ExecutionIdentityAdmissionWork) => boolean;
19
+ recordExecutionDecision: AuditEventWriter["recordExecutionDecision"];
20
+ recordExecutionDecisionWork: AuditEventWriter["recordExecutionDecisionWork"];
21
+ };
22
+
23
+ export function createAuditEventRecorder(options: {
24
+ messageMode: AuditMessageMode;
25
+ writer?: AuditEventWriter;
26
+ stateDir?: string;
27
+ terminalSettleMs?: number;
28
+ }): AuditEventRecorder {
29
+ let nextAcceptedMessageSequence = 0;
30
+ const writer =
31
+ options.writer ??
32
+ createAuditEventWriter({
33
+ ...(options.stateDir ? { stateDir: options.stateDir } : {}),
34
+ onContention: (message) => log.warn(message),
35
+ onError: (error) => {
36
+ if (!persistenceFailureWarned) {
37
+ persistenceFailureWarned = true;
38
+ log.warn(`audit event persistence failed: ${error}`);
39
+ }
40
+ },
41
+ });
42
+ const agentRecorder = createAgentEventAuditRecorder({
43
+ writer,
44
+ ...(options.terminalSettleMs !== undefined
45
+ ? { terminalSettleMs: options.terminalSettleMs }
46
+ : {}),
47
+ });
48
+
49
+ return {
50
+ ...agentRecorder,
51
+ recordExecutionIdentity: writer.recordExecutionIdentity,
52
+ recordExecutionDecision: writer.recordExecutionDecision,
53
+ recordExecutionDecisionWork: writer.recordExecutionDecisionWork,
54
+ recordMessage: (event) => {
55
+ if (options.messageMode === "off") {
56
+ return;
57
+ }
58
+ if (options.messageMode === "direct" && event.conversationKind !== "direct") {
59
+ return;
60
+ }
61
+ nextAcceptedMessageSequence += 1;
62
+ writer.record({
63
+ ...event,
64
+ sourceId: event.sourceId?.trim() || `message:${randomUUID()}`,
65
+ sourceSequence: nextAcceptedMessageSequence,
66
+ });
67
+ },
68
+ };
69
+ }
src/audit/execution-decision-facts.test.ts ADDED
@@ -0,0 +1,1038 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Compile } from "typebox/compile";
2
+ import { afterEach, describe, expect, it } from "vitest";
3
+ import {
4
+ AuditRunInspectResultSchema,
5
+ type DecisionReceiptV1,
6
+ type ExecutionIdentityContextV1,
7
+ } from "../../packages/gateway-protocol/src/index.js";
8
+ import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
9
+ import { activityRunInspectorSearch } from "../../ui/src/pages/activity/run-inspector-model.js";
10
+ import { tableExists } from "../state/openclaw-state-db-schema-helpers.js";
11
+ import {
12
+ closeOpenClawStateDatabaseForTest,
13
+ openOpenClawStateDatabase,
14
+ } from "../state/openclaw-state-db.js";
15
+ import { recordAuditEvent } from "./audit-event-store.js";
16
+ import {
17
+ pageExecutionDecisionFactsForContext,
18
+ pruneExpiredExecutionDecisionFacts,
19
+ recordExecutionDecisionFact,
20
+ summarizeExecutionDecisionFactsForContext,
21
+ } from "./execution-decision-facts.js";
22
+ import { presentExecutionDecisionReceipts } from "./execution-decision-receipts.js";
23
+ import {
24
+ configureExecutionIdentityAdmissionSink,
25
+ createExecutionIdentityAdmissionToken,
26
+ enqueueExecutionIdentityContextAtAdmission,
27
+ type ExecutionIdentityAdmissionEnvelope,
28
+ } from "./execution-identity-admission.js";
29
+ import { processExecutionIdentityAdmissionWork } from "./execution-identity-context.js";
30
+ import { bindExecutionOwnerLifecycleMetadata } from "./execution-owner-lifecycle-binding-store.js";
31
+ import {
32
+ configureMessageActionDecisionSink,
33
+ recordMessageActionDecision,
34
+ } from "./message-action-decision.js";
35
+ import { recordOutboundMessageProgress } from "./message-delivery-progress-store.js";
36
+
37
+ const RETENTION_MS = 30 * 24 * 60 * 60_000;
38
+
39
+ afterEach(() => {
40
+ closeOpenClawStateDatabaseForTest();
41
+ });
42
+
43
+ const tempDirs = useAutoCleanupTempDirTracker(afterEach);
44
+
45
+ function databaseOptions() {
46
+ return { env: { OPENCLAW_STATE_DIR: tempDirs.make("openclaw-decision-facts-") } };
47
+ }
48
+
49
+ function seedExecutionContext(
50
+ database: ReturnType<typeof databaseOptions>,
51
+ overrides: {
52
+ runId?: string;
53
+ contextId?: string;
54
+ executionId?: string;
55
+ } = {},
56
+ ): ExecutionIdentityContextV1 {
57
+ const runId = overrides.runId ?? "run-1";
58
+ const contextId = overrides.contextId ?? "context-1";
59
+ const executionId = overrides.executionId ?? "execution-1";
60
+ let envelope: ExecutionIdentityAdmissionEnvelope | undefined;
61
+ const clear = configureExecutionIdentityAdmissionSink((work) => {
62
+ if (work.kind === "capture") {
63
+ envelope = work.envelope;
64
+ }
65
+ return true;
66
+ });
67
+ try {
68
+ enqueueExecutionIdentityContextAtAdmission(
69
+ {
70
+ runId,
71
+ agentId: "main",
72
+ ingress: { kind: "local-cli", boundary: "agent-command.local", state: "present" },
73
+ runtime: { kind: "embedded" },
74
+ },
75
+ {
76
+ enabled: true,
77
+ now: 50,
78
+ contextId,
79
+ executionId,
80
+ runtimeInstanceId: "runtime-1",
81
+ },
82
+ );
83
+ } finally {
84
+ clear();
85
+ }
86
+ if (!envelope) {
87
+ throw new Error("expected execution identity envelope");
88
+ }
89
+ const stored = processExecutionIdentityAdmissionWork(
90
+ { kind: "capture", envelope },
91
+ { ...database, now: 50 },
92
+ );
93
+ if (
94
+ stored.contextId !== contextId ||
95
+ stored.executionId !== executionId ||
96
+ stored.runId !== runId
97
+ ) {
98
+ throw new Error(`unexpected execution context: ${JSON.stringify(stored)}`);
99
+ }
100
+ return stored;
101
+ }
102
+
103
+ function receipt(id: string, occurredAt = 100): DecisionReceiptV1 {
104
+ return {
105
+ schemaVersion: 1,
106
+ receiptId: id,
107
+ contextId: "context-1",
108
+ executionId: "execution-1",
109
+ runId: "run-1",
110
+ actionId: `action-${id}`,
111
+ occurredAt,
112
+ action: { family: "tool", operation: "policy" },
113
+ decision: { outcome: "denied", reasonCode: "tool_policy_denied" },
114
+ enforcement: {
115
+ coverageState: "enforced",
116
+ evaluatorRef: "tool-policy",
117
+ policyRefs: ["tool-policy:deny"],
118
+ grantRefs: [],
119
+ contextFieldsUsed: ["runId"],
120
+ },
121
+ source: {
122
+ owner: "tool-policy",
123
+ recordRef: `record-${id}`,
124
+ decisionBoundary: "agent-tool.before-call",
125
+ },
126
+ missingEvidence: [],
127
+ remediation: [{ code: "choose_allowed_tool", text: "Choose an allowed tool and retry." }],
128
+ };
129
+ }
130
+
131
+ function tokenForContext(context: ExecutionIdentityContextV1) {
132
+ return createExecutionIdentityAdmissionToken(context.runId, {
133
+ contextId: context.contextId,
134
+ executionId: context.executionId,
135
+ now: context.createdAt,
136
+ });
137
+ }
138
+
139
+ describe("execution decision facts", () => {
140
+ it("persists repeated same-reason broadcast denials with opaque distinct ids", () => {
141
+ const database = databaseOptions();
142
+ seedExecutionContext(database);
143
+ const token = createExecutionIdentityAdmissionToken("run-1", {
144
+ contextId: "context-1",
145
+ executionId: "execution-1",
146
+ now: 100,
147
+ });
148
+ const clear = configureMessageActionDecisionSink(
149
+ (decision) => recordExecutionDecisionFact(decision, { ...database, now: 100 }) === "inserted",
150
+ );
151
+ try {
152
+ for (const receiptDiscriminator of ["broadcast:0", "broadcast:1"]) {
153
+ expect(
154
+ recordMessageActionDecision({
155
+ token,
156
+ actionId: "broadcast-action",
157
+ action: "broadcast",
158
+ channel: "qa-channel",
159
+ outcome: "denied",
160
+ reasonCode: "message_target_unknown",
161
+ coverageState: "enforced",
162
+ policyRefs: ["message-target:known"],
163
+ summary: "Message action was denied before platform delivery.",
164
+ remediation: [],
165
+ receiptDiscriminator,
166
+ occurredAt: 100,
167
+ }),
168
+ ).toBe(true);
169
+ }
170
+ } finally {
171
+ clear();
172
+ }
173
+
174
+ const receipts = pageExecutionDecisionFactsForContext({
175
+ context: { contextId: "context-1", executionId: "execution-1", runId: "run-1" },
176
+ limit: 10,
177
+ now: 100,
178
+ database,
179
+ }).receipts;
180
+ expect(receipts).toHaveLength(2);
181
+ expect(new Set(receipts.map((item) => item.receiptId)).size).toBe(2);
182
+ expect(JSON.stringify(receipts)).not.toContain("broadcast:0");
183
+ expect(JSON.stringify(receipts)).not.toContain("broadcast:1");
184
+ });
185
+
186
+ it("projects owner-native outbound delivery into run inspection", () => {
187
+ const database = databaseOptions();
188
+ const context = seedExecutionContext(database);
189
+ const now = Date.now();
190
+ const storedEvent = recordAuditEvent(
191
+ {
192
+ sourceId: "message:outbound:queue:delivery-1:payload:0",
193
+ sourceSequence: 1,
194
+ occurredAt: now,
195
+ kind: "message",
196
+ action: "message.outbound.finished",
197
+ status: "succeeded",
198
+ outcome: "sent",
199
+ actorType: "agent",
200
+ actorId: "main",
201
+ agentId: "main",
202
+ runId: "run-1",
203
+ executionIdentityToken: tokenForContext(context),
204
+ direction: "outbound",
205
+ channel: "qa-channel",
206
+ conversationKind: "direct",
207
+ resultCount: 1,
208
+ targetId: "raw-target",
209
+ messageId: "raw-message-id",
210
+ },
211
+ database,
212
+ );
213
+ if (!storedEvent) {
214
+ throw new Error("expected owner-native message event");
215
+ }
216
+
217
+ const inspection = presentExecutionDecisionReceipts({
218
+ context,
219
+ decisionLimit: 10,
220
+ options: { ...database, now },
221
+ });
222
+ expect(inspection.decisions).toEqual(
223
+ expect.arrayContaining([
224
+ expect.objectContaining({
225
+ action: expect.objectContaining({ family: "message", operation: "send" }),
226
+ decision: { outcome: "allowed", reasonCode: "message_delivered" },
227
+ enforcement: expect.objectContaining({ coverageState: "attribution-only" }),
228
+ source: expect.objectContaining({ owner: "audit_events" }),
229
+ }),
230
+ ]),
231
+ );
232
+ expect(inspection.decisionDisplays).toEqual(
233
+ expect.arrayContaining([
234
+ expect.objectContaining({
235
+ action: expect.objectContaining({ family: "message", operation: "send" }),
236
+ decision: { outcome: "allowed", reasonCode: "message_delivered" },
237
+ provenance: { state: "verified", producer: "message-delivery" },
238
+ }),
239
+ ]),
240
+ );
241
+ const messageReceipt = inspection.decisions.find((item) => item.action.family === "message");
242
+ const messageDisplay = inspection.decisionDisplays.find(
243
+ (item) =>
244
+ item.provenance.state === "verified" && item.provenance.producer === "message-delivery",
245
+ );
246
+ expect(messageDisplay?.selectorId).toBe(
247
+ `message-decision:${2_000_000_000_000 + storedEvent.sequence}`,
248
+ );
249
+ expect(messageDisplay?.selectorId).not.toBe(messageReceipt?.receiptId);
250
+ const receiptSearch = activityRunInspectorSearch(
251
+ { kind: "run", id: context.runId },
252
+ { id: messageDisplay?.selectorId ?? "" },
253
+ );
254
+ expect(new URLSearchParams(receiptSearch.slice(1)).get("receipt")).toBe(
255
+ messageDisplay?.selectorId,
256
+ );
257
+ expect(receiptSearch).not.toContain(encodeURIComponent(messageReceipt?.receiptId ?? ""));
258
+ });
259
+
260
+ it("projects exact-bound cron, task, and flow owner rows without generic facts", () => {
261
+ const database = databaseOptions();
262
+ const context = seedExecutionContext(database);
263
+ const db = openOpenClawStateDatabase(database).db;
264
+ db.prepare(
265
+ `INSERT INTO cron_run_receipts (
266
+ receipt_id, store_key, job_id, config_revision, agent_id, request_run_id,
267
+ status, owner_pid, started_at_ms, finished_at_ms
268
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
269
+ ).run(
270
+ "cron-receipt-1",
271
+ "default",
272
+ "job-1",
273
+ "revision-1",
274
+ "main",
275
+ context.runId,
276
+ "ok",
277
+ 1,
278
+ 60,
279
+ 70,
280
+ );
281
+ db.prepare(
282
+ `INSERT INTO task_runs (
283
+ task_id, runtime, owner_key, scope_kind, task, status, delivery_status,
284
+ notify_policy, created_at
285
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
286
+ ).run(
287
+ "task-1",
288
+ "cron",
289
+ "owner-1",
290
+ "session",
291
+ "private task text",
292
+ "succeeded",
293
+ "not-requested",
294
+ "never",
295
+ 61,
296
+ );
297
+ db.prepare(
298
+ `INSERT INTO flow_runs (
299
+ flow_id, owner_key, status, notify_policy, goal, created_at, updated_at
300
+ ) VALUES (?, ?, ?, ?, ?, ?, ?)`,
301
+ ).run("flow-1", "owner-1", "succeeded", "never", "private flow goal", 62, 70);
302
+ for (const [ownerKind, ownerId] of [
303
+ ["cron", "cron-receipt-1"],
304
+ ["task", "task-1"],
305
+ ["flow", "flow-1"],
306
+ ] as const) {
307
+ expect(
308
+ bindExecutionOwnerLifecycleMetadata({
309
+ db,
310
+ ownerKind,
311
+ ownerId,
312
+ binding: { contextId: context.contextId, executionId: context.executionId },
313
+ }),
314
+ ).toBe("bound");
315
+ }
316
+
317
+ const result = presentExecutionDecisionReceipts({
318
+ context,
319
+ decisionLimit: 10,
320
+ options: { ...database, now: 100 },
321
+ });
322
+ expect(result.decisions.map((item) => item.source.owner)).toEqual([
323
+ "agent-command",
324
+ "cron_run_receipts",
325
+ "task_runs",
326
+ "flow_runs",
327
+ ]);
328
+ expect(JSON.stringify(result.decisions)).not.toContain("private task text");
329
+ expect(JSON.stringify(result.decisions)).not.toContain("private flow goal");
330
+ expect(tableExists(db, "execution_decision_facts")).toBe(false);
331
+ });
332
+
333
+ it("does not assign run-only delivery evidence to either exact execution sharing a run id", () => {
334
+ const database = databaseOptions();
335
+ const first = seedExecutionContext(database, {
336
+ runId: "shared-run",
337
+ contextId: "context-first",
338
+ executionId: "execution-first",
339
+ });
340
+ const second = seedExecutionContext(database, {
341
+ runId: "shared-run",
342
+ contextId: "context-second",
343
+ executionId: "execution-second",
344
+ });
345
+ const now = Date.now();
346
+ recordAuditEvent(
347
+ {
348
+ sourceId: "message:shared-run:unbound",
349
+ sourceSequence: 1,
350
+ occurredAt: now,
351
+ kind: "message",
352
+ action: "message.outbound.finished",
353
+ status: "succeeded",
354
+ outcome: "sent",
355
+ actorType: "agent",
356
+ actorId: "main",
357
+ agentId: "main",
358
+ runId: "shared-run",
359
+ direction: "outbound",
360
+ channel: "qa-channel",
361
+ conversationKind: "direct",
362
+ resultCount: 1,
363
+ },
364
+ database,
365
+ );
366
+
367
+ for (const context of [first, second]) {
368
+ expect(
369
+ presentExecutionDecisionReceipts({
370
+ context,
371
+ decisionLimit: 10,
372
+ options: { ...database, now },
373
+ }).decisions.filter((item) => item.action.family === "message"),
374
+ ).toEqual([]);
375
+ }
376
+ expect(
377
+ tableExists(openOpenClawStateDatabase(database).db, "outbound_message_execution_bindings"),
378
+ ).toBe(false);
379
+
380
+ recordAuditEvent(
381
+ {
382
+ sourceId: "message:shared-run:first-execution",
383
+ sourceSequence: 2,
384
+ occurredAt: now + 1,
385
+ kind: "message",
386
+ action: "message.outbound.finished",
387
+ status: "succeeded",
388
+ outcome: "sent",
389
+ actorType: "agent",
390
+ actorId: "main",
391
+ agentId: "main",
392
+ runId: "shared-run",
393
+ executionIdentityToken: tokenForContext(first),
394
+ direction: "outbound",
395
+ channel: "qa-channel",
396
+ conversationKind: "direct",
397
+ resultCount: 1,
398
+ },
399
+ database,
400
+ );
401
+ const messageReceipts = (context: ExecutionIdentityContextV1) =>
402
+ presentExecutionDecisionReceipts({
403
+ context,
404
+ decisionLimit: 10,
405
+ options: { ...database, now: now + 1 },
406
+ }).decisions.filter((item) => item.action.family === "message");
407
+ expect(messageReceipts(first)).toHaveLength(1);
408
+ expect(messageReceipts(second)).toEqual([]);
409
+ expect(
410
+ openOpenClawStateDatabase(database)
411
+ .db.prepare(
412
+ "SELECT context_id, execution_id, run_id FROM outbound_message_execution_bindings",
413
+ )
414
+ .all(),
415
+ ).toEqual([
416
+ {
417
+ context_id: "context-first",
418
+ execution_id: "execution-first",
419
+ run_id: "shared-run",
420
+ },
421
+ ]);
422
+ });
423
+
424
+ it("keeps delivery stages distinct, redacted, replay-safe, and retention bounded", () => {
425
+ const database = databaseOptions();
426
+ const context = seedExecutionContext(database);
427
+ const now = Date.now();
428
+ const common = {
429
+ sourceSequence: 1,
430
+ occurredAt: now,
431
+ kind: "message" as const,
432
+ actorType: "agent" as const,
433
+ actorId: "main",
434
+ agentId: "main",
435
+ runId: "run-1",
436
+ executionIdentityToken: tokenForContext(context),
437
+ direction: "outbound" as const,
438
+ channel: "qa-channel",
439
+ conversationKind: "direct" as const,
440
+ targetId: "raw-channel-target",
441
+ };
442
+ const events = [
443
+ {
444
+ ...common,
445
+ occurredAt: now,
446
+ sourceId: "queue-1:queued",
447
+ action: "message.outbound.queued" as const,
448
+ status: "started" as const,
449
+ outcome: "queued" as const,
450
+ },
451
+ {
452
+ ...common,
453
+ occurredAt: now + 1,
454
+ sourceId: "queue-1:platform",
455
+ action: "message.outbound.platform-started" as const,
456
+ status: "started" as const,
457
+ outcome: "platform_started" as const,
458
+ },
459
+ {
460
+ ...common,
461
+ occurredAt: now + 2,
462
+ sourceId: "queue-1:finished",
463
+ action: "message.outbound.finished" as const,
464
+ status: "succeeded" as const,
465
+ outcome: "sent" as const,
466
+ messageId: "raw-platform-message",
467
+ },
468
+ {
469
+ ...common,
470
+ occurredAt: now + 3,
471
+ sourceId: "queue-2:failed",
472
+ action: "message.outbound.finished" as const,
473
+ status: "failed" as const,
474
+ outcome: "failed" as const,
475
+ errorCode: "message_delivery_failed" as const,
476
+ failureStage: "queue" as const,
477
+ },
478
+ {
479
+ ...common,
480
+ occurredAt: now + 4,
481
+ sourceId: "queue-3:failed",
482
+ action: "message.outbound.finished" as const,
483
+ status: "failed" as const,
484
+ outcome: "failed" as const,
485
+ errorCode: "message_delivery_failed" as const,
486
+ failureStage: "platform_send" as const,
487
+ },
488
+ {
489
+ ...common,
490
+ occurredAt: now + 5,
491
+ sourceId: "queue-4:suppressed",
492
+ action: "message.outbound.finished" as const,
493
+ status: "blocked" as const,
494
+ outcome: "suppressed" as const,
495
+ reasonCode: "no_visible_payload" as const,
496
+ },
497
+ ];
498
+ for (const event of events) {
499
+ expect(
500
+ event.action === "message.outbound.finished"
501
+ ? recordAuditEvent(event, database)
502
+ : recordOutboundMessageProgress(event, database),
503
+ ).toBeDefined();
504
+ }
505
+ expect(
506
+ recordOutboundMessageProgress(
507
+ {
508
+ ...common,
509
+ occurredAt: now,
510
+ sourceId: "queue-1:queued",
511
+ action: "message.outbound.queued",
512
+ status: "started",
513
+ outcome: "queued",
514
+ },
515
+ database,
516
+ ),
517
+ ).toBeUndefined();
518
+
519
+ const inspect = () =>
520
+ presentExecutionDecisionReceipts({
521
+ context,
522
+ decisionCursor: "m:0:0",
523
+ decisionLimit: 10,
524
+ options: { ...database, now },
525
+ });
526
+ const firstInspection = inspect();
527
+ expect(firstInspection.decisions.map((item) => item.decision.reasonCode)).toEqual([
528
+ "message_queued",
529
+ "message_platform_started",
530
+ "message_delivered",
531
+ "message_delivery_failed_queue",
532
+ "message_delivery_failed_platform_send",
533
+ "message_suppressed_no_visible_payload",
534
+ ]);
535
+ expect(firstInspection.decisions.map((item) => item.enforcement.coverageState)).toEqual(
536
+ Array.from({ length: 6 }, () => "attribution-only"),
537
+ );
538
+ expect(firstInspection.decisions.map((item) => item.source.owner)).toEqual([
539
+ "outbound_message_progress",
540
+ "outbound_message_progress",
541
+ "audit_events",
542
+ "audit_events",
543
+ "audit_events",
544
+ "audit_events",
545
+ ]);
546
+ const selectors = firstInspection.decisionDisplays.map((item) => item.selectorId);
547
+ expect(selectors).toHaveLength(firstInspection.decisions.length);
548
+ expect(new Set(selectors).size).toBe(selectors.length);
549
+ expect(selectors.every((selector) => selector.startsWith("message-decision:"))).toBe(true);
550
+ expect(JSON.stringify(firstInspection)).not.toContain("raw-channel-target");
551
+ expect(JSON.stringify(firstInspection)).not.toContain("raw-platform-message");
552
+
553
+ closeOpenClawStateDatabaseForTest();
554
+ expect(inspect().decisionDisplays.map((item) => item.selectorId)).toEqual(selectors);
555
+ expect(
556
+ presentExecutionDecisionReceipts({
557
+ context,
558
+ decisionCursor: "m:0:0",
559
+ decisionLimit: 10,
560
+ options: { ...database, now: now + RETENTION_MS + events.length + 1 },
561
+ }).decisions,
562
+ ).toEqual([]);
563
+ });
564
+
565
+ it("stays absent until a future owner writes one immutable fact", () => {
566
+ const database = databaseOptions();
567
+ seedExecutionContext(database);
568
+ const opened = openOpenClawStateDatabase(database);
569
+ expect(tableExists(opened.db, "execution_decision_facts")).toBe(false);
570
+ expect(pruneExpiredExecutionDecisionFacts({ database })).toBe(0);
571
+ expect(tableExists(opened.db, "execution_decision_facts")).toBe(false);
572
+
573
+ expect(recordExecutionDecisionFact(receipt("receipt-1"), { ...database, now: 100 })).toBe(
574
+ "inserted",
575
+ );
576
+ expect(recordExecutionDecisionFact(receipt("receipt-1"), { ...database, now: 100 })).toBe(
577
+ "existing",
578
+ );
579
+ expect(() =>
580
+ recordExecutionDecisionFact(
581
+ { ...receipt("receipt-1"), decision: { outcome: "allowed", reasonCode: "changed" } },
582
+ { ...database, now: 100 },
583
+ ),
584
+ ).toThrow("conflicts with retained state");
585
+
586
+ expect(
587
+ pageExecutionDecisionFactsForContext({
588
+ context: { contextId: "context-1", executionId: "execution-1", runId: "run-1" },
589
+ limit: 10,
590
+ now: 100,
591
+ database,
592
+ }).receipts,
593
+ ).toEqual([receipt("receipt-1")]);
594
+ expect(
595
+ summarizeExecutionDecisionFactsForContext({
596
+ context: { contextId: "context-1", executionId: "execution-1", runId: "run-1" },
597
+ now: 100,
598
+ database,
599
+ }),
600
+ ).toEqual({ count: 1, coverageState: "enforced", missingEvidence: [] });
601
+ });
602
+
603
+ it("rejects approval duplication before creating the generic table", () => {
604
+ const database = databaseOptions();
605
+ expect(() =>
606
+ recordExecutionDecisionFact(
607
+ {
608
+ ...receipt("approval-duplicate"),
609
+ source: {
610
+ owner: "operator_approvals",
611
+ recordRef: "approval-ref",
612
+ decisionBoundary: "gateway.operator-approval.first-answer",
613
+ },
614
+ },
615
+ { ...database, now: 100 },
616
+ ),
617
+ ).toThrow("owner-native table");
618
+ expect(tableExists(openOpenClawStateDatabase(database).db, "execution_decision_facts")).toBe(
619
+ false,
620
+ );
621
+ });
622
+
623
+ it("keeps high-cardinality summary work bounded and conservative", () => {
624
+ const database = databaseOptions();
625
+ seedExecutionContext(database);
626
+ for (let index = 0; index < 130; index += 1) {
627
+ recordExecutionDecisionFact(receipt(`bounded-${String(index).padStart(3, "0")}`), {
628
+ ...database,
629
+ now: 100,
630
+ limits: { maxRows: 1_000, pruneBatchRows: 10 },
631
+ });
632
+ }
633
+
634
+ expect(
635
+ summarizeExecutionDecisionFactsForContext({
636
+ context: { contextId: "context-1", executionId: "execution-1", runId: "run-1" },
637
+ now: 100,
638
+ database,
639
+ }),
640
+ ).toEqual({
641
+ count: 129,
642
+ coverageState: "unknown",
643
+ missingEvidence: ["decision.fact.summary_bounded"],
644
+ });
645
+ });
646
+
647
+ it("pages equal-time facts by a bounded row key", () => {
648
+ const database = databaseOptions();
649
+ const context = seedExecutionContext(database);
650
+ for (const id of ["same-time-a", "same-time-b", "same-time-c"]) {
651
+ recordExecutionDecisionFact(receipt(id, 100), { ...database, now: 100 });
652
+ }
653
+
654
+ const first = pageExecutionDecisionFactsForContext({
655
+ context: { contextId: "context-1", executionId: "execution-1", runId: "run-1" },
656
+ limit: 1,
657
+ now: 100,
658
+ database,
659
+ });
660
+ expect(first.receipts.map((item) => item.receiptId)).toEqual(["same-time-a"]);
661
+ expect(first.entries).toEqual([
662
+ { receipt: receipt("same-time-a", 100), selectorId: "decision-fact:1" },
663
+ ]);
664
+ expect(first.nextCursor).toEqual({ occurredAt: 100, rowId: expect.any(Number) });
665
+ const second = pageExecutionDecisionFactsForContext({
666
+ context: { contextId: "context-1", executionId: "execution-1", runId: "run-1" },
667
+ after: first.nextCursor,
668
+ limit: 2,
669
+ now: 100,
670
+ database,
671
+ });
672
+ expect(second.receipts.map((item) => item.receiptId)).toEqual(["same-time-b", "same-time-c"]);
673
+ expect(second.entries.map((entry) => entry.selectorId)).toEqual([
674
+ "decision-fact:2",
675
+ "decision-fact:3",
676
+ ]);
677
+ expect(
678
+ pageExecutionDecisionFactsForContext({
679
+ context: { contextId: "context-1", executionId: "execution-1", runId: "run-1" },
680
+ limit: 1,
681
+ now: 100,
682
+ database,
683
+ }).entries,
684
+ ).toEqual(first.entries);
685
+ expect(
686
+ new Set([...first.entries, ...second.entries].map((entry) => entry.selectorId)).size,
687
+ ).toBe(3);
688
+
689
+ for (const decisionCursor of ["1", "001"]) {
690
+ const legacyPage = presentExecutionDecisionReceipts({
691
+ context,
692
+ decisionCursor,
693
+ decisionLimit: 1,
694
+ options: { ...database, now: 100 },
695
+ });
696
+ expect(legacyPage.decisions.map((item) => item.receiptId)).toEqual(["same-time-a"]);
697
+ expect(legacyPage.decisionDisplays?.map((item) => item.selectorId)).toEqual([
698
+ "decision-fact:1",
699
+ ]);
700
+ expect(legacyPage.nextDecisionCursor).toMatch(/^g:/);
701
+ }
702
+ const legacyPage = presentExecutionDecisionReceipts({
703
+ context,
704
+ decisionCursor: "1",
705
+ decisionLimit: 1,
706
+ options: { ...database, now: 100 },
707
+ });
708
+ const next = presentExecutionDecisionReceipts({
709
+ context,
710
+ decisionCursor: legacyPage.nextDecisionCursor,
711
+ decisionLimit: 2,
712
+ options: { ...database, now: 100 },
713
+ });
714
+ expect(next.decisions.map((item) => item.receiptId)).toEqual(["same-time-b", "same-time-c"]);
715
+ expect(next.decisionDisplays?.map((item) => item.selectorId)).toEqual([
716
+ "decision-fact:2",
717
+ "decision-fact:3",
718
+ ]);
719
+ });
720
+
721
+ it("replaces unverified aggregate evidence with a fixed display marker", () => {
722
+ const database = databaseOptions();
723
+ seedExecutionContext(database);
724
+ const context: ExecutionIdentityContextV1 = {
725
+ schemaVersion: 1,
726
+ contextId: "context-1",
727
+ executionId: "execution-1",
728
+ runId: "run-1",
729
+ createdAt: 50,
730
+ trustDomain: { kind: "gateway-cell", domainRef: "domain-1", state: "present" },
731
+ invoker: { state: "absent" },
732
+ ingress: { kind: "local-cli", boundary: "agent-command.local", state: "present" },
733
+ agentPrincipal: { kind: "agent", domainRef: "domain-1", principalRef: "agent-main" },
734
+ agentDefinition: { definitionRef: "main", state: "present" },
735
+ runtimeInstance: { runtimeRef: "runtime-1", kind: "embedded", state: "present" },
736
+ applicableGrants: [],
737
+ assurance: [],
738
+ coverageState: "unattributed",
739
+ missingEvidence: [],
740
+ };
741
+ for (const owner of ["one", "two"] as const) {
742
+ recordExecutionDecisionFact(
743
+ {
744
+ ...receipt(owner),
745
+ missingEvidence: Array.from(
746
+ { length: 16 },
747
+ (_, index) => `${owner}.missing.${String(index).padStart(2, "0")}`,
748
+ ),
749
+ },
750
+ { ...database, now: 100 },
751
+ );
752
+ }
753
+
754
+ const result = presentExecutionDecisionReceipts({
755
+ context,
756
+ decisionLimit: 10,
757
+ options: { ...database, now: 100 },
758
+ });
759
+ expect(result.coverage).toEqual({
760
+ state: "unknown",
761
+ missingEvidence: expect.arrayContaining(["decision.display_provenance"]),
762
+ });
763
+ expect(JSON.stringify(result.coverage)).not.toContain("one.missing");
764
+ expect(JSON.stringify(result.coverage)).not.toContain("two.missing");
765
+ expect(Compile(AuditRunInspectResultSchema).Check(result)).toBe(false);
766
+ });
767
+
768
+ it("keeps receipt-controlled prose out of the owner display projection", () => {
769
+ const database = databaseOptions();
770
+ const context = seedExecutionContext(database);
771
+ const receiptIdSecret = "U2_R6_RECEIPT_ID_SECRET_42b17d";
772
+ const summarySecret = "U2_R6_SUMMARY_SECRET_7c4f9a";
773
+ const remediationCodeSecret = "U2_R6_CODE_SECRET_81d2be";
774
+ const remediationTextSecret = "U2_R6_TEXT_SECRET_36a5c0";
775
+ const policyRefSecret = "U2_R6_POLICY_REF_SECRET_ea731c";
776
+ const grantRefSecret = "U2_R6_GRANT_REF_SECRET_b529f4";
777
+ const missingEvidenceSecret = "U2_R6_MISSING_EVIDENCE_SECRET_2d97c1";
778
+ recordExecutionDecisionFact(
779
+ {
780
+ ...receipt(receiptIdSecret),
781
+ action: {
782
+ family: "tool",
783
+ operation: "policy",
784
+ summary: summarySecret,
785
+ },
786
+ source: {
787
+ owner: "audit_events",
788
+ recordRef: "forged-core-looking-record",
789
+ decisionBoundary: "message.outbound.finished",
790
+ },
791
+ enforcement: {
792
+ coverageState: "enforced",
793
+ evaluatorRef: "forged-core-looking-evaluator",
794
+ policyRefs: [policyRefSecret],
795
+ grantRefs: [grantRefSecret],
796
+ contextFieldsUsed: ["runId"],
797
+ },
798
+ missingEvidence: [missingEvidenceSecret],
799
+ remediation: [{ code: remediationCodeSecret, text: remediationTextSecret }],
800
+ },
801
+ { ...database, now: 100 },
802
+ );
803
+
804
+ const result = presentExecutionDecisionReceipts({
805
+ context,
806
+ decisionLimit: 10,
807
+ options: { ...database, now: 100 },
808
+ });
809
+ expect(result.decisionDisplays).toBeDefined();
810
+ const displayJson = JSON.stringify(result.decisionDisplays);
811
+ expect(displayJson).not.toContain(receiptIdSecret);
812
+ expect(displayJson).not.toContain(summarySecret);
813
+ expect(displayJson).not.toContain(remediationCodeSecret);
814
+ expect(displayJson).not.toContain(remediationTextSecret);
815
+ expect(displayJson).not.toContain(policyRefSecret);
816
+ expect(displayJson).not.toContain(grantRefSecret);
817
+ expect(JSON.stringify(result.coverage)).not.toContain(missingEvidenceSecret);
818
+ expect(result.coverage).toEqual({
819
+ state: "unknown",
820
+ missingEvidence: expect.arrayContaining(["decision.display_provenance"]),
821
+ });
822
+ expect(JSON.stringify(result.decisions)).toContain(policyRefSecret);
823
+ expect(JSON.stringify(result.decisions)).toContain(grantRefSecret);
824
+ expect(result.decisionDisplays).toEqual([
825
+ expect.objectContaining({
826
+ action: expect.objectContaining({
827
+ summary: "Run admission was recorded without an identity-aware policy or grant decision.",
828
+ }),
829
+ provenance: { state: "verified", producer: "run-admission" },
830
+ }),
831
+ expect.objectContaining({
832
+ selectorId: "decision-fact:1",
833
+ action: { family: "decision", operation: "record" },
834
+ decision: { outcome: "unknown", reasonCode: "decision_fact_display_unverified" },
835
+ enforcement: {
836
+ coverageState: "unknown",
837
+ policyCount: 0,
838
+ grantCount: 0,
839
+ contextFieldsUsed: [],
840
+ },
841
+ provenance: { state: "unverified" },
842
+ missingEvidence: ["decision.display_provenance"],
843
+ remediation: [],
844
+ }),
845
+ ]);
846
+ expect(Compile(AuditRunInspectResultSchema).Check(result)).toBe(false);
847
+ });
848
+
849
+ it("rejects a generic fact whose context, execution, and run tuple is not exact", () => {
850
+ const database = databaseOptions();
851
+ seedExecutionContext(database);
852
+ expect(() =>
853
+ recordExecutionDecisionFact(
854
+ { ...receipt("wrong-execution"), executionId: "execution-2" },
855
+ database,
856
+ ),
857
+ ).toThrow("exact retained execution context");
858
+ expect(tableExists(openOpenClawStateDatabase(database).db, "execution_decision_facts")).toBe(
859
+ false,
860
+ );
861
+ });
862
+
863
+ it("projects a fact as unknown when the requested tuple does not match", () => {
864
+ const database = databaseOptions();
865
+ seedExecutionContext(database);
866
+ recordExecutionDecisionFact(receipt("tuple-mismatch"), { ...database, now: 100 });
867
+
868
+ const page = pageExecutionDecisionFactsForContext({
869
+ context: { contextId: "context-1", executionId: "execution-2", runId: "run-1" },
870
+ limit: 10,
871
+ now: 100,
872
+ database,
873
+ });
874
+ expect(page.entries).toEqual([
875
+ {
876
+ selectorId: "decision-fact:1",
877
+ receipt: expect.objectContaining({
878
+ decision: { outcome: "unknown", reasonCode: "decision_fact_execution_link_mismatch" },
879
+ enforcement: expect.objectContaining({ coverageState: "unknown" }),
880
+ missingEvidence: ["decision.execution_link"],
881
+ }),
882
+ },
883
+ ]);
884
+ });
885
+
886
+ it("enforces the 30-day read boundary and bounded retention pruning", () => {
887
+ const database = databaseOptions();
888
+ seedExecutionContext(database);
889
+ recordExecutionDecisionFact(receipt("old", 0), { ...database, now: 0 });
890
+ recordExecutionDecisionFact(receipt("new", RETENTION_MS + 1), {
891
+ ...database,
892
+ now: RETENTION_MS + 1,
893
+ limits: { maxRows: 10, pruneBatchRows: 1 },
894
+ });
895
+
896
+ expect(
897
+ pageExecutionDecisionFactsForContext({
898
+ context: { contextId: "context-1", executionId: "execution-1", runId: "run-1" },
899
+ limit: 10,
900
+ now: RETENTION_MS + 1,
901
+ database,
902
+ }).receipts.map((item) => item.receiptId),
903
+ ).toEqual(["new"]);
904
+ expect(
905
+ openOpenClawStateDatabase(database)
906
+ .db.prepare("SELECT COUNT(*) AS count FROM execution_decision_facts")
907
+ .get(),
908
+ ).toEqual({ count: 1 });
909
+ });
910
+
911
+ it("caps retained facts without accepting a non-identical receipt id", () => {
912
+ const database = databaseOptions();
913
+ seedExecutionContext(database);
914
+ for (const [index, id] of ["one", "two", "three"].entries()) {
915
+ recordExecutionDecisionFact(receipt(id, 100 + index), {
916
+ ...database,
917
+ now: 100 + index,
918
+ limits: { maxRows: 2, pruneBatchRows: 1 },
919
+ });
920
+ }
921
+ expect(
922
+ pageExecutionDecisionFactsForContext({
923
+ context: { contextId: "context-1", executionId: "execution-1", runId: "run-1" },
924
+ limit: 10,
925
+ now: 200,
926
+ database,
927
+ }).receipts.map((item) => item.receiptId),
928
+ ).toEqual(["two", "three"]);
929
+ });
930
+
931
+ it("turns corrupt retained payloads into bounded unknown receipts", () => {
932
+ const database = databaseOptions();
933
+ seedExecutionContext(database);
934
+ const context: ExecutionIdentityContextV1 = {
935
+ schemaVersion: 1,
936
+ contextId: "context-1",
937
+ executionId: "execution-1",
938
+ runId: "run-1",
939
+ createdAt: 50,
940
+ trustDomain: { kind: "gateway-cell", domainRef: "domain-1", state: "present" },
941
+ invoker: { state: "absent" },
942
+ ingress: { kind: "local-cli", boundary: "agent-command.local", state: "present" },
943
+ agentPrincipal: { kind: "agent", domainRef: "domain-1", principalRef: "agent-main" },
944
+ agentDefinition: { definitionRef: "main", state: "present" },
945
+ runtimeInstance: { runtimeRef: "runtime-1", kind: "embedded", state: "present" },
946
+ applicableGrants: [],
947
+ assurance: [],
948
+ coverageState: "unattributed",
949
+ missingEvidence: [],
950
+ };
951
+ recordExecutionDecisionFact(receipt("corrupt"), { ...database, now: 100 });
952
+ openOpenClawStateDatabase(database)
953
+ .db.prepare("UPDATE execution_decision_facts SET receipt_json = ? WHERE receipt_id = ?")
954
+ .run("{", "corrupt");
955
+
956
+ const page = pageExecutionDecisionFactsForContext({
957
+ context: { contextId: "context-1", executionId: "execution-1", runId: "run-1" },
958
+ limit: 10,
959
+ now: 100,
960
+ database,
961
+ });
962
+ expect(page.entries).toEqual([
963
+ {
964
+ selectorId: "decision-fact:1",
965
+ receipt: expect.objectContaining({
966
+ receiptId: "corrupt",
967
+ decision: { outcome: "unknown", reasonCode: "decision_fact_record_corrupt" },
968
+ enforcement: expect.objectContaining({ coverageState: "unknown" }),
969
+ missingEvidence: ["decision.fact.valid"],
970
+ }),
971
+ },
972
+ ]);
973
+ expect(
974
+ summarizeExecutionDecisionFactsForContext({
975
+ context: { contextId: "context-1", executionId: "execution-1", runId: "run-1" },
976
+ now: 100,
977
+ database,
978
+ }),
979
+ ).toEqual({
980
+ count: 1,
981
+ coverageState: "unknown",
982
+ missingEvidence: ["decision.fact.valid"],
983
+ });
984
+ expect(
985
+ presentExecutionDecisionReceipts({
986
+ context,
987
+ decisionCursor: "g:0:0",
988
+ decisionLimit: 10,
989
+ options: { ...database, now: 100 },
990
+ }),
991
+ ).toMatchObject({
992
+ coverage: {
993
+ state: "unknown",
994
+ missingEvidence: expect.arrayContaining(["decision.display_provenance"]),
995
+ },
996
+ decisions: [{ decision: { outcome: "unknown", reasonCode: "decision_fact_record_corrupt" } }],
997
+ decisionDisplays: [{ selectorId: "decision-fact:1" }],
998
+ });
999
+ });
1000
+
1001
+ it("does not materialize an oversized retained fact payload", () => {
1002
+ const database = databaseOptions();
1003
+ const context = seedExecutionContext(database);
1004
+ recordExecutionDecisionFact(receipt("oversized"), { ...database, now: 100 });
1005
+ const db = openOpenClawStateDatabase(database).db;
1006
+ db.exec("PRAGMA ignore_check_constraints = ON");
1007
+ db.prepare("UPDATE execution_decision_facts SET receipt_json = ? WHERE receipt_id = ?").run(
1008
+ "x".repeat(20_000),
1009
+ "oversized",
1010
+ );
1011
+
1012
+ const page = pageExecutionDecisionFactsForContext({
1013
+ context: { contextId: "context-1", executionId: "execution-1", runId: "run-1" },
1014
+ limit: 1,
1015
+ now: 100,
1016
+ database,
1017
+ });
1018
+ expect(page.entries).toEqual([
1019
+ {
1020
+ selectorId: "decision-fact:1",
1021
+ receipt: expect.objectContaining({
1022
+ decision: { outcome: "unknown", reasonCode: "decision_fact_payload_bounded" },
1023
+ missingEvidence: ["decision.fact.payload_bounded"],
1024
+ }),
1025
+ },
1026
+ ]);
1027
+ const result = presentExecutionDecisionReceipts({
1028
+ context,
1029
+ decisionCursor: "g:0:0",
1030
+ decisionLimit: 1,
1031
+ options: { ...database, now: 100 },
1032
+ });
1033
+ expect(result.decisions).toHaveLength(1);
1034
+ expect(result.decisionDisplays).toEqual([
1035
+ expect.objectContaining({ selectorId: "decision-fact:1" }),
1036
+ ]);
1037
+ });
1038
+ });
src/audit/execution-decision-receipts.ts ADDED
@@ -0,0 +1,492 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** Bounded receipt projection across admission, owner-native, and generic decision facts. */
2
+ import type {
3
+ AuditRunInspectResult,
4
+ DecisionReceiptDisplayV1,
5
+ DecisionReceiptV1,
6
+ ExecutionIdentityContextV1,
7
+ } from "../../packages/gateway-protocol/src/index.js";
8
+ import {
9
+ pageOperatorApprovalReceiptsForRun,
10
+ summarizeOperatorApprovalReceiptsForRun,
11
+ } from "../gateway/operator-approval-store.js";
12
+ import type { OpenClawStateDatabaseOptions } from "../state/openclaw-state-db.js";
13
+ import { parsePositiveAuditCursor } from "./audit-cursor.js";
14
+ import {
15
+ pageExecutionDecisionFactsForContext,
16
+ summarizeExecutionDecisionFactsForContext,
17
+ } from "./execution-decision-facts.js";
18
+ import {
19
+ pageOwnerLifecycleReceipts,
20
+ summarizeOwnerLifecycleReceipts,
21
+ type OwnerLifecycleCursor,
22
+ type OwnerLifecycleStage,
23
+ } from "./execution-owner-lifecycle-receipts.js";
24
+ import {
25
+ pageMessageDeliveryReceiptsForRun,
26
+ summarizeMessageDeliveryReceiptsForRun,
27
+ } from "./message-delivery-receipts.js";
28
+
29
+ type ExecutionDecisionReadOptions = OpenClawStateDatabaseOptions & { now?: number };
30
+
31
+ export type InternalAuditRunInspectResult = AuditRunInspectResult & {
32
+ decisions: DecisionReceiptV1[];
33
+ };
34
+
35
+ type ProvenancedDecisionReceipt = {
36
+ receipt: DecisionReceiptV1;
37
+ provenance: DecisionReceiptDisplayV1["provenance"];
38
+ selectorId: string;
39
+ };
40
+
41
+ const MAX_AGGREGATE_MISSING_EVIDENCE = 16;
42
+ const MISSING_EVIDENCE_TRUNCATED = "decision.missing_evidence_truncated";
43
+ type DecisionStage = "approval" | "message" | "generic" | OwnerLifecycleStage;
44
+ type DecisionCursor =
45
+ | {
46
+ stage: DecisionStage;
47
+ after?: { occurredAt: number; rowId: number };
48
+ }
49
+ | {
50
+ offset: number;
51
+ };
52
+
53
+ export class ExecutionDecisionCursorError extends Error {
54
+ constructor(message = "invalid execution decision cursor") {
55
+ super(message);
56
+ this.name = "ExecutionDecisionCursorError";
57
+ }
58
+ }
59
+
60
+ function parseDecisionCursor(value: string | undefined): DecisionCursor | undefined | null {
61
+ if (value === undefined) {
62
+ return undefined;
63
+ }
64
+ const offset = parsePositiveAuditCursor(value);
65
+ if (offset !== null && offset !== undefined) {
66
+ return { offset };
67
+ }
68
+ const match = /^([amgctf]):(0|[1-9]\d*):(0|[1-9]\d*)$/.exec(value);
69
+ if (!match) {
70
+ return null;
71
+ }
72
+ const occurredAt = Number(match[2]);
73
+ const rowId = Number(match[3]);
74
+ if (!Number.isSafeInteger(occurredAt) || !Number.isSafeInteger(rowId)) {
75
+ return null;
76
+ }
77
+ const stage =
78
+ match[1] === "a"
79
+ ? "approval"
80
+ : match[1] === "m"
81
+ ? "message"
82
+ : match[1] === "g"
83
+ ? "generic"
84
+ : match[1] === "c"
85
+ ? "cron"
86
+ : match[1] === "t"
87
+ ? "task"
88
+ : "flow";
89
+ return {
90
+ stage,
91
+ ...(occurredAt === 0 && rowId === 0 ? {} : { after: { occurredAt, rowId } }),
92
+ };
93
+ }
94
+
95
+ export function isExecutionDecisionCursor(value: string): boolean {
96
+ return parseDecisionCursor(value) !== null;
97
+ }
98
+
99
+ function formatDecisionCursor(
100
+ stage: DecisionStage,
101
+ cursor?: { occurredAt: number; rowId: number },
102
+ ): string {
103
+ const prefix = {
104
+ approval: "a",
105
+ message: "m",
106
+ generic: "g",
107
+ cron: "c",
108
+ task: "t",
109
+ flow: "f",
110
+ }[stage];
111
+ return `${prefix}:${cursor?.occurredAt ?? 0}:${cursor?.rowId ?? 0}`;
112
+ }
113
+
114
+ function boundMissingEvidence(values: readonly string[]): {
115
+ missingEvidence: string[];
116
+ truncated: boolean;
117
+ } {
118
+ const unique = [...new Set(values)].toSorted();
119
+ if (unique.length <= MAX_AGGREGATE_MISSING_EVIDENCE) {
120
+ return { missingEvidence: unique, truncated: false };
121
+ }
122
+ return {
123
+ missingEvidence: [
124
+ ...unique
125
+ .filter((value) => value !== MISSING_EVIDENCE_TRUNCATED)
126
+ .slice(0, MAX_AGGREGATE_MISSING_EVIDENCE - 1),
127
+ MISSING_EVIDENCE_TRUNCATED,
128
+ ].toSorted(),
129
+ truncated: true,
130
+ };
131
+ }
132
+
133
+ function admissionDecision(context: ExecutionIdentityContextV1): DecisionReceiptV1 {
134
+ return {
135
+ schemaVersion: 1,
136
+ receiptId: `${context.contextId}:admission`,
137
+ contextId: context.contextId,
138
+ executionId: context.executionId,
139
+ runId: context.runId,
140
+ occurredAt: context.createdAt,
141
+ action: {
142
+ family: "run",
143
+ operation: "admission",
144
+ summary: "Run admission was recorded without an identity-aware policy or grant decision.",
145
+ },
146
+ decision: {
147
+ outcome: "not-applicable",
148
+ reasonCode: "run_admission_identity_not_evaluated",
149
+ },
150
+ enforcement: {
151
+ coverageState: context.coverageState,
152
+ policyRefs: [],
153
+ grantRefs: [],
154
+ contextFieldsUsed: [],
155
+ },
156
+ source: {
157
+ owner: "agent-command",
158
+ recordRef: context.contextId,
159
+ decisionBoundary: "agent-command.run-admission",
160
+ },
161
+ missingEvidence: [...context.missingEvidence],
162
+ remediation: [
163
+ {
164
+ code: "no_identity_enforcement_claimed",
165
+ text: "Treat this receipt as attribution only; it does not prove authorization.",
166
+ },
167
+ ],
168
+ };
169
+ }
170
+
171
+ function projectDecisionDisplay({
172
+ receipt,
173
+ provenance,
174
+ selectorId,
175
+ }: ProvenancedDecisionReceipt): DecisionReceiptDisplayV1 {
176
+ if (provenance.state === "unverified") {
177
+ return {
178
+ schemaVersion: 1,
179
+ selectorId,
180
+ occurredAt: receipt.occurredAt,
181
+ action: { family: "decision", operation: "record" },
182
+ decision: { outcome: "unknown", reasonCode: "decision_fact_display_unverified" },
183
+ enforcement: {
184
+ coverageState: "unknown",
185
+ policyCount: 0,
186
+ grantCount: 0,
187
+ contextFieldsUsed: [],
188
+ },
189
+ provenance,
190
+ missingEvidence: ["decision.display_provenance"],
191
+ remediation: [],
192
+ };
193
+ }
194
+ const counts = {
195
+ policyCount: receipt.enforcement.policyRefs.length,
196
+ grantCount: receipt.enforcement.grantRefs.length,
197
+ };
198
+ return {
199
+ schemaVersion: 1,
200
+ selectorId,
201
+ occurredAt: receipt.occurredAt,
202
+ action: {
203
+ family: receipt.action.family,
204
+ operation: receipt.action.operation,
205
+ ...(receipt.action.summary ? { summary: receipt.action.summary } : {}),
206
+ },
207
+ decision: receipt.decision,
208
+ enforcement: {
209
+ coverageState: receipt.enforcement.coverageState,
210
+ ...counts,
211
+ contextFieldsUsed: receipt.enforcement.contextFieldsUsed,
212
+ },
213
+ provenance,
214
+ missingEvidence: receipt.missingEvidence,
215
+ remediation: receipt.remediation,
216
+ };
217
+ }
218
+
219
+ export function presentExecutionDecisionReceipts(params: {
220
+ context: ExecutionIdentityContextV1;
221
+ decisionCursor?: string;
222
+ decisionLimit?: number;
223
+ options: ExecutionDecisionReadOptions;
224
+ }): InternalAuditRunInspectResult {
225
+ const cursor = parseDecisionCursor(params.decisionCursor);
226
+ if (cursor === null) {
227
+ throw new ExecutionDecisionCursorError();
228
+ }
229
+ const decisionLimit = params.decisionLimit ?? 50;
230
+ const now = params.options.now ?? Date.now();
231
+ const opaqueCursor = cursor && "stage" in cursor ? cursor : undefined;
232
+ const legacyOffset = cursor && "offset" in cursor ? cursor.offset - 1 : undefined;
233
+ const approvalSummary = summarizeOperatorApprovalReceiptsForRun({
234
+ context: {
235
+ contextId: params.context.contextId,
236
+ executionId: params.context.executionId,
237
+ runId: params.context.runId,
238
+ },
239
+ nowMs: now,
240
+ databaseOptions: params.options,
241
+ exactCount: legacyOffset !== undefined,
242
+ });
243
+ const genericSummary = summarizeExecutionDecisionFactsForContext({
244
+ context: params.context,
245
+ now,
246
+ database: params.options,
247
+ });
248
+ const messageSummary = summarizeMessageDeliveryReceiptsForRun({
249
+ context: params.context,
250
+ options: { ...params.options, now },
251
+ });
252
+ const cronSummary = summarizeOwnerLifecycleReceipts({
253
+ stage: "cron",
254
+ context: params.context,
255
+ options: params.options,
256
+ });
257
+ const taskSummary = summarizeOwnerLifecycleReceipts({
258
+ stage: "task",
259
+ context: params.context,
260
+ options: params.options,
261
+ });
262
+ const flowSummary = summarizeOwnerLifecycleReceipts({
263
+ stage: "flow",
264
+ context: params.context,
265
+ options: params.options,
266
+ });
267
+ const stages: Array<{
268
+ stage: DecisionStage;
269
+ count: number;
270
+ page: (params: { after?: OwnerLifecycleCursor; offset?: number; limit: number }) => {
271
+ entries: ProvenancedDecisionReceipt[];
272
+ nextCursor?: OwnerLifecycleCursor;
273
+ };
274
+ }> = [
275
+ {
276
+ stage: "approval",
277
+ count: approvalSummary.count,
278
+ page: ({ after, offset, limit }) => {
279
+ const page = pageOperatorApprovalReceiptsForRun({
280
+ context: {
281
+ contextId: params.context.contextId,
282
+ executionId: params.context.executionId,
283
+ runId: params.context.runId,
284
+ },
285
+ after,
286
+ offset,
287
+ limit,
288
+ nowMs: now,
289
+ databaseOptions: params.options,
290
+ });
291
+ return {
292
+ entries: page.entries.map((entry) => ({
293
+ receipt: entry.receipt,
294
+ provenance: { state: "verified", producer: "operator-approval" },
295
+ selectorId: entry.selectorId,
296
+ })),
297
+ ...(page.nextCursor ? { nextCursor: page.nextCursor } : {}),
298
+ };
299
+ },
300
+ },
301
+ {
302
+ stage: "message",
303
+ count: messageSummary.count,
304
+ page: ({ after, offset, limit }) => {
305
+ const page = pageMessageDeliveryReceiptsForRun({
306
+ context: params.context,
307
+ after,
308
+ offset,
309
+ limit,
310
+ options: { ...params.options, now },
311
+ });
312
+ return {
313
+ entries: page.entries.map((entry) => ({
314
+ receipt: entry.receipt,
315
+ provenance: { state: "verified", producer: "message-delivery" },
316
+ selectorId: entry.selectorId,
317
+ })),
318
+ ...(page.nextCursor ? { nextCursor: page.nextCursor } : {}),
319
+ };
320
+ },
321
+ },
322
+ {
323
+ stage: "generic",
324
+ count: genericSummary.count,
325
+ page: ({ after, offset, limit }) => {
326
+ const page = pageExecutionDecisionFactsForContext({
327
+ context: params.context,
328
+ after,
329
+ offset,
330
+ limit,
331
+ now,
332
+ database: params.options,
333
+ });
334
+ return {
335
+ entries: page.entries.map((entry) => ({
336
+ receipt: entry.receipt,
337
+ provenance: { state: "unverified" },
338
+ selectorId: entry.selectorId,
339
+ })),
340
+ ...(page.nextCursor ? { nextCursor: page.nextCursor } : {}),
341
+ };
342
+ },
343
+ },
344
+ ...(["cron", "task", "flow"] as const).map((stage) => ({
345
+ stage,
346
+ count: { cron: cronSummary, task: taskSummary, flow: flowSummary }[stage].count,
347
+ page: ({
348
+ after,
349
+ offset,
350
+ limit,
351
+ }: {
352
+ after?: OwnerLifecycleCursor;
353
+ offset?: number;
354
+ limit: number;
355
+ }) => {
356
+ const page = pageOwnerLifecycleReceipts({
357
+ stage,
358
+ context: params.context,
359
+ after,
360
+ offset,
361
+ limit,
362
+ options: params.options,
363
+ });
364
+ return {
365
+ entries: page.entries.map((entry) => ({
366
+ receipt: entry.receipt,
367
+ provenance: { state: "verified" as const, producer: entry.displayProducer },
368
+ selectorId: entry.selectorId,
369
+ })),
370
+ ...(page.nextCursor ? { nextCursor: page.nextCursor } : {}),
371
+ };
372
+ },
373
+ })),
374
+ ];
375
+ const decisions: ProvenancedDecisionReceipt[] = [];
376
+ let remainingLimit = decisionLimit;
377
+ let nextDecisionCursor: string | undefined;
378
+
379
+ if (cursor === undefined && remainingLimit > 0) {
380
+ decisions.push({
381
+ receipt: admissionDecision(params.context),
382
+ provenance: { state: "verified", producer: "run-admission" },
383
+ selectorId: `${params.context.contextId}:admission`,
384
+ });
385
+ remainingLimit -= 1;
386
+ if (remainingLimit === 0 && stages.some((stage) => stage.count > 0)) {
387
+ // The shipped first successor remains the approval cursor even when that owner is empty.
388
+ nextDecisionCursor = formatDecisionCursor("approval");
389
+ }
390
+ }
391
+ let startStage = 0;
392
+ let firstStageOffset: number | undefined;
393
+ if (opaqueCursor) {
394
+ startStage = stages.findIndex((stage) => stage.stage === opaqueCursor.stage);
395
+ } else if (legacyOffset !== undefined) {
396
+ let preceding = 0;
397
+ startStage = stages.findIndex((stage) => {
398
+ if (legacyOffset < preceding + stage.count) {
399
+ firstStageOffset = legacyOffset - preceding;
400
+ return true;
401
+ }
402
+ preceding += stage.count;
403
+ return false;
404
+ });
405
+ if (startStage < 0) {
406
+ startStage = stages.length;
407
+ }
408
+ }
409
+ for (let index = startStage; index < stages.length && remainingLimit > 0; index += 1) {
410
+ const stage = stages[index];
411
+ if (!stage) {
412
+ continue;
413
+ }
414
+ let page;
415
+ try {
416
+ page = stage.page({
417
+ ...(index === startStage && opaqueCursor?.stage === stage.stage
418
+ ? { after: opaqueCursor.after }
419
+ : {}),
420
+ ...(index === startStage && firstStageOffset !== undefined
421
+ ? { offset: firstStageOffset }
422
+ : {}),
423
+ limit: remainingLimit,
424
+ });
425
+ } catch (error) {
426
+ if (error instanceof Error && error.message.includes("cursor is no longer retained")) {
427
+ throw new ExecutionDecisionCursorError(
428
+ "decision cursor is no longer retained; restart inspection without --cursor",
429
+ );
430
+ }
431
+ throw error;
432
+ }
433
+ decisions.push(...page.entries);
434
+ remainingLimit -= page.entries.length;
435
+ if (page.nextCursor) {
436
+ nextDecisionCursor = formatDecisionCursor(stage.stage, page.nextCursor);
437
+ break;
438
+ }
439
+ if (remainingLimit === 0) {
440
+ const successor = stages.slice(index + 1).find((candidate) => candidate.count > 0);
441
+ nextDecisionCursor = successor ? formatDecisionCursor(successor.stage) : undefined;
442
+ }
443
+ }
444
+ const ownerCoverage = new Set<DecisionReceiptV1["enforcement"]["coverageState"]>(
445
+ [
446
+ approvalSummary.coverageState,
447
+ messageSummary.coverageState,
448
+ cronSummary.coverageState,
449
+ taskSummary.coverageState,
450
+ flowSummary.coverageState,
451
+ ].filter(
452
+ (coverageState): coverageState is NonNullable<typeof coverageState> =>
453
+ coverageState !== undefined,
454
+ ),
455
+ );
456
+ const hasUnverifiedGenericDecisions = genericSummary.count > 0;
457
+ const boundedEvidence = boundMissingEvidence([
458
+ ...params.context.missingEvidence,
459
+ ...approvalSummary.missingEvidence,
460
+ ...messageSummary.missingEvidence,
461
+ ...cronSummary.missingEvidence,
462
+ ...taskSummary.missingEvidence,
463
+ ...flowSummary.missingEvidence,
464
+ ...(hasUnverifiedGenericDecisions ? ["decision.display_provenance"] : []),
465
+ ]);
466
+ const coverageState = boundedEvidence.truncated
467
+ ? "unknown"
468
+ : hasUnverifiedGenericDecisions
469
+ ? "unknown"
470
+ : ownerCoverage.has("unsupported")
471
+ ? "unsupported"
472
+ : ownerCoverage.has("unknown")
473
+ ? "unknown"
474
+ : ownerCoverage.has("enforced")
475
+ ? "enforced"
476
+ : ownerCoverage.has("attribution-only")
477
+ ? "attribution-only"
478
+ : params.context.coverageState;
479
+ return {
480
+ schemaVersion: 1,
481
+ run: {
482
+ runId: params.context.runId,
483
+ executionId: params.context.executionId,
484
+ status: "known",
485
+ },
486
+ identity: { state: "present", context: params.context },
487
+ decisions: decisions.map(({ receipt }) => receipt),
488
+ decisionDisplays: decisions.map(projectDecisionDisplay),
489
+ coverage: { state: coverageState, missingEvidence: boundedEvidence.missingEvidence },
490
+ ...(nextDecisionCursor ? { nextDecisionCursor } : {}),
491
+ };
492
+ }
src/audit/execution-decision-work.test.ts ADDED
@@ -0,0 +1,251 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import path from "node:path";
2
+ import { DatabaseSync } from "node:sqlite";
3
+ import { afterEach, describe, expect, it, vi } from "vitest";
4
+ import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
5
+ import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js";
6
+ import { createAuditEventWriter } from "./audit-event-writer.js";
7
+ import { pageExecutionDecisionFactsForContext } from "./execution-decision-facts.js";
8
+ import type { ExecutionDecisionWork } from "./execution-decision-work.js";
9
+ import {
10
+ configureExecutionIdentityAdmissionSink,
11
+ createExecutionIdentityAdmissionToken,
12
+ enqueueExecutionIdentityContextAtAdmission,
13
+ } from "./execution-identity-admission.js";
14
+
15
+ function decisionWork(params: {
16
+ token: ReturnType<typeof createExecutionIdentityAdmissionToken>;
17
+ receiptId: string;
18
+ rawResource: string;
19
+ rawTarget: string;
20
+ targetNamespace?: "model-route" | "session";
21
+ }): ExecutionDecisionWork {
22
+ return {
23
+ workVersion: 1,
24
+ token: params.token,
25
+ receipt: {
26
+ schemaVersion: 1,
27
+ receiptId: params.receiptId,
28
+ occurredAt: params.token.createdAt + 1,
29
+ action: {
30
+ family: "model-routing",
31
+ operation: "select",
32
+ summary: "Selected one admitted model route.",
33
+ },
34
+ decision: { outcome: "allowed", reasonCode: "model_route_selected" },
35
+ enforcement: {
36
+ coverageState: "attribution-only",
37
+ policyRefs: [],
38
+ grantRefs: [],
39
+ contextFieldsUsed: ["contextId", "executionId", "runId"],
40
+ },
41
+ source: {
42
+ owner: "model-routing",
43
+ recordRef: params.receiptId,
44
+ decisionBoundary: "agent-runtime.post-admission",
45
+ },
46
+ missingEvidence: [],
47
+ remediation: [],
48
+ },
49
+ refs: {
50
+ resource: { namespace: "credential-profile", value: params.rawResource },
51
+ target: { namespace: params.targetNamespace ?? "model-route", value: params.rawTarget },
52
+ },
53
+ };
54
+ }
55
+
56
+ afterEach(() => {
57
+ closeOpenClawStateDatabaseForTest();
58
+ });
59
+ const tempDirs = useAutoCleanupTempDirTracker(afterEach);
60
+
61
+ describe("private execution decision work", () => {
62
+ it.each(["raw ref", "complete envelope"] as const)(
63
+ "rejects an oversized %s before the FIFO clone boundary",
64
+ async (oversizedPart) => {
65
+ const stateDir = tempDirs.make("openclaw-audit-private-decision-bounds-");
66
+ const errors: string[] = [];
67
+ const writer = createAuditEventWriter({ stateDir, onError: (error) => errors.push(error) });
68
+ const token = createExecutionIdentityAdmissionToken("bounded-private-decision-run", {
69
+ contextId: "bounded-private-decision-context",
70
+ executionId: "bounded-private-decision-execution",
71
+ now: 1_000,
72
+ });
73
+ const work = decisionWork({
74
+ token,
75
+ receiptId: "bounded-private-decision",
76
+ rawResource: oversizedPart === "raw ref" ? "r".repeat(4_097) : "resource",
77
+ rawTarget: "target",
78
+ });
79
+ if (oversizedPart === "complete envelope") {
80
+ const refs = Array.from({ length: 16 }, (_, index) => `${index}:`.padEnd(256, "p"));
81
+ work.receipt.enforcement.policyRefs = refs;
82
+ work.receipt.enforcement.grantRefs = refs;
83
+ work.receipt.enforcement.contextFieldsUsed = refs;
84
+ work.receipt.missingEvidence = refs;
85
+ work.receipt.remediation = Array.from({ length: 8 }, (_, index) => ({
86
+ code: `repair-${index}`,
87
+ text: "r".repeat(512),
88
+ }));
89
+ }
90
+
91
+ await writer.ready;
92
+ const clone = vi.spyOn(globalThis, "structuredClone");
93
+ expect(writer.recordExecutionDecisionWork(work)).toBe(false);
94
+ expect(clone).not.toHaveBeenCalled();
95
+ clone.mockRestore();
96
+ await writer.stop();
97
+
98
+ expect(errors).toEqual(["audit execution decision receipt could not be queued"]);
99
+ },
100
+ );
101
+
102
+ it("projects private refs inside the admission FIFO without retaining raw owners", async () => {
103
+ const stateDir = tempDirs.make("openclaw-audit-private-decision-");
104
+ const database = { env: { OPENCLAW_STATE_DIR: stateDir } };
105
+ const errors: string[] = [];
106
+ const writer = createAuditEventWriter({ stateDir, onError: (error) => errors.push(error) });
107
+ const admittedAt = Date.now();
108
+ const token = createExecutionIdentityAdmissionToken("private-decision-run", {
109
+ contextId: "private-decision-context",
110
+ executionId: "private-decision-execution",
111
+ now: admittedAt,
112
+ });
113
+ const rawCredentialProfile = "openai:user@example.test";
114
+ const rawModelTarget = "openai\0gpt-5.6-sol";
115
+ const clearAdmissionSink = configureExecutionIdentityAdmissionSink(
116
+ writer.recordExecutionIdentity,
117
+ );
118
+
119
+ await writer.ready;
120
+ expect(
121
+ enqueueExecutionIdentityContextAtAdmission(
122
+ {
123
+ runId: token.runId,
124
+ agentId: "main",
125
+ ingress: { kind: "local-cli", boundary: "agent-command.local", state: "present" },
126
+ runtime: { kind: "embedded" },
127
+ },
128
+ { enabled: true, token, runtimeInstanceId: "private-decision-runtime" },
129
+ )?.accepted,
130
+ ).toBe(true);
131
+ expect(
132
+ writer.recordExecutionDecisionWork(
133
+ decisionWork({
134
+ token,
135
+ receiptId: "private-model-route",
136
+ rawResource: rawCredentialProfile,
137
+ rawTarget: rawModelTarget,
138
+ }),
139
+ ),
140
+ ).toBe(true);
141
+ clearAdmissionSink();
142
+ await writer.stop();
143
+
144
+ expect(errors).toEqual([]);
145
+ const [receipt] = pageExecutionDecisionFactsForContext({
146
+ context: token,
147
+ limit: 10,
148
+ now: admittedAt + 1,
149
+ database,
150
+ }).receipts;
151
+ expect(receipt).toMatchObject({
152
+ contextId: token.contextId,
153
+ executionId: token.executionId,
154
+ runId: token.runId,
155
+ action: {
156
+ family: "model-routing",
157
+ resourceRef: expect.stringMatching(/^hmac-sha256:v1:/u),
158
+ targetRef: expect.stringMatching(/^hmac-sha256:v1:/u),
159
+ },
160
+ });
161
+ expect(receipt?.action.resourceRef).not.toBe(receipt?.action.targetRef);
162
+ const sqlite = new DatabaseSync(path.join(stateDir, "state", "openclaw.sqlite"), {
163
+ readOnly: true,
164
+ });
165
+ try {
166
+ const persisted = JSON.stringify(
167
+ sqlite
168
+ .prepare("SELECT * FROM execution_decision_facts WHERE receipt_id = ?")
169
+ .get("private-model-route"),
170
+ );
171
+ expect(persisted).not.toContain(rawCredentialProfile);
172
+ expect(persisted).not.toContain(rawModelTarget);
173
+ } finally {
174
+ sqlite.close();
175
+ }
176
+ });
177
+
178
+ it("keeps refs restart-stable, namespace-separated, and installation-local", async () => {
179
+ const rawRef = "same-private-owner-ref";
180
+ const firstStateDir = tempDirs.make("openclaw-audit-private-stability-");
181
+ const write = async (params: {
182
+ stateDir: string;
183
+ suffix: string;
184
+ targetNamespace?: "model-route" | "session";
185
+ }) => {
186
+ const now = Date.now();
187
+ const token = createExecutionIdentityAdmissionToken(`run-${params.suffix}`, {
188
+ contextId: `context-${params.suffix}`,
189
+ executionId: `execution-${params.suffix}`,
190
+ now,
191
+ });
192
+ const database = { env: { OPENCLAW_STATE_DIR: params.stateDir } };
193
+ const writer = createAuditEventWriter({ stateDir: params.stateDir });
194
+ const clearAdmissionSink = configureExecutionIdentityAdmissionSink(
195
+ writer.recordExecutionIdentity,
196
+ );
197
+ await writer.ready;
198
+ expect(
199
+ enqueueExecutionIdentityContextAtAdmission(
200
+ {
201
+ runId: token.runId,
202
+ agentId: "main",
203
+ ingress: { kind: "local-cli", boundary: "agent-command.local" },
204
+ runtime: { kind: "embedded" },
205
+ },
206
+ { enabled: true, token, runtimeInstanceId: `runtime-${params.suffix}` },
207
+ )?.accepted,
208
+ ).toBe(true);
209
+ expect(
210
+ writer.recordExecutionDecisionWork(
211
+ decisionWork({
212
+ token,
213
+ receiptId: `receipt-${params.suffix}`,
214
+ rawResource: rawRef,
215
+ rawTarget: rawRef,
216
+ ...(params.targetNamespace ? { targetNamespace: params.targetNamespace } : {}),
217
+ }),
218
+ ),
219
+ ).toBe(true);
220
+ clearAdmissionSink();
221
+ await writer.stop();
222
+ return pageExecutionDecisionFactsForContext({
223
+ context: token,
224
+ limit: 1,
225
+ now: now + 1,
226
+ database,
227
+ }).receipts[0]!;
228
+ };
229
+
230
+ const first = await write({ stateDir: firstStateDir, suffix: "first" });
231
+ closeOpenClawStateDatabaseForTest();
232
+ const restarted = await write({ stateDir: firstStateDir, suffix: "restart" });
233
+ const sessionScoped = await write({
234
+ stateDir: firstStateDir,
235
+ suffix: "session",
236
+ targetNamespace: "session",
237
+ });
238
+ closeOpenClawStateDatabaseForTest();
239
+ const otherInstallation = await write({
240
+ stateDir: tempDirs.make("openclaw-audit-private-other-installation-"),
241
+ suffix: "other",
242
+ });
243
+
244
+ expect(restarted.action.resourceRef).toBe(first.action.resourceRef);
245
+ expect(restarted.action.targetRef).toBe(first.action.targetRef);
246
+ expect(first.action.resourceRef).not.toBe(first.action.targetRef);
247
+ expect(sessionScoped.action.targetRef).not.toBe(first.action.targetRef);
248
+ expect(otherInstallation.action.resourceRef).not.toBe(first.action.resourceRef);
249
+ expect(otherInstallation.action.targetRef).not.toBe(first.action.targetRef);
250
+ });
251
+ });
src/audit/execution-identity-context-build.ts ADDED
@@ -0,0 +1,195 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** Worker-only canonical context construction and bounded value helpers. */
2
+ import type { DatabaseSync } from "node:sqlite";
3
+ import type { ExecutionIdentityContextV1 } from "../../packages/gateway-protocol/src/index.js";
4
+ import { validateExecutionIdentityContextV1 } from "../../packages/gateway-protocol/src/index.js";
5
+ import { pseudonymizeExecutionIdentityRef } from "./audit-identity.js";
6
+ import type { ExecutionIdentityAdmissionEnvelope } from "./execution-identity-admission.js";
7
+ import { sortUniqueExecutionIdentityEntries } from "./execution-identity-ordering.js";
8
+ import { executionIdentitySpawnAdmission } from "./execution-identity-spawn-admission.js";
9
+
10
+ const EXECUTION_IDENTITY_CONTEXT_MAX_BYTES = 16 * 1024;
11
+
12
+ export function ensureBoundedExecutionIdentityRef(
13
+ value: string,
14
+ label: string,
15
+ maxLength = 256,
16
+ ): string {
17
+ if (!value || value.length > maxLength) {
18
+ throw new Error(`${label} must be between 1 and ${String(maxLength)} characters`);
19
+ }
20
+ return value;
21
+ }
22
+
23
+ function ensureRawRef(value: string, label: string): string {
24
+ return ensureBoundedExecutionIdentityRef(value, label, 4_096);
25
+ }
26
+
27
+ export function freezeExecutionIdentityContext<T>(value: T, seen = new WeakSet<object>()): T {
28
+ if (!value || typeof value !== "object" || seen.has(value as object)) {
29
+ return value;
30
+ }
31
+ seen.add(value as object);
32
+ for (const nested of Object.values(value as Record<string, unknown>)) {
33
+ freezeExecutionIdentityContext(nested, seen);
34
+ }
35
+ return Object.freeze(value);
36
+ }
37
+
38
+ function hmacRef(
39
+ db: DatabaseSync,
40
+ kind: Parameters<typeof pseudonymizeExecutionIdentityRef>[0]["kind"],
41
+ scope: string,
42
+ value: string,
43
+ ): string {
44
+ return pseudonymizeExecutionIdentityRef({
45
+ db,
46
+ kind,
47
+ scope: ensureBoundedExecutionIdentityRef(scope, "HMAC scope"),
48
+ value: ensureRawRef(value, "HMAC value"),
49
+ });
50
+ }
51
+
52
+ export function buildExecutionIdentityContext(
53
+ db: DatabaseSync,
54
+ envelope: ExecutionIdentityAdmissionEnvelope,
55
+ fixed: { contextId: string; createdAt: number },
56
+ ): ExecutionIdentityContextV1 {
57
+ const runId = ensureBoundedExecutionIdentityRef(envelope.runId, "run id");
58
+ const executionId = ensureBoundedExecutionIdentityRef(envelope.executionId, "execution id");
59
+ const agentId = ensureBoundedExecutionIdentityRef(envelope.agentId, "agent id");
60
+ const contextId = ensureBoundedExecutionIdentityRef(fixed.contextId, "context id");
61
+ const domainRef = hmacRef(db, "domain", "gateway-cell", "gateway-cell");
62
+ const runtimeRef = hmacRef(
63
+ db,
64
+ "runtime",
65
+ domainRef,
66
+ ensureRawRef(envelope.runtimeInstanceId, "runtime instance id"),
67
+ );
68
+ const invoker =
69
+ envelope.invoker?.state === "present"
70
+ ? {
71
+ state: "present" as const,
72
+ principal: {
73
+ kind: envelope.invoker.kind,
74
+ domainRef,
75
+ principalRef: hmacRef(
76
+ db,
77
+ "principal",
78
+ `${domainRef}:${envelope.invoker.kind}`,
79
+ envelope.invoker.rawPrincipalRef,
80
+ ),
81
+ ...(envelope.invoker.displayLabel !== undefined
82
+ ? { displayLabel: envelope.invoker.displayLabel }
83
+ : {}),
84
+ },
85
+ }
86
+ : envelope.invoker?.state === "unknown"
87
+ ? { state: "unknown" as const }
88
+ : { state: "absent" as const };
89
+ const assurance = sortUniqueExecutionIdentityEntries(
90
+ envelope.assurance.map((item) => ({
91
+ kind: item.kind,
92
+ evidenceRef: hmacRef(db, "evidence", `${domainRef}:${item.kind}`, item.rawEvidenceRef),
93
+ strength: item.strength,
94
+ })),
95
+ (item) => `${item.kind}\0${item.evidenceRef}\0${item.strength}`,
96
+ );
97
+ const applicableGrants = sortUniqueExecutionIdentityEntries(
98
+ envelope.applicableGrants.map((grant) => ({
99
+ grantRef: hmacRef(db, "grant", domainRef, grant.rawGrantRef),
100
+ state: grant.state,
101
+ })),
102
+ (grant) => `${grant.grantRef}\0${grant.state}`,
103
+ );
104
+ const serializedSpawnFacts = executionIdentitySpawnAdmission({
105
+ operation: "read",
106
+ value: envelope,
107
+ });
108
+ const [lineageFacts, spawnMissingEvidence] = serializedSpawnFacts
109
+ ? executionIdentitySpawnAdmission({ operation: "parse", value: serializedSpawnFacts })
110
+ : [undefined, []];
111
+ const lineage = lineageFacts
112
+ ? {
113
+ ...(typeof lineageFacts.parentContextId === "string"
114
+ ? { parentContextId: lineageFacts.parentContextId }
115
+ : {}),
116
+ ...(typeof lineageFacts.parentExecutionId === "string"
117
+ ? { parentExecutionId: lineageFacts.parentExecutionId }
118
+ : {}),
119
+ ...(typeof lineageFacts.parentRunId === "string"
120
+ ? { parentRunId: lineageFacts.parentRunId }
121
+ : {}),
122
+ parentAgentPrincipal: {
123
+ kind: "agent" as const,
124
+ domainRef,
125
+ principalRef: lineageFacts.parentAgentId,
126
+ },
127
+ delegationRef: hmacRef(
128
+ db,
129
+ "grant",
130
+ `${domainRef}:delegation`,
131
+ JSON.stringify([
132
+ lineageFacts.relation,
133
+ lineageFacts.rawRequesterRef,
134
+ lineageFacts.rawControllerRef,
135
+ lineageFacts.localPolicyRefs,
136
+ lineageFacts.targetPolicyRefs,
137
+ ]),
138
+ ),
139
+ depth: lineageFacts.depth,
140
+ }
141
+ : undefined;
142
+ const missingEvidence = sortUniqueExecutionIdentityEntries(
143
+ [
144
+ ...(envelope.invoker?.state === "present" ? [] : ["invoker.principal"]),
145
+ ...spawnMissingEvidence,
146
+ ],
147
+ (item) => item,
148
+ );
149
+ const context: Record<string, unknown> = {
150
+ schemaVersion: 1,
151
+ contextId,
152
+ executionId,
153
+ runId,
154
+ createdAt: fixed.createdAt,
155
+ trustDomain: { kind: "gateway-cell", domainRef, state: "present" },
156
+ invoker,
157
+ ingress: {
158
+ kind: envelope.ingress.kind,
159
+ boundary: ensureBoundedExecutionIdentityRef(envelope.ingress.boundary, "ingress boundary"),
160
+ state: envelope.ingress.state,
161
+ ...(envelope.ingress.rawSourceRef
162
+ ? {
163
+ sourceRef: hmacRef(
164
+ db,
165
+ "principal",
166
+ `${domainRef}:ingress:${envelope.ingress.kind}`,
167
+ envelope.ingress.rawSourceRef,
168
+ ),
169
+ }
170
+ : {}),
171
+ },
172
+ agentPrincipal: { kind: "agent", domainRef, principalRef: agentId },
173
+ agentDefinition: { definitionRef: agentId, state: "present" },
174
+ runtimeInstance: { runtimeRef, kind: envelope.runtime.kind, state: "present" },
175
+ applicableGrants,
176
+ assurance,
177
+ ...(lineage ? { lineage } : {}),
178
+ coverageState: lineage
179
+ ? "attribution-only"
180
+ : envelope.invoker?.state === "present"
181
+ ? "attribution-only"
182
+ : envelope.invoker?.state === "unknown"
183
+ ? "unknown"
184
+ : "unattributed",
185
+ missingEvidence,
186
+ };
187
+ if (!validateExecutionIdentityContextV1(context)) {
188
+ throw new Error("prepared execution identity context violates the V1 contract");
189
+ }
190
+ const encoded = JSON.stringify(context);
191
+ if (Buffer.byteLength(encoded, "utf8") > EXECUTION_IDENTITY_CONTEXT_MAX_BYTES) {
192
+ throw new Error("prepared execution identity context exceeds 16 KiB");
193
+ }
194
+ return freezeExecutionIdentityContext(context);
195
+ }
src/audit/execution-identity-context.test.ts ADDED
@@ -0,0 +1,1065 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { afterEach, describe, expect, it } from "vitest";
2
+ import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
3
+ import {
4
+ insertOperatorApproval,
5
+ resolveOperatorApproval,
6
+ } from "../gateway/operator-approval-store.js";
7
+ import { openNodeSqliteDatabase } from "../infra/node-sqlite.js";
8
+ import {
9
+ closeOpenClawStateDatabaseForTest,
10
+ openOpenClawStateDatabase,
11
+ type OpenClawStateDatabase,
12
+ } from "../state/openclaw-state-db.js";
13
+ import { recordAuditEvent } from "./audit-event-store.js";
14
+ import {
15
+ configureExecutionIdentityAdmissionSink,
16
+ createExecutionIdentityAdmissionToken,
17
+ enqueueExecutionIdentityContextAtAdmission,
18
+ type ExecutionIdentityAdmissionEnvelope,
19
+ type ExecutionIdentityAdmissionFacts,
20
+ } from "./execution-identity-admission.js";
21
+ import {
22
+ inspectExecutionIdentityRun,
23
+ processExecutionIdentityAdmissionWork,
24
+ pruneExpiredExecutionIdentityContexts,
25
+ } from "./execution-identity-context.js";
26
+
27
+ const RETENTION_MS = 30 * 24 * 60 * 60_000;
28
+
29
+ afterEach(() => {
30
+ closeOpenClawStateDatabaseForTest();
31
+ });
32
+
33
+ const tempDirs = useAutoCleanupTempDirTracker(afterEach);
34
+
35
+ function databaseOptions() {
36
+ return { env: { OPENCLAW_STATE_DIR: tempDirs.make("openclaw-identity-") } };
37
+ }
38
+
39
+ function openIndependentStateDatabase(path: string): OpenClawStateDatabase {
40
+ return {
41
+ db: openNodeSqliteDatabase(path),
42
+ path,
43
+ walMaintenance: { checkpoint: () => true, close: () => true },
44
+ };
45
+ }
46
+
47
+ function facts(
48
+ runId: string,
49
+ overrides: Partial<ExecutionIdentityAdmissionFacts> = {},
50
+ ): ExecutionIdentityAdmissionFacts {
51
+ return {
52
+ runId,
53
+ agentId: "main",
54
+ ingress: { kind: "local-cli", boundary: "agent-command.local", state: "present" },
55
+ runtime: { kind: "embedded" },
56
+ ...overrides,
57
+ };
58
+ }
59
+
60
+ function captureExecutionIdentityAdmissionEnvelope(
61
+ admissionFacts: ExecutionIdentityAdmissionFacts,
62
+ options: {
63
+ now?: number;
64
+ contextId?: string;
65
+ executionId?: string;
66
+ runtimeInstanceId?: string;
67
+ } = {},
68
+ ): ExecutionIdentityAdmissionEnvelope {
69
+ const { contextId, executionId, runtimeInstanceId, now } = options;
70
+ let envelope: ExecutionIdentityAdmissionEnvelope | undefined;
71
+ const clear = configureExecutionIdentityAdmissionSink((captured) => {
72
+ if (captured.kind === "capture") {
73
+ envelope = captured.envelope;
74
+ }
75
+ return true;
76
+ });
77
+ try {
78
+ const result = enqueueExecutionIdentityContextAtAdmission(admissionFacts, {
79
+ enabled: true,
80
+ ...(contextId !== undefined ? { contextId } : {}),
81
+ ...(executionId !== undefined ? { executionId } : {}),
82
+ ...(runtimeInstanceId !== undefined ? { runtimeInstanceId } : {}),
83
+ ...(now !== undefined ? { now } : {}),
84
+ });
85
+ if (!result || !envelope) {
86
+ throw new Error("expected admission envelope");
87
+ }
88
+ return envelope;
89
+ } finally {
90
+ clear();
91
+ }
92
+ }
93
+
94
+ function persistExecutionIdentityAdmissionEnvelope(
95
+ envelope: ExecutionIdentityAdmissionEnvelope,
96
+ options: Parameters<typeof processExecutionIdentityAdmissionWork>[1] = {},
97
+ ) {
98
+ return processExecutionIdentityAdmissionWork({ kind: "capture", envelope }, options);
99
+ }
100
+
101
+ function prepareExecutionIdentityContextAtAdmission(
102
+ admissionFacts: ExecutionIdentityAdmissionFacts,
103
+ options: {
104
+ database?: OpenClawStateDatabase;
105
+ env?: NodeJS.ProcessEnv;
106
+ now?: number;
107
+ contextId?: string;
108
+ executionId?: string;
109
+ runtimeInstanceId?: string;
110
+ limits?: { maxRows: number; pruneBatchRows: number };
111
+ } = {},
112
+ ) {
113
+ const { contextId, executionId, runtimeInstanceId, now, limits, ...database } = options;
114
+ const envelope = captureExecutionIdentityAdmissionEnvelope(admissionFacts, {
115
+ ...(contextId !== undefined ? { contextId } : {}),
116
+ ...(executionId !== undefined ? { executionId } : {}),
117
+ ...(runtimeInstanceId !== undefined ? { runtimeInstanceId } : {}),
118
+ ...(now !== undefined ? { now } : {}),
119
+ });
120
+ return persistExecutionIdentityAdmissionEnvelope(envelope, {
121
+ ...database,
122
+ ...(now !== undefined ? { now } : {}),
123
+ ...(limits !== undefined ? { limits } : {}),
124
+ });
125
+ }
126
+
127
+ function recordDeniedApprovalForRun(
128
+ runId: string,
129
+ database: ReturnType<typeof databaseOptions>,
130
+ id = "denied-approval",
131
+ binding?: { contextId: string; executionId: string },
132
+ ): void {
133
+ insertOperatorApproval({
134
+ approval: {
135
+ id,
136
+ kind: "exec",
137
+ presentation: {
138
+ kind: "exec",
139
+ commandText: "details withheld",
140
+ allowedDecisions: ["allow-once", "deny"],
141
+ },
142
+ source: { runId, toolCallId: "private-tool-call", toolName: "exec" },
143
+ runtimeEpoch: "runtime-1",
144
+ createdAtMs: 100,
145
+ expiresAtMs: 1_000,
146
+ ...(binding
147
+ ? {
148
+ executionIdentityToken: {
149
+ tokenVersion: 1,
150
+ createdAt: 100,
151
+ runId,
152
+ contextId: binding.contextId,
153
+ executionId: binding.executionId,
154
+ },
155
+ }
156
+ : {}),
157
+ },
158
+ databaseOptions: database,
159
+ });
160
+ resolveOperatorApproval({
161
+ id,
162
+ decision: "deny",
163
+ resolver: { kind: "device", id: "private-reviewer-device" },
164
+ nowMs: 200,
165
+ databaseOptions: database,
166
+ });
167
+ }
168
+
169
+ describe("execution identity context storage", () => {
170
+ it("replays one byte-identical canonical context idempotently across restart", () => {
171
+ const database = databaseOptions();
172
+ const envelope = captureExecutionIdentityAdmissionEnvelope(facts("run-1"), {
173
+ now: 100,
174
+ contextId: "context-1",
175
+ executionId: "execution-1",
176
+ runtimeInstanceId: "runtime-secret-1",
177
+ });
178
+ const first = persistExecutionIdentityAdmissionEnvelope(envelope, { ...database, now: 100 });
179
+
180
+ closeOpenClawStateDatabaseForTest();
181
+ const second = persistExecutionIdentityAdmissionEnvelope(structuredClone(envelope), {
182
+ ...database,
183
+ now: 999,
184
+ });
185
+
186
+ expect(second).toEqual(first);
187
+ expect(first.coverageState).toBe("unattributed");
188
+ expect(first.invoker).toEqual({ state: "absent" });
189
+ expect(Object.isFrozen(first)).toBe(true);
190
+ expect(Object.isFrozen(first.runtimeInstance)).toBe(true);
191
+ expect(JSON.stringify(first)).not.toContain("runtime-secret-1");
192
+
193
+ closeOpenClawStateDatabaseForTest();
194
+ const afterRestart = inspectExecutionIdentityRun(
195
+ { executionId: "execution-1" },
196
+ {
197
+ ...database,
198
+ now: 999,
199
+ },
200
+ );
201
+ expect(afterRestart.identity).toEqual({ state: "present", context: first });
202
+ });
203
+
204
+ it("keeps explicit unknown invoker evidence distinct from omission across restart", () => {
205
+ const database = databaseOptions();
206
+ const unknown = prepareExecutionIdentityContextAtAdmission(
207
+ facts("run-unknown", { invoker: { state: "unknown" } }),
208
+ {
209
+ ...database,
210
+ now: 100,
211
+ contextId: "context-unknown",
212
+ executionId: "execution-unknown",
213
+ runtimeInstanceId: "runtime-unknown",
214
+ },
215
+ );
216
+ const absent = prepareExecutionIdentityContextAtAdmission(facts("run-absent"), {
217
+ ...database,
218
+ now: 101,
219
+ contextId: "context-absent",
220
+ executionId: "execution-absent",
221
+ runtimeInstanceId: "runtime-absent",
222
+ });
223
+
224
+ expect(unknown).toMatchObject({
225
+ invoker: { state: "unknown" },
226
+ coverageState: "unknown",
227
+ missingEvidence: ["invoker.principal"],
228
+ });
229
+ expect(unknown.invoker).not.toHaveProperty("principal");
230
+ expect(absent).toMatchObject({
231
+ invoker: { state: "absent" },
232
+ coverageState: "unattributed",
233
+ missingEvidence: ["invoker.principal"],
234
+ });
235
+
236
+ closeOpenClawStateDatabaseForTest();
237
+ const unknownAfterRestart = inspectExecutionIdentityRun(
238
+ { executionId: "execution-unknown" },
239
+ { ...database, now: 101 },
240
+ );
241
+ expect(unknownAfterRestart.identity).toEqual({ state: "present", context: unknown });
242
+ expect(unknownAfterRestart.coverage).toEqual({
243
+ state: "unknown",
244
+ missingEvidence: ["invoker.principal"],
245
+ });
246
+ expect(unknownAfterRestart.decisions).toEqual([
247
+ expect.objectContaining({
248
+ enforcement: expect.objectContaining({ coverageState: "unknown" }),
249
+ missingEvidence: ["invoker.principal"],
250
+ }),
251
+ ]);
252
+ expect(
253
+ inspectExecutionIdentityRun({ executionId: "execution-absent" }, { ...database, now: 101 })
254
+ .identity,
255
+ ).toEqual({ state: "present", context: absent });
256
+ });
257
+
258
+ it.each([
259
+ {
260
+ difference: "contextId",
261
+ mutate: (envelope: ExecutionIdentityAdmissionEnvelope) => ({
262
+ ...envelope,
263
+ contextId: "context-conflicting",
264
+ }),
265
+ },
266
+ {
267
+ difference: "createdAt",
268
+ mutate: (envelope: ExecutionIdentityAdmissionEnvelope) => ({
269
+ ...envelope,
270
+ createdAt: envelope.createdAt + 1,
271
+ }),
272
+ },
273
+ {
274
+ difference: "identity facts",
275
+ mutate: (envelope: ExecutionIdentityAdmissionEnvelope) => ({
276
+ ...envelope,
277
+ agentId: "other",
278
+ }),
279
+ },
280
+ ])(
281
+ "conflicts on a same-execution $difference and leaves canonical bytes unchanged",
282
+ ({ mutate }) => {
283
+ const database = databaseOptions();
284
+ const envelope = captureExecutionIdentityAdmissionEnvelope(facts("run-conflict"), {
285
+ contextId: "context-original",
286
+ executionId: "execution-original",
287
+ now: 100,
288
+ runtimeInstanceId: "runtime-1",
289
+ });
290
+ const original = persistExecutionIdentityAdmissionEnvelope(envelope, {
291
+ ...database,
292
+ now: 100,
293
+ });
294
+ const originalRow = openOpenClawStateDatabase(database)
295
+ .db.prepare("SELECT context_json FROM execution_identity_contexts WHERE execution_id = ?")
296
+ .get("execution-original");
297
+
298
+ expect(() =>
299
+ persistExecutionIdentityAdmissionEnvelope(mutate(envelope), { ...database, now: 101 }),
300
+ ).toThrow("execution identity context conflict");
301
+ expect(
302
+ openOpenClawStateDatabase(database)
303
+ .db.prepare("SELECT context_json FROM execution_identity_contexts WHERE execution_id = ?")
304
+ .get("execution-original"),
305
+ ).toEqual(originalRow);
306
+ expect(
307
+ inspectExecutionIdentityRun(
308
+ { executionId: "execution-original" },
309
+ { ...database, now: 101 },
310
+ ).identity,
311
+ ).toEqual({ state: "present", context: original });
312
+ },
313
+ );
314
+
315
+ it("keeps distinct turns sharing one run correlation exactly inspectable", () => {
316
+ const database = databaseOptions();
317
+ prepareExecutionIdentityContextAtAdmission(facts("session-run"), {
318
+ ...database,
319
+ now: 100,
320
+ contextId: "context-first",
321
+ executionId: "execution-first",
322
+ runtimeInstanceId: "runtime-1",
323
+ });
324
+ prepareExecutionIdentityContextAtAdmission(facts("session-run"), {
325
+ ...database,
326
+ now: 101,
327
+ contextId: "context-second",
328
+ executionId: "execution-second",
329
+ runtimeInstanceId: "runtime-1",
330
+ });
331
+ recordDeniedApprovalForRun("session-run", database, "shared-run-approval", {
332
+ contextId: "context-first",
333
+ executionId: "execution-first",
334
+ });
335
+
336
+ const discovery = inspectExecutionIdentityRun(
337
+ { runId: "session-run" },
338
+ { ...database, now: 300 },
339
+ );
340
+ expect(discovery).toMatchObject({
341
+ run: { runId: "session-run", status: "known" },
342
+ identity: {
343
+ state: "ambiguous",
344
+ reasonCode: "execution_selection_required",
345
+ candidates: [
346
+ { executionId: "execution-first", contextId: "context-first" },
347
+ { executionId: "execution-second", contextId: "context-second" },
348
+ ],
349
+ },
350
+ decisions: [],
351
+ });
352
+ for (const [executionOffset, executionId, nextExecutionCursor] of [
353
+ [0, "execution-first", "1"],
354
+ [1, "execution-second", undefined],
355
+ ] as const) {
356
+ expect(
357
+ inspectExecutionIdentityRun(
358
+ { runId: "session-run", executionOffset, executionLimit: 1 },
359
+ { ...database, now: 300 },
360
+ ),
361
+ ).toMatchObject({
362
+ identity: { state: "ambiguous", candidates: [{ executionId }] },
363
+ ...(nextExecutionCursor ? { nextExecutionCursor } : {}),
364
+ });
365
+ }
366
+ const firstInspection = inspectExecutionIdentityRun(
367
+ { executionId: "execution-first" },
368
+ { ...database, now: 300 },
369
+ );
370
+ const secondInspection = inspectExecutionIdentityRun(
371
+ { executionId: "execution-second" },
372
+ { ...database, now: 300 },
373
+ );
374
+ expect(firstInspection).toMatchObject({
375
+ identity: { state: "present", context: { contextId: "context-first" } },
376
+ coverage: { state: "enforced" },
377
+ decisions: [{ decision: { outcome: "not-applicable" } }, { decision: { outcome: "denied" } }],
378
+ });
379
+ expect(secondInspection).toMatchObject({
380
+ identity: { state: "present", context: { contextId: "context-second" } },
381
+ coverage: {
382
+ state: "unknown",
383
+ missingEvidence: expect.arrayContaining(["decision.execution_link"]),
384
+ },
385
+ decisions: [
386
+ { decision: { outcome: "not-applicable" } },
387
+ {
388
+ decision: {
389
+ outcome: "unknown",
390
+ reasonCode: "operator_approval_execution_link_mismatch",
391
+ },
392
+ },
393
+ ],
394
+ });
395
+ });
396
+
397
+ it("confirms durable retries without manufacturing lost evidence", () => {
398
+ const database = databaseOptions();
399
+ const envelope = captureExecutionIdentityAdmissionEnvelope(facts("run-recovery"), {
400
+ now: 100,
401
+ contextId: "context-recovery",
402
+ executionId: "execution-recovery",
403
+ runtimeInstanceId: "runtime-original",
404
+ });
405
+ const original = processExecutionIdentityAdmissionWork(
406
+ { kind: "capture", envelope },
407
+ { ...database, now: 100 },
408
+ );
409
+ const token = createExecutionIdentityAdmissionToken("run-recovery", {
410
+ now: 100,
411
+ contextId: "context-recovery",
412
+ executionId: "execution-recovery",
413
+ });
414
+
415
+ closeOpenClawStateDatabaseForTest();
416
+ expect(
417
+ processExecutionIdentityAdmissionWork({ kind: "retry-reference", token }, database),
418
+ ).toEqual(original);
419
+
420
+ const missingDatabase = databaseOptions();
421
+ expect(() =>
422
+ processExecutionIdentityAdmissionWork({ kind: "retry-reference", token }, missingDatabase),
423
+ ).toThrow("execution identity recovery evidence unavailable");
424
+ expect(
425
+ inspectExecutionIdentityRun({ executionId: "execution-recovery" }, missingDatabase),
426
+ ).toMatchObject({
427
+ run: { executionId: "execution-recovery", status: "unknown" },
428
+ identity: { state: "unknown", reasonCode: "execution_not_found" },
429
+ decisions: [],
430
+ });
431
+ });
432
+
433
+ it("projects authoritative local CLI and system ingress without conflating them", () => {
434
+ const database = databaseOptions();
435
+ prepareExecutionIdentityContextAtAdmission(facts("run-local"), database);
436
+ prepareExecutionIdentityContextAtAdmission(
437
+ facts("run-system", {
438
+ ingress: { kind: "system", boundary: "gateway.boot", state: "present" },
439
+ }),
440
+ database,
441
+ );
442
+
443
+ expect(inspectExecutionIdentityRun({ runId: "run-local" }, database).identity).toMatchObject({
444
+ state: "present",
445
+ context: {
446
+ ingress: { kind: "local-cli", boundary: "agent-command.local", state: "present" },
447
+ },
448
+ });
449
+ expect(inspectExecutionIdentityRun({ runId: "run-system" }, database).identity).toMatchObject({
450
+ state: "present",
451
+ context: {
452
+ ingress: { kind: "system", boundary: "gateway.boot", state: "present" },
453
+ },
454
+ });
455
+ });
456
+
457
+ it("keeps inspection read-only and lets persistence create the additive table", () => {
458
+ const database = databaseOptions();
459
+ const reopened = openOpenClawStateDatabase(database);
460
+ expect(
461
+ reopened.db
462
+ .prepare("SELECT name FROM sqlite_schema WHERE type = 'table' AND name = ?")
463
+ .get("execution_identity_contexts"),
464
+ ).toBeUndefined();
465
+ expect(inspectExecutionIdentityRun({ runId: "missing" }, database)).toMatchObject({
466
+ run: { status: "unknown" },
467
+ identity: { state: "unknown", reasonCode: "run_not_found" },
468
+ });
469
+ expect(
470
+ reopened.db
471
+ .prepare("SELECT name FROM sqlite_schema WHERE type = 'table' AND name = ?")
472
+ .get("execution_identity_contexts"),
473
+ ).toBeUndefined();
474
+
475
+ prepareExecutionIdentityContextAtAdmission(facts("schema-restored"), database);
476
+ expect(
477
+ reopened.db
478
+ .prepare("SELECT name FROM sqlite_schema WHERE type = 'table' AND name = ?")
479
+ .get("execution_identity_contexts"),
480
+ ).toEqual({ name: "execution_identity_contexts" });
481
+ expect(
482
+ reopened.db
483
+ .prepare("SELECT name FROM sqlite_schema WHERE type = 'index' AND name = ?")
484
+ .get("execution_identity_contexts_run_created_idx"),
485
+ ).toEqual({ name: "execution_identity_contexts_run_created_idx" });
486
+ });
487
+
488
+ it("keeps maintenance read-only until the first identity capture", () => {
489
+ const database = databaseOptions();
490
+ const opened = openOpenClawStateDatabase(database);
491
+ expect(
492
+ opened.db
493
+ .prepare("SELECT name FROM sqlite_schema WHERE type = 'table' AND name = ?")
494
+ .get("execution_identity_contexts"),
495
+ ).toBeUndefined();
496
+
497
+ expect(pruneExpiredExecutionIdentityContexts({ database })).toBe(0);
498
+ expect(
499
+ opened.db
500
+ .prepare("SELECT name FROM sqlite_schema WHERE type = 'table' AND name = ?")
501
+ .get("execution_identity_contexts"),
502
+ ).toBeUndefined();
503
+ });
504
+
505
+ it("records attribution only when an invoker fact is actually present", () => {
506
+ const database = databaseOptions();
507
+ const context = prepareExecutionIdentityContextAtAdmission(
508
+ facts("run-attributed", {
509
+ invoker: {
510
+ state: "present",
511
+ kind: "local-account",
512
+ rawPrincipalRef: "private-local-account",
513
+ displayLabel: "Operator OPENAI_API_KEY=sk-1234567890abcdef",
514
+ },
515
+ applicableGrants: [
516
+ { rawGrantRef: "grant-z", state: "present" },
517
+ { rawGrantRef: "grant-a", state: "present" },
518
+ ],
519
+ assurance: [
520
+ {
521
+ kind: "local-process",
522
+ rawEvidenceRef: "private-process-evidence",
523
+ strength: "boundary-verified",
524
+ },
525
+ ],
526
+ }),
527
+ { ...database, runtimeInstanceId: "private-runtime" },
528
+ );
529
+ const encoded = JSON.stringify(context);
530
+
531
+ expect(context.coverageState).toBe("attribution-only");
532
+ expect(context.invoker.state).toBe("present");
533
+ expect(context.missingEvidence).toEqual([]);
534
+ expect(context.applicableGrants.map((grant) => grant.grantRef)).toEqual(
535
+ context.applicableGrants.map((grant) => grant.grantRef).toSorted(),
536
+ );
537
+ for (const secret of [
538
+ "private-local-account",
539
+ "private-process-evidence",
540
+ "private-runtime",
541
+ "grant-a",
542
+ "grant-z",
543
+ "sk-1234567890abcdef",
544
+ ]) {
545
+ expect(encoded).not.toContain(secret);
546
+ }
547
+ expect(context.invoker.principal?.principalRef).toMatch(/^hmac-sha256:v1:/u);
548
+ });
549
+
550
+ it("declines recording instead of rotating a missing HMAC key with retained contexts", () => {
551
+ const database = databaseOptions();
552
+ prepareExecutionIdentityContextAtAdmission(facts("run-before-key-loss"), database);
553
+ openOpenClawStateDatabase(database).db.exec("DELETE FROM audit_identity_keys;");
554
+ closeOpenClawStateDatabaseForTest();
555
+
556
+ expect(() =>
557
+ prepareExecutionIdentityContextAtAdmission(facts("run-after-key-loss"), database),
558
+ ).toThrow("audit identity key is missing");
559
+ });
560
+
561
+ it("skips new context rows when audit collection is disabled", () => {
562
+ const database = databaseOptions();
563
+
564
+ expect(
565
+ enqueueExecutionIdentityContextAtAdmission(facts("run-disabled"), { enabled: false }),
566
+ ).toBeUndefined();
567
+
568
+ expect(
569
+ openOpenClawStateDatabase(database)
570
+ .db.prepare("SELECT name FROM sqlite_schema WHERE type = 'table' AND name = ?")
571
+ .get("execution_identity_contexts"),
572
+ ).toBeUndefined();
573
+ });
574
+
575
+ it("keeps bounded retention maintenance available while collection is disabled", () => {
576
+ const database = databaseOptions();
577
+ prepareExecutionIdentityContextAtAdmission(facts("run-before-disable"), {
578
+ ...database,
579
+ now: 0,
580
+ runtimeInstanceId: "runtime-1",
581
+ });
582
+ expect(
583
+ enqueueExecutionIdentityContextAtAdmission(facts("run-disabled"), { enabled: false }),
584
+ ).toBeUndefined();
585
+
586
+ expect(pruneExpiredExecutionIdentityContexts({ database, now: RETENTION_MS + 1 })).toBe(1);
587
+ expect(
588
+ openOpenClawStateDatabase(database)
589
+ .db.prepare("SELECT COUNT(*) AS count FROM execution_identity_contexts")
590
+ .get(),
591
+ ).toEqual({ count: 0 });
592
+ });
593
+
594
+ it("leaves the original context intact when a later worker write conflicts", () => {
595
+ const database = databaseOptions();
596
+ const envelope = captureExecutionIdentityAdmissionEnvelope(facts("run-best-effort"), {
597
+ contextId: "context-best-effort",
598
+ executionId: "execution-best-effort",
599
+ runtimeInstanceId: "runtime-1",
600
+ });
601
+ persistExecutionIdentityAdmissionEnvelope(envelope, database);
602
+
603
+ expect(() =>
604
+ persistExecutionIdentityAdmissionEnvelope(
605
+ { ...envelope, agentId: "conflicting-agent" },
606
+ database,
607
+ ),
608
+ ).toThrow("execution identity context conflict");
609
+ });
610
+
611
+ it("rolls back the worker write when insert-time retention cleanup fails", () => {
612
+ const database = databaseOptions();
613
+ prepareExecutionIdentityContextAtAdmission(facts("run-expired"), {
614
+ ...database,
615
+ now: 0,
616
+ runtimeInstanceId: "runtime-1",
617
+ });
618
+ openOpenClawStateDatabase(database).db.exec(`
619
+ CREATE TRIGGER reject_identity_cleanup
620
+ BEFORE DELETE ON execution_identity_contexts
621
+ BEGIN
622
+ SELECT RAISE(ABORT, 'cleanup unavailable');
623
+ END;
624
+ `);
625
+
626
+ expect(() =>
627
+ prepareExecutionIdentityContextAtAdmission(facts("run-still-admitted"), {
628
+ ...database,
629
+ now: RETENTION_MS + 1,
630
+ runtimeInstanceId: "runtime-1",
631
+ }),
632
+ ).toThrow("cleanup unavailable");
633
+ });
634
+
635
+ it("stops projecting context and decisions immediately after the retention boundary", () => {
636
+ const database = databaseOptions();
637
+ const createdAt = 1_000;
638
+ prepareExecutionIdentityContextAtAdmission(facts("run-retention"), {
639
+ ...database,
640
+ now: createdAt,
641
+ contextId: "expired-context-secret",
642
+ executionId: "expired-execution-secret",
643
+ runtimeInstanceId: "expired-runtime-secret",
644
+ });
645
+
646
+ const immediatelyBefore = inspectExecutionIdentityRun(
647
+ { runId: "run-retention" },
648
+ { ...database, now: createdAt + RETENTION_MS - 1 },
649
+ );
650
+ expect(immediatelyBefore.identity).toMatchObject({
651
+ state: "present",
652
+ context: { contextId: "expired-context-secret" },
653
+ });
654
+ expect(immediatelyBefore.decisions).toHaveLength(1);
655
+ expect(
656
+ inspectExecutionIdentityRun(
657
+ { runId: "run-retention" },
658
+ { ...database, now: createdAt + RETENTION_MS },
659
+ ).identity.state,
660
+ ).toBe("present");
661
+
662
+ const immediatelyAfter = inspectExecutionIdentityRun(
663
+ { runId: "run-retention" },
664
+ { ...database, now: createdAt + RETENTION_MS + 1 },
665
+ );
666
+ expect(immediatelyAfter).toMatchObject({
667
+ run: { status: "known" },
668
+ identity: {
669
+ state: "unsupported",
670
+ reasonCode: "identity_context_unavailable",
671
+ remediation: [
672
+ expect.objectContaining({
673
+ code: "run_again_after_expiry",
674
+ text: expect.stringContaining("outside the 30-day window"),
675
+ }),
676
+ ],
677
+ },
678
+ decisions: [],
679
+ coverage: { state: "unsupported", missingEvidence: ["identity.context"] },
680
+ });
681
+ expect(JSON.stringify(immediatelyAfter)).not.toContain("expired-context-secret");
682
+ expect(JSON.stringify(immediatelyAfter)).not.toContain("expired-runtime-secret");
683
+ expect(JSON.stringify(immediatelyAfter)).not.toContain("run_admission_identity_not_evaluated");
684
+ const exactAfter = inspectExecutionIdentityRun(
685
+ { executionId: "expired-execution-secret" },
686
+ { ...database, now: createdAt + RETENTION_MS + 1 },
687
+ );
688
+ expect(exactAfter).toMatchObject({
689
+ run: { executionId: "expired-execution-secret", status: "known" },
690
+ identity: { state: "unsupported", reasonCode: "identity_context_unavailable" },
691
+ decisions: [],
692
+ });
693
+ expect(JSON.stringify(exactAfter)).not.toContain("expired-context-secret");
694
+ expect(JSON.stringify(exactAfter)).not.toContain("expired-runtime-secret");
695
+
696
+ closeOpenClawStateDatabaseForTest();
697
+ expect(
698
+ inspectExecutionIdentityRun(
699
+ { runId: "run-retention" },
700
+ { ...database, now: createdAt + RETENTION_MS + 1 },
701
+ ),
702
+ ).toEqual(immediatelyAfter);
703
+
704
+ expect(
705
+ pruneExpiredExecutionIdentityContexts({
706
+ database,
707
+ now: createdAt + RETENTION_MS + 1,
708
+ }),
709
+ ).toBe(1);
710
+ expect(
711
+ inspectExecutionIdentityRun(
712
+ { runId: "run-retention" },
713
+ { ...database, now: createdAt + RETENTION_MS + 1 },
714
+ ),
715
+ ).toMatchObject({
716
+ run: { status: "unknown" },
717
+ identity: {
718
+ state: "unknown",
719
+ reasonCode: "run_not_found",
720
+ remediation: [
721
+ expect.objectContaining({ text: expect.stringContaining("not proof of no run") }),
722
+ ],
723
+ },
724
+ decisions: [],
725
+ });
726
+ });
727
+
728
+ it("prunes expired contexts in bounded maintenance batches without new inserts", () => {
729
+ const database = databaseOptions();
730
+ prepareExecutionIdentityContextAtAdmission(facts("schema-seed"), {
731
+ ...database,
732
+ now: 1,
733
+ runtimeInstanceId: "runtime-1",
734
+ });
735
+ const { db } = openOpenClawStateDatabase(database);
736
+ db.exec("DELETE FROM execution_identity_contexts;");
737
+ db.prepare(
738
+ `WITH RECURSIVE rows(n) AS (
739
+ VALUES (1)
740
+ UNION ALL
741
+ SELECT n + 1 FROM rows WHERE n < 1025
742
+ )
743
+ INSERT INTO execution_identity_contexts (
744
+ context_id, execution_id, run_id, created_at, coverage_state, context_bytes, context_json
745
+ )
746
+ SELECT 'context-' || n, 'execution-' || n, 'run-' || n, 0, 'unattributed', 2, '{}'
747
+ FROM rows`,
748
+ ).run();
749
+
750
+ expect(pruneExpiredExecutionIdentityContexts({ database, now: RETENTION_MS + 1 })).toBe(1_024);
751
+ expect(db.prepare("SELECT COUNT(*) AS count FROM execution_identity_contexts").get()).toEqual({
752
+ count: 1,
753
+ });
754
+ expect(pruneExpiredExecutionIdentityContexts({ database, now: RETENTION_MS + 1 })).toBe(1);
755
+ expect(db.prepare("SELECT COUNT(*) AS count FROM execution_identity_contexts").get()).toEqual({
756
+ count: 0,
757
+ });
758
+ });
759
+
760
+ it("prunes retention and row-cap overflow in bounded batches", () => {
761
+ const retentionDatabase = databaseOptions();
762
+ for (const runId of ["old-1", "old-2", "old-3"]) {
763
+ prepareExecutionIdentityContextAtAdmission(facts(runId), {
764
+ ...retentionDatabase,
765
+ now: 0,
766
+ runtimeInstanceId: "runtime-1",
767
+ limits: { maxRows: 10, pruneBatchRows: 1 },
768
+ });
769
+ }
770
+ prepareExecutionIdentityContextAtAdmission(facts("new-1"), {
771
+ ...retentionDatabase,
772
+ now: RETENTION_MS + 1,
773
+ runtimeInstanceId: "runtime-1",
774
+ limits: { maxRows: 1, pruneBatchRows: 1 },
775
+ });
776
+ const retainedAfterOneBatch = openOpenClawStateDatabase(retentionDatabase)
777
+ .db.prepare("SELECT COUNT(*) AS count FROM execution_identity_contexts")
778
+ .get() as { count: number };
779
+ expect(retainedAfterOneBatch.count).toBe(3);
780
+
781
+ const capDatabase = databaseOptions();
782
+ for (const runId of ["cap-1", "cap-2", "cap-3"]) {
783
+ prepareExecutionIdentityContextAtAdmission(facts(runId), {
784
+ ...capDatabase,
785
+ now: 100,
786
+ contextId: `context-${runId}`,
787
+ runtimeInstanceId: "runtime-1",
788
+ limits: { maxRows: 2, pruneBatchRows: 1 },
789
+ });
790
+ }
791
+ const capped = openOpenClawStateDatabase(capDatabase)
792
+ .db.prepare("SELECT run_id FROM execution_identity_contexts ORDER BY context_id")
793
+ .all() as Array<{ run_id: string }>;
794
+ expect(capped).toHaveLength(2);
795
+ expect(capped.map((row) => row.run_id)).not.toContain("cap-1");
796
+ });
797
+
798
+ it("enforces the row cap across independent database connections", () => {
799
+ const database = databaseOptions();
800
+ const path = openOpenClawStateDatabase(database).path;
801
+ closeOpenClawStateDatabaseForTest();
802
+ const first = openIndependentStateDatabase(path);
803
+ const second = openIndependentStateDatabase(path);
804
+ try {
805
+ for (const [index, connection] of [first, second, first, second].entries()) {
806
+ const runId = `shared-cap-${index + 1}`;
807
+ prepareExecutionIdentityContextAtAdmission(facts(runId), {
808
+ database: connection,
809
+ now: 100 + index,
810
+ contextId: `context-${runId}`,
811
+ runtimeInstanceId: "runtime-1",
812
+ limits: { maxRows: 2, pruneBatchRows: 1 },
813
+ });
814
+ }
815
+
816
+ const retained = first.db
817
+ .prepare("SELECT run_id FROM execution_identity_contexts ORDER BY created_at")
818
+ .all() as Array<{ run_id: string }>;
819
+ expect(retained.map((row) => row.run_id)).toEqual(["shared-cap-3", "shared-cap-4"]);
820
+ } finally {
821
+ first.db.close();
822
+ second.db.close();
823
+ }
824
+ });
825
+
826
+ it("inspects through a read-only connection while another writer holds the database", () => {
827
+ const database = databaseOptions();
828
+ prepareExecutionIdentityContextAtAdmission(facts("held-lock-inspection"), {
829
+ ...database,
830
+ now: 100,
831
+ contextId: "context-held-lock-inspection",
832
+ executionId: "execution-held-lock-inspection",
833
+ runtimeInstanceId: "runtime-1",
834
+ });
835
+ const path = openOpenClawStateDatabase(database).path;
836
+ closeOpenClawStateDatabaseForTest();
837
+ const lockDatabase = openNodeSqliteDatabase(path);
838
+ lockDatabase.exec("BEGIN IMMEDIATE");
839
+ try {
840
+ expect(
841
+ inspectExecutionIdentityRun(
842
+ { executionId: "execution-held-lock-inspection" },
843
+ { ...database, now: 100 },
844
+ ),
845
+ ).toMatchObject({
846
+ identity: {
847
+ state: "present",
848
+ context: {
849
+ contextId: "context-held-lock-inspection",
850
+ executionId: "execution-held-lock-inspection",
851
+ runId: "held-lock-inspection",
852
+ },
853
+ },
854
+ });
855
+ } finally {
856
+ lockDatabase.exec("ROLLBACK");
857
+ lockDatabase.close();
858
+ }
859
+ });
860
+
861
+ it("returns typed corrupt, unknown, and unsupported projections", () => {
862
+ const corruptDatabase = databaseOptions();
863
+ prepareExecutionIdentityContextAtAdmission(facts("run-corrupt"), {
864
+ ...corruptDatabase,
865
+ runtimeInstanceId: "runtime-1",
866
+ });
867
+ openOpenClawStateDatabase(corruptDatabase)
868
+ .db.prepare("UPDATE execution_identity_contexts SET context_json = ? WHERE run_id = ?")
869
+ .run("{", "run-corrupt");
870
+ expect(inspectExecutionIdentityRun({ runId: "run-corrupt" }, corruptDatabase)).toMatchObject({
871
+ run: { status: "known" },
872
+ identity: { state: "unknown", reasonCode: "identity_context_corrupt" },
873
+ coverage: { state: "unknown" },
874
+ });
875
+
876
+ const unknownDatabase = databaseOptions();
877
+ expect(inspectExecutionIdentityRun({ runId: "never-seen" }, unknownDatabase)).toMatchObject({
878
+ run: { status: "unknown" },
879
+ identity: {
880
+ state: "unknown",
881
+ reasonCode: "run_not_found",
882
+ remediation: [expect.objectContaining({ code: "verify_run_id" })],
883
+ },
884
+ });
885
+
886
+ recordAuditEvent(
887
+ {
888
+ sourceId: "legacy-run:1",
889
+ sourceSequence: 1,
890
+ occurredAt: Date.now(),
891
+ kind: "agent_run",
892
+ action: "agent.run.started",
893
+ status: "started",
894
+ actorType: "agent",
895
+ actorId: "main",
896
+ agentId: "main",
897
+ runId: "legacy-run",
898
+ },
899
+ unknownDatabase,
900
+ );
901
+ expect(inspectExecutionIdentityRun({ runId: "legacy-run" }, unknownDatabase)).toMatchObject({
902
+ run: { status: "known" },
903
+ identity: {
904
+ state: "unsupported",
905
+ reasonCode: "identity_context_unavailable",
906
+ remediation: [expect.objectContaining({ code: "record_new_identity_context" })],
907
+ },
908
+ });
909
+ });
910
+
911
+ it("projects one non-enforcement admission explanation", () => {
912
+ const database = databaseOptions();
913
+ prepareExecutionIdentityContextAtAdmission(facts("run-receipt"), {
914
+ ...database,
915
+ now: 123,
916
+ contextId: "context-receipt",
917
+ runtimeInstanceId: "runtime-1",
918
+ });
919
+ const result = inspectExecutionIdentityRun({ runId: "run-receipt" }, { ...database, now: 123 });
920
+
921
+ expect(result.identity).toMatchObject({
922
+ state: "present",
923
+ context: { contextId: "context-receipt", coverageState: "unattributed" },
924
+ });
925
+ expect(result.decisions).toEqual([
926
+ expect.objectContaining({
927
+ decision: {
928
+ outcome: "not-applicable",
929
+ reasonCode: "run_admission_identity_not_evaluated",
930
+ },
931
+ enforcement: expect.objectContaining({
932
+ coverageState: "unattributed",
933
+ policyRefs: [],
934
+ grantRefs: [],
935
+ contextFieldsUsed: [],
936
+ }),
937
+ }),
938
+ ]);
939
+ expect(
940
+ inspectExecutionIdentityRun(
941
+ { runId: "run-receipt", decisionLimit: 1 },
942
+ { ...database, now: 123 },
943
+ ).nextDecisionCursor,
944
+ ).toBeUndefined();
945
+ expect(
946
+ inspectExecutionIdentityRun(
947
+ { runId: "run-receipt", decisionCursor: "a:0:0" },
948
+ { ...database, now: 123 },
949
+ ).decisions,
950
+ ).toEqual([]);
951
+ });
952
+
953
+ it("projects an authoritative denied approval by run before and after restart", () => {
954
+ const database = databaseOptions();
955
+ prepareExecutionIdentityContextAtAdmission(facts("run-denied-receipt"), {
956
+ ...database,
957
+ now: 100,
958
+ contextId: "context-denied-receipt",
959
+ executionId: "execution-denied-receipt",
960
+ runtimeInstanceId: "runtime-1",
961
+ });
962
+ recordDeniedApprovalForRun("run-denied-receipt", database, "denied-approval", {
963
+ contextId: "context-denied-receipt",
964
+ executionId: "execution-denied-receipt",
965
+ });
966
+
967
+ const beforeRestart = inspectExecutionIdentityRun(
968
+ { runId: "run-denied-receipt" },
969
+ { ...database, now: 300 },
970
+ );
971
+ expect(beforeRestart).toMatchObject({
972
+ coverage: { state: "enforced" },
973
+ decisions: [
974
+ { decision: { outcome: "not-applicable" } },
975
+ {
976
+ contextId: "context-denied-receipt",
977
+ executionId: "execution-denied-receipt",
978
+ runId: "run-denied-receipt",
979
+ decision: {
980
+ outcome: "denied",
981
+ reasonCode: "operator_approval_denied_by_reviewer",
982
+ },
983
+ enforcement: {
984
+ coverageState: "enforced",
985
+ contextFieldsUsed: ["contextId", "executionId", "runId"],
986
+ },
987
+ source: { owner: "operator_approvals" },
988
+ },
989
+ ],
990
+ });
991
+ expect(JSON.stringify(beforeRestart)).not.toContain("private-reviewer-device");
992
+ expect(JSON.stringify(beforeRestart)).not.toContain("private-tool-call");
993
+
994
+ closeOpenClawStateDatabaseForTest();
995
+ expect(
996
+ inspectExecutionIdentityRun({ runId: "run-denied-receipt" }, { ...database, now: 300 }),
997
+ ).toEqual(beforeRestart);
998
+ expect(
999
+ inspectExecutionIdentityRun(
1000
+ { runId: "run-denied-receipt", decisionCursor: "a:0:0", decisionLimit: 1 },
1001
+ { ...database, now: 300 },
1002
+ ),
1003
+ ).toMatchObject({
1004
+ decisions: [{ decision: { reasonCode: "operator_approval_denied_by_reviewer" } }],
1005
+ });
1006
+ expect(
1007
+ inspectExecutionIdentityRun(
1008
+ { runId: "run-denied-receipt", decisionCursor: "1", decisionLimit: 1 },
1009
+ { ...database, now: 300 },
1010
+ ).decisions,
1011
+ ).toMatchObject([{ decision: { reasonCode: "operator_approval_denied_by_reviewer" } }]);
1012
+ });
1013
+
1014
+ it("keeps a corrupt approval unknown before its decision page is returned", () => {
1015
+ const database = databaseOptions();
1016
+ prepareExecutionIdentityContextAtAdmission(facts("run-corrupt-approval"), {
1017
+ ...database,
1018
+ now: 100,
1019
+ contextId: "context-corrupt-approval",
1020
+ executionId: "execution-corrupt-approval",
1021
+ runtimeInstanceId: "runtime-1",
1022
+ });
1023
+ recordDeniedApprovalForRun("run-corrupt-approval", database, "corrupt-approval", {
1024
+ contextId: "context-corrupt-approval",
1025
+ executionId: "execution-corrupt-approval",
1026
+ });
1027
+ openOpenClawStateDatabase(database)
1028
+ .db.prepare("UPDATE operator_approvals SET presentation_json = ? WHERE approval_id = ?")
1029
+ .run("{", "corrupt-approval");
1030
+
1031
+ expect(
1032
+ inspectExecutionIdentityRun(
1033
+ { executionId: "execution-corrupt-approval", decisionLimit: 1 },
1034
+ { ...database, now: 300 },
1035
+ ),
1036
+ ).toMatchObject({
1037
+ coverage: {
1038
+ state: "unknown",
1039
+ missingEvidence: expect.arrayContaining(["operator_approval.valid"]),
1040
+ },
1041
+ decisions: [{ decision: { outcome: "not-applicable" } }],
1042
+ nextDecisionCursor: "a:0:0",
1043
+ });
1044
+ });
1045
+
1046
+ it("reports a retained approval with no identity context as an unknown missing link", () => {
1047
+ const database = databaseOptions();
1048
+ recordDeniedApprovalForRun("run-missing-context", database);
1049
+
1050
+ expect(
1051
+ inspectExecutionIdentityRun({ runId: "run-missing-context" }, { ...database, now: 300 }),
1052
+ ).toMatchObject({
1053
+ run: { runId: "run-missing-context", status: "known" },
1054
+ identity: {
1055
+ state: "unknown",
1056
+ reasonCode: "decision_context_link_missing",
1057
+ },
1058
+ decisions: [],
1059
+ coverage: {
1060
+ state: "unknown",
1061
+ missingEvidence: ["identity.context", "decision.context_link"],
1062
+ },
1063
+ });
1064
+ });
1065
+ });
src/audit/execution-owner-lifecycle-binding-store.ts ADDED
@@ -0,0 +1,121 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { DatabaseSync } from "node:sqlite";
2
+ import {
3
+ executeSqliteQuerySync,
4
+ executeSqliteQueryTakeFirstSync,
5
+ getNodeSqliteKysely,
6
+ } from "../infra/kysely-sync.js";
7
+ import { tableExists } from "../state/openclaw-state-db-schema-helpers.js";
8
+ import type { DB as OpenClawStateDatabase } from "../state/openclaw-state-db.generated.js";
9
+ import { OPENCLAW_STATE_SCHEMA_SQL } from "../state/openclaw-state-schema.js";
10
+ import {
11
+ classifyExecutionOwnerBinding,
12
+ type ExecutionOwnerBindingResult,
13
+ } from "./execution-owner-binding.js";
14
+
15
+ export const EXECUTION_OWNER_LIFECYCLE_BINDING_TABLE =
16
+ "execution_owner_lifecycle_bindings" as const;
17
+ type ExecutionOwnerLifecycleKind = "cron" | "task" | "flow";
18
+
19
+ type ExecutionOwnerLifecycleDatabase = Pick<
20
+ OpenClawStateDatabase,
21
+ "execution_owner_lifecycle_bindings"
22
+ >;
23
+
24
+ const SCHEMA_START = `CREATE TABLE IF NOT EXISTS ${EXECUTION_OWNER_LIFECYCLE_BINDING_TABLE} (`;
25
+ const SCHEMA_END = ") STRICT;";
26
+
27
+ function lifecycleDb(db: DatabaseSync) {
28
+ return getNodeSqliteKysely<ExecutionOwnerLifecycleDatabase>(db);
29
+ }
30
+
31
+ /** Creates only the canonical additive metadata table at first admitted owner use. */
32
+ function ensureExecutionOwnerLifecycleBindingSchema(db: DatabaseSync): void {
33
+ if (tableExists(db, EXECUTION_OWNER_LIFECYCLE_BINDING_TABLE)) {
34
+ return;
35
+ }
36
+ const start = OPENCLAW_STATE_SCHEMA_SQL.indexOf(SCHEMA_START);
37
+ const end = start < 0 ? -1 : OPENCLAW_STATE_SCHEMA_SQL.indexOf(SCHEMA_END, start);
38
+ if (start < 0 || end < start) {
39
+ throw new Error("OpenClaw execution owner lifecycle binding schema marker is missing.");
40
+ }
41
+ // sqlite-allow-raw -- Canonical feature-local additive DDL only; metadata rows use Kysely.
42
+ db.exec(OPENCLAW_STATE_SCHEMA_SQL.slice(start, end + SCHEMA_END.length));
43
+ }
44
+
45
+ function classifyRetainedBinding(
46
+ current: { context_id: string; execution_id: string },
47
+ binding: { contextId: string; executionId: string },
48
+ ): "already-bound" | "mismatch" {
49
+ const state = classifyExecutionOwnerBinding(
50
+ { contextId: current.context_id, executionId: current.execution_id },
51
+ binding,
52
+ );
53
+ return state === "unbound" ? "mismatch" : state;
54
+ }
55
+
56
+ /** Stores one exact admission identity after its canonical owner row has been revalidated. */
57
+ export function bindExecutionOwnerLifecycleMetadata(params: {
58
+ db: DatabaseSync;
59
+ ownerKind: ExecutionOwnerLifecycleKind;
60
+ ownerId: string;
61
+ binding: { contextId: string; executionId: string };
62
+ }): Exclude<ExecutionOwnerBindingResult, "disabled" | "missing"> {
63
+ ensureExecutionOwnerLifecycleBindingSchema(params.db);
64
+ const database = lifecycleDb(params.db);
65
+ const current = executeSqliteQueryTakeFirstSync(
66
+ params.db,
67
+ database
68
+ .selectFrom(EXECUTION_OWNER_LIFECYCLE_BINDING_TABLE)
69
+ .select(["context_id", "execution_id"])
70
+ .where("owner_kind", "=", params.ownerKind)
71
+ .where("owner_id", "=", params.ownerId),
72
+ );
73
+ if (current) {
74
+ return classifyRetainedBinding(current, params.binding);
75
+ }
76
+ const inserted = executeSqliteQuerySync(
77
+ params.db,
78
+ database
79
+ .insertInto(EXECUTION_OWNER_LIFECYCLE_BINDING_TABLE)
80
+ .values({
81
+ owner_kind: params.ownerKind,
82
+ owner_id: params.ownerId,
83
+ context_id: params.binding.contextId,
84
+ execution_id: params.binding.executionId,
85
+ })
86
+ .onConflict((conflict) => conflict.columns(["owner_kind", "owner_id"]).doNothing()),
87
+ );
88
+ if (Number(inserted.numAffectedRows ?? 0n) === 1) {
89
+ return "bound";
90
+ }
91
+ const raced = executeSqliteQueryTakeFirstSync(
92
+ params.db,
93
+ database
94
+ .selectFrom(EXECUTION_OWNER_LIFECYCLE_BINDING_TABLE)
95
+ .select(["context_id", "execution_id"])
96
+ .where("owner_kind", "=", params.ownerKind)
97
+ .where("owner_id", "=", params.ownerId),
98
+ );
99
+ return raced ? classifyRetainedBinding(raced, params.binding) : "mismatch";
100
+ }
101
+
102
+ /** Removes exact owner metadata without allocating the opt-in table. */
103
+ export function deleteExecutionOwnerLifecycleMetadata(params: {
104
+ db: DatabaseSync;
105
+ ownerKind: ExecutionOwnerLifecycleKind;
106
+ ownerIds: readonly string[];
107
+ }): void {
108
+ if (
109
+ params.ownerIds.length === 0 ||
110
+ !tableExists(params.db, EXECUTION_OWNER_LIFECYCLE_BINDING_TABLE)
111
+ ) {
112
+ return;
113
+ }
114
+ executeSqliteQuerySync(
115
+ params.db,
116
+ lifecycleDb(params.db)
117
+ .deleteFrom(EXECUTION_OWNER_LIFECYCLE_BINDING_TABLE)
118
+ .where("owner_kind", "=", params.ownerKind)
119
+ .where("owner_id", "in", params.ownerIds),
120
+ );
121
+ }
src/audit/execution-owner-lifecycle-receipts.ts ADDED
@@ -0,0 +1,445 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** Static owner-native cron/task/flow lifecycle projection for run inspection. */
2
+ import type { DatabaseSync } from "node:sqlite";
3
+ import type {
4
+ DecisionReceiptV1,
5
+ ExecutionIdentityContextV1,
6
+ } from "../../packages/gateway-protocol/src/index.js";
7
+ import {
8
+ executeSqliteQuerySync,
9
+ executeSqliteQueryTakeFirstSync,
10
+ getNodeSqliteKysely,
11
+ } from "../infra/kysely-sync.js";
12
+ import { runSqliteDeferredTransactionSync } from "../infra/sqlite-transaction.js";
13
+ import { withExistingOpenClawStateDatabaseReadOnly } from "../state/openclaw-state-db-readonly.js";
14
+ import { tableExists } from "../state/openclaw-state-db-schema-helpers.js";
15
+ import type { DB as OpenClawStateDatabase } from "../state/openclaw-state-db.generated.js";
16
+ import type { OpenClawStateDatabaseOptions } from "../state/openclaw-state-db.js";
17
+ import { EXECUTION_OWNER_LIFECYCLE_BINDING_TABLE } from "./execution-owner-lifecycle-binding-store.js";
18
+
19
+ type WithSqliteRowId<Row> = Row & { rowid: number };
20
+ type OwnerLifecycleDatabase = {
21
+ cron_run_receipts: WithSqliteRowId<OpenClawStateDatabase["cron_run_receipts"]>;
22
+ execution_owner_lifecycle_bindings: OpenClawStateDatabase["execution_owner_lifecycle_bindings"];
23
+ flow_runs: WithSqliteRowId<OpenClawStateDatabase["flow_runs"]>;
24
+ task_runs: WithSqliteRowId<OpenClawStateDatabase["task_runs"]>;
25
+ };
26
+ export type OwnerLifecycleStage = "cron" | "task" | "flow";
27
+ export type OwnerLifecycleCursor = { occurredAt: number; rowId: number };
28
+ type OwnerLifecycleDisplayProducer = "cron-lifecycle" | "task-lifecycle" | "flow-lifecycle";
29
+ type OwnerLifecycleReceiptEntry = {
30
+ receipt: DecisionReceiptV1;
31
+ selectorId: string;
32
+ displayProducer: OwnerLifecycleDisplayProducer;
33
+ };
34
+ type OwnerLifecycleRow = {
35
+ executionId: string | null;
36
+ occurredAt: number;
37
+ owner: "cron_run_receipts" | "task_runs" | "flow_runs";
38
+ recordId: string;
39
+ rowId: number;
40
+ status: string;
41
+ };
42
+
43
+ const KNOWN_STATUSES: Record<OwnerLifecycleStage, ReadonlySet<string>> = {
44
+ cron: new Set(["running", "ok", "error", "skipped", "interrupted", "superseded"]),
45
+ task: new Set([
46
+ "queued",
47
+ "running",
48
+ "succeeded",
49
+ "failed",
50
+ "timed_out",
51
+ "cancelled",
52
+ "lost",
53
+ "blocked",
54
+ ]),
55
+ flow: new Set([
56
+ "queued",
57
+ "running",
58
+ "waiting",
59
+ "blocked",
60
+ "succeeded",
61
+ "failed",
62
+ "cancelled",
63
+ "lost",
64
+ ]),
65
+ };
66
+ const OWNER_LIFECYCLE_CURSOR_RETAINED_ERROR = "owner lifecycle cursor is no longer retained";
67
+
68
+ function ownerName(stage: OwnerLifecycleStage): OwnerLifecycleRow["owner"] {
69
+ return stage === "cron" ? "cron_run_receipts" : stage === "task" ? "task_runs" : "flow_runs";
70
+ }
71
+
72
+ function displayProducer(stage: OwnerLifecycleStage): OwnerLifecycleDisplayProducer {
73
+ return stage === "cron"
74
+ ? "cron-lifecycle"
75
+ : stage === "task"
76
+ ? "task-lifecycle"
77
+ : "flow-lifecycle";
78
+ }
79
+
80
+ function assertRetainedCursor(params: {
81
+ db: DatabaseSync;
82
+ stage: OwnerLifecycleStage;
83
+ contextId: string;
84
+ executionId: string;
85
+ after?: OwnerLifecycleCursor;
86
+ }): void {
87
+ if (!params.after) {
88
+ return;
89
+ }
90
+ const kysely = getNodeSqliteKysely<OwnerLifecycleDatabase>(params.db);
91
+ const ownerQuery =
92
+ params.stage === "cron"
93
+ ? kysely
94
+ .selectFrom("cron_run_receipts")
95
+ .select("receipt_id as ownerId")
96
+ .where("rowid", "=", params.after.rowId)
97
+ .where("started_at_ms", "=", params.after.occurredAt)
98
+ : params.stage === "task"
99
+ ? kysely
100
+ .selectFrom("task_runs")
101
+ .select("task_id as ownerId")
102
+ .where("rowid", "=", params.after.rowId)
103
+ .where("created_at", "=", params.after.occurredAt)
104
+ : kysely
105
+ .selectFrom("flow_runs")
106
+ .select("flow_id as ownerId")
107
+ .where("rowid", "=", params.after.rowId)
108
+ .where("created_at", "=", params.after.occurredAt);
109
+ const owner = executeSqliteQueryTakeFirstSync(params.db, ownerQuery);
110
+ // Admission binds at most one owner per lifecycle kind to an execution.
111
+ // The exact execution match therefore rejects a different owner reusing this rowid.
112
+ const binding = owner
113
+ ? executeSqliteQueryTakeFirstSync(
114
+ params.db,
115
+ kysely
116
+ .selectFrom(EXECUTION_OWNER_LIFECYCLE_BINDING_TABLE)
117
+ .select("owner_id")
118
+ .where("owner_kind", "=", params.stage)
119
+ .where("owner_id", "=", owner.ownerId)
120
+ .where("context_id", "=", params.contextId)
121
+ .where("execution_id", "=", params.executionId),
122
+ )
123
+ : undefined;
124
+ if (!binding) {
125
+ throw new Error(OWNER_LIFECYCLE_CURSOR_RETAINED_ERROR);
126
+ }
127
+ }
128
+
129
+ function readRows(params: {
130
+ db: DatabaseSync;
131
+ stage: OwnerLifecycleStage;
132
+ contextId: string;
133
+ executionId: string;
134
+ after?: OwnerLifecycleCursor;
135
+ offset?: number;
136
+ limit: number;
137
+ }): OwnerLifecycleRow[] {
138
+ const owner = ownerName(params.stage);
139
+ if (
140
+ !tableExists(params.db, owner) ||
141
+ !tableExists(params.db, EXECUTION_OWNER_LIFECYCLE_BINDING_TABLE)
142
+ ) {
143
+ if (params.after) {
144
+ throw new Error(OWNER_LIFECYCLE_CURSOR_RETAINED_ERROR);
145
+ }
146
+ return [];
147
+ }
148
+ assertRetainedCursor(params);
149
+ const kysely = getNodeSqliteKysely<OwnerLifecycleDatabase>(params.db);
150
+ if (params.stage === "cron") {
151
+ let query = kysely
152
+ .selectFrom("cron_run_receipts")
153
+ .innerJoin(EXECUTION_OWNER_LIFECYCLE_BINDING_TABLE, (join) =>
154
+ join
155
+ .onRef("execution_owner_lifecycle_bindings.owner_id", "=", "cron_run_receipts.receipt_id")
156
+ .on("execution_owner_lifecycle_bindings.owner_kind", "=", "cron"),
157
+ )
158
+ .select([
159
+ "cron_run_receipts.receipt_id as recordId",
160
+ "execution_owner_lifecycle_bindings.execution_id as executionId",
161
+ "cron_run_receipts.started_at_ms as occurredAt",
162
+ "cron_run_receipts.status",
163
+ "cron_run_receipts.rowid as rowId",
164
+ ])
165
+ .where("execution_owner_lifecycle_bindings.context_id", "=", params.contextId)
166
+ .orderBy("cron_run_receipts.started_at_ms", "asc")
167
+ .orderBy("cron_run_receipts.rowid", "asc")
168
+ .limit(params.limit);
169
+ if (params.after) {
170
+ query = query.where((eb) =>
171
+ eb.or([
172
+ eb("cron_run_receipts.started_at_ms", ">", params.after!.occurredAt),
173
+ eb.and([
174
+ eb("cron_run_receipts.started_at_ms", "=", params.after!.occurredAt),
175
+ eb("cron_run_receipts.rowid", ">", params.after!.rowId),
176
+ ]),
177
+ ]),
178
+ );
179
+ } else if (params.offset) {
180
+ query = query.offset(params.offset);
181
+ }
182
+ return executeSqliteQuerySync(params.db, query).rows.map((row) =>
183
+ Object.assign(row, { owner }),
184
+ );
185
+ }
186
+ if (params.stage === "task") {
187
+ let query = kysely
188
+ .selectFrom("task_runs")
189
+ .innerJoin(EXECUTION_OWNER_LIFECYCLE_BINDING_TABLE, (join) =>
190
+ join
191
+ .onRef("execution_owner_lifecycle_bindings.owner_id", "=", "task_runs.task_id")
192
+ .on("execution_owner_lifecycle_bindings.owner_kind", "=", "task"),
193
+ )
194
+ .select([
195
+ "task_runs.task_id as recordId",
196
+ "execution_owner_lifecycle_bindings.execution_id as executionId",
197
+ "task_runs.created_at as occurredAt",
198
+ "task_runs.status",
199
+ "task_runs.terminal_outcome as terminalOutcome",
200
+ "task_runs.rowid as rowId",
201
+ ])
202
+ .where("execution_owner_lifecycle_bindings.context_id", "=", params.contextId)
203
+ .orderBy("task_runs.created_at", "asc")
204
+ .orderBy("task_runs.rowid", "asc")
205
+ .limit(params.limit);
206
+ if (params.after) {
207
+ query = query.where((eb) =>
208
+ eb.or([
209
+ eb("task_runs.created_at", ">", params.after!.occurredAt),
210
+ eb.and([
211
+ eb("task_runs.created_at", "=", params.after!.occurredAt),
212
+ eb("task_runs.rowid", ">", params.after!.rowId),
213
+ ]),
214
+ ]),
215
+ );
216
+ } else if (params.offset) {
217
+ query = query.offset(params.offset);
218
+ }
219
+ return executeSqliteQuerySync(params.db, query).rows.map((row) =>
220
+ Object.assign(row, {
221
+ owner,
222
+ status: row.terminalOutcome === "blocked" ? "blocked" : row.status,
223
+ }),
224
+ );
225
+ }
226
+ let query = kysely
227
+ .selectFrom("flow_runs")
228
+ .innerJoin(EXECUTION_OWNER_LIFECYCLE_BINDING_TABLE, (join) =>
229
+ join
230
+ .onRef("execution_owner_lifecycle_bindings.owner_id", "=", "flow_runs.flow_id")
231
+ .on("execution_owner_lifecycle_bindings.owner_kind", "=", "flow"),
232
+ )
233
+ .select([
234
+ "flow_runs.flow_id as recordId",
235
+ "execution_owner_lifecycle_bindings.execution_id as executionId",
236
+ "flow_runs.created_at as occurredAt",
237
+ "flow_runs.status",
238
+ "flow_runs.rowid as rowId",
239
+ ])
240
+ .where("execution_owner_lifecycle_bindings.context_id", "=", params.contextId)
241
+ .orderBy("flow_runs.created_at", "asc")
242
+ .orderBy("flow_runs.rowid", "asc")
243
+ .limit(params.limit);
244
+ if (params.after) {
245
+ query = query.where((eb) =>
246
+ eb.or([
247
+ eb("flow_runs.created_at", ">", params.after!.occurredAt),
248
+ eb.and([
249
+ eb("flow_runs.created_at", "=", params.after!.occurredAt),
250
+ eb("flow_runs.rowid", ">", params.after!.rowId),
251
+ ]),
252
+ ]),
253
+ );
254
+ } else if (params.offset) {
255
+ query = query.offset(params.offset);
256
+ }
257
+ return executeSqliteQuerySync(params.db, query).rows.map((row) => Object.assign(row, { owner }));
258
+ }
259
+
260
+ function countRows(params: {
261
+ db: DatabaseSync;
262
+ stage: OwnerLifecycleStage;
263
+ contextId: string;
264
+ executionId?: string;
265
+ }): number {
266
+ const owner = ownerName(params.stage);
267
+ if (
268
+ !tableExists(params.db, owner) ||
269
+ !tableExists(params.db, EXECUTION_OWNER_LIFECYCLE_BINDING_TABLE)
270
+ ) {
271
+ return 0;
272
+ }
273
+ const kysely = getNodeSqliteKysely<OwnerLifecycleDatabase>(params.db);
274
+ const query =
275
+ params.stage === "cron"
276
+ ? kysely
277
+ .selectFrom("cron_run_receipts")
278
+ .innerJoin(EXECUTION_OWNER_LIFECYCLE_BINDING_TABLE, (join) =>
279
+ join
280
+ .onRef(
281
+ "execution_owner_lifecycle_bindings.owner_id",
282
+ "=",
283
+ "cron_run_receipts.receipt_id",
284
+ )
285
+ .on("execution_owner_lifecycle_bindings.owner_kind", "=", "cron"),
286
+ )
287
+ .select((eb) => eb.fn.countAll<number>().as("count"))
288
+ .where("execution_owner_lifecycle_bindings.context_id", "=", params.contextId)
289
+ .$if(params.executionId !== undefined, (qb) =>
290
+ qb.where("execution_owner_lifecycle_bindings.execution_id", "=", params.executionId!),
291
+ )
292
+ : params.stage === "task"
293
+ ? kysely
294
+ .selectFrom("task_runs")
295
+ .innerJoin(EXECUTION_OWNER_LIFECYCLE_BINDING_TABLE, (join) =>
296
+ join
297
+ .onRef("execution_owner_lifecycle_bindings.owner_id", "=", "task_runs.task_id")
298
+ .on("execution_owner_lifecycle_bindings.owner_kind", "=", "task"),
299
+ )
300
+ .select((eb) => eb.fn.countAll<number>().as("count"))
301
+ .where("execution_owner_lifecycle_bindings.context_id", "=", params.contextId)
302
+ .$if(params.executionId !== undefined, (qb) =>
303
+ qb.where("execution_owner_lifecycle_bindings.execution_id", "=", params.executionId!),
304
+ )
305
+ : kysely
306
+ .selectFrom("flow_runs")
307
+ .innerJoin(EXECUTION_OWNER_LIFECYCLE_BINDING_TABLE, (join) =>
308
+ join
309
+ .onRef("execution_owner_lifecycle_bindings.owner_id", "=", "flow_runs.flow_id")
310
+ .on("execution_owner_lifecycle_bindings.owner_kind", "=", "flow"),
311
+ )
312
+ .select((eb) => eb.fn.countAll<number>().as("count"))
313
+ .where("execution_owner_lifecycle_bindings.context_id", "=", params.contextId)
314
+ .$if(params.executionId !== undefined, (qb) =>
315
+ qb.where("execution_owner_lifecycle_bindings.execution_id", "=", params.executionId!),
316
+ );
317
+ return executeSqliteQueryTakeFirstSync(params.db, query)?.count ?? 0;
318
+ }
319
+
320
+ function projectReceipt(
321
+ stage: OwnerLifecycleStage,
322
+ row: OwnerLifecycleRow,
323
+ context: ExecutionIdentityContextV1,
324
+ ): DecisionReceiptV1 {
325
+ const exact = row.executionId === context.executionId;
326
+ const known = KNOWN_STATUSES[stage].has(row.status);
327
+ const valid = exact && known;
328
+ const missingEvidence = valid
329
+ ? []
330
+ : [exact ? `decision.${stage}_owner_status` : "decision.execution_link"];
331
+ return {
332
+ schemaVersion: 1,
333
+ receiptId: `${stage}:${row.recordId}`,
334
+ contextId: context.contextId,
335
+ executionId: context.executionId,
336
+ runId: context.runId,
337
+ actionId: row.recordId,
338
+ occurredAt: row.occurredAt,
339
+ action: {
340
+ family: stage === "cron" ? "scheduled-run" : stage === "task" ? "task" : "flow",
341
+ operation: "lifecycle",
342
+ summary: valid
343
+ ? `${stage === "cron" ? "Scheduled run" : stage === "task" ? "Task" : "Flow"} lifecycle: ${row.status.replaceAll("_", "-")}.`
344
+ : "Owner lifecycle evidence could not be matched exactly.",
345
+ },
346
+ decision: {
347
+ outcome: valid ? "not-applicable" : "unknown",
348
+ reasonCode: valid
349
+ ? `${stage}_run_${row.status}`
350
+ : exact
351
+ ? `${stage}_run_status_unknown`
352
+ : `${stage}_run_execution_link_mismatch`,
353
+ },
354
+ enforcement: {
355
+ coverageState: valid ? "attribution-only" : "unknown",
356
+ evaluatorRef: `${stage}-lifecycle-owner`,
357
+ policyRefs: [],
358
+ grantRefs: [],
359
+ contextFieldsUsed: ["contextId", "executionId"],
360
+ },
361
+ source: {
362
+ owner: row.owner,
363
+ recordRef: row.recordId,
364
+ decisionBoundary: `${stage}.run.lifecycle`,
365
+ },
366
+ missingEvidence,
367
+ remediation: valid
368
+ ? []
369
+ : [
370
+ {
371
+ code: "inspect_owner_execution_binding",
372
+ text: "Inspect the owner row and its exact admission binding before drawing a lifecycle conclusion.",
373
+ },
374
+ ],
375
+ };
376
+ }
377
+
378
+ export function summarizeOwnerLifecycleReceipts(params: {
379
+ stage: OwnerLifecycleStage;
380
+ context: ExecutionIdentityContextV1;
381
+ options: OpenClawStateDatabaseOptions;
382
+ }): { count: number; coverageState?: "attribution-only" | "unknown"; missingEvidence: string[] } {
383
+ return (
384
+ withExistingOpenClawStateDatabaseReadOnly(({ db }) => {
385
+ const count = countRows({ db, stage: params.stage, contextId: params.context.contextId });
386
+ const exactCount = countRows({
387
+ db,
388
+ stage: params.stage,
389
+ contextId: params.context.contextId,
390
+ executionId: params.context.executionId,
391
+ });
392
+ const mismatch = count !== exactCount;
393
+ return {
394
+ count,
395
+ ...(count > 0
396
+ ? { coverageState: mismatch ? ("unknown" as const) : ("attribution-only" as const) }
397
+ : {}),
398
+ missingEvidence: mismatch ? ["decision.execution_link"] : [],
399
+ };
400
+ }, params.options) ?? { count: 0, missingEvidence: [] }
401
+ );
402
+ }
403
+
404
+ export function pageOwnerLifecycleReceipts(params: {
405
+ stage: OwnerLifecycleStage;
406
+ context: ExecutionIdentityContextV1;
407
+ after?: OwnerLifecycleCursor;
408
+ offset?: number;
409
+ limit: number;
410
+ options: OpenClawStateDatabaseOptions;
411
+ }): { entries: OwnerLifecycleReceiptEntry[]; nextCursor?: OwnerLifecycleCursor } {
412
+ const retainedRows = withExistingOpenClawStateDatabaseReadOnly(
413
+ ({ db }) =>
414
+ runSqliteDeferredTransactionSync(
415
+ db,
416
+ () =>
417
+ readRows({
418
+ db,
419
+ stage: params.stage,
420
+ contextId: params.context.contextId,
421
+ executionId: params.context.executionId,
422
+ after: params.after,
423
+ offset: params.offset,
424
+ limit: params.limit + 1,
425
+ }),
426
+ { operationLabel: "owner lifecycle receipt page" },
427
+ ),
428
+ params.options,
429
+ );
430
+ if (!retainedRows && params.after) {
431
+ throw new Error(OWNER_LIFECYCLE_CURSOR_RETAINED_ERROR);
432
+ }
433
+ const rows = retainedRows ?? [];
434
+ const hasMore = rows.length > params.limit;
435
+ const page = hasMore ? rows.slice(0, params.limit) : rows;
436
+ const last = page.at(-1);
437
+ return {
438
+ entries: page.map((row) => ({
439
+ receipt: projectReceipt(params.stage, row, params.context),
440
+ selectorId: `${params.stage}-lifecycle:${row.occurredAt}:${row.rowId}`,
441
+ displayProducer: displayProducer(params.stage),
442
+ })),
443
+ ...(hasMore && last ? { nextCursor: { occurredAt: last.occurredAt, rowId: last.rowId } } : {}),
444
+ };
445
+ }
src/audit/message-audit-events.ts ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { resolveGlobalSet } from "../shared/global-singleton.js";
2
+ /** Trusted in-process message lifecycle stream for durable audit projection. */
3
+ import { notifyListeners, registerListener } from "../shared/listeners.js";
4
+ import type { MessageAuditEventInput } from "./audit-event-types.js";
5
+
6
+ type TrustedMessageAuditEventVariant<Event> = Event extends MessageAuditEventInput
7
+ ? Omit<Event, "sourceId" | "sourceSequence"> & {
8
+ /** Optional stable producer identity for replay-safe durable projections. */
9
+ sourceId?: string;
10
+ }
11
+ : never;
12
+
13
+ export type TrustedMessageAuditEvent = TrustedMessageAuditEventVariant<MessageAuditEventInput>;
14
+
15
+ type MessageAuditListener = (event: TrustedMessageAuditEvent) => void;
16
+
17
+ const listeners = resolveGlobalSet<MessageAuditListener>(
18
+ Symbol.for("openclaw.messageAuditListeners"),
19
+ "close-and-restart",
20
+ );
21
+
22
+ /** Emit only closed metadata. This stream is intentionally not part of the plugin SDK. */
23
+ export function emitTrustedMessageAuditEvent(event: TrustedMessageAuditEvent): void {
24
+ if (listeners.size === 0) {
25
+ return;
26
+ }
27
+ notifyListeners(listeners, event);
28
+ }
29
+
30
+ export function onTrustedMessageAuditEvent(listener: MessageAuditListener): () => void {
31
+ return registerListener(listeners, listener);
32
+ }
33
+
34
+ /** Lets hot producers skip attribution work while message audit is disabled. */
35
+ export function hasTrustedMessageAuditListeners(): boolean {
36
+ return listeners.size > 0;
37
+ }
src/audit/message-delivery-audit-store.ts ADDED
@@ -0,0 +1,347 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** Read-only owner-native outbound message lifecycle queries for run inspection. */
2
+ import type { DatabaseSync } from "node:sqlite";
3
+ import {
4
+ executeSqliteQuerySync,
5
+ executeSqliteQueryTakeFirstSync,
6
+ getNodeSqliteKysely,
7
+ } from "../infra/kysely-sync.js";
8
+ import { normalizeSqliteNumber } from "../infra/sqlite-number.js";
9
+ import { withExistingOpenClawStateDatabaseReadOnly } from "../state/openclaw-state-db-readonly.js";
10
+ import { tableExists } from "../state/openclaw-state-db-schema-helpers.js";
11
+ import type { DB as OpenClawStateKyselyDatabase } from "../state/openclaw-state-db.generated.js";
12
+ import type { OpenClawStateDatabaseOptions } from "../state/openclaw-state-db.js";
13
+ import { AUDIT_EVENT_RETENTION_MS, rowToAuditEvent } from "./audit-event-store.js";
14
+ import type { OutboundMessageAuditEventRecord } from "./audit-event-types.js";
15
+ import {
16
+ countOutboundMessageProgressForRun,
17
+ hasOutboundMessageProgressCursor,
18
+ readOutboundMessageProgressForRun,
19
+ } from "./message-delivery-progress-store.js";
20
+ import { selectMessageExecutionBinding } from "./message-execution-binding.js";
21
+
22
+ type MessageDeliveryAuditDatabase = Pick<
23
+ OpenClawStateKyselyDatabase,
24
+ "audit_events" | "outbound_message_execution_bindings"
25
+ >;
26
+
27
+ function deliveryAuditDb(db: DatabaseSync) {
28
+ return getNodeSqliteKysely<MessageDeliveryAuditDatabase>(db);
29
+ }
30
+
31
+ export type OutboundMessageAuditEventCursor = { occurredAt: number; rowId: number };
32
+
33
+ type OwnedMessageEvent = {
34
+ event: OutboundMessageAuditEventRecord;
35
+ rowId: number;
36
+ };
37
+
38
+ type MessageExecutionSelector = {
39
+ runId: string;
40
+ contextId?: string;
41
+ executionId?: string;
42
+ };
43
+
44
+ function terminalBindingEventIds(
45
+ db: DatabaseSync,
46
+ runId: string,
47
+ binding: { contextId: string; executionId: string },
48
+ ) {
49
+ return deliveryAuditDb(db)
50
+ .selectFrom("outbound_message_execution_bindings")
51
+ .select("event_id")
52
+ .where("context_id", "=", binding.contextId)
53
+ .where("execution_id", "=", binding.executionId)
54
+ .where("run_id", "=", runId);
55
+ }
56
+
57
+ const MESSAGE_CURSOR_STAGE_SPAN = 1_000_000_000_000;
58
+ const MESSAGE_STREAM_CHUNK_SIZE = 256;
59
+ type MessageStage = 0 | 1 | 2;
60
+ type MessageStream = {
61
+ stage: MessageStage;
62
+ after?: { occurredAt: number; sequence: number };
63
+ buffered: OwnedMessageEvent[];
64
+ exhausted: boolean;
65
+ };
66
+
67
+ function messageStage(event: OutboundMessageAuditEventRecord): MessageStage {
68
+ return event.action === "message.outbound.queued"
69
+ ? 0
70
+ : event.action === "message.outbound.platform-started"
71
+ ? 1
72
+ : 2;
73
+ }
74
+
75
+ function compositeMessageRowId(event: OutboundMessageAuditEventRecord): number {
76
+ if (event.sequence >= MESSAGE_CURSOR_STAGE_SPAN) {
77
+ throw new Error("outbound message decision cursor is outside the supported integer range");
78
+ }
79
+ const rowId = messageStage(event) * MESSAGE_CURSOR_STAGE_SPAN + event.sequence;
80
+ if (!Number.isSafeInteger(rowId)) {
81
+ throw new Error("outbound message decision cursor is outside the supported integer range");
82
+ }
83
+ return rowId;
84
+ }
85
+
86
+ function readTerminalEventsForRun(params: {
87
+ runId: string;
88
+ contextId?: string;
89
+ executionId?: string;
90
+ after?: { occurredAt: number; sequence: number };
91
+ limit: number;
92
+ now?: number;
93
+ database?: OpenClawStateDatabaseOptions;
94
+ }): OutboundMessageAuditEventRecord[] {
95
+ return (
96
+ withExistingOpenClawStateDatabaseReadOnly(({ db }) => {
97
+ const exact = selectMessageExecutionBinding(params);
98
+ if (exact && !tableExists(db, "outbound_message_execution_bindings")) {
99
+ return [];
100
+ }
101
+ let query = deliveryAuditDb(db)
102
+ .selectFrom("audit_events")
103
+ .selectAll()
104
+ .where("kind", "=", "message")
105
+ .where("direction", "=", "outbound")
106
+ .where("action", "=", "message.outbound.finished")
107
+ .where("run_id", "=", params.runId)
108
+ .where("occurred_at", ">=", (params.now ?? Date.now()) - AUDIT_EVENT_RETENTION_MS);
109
+ if (exact) {
110
+ query = query.where("event_id", "in", terminalBindingEventIds(db, params.runId, exact));
111
+ }
112
+ const after = params.after;
113
+ if (after) {
114
+ query = query.where((expression) =>
115
+ expression.or([
116
+ expression("occurred_at", ">", after.occurredAt),
117
+ expression.and([
118
+ expression("occurred_at", "=", after.occurredAt),
119
+ expression("sequence", ">", after.sequence),
120
+ ]),
121
+ ]),
122
+ );
123
+ }
124
+ return executeSqliteQuerySync(
125
+ db,
126
+ query.orderBy("occurred_at", "asc").orderBy("sequence", "asc").limit(params.limit),
127
+ ).rows.map(
128
+ // SAFETY: the query fixes the row to the validated outbound terminal-message variant.
129
+ (row) => rowToAuditEvent(row) as OutboundMessageAuditEventRecord,
130
+ );
131
+ }, params.database) ?? []
132
+ );
133
+ }
134
+
135
+ function compareOwnedMessageEvents(left: OwnedMessageEvent, right: OwnedMessageEvent): number {
136
+ return left.event.occurredAt === right.event.occurredAt
137
+ ? left.rowId - right.rowId
138
+ : left.event.occurredAt - right.event.occurredAt;
139
+ }
140
+
141
+ function streamAfterCursor(
142
+ cursor: OutboundMessageAuditEventCursor | undefined,
143
+ stage: MessageStage,
144
+ ): MessageStream["after"] {
145
+ if (!cursor) {
146
+ return undefined;
147
+ }
148
+ const cursorStage = Math.floor(cursor.rowId / MESSAGE_CURSOR_STAGE_SPAN);
149
+ const cursorSequence = cursor.rowId % MESSAGE_CURSOR_STAGE_SPAN;
150
+ return {
151
+ occurredAt: cursor.occurredAt,
152
+ sequence:
153
+ stage < cursorStage ? Number.MAX_SAFE_INTEGER : stage > cursorStage ? 0 : cursorSequence,
154
+ };
155
+ }
156
+
157
+ function fillMessageStream(
158
+ stream: MessageStream,
159
+ params: MessageExecutionSelector & {
160
+ now: number;
161
+ database?: OpenClawStateDatabaseOptions;
162
+ },
163
+ ): void {
164
+ if (stream.buffered.length > 0 || stream.exhausted) {
165
+ return;
166
+ }
167
+ const query = {
168
+ runId: params.runId,
169
+ ...(params.contextId ? { contextId: params.contextId } : {}),
170
+ ...(params.executionId ? { executionId: params.executionId } : {}),
171
+ now: params.now,
172
+ database: params.database,
173
+ after: stream.after,
174
+ limit: MESSAGE_STREAM_CHUNK_SIZE,
175
+ };
176
+ const events =
177
+ stream.stage === 2
178
+ ? readTerminalEventsForRun(query)
179
+ : readOutboundMessageProgressForRun({
180
+ ...query,
181
+ action:
182
+ stream.stage === 0 ? "message.outbound.queued" : "message.outbound.platform-started",
183
+ });
184
+ stream.buffered = events.map((event) => ({ event, rowId: compositeMessageRowId(event) }));
185
+ stream.exhausted = events.length < MESSAGE_STREAM_CHUNK_SIZE;
186
+ const last = events.at(-1);
187
+ if (last) {
188
+ stream.after = { occurredAt: last.occurredAt, sequence: last.sequence };
189
+ }
190
+ }
191
+
192
+ function takeNextMessageEvent(
193
+ streams: MessageStream[],
194
+ params: MessageExecutionSelector & {
195
+ now: number;
196
+ database?: OpenClawStateDatabaseOptions;
197
+ },
198
+ ): OwnedMessageEvent | undefined {
199
+ for (const stream of streams) {
200
+ fillMessageStream(stream, params);
201
+ }
202
+ let selected: MessageStream | undefined;
203
+ for (const stream of streams) {
204
+ const candidate = stream.buffered[0];
205
+ const current = selected?.buffered[0];
206
+ if (candidate && (!current || compareOwnedMessageEvents(candidate, current) < 0)) {
207
+ selected = stream;
208
+ }
209
+ }
210
+ return selected?.buffered.shift();
211
+ }
212
+
213
+ function hasTerminalCursor(params: {
214
+ runId: string;
215
+ contextId?: string;
216
+ executionId?: string;
217
+ occurredAt: number;
218
+ sequence: number;
219
+ database?: OpenClawStateDatabaseOptions;
220
+ }): boolean {
221
+ return (
222
+ withExistingOpenClawStateDatabaseReadOnly(({ db }) => {
223
+ const exact = selectMessageExecutionBinding(params);
224
+ if (exact && !tableExists(db, "outbound_message_execution_bindings")) {
225
+ return false;
226
+ }
227
+ let query = deliveryAuditDb(db)
228
+ .selectFrom("audit_events")
229
+ .select("sequence")
230
+ .where("sequence", "=", params.sequence)
231
+ .where("run_id", "=", params.runId)
232
+ .where("occurred_at", "=", params.occurredAt)
233
+ .where("kind", "=", "message")
234
+ .where("direction", "=", "outbound")
235
+ .where("action", "=", "message.outbound.finished");
236
+ if (exact) {
237
+ query = query.where("event_id", "in", terminalBindingEventIds(db, params.runId, exact));
238
+ }
239
+ return Boolean(executeSqliteQueryTakeFirstSync(db, query));
240
+ }, params.database) ?? false
241
+ );
242
+ }
243
+
244
+ /** Count retained owner-native outbound lifecycle records for one run. */
245
+ export function countOutboundMessageAuditEventsForRun(params: {
246
+ runId: string;
247
+ contextId?: string;
248
+ executionId?: string;
249
+ now?: number;
250
+ database?: OpenClawStateDatabaseOptions;
251
+ }): number {
252
+ return (
253
+ (withExistingOpenClawStateDatabaseReadOnly(({ db }) => {
254
+ const exact = selectMessageExecutionBinding(params);
255
+ if (exact && !tableExists(db, "outbound_message_execution_bindings")) {
256
+ return 0;
257
+ }
258
+ let query = deliveryAuditDb(db)
259
+ .selectFrom("audit_events")
260
+ .select((expression) => expression.fn.countAll<number>().as("count"))
261
+ .where("kind", "=", "message")
262
+ .where("direction", "=", "outbound")
263
+ .where("action", "=", "message.outbound.finished")
264
+ .where("run_id", "=", params.runId)
265
+ .where("occurred_at", ">=", (params.now ?? Date.now()) - AUDIT_EVENT_RETENTION_MS);
266
+ if (exact) {
267
+ query = query.where("event_id", "in", terminalBindingEventIds(db, params.runId, exact));
268
+ }
269
+ const row = executeSqliteQueryTakeFirstSync(db, query);
270
+ return normalizeSqliteNumber(row?.count ?? null) ?? 0;
271
+ }, params.database) ?? 0) + countOutboundMessageProgressForRun(params)
272
+ );
273
+ }
274
+
275
+ /** Page retained owner-native outbound lifecycle records in decision order. */
276
+ export function pageOutboundMessageAuditEventsForRun(params: {
277
+ runId: string;
278
+ contextId?: string;
279
+ executionId?: string;
280
+ after?: OutboundMessageAuditEventCursor;
281
+ offset?: number;
282
+ limit: number;
283
+ now?: number;
284
+ database?: OpenClawStateDatabaseOptions;
285
+ }): { entries: OwnedMessageEvent[]; nextCursor?: OutboundMessageAuditEventCursor } {
286
+ if (params.after) {
287
+ const stage = Math.floor(params.after.rowId / MESSAGE_CURSOR_STAGE_SPAN);
288
+ const sequence = params.after.rowId % MESSAGE_CURSOR_STAGE_SPAN;
289
+ const retained =
290
+ Number.isSafeInteger(sequence) && sequence >= 1 && stage >= 0 && stage <= 2
291
+ ? stage === 2
292
+ ? hasTerminalCursor({
293
+ runId: params.runId,
294
+ ...(params.contextId ? { contextId: params.contextId } : {}),
295
+ ...(params.executionId ? { executionId: params.executionId } : {}),
296
+ occurredAt: params.after.occurredAt,
297
+ sequence,
298
+ database: params.database,
299
+ })
300
+ : hasOutboundMessageProgressCursor({
301
+ runId: params.runId,
302
+ ...(params.contextId ? { contextId: params.contextId } : {}),
303
+ ...(params.executionId ? { executionId: params.executionId } : {}),
304
+ occurredAt: params.after.occurredAt,
305
+ sequence,
306
+ action: stage === 0 ? "message.outbound.queued" : "message.outbound.platform-started",
307
+ database: params.database,
308
+ })
309
+ : false;
310
+ if (!retained) {
311
+ throw new Error("outbound message decision cursor is no longer retained");
312
+ }
313
+ }
314
+ const streams: MessageStream[] = ([0, 1, 2] as const).map((stage) => ({
315
+ stage,
316
+ after: streamAfterCursor(params.after, stage),
317
+ buffered: [],
318
+ exhausted: false,
319
+ }));
320
+ const streamParams = {
321
+ runId: params.runId,
322
+ ...(params.contextId ? { contextId: params.contextId } : {}),
323
+ ...(params.executionId ? { executionId: params.executionId } : {}),
324
+ now: params.now ?? Date.now(),
325
+ database: params.database,
326
+ };
327
+ let remainingOffset = params.offset ?? 0;
328
+ while (remainingOffset > 0 && takeNextMessageEvent(streams, streamParams)) {
329
+ remainingOffset -= 1;
330
+ }
331
+ const rows: OwnedMessageEvent[] = [];
332
+ while (rows.length <= params.limit) {
333
+ const next = takeNextMessageEvent(streams, streamParams);
334
+ if (!next) {
335
+ break;
336
+ }
337
+ rows.push(next);
338
+ }
339
+ const pageRows = rows.slice(0, params.limit);
340
+ const last = pageRows.at(-1);
341
+ return {
342
+ entries: pageRows,
343
+ ...(rows.length > params.limit && last
344
+ ? { nextCursor: { occurredAt: last.event.occurredAt, rowId: last.rowId } }
345
+ : {}),
346
+ };
347
+ }
src/audit/message-delivery-progress-store.ts ADDED
@@ -0,0 +1,481 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** Lazy owner-native persistence for nonterminal outbound message progress. */
2
+ import { randomUUID } from "node:crypto";
3
+ import type { DatabaseSync } from "node:sqlite";
4
+ import type { Insertable, Selectable } from "kysely";
5
+ import {
6
+ executeSqliteQuerySync,
7
+ executeSqliteQueryTakeFirstSync,
8
+ getNodeSqliteKysely,
9
+ } from "../infra/kysely-sync.js";
10
+ import { normalizeSqliteNumber } from "../infra/sqlite-number.js";
11
+ import { withExistingOpenClawStateDatabaseReadOnly } from "../state/openclaw-state-db-readonly.js";
12
+ import { ensureColumn, tableExists } from "../state/openclaw-state-db-schema-helpers.js";
13
+ import type { DB as OpenClawStateKyselyDatabase } from "../state/openclaw-state-db.generated.js";
14
+ import {
15
+ openOpenClawStateDatabase,
16
+ runOpenClawStateWriteTransaction,
17
+ type OpenClawStateDatabaseOptions,
18
+ } from "../state/openclaw-state-db.js";
19
+ import { OPENCLAW_STATE_SCHEMA_SQL } from "../state/openclaw-state-schema.js";
20
+ import type {
21
+ OutboundMessageAuditEventRecord,
22
+ OutboundMessageProgressInput,
23
+ } from "./audit-event-types.js";
24
+ import {
25
+ clearAuditIdentityKeyCacheForDatabase,
26
+ loadOrCreateAuditIdentityKey,
27
+ pseudonymizeAuditIdentity,
28
+ } from "./audit-identity.js";
29
+ import {
30
+ confirmMessageExecutionBinding,
31
+ hasMessageExecutionBindingColumns,
32
+ planMessageExecutionBinding,
33
+ selectMessageExecutionBinding,
34
+ type MessageExecutionBinding,
35
+ } from "./message-execution-binding.js";
36
+
37
+ type ProgressTable = OpenClawStateKyselyDatabase["outbound_message_progress"];
38
+ type ProgressDatabase = Pick<OpenClawStateKyselyDatabase, "outbound_message_progress">;
39
+ type ProgressRow = Selectable<ProgressTable>;
40
+
41
+ const OUTBOUND_MESSAGE_PROGRESS_RETENTION_MS = 30 * 24 * 60 * 60_000;
42
+ const OUTBOUND_MESSAGE_PROGRESS_MAX_ROWS = 200_000;
43
+ const OUTBOUND_MESSAGE_PROGRESS_PRUNE_BATCH_ROWS = 1_024;
44
+ const AUDIT_HMAC_REF_RE = /^hmac-sha256:v1:[a-f0-9]{32}:[a-f0-9]{64}$/u;
45
+ const ensuredDatabases = new WeakSet<DatabaseSync>();
46
+ const progressRowCounts = new WeakMap<DatabaseSync, number>();
47
+
48
+ function progressSchemaSql(): string {
49
+ const start = OPENCLAW_STATE_SCHEMA_SQL.indexOf(
50
+ "CREATE TABLE IF NOT EXISTS outbound_message_progress (",
51
+ );
52
+ const finalIndex = OPENCLAW_STATE_SCHEMA_SQL.indexOf(
53
+ "CREATE INDEX IF NOT EXISTS outbound_message_progress_run_occurred_idx",
54
+ start,
55
+ );
56
+ const end = finalIndex >= 0 ? OPENCLAW_STATE_SCHEMA_SQL.indexOf(";", finalIndex) : -1;
57
+ if (start < 0 || end < 0) {
58
+ throw new Error("canonical outbound message progress schema is missing");
59
+ }
60
+ return OPENCLAW_STATE_SCHEMA_SQL.slice(start, end + 1);
61
+ }
62
+
63
+ function progressDb(db: DatabaseSync) {
64
+ return getNodeSqliteKysely<ProgressDatabase>(db);
65
+ }
66
+
67
+ function ensureProgressSchema(options: OpenClawStateDatabaseOptions): void {
68
+ const database = openOpenClawStateDatabase(options);
69
+ if (ensuredDatabases.has(database.db)) {
70
+ return;
71
+ }
72
+ runOpenClawStateWriteTransaction(
73
+ ({ db }) => {
74
+ // sqlite-allow-raw -- feature-local additive schema DDL; progress rows use Kysely.
75
+ db.exec(progressSchemaSql());
76
+ ensureColumn(db, "outbound_message_progress", "context_id TEXT");
77
+ ensureColumn(db, "outbound_message_progress", "execution_id TEXT");
78
+ },
79
+ options,
80
+ { operationLabel: "audit.outbound-message-progress.schema.ensure" },
81
+ );
82
+ ensuredDatabases.add(database.db);
83
+ }
84
+
85
+ function projectProgressIdentities(db: DatabaseSync, input: OutboundMessageProgressInput) {
86
+ const identity = loadOrCreateAuditIdentityKey(db);
87
+ const conversationId = input.conversationId ?? input.targetId;
88
+ const ref = (
89
+ kind: Parameters<typeof pseudonymizeAuditIdentity>[0]["kind"],
90
+ value: string | undefined,
91
+ ) =>
92
+ pseudonymizeAuditIdentity({
93
+ identity,
94
+ kind,
95
+ channel: input.channel,
96
+ ...(kind !== "account" && input.accountId !== undefined
97
+ ? { accountId: input.accountId }
98
+ : {}),
99
+ value,
100
+ });
101
+ return {
102
+ accountRef: ref("account", input.accountId),
103
+ conversationRef: ref("conversation", conversationId),
104
+ targetRef: ref("target", input.targetId),
105
+ };
106
+ }
107
+
108
+ function bindProgressRow(
109
+ db: DatabaseSync,
110
+ input: OutboundMessageProgressInput,
111
+ executionBinding?: MessageExecutionBinding,
112
+ ): Insertable<ProgressTable> {
113
+ const refs = projectProgressIdentities(db, input);
114
+ return {
115
+ progress_id: randomUUID(),
116
+ source_id: input.sourceId,
117
+ source_sequence: input.sourceSequence,
118
+ schema_version: 1,
119
+ occurred_at: input.occurredAt,
120
+ action: input.action,
121
+ outcome: input.outcome,
122
+ actor_type: input.actorType,
123
+ actor_id: input.actorId,
124
+ agent_id: input.agentId ?? null,
125
+ run_id: input.runId ?? null,
126
+ context_id: executionBinding?.contextId ?? null,
127
+ execution_id: executionBinding?.executionId ?? null,
128
+ channel: input.channel,
129
+ conversation_kind: input.conversationKind,
130
+ duration_ms: input.durationMs ?? null,
131
+ account_ref: refs.accountRef ?? null,
132
+ conversation_ref: refs.conversationRef ?? null,
133
+ target_ref: refs.targetRef ?? null,
134
+ };
135
+ }
136
+
137
+ function requiredText(value: unknown, field: string): string {
138
+ if (typeof value !== "string" || value.length === 0) {
139
+ throw new Error(`corrupt outbound message progress row: invalid ${field}`);
140
+ }
141
+ return value;
142
+ }
143
+
144
+ function optionalText(value: unknown, field: string): string | undefined {
145
+ if (value === null || value === undefined) {
146
+ return undefined;
147
+ }
148
+ return requiredText(value, field);
149
+ }
150
+
151
+ function optionalHmacRef(value: unknown, field: string): string | undefined {
152
+ const ref = optionalText(value, field);
153
+ if (ref !== undefined && !AUDIT_HMAC_REF_RE.test(ref)) {
154
+ throw new Error(`corrupt outbound message progress row: invalid ${field}`);
155
+ }
156
+ return ref;
157
+ }
158
+
159
+ function rowToProgressEvent(row: ProgressRow): OutboundMessageAuditEventRecord {
160
+ const sequence = normalizeSqliteNumber(row.sequence);
161
+ const sourceSequence = normalizeSqliteNumber(row.source_sequence);
162
+ const occurredAt = normalizeSqliteNumber(row.occurred_at);
163
+ const durationMs = normalizeSqliteNumber(row.duration_ms);
164
+ if (
165
+ sequence === undefined ||
166
+ sequence < 1 ||
167
+ sourceSequence === undefined ||
168
+ sourceSequence < 1 ||
169
+ occurredAt === undefined ||
170
+ occurredAt < 0 ||
171
+ row.schema_version !== 1 ||
172
+ (durationMs !== undefined && durationMs < 0)
173
+ ) {
174
+ throw new Error("corrupt outbound message progress row: invalid numeric field");
175
+ }
176
+ if (
177
+ (row.action === "message.outbound.queued" && row.outcome !== "queued") ||
178
+ (row.action === "message.outbound.platform-started" && row.outcome !== "platform_started") ||
179
+ (row.action !== "message.outbound.queued" && row.action !== "message.outbound.platform-started")
180
+ ) {
181
+ throw new Error("corrupt outbound message progress row: invalid lifecycle field");
182
+ }
183
+ const actorType =
184
+ row.actor_type === "agent" ? "agent" : row.actor_type === "system" ? "system" : undefined;
185
+ if (!actorType) {
186
+ throw new Error("corrupt outbound message progress row: invalid actor type");
187
+ }
188
+ const conversationKind =
189
+ row.conversation_kind === "direct"
190
+ ? "direct"
191
+ : row.conversation_kind === "group"
192
+ ? "group"
193
+ : row.conversation_kind === "channel"
194
+ ? "channel"
195
+ : row.conversation_kind === "unknown"
196
+ ? "unknown"
197
+ : undefined;
198
+ if (!conversationKind) {
199
+ throw new Error("corrupt outbound message progress row: invalid conversation kind");
200
+ }
201
+ const common = {
202
+ schemaVersion: 1 as const,
203
+ sequence,
204
+ eventId: requiredText(row.progress_id, "progressId"),
205
+ sourceSequence,
206
+ occurredAt,
207
+ redaction: "metadata_only" as const,
208
+ kind: "message" as const,
209
+ actorType,
210
+ actorId: requiredText(row.actor_id, "actorId"),
211
+ ...(optionalText(row.agent_id, "agentId") !== undefined ? { agentId: row.agent_id! } : {}),
212
+ ...(optionalText(row.run_id, "runId") !== undefined ? { runId: row.run_id! } : {}),
213
+ direction: "outbound" as const,
214
+ channel: requiredText(row.channel, "channel"),
215
+ conversationKind,
216
+ ...(durationMs !== undefined ? { durationMs } : {}),
217
+ resultCount: 0,
218
+ ...(optionalHmacRef(row.account_ref, "accountRef") !== undefined
219
+ ? { accountRef: row.account_ref! }
220
+ : {}),
221
+ ...(optionalHmacRef(row.conversation_ref, "conversationRef") !== undefined
222
+ ? { conversationRef: row.conversation_ref! }
223
+ : {}),
224
+ ...(optionalHmacRef(row.target_ref, "targetRef") !== undefined
225
+ ? { targetRef: row.target_ref! }
226
+ : {}),
227
+ } as const;
228
+ return row.action === "message.outbound.queued"
229
+ ? { ...common, action: row.action, status: "started", outcome: "queued" }
230
+ : { ...common, action: row.action, status: "started", outcome: "platform_started" };
231
+ }
232
+
233
+ function countProgressRows(db: DatabaseSync): number {
234
+ const row = executeSqliteQueryTakeFirstSync(
235
+ db,
236
+ progressDb(db)
237
+ .selectFrom("outbound_message_progress")
238
+ .select((expression) => expression.fn.countAll<number>().as("count")),
239
+ );
240
+ return normalizeSqliteNumber(row?.count ?? null) ?? 0;
241
+ }
242
+
243
+ function deleteExpiredProgressRows(db: DatabaseSync, now: number, limit: number) {
244
+ const kysely = progressDb(db);
245
+ const expiredSequences = kysely
246
+ .selectFrom("outbound_message_progress")
247
+ .select("sequence")
248
+ .where("occurred_at", "<", now - OUTBOUND_MESSAGE_PROGRESS_RETENTION_MS)
249
+ .orderBy("occurred_at", "asc")
250
+ .orderBy("sequence", "asc")
251
+ .limit(limit);
252
+ return executeSqliteQuerySync(
253
+ db,
254
+ kysely.deleteFrom("outbound_message_progress").where("sequence", "in", expiredSequences),
255
+ );
256
+ }
257
+
258
+ function pruneProgressAfterInsert(db: DatabaseSync, now: number): void {
259
+ const kysely = progressDb(db);
260
+ const expired = deleteExpiredProgressRows(db, now, OUTBOUND_MESSAGE_PROGRESS_PRUNE_BATCH_ROWS);
261
+ const cachedCount = progressRowCounts.get(db);
262
+ let rowCount =
263
+ cachedCount === undefined
264
+ ? countProgressRows(db)
265
+ : Math.max(0, cachedCount + 1 - Number(expired.numAffectedRows ?? 0n));
266
+ if (rowCount <= OUTBOUND_MESSAGE_PROGRESS_MAX_ROWS) {
267
+ progressRowCounts.set(db, rowCount);
268
+ return;
269
+ }
270
+ const retainedRows = Math.max(
271
+ 0,
272
+ OUTBOUND_MESSAGE_PROGRESS_MAX_ROWS - OUTBOUND_MESSAGE_PROGRESS_PRUNE_BATCH_ROWS,
273
+ );
274
+ const overflow = executeSqliteQueryTakeFirstSync(
275
+ db,
276
+ kysely
277
+ .selectFrom("outbound_message_progress")
278
+ .select("sequence")
279
+ .orderBy("sequence", "desc")
280
+ .offset(retainedRows)
281
+ .limit(1),
282
+ );
283
+ const cutoff = overflow ? normalizeSqliteNumber(overflow.sequence) : undefined;
284
+ if (cutoff !== undefined) {
285
+ const pruned = executeSqliteQuerySync(
286
+ db,
287
+ kysely.deleteFrom("outbound_message_progress").where("sequence", "<=", cutoff),
288
+ );
289
+ rowCount = Math.max(0, rowCount - Number(pruned.numAffectedRows ?? 0n));
290
+ }
291
+ progressRowCounts.set(db, rowCount);
292
+ }
293
+
294
+ /** Persist one progress fact idempotently; first use installs only this owner table. */
295
+ export function recordOutboundMessageProgress(
296
+ input: OutboundMessageProgressInput,
297
+ options: OpenClawStateDatabaseOptions = {},
298
+ ): OutboundMessageAuditEventRecord | undefined {
299
+ const executionToken = planMessageExecutionBinding(input.executionIdentityToken, input.runId);
300
+ ensureProgressSchema(options);
301
+ let cacheDatabase: DatabaseSync | undefined;
302
+ try {
303
+ return runOpenClawStateWriteTransaction(({ db }) => {
304
+ cacheDatabase = db;
305
+ const executionBinding = confirmMessageExecutionBinding(db, executionToken);
306
+ const insert = executeSqliteQuerySync(
307
+ db,
308
+ progressDb(db)
309
+ .insertInto("outbound_message_progress")
310
+ .values(bindProgressRow(db, input, executionBinding))
311
+ .onConflict((conflict) => conflict.column("source_id").doNothing()),
312
+ );
313
+ if (insert.insertId === undefined) {
314
+ return undefined;
315
+ }
316
+ const sequence = Number(insert.insertId);
317
+ if (!Number.isSafeInteger(sequence) || sequence < 1) {
318
+ throw new Error("outbound message progress sequence is outside the supported range");
319
+ }
320
+ pruneProgressAfterInsert(db, Date.now());
321
+ const row = executeSqliteQueryTakeFirstSync(
322
+ db,
323
+ progressDb(db)
324
+ .selectFrom("outbound_message_progress")
325
+ .selectAll()
326
+ .where("sequence", "=", sequence),
327
+ );
328
+ return row ? rowToProgressEvent(row) : undefined;
329
+ }, options);
330
+ } catch (error) {
331
+ if (cacheDatabase) {
332
+ progressRowCounts.delete(cacheDatabase);
333
+ clearAuditIdentityKeyCacheForDatabase(cacheDatabase);
334
+ }
335
+ throw error;
336
+ }
337
+ }
338
+
339
+ export function countOutboundMessageProgressForRun(params: {
340
+ runId: string;
341
+ contextId?: string;
342
+ executionId?: string;
343
+ now?: number;
344
+ database?: OpenClawStateDatabaseOptions;
345
+ }): number {
346
+ return (
347
+ withExistingOpenClawStateDatabaseReadOnly(({ db }) => {
348
+ const exact = selectMessageExecutionBinding(params);
349
+ if (
350
+ !tableExists(db, "outbound_message_progress") ||
351
+ (exact && !hasMessageExecutionBindingColumns(db, "outbound_message_progress"))
352
+ ) {
353
+ return 0;
354
+ }
355
+ let query = progressDb(db)
356
+ .selectFrom("outbound_message_progress")
357
+ .select((expression) => expression.fn.countAll<number>().as("count"))
358
+ .where("run_id", "=", params.runId)
359
+ .where(
360
+ "occurred_at",
361
+ ">=",
362
+ (params.now ?? Date.now()) - OUTBOUND_MESSAGE_PROGRESS_RETENTION_MS,
363
+ );
364
+ if (exact) {
365
+ query = query
366
+ .where("context_id", "=", exact.contextId)
367
+ .where("execution_id", "=", exact.executionId);
368
+ }
369
+ const row = executeSqliteQueryTakeFirstSync(db, query);
370
+ return normalizeSqliteNumber(row?.count ?? null) ?? 0;
371
+ }, params.database) ?? 0
372
+ );
373
+ }
374
+
375
+ export function readOutboundMessageProgressForRun(params: {
376
+ runId: string;
377
+ contextId?: string;
378
+ executionId?: string;
379
+ action: OutboundMessageProgressInput["action"];
380
+ after?: { occurredAt: number; sequence: number };
381
+ limit: number;
382
+ now?: number;
383
+ database?: OpenClawStateDatabaseOptions;
384
+ }): OutboundMessageAuditEventRecord[] {
385
+ return (
386
+ withExistingOpenClawStateDatabaseReadOnly(({ db }) => {
387
+ const exact = selectMessageExecutionBinding(params);
388
+ if (
389
+ !tableExists(db, "outbound_message_progress") ||
390
+ (exact && !hasMessageExecutionBindingColumns(db, "outbound_message_progress"))
391
+ ) {
392
+ return [];
393
+ }
394
+ let query = progressDb(db)
395
+ .selectFrom("outbound_message_progress")
396
+ .selectAll()
397
+ .where("run_id", "=", params.runId)
398
+ .where("action", "=", params.action)
399
+ .where(
400
+ "occurred_at",
401
+ ">=",
402
+ (params.now ?? Date.now()) - OUTBOUND_MESSAGE_PROGRESS_RETENTION_MS,
403
+ );
404
+ if (exact) {
405
+ query = query
406
+ .where("context_id", "=", exact.contextId)
407
+ .where("execution_id", "=", exact.executionId);
408
+ }
409
+ const after = params.after;
410
+ if (after) {
411
+ query = query.where((expression) =>
412
+ expression.or([
413
+ expression("occurred_at", ">", after.occurredAt),
414
+ expression.and([
415
+ expression("occurred_at", "=", after.occurredAt),
416
+ expression("sequence", ">", after.sequence),
417
+ ]),
418
+ ]),
419
+ );
420
+ }
421
+ return executeSqliteQuerySync(
422
+ db,
423
+ query.orderBy("occurred_at", "asc").orderBy("sequence", "asc").limit(params.limit),
424
+ ).rows.map(rowToProgressEvent);
425
+ }, params.database) ?? []
426
+ );
427
+ }
428
+
429
+ export function hasOutboundMessageProgressCursor(params: {
430
+ runId: string;
431
+ contextId?: string;
432
+ executionId?: string;
433
+ occurredAt: number;
434
+ sequence: number;
435
+ action: OutboundMessageProgressInput["action"];
436
+ database?: OpenClawStateDatabaseOptions;
437
+ }): boolean {
438
+ return (
439
+ withExistingOpenClawStateDatabaseReadOnly(({ db }) => {
440
+ const exact = selectMessageExecutionBinding(params);
441
+ if (
442
+ !tableExists(db, "outbound_message_progress") ||
443
+ (exact && !hasMessageExecutionBindingColumns(db, "outbound_message_progress"))
444
+ ) {
445
+ return false;
446
+ }
447
+ let query = progressDb(db)
448
+ .selectFrom("outbound_message_progress")
449
+ .select("sequence")
450
+ .where("sequence", "=", params.sequence)
451
+ .where("run_id", "=", params.runId)
452
+ .where("occurred_at", "=", params.occurredAt)
453
+ .where("action", "=", params.action);
454
+ if (exact) {
455
+ query = query
456
+ .where("context_id", "=", exact.contextId)
457
+ .where("execution_id", "=", exact.executionId);
458
+ }
459
+ return Boolean(executeSqliteQueryTakeFirstSync(db, query));
460
+ }, params.database) ?? false
461
+ );
462
+ }
463
+
464
+ /** Prune existing progress without creating its lazy table. */
465
+ export function pruneExpiredOutboundMessageProgress(
466
+ params: { now?: number; database?: OpenClawStateDatabaseOptions } = {},
467
+ ): number {
468
+ const database = openOpenClawStateDatabase(params.database);
469
+ if (!tableExists(database.db, "outbound_message_progress")) {
470
+ return 0;
471
+ }
472
+ return runOpenClawStateWriteTransaction(({ db }) => {
473
+ const deleted = deleteExpiredProgressRows(
474
+ db,
475
+ params.now ?? Date.now(),
476
+ OUTBOUND_MESSAGE_PROGRESS_PRUNE_BATCH_ROWS,
477
+ );
478
+ progressRowCounts.delete(db);
479
+ return Number(deleted.numAffectedRows ?? 0n);
480
+ }, params.database);
481
+ }
src/audit/message-delivery-receipts.ts ADDED
@@ -0,0 +1,175 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** Owner-native outbound message lifecycle projection for run inspection. */
2
+ import type {
3
+ DecisionReceiptV1,
4
+ ExecutionIdentityContextV1,
5
+ } from "../../packages/gateway-protocol/src/index.js";
6
+ import type { OpenClawStateDatabaseOptions } from "../state/openclaw-state-db.js";
7
+ import type { OutboundMessageAuditEventRecord } from "./audit-event-types.js";
8
+ import {
9
+ countOutboundMessageAuditEventsForRun,
10
+ pageOutboundMessageAuditEventsForRun,
11
+ type OutboundMessageAuditEventCursor,
12
+ } from "./message-delivery-audit-store.js";
13
+
14
+ type MessageDeliveryReadOptions = OpenClawStateDatabaseOptions & { now?: number };
15
+
16
+ function messageOutcome(
17
+ event: OutboundMessageAuditEventRecord,
18
+ ): Pick<DecisionReceiptV1, "decision" | "remediation"> {
19
+ switch (event.outcome) {
20
+ case "queued":
21
+ return {
22
+ decision: { outcome: "allowed", reasonCode: "message_queued" },
23
+ remediation: [
24
+ {
25
+ code: "inspect_delivery_progress",
26
+ text: "Inspect this run again to observe the platform and terminal delivery outcome.",
27
+ },
28
+ ],
29
+ };
30
+ case "platform_started":
31
+ return {
32
+ decision: { outcome: "allowed", reasonCode: "message_platform_started" },
33
+ remediation: [
34
+ {
35
+ code: "inspect_delivery_result",
36
+ text: "Inspect this run again for a delivered, failed, or unknown terminal outcome.",
37
+ },
38
+ ],
39
+ };
40
+ case "sent":
41
+ return {
42
+ decision: { outcome: "allowed", reasonCode: "message_delivered" },
43
+ remediation: [],
44
+ };
45
+ case "suppressed":
46
+ return {
47
+ decision: {
48
+ outcome: "not-applicable",
49
+ reasonCode: `message_suppressed_${event.reasonCode}`,
50
+ },
51
+ remediation: [
52
+ {
53
+ code: "revise_suppressed_message",
54
+ text: "Revise the outbound content or remove the suppressing hook before retrying.",
55
+ },
56
+ ],
57
+ };
58
+ case "failed":
59
+ return {
60
+ decision: {
61
+ outcome: "unknown",
62
+ reasonCode: `message_delivery_failed_${event.failureStage}`,
63
+ },
64
+ remediation: [
65
+ {
66
+ code: "inspect_delivery_failure",
67
+ text: "Inspect channel configuration and delivery logs before retrying the message.",
68
+ },
69
+ ],
70
+ };
71
+ case "unknown":
72
+ return {
73
+ decision: {
74
+ outcome: "unknown",
75
+ reasonCode: `message_delivery_unknown_${event.failureStage}`,
76
+ },
77
+ remediation: [
78
+ {
79
+ code: "reconcile_delivery_outcome",
80
+ text: "Reconcile the platform delivery outcome before retrying to avoid a duplicate message.",
81
+ },
82
+ ],
83
+ };
84
+ }
85
+ throw new Error("unsupported outbound message outcome");
86
+ }
87
+
88
+ function projectMessageDeliveryReceipt(
89
+ event: OutboundMessageAuditEventRecord,
90
+ context: ExecutionIdentityContextV1,
91
+ ): DecisionReceiptV1 {
92
+ const resourceRef = `channel:${event.channel}`;
93
+ const outcome = messageOutcome(event);
94
+ return {
95
+ schemaVersion: 1,
96
+ receiptId: `message:${event.eventId}`,
97
+ contextId: context.contextId,
98
+ executionId: context.executionId,
99
+ runId: context.runId,
100
+ actionId: event.eventId,
101
+ occurredAt: event.occurredAt,
102
+ action: {
103
+ family: "message",
104
+ operation: "send",
105
+ ...(resourceRef.length <= 256 ? { resourceRef } : {}),
106
+ ...(event.targetRef ? { targetRef: event.targetRef } : {}),
107
+ summary: `Outbound message lifecycle: ${event.outcome.replaceAll("_", "-")}.`,
108
+ },
109
+ decision: outcome.decision,
110
+ enforcement: {
111
+ coverageState: "attribution-only",
112
+ evaluatorRef: "outbound-delivery",
113
+ policyRefs: [],
114
+ grantRefs: [],
115
+ contextFieldsUsed: ["contextId", "executionId", "runId"],
116
+ },
117
+ source: {
118
+ owner:
119
+ event.action === "message.outbound.finished" ? "audit_events" : "outbound_message_progress",
120
+ recordRef: event.eventId,
121
+ decisionBoundary: event.action,
122
+ },
123
+ // Exact linkage remains diagnostic provenance. Delivery never becomes an
124
+ // authorization claim merely because it belongs to this execution.
125
+ missingEvidence: [],
126
+ remediation: outcome.remediation,
127
+ };
128
+ }
129
+
130
+ export function summarizeMessageDeliveryReceiptsForRun(params: {
131
+ context: ExecutionIdentityContextV1;
132
+ options: MessageDeliveryReadOptions;
133
+ }): { count: number; coverageState?: "attribution-only"; missingEvidence: string[] } {
134
+ const count = countOutboundMessageAuditEventsForRun({
135
+ runId: params.context.runId,
136
+ contextId: params.context.contextId,
137
+ executionId: params.context.executionId,
138
+ now: params.options.now,
139
+ database: params.options,
140
+ });
141
+ return {
142
+ count,
143
+ ...(count > 0 ? { coverageState: "attribution-only" as const } : {}),
144
+ missingEvidence: [],
145
+ };
146
+ }
147
+
148
+ export function pageMessageDeliveryReceiptsForRun(params: {
149
+ context: ExecutionIdentityContextV1;
150
+ after?: OutboundMessageAuditEventCursor;
151
+ offset?: number;
152
+ limit: number;
153
+ options: MessageDeliveryReadOptions;
154
+ }): {
155
+ entries: Array<{ receipt: DecisionReceiptV1; selectorId: string }>;
156
+ nextCursor?: OutboundMessageAuditEventCursor;
157
+ } {
158
+ const page = pageOutboundMessageAuditEventsForRun({
159
+ runId: params.context.runId,
160
+ contextId: params.context.contextId,
161
+ executionId: params.context.executionId,
162
+ after: params.after,
163
+ offset: params.offset,
164
+ limit: params.limit,
165
+ now: params.options.now,
166
+ database: params.options,
167
+ });
168
+ return {
169
+ entries: page.entries.map(({ event, rowId }) => ({
170
+ receipt: projectMessageDeliveryReceipt(event, params.context),
171
+ selectorId: `message-decision:${rowId}`,
172
+ })),
173
+ ...(page.nextCursor ? { nextCursor: page.nextCursor } : {}),
174
+ };
175
+ }
src/audit/message-execution-binding.ts ADDED
@@ -0,0 +1,156 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** Exact execution binding for owner-native outbound message lifecycle facts. */
2
+ import type { DatabaseSync } from "node:sqlite";
3
+ import {
4
+ executeSqliteQuerySync,
5
+ executeSqliteQueryTakeFirstSync,
6
+ getNodeSqliteKysely,
7
+ } from "../infra/kysely-sync.js";
8
+ import { tableExists, tableHasColumn } from "../state/openclaw-state-db-schema-helpers.js";
9
+ import type { DB as OpenClawStateKyselyDatabase } from "../state/openclaw-state-db.generated.js";
10
+ import {
11
+ openOpenClawStateDatabase,
12
+ runOpenClawStateWriteTransaction,
13
+ type OpenClawStateDatabaseOptions,
14
+ } from "../state/openclaw-state-db.js";
15
+ import { OPENCLAW_STATE_SCHEMA_SQL } from "../state/openclaw-state-schema.js";
16
+ import {
17
+ parseExecutionIdentityAdmissionToken,
18
+ type ExecutionIdentityAdmissionToken,
19
+ } from "./execution-identity-admission.js";
20
+
21
+ type ExecutionBindingDatabase = Pick<
22
+ OpenClawStateKyselyDatabase,
23
+ "execution_identity_contexts" | "outbound_message_execution_bindings"
24
+ >;
25
+ const ensuredTerminalBindingDatabases = new WeakSet<DatabaseSync>();
26
+
27
+ export type MessageExecutionBinding = Readonly<{
28
+ contextId: string;
29
+ executionId: string;
30
+ }>;
31
+
32
+ export function selectMessageExecutionBinding(params: {
33
+ contextId?: string;
34
+ executionId?: string;
35
+ }): MessageExecutionBinding | undefined {
36
+ if (params.contextId === undefined && params.executionId === undefined) {
37
+ return undefined;
38
+ }
39
+ if (!params.contextId || !params.executionId) {
40
+ throw new Error("outbound message decision query requires the exact context and execution");
41
+ }
42
+ return { contextId: params.contextId, executionId: params.executionId };
43
+ }
44
+
45
+ export function hasMessageExecutionBindingColumns(
46
+ db: DatabaseSync,
47
+ tableName: "outbound_message_progress",
48
+ ): boolean {
49
+ return (
50
+ tableHasColumn(db, tableName, "context_id") && tableHasColumn(db, tableName, "execution_id")
51
+ );
52
+ }
53
+
54
+ function terminalBindingSchemaSql(): string {
55
+ const start = OPENCLAW_STATE_SCHEMA_SQL.indexOf(
56
+ "CREATE TABLE IF NOT EXISTS outbound_message_execution_bindings (",
57
+ );
58
+ const indexStart = OPENCLAW_STATE_SCHEMA_SQL.indexOf(
59
+ "CREATE INDEX IF NOT EXISTS outbound_message_execution_bindings_execution_event_idx",
60
+ start,
61
+ );
62
+ const end = indexStart >= 0 ? OPENCLAW_STATE_SCHEMA_SQL.indexOf(";", indexStart) : -1;
63
+ if (start < 0 || end < 0) {
64
+ throw new Error("canonical outbound message execution binding schema is missing");
65
+ }
66
+ return OPENCLAW_STATE_SCHEMA_SQL.slice(start, end + 1);
67
+ }
68
+
69
+ /** Install the terminal binding companion only when an exact producer first uses it. */
70
+ export function ensureTerminalMessageExecutionBindingSchema(
71
+ options: OpenClawStateDatabaseOptions,
72
+ ): void {
73
+ const database = openOpenClawStateDatabase(options);
74
+ if (ensuredTerminalBindingDatabases.has(database.db)) {
75
+ return;
76
+ }
77
+ runOpenClawStateWriteTransaction(
78
+ ({ db }) => {
79
+ // sqlite-allow-raw -- feature-local additive schema DDL; bindings use Kysely.
80
+ db.exec(terminalBindingSchemaSql());
81
+ },
82
+ { ...options, database },
83
+ { operationLabel: "audit.outbound-message.execution-binding.schema.ensure" },
84
+ );
85
+ ensuredTerminalBindingDatabases.add(database.db);
86
+ }
87
+
88
+ /** Validate queue-loaded token bytes before entering a synchronous write transaction. */
89
+ export function planMessageExecutionBinding(
90
+ token: ExecutionIdentityAdmissionToken | undefined,
91
+ runId: string | undefined,
92
+ ): ExecutionIdentityAdmissionToken | undefined {
93
+ if (!token) {
94
+ return undefined;
95
+ }
96
+ const planned = parseExecutionIdentityAdmissionToken(token);
97
+ if (!runId || planned.runId !== runId) {
98
+ throw new Error("outbound message execution binding disagrees with the admitted run");
99
+ }
100
+ return planned;
101
+ }
102
+
103
+ /** Confirm the exact retained admission row; run correlation alone never binds a receipt. */
104
+ export function confirmMessageExecutionBinding(
105
+ db: DatabaseSync,
106
+ token: ExecutionIdentityAdmissionToken | undefined,
107
+ ): MessageExecutionBinding | undefined {
108
+ if (!token || !tableExists(db, "execution_identity_contexts")) {
109
+ return undefined;
110
+ }
111
+ const row = executeSqliteQueryTakeFirstSync(
112
+ db,
113
+ getNodeSqliteKysely<ExecutionBindingDatabase>(db)
114
+ .selectFrom("execution_identity_contexts")
115
+ .select("context_id")
116
+ .where("context_id", "=", token.contextId)
117
+ .where("execution_id", "=", token.executionId)
118
+ .where("run_id", "=", token.runId)
119
+ .where("created_at", "=", token.createdAt),
120
+ );
121
+ return row ? { contextId: token.contextId, executionId: token.executionId } : undefined;
122
+ }
123
+
124
+ function recordTerminalMessageExecutionBinding(
125
+ db: DatabaseSync,
126
+ params: MessageExecutionBinding & { eventId: string; runId: string },
127
+ ): void {
128
+ executeSqliteQuerySync(
129
+ db,
130
+ getNodeSqliteKysely<ExecutionBindingDatabase>(db)
131
+ .insertInto("outbound_message_execution_bindings")
132
+ .values({
133
+ event_id: params.eventId,
134
+ context_id: params.contextId,
135
+ execution_id: params.executionId,
136
+ run_id: params.runId,
137
+ }),
138
+ );
139
+ }
140
+
141
+ export function recordConfirmedTerminalMessageExecutionBinding(
142
+ db: DatabaseSync,
143
+ params: { eventId: string | undefined; token?: ExecutionIdentityAdmissionToken },
144
+ ): void {
145
+ if (!params.eventId) {
146
+ return;
147
+ }
148
+ const binding = confirmMessageExecutionBinding(db, params.token);
149
+ if (binding && params.token) {
150
+ recordTerminalMessageExecutionBinding(db, {
151
+ eventId: params.eventId,
152
+ runId: params.token.runId,
153
+ ...binding,
154
+ });
155
+ }
156
+ }
src/canvas/config.ts ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** Core Canvas host enablement from the shipped Canvas plugin configuration surface. */
2
+ import { isRecord } from "@openclaw/normalization-core/record-coerce";
3
+ import type { OpenClawConfig } from "../config/types.openclaw.js";
4
+ import { isTruthyEnvValue } from "../infra/env.js";
5
+
6
+ /** Returns whether core-owned widget hosting and tools should be active. */
7
+ export function isCoreCanvasHostEnabled(
8
+ config?: OpenClawConfig,
9
+ env: NodeJS.ProcessEnv = process.env,
10
+ ): boolean {
11
+ // Canvas owned these shipped operator switches before hosting moved into core.
12
+ // Core keeps reading them so existing disablement still covers the whole Canvas family.
13
+ if (isTruthyEnvValue(env.OPENCLAW_SKIP_CANVAS_HOST)) {
14
+ return false;
15
+ }
16
+ const host = config?.plugins?.entries?.canvas?.config?.host;
17
+ return !isRecord(host) || host.enabled !== false;
18
+ }
src/canvas/constants.test.ts ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ isCanvasDocumentHttpPath,
4
+ resolveCanvasNodeCapability,
5
+ withCoreCanvasNodeCapability,
6
+ } from "./constants.js";
7
+
8
+ describe("core canvas contracts", () => {
9
+ it("keeps document auth scoped to the stable Canvas path", () => {
10
+ expect(isCanvasDocumentHttpPath("/__openclaw__/canvas/documents/cv_1/index.html")).toBe(true);
11
+ expect(isCanvasDocumentHttpPath("/__openclaw__/canvas/index.html")).toBe(false);
12
+ expect(
13
+ resolveCanvasNodeCapability([
14
+ "/encoded-path",
15
+ "/__openclaw__/canvas/documents/cv_1/index.html",
16
+ ]),
17
+ ).toEqual({ surface: "canvas", scopeKey: "canvas:canvas" });
18
+ });
19
+
20
+ it("advertises one core-owned canvas surface with the historical scope key", () => {
21
+ expect(
22
+ withCoreCanvasNodeCapability([
23
+ { surface: "canvas", scopeKey: "canvas:canvas" },
24
+ { surface: "files", scopeKey: "files:files" },
25
+ ]),
26
+ ).toEqual([
27
+ { surface: "canvas", scopeKey: "canvas:canvas" },
28
+ { surface: "files", scopeKey: "files:files" },
29
+ ]);
30
+ });
31
+ });
src/canvas/constants.ts ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { PluginNodeCapabilitySurface } from "../gateway/plugin-node-capability.js";
2
+
3
+ /** Stable Gateway path used by chat clients for hosted Canvas content. */
4
+ const CANVAS_HOST_PATH = "/__openclaw__/canvas";
5
+ export const CANVAS_DOCUMENTS_PATH = `${CANVAS_HOST_PATH}/documents`;
6
+
7
+ /** Keep the historical Canvas plugin scope so existing capability URLs remain valid. */
8
+ const CANVAS_NODE_CAPABILITY: PluginNodeCapabilitySurface = {
9
+ surface: "canvas",
10
+ scopeKey: "canvas:canvas",
11
+ };
12
+
13
+ /** Returns true only for the core-owned Canvas document subtree. */
14
+ export function isCanvasDocumentHttpPath(pathname: string): boolean {
15
+ return pathname.startsWith(`${CANVAS_DOCUMENTS_PATH}/`);
16
+ }
17
+
18
+ /** Resolves auth for any canonicalized candidate targeting core Canvas documents. */
19
+ export function resolveCanvasNodeCapability(
20
+ pathCandidates: readonly string[],
21
+ ): PluginNodeCapabilitySurface | undefined {
22
+ return pathCandidates.some(isCanvasDocumentHttpPath) ? CANVAS_NODE_CAPABILITY : undefined;
23
+ }
24
+
25
+ /** Adds the core Canvas surface while removing stale plugin-owned duplicates. */
26
+ export function withCoreCanvasNodeCapability(
27
+ surfaces: readonly PluginNodeCapabilitySurface[],
28
+ enabled = true,
29
+ ): PluginNodeCapabilitySurface[] {
30
+ const withoutStaleCanvas = surfaces.filter(
31
+ (entry) => entry.surface.trim() !== CANVAS_NODE_CAPABILITY.surface,
32
+ );
33
+ return enabled ? [CANVAS_NODE_CAPABILITY, ...withoutStaleCanvas] : withoutStaleCanvas;
34
+ }
src/canvas/documents.test.ts ADDED
@@ -0,0 +1,270 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Core Canvas document storage and URL contract coverage.
2
+ import { link, mkdir, readFile, rename, symlink, writeFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { afterEach, describe, expect, it } from "vitest";
5
+ import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
6
+ import {
7
+ createCanvasDocument,
8
+ readCanvasDocumentHtmlSource,
9
+ resolveCanvasDocumentsDir,
10
+ resolveCanvasHttpPathToLocalPath,
11
+ } from "./documents.js";
12
+
13
+ const tempDirs = useAutoCleanupTempDirTracker(afterEach);
14
+
15
+ function resolveCanvasDocumentDir(stateDir: string, documentId: string): string {
16
+ return path.join(resolveCanvasDocumentsDir(stateDir), documentId);
17
+ }
18
+
19
+ describe("canvas documents", () => {
20
+ it.skipIf(process.platform === "win32").each(["document", "manifest.json", "index.html"])(
21
+ "rejects a symlinked %s when reading widget HTML",
22
+ async (target) => {
23
+ const stateDir = tempDirs.make("openclaw-canvas-links-");
24
+ const outsideDir = tempDirs.make("openclaw-canvas-outside-");
25
+ const document = await createCanvasDocument(
26
+ { kind: "html_bundle", entrypoint: { type: "html", value: "<p>outside</p>" } },
27
+ { stateDir },
28
+ );
29
+ const documentDir = resolveCanvasDocumentDir(stateDir, document.id);
30
+ const original = target === "document" ? documentDir : path.join(documentDir, target);
31
+ const outside = path.join(outsideDir, target);
32
+ await rename(original, outside);
33
+ await symlink(outside, original, target === "document" ? "dir" : "file");
34
+
35
+ await expect(readCanvasDocumentHtmlSource(document.id, { stateDir })).rejects.toMatchObject({
36
+ code: target === "document" ? "outside-workspace" : "symlink",
37
+ });
38
+ },
39
+ );
40
+
41
+ it.each(["manifest.json", "index.html"])(
42
+ "rejects a hardlinked %s when reading widget HTML",
43
+ async (target) => {
44
+ const stateDir = tempDirs.make("openclaw-canvas-hardlinks-");
45
+ const document = await createCanvasDocument(
46
+ { kind: "html_bundle", entrypoint: { type: "html", value: "<p>aliased</p>" } },
47
+ { stateDir },
48
+ );
49
+ await link(
50
+ path.join(resolveCanvasDocumentDir(stateDir, document.id), target),
51
+ path.join(stateDir, `alias-${target}`),
52
+ );
53
+ await expect(readCanvasDocumentHtmlSource(document.id, { stateDir })).rejects.toMatchObject({
54
+ code: "hardlink",
55
+ });
56
+ },
57
+ );
58
+
59
+ it("bounds HTML reads by bytes while independently allowing the manifest", async () => {
60
+ const stateDir = tempDirs.make("openclaw-canvas-bounded-");
61
+ const document = await createCanvasDocument(
62
+ { kind: "html_bundle", entrypoint: { type: "html", value: "éééé" } },
63
+ { stateDir },
64
+ );
65
+ await expect(
66
+ readCanvasDocumentHtmlSource(document.id, { stateDir, maxBytes: 7 }),
67
+ ).rejects.toMatchObject({ code: "too-large" });
68
+ await expect(
69
+ readCanvasDocumentHtmlSource(document.id, { stateDir, maxBytes: 8 }),
70
+ ).resolves.toEqual({ html: "éééé" });
71
+ });
72
+
73
+ it("rejects oversized manifests before parsing them", async () => {
74
+ const stateDir = tempDirs.make("openclaw-canvas-manifest-");
75
+ const document = await createCanvasDocument(
76
+ { kind: "html_bundle", entrypoint: { type: "html", value: "<p>small</p>" } },
77
+ { stateDir },
78
+ );
79
+ const manifestPath = path.join(
80
+ resolveCanvasDocumentDir(stateDir, document.id),
81
+ "manifest.json",
82
+ );
83
+ await writeFile(
84
+ manifestPath,
85
+ JSON.stringify({ ...document, title: "x".repeat(2 * 1024 * 1024) }),
86
+ );
87
+ await expect(readCanvasDocumentHtmlSource(document.id, { stateDir })).rejects.toMatchObject({
88
+ code: "too-large",
89
+ });
90
+ });
91
+
92
+ it("builds entry urls for materialized path documents under managed storage", async () => {
93
+ const stateDir = tempDirs.make("openclaw-canvas-documents-");
94
+ const workspaceDir = tempDirs.make("openclaw-canvas-documents-workspace-");
95
+ await mkdir(path.join(workspaceDir, "player"), { recursive: true });
96
+ await writeFile(path.join(workspaceDir, "player/index.html"), "<div>ok</div>", "utf8");
97
+
98
+ const document = await createCanvasDocument(
99
+ {
100
+ kind: "html_bundle",
101
+ entrypoint: { type: "path", value: "player/index.html" },
102
+ },
103
+ { stateDir, workspaceDir },
104
+ );
105
+
106
+ expect(document.entryUrl).toContain("/__openclaw__/canvas/documents/");
107
+ expect(document.localEntrypoint).toBe("index.html");
108
+ expect(resolveCanvasDocumentDir(stateDir, document.id)).toContain(stateDir);
109
+ });
110
+
111
+ it("materializes inline html bundles as index documents", async () => {
112
+ const stateDir = tempDirs.make("openclaw-canvas-documents-");
113
+ const document = await createCanvasDocument(
114
+ {
115
+ kind: "html_bundle",
116
+ title: "Preview",
117
+ entrypoint: {
118
+ type: "html",
119
+ value:
120
+ "<!doctype html><html><head><style>.demo{color:red}</style></head><body><div class='demo'>Front</div></body></html>",
121
+ },
122
+ },
123
+ { stateDir },
124
+ );
125
+
126
+ const indexHtml = await readFile(
127
+ path.join(resolveCanvasDocumentDir(stateDir, document.id), "index.html"),
128
+ "utf8",
129
+ );
130
+ expect(indexHtml).toContain("<div class='demo'>Front</div>");
131
+ expect(indexHtml).toContain("<style>.demo{color:red}</style>");
132
+ expect(document.title).toBe("Preview");
133
+ expect(document.entryUrl).toBe(`/__openclaw__/canvas/documents/${document.id}/index.html`);
134
+ await expect(readCanvasDocumentHtmlSource(document.id, { stateDir })).resolves.toEqual({
135
+ html: indexHtml,
136
+ });
137
+ });
138
+
139
+ it("reports the document sandbox policy alongside board source bytes", async () => {
140
+ const stateDir = tempDirs.make("openclaw-canvas-documents-");
141
+ const document = await createCanvasDocument(
142
+ {
143
+ kind: "html_bundle",
144
+ entrypoint: { type: "html", value: "<script>ready()</script>" },
145
+ cspSandbox: "scripts",
146
+ },
147
+ { stateDir },
148
+ );
149
+
150
+ await expect(readCanvasDocumentHtmlSource(document.id, { stateDir })).resolves.toEqual({
151
+ html: "<script>ready()</script>",
152
+ cspSandbox: "scripts",
153
+ });
154
+ });
155
+
156
+ it("reuses a supplied stable id by replacing the prior materialized view", async () => {
157
+ const stateDir = tempDirs.make("openclaw-canvas-documents-");
158
+ const first = await createCanvasDocument(
159
+ {
160
+ id: "status-card",
161
+ kind: "html_bundle",
162
+ entrypoint: { type: "html", value: "<div>first</div>" },
163
+ },
164
+ { stateDir },
165
+ );
166
+ const second = await createCanvasDocument(
167
+ {
168
+ id: "status-card",
169
+ kind: "html_bundle",
170
+ entrypoint: { type: "html", value: "<div>second</div>" },
171
+ },
172
+ { stateDir },
173
+ );
174
+
175
+ expect(first.id).toBe("status-card");
176
+ expect(second.id).toBe("status-card");
177
+ const indexHtml = await readFile(
178
+ path.join(resolveCanvasDocumentDir(stateDir, second.id), "index.html"),
179
+ "utf8",
180
+ );
181
+ expect(indexHtml).toContain("second");
182
+ expect(indexHtml).not.toContain("first");
183
+ });
184
+
185
+ it("copies declared assets into managed storage", async () => {
186
+ const stateDir = tempDirs.make("openclaw-canvas-documents-");
187
+ const workspaceDir = tempDirs.make("openclaw-canvas-documents-workspace-");
188
+ await mkdir(path.join(workspaceDir, "collection.media"), { recursive: true });
189
+ await writeFile(path.join(workspaceDir, "collection.media/audio.mp3"), "audio", "utf8");
190
+
191
+ const document = await createCanvasDocument(
192
+ {
193
+ kind: "html_bundle",
194
+ entrypoint: { type: "html", value: "<audio></audio>" },
195
+ assets: [
196
+ {
197
+ logicalPath: "collection.media/audio.mp3",
198
+ sourcePath: "collection.media/audio.mp3",
199
+ contentType: "audio/mpeg",
200
+ },
201
+ ],
202
+ },
203
+ { stateDir, workspaceDir },
204
+ );
205
+
206
+ expect(document.assets).toEqual([
207
+ { logicalPath: "collection.media/audio.mp3", contentType: "audio/mpeg" },
208
+ ]);
209
+ await expect(
210
+ readFile(
211
+ path.join(resolveCanvasDocumentDir(stateDir, document.id), "collection.media/audio.mp3"),
212
+ "utf8",
213
+ ),
214
+ ).resolves.toBe("audio");
215
+ });
216
+
217
+ it("wraps local and remote PDF documents in index viewer pages", async () => {
218
+ const stateDir = tempDirs.make("openclaw-canvas-documents-");
219
+ const workspaceDir = tempDirs.make("openclaw-canvas-documents-workspace-");
220
+ await writeFile(path.join(workspaceDir, "demo.pdf"), "%PDF-1.4", "utf8");
221
+ const localDocument = await createCanvasDocument(
222
+ { kind: "document", entrypoint: { type: "path", value: "demo.pdf" } },
223
+ { stateDir, workspaceDir },
224
+ );
225
+ const remoteDocument = await createCanvasDocument(
226
+ {
227
+ kind: "document",
228
+ entrypoint: { type: "url", value: "https://example.com/demo.pdf" },
229
+ },
230
+ { stateDir },
231
+ );
232
+
233
+ const localHtml = await readFile(
234
+ path.join(resolveCanvasDocumentDir(stateDir, localDocument.id), "index.html"),
235
+ "utf8",
236
+ );
237
+ const remoteHtml = await readFile(
238
+ path.join(resolveCanvasDocumentDir(stateDir, remoteDocument.id), "index.html"),
239
+ "utf8",
240
+ );
241
+ expect(localHtml).toContain('data="demo.pdf"');
242
+ expect(remoteHtml).toContain('data="https://example.com/demo.pdf"');
243
+ });
244
+
245
+ it("rejects traversal and malformed encoded hosted paths", async () => {
246
+ const stateDir = tempDirs.make("openclaw-canvas-documents-");
247
+ expect(
248
+ resolveCanvasHttpPathToLocalPath(
249
+ "/__openclaw__/canvas/documents/../collection.media/index.html",
250
+ { stateDir },
251
+ ),
252
+ ).toBeNull();
253
+
254
+ const documentDir = resolveCanvasDocumentDir(stateDir, "cv_malformed");
255
+ await mkdir(documentDir, { recursive: true });
256
+ await writeFile(path.join(documentDir, "%E0%A4%A.html"), "literal-percent-name", "utf8");
257
+ expect(
258
+ resolveCanvasHttpPathToLocalPath(
259
+ "/__openclaw__/canvas/documents/cv_malformed/%E0%A4%A.html",
260
+ { stateDir },
261
+ ),
262
+ ).toBeNull();
263
+ expect(
264
+ resolveCanvasHttpPathToLocalPath(
265
+ "/__openclaw__/canvas/documents/cv_malformed/%25E0%25A4%25A.html",
266
+ { stateDir },
267
+ ),
268
+ ).toBe(path.join(documentDir, "%E0%A4%A.html"));
269
+ });
270
+ });
src/canvas/documents.ts ADDED
@@ -0,0 +1,379 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** Core Canvas document materialization and hosted-path resolution. */
2
+ import { randomUUID } from "node:crypto";
3
+ import fs from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { resolveStateDir } from "../config/paths.js";
6
+ import { sanitizeUntrustedFileName } from "../infra/fs-safe-advanced.js";
7
+ import { root as fsRoot } from "../infra/fs-safe.js";
8
+ import { escapeHtml } from "../shared/html-escape.js";
9
+ import { resolveUserPath } from "../utils.js";
10
+ import { CANVAS_DOCUMENTS_PATH } from "./constants.js";
11
+
12
+ const CANVAS_DOCUMENT_READ_MAX_BYTES = 2 * 1024 * 1024;
13
+
14
+ type CanvasDocumentKind = "html_bundle" | "url_embed" | "document" | "image" | "video_asset";
15
+
16
+ type CanvasDocumentAsset = {
17
+ logicalPath: string;
18
+ sourcePath: string;
19
+ contentType?: string;
20
+ };
21
+
22
+ type CanvasDocumentEntrypoint =
23
+ | { type: "html"; value: string }
24
+ | { type: "path"; value: string }
25
+ | { type: "url"; value: string };
26
+
27
+ type CanvasDocumentCreateInput = {
28
+ id?: string;
29
+ kind: CanvasDocumentKind;
30
+ title?: string;
31
+ preferredHeight?: number;
32
+ entrypoint?: CanvasDocumentEntrypoint;
33
+ assets?: CanvasDocumentAsset[];
34
+ surface?: "assistant_message" | "tool_card" | "sidebar";
35
+ retentionScope?: string;
36
+ /** Serve with a CSP sandbox header so direct opens get an opaque origin. */
37
+ cspSandbox?: "scripts";
38
+ };
39
+
40
+ export type CanvasDocumentManifest = {
41
+ id: string;
42
+ kind: CanvasDocumentKind;
43
+ title?: string;
44
+ preferredHeight?: number;
45
+ createdAt: string;
46
+ entryUrl: string;
47
+ localEntrypoint?: string;
48
+ externalUrl?: string;
49
+ surface?: "assistant_message" | "tool_card" | "sidebar";
50
+ retentionScope?: string;
51
+ cspSandbox?: "scripts";
52
+ assets: Array<{
53
+ logicalPath: string;
54
+ contentType?: string;
55
+ }>;
56
+ };
57
+
58
+ function isPdfPathLike(value: string): boolean {
59
+ return /\.pdf(?:[?#].*)?$/i.test(value.trim());
60
+ }
61
+
62
+ function buildPdfWrapper(url: string): string {
63
+ const escaped = escapeHtml(url);
64
+ return `<!doctype html><html><body style="margin:0;background:#e5e7eb;"><object data="${escaped}" type="application/pdf" style="width:100%;height:100vh;border:0;"><iframe src="${escaped}" style="width:100%;height:100vh;border:0;"></iframe><p style="padding:16px;font:14px system-ui,sans-serif;">Unable to render PDF preview. <a href="${escaped}" target="_blank" rel="noopener noreferrer">Open PDF</a>.</p></object></body></html>`;
65
+ }
66
+
67
+ function hasControlCharacter(value: string): boolean {
68
+ for (const char of value) {
69
+ const code = char.charCodeAt(0);
70
+ if (code < 0x20 || code === 0x7f) {
71
+ return true;
72
+ }
73
+ }
74
+ return false;
75
+ }
76
+
77
+ function normalizeLogicalPath(value: string): string {
78
+ const normalized = value.replaceAll("\\", "/").replace(/^\/+/, "");
79
+ const parts = normalized.split("/").filter(Boolean);
80
+ if (
81
+ parts.length === 0 ||
82
+ parts.some(
83
+ (part) => part === "." || part === ".." || part.includes(":") || hasControlCharacter(part),
84
+ )
85
+ ) {
86
+ throw new Error("canvas document logicalPath invalid");
87
+ }
88
+ return parts.join("/");
89
+ }
90
+
91
+ function canvasDocumentId(): string {
92
+ return `cv_${randomUUID().replaceAll("-", "")}`;
93
+ }
94
+
95
+ function normalizeCanvasDocumentId(value: string): string {
96
+ const normalized = value.trim();
97
+ if (
98
+ !normalized ||
99
+ normalized === "." ||
100
+ normalized === ".." ||
101
+ !/^[A-Za-z0-9._-]+$/.test(normalized)
102
+ ) {
103
+ throw new Error("canvas document id invalid");
104
+ }
105
+ return normalized;
106
+ }
107
+
108
+ /** Stable root for existing and newly created Canvas documents. */
109
+ export function resolveCanvasDocumentsDir(stateDir = resolveStateDir()): string {
110
+ return path.resolve(stateDir, "canvas", "documents");
111
+ }
112
+
113
+ /** Reads the managed HTML entrypoint for a core Canvas document. */
114
+ export async function readCanvasDocumentHtmlSource(
115
+ documentId: string,
116
+ options?: { stateDir?: string; maxBytes?: number },
117
+ ): Promise<{ html: string; cspSandbox?: "scripts" }> {
118
+ const id = normalizeCanvasDocumentId(documentId);
119
+ // Keep the document directory inside the guarded root so aliases cannot select another document.
120
+ const root = await fsRoot(resolveCanvasDocumentsDir(options?.stateDir), {
121
+ maxBytes: CANVAS_DOCUMENT_READ_MAX_BYTES,
122
+ });
123
+ const manifest = await root.readJson<Partial<CanvasDocumentManifest>>(`${id}/manifest.json`);
124
+ if (manifest.id !== id || typeof manifest.localEntrypoint !== "string") {
125
+ throw new Error(`canvas document has no local entrypoint: ${id}`);
126
+ }
127
+ const entrypoint = normalizeLogicalPath(manifest.localEntrypoint);
128
+ if (!entrypoint.toLowerCase().endsWith(".html")) {
129
+ throw new Error(`canvas document entrypoint is not HTML: ${id}`);
130
+ }
131
+ return {
132
+ html: await root.readText(`${id}/${entrypoint}`, {
133
+ maxBytes: options?.maxBytes ?? CANVAS_DOCUMENT_READ_MAX_BYTES,
134
+ }),
135
+ ...(manifest.cspSandbox === "scripts" ? { cspSandbox: "scripts" as const } : {}),
136
+ };
137
+ }
138
+
139
+ async function pruneCanvasDocumentsForScope(params: {
140
+ documentsDir: string;
141
+ retentionScope: string;
142
+ maxDocuments: number;
143
+ }): Promise<void> {
144
+ const entries = await fs.readdir(params.documentsDir, { withFileTypes: true });
145
+ const scopedDocuments = (
146
+ await Promise.all(
147
+ entries
148
+ .filter((entry) => entry.isDirectory())
149
+ .map(async (entry) => {
150
+ try {
151
+ const manifest = JSON.parse(
152
+ await fs.readFile(
153
+ path.join(params.documentsDir, entry.name, "manifest.json"),
154
+ "utf8",
155
+ ),
156
+ ) as { createdAt?: unknown; retentionScope?: unknown };
157
+ if (
158
+ manifest.retentionScope !== params.retentionScope ||
159
+ typeof manifest.createdAt !== "string"
160
+ ) {
161
+ return null;
162
+ }
163
+ return { id: entry.name, createdAt: manifest.createdAt };
164
+ } catch {
165
+ return null;
166
+ }
167
+ }),
168
+ )
169
+ ).filter((entry): entry is { id: string; createdAt: string } => entry !== null);
170
+ const deleteCount = Math.max(0, scopedDocuments.length - params.maxDocuments);
171
+ const oldest = scopedDocuments
172
+ .toSorted(
173
+ (left, right) =>
174
+ left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id),
175
+ )
176
+ .slice(0, deleteCount);
177
+ await Promise.all(
178
+ oldest.map((entry) =>
179
+ fs.rm(path.join(params.documentsDir, entry.id), { recursive: true, force: true }),
180
+ ),
181
+ );
182
+ }
183
+
184
+ /** Resolves the on-disk directory for one Canvas document id. */
185
+ function resolveCanvasDocumentDir(documentId: string, options?: { stateDir?: string }): string {
186
+ return path.join(resolveCanvasDocumentsDir(options?.stateDir), documentId);
187
+ }
188
+
189
+ /** Builds the hosted URL path for a Canvas document entrypoint. */
190
+ function buildCanvasDocumentEntryUrl(documentId: string, entrypoint: string): string {
191
+ const normalizedEntrypoint = normalizeLogicalPath(entrypoint);
192
+ const encodedEntrypoint = normalizedEntrypoint
193
+ .split("/")
194
+ .map((segment) => encodeURIComponent(segment))
195
+ .join("/");
196
+ return `${CANVAS_DOCUMENTS_PATH}/${encodeURIComponent(documentId)}/${encodedEntrypoint}`;
197
+ }
198
+
199
+ /** Maps a Canvas hosted document URL path back to its managed local file. */
200
+ export function resolveCanvasHttpPathToLocalPath(
201
+ requestPath: string,
202
+ options?: { stateDir?: string },
203
+ ): string | null {
204
+ const trimmed = requestPath.trim();
205
+ const prefix = `${CANVAS_DOCUMENTS_PATH}/`;
206
+ if (!trimmed.startsWith(prefix)) {
207
+ return null;
208
+ }
209
+ const pathWithoutQuery = trimmed.replace(/[?#].*$/, "");
210
+ const relative = pathWithoutQuery.slice(prefix.length);
211
+ const segments: string[] = [];
212
+ for (const segment of relative.split("/")) {
213
+ if (!segment) {
214
+ continue;
215
+ }
216
+ try {
217
+ segments.push(decodeURIComponent(segment));
218
+ } catch {
219
+ return null;
220
+ }
221
+ }
222
+ if (segments.length < 2) {
223
+ return null;
224
+ }
225
+ const [rawDocumentId, ...entrySegments] = segments;
226
+ if (!rawDocumentId) {
227
+ return null;
228
+ }
229
+ try {
230
+ const documentId = normalizeCanvasDocumentId(rawDocumentId);
231
+ const normalizedEntrypoint = normalizeLogicalPath(entrySegments.join("/"));
232
+ const documentsDir = resolveCanvasDocumentsDir(options?.stateDir);
233
+ const candidatePath = path.resolve(
234
+ resolveCanvasDocumentDir(documentId, options),
235
+ normalizedEntrypoint,
236
+ );
237
+ if (!candidatePath.startsWith(`${documentsDir}${path.sep}`)) {
238
+ return null;
239
+ }
240
+ return candidatePath;
241
+ } catch {
242
+ return null;
243
+ }
244
+ }
245
+
246
+ type CanvasDocumentRoot = Awaited<ReturnType<typeof fsRoot>>;
247
+
248
+ async function copyAssets(
249
+ root: CanvasDocumentRoot,
250
+ assets: CanvasDocumentAsset[] | undefined,
251
+ workspaceDir: string,
252
+ ): Promise<CanvasDocumentManifest["assets"]> {
253
+ const copied: CanvasDocumentManifest["assets"] = [];
254
+ for (const asset of assets ?? []) {
255
+ const logicalPath = normalizeLogicalPath(asset.logicalPath);
256
+ const sourcePath = asset.sourcePath.startsWith("~")
257
+ ? resolveUserPath(asset.sourcePath)
258
+ : path.isAbsolute(asset.sourcePath)
259
+ ? path.resolve(asset.sourcePath)
260
+ : path.resolve(workspaceDir, asset.sourcePath);
261
+ await root.copyIn(logicalPath, sourcePath);
262
+ copied.push({
263
+ logicalPath,
264
+ ...(asset.contentType ? { contentType: asset.contentType } : {}),
265
+ });
266
+ }
267
+ return copied;
268
+ }
269
+
270
+ async function materializeEntrypoint(
271
+ rootDir: string,
272
+ root: CanvasDocumentRoot,
273
+ input: CanvasDocumentCreateInput,
274
+ workspaceDir: string,
275
+ ): Promise<Pick<CanvasDocumentManifest, "entryUrl" | "localEntrypoint" | "externalUrl">> {
276
+ const entrypoint = input.entrypoint;
277
+ if (!entrypoint) {
278
+ throw new Error("canvas document entrypoint required");
279
+ }
280
+ if (entrypoint.type === "html") {
281
+ const fileName = "index.html";
282
+ await root.write(fileName, entrypoint.value);
283
+ return {
284
+ localEntrypoint: fileName,
285
+ entryUrl: buildCanvasDocumentEntryUrl(path.basename(rootDir), fileName),
286
+ };
287
+ }
288
+ if (entrypoint.type === "url") {
289
+ if (input.kind === "document" && isPdfPathLike(entrypoint.value)) {
290
+ const fileName = "index.html";
291
+ await root.write(fileName, buildPdfWrapper(entrypoint.value));
292
+ return {
293
+ localEntrypoint: fileName,
294
+ externalUrl: entrypoint.value,
295
+ entryUrl: buildCanvasDocumentEntryUrl(path.basename(rootDir), fileName),
296
+ };
297
+ }
298
+ return { externalUrl: entrypoint.value, entryUrl: entrypoint.value };
299
+ }
300
+
301
+ const resolvedPath = entrypoint.value.startsWith("~")
302
+ ? resolveUserPath(entrypoint.value)
303
+ : path.isAbsolute(entrypoint.value)
304
+ ? path.resolve(entrypoint.value)
305
+ : path.resolve(workspaceDir, entrypoint.value);
306
+
307
+ if (input.kind === "image" || input.kind === "video_asset") {
308
+ const copiedName = sanitizeUntrustedFileName(path.basename(resolvedPath), "asset");
309
+ await root.copyIn(copiedName, resolvedPath);
310
+ const wrapper =
311
+ input.kind === "image"
312
+ ? `<!doctype html><html><body style="margin:0;background:#0f172a;display:flex;align-items:center;justify-content:center;"><img src="${escapeHtml(copiedName)}" style="max-width:100%;max-height:100vh;object-fit:contain;" /></body></html>`
313
+ : `<!doctype html><html><body style="margin:0;background:#0f172a;"><video src="${escapeHtml(copiedName)}" controls autoplay style="width:100%;height:100vh;object-fit:contain;background:#000;"></video></body></html>`;
314
+ await root.write("index.html", wrapper);
315
+ return {
316
+ localEntrypoint: "index.html",
317
+ entryUrl: buildCanvasDocumentEntryUrl(path.basename(rootDir), "index.html"),
318
+ };
319
+ }
320
+
321
+ const fileName = sanitizeUntrustedFileName(path.basename(resolvedPath), "document");
322
+ await root.copyIn(fileName, resolvedPath);
323
+ if (input.kind === "document" && isPdfPathLike(fileName)) {
324
+ await root.write("index.html", buildPdfWrapper(fileName));
325
+ return {
326
+ localEntrypoint: "index.html",
327
+ entryUrl: buildCanvasDocumentEntryUrl(path.basename(rootDir), "index.html"),
328
+ };
329
+ }
330
+ return {
331
+ localEntrypoint: fileName,
332
+ entryUrl: buildCanvasDocumentEntryUrl(path.basename(rootDir), fileName),
333
+ };
334
+ }
335
+
336
+ /** Creates a Canvas document directory, copies assets, and writes its manifest. */
337
+ export async function createCanvasDocument(
338
+ input: CanvasDocumentCreateInput,
339
+ options?: {
340
+ stateDir?: string;
341
+ workspaceDir?: string;
342
+ maxDocumentsPerScope?: number;
343
+ },
344
+ ): Promise<CanvasDocumentManifest> {
345
+ const workspaceDir = options?.workspaceDir ?? process.cwd();
346
+ const id = input.id?.trim() ? normalizeCanvasDocumentId(input.id) : canvasDocumentId();
347
+ const rootDir = resolveCanvasDocumentDir(id, { stateDir: options?.stateDir });
348
+ await fs.rm(rootDir, { recursive: true, force: true }).catch(() => undefined);
349
+ await fs.mkdir(rootDir, { recursive: true });
350
+ const root = await fsRoot(rootDir);
351
+ const assets = await copyAssets(root, input.assets, workspaceDir);
352
+ const entry = await materializeEntrypoint(rootDir, root, input, workspaceDir);
353
+ const manifest: CanvasDocumentManifest = {
354
+ id,
355
+ kind: input.kind,
356
+ ...(input.title?.trim() ? { title: input.title.trim() } : {}),
357
+ ...(typeof input.preferredHeight === "number"
358
+ ? { preferredHeight: input.preferredHeight }
359
+ : {}),
360
+ ...(input.surface ? { surface: input.surface } : {}),
361
+ ...(input.retentionScope ? { retentionScope: input.retentionScope } : {}),
362
+ ...(input.cspSandbox ? { cspSandbox: input.cspSandbox } : {}),
363
+ createdAt: new Date().toISOString(),
364
+ entryUrl: entry.entryUrl,
365
+ ...(entry.localEntrypoint ? { localEntrypoint: entry.localEntrypoint } : {}),
366
+ ...(entry.externalUrl ? { externalUrl: entry.externalUrl } : {}),
367
+ assets,
368
+ };
369
+ await root.writeJson("manifest.json", manifest, { space: 2 });
370
+ if (input.retentionScope && options?.maxDocumentsPerScope) {
371
+ // Bounded transcript widgets cannot grow managed Canvas storage without limit.
372
+ await pruneCanvasDocumentsForScope({
373
+ documentsDir: resolveCanvasDocumentsDir(options.stateDir),
374
+ retentionScope: input.retentionScope,
375
+ maxDocuments: options.maxDocumentsPerScope,
376
+ });
377
+ }
378
+ return manifest;
379
+ }
src/canvas/serve.runtime.test.ts ADDED
@@ -0,0 +1,194 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Core Canvas document HTTP response coverage.
2
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
3
+ import type { IncomingMessage, ServerResponse } from "node:http";
4
+ import { tmpdir } from "node:os";
5
+ import path from "node:path";
6
+ import { afterEach, describe, expect, it, vi } from "vitest";
7
+ import { createCanvasDocument, resolveCanvasDocumentsDir } from "./documents.js";
8
+ import { handleCanvasDocumentHttpRequest } from "./serve.runtime.js";
9
+ import { buildWidgetDocument } from "./wrap.js";
10
+
11
+ const tempDirs: string[] = [];
12
+
13
+ afterEach(async () => {
14
+ vi.unstubAllEnvs();
15
+ await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
16
+ });
17
+
18
+ async function createStateDir(): Promise<string> {
19
+ const stateDir = await mkdtemp(path.join(tmpdir(), "openclaw-canvas-serve-"));
20
+ tempDirs.push(stateDir);
21
+ vi.stubEnv("OPENCLAW_STATE_DIR", stateDir);
22
+ return stateDir;
23
+ }
24
+
25
+ async function capture(url: string, method = "GET") {
26
+ const response = {
27
+ statusCode: 200,
28
+ headers: {} as Record<string, number | string | string[]>,
29
+ body: Buffer.alloc(0) as Buffer,
30
+ setHeader(name: string, value: number | string | readonly string[]) {
31
+ this.headers[name.toLowerCase()] = typeof value === "object" ? [...value] : value;
32
+ return this;
33
+ },
34
+ end(chunk?: string | Buffer) {
35
+ this.body = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk ?? "");
36
+ return this;
37
+ },
38
+ };
39
+ const handled = await handleCanvasDocumentHttpRequest(
40
+ { method, url } as IncomingMessage,
41
+ response as unknown as ServerResponse,
42
+ );
43
+ return { handled, ...response, text: response.body.toString("utf8") };
44
+ }
45
+
46
+ describe("core canvas document host", () => {
47
+ it.each([
48
+ [
49
+ "stored",
50
+ `<!doctype html><meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline' 'self'"><script src="/__openclaw__/a2ui/a2ui.bundle.js"></script>`,
51
+ ],
52
+ [
53
+ "new",
54
+ buildWidgetDocument(
55
+ "Registered renderer",
56
+ '<script src="/__openclaw__/a2ui/a2ui-v0.9.bundle.js"></script>',
57
+ { scriptOrigins: ["'self'"] },
58
+ ),
59
+ ],
60
+ ])("preserves document-approved renderer sources for %s documents", async (_, html) => {
61
+ const stateDir = await createStateDir();
62
+ const document = await createCanvasDocument(
63
+ { kind: "html_bundle", entrypoint: { type: "html", value: html }, cspSandbox: "scripts" },
64
+ { stateDir },
65
+ );
66
+ const response = await capture(document.entryUrl);
67
+ const policy = String(response.headers["content-security-policy"]);
68
+ const scripts = policy
69
+ .split(";")
70
+ .find((directive) => directive.trim().startsWith("script-src "));
71
+ expect(scripts?.split(/\s+/)).toContain("'self'");
72
+ expect(policy).toContain("connect-src 'none'");
73
+ expect(policy).toContain("sandbox allow-scripts");
74
+ expect(response.text).toBe(html);
75
+ });
76
+
77
+ it("serves sandbox-marked HTML with the stable CSP header and no mutation", async () => {
78
+ const stateDir = await createStateDir();
79
+ const html = "<html><body>widget</body></html>";
80
+ const document = await createCanvasDocument(
81
+ {
82
+ id: "widget-1",
83
+ kind: "html_bundle",
84
+ entrypoint: { type: "html", value: html },
85
+ cspSandbox: "scripts",
86
+ },
87
+ { stateDir },
88
+ );
89
+
90
+ const response = await capture(document.entryUrl);
91
+ expect(response.handled).toBe(true);
92
+ expect(response.statusCode).toBe(200);
93
+ expect(response.headers["content-type"]).toBe("text/html; charset=utf-8");
94
+ expect(response.headers["cache-control"]).toBe("no-store");
95
+ const csp = String(response.headers["content-security-policy"]);
96
+ expect(csp).toContain("sandbox allow-scripts");
97
+ expect(csp).toContain("default-src 'none'");
98
+ expect(csp).toContain("https://cdn.jsdelivr.net");
99
+ expect(csp).toContain("font-src data:");
100
+ expect(csp).toContain("connect-src 'none'");
101
+ expect(response.headers["referrer-policy"]).toBe("no-referrer");
102
+ expect(response.text).toBe(html);
103
+ });
104
+
105
+ it("omits the sandbox response header for unmarked documents", async () => {
106
+ const stateDir = await createStateDir();
107
+ const document = await createCanvasDocument(
108
+ {
109
+ id: "plain-1",
110
+ kind: "html_bundle",
111
+ entrypoint: { type: "html", value: "<html><body>plain</body></html>" },
112
+ },
113
+ { stateDir },
114
+ );
115
+
116
+ const response = await capture(document.entryUrl);
117
+ expect(response.statusCode).toBe(200);
118
+ expect(response.headers["content-security-policy"]).toBeUndefined();
119
+ });
120
+
121
+ it("serves Content-Length on HEAD responses", async () => {
122
+ const stateDir = await createStateDir();
123
+ const html = "<html><body>widget</body></html>";
124
+ const document = await createCanvasDocument(
125
+ {
126
+ id: "widget-1",
127
+ kind: "html_bundle",
128
+ entrypoint: { type: "html", value: html },
129
+ cspSandbox: "scripts",
130
+ },
131
+ { stateDir },
132
+ );
133
+ const css = "body { color: red; }";
134
+ await writeFile(
135
+ path.join(resolveCanvasDocumentsDir(stateDir), "widget-1", "style.css"),
136
+ css,
137
+ "utf8",
138
+ );
139
+ const cssUrl = document.entryUrl.replace(/index\.html$/, "style.css");
140
+
141
+ const getHtml = await capture(document.entryUrl);
142
+ const headHtml = await capture(document.entryUrl, "HEAD");
143
+ expect(headHtml.statusCode).toBe(200);
144
+ expect(headHtml.headers["content-length"]).toBe(String(getHtml.body.byteLength));
145
+ expect(headHtml.body.byteLength).toBe(0);
146
+ expect(headHtml.headers["content-type"]).toBe("text/html; charset=utf-8");
147
+ expect(headHtml.headers["content-security-policy"]).toBe(
148
+ getHtml.headers["content-security-policy"],
149
+ );
150
+
151
+ const getCss = await capture(cssUrl);
152
+ const headCss = await capture(cssUrl, "HEAD");
153
+ expect(getCss.statusCode).toBe(200);
154
+ expect(headCss.statusCode).toBe(200);
155
+ expect(headCss.headers["content-length"]).toBe(String(getCss.body.byteLength));
156
+ expect(headCss.body.byteLength).toBe(0);
157
+
158
+ // Invalid UTF-8 in a copied HTML file expands to U+FFFD when served, so the
159
+ // header must be measured from the decoded representation, not raw bytes.
160
+ const brokenBytes = Buffer.from([
161
+ 0x3c, 0x68, 0x31, 0x3e, 0xff, 0xfe, 0x3c, 0x2f, 0x68, 0x31, 0x3e,
162
+ ]);
163
+ await writeFile(
164
+ path.join(resolveCanvasDocumentsDir(stateDir), "widget-1", "broken.html"),
165
+ brokenBytes,
166
+ );
167
+ const brokenUrl = document.entryUrl.replace(/index\.html$/, "broken.html");
168
+ const getBroken = await capture(brokenUrl);
169
+ const headBroken = await capture(brokenUrl, "HEAD");
170
+ expect(getBroken.statusCode).toBe(200);
171
+ expect(getBroken.body.byteLength).toBeGreaterThan(brokenBytes.byteLength);
172
+ expect(headBroken.headers["content-length"]).toBe(String(getBroken.body.byteLength));
173
+ expect(headBroken.body.byteLength).toBe(0);
174
+ });
175
+
176
+ it("rejects unsupported methods and traversal paths", async () => {
177
+ await createStateDir();
178
+ const methodResponse = await capture(
179
+ "/__openclaw__/canvas/documents/widget-1/index.html",
180
+ "POST",
181
+ );
182
+ expect(methodResponse.statusCode).toBe(405);
183
+ expect(methodResponse.text).toBe("Method Not Allowed");
184
+
185
+ const traversalResponse = await capture(
186
+ "/__openclaw__/canvas/documents/../widget-1/index.html",
187
+ );
188
+ expect(traversalResponse.handled).toBe(false);
189
+
190
+ const missingResponse = await capture("/__openclaw__/canvas/documents/widget-1/index.html");
191
+ expect(missingResponse.statusCode).toBe(404);
192
+ expect(missingResponse.text).toBe("not found");
193
+ });
194
+ });
src/canvas/serve.runtime.ts ADDED
@@ -0,0 +1,122 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** Gateway runtime for core-owned Canvas document HTTP responses. */
2
+ import type { IncomingMessage, ServerResponse } from "node:http";
3
+ import path from "node:path";
4
+ import { detectMime } from "@openclaw/media-core/mime";
5
+ import { buildBoardWidgetContentSecurityPolicy } from "../gateway/board-sandbox.js";
6
+ import { FsSafeError, root as fsRoot } from "../infra/fs-safe.js";
7
+ import {
8
+ resolveCanvasDocumentsDir,
9
+ resolveCanvasHttpPathToLocalPath,
10
+ type CanvasDocumentManifest,
11
+ } from "./documents.js";
12
+
13
+ async function readRootFile(root: Awaited<ReturnType<typeof fsRoot>>, relativePath: string) {
14
+ try {
15
+ const opened = await root.open(relativePath);
16
+ try {
17
+ return { data: await opened.handle.readFile(), realPath: opened.realPath };
18
+ } finally {
19
+ await opened.handle.close().catch(() => {});
20
+ }
21
+ } catch (error) {
22
+ if (error instanceof FsSafeError) {
23
+ return null;
24
+ }
25
+ throw error;
26
+ }
27
+ }
28
+
29
+ async function resolveDocumentSandbox(
30
+ root: Awaited<ReturnType<typeof fsRoot>>,
31
+ relativePath: string,
32
+ ): Promise<"scripts" | undefined> {
33
+ const documentId = relativePath.split(path.sep)[0];
34
+ if (!documentId) {
35
+ return undefined;
36
+ }
37
+ const opened = await readRootFile(root, path.join(documentId, "manifest.json"));
38
+ if (!opened) {
39
+ return undefined;
40
+ }
41
+ try {
42
+ const manifest = JSON.parse(opened.data.toString("utf8")) as CanvasDocumentManifest;
43
+ return manifest.cspSandbox === "scripts" ? "scripts" : undefined;
44
+ } catch {
45
+ return undefined;
46
+ }
47
+ }
48
+
49
+ /** Serves one managed Canvas document request. */
50
+ export async function handleCanvasDocumentHttpRequest(
51
+ req: IncomingMessage,
52
+ res: ServerResponse,
53
+ ): Promise<boolean> {
54
+ const localPath = resolveCanvasHttpPathToLocalPath(req.url ?? "");
55
+ if (!localPath) {
56
+ return false;
57
+ }
58
+ if (req.method !== "GET" && req.method !== "HEAD") {
59
+ res.statusCode = 405;
60
+ res.setHeader("Content-Type", "text/plain; charset=utf-8");
61
+ res.end("Method Not Allowed");
62
+ return true;
63
+ }
64
+
65
+ try {
66
+ const documentsDir = resolveCanvasDocumentsDir();
67
+ const relativePath = path.relative(documentsDir, localPath);
68
+ const root = await fsRoot(documentsDir);
69
+ const opened = await readRootFile(root, relativePath);
70
+ if (!opened) {
71
+ res.statusCode = 404;
72
+ res.setHeader("Content-Type", "text/plain; charset=utf-8");
73
+ res.end("not found");
74
+ return true;
75
+ }
76
+
77
+ const lowerPath = opened.realPath.toLowerCase();
78
+ const mime =
79
+ lowerPath.endsWith(".html") || lowerPath.endsWith(".htm")
80
+ ? "text/html"
81
+ : ((await detectMime({ filePath: opened.realPath })) ?? "application/octet-stream");
82
+ res.setHeader("Cache-Control", "no-store");
83
+ if (mime === "text/html") {
84
+ // Measure the decoded representation: toString("utf8") expands invalid
85
+ // bytes to U+FFFD, so the raw file length can differ from the body sent.
86
+ const body = opened.data.toString("utf8");
87
+ res.setHeader("Content-Type", "text/html; charset=utf-8");
88
+ res.setHeader("Content-Length", String(Buffer.byteLength(body)));
89
+ if ((await resolveDocumentSandbox(root, relativePath)) === "scripts") {
90
+ // Registered documents allow local renderer scripts in their own CSP;
91
+ // the response policy must preserve that permission because policies intersect.
92
+ res.setHeader(
93
+ "Content-Security-Policy",
94
+ buildBoardWidgetContentSecurityPolicy({
95
+ grantState: "none",
96
+ resourceOrigins: ["'self'"],
97
+ }),
98
+ );
99
+ res.setHeader("Referrer-Policy", "no-referrer");
100
+ }
101
+ if (req.method === "HEAD") {
102
+ res.end();
103
+ return true;
104
+ }
105
+ res.end(body);
106
+ return true;
107
+ }
108
+ res.setHeader("Content-Type", mime);
109
+ res.setHeader("Content-Length", String(opened.data.byteLength));
110
+ if (req.method === "HEAD") {
111
+ res.end();
112
+ return true;
113
+ }
114
+ res.end(opened.data);
115
+ return true;
116
+ } catch (error) {
117
+ res.statusCode = error instanceof FsSafeError ? 404 : 500;
118
+ res.setHeader("Content-Type", "text/plain; charset=utf-8");
119
+ res.end(error instanceof FsSafeError ? "not found" : "error");
120
+ return true;
121
+ }
122
+ }
src/canvas/widget-script-syntax.test.ts ADDED
@@ -0,0 +1,243 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, expect, it } from "vitest";
2
+ import { findWidgetScriptSyntaxError } from "./widget-script-syntax.js";
3
+
4
+ describe("widget script syntax", () => {
5
+ it("maps a raw newline in a single-quoted string to the original widget line", () => {
6
+ const widgetCode = "\n<section>\n <script>const a='x\n'+b;</script>\n</section>";
7
+ expect(findWidgetScriptSyntaxError(widgetCode)).toEqual({
8
+ message: "Unterminated string constant",
9
+ line: 3,
10
+ column: 18,
11
+ snippet: "<script>const a='x",
12
+ scriptIndex: 1,
13
+ });
14
+ });
15
+
16
+ it.each([
17
+ "<p>No scripts</p>",
18
+ '<script>const html = "<!--<script></script>-->";</script>',
19
+ '<script>a = "<!--"; </script>',
20
+ '<script>const html = "<!--<script></script>";</script>',
21
+ '<script>const html = "<!--<script>-->";</script>',
22
+ '<script>const html = "<!--<ScRiPt/></SCRIPT>-->";</script>',
23
+ '<script type="&#109;odule">await Promise.resolve();</script>',
24
+ "<script>#!/usr/bin/env node\nconst a=1;</script>",
25
+ "<script>const value = 1;",
26
+ '<script>const value = "</scripture>";</script>',
27
+ "<script-example>const =</script-example>",
28
+ "1 < 2 <script>const value = 1;</script>",
29
+ "<!doctype html><script>const value = 1;</script>",
30
+ "<script>const a='x\\n'; document.body.textContent = a;</script>",
31
+ '<script type="module">const value = await Promise.resolve(1); export { value };</script>',
32
+ '<script type=" Text/JavaScript ">const value = 1;</script>',
33
+ '<script data-label="src=ignored > type=application/json">const value = 1;</SCRIPT>',
34
+ '<button onclick="const =">Handlers are not parsed</button>',
35
+ ])("accepts valid scripts or markup: %s", (widgetCode) => {
36
+ expect(findWidgetScriptSyntaxError(widgetCode)).toBeUndefined();
37
+ });
38
+
39
+ it.each([
40
+ "",
41
+ "type",
42
+ 'type=""',
43
+ "type='text/javascript'",
44
+ "type=application/javascript",
45
+ 'TYPE=" TEXT/ECMASCRIPT "',
46
+ "type=application/ecmascript",
47
+ "type=application/x-javascript",
48
+ "type=text/jscript",
49
+ "type=text/javascript1.5",
50
+ "type=text/livescript",
51
+ ])("rejects top-level await in classic scripts: %s", (attributes) => {
52
+ expect(
53
+ findWidgetScriptSyntaxError(`<script ${attributes}>await Promise.resolve();</script>`),
54
+ ).toMatchObject({ scriptIndex: 1 });
55
+ });
56
+
57
+ it("does not allow wrapped-function grammar", () => {
58
+ expect(findWidgetScriptSyntaxError("<script>return 1;</script>")).toBeDefined();
59
+ });
60
+
61
+ it.each([
62
+ 'type="application/json"',
63
+ "type='application/ld+json'",
64
+ "type=importmap",
65
+ "type=text/template",
66
+ "type=text/x-handlebars-template",
67
+ 'src="script.js"',
68
+ "src='script.js'",
69
+ "SRC=script.js",
70
+ "src",
71
+ ])("skips non-JavaScript and external scripts: %s", (attributes) => {
72
+ expect(findWidgetScriptSyntaxError(`<script ${attributes}>const =</script>`)).toBeUndefined();
73
+ });
74
+
75
+ it.each(["<script>const a = 1;</script>", '<script type="application/json">invalid JS</script>'])(
76
+ "maps the second script after %s and stops at a case-insensitive raw-text close",
77
+ (first) => {
78
+ const widgetCode = `${first}\r\n<p>Text</p>\r\n<script>const a = "</SCRIPT>";</script>`;
79
+ expect(findWidgetScriptSyntaxError(widgetCode)).toEqual({
80
+ message: "Unterminated string constant",
81
+ line: 3,
82
+ column: 18,
83
+ snippet: '<script>const a = "</SCRIPT>";</script>',
84
+ scriptIndex: 2,
85
+ });
86
+ },
87
+ );
88
+
89
+ it("reports only the first error across multiple scripts", () => {
90
+ expect(
91
+ findWidgetScriptSyntaxError("<script>const =</script><script>let =</script>"),
92
+ ).toMatchObject({ scriptIndex: 1, line: 1, column: 14 });
93
+ });
94
+
95
+ it.each([
96
+ "<!-- <script>const =</script> -->",
97
+ "<!-- <script>const =</script>",
98
+ '<div title="<script>const =</script>">Text</div>',
99
+ "<div title='<script>const =</script>'>Text</div>",
100
+ "<textarea><script>const =</script></textarea>",
101
+ "<plaintext></plaintext><script>const =</script>",
102
+ '<div title="<script>const =</script>',
103
+ ])("ignores script-looking text outside script elements: %s", (widgetCode) => {
104
+ expect(findWidgetScriptSyntaxError(widgetCode)).toBeUndefined();
105
+ });
106
+
107
+ it.each(["style", "textarea", "title", "xmp", "iframe", "noembed", "noframes", "noscript"])(
108
+ "skips %s content and resumes at its actual end tag",
109
+ (tag) => {
110
+ const prefix = `<${tag}>ignored </${tag}x><script>const =</script></${tag.toUpperCase()} >`;
111
+ expect(findWidgetScriptSyntaxError(prefix)).toBeUndefined();
112
+ expect(findWidgetScriptSyntaxError(`${prefix}\n<script>const =</script>`)).toMatchObject({
113
+ scriptIndex: 1,
114
+ line: 2,
115
+ column: 14,
116
+ });
117
+ expect(findWidgetScriptSyntaxError(`<${tag}><script>const =</script>`)).toBeUndefined();
118
+ },
119
+ );
120
+
121
+ it("preserves offsets and script indexes after skipping inert HTML contexts", () => {
122
+ const widgetCode = [
123
+ "<!-- <script>const =</script> -->",
124
+ "<textarea><script>const =</script></textarea>",
125
+ '<div title="İ <script>const =</script>"></div>',
126
+ " <script>const =</script>",
127
+ ].join("\n");
128
+ expect(findWidgetScriptSyntaxError(widgetCode)).toEqual({
129
+ message: "Unexpected token",
130
+ scriptIndex: 1,
131
+ line: 4,
132
+ column: 16,
133
+ snippet: "<script>const =</script>",
134
+ });
135
+ });
136
+
137
+ it.each(["</script/>", '</script foo="bar">', "</SCRIPT >", ""])(
138
+ "parses the script body through its terminator or EOF: %s",
139
+ (endTag) => {
140
+ expect(findWidgetScriptSyntaxError(`<script>const =${endTag}`)).toMatchObject({
141
+ scriptIndex: 1,
142
+ line: 1,
143
+ column: 14,
144
+ });
145
+ expect(findWidgetScriptSyntaxError(`<script>const value = 1;${endTag}`)).toBeUndefined();
146
+ if (endTag) {
147
+ expect(
148
+ findWidgetScriptSyntaxError(
149
+ `<script>const value = 1;${endTag}\n<script>const =</script>`,
150
+ ),
151
+ ).toMatchObject({ scriptIndex: 2, line: 2, column: 14 });
152
+ }
153
+ },
154
+ );
155
+
156
+ it.each([
157
+ '<script>const html = "<script></script>";</script>',
158
+ '<script>const html = "<!--<scripture></script>";</script>',
159
+ ])("ends script data unless the exact script token double-escapes it: %s", (widgetCode) => {
160
+ expect(findWidgetScriptSyntaxError(widgetCode)?.message).toBe("Unterminated string constant");
161
+ });
162
+
163
+ it.each([
164
+ "<svg><script><![CDATA[const value = 1;]]></script></svg>",
165
+ "<svg><svg></svg><script><![CDATA[const value = 1;]]></script></svg>",
166
+ "<svg><svg/><script><![CDATA[const value = 1;]]></script></svg>",
167
+ '<svg><script><![CDATA[const text = "</script>";]]></script></svg>',
168
+ "<svg><title/><style/><script><![CDATA[const value = 1;]]></script></svg>",
169
+ "<div><svg><text><![CDATA[Example: > <script>const =</script>]]></text></svg></div>",
170
+ '<div><svg><foreignObject><script>const text = "<![CDATA[";</script></foreignObject></svg></div>',
171
+ "<svg><foreignObject/><script><![CDATA[const value = 1;]]></script></svg>",
172
+ '<SVG xmlns="http://www.w3.org/2000/svg"><script> \n<![CDATA[const value = 1;]]> \n</script></SVG>',
173
+ ])("parses CDATA-wrapped scripts in SVG: %s", (widgetCode) => {
174
+ expect(findWidgetScriptSyntaxError(widgetCode)).toBeUndefined();
175
+ });
176
+
177
+ it.each([
178
+ {
179
+ widgetCode: "<svg><script><![CDATA[const =]]></script></svg>",
180
+ line: 1,
181
+ column: 28,
182
+ snippet: "<svg><script><![CDATA[const =]]></script></svg>",
183
+ },
184
+ {
185
+ widgetCode: "<svg>\n <script> <![CDATA[const =]]> </script>\n</svg>",
186
+ line: 2,
187
+ column: 27,
188
+ snippet: "<script> <![CDATA[const =]]> </script>",
189
+ },
190
+ {
191
+ widgetCode: "<svg>\r\n<script>\n<![CDATA[\nconst =\n]]>\n</script></svg>",
192
+ line: 4,
193
+ column: 6,
194
+ snippet: "const =",
195
+ },
196
+ ])(
197
+ "maps CDATA errors to the original widget: $widgetCode",
198
+ ({ widgetCode, line, column, snippet }) => {
199
+ expect(findWidgetScriptSyntaxError(widgetCode)).toEqual({
200
+ message: "Unexpected token",
201
+ scriptIndex: 1,
202
+ line,
203
+ column,
204
+ snippet,
205
+ });
206
+ },
207
+ );
208
+
209
+ it.each([
210
+ "<svg><title/></svg><script>const =</script>",
211
+ "<svg><style/><script>const =</script></svg>",
212
+ '<svg><script><![CDATA[const text = "</script>";]]></script></svg><script>const =</script>',
213
+ "<svg><text><![CDATA[text]]></text></svg><script>const =</script>",
214
+ "<svg><foreignObject><script>const =</script></foreignObject></svg>",
215
+ "<svg><foreignObject><script><![CDATA[const value = 1;]]></script></foreignObject></svg>",
216
+ ])("still reaches scripts after self-closing or CDATA foreign content: %s", (widgetCode) => {
217
+ expect(findWidgetScriptSyntaxError(widgetCode)).toMatchObject({ line: 1 });
218
+ });
219
+
220
+ it.each(["", "<svg/>", "<svg></svg>", "<svg><svg/></svg>"])(
221
+ "leaves CDATA markers outside SVG untouched after %s",
222
+ (prefix) => {
223
+ expect(
224
+ findWidgetScriptSyntaxError(`${prefix}<script><![CDATA[const value = 1;]]></script>`),
225
+ ).toMatchObject({ message: "Unexpected token", scriptIndex: 1 });
226
+ },
227
+ );
228
+
229
+ it.each(["text&#47;javascript", "text&sol;javascript", "&#109;odule"])(
230
+ "validates scripts whose type contains character references: %s",
231
+ (type) => {
232
+ expect(findWidgetScriptSyntaxError(`<script type="${type}">const =</script>`)).toMatchObject({
233
+ message: "Unexpected token",
234
+ scriptIndex: 1,
235
+ });
236
+ },
237
+ );
238
+
239
+ it("bounds the offending line snippet", () => {
240
+ const widgetCode = `<script>const = ${"x".repeat(1_000)}</script>`;
241
+ expect(findWidgetScriptSyntaxError(widgetCode)?.snippet).toBe(widgetCode.slice(0, 160));
242
+ });
243
+ });
src/canvas/widget-script-syntax.ts ADDED
@@ -0,0 +1,220 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { getLineInfo, parse } from "acorn";
2
+ import { decodeHTMLAttribute } from "entities";
3
+
4
+ type WidgetScriptSyntaxError = {
5
+ message: string;
6
+ /** Location in the supplied widget_code: 1-based line, 0-based UTF-16 column. */
7
+ line: number;
8
+ column: number;
9
+ snippet: string;
10
+ /** 1-based index among all scanned script elements, including skipped scripts. */
11
+ scriptIndex: number;
12
+ };
13
+
14
+ /** JavaScript MIME type essences per https://mimesniff.spec.whatwg.org/#javascript-mime-type. */
15
+ const JAVASCRIPT_MIME_ESSENCES = new Set([
16
+ "application/ecmascript",
17
+ "application/javascript",
18
+ "application/x-ecmascript",
19
+ "application/x-javascript",
20
+ "text/ecmascript",
21
+ "text/javascript",
22
+ "text/javascript1.0",
23
+ "text/javascript1.1",
24
+ "text/javascript1.2",
25
+ "text/javascript1.3",
26
+ "text/javascript1.4",
27
+ "text/javascript1.5",
28
+ "text/jscript",
29
+ "text/livescript",
30
+ "text/x-ecmascript",
31
+ "text/x-javascript",
32
+ ]);
33
+
34
+ function scriptSourceType(attributes: string): "script" | "module" | undefined {
35
+ let type: string | undefined;
36
+ const attributePattern = /([^\s=/>]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g;
37
+ for (const attribute of attributes.matchAll(attributePattern)) {
38
+ const name = (attribute[1] ?? "").toLowerCase();
39
+ if (name === "src") {
40
+ return undefined;
41
+ }
42
+ if (name === "type") {
43
+ type ??= decodeHTMLAttribute(attribute[2] ?? attribute[3] ?? attribute[4] ?? "")
44
+ .trim()
45
+ .toLowerCase();
46
+ }
47
+ }
48
+ if (type === "module") {
49
+ return "module";
50
+ }
51
+ return !type || JAVASCRIPT_MIME_ESSENCES.has(type) ? "script" : undefined;
52
+ }
53
+
54
+ /** Finds a tag's closing bracket without interpreting quoted attribute values as markup. */
55
+ function findTagEnd(html: string, offset: number): number {
56
+ let quote = "";
57
+ for (let index = offset; index < html.length; index++) {
58
+ const char = html[index];
59
+ if (quote) {
60
+ if (char === quote) {
61
+ quote = "";
62
+ }
63
+ } else if (char === '"' || char === "'") {
64
+ quote = char;
65
+ } else if (char === ">") {
66
+ return index;
67
+ }
68
+ }
69
+ return html.length;
70
+ }
71
+
72
+ /**
73
+ * Script-looking text in double-escaped data cannot close the script element, and inside
74
+ * foreign (SVG) content a CDATA section keeps everything up to `]]>` as script text.
75
+ */
76
+ function findRawTextEnd(html: string, start: number, name: string, foreign: boolean): number {
77
+ const tokens =
78
+ name === "script"
79
+ ? foreign
80
+ ? /<!\[CDATA\[|\]\]>|<!--|-->|<\/?script(?=[\t\n\f\r />])/gi
81
+ : /<!--|-->|<\/?script(?=[\t\n\f\r />])/gi
82
+ : new RegExp(`</${name}(?=[\\t\\n\\f\\r />])`, "gi");
83
+ tokens.lastIndex = start;
84
+ let state: "data" | "escaped" | "double-escaped" | "cdata" = "data";
85
+ for (const match of html.matchAll(tokens)) {
86
+ const token = match[0].toLowerCase();
87
+ if (state === "cdata") {
88
+ if (token === "]]>") {
89
+ state = "data";
90
+ }
91
+ } else if (token === "<![cdata[") {
92
+ state = "cdata";
93
+ } else if (token === "<!--" && state === "data") {
94
+ state = "escaped";
95
+ } else if (token === "-->") {
96
+ state = "data";
97
+ } else if (token.startsWith("</")) {
98
+ if (state !== "double-escaped") {
99
+ return match.index;
100
+ }
101
+ state = "escaped";
102
+ } else if (token === "<script" && state === "escaped") {
103
+ state = "double-escaped";
104
+ }
105
+ }
106
+ return html.length;
107
+ }
108
+
109
+ /**
110
+ * Tracks HTML contexts over the original input so script offsets never need normalization.
111
+ *
112
+ * Scope: SVG support covers CDATA-wrapped scripts, opaque CDATA sections, self-closing foreign
113
+ * elements, and foreignObject switching back to HTML rules. Character references inside foreign
114
+ * script text, CDATA sections interleaved with script text, and SVG nested inside foreignObject
115
+ * are intentionally not modeled; such input yields an explicit tool error, never a hosted widget
116
+ * with a script that cannot run.
117
+ */
118
+ export function findWidgetScriptSyntaxError(
119
+ widgetCode: string,
120
+ ): WidgetScriptSyntaxError | undefined {
121
+ const tagPattern = /<\/?([a-z][^\t\n\f\r />]*)/iy;
122
+ let position = 0;
123
+ let scriptIndex = 0;
124
+ let svgDepth = 0;
125
+ let foreignObjectDepth = 0;
126
+ while (position < widgetCode.length) {
127
+ const start = widgetCode.indexOf("<", position);
128
+ if (start < 0) {
129
+ break;
130
+ }
131
+ position = start + 1;
132
+ if (widgetCode.startsWith("<!--", start)) {
133
+ const end = widgetCode.indexOf("-->", start + 4);
134
+ position = end < 0 ? widgetCode.length : end + 3;
135
+ continue;
136
+ }
137
+ // foreignObject is an HTML integration point, so its children follow HTML rules again.
138
+ const foreign = svgDepth > 0 && foreignObjectDepth === 0;
139
+ if (foreign && widgetCode.startsWith("<![CDATA[", start)) {
140
+ const end = widgetCode.indexOf("]]>", start + 9);
141
+ position = end < 0 ? widgetCode.length : end + 3;
142
+ continue;
143
+ }
144
+ tagPattern.lastIndex = start;
145
+ const tag = tagPattern.exec(widgetCode);
146
+ if (!tag) {
147
+ if (widgetCode[position] === "!" || widgetCode[position] === "/") {
148
+ position = findTagEnd(widgetCode, position + 1) + 1;
149
+ }
150
+ continue;
151
+ }
152
+ const attributesStart = tagPattern.lastIndex;
153
+ const tagEnd = findTagEnd(widgetCode, attributesStart);
154
+ position = tagEnd + 1;
155
+ if (tagEnd === widgetCode.length) {
156
+ continue;
157
+ }
158
+ const name = (tag[1] ?? "").toLowerCase();
159
+ const closing = widgetCode[start + 1] === "/";
160
+ const selfClosing = widgetCode[tagEnd - 1] === "/";
161
+ if (name === "svg") {
162
+ svgDepth = closing ? Math.max(0, svgDepth - 1) : svgDepth + (selfClosing ? 0 : 1);
163
+ } else if (name === "foreignobject" && svgDepth > 0) {
164
+ foreignObjectDepth = closing
165
+ ? Math.max(0, foreignObjectDepth - 1)
166
+ : foreignObjectDepth + (selfClosing ? 0 : 1);
167
+ }
168
+ if (closing) {
169
+ continue;
170
+ }
171
+ if (name === "plaintext") {
172
+ break;
173
+ }
174
+ if (!/^(?:script|style|textarea|title|xmp|iframe|noembed|noframes|noscript)$/.test(name)) {
175
+ continue;
176
+ }
177
+ // Foreign content honors self-closing start tags; HTML content ignores the slash.
178
+ if (foreign && selfClosing) {
179
+ continue;
180
+ }
181
+ let bodyStart = position;
182
+ const bodyEnd = findRawTextEnd(widgetCode, bodyStart, name, foreign);
183
+ const closingBracket = bodyEnd < widgetCode.length ? widgetCode.indexOf(">", bodyEnd) : -1;
184
+ position = closingBracket < 0 ? widgetCode.length : closingBracket + 1;
185
+ if (name !== "script") {
186
+ continue;
187
+ }
188
+ scriptIndex++;
189
+ const sourceType = scriptSourceType(widgetCode.slice(attributesStart, tagEnd));
190
+ if (!sourceType) {
191
+ continue;
192
+ }
193
+ let body = widgetCode.slice(bodyStart, bodyEnd);
194
+ const trimmed = body.trim();
195
+ if (foreign && trimmed.startsWith("<![CDATA[") && trimmed.endsWith("]]>")) {
196
+ bodyStart += body.length - body.trimStart().length + "<![CDATA[".length;
197
+ body = trimmed.slice("<![CDATA[".length, -"]]>".length);
198
+ }
199
+ try {
200
+ parse(body, { ecmaVersion: "latest", sourceType });
201
+ } catch (error) {
202
+ if (!(error instanceof SyntaxError) || !("pos" in error) || typeof error.pos !== "number") {
203
+ throw error;
204
+ }
205
+ const offset = bodyStart + error.pos;
206
+ const { line, column } = getLineInfo(widgetCode, offset);
207
+ const snippet = (widgetCode.slice(offset - column).split(/[\r\n\u2028\u2029]/u, 1)[0] ?? "")
208
+ .trim()
209
+ .slice(0, 160);
210
+ return {
211
+ message: error.message.replace(/ \(\d+:\d+\)$/u, "").slice(0, 200),
212
+ line,
213
+ column,
214
+ snippet,
215
+ scriptIndex,
216
+ };
217
+ }
218
+ }
219
+ return undefined;
220
+ }
src/canvas/widget-tool.content-kinds.test-support.ts ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { createPluginBoardWidgetContentKindRegistrar } from "../plugins/board-widget-content-kinds.js";
2
+ import { createPluginRecord } from "../plugins/loader-records.js";
3
+ import { createEmptyPluginRegistry } from "../plugins/registry-empty.js";
4
+ import { setActivePluginRegistry } from "../plugins/runtime.js";
5
+
6
+ export function registerTestWidgetContentKind(
7
+ kind = "diagram",
8
+ composeDocument = ({ source }: { source: string }) => `<main>${source}</main>`,
9
+ ): void {
10
+ const registry = createEmptyPluginRegistry();
11
+ const record = createPluginRecord({
12
+ id: "diagram",
13
+ source: "diagram-fixture",
14
+ origin: "bundled",
15
+ enabled: true,
16
+ configSchema: false,
17
+ });
18
+ createPluginBoardWidgetContentKindRegistrar(registry)(record, {
19
+ kind,
20
+ label: "Diagram",
21
+ resources: { surface: "diagram", paths: ["/__openclaw__/diagram/app.js"] },
22
+ validateSource(source) {
23
+ if (!source.startsWith("diagram:")) {
24
+ throw new Error("diagram prefix required");
25
+ }
26
+ },
27
+ composeDocument,
28
+ });
29
+ setActivePluginRegistry(registry);
30
+ }
src/canvas/widget-tool.grants.test.ts ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
2
+ import { resetPluginRuntimeStateForTest } from "../plugins/runtime.js";
3
+ import { createShowWidgetTool } from "./widget-tool.js";
4
+ import { createBoardPutCaller } from "./widget-tool.test-support.js";
5
+
6
+ beforeEach(() => resetPluginRuntimeStateForTest());
7
+ afterEach(() => resetPluginRuntimeStateForTest());
8
+
9
+ describe("show_widget capability outcomes", () => {
10
+ it.each(["pending", "rejected", "granted"] as const)(
11
+ "reports the owner's %s capability outcome",
12
+ async (grantState) => {
13
+ const { mock, callGateway } = createBoardPutCaller();
14
+ const original = mock.getMockImplementation()!;
15
+ mock.mockImplementation(async (...args) => {
16
+ const snapshot = await original(...args);
17
+ snapshot.widgets[0]!.grantState = grantState;
18
+ return snapshot;
19
+ });
20
+ const tool = createShowWidgetTool({
21
+ agentSessionKey: "agent:main:approval",
22
+ inlineHostEnabled: false,
23
+ callGateway,
24
+ });
25
+ const result = await tool.execute("pin", {
26
+ title: "Runs",
27
+ widget_code: "<p>runs</p>",
28
+ pin: true,
29
+ capabilities: { tools: ["github.actions.runs:owner/repo"] },
30
+ });
31
+ const text = result.content.find((item) => item.type === "text")?.text;
32
+ if (!text) {
33
+ throw new Error("expected widget tool text result");
34
+ }
35
+ const payload = JSON.parse(text);
36
+ expect(payload).toMatchObject({ boardWidgetName: "runs", capabilityState: grantState });
37
+ expect(payload.text).toContain(grantState);
38
+ if (grantState !== "granted") {
39
+ expect(payload.text).toMatch(/approve|review/i);
40
+ }
41
+ },
42
+ );
43
+ });
src/canvas/widget-tool.presenter.test.ts ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { access } from "node:fs/promises";
2
+ import { afterEach, describe, expect, it, vi } from "vitest";
3
+ import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
4
+ import type { WidgetPresenter } from "../plugins/plugin-registration.types.js";
5
+ import { resolveCanvasDocumentsDir } from "./documents.js";
6
+ import { createShowWidgetTool } from "./widget-tool.js";
7
+ import { buildWidgetDocument } from "./wrap.js";
8
+
9
+ const tempDirs = useAutoCleanupTempDirTracker(afterEach);
10
+
11
+ describe("show_widget current-channel presentation", () => {
12
+ it.each([false, true])("prefers the channel presenter (inline=%s)", async (inlineClient) => {
13
+ const stateDir = tempDirs.make("openclaw-widget-presenter-");
14
+ const present = vi.fn(async () => ({
15
+ ok: true as const,
16
+ value: {
17
+ kind: "message" as const,
18
+ receipt: {
19
+ primaryPlatformMessageId: "message-1",
20
+ platformMessageIds: ["message-1"],
21
+ parts: [],
22
+ sentAt: 1,
23
+ },
24
+ },
25
+ }));
26
+ const context = {
27
+ messageChannel: "discord",
28
+ accountId: "work",
29
+ nativeChannelId: "channel-1",
30
+ currentChannelId: "channel:channel-1",
31
+ currentMessagingTarget: "discord:channel:channel-1",
32
+ sessionKey: "agent:main:discord",
33
+ };
34
+ const presenter: WidgetPresenter = {
35
+ target: "current_channel",
36
+ description: "Post in the current channel",
37
+ capabilities: { sourceKinds: ["html"], maxSourceBytes: 48 * 1024 },
38
+ match: (candidate) => candidate.messageChannel === "discord",
39
+ availability: async () => ({ ok: true, value: { available: true } }),
40
+ present,
41
+ };
42
+ const tool = createShowWidgetTool({
43
+ stateDir,
44
+ sessionId: "current-channel",
45
+ inlineClientAvailable: inlineClient,
46
+ presenters: [presenter],
47
+ presenterContext: context,
48
+ });
49
+
50
+ expect(tool.requiredClientCaps).toBeUndefined();
51
+ expect(tool.description).toContain("current channel presenter");
52
+ expect(tool.description).not.toContain("Keep one-off visualizations inline");
53
+ expect(
54
+ (tool.parameters as { properties?: { kind?: { enum?: string[] } } }).properties?.kind?.enum,
55
+ ).toEqual(["html"]);
56
+ const result = await tool.execute("current-channel", {
57
+ title: "Status",
58
+ widget_code: "<p>ready</p>",
59
+ });
60
+ const parsed = JSON.parse(result.content[0]?.type === "text" ? result.content[0].text : "null");
61
+
62
+ expect(parsed).toMatchObject({
63
+ kind: "widget",
64
+ presentation: {
65
+ target: "current_channel",
66
+ title: "Status",
67
+ receipt: { primaryPlatformMessageId: "message-1" },
68
+ },
69
+ text: "Widget presented in the current channel as message message-1",
70
+ });
71
+ expect(present).toHaveBeenCalledExactlyOnceWith({
72
+ document: { kind: "html", html: buildWidgetDocument("Status", "<p>ready</p>") },
73
+ title: "Status",
74
+ context,
75
+ });
76
+ await expect(access(resolveCanvasDocumentsDir(stateDir))).rejects.toThrow();
77
+ });
78
+ });
src/canvas/widget-tool.prompt.test.ts ADDED
@@ -0,0 +1,132 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import { createPluginGatewayMethodDescriptor } from "../gateway/methods/descriptor.js";
3
+ import { registerPluginDashboardCapabilities } from "../plugins/dashboard-capabilities.js";
4
+ import { createPluginRecord } from "../plugins/loader-records.js";
5
+ import { createEmptyPluginRegistry } from "../plugins/registry-empty.js";
6
+ import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../plugins/runtime.js";
7
+ import * as processExec from "../process/exec.js";
8
+ import { createShowWidgetTool } from "./widget-tool.js";
9
+
10
+ describe("show_widget prompt", () => {
11
+ afterEach(() => {
12
+ resetPluginRuntimeStateForTest();
13
+ vi.restoreAllMocks();
14
+ });
15
+
16
+ it("discovers active host capabilities with usable contracts", () => {
17
+ const registry = createEmptyPluginRegistry();
18
+ const handler = () => {};
19
+ registry.gatewayHandlers["fixture.list"] = handler;
20
+ registry.gatewayHandlers["fixture.dispatch"] = handler;
21
+ registry.gatewayMethodDescriptors.push(
22
+ createPluginGatewayMethodDescriptor({
23
+ pluginId: "fixture",
24
+ name: "fixture.list",
25
+ scope: "operator.read",
26
+ handler,
27
+ }),
28
+ createPluginGatewayMethodDescriptor({
29
+ pluginId: "fixture",
30
+ name: "fixture.dispatch",
31
+ scope: "operator.write",
32
+ handler,
33
+ }),
34
+ );
35
+ registerPluginDashboardCapabilities({
36
+ registry,
37
+ record: createPluginRecord({
38
+ id: "fixture",
39
+ source: "fixture",
40
+ origin: "bundled",
41
+ enabled: true,
42
+ configSchema: false,
43
+ dashboard: {
44
+ dataBindings: [
45
+ { id: "large", method: "fixture.list", description: "Too large ".repeat(500) },
46
+ { id: "list", method: "fixture.list", description: "List fixture items" },
47
+ ],
48
+ actionVerbs: [
49
+ {
50
+ id: "dispatch",
51
+ method: "fixture.dispatch",
52
+ description: "Dispatch items",
53
+ paramShape: { type: "object", properties: { force: { type: "boolean" } } },
54
+ },
55
+ ],
56
+ },
57
+ }),
58
+ });
59
+ setActivePluginRegistry(registry);
60
+ const tool = createShowWidgetTool();
61
+ const instructions = JSON.stringify(tool.parameters) + tool.description;
62
+ expect(instructions).toContain("With a usable connected agent GitHub identity");
63
+ expect(instructions).toContain("Identity is checked before save");
64
+ expect(instructions).toContain("github.actions.runs");
65
+ expect(instructions).toContain("github.actions.runs:owner/repo");
66
+ expect(instructions).toContain("fixture.list");
67
+ expect(instructions).toContain("List fixture items");
68
+ expect(instructions).toContain("fixture.dispatch");
69
+ expect(instructions).toContain("force");
70
+ expect(instructions).toContain("1 plugin capabilities omitted");
71
+ const guidance = (
72
+ tool.parameters as {
73
+ properties: { capabilities: { properties: { tools: { description: string } } } };
74
+ }
75
+ ).properties.capabilities.properties.tools.description;
76
+ expect(guidance.length).toBeLessThanOrEqual(1200);
77
+ expect(guidance.indexOf("fixture.dispatch")).toBeLessThan(guidance.indexOf("fixture.list"));
78
+ expect(guidance).not.toContain("Too large");
79
+ setActivePluginRegistry(createEmptyPluginRegistry());
80
+ expect(JSON.stringify(createShowWidgetTool())).not.toContain("fixture.list");
81
+ expect(JSON.stringify(createShowWidgetTool())).not.toContain("fixture.dispatch");
82
+ });
83
+ it("constructs synchronously without probing GitHub identity", () => {
84
+ const http = vi
85
+ .spyOn(globalThis, "fetch")
86
+ .mockRejectedValue(new Error("Unexpected HTTP probe"));
87
+ const native = vi
88
+ .spyOn(processExec, "runCommandBuffered")
89
+ .mockRejectedValue(new Error("Unexpected native probe"));
90
+ expect(createShowWidgetTool().name).toBe("show_widget");
91
+ expect(http).not.toHaveBeenCalled();
92
+ expect(native).not.toHaveBeenCalled();
93
+ });
94
+ it("keeps proactive single visualizations inline unless dashboard use meets its threshold", () => {
95
+ const tool = createShowWidgetTool();
96
+ const directoryDescription = tool.description.slice(0, 184);
97
+ const properties = (
98
+ tool.parameters as {
99
+ properties?: {
100
+ pin?: { description?: string };
101
+ name?: { description?: string };
102
+ widget_code?: { description?: string };
103
+ };
104
+ }
105
+ ).properties;
106
+ const pinDescription = properties?.pin?.description;
107
+
108
+ expect(directoryDescription).toMatch(/^Visual helps\? Make widget\. Do not wait for ask\./);
109
+ expect(directoryDescription).toMatch(
110
+ /(?:single|one[- ]off|ad hoc).{0,40}visualizations?.{0,40}inline/i,
111
+ );
112
+ expect(directoryDescription).toContain("explicit dashboard request");
113
+ expect(directoryDescription).toContain("multiple non-code visualizations");
114
+ expect(directoryDescription).toContain("Update pinned HTML by name");
115
+ expect(pinDescription).toContain("explicit dashboard request");
116
+ expect(pinDescription).toContain("multiple non-code visualizations");
117
+ expect(properties?.name?.description).toMatch(/same name.*pin=true.*widget_code/i);
118
+ expect(properties?.widget_code?.description).toContain("fluid widths");
119
+ expect(properties?.widget_code?.description).toMatch(/wrap or stack.*narrow/i);
120
+ });
121
+
122
+ it("offers native reports for a session dashboard with a bounded data contract", () => {
123
+ const tool = createShowWidgetTool({ agentSessionKey: "agent:main:report" });
124
+ const description = tool.description;
125
+ expect(description).toContain("Prefer the report argument with pin=true");
126
+ expect(description).toContain("dashboard-only");
127
+ const schema = JSON.stringify(tool.parameters);
128
+ expect(schema).toContain("Maximum 8KB JSON");
129
+ expect(schema).toContain("Metric values and table cells are strings");
130
+ expect(createShowWidgetTool().description).not.toContain("Prefer the report argument");
131
+ });
132
+ });
src/canvas/widget-tool.report.test.ts ADDED
@@ -0,0 +1,256 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { access } from "node:fs/promises";
2
+ import { afterEach, describe, expect, it, vi } from "vitest";
3
+ import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
4
+ import { createDashboardTool } from "../agents/tools/dashboard-tool.js";
5
+ import type { InProcessGatewayCaller } from "../agents/tools/in-process-gateway.js";
6
+ import type { BoardReport } from "../boards/board-report.js";
7
+ import { readBoardHtml, createTestBoardStore } from "../boards/board-store.test-support.js";
8
+ import { createBoardHarness } from "../gateway/server-methods/board.test-support.js";
9
+ import { resetPluginRuntimeStateForTest } from "../plugins/runtime.js";
10
+ import { closeOpenClawAgentDatabasesForTest } from "../state/openclaw-agent-db.js";
11
+ import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js";
12
+ import { resolveCanvasDocumentsDir } from "./documents.js";
13
+ import { registerTestWidgetContentKind } from "./widget-tool.content-kinds.test-support.js";
14
+ import { createShowWidgetTool } from "./widget-tool.js";
15
+ import { createBoardPutCaller } from "./widget-tool.test-support.js";
16
+
17
+ const tempDirs = useAutoCleanupTempDirTracker(afterEach);
18
+ afterEach(() => {
19
+ closeOpenClawAgentDatabasesForTest();
20
+ closeOpenClawStateDatabaseForTest();
21
+ resetPluginRuntimeStateForTest();
22
+ });
23
+
24
+ const report: BoardReport = {
25
+ blocks: [
26
+ { type: "text", title: "Community pulse", text: "Activity is steady." },
27
+ { type: "metrics", items: [{ label: "Authors", value: "312", detail: "This week" }] },
28
+ { type: "table", columns: ["Topic", "Threads"], rows: [["Support", "160"]] },
29
+ { type: "chart", style: "line", points: [{ label: "Mon", value: 12 }] },
30
+ { type: "links", items: [{ label: "Details", url: "https://example.com/details" }] },
31
+ ],
32
+ };
33
+
34
+ describe("native report authoring", () => {
35
+ it("fails a registered renderer before writing a pinned widget", async () => {
36
+ registerTestWidgetContentKind("diagram", () => {
37
+ throw new Error("Renderer unavailable");
38
+ });
39
+ const callGateway = vi.fn();
40
+ const tool = createShowWidgetTool({ agentSessionKey: "agent:main:report", callGateway });
41
+ await expect(
42
+ tool.execute("renderer-failed", {
43
+ title: "Diagram",
44
+ kind: "diagram",
45
+ pin: true,
46
+ widget_code: "diagram:ready",
47
+ }),
48
+ ).rejects.toThrow("Renderer unavailable");
49
+ expect(callGateway).not.toHaveBeenCalled();
50
+ });
51
+
52
+ it("preserves plugin source kinds named report alongside native report data", async () => {
53
+ registerTestWidgetContentKind("report");
54
+ const { mock, callGateway } = createBoardPutCaller();
55
+ const tool = createShowWidgetTool({
56
+ stateDir: tempDirs.make("openclaw-report-plugin-"),
57
+ agentSessionKey: "agent:main:report-plugin",
58
+ callGateway,
59
+ });
60
+ const result = await tool.execute("registered-report", {
61
+ title: "Plugin report",
62
+ kind: "report",
63
+ widget_code: "diagram:ready",
64
+ pin: true,
65
+ });
66
+ expect(result.details).toMatchObject({ kind: "canvas", view: { id: expect.any(String) } });
67
+ expect(mock).toHaveBeenCalledWith(
68
+ "board.widget.put",
69
+ expect.objectContaining({
70
+ content: { kind: "registered", contentKind: "report", source: "diagram:ready" },
71
+ }),
72
+ );
73
+ });
74
+
75
+ it.each([
76
+ { sessionKey: "agent:main:dashboard:report", agentId: "main" },
77
+ { sessionKey: "global", agentId: "research" },
78
+ ])(
79
+ "pins, updates and reopens a report for $agentId/$sessionKey without a document",
80
+ async (target) => {
81
+ const stateDir = tempDirs.make("openclaw-native-report-");
82
+ const store = createTestBoardStore({ stateDir });
83
+ const sibling = {
84
+ sessionKey: "global",
85
+ agentId: target.agentId === "main" ? "research" : "main",
86
+ };
87
+ const siblingBefore = await store.getSnapshot(sibling);
88
+ const { invoke } = createBoardHarness(undefined, {}, store, {
89
+ getRuntimeConfig: () => ({
90
+ agents: { ownership: "explicit", entries: { main: {}, research: {} } },
91
+ }),
92
+ });
93
+ const callGateway: InProcessGatewayCaller = async <T>(
94
+ method: string,
95
+ params: Record<string, unknown>,
96
+ ) => {
97
+ const response = await invoke(method, params);
98
+ const [ok, payload, error] = response.mock.calls[0]!;
99
+ if (!ok) {
100
+ throw new Error(error?.message ?? "Report request failed");
101
+ }
102
+ return payload as T;
103
+ };
104
+ const tool = createShowWidgetTool({
105
+ stateDir,
106
+ agentSessionKey: target.sessionKey,
107
+ agentId: target.agentId,
108
+ callGateway,
109
+ });
110
+ const result = await tool.execute("create-report", {
111
+ title: "Community pulse",
112
+ name: "community-pulse",
113
+ pin: true,
114
+ report,
115
+ });
116
+ expect(result.details).toMatchObject({
117
+ status: "pinned",
118
+ boardWidgetName: "community-pulse",
119
+ });
120
+ expect((await store.getSnapshot(target)).widgets[0]).toMatchObject({
121
+ name: "community-pulse",
122
+ contentKind: "plugin",
123
+ contentOwner: "plugin",
124
+ pluginKind: "session:report",
125
+ props: report,
126
+ grantState: "none",
127
+ revision: 1,
128
+ });
129
+ const updated = { blocks: [{ type: "text", text: "Refreshed from the agent." }] };
130
+ await createDashboardTool({
131
+ agentSessionKey: target.sessionKey,
132
+ agentId: target.agentId,
133
+ callGateway,
134
+ }).execute("update-report", {
135
+ action: "widget_put",
136
+ name: "community-pulse",
137
+ pluginKind: "session:report",
138
+ props: updated,
139
+ });
140
+ closeOpenClawAgentDatabasesForTest();
141
+ expect((await store.getSnapshot(target)).widgets[0]).toMatchObject({
142
+ props: updated,
143
+ revision: 2,
144
+ });
145
+ const view = await invoke("board.get", target);
146
+ expect(view.mock.calls[0]?.[0]).toBe(true);
147
+ const snapshot = view.mock.calls[0]?.[1] as { widgets: Array<Record<string, unknown>> };
148
+ expect(snapshot.widgets[0]).toMatchObject({ pluginKind: "session:report", props: updated });
149
+ expect(snapshot.widgets[0]).not.toHaveProperty("frameUrl");
150
+ expect(snapshot.widgets[0]).not.toHaveProperty("viewTicket");
151
+ expect(await readBoardHtml(store, target, "community-pulse")).toBeUndefined();
152
+ expect(await store.getSnapshot(sibling)).toEqual(siblingBefore);
153
+ await expect(access(resolveCanvasDocumentsDir(stateDir))).rejects.toThrow();
154
+ },
155
+ );
156
+
157
+ it.each([
158
+ {
159
+ label: "executable fields",
160
+ props: { blocks: [{ type: "text", text: "Hello", script: "ignored" }] },
161
+ },
162
+ {
163
+ label: "unsupported blocks",
164
+ props: { blocks: [{ type: "image", url: "https://example.com/image.png" }] },
165
+ },
166
+ {
167
+ label: "script links",
168
+ props: {
169
+ blocks: [{ type: "links", items: [{ label: "Invalid", url: "javascript:void(0)" }] }],
170
+ },
171
+ },
172
+ {
173
+ label: "mismatched table rows",
174
+ props: { blocks: [{ type: "table", columns: ["A", "B"], rows: [["one"]] }] },
175
+ },
176
+ {
177
+ label: "too many points",
178
+ props: {
179
+ blocks: [
180
+ { type: "chart", points: Array.from({ length: 41 }, () => ({ label: "A", value: 1 })) },
181
+ ],
182
+ },
183
+ },
184
+ {
185
+ label: "oversized UTF-8 data",
186
+ props: {
187
+ blocks: Array.from({ length: 3 }, () => ({ type: "text", text: "é".repeat(2_000) })),
188
+ },
189
+ },
190
+ ])("rejects $label at the storage owner without changing the saved report", async ({ props }) => {
191
+ const store = createTestBoardStore();
192
+ const target = { sessionKey: "agent:main:report" };
193
+ const content = { kind: "plugin" as const, pluginKind: "session:report", props: report };
194
+ await store.putWidget({ ...target, name: "report", content });
195
+ const before = await store.getSnapshot(target);
196
+ await expect(
197
+ (async () =>
198
+ await store.putWidget({ ...target, name: "report", content: { ...content, props } }))(),
199
+ ).rejects.toThrow();
200
+ expect(await store.getSnapshot(target)).toEqual(before);
201
+ });
202
+
203
+ it.each([
204
+ { pin: false },
205
+ { pin: true, capabilities: { tools: ["prompt"] } },
206
+ { pin: true, kind: "html" },
207
+ { pin: true, widget_code: "<p>Other content</p>" },
208
+ { pin: true, presentation: { target: "assistant_message" } },
209
+ { pin: true, presentation: { target: "node_panel" } },
210
+ ])("rejects unsupported report presentation before any write: %j", async (options) => {
211
+ const callGateway = vi.fn();
212
+ const tool = createShowWidgetTool({ agentSessionKey: "agent:main:report", callGateway });
213
+ await expect(
214
+ tool.execute("invalid-report", {
215
+ title: "Report",
216
+ report,
217
+ ...options,
218
+ }),
219
+ ).rejects.toThrow("Reports require pin=true");
220
+ expect(callGateway).not.toHaveBeenCalled();
221
+ });
222
+
223
+ it("accepts HTML widget payload when report is an empty object", async () => {
224
+ const { mock, callGateway } = createBoardPutCaller();
225
+ const tool = createShowWidgetTool({
226
+ stateDir: tempDirs.make("openclaw-empty-report-html-"),
227
+ agentSessionKey: "agent:main:dashboard:empty-report",
228
+ callGateway,
229
+ });
230
+ const result = await tool.execute("empty-report-with-html", {
231
+ title: "SaaS Operations",
232
+ pin: true,
233
+ report: {},
234
+ widget_code: "<main>dashboard</main>",
235
+ kind: "html",
236
+ capabilities: {},
237
+ presentation: {
238
+ target: "assistant_message",
239
+ frame: "card",
240
+ },
241
+ });
242
+ expect(result.details).toMatchObject({
243
+ kind: "canvas",
244
+ view: { boardWidgetName: "saas-operations" },
245
+ });
246
+ expect(mock).toHaveBeenCalledWith(
247
+ "board.widget.put",
248
+ expect.objectContaining({
249
+ content: expect.objectContaining({
250
+ kind: "html",
251
+ html: "<main>dashboard</main>",
252
+ }),
253
+ }),
254
+ );
255
+ });
256
+ });
src/canvas/widget-tool.scheduled.test.ts ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Scheduled show_widget schema and board-only execution coverage.
2
+ import { access } from "node:fs/promises";
3
+ import { afterEach, describe, expect, it } from "vitest";
4
+ import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
5
+ import { resolveCanvasDocumentsDir } from "./documents.js";
6
+ import { createShowWidgetTool } from "./widget-tool.js";
7
+ import { createBoardPutCaller } from "./widget-tool.test-support.js";
8
+
9
+ const tempDirs = useAutoCleanupTempDirTracker(afterEach);
10
+
11
+ describe("scheduled show_widget", () => {
12
+ it("keeps scheduled widget authoring pinned-only without an inline client", async () => {
13
+ const { mock: callGatewayMock, callGateway } = createBoardPutCaller();
14
+ const stateDir = tempDirs.make("openclaw-scheduled-widget-");
15
+ const tool = createShowWidgetTool({
16
+ stateDir,
17
+ sessionId: "scheduled-board-only",
18
+ agentSessionKey: "agent:main:dashboard:scheduled",
19
+ inlineClientAvailable: false,
20
+ pinnedOnly: true,
21
+ callGateway,
22
+ });
23
+ const schema = tool.parameters as {
24
+ properties?: {
25
+ pin?: { const?: boolean };
26
+ presentation?: { properties?: { target?: unknown } };
27
+ };
28
+ required?: string[];
29
+ };
30
+
31
+ expect(tool.requiredClientCaps).toBeUndefined();
32
+ expect(schema.required).toContain("pin");
33
+ expect(schema.properties?.pin?.const).toBe(true);
34
+ expect(schema.properties?.presentation?.properties).not.toHaveProperty("target");
35
+ expect(tool.description).toContain("This surface is pinned-only");
36
+
37
+ await expect(
38
+ tool.execute("scheduled-unpinned", {
39
+ title: "Scheduled status",
40
+ widget_code: "<main>ready</main>",
41
+ }),
42
+ ).rejects.toThrow("pin=true is required for this pinned-only widget surface");
43
+ await expect(
44
+ tool.execute("scheduled-target", {
45
+ title: "Scheduled status",
46
+ widget_code: "<main>ready</main>",
47
+ pin: true,
48
+ presentation: { target: "assistant_message" },
49
+ }),
50
+ ).rejects.toThrow("presentation.target is unavailable for this pinned-only widget surface");
51
+
52
+ const result = await tool.execute("scheduled-pinned", {
53
+ title: "Scheduled status",
54
+ widget_code: "<button onclick=\"this.textContent='updated'\">Update</button>",
55
+ name: "scheduled-status",
56
+ pin: true,
57
+ });
58
+ const text = result.content.find((item) => item.type === "text")?.text;
59
+ expect(JSON.parse(text ?? "null")).toEqual({
60
+ status: "pinned",
61
+ boardWidgetName: "scheduled-status",
62
+ capabilityState: "none",
63
+ text: "Widget pinned to dashboard tab main as scheduled-status. Open this dashboard tab in Control UI to view it.",
64
+ });
65
+ expect(callGatewayMock).toHaveBeenCalledExactlyOnceWith(
66
+ "board.widget.put",
67
+ expect.objectContaining({
68
+ sessionKey: "agent:main:dashboard:scheduled",
69
+ name: "scheduled-status",
70
+ content: {
71
+ kind: "html",
72
+ html: "<button onclick=\"this.textContent='updated'\">Update</button>",
73
+ },
74
+ }),
75
+ );
76
+ await expect(access(resolveCanvasDocumentsDir(stateDir))).rejects.toThrow();
77
+ });
78
+ });
src/canvas/widget-tool.script-syntax.test.ts ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { access } from "node:fs/promises";
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
+ import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
4
+ import { WidgetHtmlInputError } from "../plugin-sdk/widget-html.js";
5
+ import type { WidgetPresenter } from "../plugins/plugin-registration.types.js";
6
+ import { resetPluginRuntimeStateForTest } from "../plugins/runtime.js";
7
+ import { closeOpenClawAgentDatabasesForTest } from "../state/openclaw-agent-db.js";
8
+ import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js";
9
+ import { resolveCanvasDocumentsDir } from "./documents.js";
10
+ import { registerTestWidgetContentKind } from "./widget-tool.content-kinds.test-support.js";
11
+ import { createShowWidgetTool } from "./widget-tool.js";
12
+ import { createBoardPutCaller } from "./widget-tool.test-support.js";
13
+
14
+ const tempDirs = useAutoCleanupTempDirTracker(afterEach);
15
+
16
+ beforeEach(() => resetPluginRuntimeStateForTest());
17
+ afterEach(() => {
18
+ closeOpenClawAgentDatabasesForTest();
19
+ closeOpenClawStateDatabaseForTest();
20
+ resetPluginRuntimeStateForTest();
21
+ });
22
+
23
+ describe("show_widget script syntax gate", () => {
24
+ it.each(["inline", "pinned", "current_channel", "node_panel"] as const)(
25
+ "rejects broken scripts before any side effect on %s",
26
+ async (route) => {
27
+ const stateDir = tempDirs.make("openclaw-widget-syntax-");
28
+ const { mock: gateway, callGateway } = createBoardPutCaller();
29
+ const present = vi.fn<WidgetPresenter["present"]>();
30
+ const availability = vi.fn<WidgetPresenter["availability"]>();
31
+ const presenters: WidgetPresenter[] =
32
+ route === "current_channel"
33
+ ? [
34
+ {
35
+ target: "current_channel",
36
+ description: "Test channel",
37
+ capabilities: { sourceKinds: ["html"] },
38
+ match: () => true,
39
+ availability,
40
+ present,
41
+ },
42
+ ]
43
+ : [{ target: "node_panel", description: "Test panel", availability, present }];
44
+ const tool = createShowWidgetTool({
45
+ stateDir,
46
+ sessionId: "syntax",
47
+ agentSessionKey: "agent:main:syntax",
48
+ callGateway,
49
+ presenters,
50
+ });
51
+ const result = tool.execute("broken", {
52
+ title: "Broken widget",
53
+ widget_code: "<p>Widget</p>\n<script>const a='x\n'+b;</script>",
54
+ pin: route === "pinned",
55
+ ...(route === "node_panel" ? { presentation: { target: "node_panel" } } : {}),
56
+ });
57
+ await expect(result).rejects.toThrow(WidgetHtmlInputError);
58
+ await expect(result).rejects.toThrow(
59
+ "widget_code has a JavaScript syntax error in inline script 1 at line 2, column 16: Unterminated string constant. Offending line: <script>const a='x. Fix the script and call show_widget again.",
60
+ );
61
+ expect(gateway).not.toHaveBeenCalled();
62
+ expect(present).not.toHaveBeenCalled();
63
+ expect(availability).not.toHaveBeenCalled();
64
+ await expect(access(resolveCanvasDocumentsDir(stateDir))).rejects.toThrow();
65
+ },
66
+ );
67
+
68
+ it("leaves registered source validation to the content kind", async () => {
69
+ registerTestWidgetContentKind("diagram", () => "<p>Rendered diagram</p>");
70
+ const stateDir = tempDirs.make("openclaw-widget-syntax-");
71
+ const tool = createShowWidgetTool({ stateDir, sessionId: "registered-syntax" });
72
+ const result = await tool.execute("registered", {
73
+ title: "Diagram",
74
+ kind: "diagram",
75
+ widget_code: "diagram:<script>const =</script>",
76
+ });
77
+ const text = result.content.find((item) => item.type === "text")?.text;
78
+ expect(JSON.parse(text ?? "null")).toMatchObject({ kind: "canvas" });
79
+ });
80
+
81
+ it("still hosts a valid script as a canvas widget", async () => {
82
+ const stateDir = tempDirs.make("openclaw-widget-syntax-");
83
+ const tool = createShowWidgetTool({ stateDir, sessionId: "valid-syntax" });
84
+ const result = await tool.execute("valid", {
85
+ title: "Working widget",
86
+ widget_code: "<script>document.body.textContent = 'Working';</script>",
87
+ });
88
+ const text = result.content.find((item) => item.type === "text")?.text;
89
+ expect(JSON.parse(text ?? "null")).toMatchObject({ kind: "canvas" });
90
+ await expect(access(resolveCanvasDocumentsDir(stateDir))).resolves.toBeUndefined();
91
+ });
92
+ });
src/canvas/widget-tool.test-support.ts ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { vi } from "vitest";
2
+ import type { InProcessGatewayCaller } from "../agents/tools/in-process-gateway.js";
3
+
4
+ export function createBoardPutCaller() {
5
+ const mock = vi.fn(async (_method: string, params: Record<string, unknown>) => ({
6
+ sessionKey: params.sessionKey,
7
+ revision: 1,
8
+ tabs: [{ tabId: "main", title: "Main", position: 0, chatDock: "right" }],
9
+ widgets: [
10
+ {
11
+ name: params.name,
12
+ tabId: "main",
13
+ contentKind: "plugin",
14
+ pluginKind: "diagram:diagram",
15
+ sizeW: 6,
16
+ sizeH: 4,
17
+ position: 0,
18
+ grantState: "none",
19
+ revision: 1,
20
+ },
21
+ ],
22
+ resolvedWidgetName: params.name,
23
+ }));
24
+ const callGateway: InProcessGatewayCaller = async <T>(
25
+ method: string,
26
+ params: Record<string, unknown>,
27
+ ): Promise<T> => (await mock(method, params)) as T;
28
+ return { mock, callGateway };
29
+ }
src/canvas/widget-tool.test.ts ADDED
@@ -0,0 +1,1027 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Core inline widget validation, materialization, and retention.
2
+ import { access, mkdtemp, readFile, readdir, rm } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import path from "node:path";
5
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6
+ import type { InProcessGatewayCaller } from "../agents/tools/in-process-gateway.js";
7
+ import { readBoardHtml, createTestBoardStore } from "../boards/board-store.test-support.js";
8
+ import { retainLegacyDefaultAgentId } from "../config/legacy.default-agent-owner.js";
9
+ import type { OpenClawConfig } from "../config/types.js";
10
+ import { createBoardHandlers } from "../gateway/server-methods/board.js";
11
+ import type { GatewayRequestContext, RespondFn } from "../gateway/server-methods/types.js";
12
+ import type { WidgetPresenter } from "../plugins/plugin-registration.types.js";
13
+ import { createEmptyPluginRegistry } from "../plugins/registry-empty.js";
14
+ import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../plugins/runtime.js";
15
+ import { closeOpenClawAgentDatabasesForTest } from "../state/openclaw-agent-db.js";
16
+ import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js";
17
+ import { resolveCanvasDocumentsDir } from "./documents.js";
18
+ import { registerTestWidgetContentKind as registerDiagramContentKind } from "./widget-tool.content-kinds.test-support.js";
19
+ import { createShowWidgetTool } from "./widget-tool.js";
20
+ import { createBoardPutCaller } from "./widget-tool.test-support.js";
21
+ import { buildWidgetDocument } from "./wrap.js";
22
+
23
+ const WIDGET_CODE_MAX_CHARS = 262_144;
24
+ const PINNED_WIDGET_MAX_UTF8_BYTES = 256 * 1024;
25
+ const WIDGET_MAX_PER_SCOPE = 32;
26
+ const tempDirs: string[] = [];
27
+
28
+ beforeEach(() => {
29
+ resetPluginRuntimeStateForTest();
30
+ });
31
+
32
+ afterEach(async () => {
33
+ vi.useRealTimers();
34
+ closeOpenClawAgentDatabasesForTest();
35
+ closeOpenClawStateDatabaseForTest();
36
+ resetPluginRuntimeStateForTest();
37
+ await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
38
+ });
39
+
40
+ async function createStateDir(): Promise<string> {
41
+ const stateDir = await mkdtemp(path.join(tmpdir(), "openclaw-widget-tool-"));
42
+ tempDirs.push(stateDir);
43
+ return stateDir;
44
+ }
45
+
46
+ function createLiveBoardTestContext(
47
+ broadcast: ReturnType<typeof vi.fn> = vi.fn(),
48
+ cfg: OpenClawConfig = { agents: { entries: { main: {} } } },
49
+ ): GatewayRequestContext {
50
+ const context = {
51
+ broadcast,
52
+ getSessionEventSubscriberConnIds: () => new Set<string>(),
53
+ getRuntimeConfig: () => cfg,
54
+ } as unknown as GatewayRequestContext;
55
+ context.resolveGatewayContext = () => context;
56
+ return context;
57
+ }
58
+
59
+ function resolveCanvasDocumentDir(stateDir: string, documentId: string): string {
60
+ return path.join(resolveCanvasDocumentsDir(stateDir), documentId);
61
+ }
62
+
63
+ async function executeWidget(params: {
64
+ stateDir: string;
65
+ sessionId?: string;
66
+ title?: string;
67
+ widgetCode: string;
68
+ agentId?: string;
69
+ agentSessionKey?: string;
70
+ callGateway?: InProcessGatewayCaller;
71
+ pin?: boolean;
72
+ name?: string;
73
+ tab?: string;
74
+ size?: "sm" | "md" | "lg" | "xl" | "full";
75
+ presentation?: {
76
+ target?: "assistant_message" | "node_panel";
77
+ frame?: "card" | "full-bleed" | "frameless";
78
+ };
79
+ presenters?: readonly WidgetPresenter[];
80
+ after?: string;
81
+ capabilities?: { netOrigins?: string[]; tools?: string[] };
82
+ kind?: string;
83
+ }) {
84
+ const tool = createShowWidgetTool({
85
+ stateDir: params.stateDir,
86
+ sessionId: params.sessionId ?? "widget-session",
87
+ agentId: params.agentId ?? "main",
88
+ agentSessionKey: params.agentSessionKey,
89
+ callGateway: params.callGateway,
90
+ presenters: params.presenters,
91
+ });
92
+ const result = await tool.execute("widget-call", {
93
+ title: params.title ?? "Widget title",
94
+ widget_code: params.widgetCode,
95
+ ...(params.pin !== undefined ? { pin: params.pin } : {}),
96
+ ...(params.name ? { name: params.name } : {}),
97
+ ...(params.tab ? { tab: params.tab } : {}),
98
+ ...(params.size ? { size: params.size } : {}),
99
+ ...(params.presentation ? { presentation: params.presentation } : {}),
100
+ ...(params.after ? { after: params.after } : {}),
101
+ ...(params.capabilities ? { capabilities: params.capabilities } : {}),
102
+ ...(params.kind ? { kind: params.kind } : {}),
103
+ });
104
+ const text = result.content.find((item) => item.type === "text")?.text;
105
+ if (!text) {
106
+ throw new Error("expected widget tool text result");
107
+ }
108
+ const parsed = JSON.parse(text) as {
109
+ kind?: string;
110
+ presentation?: { target?: string; title?: string; sandbox?: string };
111
+ view?: { id?: string; url?: string; boardWidgetName?: string };
112
+ text?: string;
113
+ };
114
+ const viewId = parsed.view?.id;
115
+ const url = parsed.view?.url;
116
+ if (parsed.kind !== "canvas" || !viewId || !url) {
117
+ throw new Error("expected canvas preview handle");
118
+ }
119
+ return {
120
+ viewId,
121
+ url,
122
+ sandbox: parsed.presentation?.sandbox,
123
+ resultText: parsed.text,
124
+ boardWidgetName: parsed.view?.boardWidgetName,
125
+ target: parsed.presentation?.target,
126
+ text,
127
+ };
128
+ }
129
+
130
+ describe("show_widget", () => {
131
+ it("builds a sorted kind enum and routes registered source through board put", async () => {
132
+ registerDiagramContentKind();
133
+ const stateDir = await createStateDir();
134
+ const { mock: callGatewayMock, callGateway } = createBoardPutCaller();
135
+ const tool = createShowWidgetTool({
136
+ stateDir,
137
+ sessionId: "registered",
138
+ agentSessionKey: "agent:main:registered",
139
+ callGateway,
140
+ });
141
+ const kindSchema = (tool.parameters as { properties?: { kind?: { enum?: string[] } } })
142
+ .properties?.kind;
143
+
144
+ expect(kindSchema?.enum).toEqual(["html", "diagram"]);
145
+ await tool.execute("registered", {
146
+ title: "Diagram",
147
+ widget_code: "diagram:ready",
148
+ kind: "diagram",
149
+ pin: true,
150
+ });
151
+ expect(callGatewayMock).toHaveBeenCalledWith(
152
+ "board.widget.put",
153
+ expect.objectContaining({
154
+ content: { kind: "registered", contentKind: "diagram", source: "diagram:ready" },
155
+ }),
156
+ );
157
+
158
+ setActivePluginRegistry(createEmptyPluginRegistry());
159
+ await expect(
160
+ tool.execute("stale", {
161
+ title: "Diagram",
162
+ widget_code: "diagram:ready",
163
+ kind: "diagram",
164
+ }),
165
+ ).rejects.toThrow(
166
+ 'widget kind "diagram" is unavailable; enable the plugin that provides it and retry',
167
+ );
168
+ });
169
+
170
+ it("uses board-only delivery when inline hosting is disabled", async () => {
171
+ registerDiagramContentKind();
172
+ const stateDir = await createStateDir();
173
+ const { mock: callGatewayMock, callGateway } = createBoardPutCaller();
174
+ const tool = createShowWidgetTool({
175
+ stateDir,
176
+ sessionId: "board-only",
177
+ agentSessionKey: "agent:main:board-only",
178
+ inlineHostEnabled: false,
179
+ callGateway,
180
+ });
181
+
182
+ expect(tool.description).toContain(
183
+ "Inline previews are unavailable this turn; set pin=true to save to the session dashboard",
184
+ );
185
+ expect(tool.description).not.toContain("Keep one-off visualizations inline");
186
+ await expect(
187
+ tool.execute("unpinned", {
188
+ title: "Diagram",
189
+ widget_code: "diagram:ready",
190
+ kind: "diagram",
191
+ }),
192
+ ).rejects.toThrow(
193
+ "inline widget hosting is disabled; set pin=true to place the widget on the session dashboard",
194
+ );
195
+ await expect(access(resolveCanvasDocumentsDir(stateDir))).rejects.toThrow();
196
+
197
+ const result = await tool.execute("pinned", {
198
+ title: "Diagram",
199
+ widget_code: "diagram:ready",
200
+ kind: "diagram",
201
+ pin: true,
202
+ });
203
+ const text = result.content.find((item) => item.type === "text")?.text;
204
+ expect(JSON.parse(text ?? "null")).toEqual({
205
+ status: "pinned",
206
+ boardWidgetName: "diagram",
207
+ capabilityState: "none",
208
+ text: "Widget pinned to dashboard tab main as diagram. Open this dashboard tab in Control UI to view it.",
209
+ });
210
+ expect(callGatewayMock).toHaveBeenCalledExactlyOnceWith(
211
+ "board.widget.put",
212
+ expect.objectContaining({
213
+ content: { kind: "registered", contentKind: "diagram", source: "diagram:ready" },
214
+ }),
215
+ );
216
+ await expect(access(resolveCanvasDocumentsDir(stateDir))).rejects.toThrow();
217
+ });
218
+
219
+ it("keeps widget documents from duplicating host-owned metadata and controls", () => {
220
+ const description = createShowWidgetTool().description;
221
+
222
+ expect(description).toContain("openclaw.host.controlUiBaseUrl");
223
+ expect(description).toContain("read it at click time");
224
+ expect(description).toContain('target="_blank" and rel="noopener noreferrer"');
225
+ expect(description).toContain("`title` is host metadata");
226
+ expect(description).toContain("Start directly with content");
227
+ expect(description).toContain("do not repeat the title");
228
+ });
229
+
230
+ it("builds provider-safe kind and presentation enums from both registries", () => {
231
+ registerDiagramContentKind();
232
+ const tool = createShowWidgetTool();
233
+ const properties = (
234
+ tool.parameters as {
235
+ properties?: Record<
236
+ string,
237
+ {
238
+ anyOf?: unknown;
239
+ enum?: string[];
240
+ properties?: Record<string, { anyOf?: unknown; enum?: string[]; description?: string }>;
241
+ }
242
+ >;
243
+ }
244
+ ).properties;
245
+ expect(properties?.kind).toMatchObject({ enum: ["html", "diagram"] });
246
+ expect(properties?.size).toMatchObject({ enum: ["sm", "md", "lg", "xl", "full"] });
247
+ expect(properties?.presentation?.properties?.target).toMatchObject({
248
+ enum: ["assistant_message"],
249
+ });
250
+ expect(properties?.presentation?.properties?.target?.description).not.toContain("node_panel");
251
+ expect(properties?.presentation?.properties?.frame).toMatchObject({
252
+ enum: ["card", "full-bleed", "frameless"],
253
+ });
254
+ expect(properties?.size?.anyOf).toBeUndefined();
255
+ expect(properties?.presentation?.properties?.target?.anyOf).toBeUndefined();
256
+ expect(properties?.presentation?.properties?.frame?.anyOf).toBeUndefined();
257
+ expect(tool.description).toContain("registered kinds are diagram");
258
+
259
+ const presenter: WidgetPresenter = {
260
+ target: "node_panel",
261
+ description: "Show on a connected device panel",
262
+ availability: async () => ({ ok: true, value: { available: true } }),
263
+ present: async () => ({
264
+ ok: true,
265
+ value: { kind: "node", nodeId: "mac-panel", nodeName: "Studio" },
266
+ }),
267
+ };
268
+ const withPresenterTool = createShowWidgetTool({ presenters: [presenter] });
269
+ const withPresenter = withPresenterTool.parameters as {
270
+ properties?: Record<
271
+ string,
272
+ {
273
+ enum?: string[];
274
+ properties?: Record<string, { enum?: string[]; description?: string }>;
275
+ }
276
+ >;
277
+ };
278
+ expect(withPresenter.properties?.kind).toMatchObject({ enum: ["html", "diagram"] });
279
+ expect(withPresenter.properties?.presentation?.properties?.target).toMatchObject({
280
+ enum: ["assistant_message", "node_panel"],
281
+ description: expect.stringContaining("node_panel: Show on a connected device panel"),
282
+ });
283
+ expect(withPresenterTool.description).toContain("registered kinds are diagram");
284
+ expect(withPresenterTool.description).toContain(
285
+ "Use presentation.target to choose a registered device surface.",
286
+ );
287
+ });
288
+
289
+ it("routes node-panel presentation and reports the selected device", async () => {
290
+ const stateDir = await createStateDir();
291
+ const availability = vi.fn(async () => ({
292
+ ok: true as const,
293
+ value: { available: true as const },
294
+ }));
295
+ const present = vi.fn(async () => ({
296
+ ok: true as const,
297
+ value: { kind: "node" as const, nodeId: "mac-panel", nodeName: "Studio" },
298
+ }));
299
+ const result = await executeWidget({
300
+ stateDir,
301
+ title: "Status",
302
+ widgetCode: "<p>ready</p>",
303
+ agentSessionKey: "agent:main:status",
304
+ presentation: { target: "node_panel" },
305
+ presenters: [
306
+ {
307
+ target: "node_panel",
308
+ description: "Show on a connected device panel",
309
+ availability,
310
+ present,
311
+ },
312
+ ],
313
+ });
314
+
315
+ expect(result.target).toBe("node_panel");
316
+ expect(result.resultText).toContain("presented on Studio (mac-panel)");
317
+ expect(availability).toHaveBeenCalledWith({ sessionKey: "agent:main:status" });
318
+ expect(present).toHaveBeenCalledWith({
319
+ document: {
320
+ kind: "html",
321
+ html: expect.stringContaining("<p>ready</p>"),
322
+ hostedUrl: result.url,
323
+ },
324
+ title: "Status",
325
+ context: { sessionKey: "agent:main:status" },
326
+ });
327
+ });
328
+
329
+ it.each([
330
+ {
331
+ name: "has no registered presenter",
332
+ presenters: [] as WidgetPresenter[],
333
+ expected: "No widget presenter is registered",
334
+ },
335
+ {
336
+ name: "has no eligible node",
337
+ presenters: [
338
+ {
339
+ target: "node_panel" as const,
340
+ description: "Show on a connected device panel",
341
+ availability: async () => ({
342
+ ok: false as const,
343
+ error: { code: "no_eligible_node" as const, message: "No connected device." },
344
+ }),
345
+ present: async () => {
346
+ throw new Error("present must not run");
347
+ },
348
+ },
349
+ ],
350
+ expected: "No connected device.",
351
+ },
352
+ {
353
+ name: "hits a node error",
354
+ presenters: [
355
+ {
356
+ target: "node_panel" as const,
357
+ description: "Show on a connected device panel",
358
+ availability: async () => ({ ok: true as const, value: { available: true as const } }),
359
+ present: async () => ({
360
+ ok: false as const,
361
+ error: {
362
+ code: "node_error" as const,
363
+ message: "Canvas is disabled.",
364
+ nodeId: "mac-panel",
365
+ },
366
+ }),
367
+ },
368
+ ],
369
+ expected: "Canvas is disabled.",
370
+ },
371
+ ])(
372
+ "falls back inline with an actionable message when node presentation $name",
373
+ async ({ presenters, expected }) => {
374
+ const stateDir = await createStateDir();
375
+ const result = await executeWidget({
376
+ stateDir,
377
+ widgetCode: "<p>inline fallback</p>",
378
+ presentation: { target: "node_panel" },
379
+ presenters,
380
+ });
381
+
382
+ expect(result.target).toBe("assistant_message");
383
+ expect(result.resultText).toContain(expected);
384
+ expect(result.resultText).toContain("available inline here");
385
+ expect(result.resultText).toMatch(
386
+ /Pair a canvas-capable device|Retry the requested presentation destination/u,
387
+ );
388
+ await expect(
389
+ access(resolveCanvasDocumentDir(stateDir, result.viewId)),
390
+ ).resolves.toBeUndefined();
391
+ },
392
+ );
393
+
394
+ it("enforces current-presenter source kinds and byte limits in core", async () => {
395
+ registerDiagramContentKind();
396
+ const presenter: WidgetPresenter = {
397
+ target: "current_channel",
398
+ description: "HTML-only current channel",
399
+ capabilities: { sourceKinds: ["html"], maxSourceBytes: 8 },
400
+ match: () => true,
401
+ availability: async () => ({ ok: true, value: { available: true } }),
402
+ present: async () => {
403
+ throw new Error("present must not run");
404
+ },
405
+ };
406
+ const tool = createShowWidgetTool({
407
+ inlineClientAvailable: false,
408
+ presenters: [presenter],
409
+ presenterContext: {},
410
+ });
411
+ const kindSchema = (tool.parameters as { properties?: { kind?: { enum?: string[] } } })
412
+ .properties?.kind;
413
+
414
+ expect(kindSchema?.enum).toEqual(["html"]);
415
+ expect(tool.description).not.toContain("registered kinds are diagram");
416
+ await expect(
417
+ tool.execute("oversized-current", { title: "Large", widget_code: "123456789" }),
418
+ ).rejects.toThrow("widget_code exceeds maximum size (8 bytes)");
419
+ await expect(
420
+ tool.execute("unsupported-current", {
421
+ title: "Diagram",
422
+ widget_code: "diagram:ready",
423
+ kind: "diagram",
424
+ }),
425
+ ).rejects.toThrow("inline widget hosting is disabled");
426
+ });
427
+
428
+ it("fails visibly without inline fallback and uses a real inline route when available", async () => {
429
+ const presenter: WidgetPresenter = {
430
+ target: "current_channel",
431
+ description: "Failing current channel",
432
+ capabilities: { sourceKinds: ["html"] },
433
+ match: () => true,
434
+ availability: async () => ({ ok: true, value: { available: true } }),
435
+ present: async () => ({
436
+ ok: false,
437
+ error: { code: "presentation_error", message: "delivery rejected" },
438
+ }),
439
+ };
440
+ const noInline = createShowWidgetTool({
441
+ inlineClientAvailable: false,
442
+ presenters: [presenter],
443
+ presenterContext: {},
444
+ });
445
+ await expect(
446
+ noInline.execute("no-inline", { title: "Status", widget_code: "<p>ready</p>" }),
447
+ ).rejects.toThrow("Widget presentation failed: delivery rejected");
448
+
449
+ const stateDir = await createStateDir();
450
+ const withInline = createShowWidgetTool({
451
+ stateDir,
452
+ sessionId: "inline-fallback",
453
+ inlineClientAvailable: true,
454
+ presenters: [presenter],
455
+ presenterContext: {},
456
+ });
457
+ const fallback = await withInline.execute("with-inline", {
458
+ title: "Status",
459
+ widget_code: "<p>ready</p>",
460
+ });
461
+ const parsed = JSON.parse(
462
+ fallback.content[0]?.type === "text" ? fallback.content[0].text : "null",
463
+ );
464
+ expect(parsed).toMatchObject({
465
+ kind: "canvas",
466
+ presentation: { target: "assistant_message" },
467
+ text: expect.stringContaining("delivery rejected. The widget is available inline here."),
468
+ });
469
+ });
470
+
471
+ it("reports pin success and presentation failure as an explicit partial outcome", async () => {
472
+ const { callGateway } = createBoardPutCaller();
473
+ const presenter: WidgetPresenter = {
474
+ target: "current_channel",
475
+ description: "Failing current channel",
476
+ capabilities: { sourceKinds: ["html"] },
477
+ match: () => true,
478
+ availability: async () => ({ ok: true, value: { available: true } }),
479
+ present: async () => ({
480
+ ok: false,
481
+ error: { code: "presentation_error", message: "delivery rejected" },
482
+ }),
483
+ };
484
+ const tool = createShowWidgetTool({
485
+ agentSessionKey: "agent:main:partial",
486
+ callGateway,
487
+ inlineClientAvailable: false,
488
+ presenters: [presenter],
489
+ presenterContext: {},
490
+ });
491
+ const result = await tool.execute("partial", {
492
+ title: "Status",
493
+ widget_code: "<p>ready</p>",
494
+ pin: true,
495
+ });
496
+ const parsed = JSON.parse(result.content[0]?.type === "text" ? result.content[0].text : "null");
497
+
498
+ expect(parsed).toMatchObject({
499
+ status: "partial",
500
+ boardWidgetName: "status",
501
+ presentation: {
502
+ target: "current_channel",
503
+ status: "failed",
504
+ error: { code: "presentation_error", message: "delivery rejected" },
505
+ },
506
+ text: expect.stringContaining(
507
+ "pinned to dashboard tab main as status, but presentation failed",
508
+ ),
509
+ });
510
+ });
511
+
512
+ it("rejects empty and oversized widget code", async () => {
513
+ const stateDir = await createStateDir();
514
+ const tool = createShowWidgetTool({ stateDir, sessionId: "validation" });
515
+
516
+ await expect(tool.execute("empty", { title: "Empty", widget_code: " " })).rejects.toThrow(
517
+ "widget_code required",
518
+ );
519
+ await expect(
520
+ tool.execute("oversized", {
521
+ title: "Too large",
522
+ widget_code: "x".repeat(WIDGET_CODE_MAX_CHARS + 1),
523
+ }),
524
+ ).rejects.toThrow(`widget_code exceeds maximum size (${WIDGET_CODE_MAX_CHARS} characters)`);
525
+ });
526
+
527
+ it("rejects pinning without a session before creating a Canvas document", async () => {
528
+ const stateDir = await createStateDir();
529
+ const tool = createShowWidgetTool({ stateDir, sessionId: "missing-agent-session" });
530
+
531
+ await expect(
532
+ tool.execute("pin", {
533
+ title: "Pinned",
534
+ widget_code: "<p>never materialized</p>",
535
+ pin: true,
536
+ }),
537
+ ).rejects.toThrow("pin requires an agent session");
538
+ await expect(access(resolveCanvasDocumentsDir(stateDir))).rejects.toThrow();
539
+ });
540
+
541
+ it("rejects multibyte pin input that exceeds the wrapped UTF-8 budget", async () => {
542
+ const stateDir = await createStateDir();
543
+ const callGateway = vi.fn();
544
+ const title = "Multibyte";
545
+ const wrapperBytes = Buffer.byteLength(buildWidgetDocument(title, ""), "utf8");
546
+ const widgetCode = "é".repeat(
547
+ Math.floor((PINNED_WIDGET_MAX_UTF8_BYTES - wrapperBytes) / 2) + 1,
548
+ );
549
+ expect(widgetCode.length).toBeLessThan(WIDGET_CODE_MAX_CHARS);
550
+ expect(Buffer.byteLength(buildWidgetDocument(title, widgetCode), "utf8")).toBeGreaterThan(
551
+ PINNED_WIDGET_MAX_UTF8_BYTES,
552
+ );
553
+ const tool = createShowWidgetTool({
554
+ stateDir,
555
+ sessionId: "wrapped-budget",
556
+ agentSessionKey: "agent:main:wrapped-budget",
557
+ callGateway,
558
+ });
559
+
560
+ await expect(
561
+ tool.execute("pin", { title, widget_code: widgetCode, pin: true }),
562
+ ).rejects.toThrow(
563
+ `pin exceeds effective dashboard budget (${PINNED_WIDGET_MAX_UTF8_BYTES} UTF-8 bytes after wrapping)`,
564
+ );
565
+ expect(callGateway).not.toHaveBeenCalled();
566
+ await expect(access(resolveCanvasDocumentsDir(stateDir))).rejects.toThrow();
567
+ });
568
+
569
+ it("does not create an inline Canvas document when dashboard pinning fails", async () => {
570
+ const stateDir = await createStateDir();
571
+ const callGateway = vi.fn(async () => {
572
+ throw new Error("board tab not found: missing");
573
+ });
574
+
575
+ await expect(
576
+ executeWidget({
577
+ stateDir,
578
+ agentSessionKey: "agent:main:failed-pin",
579
+ widgetCode: "<p>never materialized</p>",
580
+ pin: true,
581
+ tab: "missing",
582
+ callGateway,
583
+ }),
584
+ ).rejects.toThrow("board tab not found: missing");
585
+ expect(callGateway).toHaveBeenCalledOnce();
586
+ await expect(access(resolveCanvasDocumentsDir(stateDir))).rejects.toThrow();
587
+ });
588
+
589
+ it("requires dashboard pinning for declared capabilities", async () => {
590
+ const stateDir = await createStateDir();
591
+ const tool = createShowWidgetTool({ stateDir, sessionId: "capabilities" });
592
+
593
+ await expect(
594
+ tool.execute("capabilities", {
595
+ title: "Weather",
596
+ widget_code: "<p>weather</p>",
597
+ capabilities: { netOrigins: ["https://api.open-meteo.com"] },
598
+ }),
599
+ ).rejects.toThrow("capabilities require pin=true");
600
+ });
601
+
602
+ it("wraps SVG widgets with the stable result and sandbox contracts", async () => {
603
+ const stateDir = await createStateDir();
604
+ const { viewId, url, sandbox, text } = await executeWidget({
605
+ stateDir,
606
+ title: "<Status>",
607
+ widgetCode: ' <SvG viewBox="0 0 10 10"><circle r="4" /></SvG> ',
608
+ });
609
+
610
+ expect(viewId).toMatch(/^cv_[a-f0-9]{32}$/);
611
+ expect(url).toBe(`/__openclaw__/canvas/documents/${viewId}/index.html`);
612
+ expect(JSON.parse(text)).toMatchObject({
613
+ kind: "canvas",
614
+ presentation: { target: "assistant_message", title: "<Status>", sandbox: "scripts" },
615
+ text: `Widget hosted at ${url}`,
616
+ });
617
+ expect(sandbox).toBe("scripts");
618
+ const html = await readFile(
619
+ path.join(resolveCanvasDocumentDir(stateDir, viewId), "index.html"),
620
+ "utf8",
621
+ );
622
+ expect(html).toContain(
623
+ `Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline' https://cdnjs.cloudflare.com`,
624
+ );
625
+ expect(html).toContain("font-src data:");
626
+ expect(html).toContain("img-src data:; connect-src 'none'");
627
+ expect(html).toContain("<title>&lt;Status&gt;</title>");
628
+ expect(html).toContain("--accent:#bd4531");
629
+ expect(html).toContain("--accent:#ff5c5c");
630
+ expect(html).toContain("--accent-fill:#d13c3c");
631
+ expect(html).toContain('<body class="svg-widget"><script>');
632
+ expect(html).toContain("openclaw:widget-size");
633
+ const manifest = JSON.parse(
634
+ await readFile(
635
+ path.join(resolveCanvasDocumentDir(stateDir, viewId), "manifest.json"),
636
+ "utf8",
637
+ ),
638
+ ) as { cspSandbox?: string };
639
+ expect(manifest.cspSandbox).toBe("scripts");
640
+ });
641
+
642
+ it("keeps unpinned behavior unchanged without a board call", async () => {
643
+ const stateDir = await createStateDir();
644
+ const callGateway = vi.fn();
645
+
646
+ const result = await executeWidget({
647
+ stateDir,
648
+ widgetCode: "<p>inline only</p>",
649
+ callGateway,
650
+ });
651
+
652
+ expect(result.resultText).toBe(`Widget hosted at ${result.url}`);
653
+ expect(callGateway).not.toHaveBeenCalled();
654
+ });
655
+
656
+ it.each([
657
+ ["qualified Main", "agent:main:pinned", "main", false],
658
+ ["explicit Research global", "global", "research", false],
659
+ ["Research global with retained Main", "global", "research", true],
660
+ ] as const)(
661
+ "creates and refreshes pinned HTML in %s",
662
+ async (_label, sessionKey, agentId, retainedMain) => {
663
+ const stateDir = await createStateDir();
664
+ const store = createTestBoardStore({ stateDir });
665
+ const broadcast = vi.fn();
666
+ const target = { sessionKey, agentId };
667
+ const sibling = { sessionKey: "global", agentId: agentId === "main" ? "research" : "main" };
668
+ const siblingBefore = await store.getSnapshot(sibling);
669
+ const boardBroadcastScope = { sessionKeys: [sessionKey], agentId };
670
+ const eventSessionKey = sessionKey === "global" ? `agent:${agentId}:global` : sessionKey;
671
+ const cfg: OpenClawConfig = {
672
+ agents: { ownership: "explicit", entries: { main: {}, research: {} } },
673
+ session: { scope: "global" },
674
+ };
675
+ if (retainedMain) {
676
+ retainLegacyDefaultAgentId(cfg, "main");
677
+ }
678
+ const handlers = createBoardHandlers(store);
679
+ const title = "Release Status ".repeat(8).trim();
680
+ const callGateway: InProcessGatewayCaller = async <T>(
681
+ method: string,
682
+ params: Record<string, unknown>,
683
+ ): Promise<T> => {
684
+ let result: unknown;
685
+ let failure: Error | undefined;
686
+ const respond: RespondFn = (ok, payload, error) => {
687
+ if (ok) {
688
+ result = payload;
689
+ } else {
690
+ failure = new Error(error?.message ?? "board request failed");
691
+ }
692
+ };
693
+ await handlers[method]!({
694
+ req: { type: "req", id: "show-widget-pin", method, params },
695
+ params,
696
+ client: null,
697
+ isWebchatConnect: () => false,
698
+ respond,
699
+ context: createLiveBoardTestContext(broadcast, cfg),
700
+ });
701
+ if (failure) {
702
+ throw failure;
703
+ }
704
+ return result as T;
705
+ };
706
+
707
+ const pinWidget = (widgetCode: string, withPlacement = false) =>
708
+ executeWidget({
709
+ stateDir,
710
+ agentId,
711
+ agentSessionKey: sessionKey,
712
+ title,
713
+ widgetCode,
714
+ pin: true,
715
+ name: "release-status",
716
+ ...(withPlacement
717
+ ? { tab: "main", size: "lg" as const, presentation: { frame: "frameless" as const } }
718
+ : {}),
719
+ callGateway,
720
+ });
721
+ const result = await pinWidget("<p>ready</p>", true);
722
+ const pinnedTitle = Array.from(title).slice(0, 80).join("");
723
+
724
+ expect(await readBoardHtml(store, target, "release-status")).toMatchObject({
725
+ html: buildWidgetDocument(pinnedTitle, "<p>ready</p>"),
726
+ revision: 1,
727
+ });
728
+ expect((await store.getSnapshot(target)).widgets[0]?.title).toBe(pinnedTitle);
729
+ expect((await store.getSnapshot(target)).widgets[0]?.presentation).toBe("frameless");
730
+ expect(result.resultText).toContain("pinned to dashboard tab main as release-status (lg)");
731
+ expect(result.boardWidgetName).toBe("release-status");
732
+ expect(broadcast).toHaveBeenCalledWith(
733
+ "board.changed",
734
+ { sessionKey: eventSessionKey, revision: 1, widget: "release-status" },
735
+ boardBroadcastScope,
736
+ );
737
+
738
+ await expect(
739
+ callGateway("board.widget.put", {
740
+ ...target,
741
+ name: "release-status",
742
+ content: { kind: "plugin", pluginKind: "workboard:card" },
743
+ }),
744
+ ).rejects.toThrow(/same content kind.*remove/i);
745
+ expect((await readBoardHtml(store, target, "release-status"))?.revision).toBe(1);
746
+
747
+ const refreshed = await pinWidget("<p>refreshed</p>");
748
+
749
+ expect(await readBoardHtml(store, target, "release-status")).toMatchObject({
750
+ html: buildWidgetDocument(pinnedTitle, "<p>refreshed</p>"),
751
+ revision: 2,
752
+ });
753
+ expect(refreshed.boardWidgetName).toBe("release-status");
754
+ expect(broadcast).toHaveBeenCalledWith(
755
+ "board.changed",
756
+ { sessionKey: eventSessionKey, revision: 2, widget: "release-status" },
757
+ boardBroadcastScope,
758
+ );
759
+ expect(await store.getSnapshot(sibling)).toEqual(siblingBefore);
760
+ },
761
+ );
762
+
763
+ it("pins a granted-CSP document and declaration without networking in the inline preview", async () => {
764
+ const stateDir = await createStateDir();
765
+ const store = createTestBoardStore({ stateDir });
766
+ const handlers = createBoardHandlers(store);
767
+ const callGateway: InProcessGatewayCaller = async <T>(
768
+ method: string,
769
+ params: Record<string, unknown>,
770
+ ): Promise<T> => {
771
+ let result: unknown;
772
+ let failure: Error | undefined;
773
+ await handlers[method]!({
774
+ req: { type: "req", id: "show-widget-capabilities", method, params },
775
+ params,
776
+ client: null,
777
+ isWebchatConnect: () => false,
778
+ respond: (ok, payload, error) => {
779
+ if (ok) {
780
+ result = payload;
781
+ } else {
782
+ failure = new Error(error?.message ?? "board request failed");
783
+ }
784
+ },
785
+ context: createLiveBoardTestContext(),
786
+ });
787
+ if (failure) {
788
+ throw failure;
789
+ }
790
+ return result as T;
791
+ };
792
+
793
+ const result = await executeWidget({
794
+ stateDir,
795
+ agentSessionKey: "agent:main:weather",
796
+ title: "Weather",
797
+ widgetCode: "<p>weather</p>",
798
+ pin: true,
799
+ capabilities: {
800
+ netOrigins: ["https://api.open-meteo.com"],
801
+ tools: ["health", "prompt"],
802
+ },
803
+ callGateway,
804
+ });
805
+ const inlineHtml = await readFile(
806
+ path.join(resolveCanvasDocumentDir(stateDir, result.viewId), "index.html"),
807
+ "utf8",
808
+ );
809
+ const pinned = await readBoardHtml(store, { sessionKey: "agent:main:weather" }, "weather");
810
+
811
+ expect(inlineHtml).toContain("connect-src 'none'");
812
+ expect(pinned).toMatchObject({
813
+ grantState: "granted",
814
+ declared: {
815
+ netOrigins: ["https://api.open-meteo.com"],
816
+ tools: ["health", "prompt"],
817
+ },
818
+ });
819
+ expect(pinned && "html" in pinned ? pinned.html : "").toContain(
820
+ "connect-src https://api.open-meteo.com",
821
+ );
822
+ });
823
+
824
+ it("keeps generated pin names distinct when titles cannot fit a plain slug", async () => {
825
+ const stateDir = await createStateDir();
826
+ const callGateway: InProcessGatewayCaller = async <T>(
827
+ _method: string,
828
+ params: Record<string, unknown>,
829
+ ): Promise<T> => {
830
+ const request = params as { name: string; sessionKey: string };
831
+ return {
832
+ sessionKey: request.sessionKey,
833
+ revision: 1,
834
+ tabs: [{ tabId: "main", title: "Main", position: 0, chatDock: "right" }],
835
+ widgets: [
836
+ {
837
+ name: request.name,
838
+ tabId: "main",
839
+ contentKind: "html",
840
+ sizeW: 6,
841
+ sizeH: 4,
842
+ position: 0,
843
+ grantState: "none",
844
+ revision: 1,
845
+ },
846
+ ],
847
+ resolvedWidgetName: request.name,
848
+ } as T;
849
+ };
850
+
851
+ const unicodeA = await executeWidget({
852
+ stateDir,
853
+ agentSessionKey: "agent:main:pinned",
854
+ title: "状态",
855
+ widgetCode: "<p>one</p>",
856
+ pin: true,
857
+ callGateway,
858
+ });
859
+ const unicodeB = await executeWidget({
860
+ stateDir,
861
+ agentSessionKey: "agent:main:pinned",
862
+ title: "天气",
863
+ widgetCode: "<p>two</p>",
864
+ pin: true,
865
+ callGateway,
866
+ });
867
+ const longA = await executeWidget({
868
+ stateDir,
869
+ agentSessionKey: "agent:main:pinned",
870
+ title: `${"shared ".repeat(20)}alpha`,
871
+ widgetCode: "<p>three</p>",
872
+ pin: true,
873
+ callGateway,
874
+ });
875
+ const longB = await executeWidget({
876
+ stateDir,
877
+ agentSessionKey: "agent:main:pinned",
878
+ title: `${"shared ".repeat(20)}beta`,
879
+ widgetCode: "<p>four</p>",
880
+ pin: true,
881
+ callGateway,
882
+ });
883
+
884
+ expect(unicodeA.boardWidgetName).toMatch(/^widget-[a-f0-9]{8}$/u);
885
+ expect(unicodeB.boardWidgetName).toMatch(/^widget-[a-f0-9]{8}$/u);
886
+ expect(unicodeA.boardWidgetName).not.toBe(unicodeB.boardWidgetName);
887
+ expect(longA.boardWidgetName).not.toBe(longB.boardWidgetName);
888
+ expect(longA.boardWidgetName).toHaveLength(64);
889
+ expect(longB.boardWidgetName).toHaveLength(64);
890
+ });
891
+
892
+ it("keeps colliding generated pins distinct and canonical spellings stable", async () => {
893
+ const stateDir = await createStateDir();
894
+ const store = createTestBoardStore({ stateDir });
895
+ const handlers = createBoardHandlers(store);
896
+ const callGateway: InProcessGatewayCaller = async <T>(
897
+ method: string,
898
+ params: Record<string, unknown>,
899
+ ): Promise<T> => {
900
+ let result: unknown;
901
+ let failure: Error | undefined;
902
+ await handlers[method]!({
903
+ req: { type: "req", id: "generated-pin", method, params },
904
+ params,
905
+ client: null,
906
+ isWebchatConnect: () => false,
907
+ respond: (ok, payload, error) => {
908
+ if (ok) {
909
+ result = payload;
910
+ } else {
911
+ failure = new Error(error?.message ?? "board request failed");
912
+ }
913
+ },
914
+ context: createLiveBoardTestContext(),
915
+ });
916
+ if (failure) {
917
+ throw failure;
918
+ }
919
+ return result as T;
920
+ };
921
+ const sessionKey = "agent:main:generated-collision";
922
+ const [slash, plus] = await Promise.all([
923
+ executeWidget({
924
+ stateDir,
925
+ agentSessionKey: sessionKey,
926
+ title: "Revenue / Cost",
927
+ widgetCode: "<p>slash</p>",
928
+ pin: true,
929
+ callGateway,
930
+ }),
931
+ executeWidget({
932
+ stateDir,
933
+ agentSessionKey: sessionKey,
934
+ title: "Revenue + Cost",
935
+ widgetCode: "<p>plus</p>",
936
+ pin: true,
937
+ callGateway,
938
+ }),
939
+ ]);
940
+ expect(new Set([slash.boardWidgetName, plus.boardWidgetName]).size).toBe(2);
941
+ expect((await store.getSnapshot({ sessionKey })).widgets).toHaveLength(2);
942
+
943
+ const composed = await executeWidget({
944
+ stateDir,
945
+ agentSessionKey: sessionKey,
946
+ title: "Café Menu".normalize("NFC"),
947
+ widgetCode: "<p>one</p>",
948
+ pin: true,
949
+ callGateway,
950
+ });
951
+ const decomposed = await executeWidget({
952
+ stateDir,
953
+ agentSessionKey: sessionKey,
954
+ title: "Café Menu".normalize("NFD"),
955
+ widgetCode: "<p>two</p>",
956
+ pin: true,
957
+ callGateway,
958
+ });
959
+ expect(decomposed.boardWidgetName).toBe(composed.boardWidgetName);
960
+ expect(
961
+ await readBoardHtml(store, { sessionKey }, composed.boardWidgetName ?? ""),
962
+ ).toMatchObject({
963
+ revision: 2,
964
+ });
965
+ });
966
+
967
+ it("keeps the host bridges ordered around HTML widget code", async () => {
968
+ const stateDir = await createStateDir();
969
+ const { viewId } = await executeWidget({
970
+ stateDir,
971
+ widgetCode: "<section><button>Run</button><script>document.title='ready'</script></section>",
972
+ });
973
+ const html = await readFile(
974
+ path.join(resolveCanvasDocumentDir(stateDir, viewId), "index.html"),
975
+ "utf8",
976
+ );
977
+
978
+ expect(html).not.toContain('<body class="svg-widget">');
979
+ expect(html.indexOf("window.sendPrompt")).toBeLessThan(html.indexOf("<section>"));
980
+ expect(html).toContain("openclaw:widget-theme");
981
+ expect(html.indexOf("openclaw:widget-theme")).toBeLessThan(html.indexOf("<section>"));
982
+ expect(html).toContain("openclaw:widget-snapshot-request");
983
+ expect(html.indexOf("openclaw:widget-theme")).toBeLessThan(
984
+ html.indexOf("openclaw:widget-snapshot-request"),
985
+ );
986
+ expect(html.indexOf("openclaw:widget-snapshot-request")).toBeLessThan(
987
+ html.indexOf("<section>"),
988
+ );
989
+ expect(html).toContain("openclaw:widget-prompt-offer");
990
+ expect(html).toContain("openclaw:widget-bridge-port-offer");
991
+ expect(html).toContain("openclaw:widget-bridge-request");
992
+ expect(html).toContain("prompt:freeze({send:sendPrompt})");
993
+ expect(html).toContain('state:freeze({emit:payload=>request("state.emit"');
994
+ expect(html).toContain('data:freeze({read:(bindingId,params)=>request("data.read"');
995
+ expect(html).toContain('action:freeze({run:(action,params)=>request("action.run"');
996
+ expect(html).toContain('cron:freeze({trigger:jobId=>request("cron.trigger"');
997
+ expect(html).toContain("navigator.userActivation");
998
+ expect(html).toContain("c.port1.postMessage.bind(c.port1)");
999
+ expect(html).toContain("b.port1.postMessage.bind(b.port1)");
1000
+ expect(html).toContain('bridgePost({type:"openclaw:widget-bridge-request"');
1001
+ expect(html).not.toContain(
1002
+ 'post({type:"openclaw:widget-bridge-request",id,method,params,ticket},"*")',
1003
+ );
1004
+ expect(html).toContain('promptPost({type:"openclaw:widget-prompt"');
1005
+ expect(html).not.toContain('window.parent.postMessage({type:"openclaw:widget-prompt",');
1006
+ expect(html).toContain("const post=(message,origin)=>parent.postMessage(message,origin)");
1007
+ expect(html).toContain('query.call(root,"script")');
1008
+ expect(html).toContain('queryDocument("canvas")');
1009
+ expect(html).toContain("canvasWidth*canvasHeight>16777216");
1010
+ expect(html).toContain('toDataURL.call(canvas,"image/png")');
1011
+ });
1012
+
1013
+ it("uses opaque ids and evicts the oldest widget within a session scope", async () => {
1014
+ vi.useFakeTimers();
1015
+ vi.setSystemTime(new Date("2026-07-07T00:00:00.000Z"));
1016
+ const stateDir = await createStateDir();
1017
+ const first = await executeWidget({ stateDir, widgetCode: "<p>0</p>" });
1018
+ for (let index = 1; index <= WIDGET_MAX_PER_SCOPE; index += 1) {
1019
+ vi.setSystemTime(new Date(`2026-07-07T00:00:${String(index).padStart(2, "0")}.000Z`));
1020
+ await executeWidget({ stateDir, widgetCode: `<p>${index}</p>` });
1021
+ }
1022
+
1023
+ await expect(access(resolveCanvasDocumentDir(stateDir, first.viewId))).rejects.toThrow();
1024
+ const entries = await readdir(path.join(stateDir, "canvas", "documents"));
1025
+ expect(entries).toHaveLength(WIDGET_MAX_PER_SCOPE);
1026
+ });
1027
+ });
src/canvas/widget-tool.ts ADDED
@@ -0,0 +1,640 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** Agent-facing inline chat widget tool. */
2
+ import { createHash } from "node:crypto";
3
+ import { truncateCodePoints } from "@openclaw/normalization-core/code-points";
4
+ import { asOptionalRecord } from "@openclaw/normalization-core/record-coerce";
5
+ import { Type } from "typebox";
6
+ import type { BoardWidgetPutResult } from "../../packages/gateway-protocol/src/index.js";
7
+ import { optionalStringEnum } from "../agents/schema/string-enum.js";
8
+ import { type AnyAgentTool, jsonResult, readToolStringParam } from "../agents/tools/common.js";
9
+ import {
10
+ callInProcessGatewayTool,
11
+ type InProcessGatewayCaller,
12
+ } from "../agents/tools/in-process-gateway.js";
13
+ import { normalizeBoardWidgetDeclared } from "../boards/board-capabilities.js";
14
+ import {
15
+ BOARD_REPORT_GUIDANCE,
16
+ BOARD_REPORT_WIDGET_KIND,
17
+ parseBoardReport,
18
+ } from "../boards/board-report.js";
19
+ import { formatErrorMessage } from "../infra/errors.js";
20
+ import {
21
+ assertWidgetHtmlSize,
22
+ WIDGET_CDN_ORIGINS,
23
+ WidgetHtmlInputError,
24
+ } from "../plugin-sdk/widget-html.js";
25
+ import {
26
+ listBoardWidgetContentKinds,
27
+ resolveBoardWidgetContentKind,
28
+ } from "../plugins/board-widget-content-kinds.js";
29
+ import { describeDashboardCapabilities } from "../plugins/dashboard-capabilities.js";
30
+ import type {
31
+ WidgetPresentationError,
32
+ WidgetPresentationSuccess,
33
+ WidgetPresenter,
34
+ WidgetPresenterContext,
35
+ WidgetPresenterDocument,
36
+ } from "../plugins/plugin-registration.types.js";
37
+ import { getActivePluginRegistry } from "../plugins/runtime.js";
38
+ import { getPluginRuntimeGatewayRequestScope } from "../plugins/runtime/gateway-request-scope.js";
39
+ import { createCanvasDocument } from "./documents.js";
40
+ import { findWidgetScriptSyntaxError } from "./widget-script-syntax.js";
41
+ import { buildWidgetDocument } from "./wrap.js";
42
+
43
+ const SHOW_WIDGET_REQUIRED_CLIENT_CAPS = ["inline-widgets"];
44
+ const WIDGET_CODE_MAX_CHARS = 262_144;
45
+ const PINNED_WIDGET_MAX_UTF8_BYTES = 256 * 1024;
46
+ const WIDGET_MAX_PER_SCOPE = 32;
47
+
48
+ function currentPluginRegistry() {
49
+ return getPluginRuntimeGatewayRequestScope()?.pluginRegistry ?? getActivePluginRegistry();
50
+ }
51
+
52
+ export function hasRegisteredShowWidgetKinds(): boolean {
53
+ return listBoardWidgetContentKinds(currentPluginRegistry()).length > 0;
54
+ }
55
+
56
+ function createShowWidgetToolSchema(
57
+ kinds: readonly string[],
58
+ presenters: readonly WidgetPresenter[],
59
+ capabilityGuidance: string,
60
+ pinnedOnly: boolean,
61
+ reportAvailable: boolean,
62
+ ) {
63
+ const presenterTargets = presenters.flatMap((presenter) =>
64
+ presenter.target === "current_channel" ? [] : [presenter.target],
65
+ );
66
+ const targets = ["assistant_message", ...presenterTargets] as const;
67
+ const presenterDescriptions = presenters.flatMap((presenter) =>
68
+ presenter.target === "current_channel" ? [] : [`${presenter.target}: ${presenter.description}`],
69
+ );
70
+ const widgetCode = Type.String({
71
+ description:
72
+ "Required for HTML/SVG or registered source. For HTML, send a fragment with optional style/script tags, not a full document or file path. Use fluid widths and wrap or stack narrow layouts; reserve horizontal scrolling for exact geometry.",
73
+ });
74
+ return Type.Object({
75
+ title: Type.String({ description: "Short host title; do not repeat it inside the widget" }),
76
+ widget_code: reportAvailable ? Type.Optional(widgetCode) : widgetCode,
77
+ ...(reportAvailable
78
+ ? {
79
+ report: Type.Optional(
80
+ Type.Record(Type.String(), Type.Unknown(), {
81
+ description: `Native dashboard data; requires pin=true. Omit widget_code, kind, capabilities, and presentation.target. ${BOARD_REPORT_GUIDANCE}`,
82
+ }),
83
+ ),
84
+ }
85
+ : {}),
86
+ kind: optionalStringEnum(kinds, {
87
+ description: `Widget source kind: ${kinds.join(", ")}`,
88
+ }),
89
+ name: Type.Optional(
90
+ Type.String({
91
+ pattern: "^[a-z0-9][a-z0-9._-]{0,63}$",
92
+ description:
93
+ "Stable dashboard widget name; reuse the same name with pin=true and new report data or widget_code to update",
94
+ }),
95
+ ),
96
+ pin: pinnedOnly
97
+ ? Type.Literal(true, {
98
+ description: "Required: this surface can only author pinned widgets",
99
+ })
100
+ : Type.Optional(
101
+ Type.Boolean({
102
+ description:
103
+ "Pin only for an explicit dashboard request or multiple non-code visualizations",
104
+ }),
105
+ ),
106
+ tab: Type.Optional(
107
+ Type.String({ pattern: "^[a-z0-9-]{1,40}$", description: "Dashboard tab slug" }),
108
+ ),
109
+ size: optionalStringEnum(["sm", "md", "lg", "xl", "full"] as const, {
110
+ description: "Dashboard size: sm, md, lg, xl, or full",
111
+ }),
112
+ presentation: Type.Optional(
113
+ Type.Object({
114
+ ...(pinnedOnly
115
+ ? {}
116
+ : {
117
+ target: optionalStringEnum(targets, {
118
+ description: [
119
+ "Where to show the widget. assistant_message: inline in chat",
120
+ ...presenterDescriptions,
121
+ ].join("; "),
122
+ }),
123
+ }),
124
+ frame: optionalStringEnum(["card", "full-bleed", "frameless"] as const, {
125
+ description: "Pinned dashboard frame: card, full-bleed, or frameless",
126
+ }),
127
+ }),
128
+ ),
129
+ after: Type.Optional(
130
+ Type.String({
131
+ pattern: "^[a-z0-9][a-z0-9._-]{0,63}$",
132
+ description: "Place after this dashboard widget name",
133
+ }),
134
+ ),
135
+ capabilities: Type.Optional(
136
+ Type.Object({
137
+ netOrigins: Type.Optional(
138
+ Type.Array(Type.String(), {
139
+ description: "Exact HTTPS origins the pinned widget may fetch after approval",
140
+ }),
141
+ ),
142
+ tools: Type.Optional(
143
+ Type.Array(Type.String(), {
144
+ description: `Pinned widget host tools: prompt or cron.trigger:<jobId>; grant each read/action ID below unless a scoped grant is specified. ${capabilityGuidance}`,
145
+ }),
146
+ ),
147
+ }),
148
+ ),
149
+ });
150
+ }
151
+
152
+ type ShowWidgetToolOptions = {
153
+ sessionId?: string;
154
+ agentId?: string;
155
+ agentSessionKey?: string;
156
+ stateDir?: string;
157
+ callGateway?: InProcessGatewayCaller;
158
+ inlineHostEnabled?: boolean;
159
+ inlineClientAvailable?: boolean;
160
+ /** Admitted callers without a rendering client may author only durable dashboard widgets. */
161
+ pinnedOnly?: boolean;
162
+ presenters?: readonly WidgetPresenter[];
163
+ presenterContext?: WidgetPresenterContext;
164
+ };
165
+
166
+ type WidgetPresentationAttempt =
167
+ | { ok: true; value: WidgetPresentationSuccess }
168
+ | { ok: false; error: WidgetPresentationError };
169
+
170
+ async function presentWidget(params: {
171
+ presenter?: WidgetPresenter;
172
+ document: WidgetPresenterDocument;
173
+ title: string;
174
+ context: WidgetPresenterContext;
175
+ }): Promise<WidgetPresentationAttempt> {
176
+ const presenter = params.presenter;
177
+ if (!presenter) {
178
+ return {
179
+ ok: false,
180
+ error: {
181
+ code: "no_eligible_node",
182
+ message: "No widget presenter is registered for this target.",
183
+ },
184
+ };
185
+ }
186
+ const errorCode = presenter.target === "current_channel" ? "presentation_error" : "node_error";
187
+ try {
188
+ const availability = await presenter.availability(params.context);
189
+ if (!availability.ok) {
190
+ return availability;
191
+ }
192
+ return await presenter.present({
193
+ document: params.document,
194
+ title: params.title,
195
+ context: params.context,
196
+ });
197
+ } catch (error) {
198
+ return {
199
+ ok: false,
200
+ error: { code: errorCode, message: formatErrorMessage(error) },
201
+ };
202
+ }
203
+ }
204
+
205
+ export function resolveCurrentChannelWidgetPresenter(
206
+ presenters: readonly WidgetPresenter[],
207
+ context: WidgetPresenterContext,
208
+ ): Extract<WidgetPresenter, { target: "current_channel" }> | undefined {
209
+ const matches = presenters.filter(
210
+ (presenter): presenter is Extract<WidgetPresenter, { target: "current_channel" }> => {
211
+ if (presenter.target !== "current_channel") {
212
+ return false;
213
+ }
214
+ try {
215
+ return presenter.match(context);
216
+ } catch {
217
+ return false;
218
+ }
219
+ },
220
+ );
221
+ return matches.length === 1 ? matches[0] : undefined;
222
+ }
223
+
224
+ function widgetPresentationFailureText(
225
+ error: WidgetPresentationError,
226
+ inlineAvailable: boolean,
227
+ ): string {
228
+ const message = /[.!?]$/u.test(error.message) ? error.message : `${error.message}.`;
229
+ if (!inlineAvailable) {
230
+ return message;
231
+ }
232
+ const nextStep =
233
+ error.code === "no_eligible_node"
234
+ ? "Pair a canvas-capable device or open the OpenClaw app, then retry."
235
+ : "Retry the requested presentation destination when it is available.";
236
+ return `${message} The widget is available inline here. ${nextStep}`;
237
+ }
238
+
239
+ function slugWidgetName(title: string): string {
240
+ const slug = title
241
+ .normalize("NFKD")
242
+ .replace(/[\u0300-\u036f]/gu, "")
243
+ .toLowerCase()
244
+ .replace(/[^a-z0-9]+/gu, "-")
245
+ .replace(/^-+|-+$/gu, "");
246
+ if (slug && slug.length <= 64) {
247
+ return slug;
248
+ }
249
+ const suffix = createHash("sha256").update(title).digest("hex").slice(0, 8);
250
+ const prefix = (slug || "widget").slice(0, 55).replace(/-+$/gu, "") || "widget";
251
+ return `${prefix}-${suffix}`;
252
+ }
253
+
254
+ function generatedWidgetIdentity(title: string, preferredName: string) {
255
+ const key = createHash("sha256").update(title.trim().normalize("NFC")).digest("hex");
256
+ const prefix = preferredName.slice(0, 55).replace(/-+$/gu, "") || "widget";
257
+ return {
258
+ source: "show_widget" as const,
259
+ key,
260
+ fallbackName: `${prefix}-${key.slice(0, 8)}`,
261
+ };
262
+ }
263
+
264
+ function boardWidgetTitle(title: string): string | undefined {
265
+ const normalized = title.trim();
266
+ return normalized ? truncateCodePoints(normalized, 80) : undefined;
267
+ }
268
+
269
+ function resolveRetentionScope(options: ShowWidgetToolOptions): string {
270
+ const scope = options.sessionId
271
+ ? `session:${options.sessionId}`
272
+ : `agent:${options.agentId ?? "default"}`;
273
+ return createHash("sha256").update(scope).digest("hex");
274
+ }
275
+
276
+ function assertPinnedWidgetDocumentSize(html: string): void {
277
+ if (Buffer.byteLength(html, "utf8") > PINNED_WIDGET_MAX_UTF8_BYTES) {
278
+ throw new WidgetHtmlInputError(
279
+ `pin exceeds effective dashboard budget (${PINNED_WIDGET_MAX_UTF8_BYTES} UTF-8 bytes after wrapping)`,
280
+ );
281
+ }
282
+ }
283
+
284
+ /** Creates a self-contained widget hosted by OpenClaw core. */
285
+ export function createShowWidgetTool(options: ShowWidgetToolOptions = {}): AnyAgentTool {
286
+ const gatewayCall = options.callGateway ?? callInProcessGatewayTool;
287
+ const pinnedOnly = options.pinnedOnly === true;
288
+ const inlineHostEnabled = options.inlineHostEnabled !== false;
289
+ const inlineAvailable =
290
+ !pinnedOnly && inlineHostEnabled && options.inlineClientAvailable !== false;
291
+ const registeredKinds = listBoardWidgetContentKinds(currentPluginRegistry());
292
+ const allKinds = ["html", ...registeredKinds];
293
+ const presenters = options.presenters ?? [];
294
+ const presenterContext =
295
+ options.presenterContext ??
296
+ (options.agentSessionKey ? { sessionKey: options.agentSessionKey } : {});
297
+ const currentChannelPresenter = pinnedOnly
298
+ ? undefined
299
+ : resolveCurrentChannelWidgetPresenter(presenters, presenterContext);
300
+ const kinds =
301
+ currentChannelPresenter && !inlineAvailable
302
+ ? allKinds.filter((kind) => currentChannelPresenter.capabilities.sourceKinds.includes(kind))
303
+ : allKinds;
304
+ const advertisedRegisteredKinds = kinds.filter((kind) => kind !== "html");
305
+ const reportAvailable = Boolean(options.agentSessionKey?.trim());
306
+ const reportGuidance = reportAvailable
307
+ ? " Prefer the report argument with pin=true for data reports; these render natively on the dashboard without a document frame. Reports are dashboard-only; omit widget_code, kind, capabilities, and presentation.target."
308
+ : "";
309
+ const explicitPresenters = pinnedOnly
310
+ ? []
311
+ : presenters.filter((presenter) => presenter.target !== "current_channel");
312
+ const presenterPrompt =
313
+ explicitPresenters.length > 0
314
+ ? " Use presentation.target to choose a registered device surface."
315
+ : "";
316
+ const usageGuidance = pinnedOnly
317
+ ? "This surface is pinned-only: set pin=true to create or update a durable session dashboard widget. Inline and device presentation are unavailable."
318
+ : currentChannelPresenter
319
+ ? "Show widgets through the current channel presenter; follow result.presentation for delivery."
320
+ : inlineAvailable
321
+ ? "Keep one-off visualizations inline; pin for explicit dashboard requests or multiple non-code visualizations."
322
+ : "Inline previews are unavailable this turn; set pin=true to save to the session dashboard.";
323
+ return {
324
+ label: "Show Widget",
325
+ name: "show_widget",
326
+ description: `Visual helps? Make widget. Do not wait for ask. ${usageGuidance} Update pinned HTML by name. Use for code architecture, execution traces, performance comparisons, interactive explanations, UI mockups, and dashboards. Text clearer? Skip. Load the visualize skill when available for composition and dashboard authoring. The source kind defaults to html${advertisedRegisteredKinds.length ? ` and registered kinds are ${advertisedRegisteredKinds.join(", ")}` : ""}. Send markup directly in widget_code. Scripts, stylesheets, and fonts may load from ${WIDGET_CDN_ORIGINS.join(", ")}; pin library versions. Inline widgets cannot fetch APIs. Pinned data access needs declared and granted capabilities.netOrigins or capabilities.tools; inline previews never inherit those grants. Keep filters and controls local; user-clicked openclaw.prompt.send(text) requests an agent follow-up in the Control UI. Data, action, state, and cron host APIs are dashboard-only. openclaw.host.controlUiBaseUrl is the Control UI origin plus base path after dashboard initialization, otherwise null; read it at click time. Dashboard HTML links support HTTP(S) destinations only; open them with target="_blank" and rel="noopener noreferrer". Put local workspace file links in chat Markdown, not widget HTML; file:// links cannot open the Files panel. \`title\` is host metadata. Start directly with content; do not repeat the title or recreate dashboard chrome. Use host theme variables such as --text, --muted, --card, --border, --accent, --font-body, and --font-mono. Inline script syntax errors return line and column; fix and retry. Check library loading and rendered interactions; hosting success alone is not visual proof.${reportGuidance}${presenterPrompt}`,
327
+ parameters: createShowWidgetToolSchema(
328
+ kinds,
329
+ explicitPresenters,
330
+ describeDashboardCapabilities(currentPluginRegistry()),
331
+ pinnedOnly,
332
+ reportAvailable,
333
+ ),
334
+ ...(currentChannelPresenter || pinnedOnly
335
+ ? {}
336
+ : { requiredClientCaps: SHOW_WIDGET_REQUIRED_CLIENT_CAPS }),
337
+ execute: async (_toolCallId, args) => {
338
+ const params = args as Record<string, unknown>;
339
+ const requestedKind = readToolStringParam(params, "kind");
340
+ const kind = requestedKind ?? "html";
341
+ const rawWidgetCode =
342
+ readToolStringParam(params, "widget_code", {
343
+ trim: false,
344
+ }) ?? "";
345
+ const rawReport = asOptionalRecord(params.report);
346
+ const isReport =
347
+ params.report !== undefined &&
348
+ params.report !== null &&
349
+ !(rawWidgetCode.trim() && rawReport && Object.keys(rawReport).length === 0);
350
+ const title = readToolStringParam(params, "title", { required: true });
351
+ if (!isReport) {
352
+ if (!rawWidgetCode.trim()) {
353
+ throw new WidgetHtmlInputError("widget_code required");
354
+ }
355
+ assertWidgetHtmlSize(rawWidgetCode, WIDGET_CODE_MAX_CHARS, {
356
+ inputName: "widget_code",
357
+ unit: "characters",
358
+ });
359
+ if (kind === "html") {
360
+ // Untrimmed so reported line/column match the widget_code the model sent.
361
+ const scriptError = findWidgetScriptSyntaxError(rawWidgetCode);
362
+ if (scriptError) {
363
+ throw new WidgetHtmlInputError(
364
+ `widget_code has a JavaScript syntax error in inline script ${scriptError.scriptIndex} at line ${scriptError.line}, column ${scriptError.column}: ${scriptError.message}. Offending line: ${scriptError.snippet}. Fix the script and call show_widget again.`,
365
+ );
366
+ }
367
+ }
368
+ }
369
+ const shouldPin = params.pin === true;
370
+ if (pinnedOnly && !shouldPin) {
371
+ throw new WidgetHtmlInputError("pin=true is required for this pinned-only widget surface");
372
+ }
373
+ const capabilities = normalizeBoardWidgetDeclared(
374
+ params.capabilities as { netOrigins?: string[]; tools?: string[] } | undefined,
375
+ );
376
+ if (capabilities && !shouldPin) {
377
+ throw new WidgetHtmlInputError("capabilities require pin=true");
378
+ }
379
+ const pinSessionKey = shouldPin ? options.agentSessionKey?.trim() : undefined;
380
+ if (shouldPin && !pinSessionKey) {
381
+ throw new WidgetHtmlInputError("pin requires an agent session");
382
+ }
383
+ const widgetCode = rawWidgetCode.trim();
384
+ const presentation = asOptionalRecord(params.presentation);
385
+ if (pinnedOnly && presentation?.target !== undefined) {
386
+ throw new WidgetHtmlInputError(
387
+ "presentation.target is unavailable for this pinned-only widget surface",
388
+ );
389
+ }
390
+ const requestedTarget =
391
+ readToolStringParam(presentation ?? {}, "target") ?? "assistant_message";
392
+ if (
393
+ isReport &&
394
+ (!shouldPin ||
395
+ rawWidgetCode ||
396
+ requestedKind ||
397
+ capabilities ||
398
+ readToolStringParam(presentation ?? {}, "target"))
399
+ ) {
400
+ throw new WidgetHtmlInputError(
401
+ "Reports require pin=true; omit widget_code, kind, capabilities, and presentation.target. Use HTML for an inline or executable widget",
402
+ );
403
+ }
404
+ const report = isReport ? parseBoardReport(params.report) : undefined;
405
+ const registration =
406
+ kind === "html" || isReport
407
+ ? undefined
408
+ : resolveBoardWidgetContentKind(currentPluginRegistry(), kind);
409
+ if (kind !== "html" && !isReport && !registration) {
410
+ throw new WidgetHtmlInputError(
411
+ `widget kind ${JSON.stringify(kind)} is unavailable; enable the plugin that provides it and retry`,
412
+ );
413
+ }
414
+ if (registration) {
415
+ try {
416
+ registration.definition.validateSource(widgetCode);
417
+ } catch (error) {
418
+ throw new WidgetHtmlInputError(`invalid ${kind} widget source: ${String(error)}`);
419
+ }
420
+ }
421
+ const currentPresenterSupportsKind =
422
+ currentChannelPresenter?.target === "current_channel" &&
423
+ currentChannelPresenter.capabilities.sourceKinds.includes(kind);
424
+ const wantsCurrentChannel =
425
+ requestedTarget === "assistant_message" && currentPresenterSupportsKind;
426
+ const wantsNodePanel = requestedTarget === "node_panel";
427
+ if (!inlineAvailable && !wantsCurrentChannel && !wantsNodePanel && !shouldPin) {
428
+ throw new WidgetHtmlInputError(
429
+ "inline widget hosting is disabled; set pin=true to place the widget on the session dashboard",
430
+ );
431
+ }
432
+ if (wantsCurrentChannel && currentChannelPresenter?.target === "current_channel") {
433
+ const { maxSourceBytes } = currentChannelPresenter.capabilities;
434
+ if (maxSourceBytes !== undefined) {
435
+ assertWidgetHtmlSize(rawWidgetCode, maxSourceBytes, { inputName: "widget_code" });
436
+ }
437
+ }
438
+ const composedWidget = registration
439
+ ? registration.definition.composeDocument({
440
+ source: widgetCode,
441
+ title,
442
+ resourceUrls: Object.fromEntries(
443
+ registration.definition.resources.paths.map((resourcePath) => [
444
+ resourcePath,
445
+ resourcePath,
446
+ ]),
447
+ ),
448
+ promptGranted: false,
449
+ })
450
+ : widgetCode;
451
+ const wrappedDocument = isReport
452
+ ? ""
453
+ : buildWidgetDocument(
454
+ title,
455
+ composedWidget,
456
+ registration ? { scriptOrigins: ["'self'"] } : {},
457
+ );
458
+ let pinnedText = "";
459
+ let pinnedWidgetName: string | undefined;
460
+ let capabilityState: BoardWidgetPutResult["widgets"][number]["grantState"] | undefined;
461
+ if (pinSessionKey) {
462
+ const explicitName = readToolStringParam(params, "name");
463
+ const name = explicitName ?? slugWidgetName(title);
464
+ const tab = readToolStringParam(params, "tab");
465
+ const size = readToolStringParam(params, "size");
466
+ const frame = readToolStringParam(presentation ?? {}, "frame");
467
+ const after = readToolStringParam(params, "after");
468
+ const pinnedTitle = boardWidgetTitle(title);
469
+ if (!registration && !isReport) {
470
+ assertPinnedWidgetDocumentSize(
471
+ buildWidgetDocument(pinnedTitle ?? name, widgetCode, {
472
+ connectOrigins: capabilities?.netOrigins,
473
+ }),
474
+ );
475
+ }
476
+ const snapshot = await gatewayCall<BoardWidgetPutResult>("board.widget.put", {
477
+ sessionKey: pinSessionKey,
478
+ agentId: options.agentId,
479
+ name,
480
+ ...(pinnedTitle ? { title: pinnedTitle } : {}),
481
+ // The Gateway owns the board document shell so agent-authored bytes
482
+ // can never run before its user-activation and bridge bootstrap.
483
+ content: report
484
+ ? { kind: "plugin", pluginKind: BOARD_REPORT_WIDGET_KIND, props: report }
485
+ : registration
486
+ ? { kind: "registered", contentKind: kind, source: widgetCode }
487
+ : { kind: "html", html: widgetCode },
488
+ ...(frame ? { presentation: frame } : {}),
489
+ ...(capabilities ? { declared: capabilities } : {}),
490
+ ...(!explicitName ? { generatedIdentity: generatedWidgetIdentity(title, name) } : {}),
491
+ ...(tab || size || after
492
+ ? {
493
+ placement: {
494
+ ...(tab ? { tabId: tab } : {}),
495
+ ...(size ? { size } : {}),
496
+ ...(after ? { after } : {}),
497
+ },
498
+ }
499
+ : {}),
500
+ });
501
+ pinnedWidgetName = snapshot.resolvedWidgetName;
502
+ const widget = snapshot.widgets.find(
503
+ (candidate) => candidate.name === snapshot.resolvedWidgetName,
504
+ );
505
+ if (!widget) {
506
+ throw new WidgetHtmlInputError(
507
+ "Dashboard did not return the pinned widget; read the board and retry.",
508
+ );
509
+ }
510
+ capabilityState = widget.grantState;
511
+ pinnedText = `pinned to dashboard tab ${widget.tabId} as ${
512
+ snapshot.resolvedWidgetName
513
+ }${size ? ` (${size})` : ""}`;
514
+ if (capabilityState === "pending") {
515
+ pinnedText +=
516
+ "; capabilities pending: ask the operator to review and approve the dashboard permission card";
517
+ }
518
+ if (capabilityState === "rejected") {
519
+ pinnedText +=
520
+ "; capabilities rejected: review the requested access and session permission policy with the operator before retrying";
521
+ }
522
+ if (capabilityState === "granted") {
523
+ pinnedText += "; capabilities granted";
524
+ }
525
+ }
526
+ const hasPresentationRoute =
527
+ !isReport && (inlineAvailable || wantsCurrentChannel || wantsNodePanel);
528
+ if (!hasPresentationRoute) {
529
+ return jsonResult({
530
+ status:
531
+ capabilityState === "pending" || capabilityState === "rejected"
532
+ ? capabilityState
533
+ : "pinned",
534
+ boardWidgetName: pinnedWidgetName,
535
+ capabilityState,
536
+ text: `Widget ${pinnedText}. Open this dashboard tab in Control UI to view it.`,
537
+ });
538
+ }
539
+ let document: Awaited<ReturnType<typeof createCanvasDocument>> | undefined;
540
+ const hostDocument = async () =>
541
+ (document ??= await createCanvasDocument(
542
+ {
543
+ kind: "html_bundle",
544
+ title,
545
+ entrypoint: { type: "html", value: wrappedDocument },
546
+ surface: "assistant_message",
547
+ retentionScope: resolveRetentionScope(options),
548
+ // Direct navigation must not run widget script as the Control UI origin.
549
+ cspSandbox: "scripts",
550
+ },
551
+ {
552
+ stateDir: options.stateDir,
553
+ maxDocumentsPerScope: WIDGET_MAX_PER_SCOPE,
554
+ },
555
+ ));
556
+
557
+ let presentationAttempt: WidgetPresentationAttempt | undefined;
558
+ if (wantsCurrentChannel && currentChannelPresenter) {
559
+ presentationAttempt = await presentWidget({
560
+ presenter: currentChannelPresenter,
561
+ document: { kind: "html", html: wrappedDocument },
562
+ title,
563
+ context: presenterContext,
564
+ });
565
+ } else if (wantsNodePanel) {
566
+ const hosted = await hostDocument();
567
+ presentationAttempt = await presentWidget({
568
+ presenter: explicitPresenters.find((presenter) => presenter.target === "node_panel"),
569
+ document: { kind: "html", html: wrappedDocument, hostedUrl: hosted.entryUrl },
570
+ title,
571
+ context: presenterContext,
572
+ });
573
+ }
574
+
575
+ if (presentationAttempt?.ok && presentationAttempt.value.kind === "message") {
576
+ const receipt = presentationAttempt.value.receipt;
577
+ const messageId = receipt.primaryPlatformMessageId ?? receipt.platformMessageIds[0];
578
+ return jsonResult({
579
+ kind: "widget",
580
+ presentation: {
581
+ target: "current_channel",
582
+ title,
583
+ receipt,
584
+ },
585
+ ...(pinnedWidgetName ? { boardWidgetName: pinnedWidgetName } : {}),
586
+ ...(capabilityState ? { capabilityState } : {}),
587
+ text: `Widget presented in the current channel${messageId ? ` as message ${messageId}` : ""}${pinnedText ? `; ${pinnedText}` : ""}`,
588
+ });
589
+ }
590
+
591
+ if (presentationAttempt && !presentationAttempt.ok && !inlineAvailable) {
592
+ const failureText = widgetPresentationFailureText(presentationAttempt.error, false);
593
+ if (pinnedWidgetName) {
594
+ return jsonResult({
595
+ status: "partial",
596
+ boardWidgetName: pinnedWidgetName,
597
+ capabilityState,
598
+ presentation: {
599
+ target: requestedTarget === "node_panel" ? "node_panel" : "current_channel",
600
+ status: "failed",
601
+ error: presentationAttempt.error,
602
+ },
603
+ text: `Widget ${pinnedText}, but presentation failed: ${failureText}`,
604
+ });
605
+ }
606
+ throw new WidgetHtmlInputError(`Widget presentation failed: ${failureText}`);
607
+ }
608
+
609
+ const hosted = await hostDocument();
610
+ const presentedNode =
611
+ presentationAttempt?.ok && presentationAttempt.value.kind === "node"
612
+ ? presentationAttempt.value
613
+ : undefined;
614
+ const target = presentedNode ? "node_panel" : "assistant_message";
615
+ const presentationText = presentedNode
616
+ ? `; presented on ${presentedNode.nodeName ?? presentedNode.nodeId} (${presentedNode.nodeId})`
617
+ : presentationAttempt && !presentationAttempt.ok
618
+ ? `; ${widgetPresentationFailureText(presentationAttempt.error, true)}`
619
+ : "";
620
+ return jsonResult({
621
+ kind: "canvas",
622
+ ...(capabilityState ? { capabilityState } : {}),
623
+ presentation: {
624
+ target,
625
+ title,
626
+ sandbox: "scripts",
627
+ ...(presentedNode
628
+ ? { node: { id: presentedNode.nodeId, name: presentedNode.nodeName } }
629
+ : {}),
630
+ },
631
+ view: {
632
+ id: hosted.id,
633
+ url: hosted.entryUrl,
634
+ ...(pinnedWidgetName ? { boardWidgetName: pinnedWidgetName } : {}),
635
+ },
636
+ text: `Widget hosted at ${hosted.entryUrl}${pinnedText ? `; ${pinnedText}` : ""}${presentationText}`,
637
+ });
638
+ },
639
+ };
640
+ }
src/canvas/wrap.test.ts ADDED
@@ -0,0 +1,123 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Widget document wrapper: byte stability and the host-bridge contract it emits.
2
+ import { createHash } from "node:crypto";
3
+ import { runInNewContext } from "node:vm";
4
+ import { describe, expect, it, vi } from "vitest";
5
+ import { buildWidgetDocument } from "./wrap.js";
6
+
7
+ describe("buildWidgetDocument", () => {
8
+ it("reports bounded runtime errors before widget code, deduplicating and limiting reports", () => {
9
+ const html = buildWidgetDocument("Failure", '<script>throw new Error("Broken")</script>');
10
+ const bridge = [...html.matchAll(/<script>([\s\S]*?)<\/script>/g)].find((match) =>
11
+ match[1]?.includes("openclaw:widget-runtime-error"),
12
+ );
13
+ if (!bridge?.[1]) {
14
+ throw new Error("Runtime error bridge missing");
15
+ }
16
+ expect(bridge.index).toBeLessThan(html.indexOf('throw new Error("Broken")'));
17
+ const handlers = new Map<string, (event: unknown) => void>();
18
+ const postMessage = vi.fn();
19
+ runInNewContext(bridge[1], {
20
+ window: {
21
+ parent: { postMessage },
22
+ addEventListener: (type: string, handler: (event: unknown) => void, capture: boolean) => {
23
+ expect(capture).toBe(true);
24
+ handlers.set(type, handler);
25
+ },
26
+ },
27
+ });
28
+ const error = handlers.get("error")!;
29
+ const rejection = handlers.get("unhandledrejection")!;
30
+ error({ type: "error" }); // Resource-load Events have no script message or error.
31
+ expect(() =>
32
+ rejection({
33
+ reason: {
34
+ get message() {
35
+ throw new Error("getter");
36
+ },
37
+ },
38
+ }),
39
+ ).not.toThrow();
40
+ error({
41
+ error: { message: "x".repeat(600) },
42
+ message: "fallback",
43
+ filename: `https://example.test/path/${"s".repeat(220)}.js?private=1`,
44
+ lineno: 12,
45
+ colno: 7,
46
+ });
47
+ error({ message: "x".repeat(600) });
48
+ rejection({ reason: new Error("Rejected"), lineno: Infinity, colno: 1.5 });
49
+ rejection({ reason: "Plain rejection" });
50
+ error({ message: "Over budget" });
51
+ expect(postMessage.mock.calls).toEqual([
52
+ [
53
+ {
54
+ type: "openclaw:widget-runtime-error",
55
+ message: "x".repeat(500),
56
+ source: "s".repeat(200),
57
+ line: 12,
58
+ column: 7,
59
+ },
60
+ "*",
61
+ ],
62
+ [{ type: "openclaw:widget-runtime-error", message: "Rejected" }, "*"],
63
+ [{ type: "openclaw:widget-runtime-error", message: "Plain rejection" }, "*"],
64
+ ]);
65
+ });
66
+ it("keeps the wrapped document bytes stable", () => {
67
+ const html = buildWidgetDocument(
68
+ "Status <live>",
69
+ '<SvG viewBox="0 0 10 10"><circle r="4" /></SvG>',
70
+ );
71
+
72
+ expect(Buffer.byteLength(html)).toBe(19910);
73
+ expect(createHash("sha256").update(html).digest("hex")).toBe(
74
+ "ab524d876df4ee6ff0904ca4d44d313a5a4d1149dbcc02704681c59e1748873c",
75
+ );
76
+ expect(html).toContain("openclaw:widget-host-init-ack");
77
+ expect(html).toContain('request("host.open",{url})');
78
+ // Widget links follow the Control UI activation contract: primary click and
79
+ // middle-button auxclick, on bubble so a widget's preventDefault still wins.
80
+ expect(html).toContain('listen("click",activate);listen("auxclick",activate);');
81
+ expect(html).toContain('event.type==="auxclick"&&event.button===1');
82
+ expect(html).toContain("event.defaultPrevented||event.shiftKey||event.altKey");
83
+ expect(html).not.toContain("{capture:true}");
84
+ expect(html).toContain("controlUiBaseUrl");
85
+ expect(html).toContain('define(host,"controlUiBaseUrl"');
86
+ expect(html).toContain("else push.call(waiting,{send,reject})");
87
+ expect(html).toContain("else push.call(promptWaiting,{send,inline,reject})");
88
+ expect(html).toContain("openclaw:widget-prompt-host-ready");
89
+ expect(html).toContain("widget host capabilities unavailable");
90
+ expect(html).toContain("widget prompt host unavailable");
91
+ expect(html).toContain("openclaw:widget-chat-host");
92
+ expect(html).toContain("openclaw:widget-board-host");
93
+ expect(html).toContain("openclaw:widget-scroll");
94
+ expect(html).toContain("event.isTrusted");
95
+ expect(html).not.toContain("widget is not hosted on a board");
96
+ const bridgeKeys = JSON.parse(html.match(/const keys=(\[[^\]]+\])/)?.[1] ?? "[]") as string[];
97
+ expect(bridgeKeys).toEqual([
98
+ "surface",
99
+ "card",
100
+ "elevated",
101
+ "text",
102
+ "text-strong",
103
+ "muted",
104
+ "border",
105
+ "border-strong",
106
+ "accent",
107
+ "accent-fill",
108
+ "accent-fg",
109
+ "ok",
110
+ "warn",
111
+ "danger",
112
+ "info",
113
+ "radius",
114
+ "radius-full",
115
+ "scrollbar-size",
116
+ "scrollbar-thumb-inset",
117
+ "scrollbar-thumb",
118
+ "scrollbar-thumb-hover",
119
+ "font-body",
120
+ "font-mono",
121
+ ]);
122
+ });
123
+ });
src/canvas/wrap.ts ADDED
@@ -0,0 +1,296 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { WIDGET_CDN_ORIGINS } from "../plugin-sdk/widget-html.js";
2
+ import { escapeHtml } from "../shared/html-escape.js";
3
+ import { WIDGET_THEME_MESSAGE_TYPE, WIDGET_THEME_TOKENS } from "../shared/widget-theme.js";
4
+
5
+ // Baked palettes mirror the host claw theme (ui/src/styles/base.css) so
6
+ // fallback renders match Control UI renders, where the theme bridge pushes the
7
+ // same host values. Contrast tradeoffs in these pairings are owned by the host
8
+ // theme; do not diverge here.
9
+ const WIDGET_BASE_STYLES = `:root{color-scheme:light dark;
10
+ --surface:#faf9f7;--card:#ffffff;--elevated:#ffffff;
11
+ --text:#403c35;--text-strong:#211e1a;--muted:#6e6960;
12
+ --border:#e8e4dc;--border-strong:#d6d0c5;
13
+ --accent:#bd4531;--accent-fill:#bd4531;--accent-fg:#ffffff;
14
+ --ok:#15803d;--warn:#b45309;--danger:#dc2626;--info:#2563eb;
15
+ --radius:10px;
16
+ --radius-full:9999px;--scrollbar-size:12px;--scrollbar-thumb-inset:3px;
17
+ --scrollbar-thumb:color-mix(in srgb,var(--muted) 32%,transparent);
18
+ --scrollbar-thumb-hover:color-mix(in srgb,var(--muted) 64%,transparent);
19
+ --font-body:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;
20
+ --font-mono:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
21
+ --accent-subtle:color-mix(in srgb,var(--accent) 10%,transparent);
22
+ --ok-subtle:color-mix(in srgb,var(--ok) 10%,transparent);
23
+ --warn-subtle:color-mix(in srgb,var(--warn) 12%,transparent);
24
+ --danger-subtle:color-mix(in srgb,var(--danger) 10%,transparent);
25
+ --info-subtle:color-mix(in srgb,var(--info) 10%,transparent)}
26
+ @media (prefers-color-scheme:dark){:root{
27
+ --surface:#0e1015;--card:#161920;--elevated:#191c24;
28
+ --text:#d4d4d8;--text-strong:#f4f4f5;--muted:#8b8b94;
29
+ --border:#1e2028;--border-strong:#2e3040;
30
+ --accent:#ff5c5c;--accent-fill:#d13c3c;--accent-fg:#ffffff;
31
+ --ok:#22c55e;--warn:#f59e0b;--danger:#ef4444;--info:#3b82f6}}
32
+ *{box-sizing:border-box}@supports not selector(::-webkit-scrollbar-thumb){*{scrollbar-color:var(--scrollbar-thumb) transparent;scrollbar-width:thin}}html,body{margin:0}::-webkit-scrollbar{width:var(--scrollbar-size);height:var(--scrollbar-size);background:var(--surface)}::-webkit-scrollbar-track,::-webkit-scrollbar-corner{background:transparent}::-webkit-scrollbar-button{display:none}::-webkit-scrollbar-thumb{background:var(--scrollbar-thumb);background-clip:content-box;border:var(--scrollbar-thumb-inset) solid transparent;border-radius:var(--radius-full)}::-webkit-scrollbar-thumb:hover{background:var(--scrollbar-thumb-hover);background-clip:content-box}.openclaw-chat-host,.openclaw-chat-host body{scrollbar-width:none}.openclaw-chat-host::-webkit-scrollbar,.openclaw-chat-host body::-webkit-scrollbar{display:none}
33
+ body{font:14px/1.5 var(--font-body);color:var(--text)}
34
+ h1,h2,h3{margin:0 0 8px;color:var(--text-strong);font-weight:600}
35
+ h1{font-size:18px}h2{font-size:16px}h3{font-size:14px}
36
+ p{margin:0 0 8px}
37
+ a{color:var(--accent)}
38
+ button{font:13px var(--font-body);color:var(--text);background:var(--card);border:1px solid var(--border-strong);border-radius:var(--radius);padding:6px 14px;cursor:pointer}
39
+ button:hover{border-color:var(--muted)}
40
+ button.primary{background:var(--accent-fill);color:var(--accent-fg);border-color:transparent}
41
+ input,select,textarea{font:13px var(--font-body);color:var(--text);background:var(--elevated);border:1px solid var(--border-strong);border-radius:var(--radius);padding:6px 10px}
42
+ input:focus,select:focus,textarea:focus,button:focus-visible{outline:2px solid var(--accent);outline-offset:1px}
43
+ table{border-collapse:collapse;width:100%;font-size:13px}
44
+ th{text-align:left;font-weight:500;color:var(--muted);font-size:12px;padding:4px 8px}
45
+ td{padding:6px 8px;border-top:1px solid var(--border)}
46
+ code,pre{font-family:var(--font-mono);font-size:12px;background:var(--card);border-radius:4px}
47
+ code{padding:1px 5px}pre{padding:10px;overflow-x:auto}
48
+ .card{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);padding:14px}
49
+ .badge{display:inline-block;font-size:12px;padding:2px 10px;border-radius:999px;background:var(--accent-subtle);color:var(--accent)}
50
+ .badge.ok{background:var(--ok-subtle);color:var(--ok)}
51
+ .badge.warn{background:var(--warn-subtle);color:var(--warn)}
52
+ .badge.danger{background:var(--danger-subtle);color:var(--danger)}
53
+ .badge.info{background:var(--info-subtle);color:var(--info)}
54
+ .metric{font-size:24px;font-weight:600;color:var(--text-strong)}
55
+ .muted{color:var(--muted)}
56
+ .row{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
57
+ .svg-widget{display:grid;place-items:center}.svg-widget>svg{max-width:100%}`;
58
+
59
+ type WidgetDocumentOptions = {
60
+ connectOrigins?: readonly string[];
61
+ scriptOrigins?: readonly string[];
62
+ };
63
+
64
+ /** Wraps agent-authored widget markup in the stable isolated Canvas document shell. */
65
+ export function buildWidgetDocument(
66
+ title: string,
67
+ widgetCode: string,
68
+ options: WidgetDocumentOptions = {},
69
+ ): string {
70
+ const isSvg = /^<svg/i.test(widgetCode);
71
+ const bodyClass = isSvg ? ' class="svg-widget"' : "";
72
+ // CSP admits public CDN assets but keeps data connections separate; preview metadata
73
+ // prevents the iframe from inheriting same-origin access to the parent application.
74
+ // The embedding bridge lets a host fit the iframe to its content. A board
75
+ // host also receives only the vertical scroll remainder that the widget
76
+ // document cannot consume itself; without that handoff, an auto-height frame
77
+ // traps touch and wheel gestures before they can reach the board scroller.
78
+ const sizeReporter =
79
+ "<script>(()=>{if(!window.parent||window.parent===window)return;" +
80
+ "const parent=window.parent;const post=parent.postMessage.bind(parent);" +
81
+ "const listen=window.addEventListener.bind(window);" +
82
+ "const stop=Event.prototype.stopImmediatePropagation;let boardNonce='';" +
83
+ 'listen("message",event=>{const data=event.data;if(event.source!==parent||' +
84
+ 'data?.type!=="openclaw:widget-board-host"||typeof data.nonce!=="string"||!data.nonce)return;' +
85
+ "boardNonce=data.nonce;stop.call(event);},true);" +
86
+ // documentElement.scrollHeight reports the viewport for short content, so
87
+ // measure the body box, which tracks the actual widget height.
88
+ "let last=0;const report=()=>{const b=document.body;if(!b)return;" +
89
+ "const h=Math.ceil(Math.max(b.scrollHeight,b.offsetHeight,b.getBoundingClientRect().height));" +
90
+ 'if(h&&h!==last){last=h;post({type:"openclaw:widget-size",height:h},"*");}};' +
91
+ "const remainder=(target,delta)=>{let value=delta;const root=document.scrollingElement;" +
92
+ "let rootSeen=false;const consume=node=>{if(!value)return;const max=node.scrollHeight-node.clientHeight;" +
93
+ "if(max<=1)return;const available=value<0?node.scrollTop:max-node.scrollTop;" +
94
+ "value=Math.sign(value)*Math.max(0,Math.abs(value)-Math.max(0,available));};" +
95
+ "let node=target instanceof Element?target:null;while(node){const style=getComputedStyle(node);" +
96
+ 'const overflow=style.overflowY;if((overflow==="auto"||overflow==="scroll")){consume(node);' +
97
+ "if(node===root)rootSeen=true;}node=node.parentElement;}if(root&&!rootSeen)consume(root);return value;};" +
98
+ 'listen("wheel",event=>{if(!event.isTrusted||!boardNonce||event.ctrlKey)return;' +
99
+ "const scale=event.deltaMode===1?16:event.deltaMode===2?window.innerHeight:1;" +
100
+ "const delta=event.deltaY*scale;if(!delta||Math.abs(delta)<=Math.abs(event.deltaX*scale))return;" +
101
+ "const deltaY=remainder(event.target,delta);if(!deltaY)return;" +
102
+ 'if(deltaY===delta)event.preventDefault();post({type:"openclaw:widget-scroll",deltaY,nonce:boardNonce},"*");},{passive:false});' +
103
+ "let touchId=-1;let lastX=0;let lastY=0;const findTouch=touches=>{" +
104
+ "for(let index=0;index<touches.length;index++){const touch=touches.item(index);" +
105
+ "if(touch?.identifier===touchId)return touch;}return null;};" +
106
+ 'listen("touchstart",event=>{if(!event.isTrusted||!boardNonce||event.touches.length!==1)return;' +
107
+ "const touch=event.touches.item(0);if(!touch)return;touchId=touch.identifier;" +
108
+ "lastX=touch.clientX;lastY=touch.clientY;},{passive:true});" +
109
+ 'listen("touchmove",event=>{if(!event.isTrusted||!boardNonce||touchId<0)return;const touch=findTouch(event.touches);' +
110
+ "if(!touch)return;const deltaX=lastX-touch.clientX;const deltaY=lastY-touch.clientY;" +
111
+ "lastX=touch.clientX;lastY=touch.clientY;if(!deltaY||Math.abs(deltaY)<=Math.abs(deltaX))return;" +
112
+ "const remaining=remainder(event.target,deltaY);if(!remaining)return;" +
113
+ 'if(remaining===deltaY)event.preventDefault();post({type:"openclaw:widget-scroll",deltaY:remaining,nonce:boardNonce},"*");},{passive:false});' +
114
+ "const endTouch=event=>{if(touchId>=0&&!findTouch(event.touches))touchId=-1;};" +
115
+ 'listen("touchend",endTouch,{passive:true});listen("touchcancel",endTouch,{passive:true});' +
116
+ "listen('load',report);new ResizeObserver(report).observe(document.body);" +
117
+ "setTimeout(report,50);setTimeout(report,500);})();</script>";
118
+ // This bridge precedes widget code and snapshots every authority-bearing
119
+ // primitive. Inline chat keeps its private prompt port; board hosting adopts
120
+ // the view ticket and routes every host API over one request channel.
121
+ // The activation listeners are that channel's navigation half: the frame
122
+ // sandbox grants no popups, so only a trusted new-tab activation on a
123
+ // ticketed board widget's http(s) anchor reaches the host. Widening the
124
+ // trusted/ticketed/http(s) gates hands ungranted widgets an outbound channel
125
+ // that the `connect-src` CSP in board-sandbox.ts otherwise denies. The
126
+ // activation predicate mirrors ui/src/app/native-link-routing.ts so widget
127
+ // links honor the same primary-click and middle-button contract; listening on
128
+ // bubble rather than capture keeps a widget's own preventDefault effective.
129
+ const widgetBridge =
130
+ '<script>(()=>{if(!window.parent||window.parent===window||Object.prototype.hasOwnProperty.call(window,"openclaw"))return;' +
131
+ "const parent=window.parent;const post=parent.postMessage.bind(parent);" +
132
+ "const P=Promise;const then=P.prototype.then;const ErrorCtor=Error;" +
133
+ "const stringify=String;const freeze=Object.freeze;const define=Object.defineProperty;" +
134
+ "const push=Array.prototype.push;const shift=Array.prototype.shift;" +
135
+ "const listen=window.addEventListener.bind(window);const path=Event.prototype.composedPath;" +
136
+ "const prevent=Event.prototype.preventDefault;const test=RegExp.prototype.test;" +
137
+ "const later=setTimeout.bind(window);const cancel=clearTimeout.bind(window);" +
138
+ "const c=new MessageChannel();" +
139
+ "const promptPost=c.port1.postMessage.bind(c.port1);" +
140
+ "const b=new MessageChannel();const bridgePost=b.port1.postMessage.bind(b.port1);" +
141
+ "const promptWaiting=[];let inlinePromptReady=false;" +
142
+ 'c.port1.addEventListener("message",event=>{if(event.data?.type!=="openclaw:widget-prompt-host-ready"||inlinePromptReady)return;' +
143
+ "inlinePromptReady=true;while(promptWaiting.length){const entry=shift.call(promptWaiting);if(entry)entry.inline();}});c.port1.start();" +
144
+ "let act=null;" +
145
+ "try{const ua=navigator.userActivation;" +
146
+ 'const d=ua&&Object.getOwnPropertyDescriptor(Object.getPrototypeOf(ua),"isActive");' +
147
+ "if(d&&d.get)act=d.get.bind(ua);}catch{}" +
148
+ 'post({type:"openclaw:widget-prompt-offer"},"*",[c.port2]);' +
149
+ 'post({type:"openclaw:widget-bridge-port-offer"},"*",[b.port2]);' +
150
+ "let ticket=null;let controlUiBaseUrl=null;let sequence=0;let hostInitExpired=false;const pending=new Map();const waiting=[];" +
151
+ 'const initTimer=later(()=>{hostInitExpired=true;while(waiting.length){const entry=shift.call(waiting);if(entry)entry.reject(new ErrorCtor("widget host capabilities unavailable"));}' +
152
+ 'while(promptWaiting.length){const entry=shift.call(promptWaiting);if(entry)entry.reject(new ErrorCtor("widget prompt host unavailable"));}},5000);' +
153
+ 'b.port1.addEventListener("message",event=>{const data=event.data;' +
154
+ 'if(data?.type==="openclaw:widget-host-init"&&typeof data.ticket==="string"){ticket=data.ticket;' +
155
+ 'const base=data.controlUiBaseUrl;controlUiBaseUrl=typeof base==="string"&&/^https?:\\/\\//.test(base)?base:null;' +
156
+ 'bridgePost({type:"openclaw:widget-host-init-ack",ticket});cancel(initTimer);' +
157
+ "while(waiting.length){const entry=shift.call(waiting);if(entry)entry.send();}" +
158
+ "while(promptWaiting.length){const entry=shift.call(promptWaiting);if(entry)entry.send();}return;}" +
159
+ 'if(data?.type!=="openclaw:widget-bridge-response"||typeof data.id!=="string")return;' +
160
+ "const entry=pending.get(data.id);if(!entry)return;pending.delete(data.id);" +
161
+ 'if(data.ok===true)entry.resolve(data.result);else entry.reject(new ErrorCtor(typeof data.error==="string"?data.error:"widget host request failed"));});b.port1.start();' +
162
+ 'const request=(method,params)=>new P((resolve,reject)=>{const send=()=>{const id="widget-"+(++sequence);' +
163
+ "pending.set(id,{resolve,reject});" +
164
+ 'try{bridgePost({type:"openclaw:widget-bridge-request",id,method,params,ticket});}' +
165
+ "catch(error){pending.delete(id);reject(error);}};" +
166
+ 'if(ticket)send();else if(hostInitExpired)reject(new ErrorCtor("widget host capabilities unavailable"));' +
167
+ "else push.call(waiting,{send,reject});});" +
168
+ "const activate=event=>{if(event.isTrusted!==true||!ticket||event.defaultPrevented||event.shiftKey||event.altKey)return;" +
169
+ 'const auxiliary=event.type==="auxclick"&&event.button===1;' +
170
+ 'if(!auxiliary&&!(event.type==="click"&&event.button===0))return;' +
171
+ 'for(let index=0,entries=path.call(event);index<entries.length;index++){const anchor=entries[index];if(anchor?.tagName!=="A")continue;' +
172
+ 'const url=anchor.href;if(!url)continue;if(!auxiliary&&anchor.target!=="_blank")return;' +
173
+ "if(!test.call(/^https?:\\/\\//i,url))return;" +
174
+ 'prevent.call(event);then.call(request("host.open",{url}),()=>{},()=>{});return;}};' +
175
+ 'listen("click",activate);listen("auxclick",activate);' +
176
+ "const sendPrompt=text=>{if(!act||act()!==true)return P.resolve(false);const value=stringify(text);" +
177
+ 'if(ticket)return request("prompt.send",{text:value});return new P((resolve,reject)=>{' +
178
+ 'const send=()=>{const result=request("prompt.send",{text:value});then.call(result,resolve,reject);};' +
179
+ 'const inline=()=>{promptPost({type:"openclaw:widget-prompt",prompt:value});resolve(true);};' +
180
+ 'if(inlinePromptReady)inline();else if(hostInitExpired)reject(new ErrorCtor("widget prompt host unavailable"));' +
181
+ "else push.call(promptWaiting,{send,inline,reject});});};" +
182
+ 'const host={};define(host,"controlUiBaseUrl",{enumerable:true,get:()=>controlUiBaseUrl});freeze(host);' +
183
+ "const api=freeze({host," +
184
+ 'prompt:freeze({send:sendPrompt}),state:freeze({emit:payload=>request("state.emit",{payload})}),' +
185
+ 'data:freeze({read:(bindingId,params)=>request("data.read",{bindingId:stringify(bindingId),params})}),' +
186
+ 'action:freeze({run:(action,params)=>request("action.run",{action:stringify(action),params:params===undefined?{}:params})}),' +
187
+ 'cron:freeze({trigger:jobId=>request("cron.trigger",{jobId:stringify(jobId)})})});' +
188
+ 'define(window,"openclaw",{value:api,writable:false,configurable:false});' +
189
+ "window.sendPrompt=text=>{void sendPrompt(text);};" +
190
+ 'define(window,"sendPrompt",{value:window.sendPrompt,writable:false,configurable:false});' +
191
+ 'post({type:"openclaw:widget-bridge-ready"},"*");})();</script>';
192
+ const errorBridge =
193
+ "<script>(()=>{if(!window.parent||window.parent===window)return;" +
194
+ "const post=window.parent.postMessage.bind(window.parent);const listen=window.addEventListener.bind(window);" +
195
+ "const stringify=String;const slice=Function.prototype.call.bind(String.prototype.slice);" +
196
+ "const replace=Function.prototype.call.bind(String.prototype.replace);const integer=Number.isInteger;" +
197
+ "const seen=new Set();const has=seen.has.bind(seen);const add=seen.add.bind(seen);let count=0;" +
198
+ "const report=(event,rejection)=>{try{if(count>=3)return;" +
199
+ 'if(!rejection&&typeof event.message!=="string"&&!event.error)return;' +
200
+ "const reason=rejection?event.reason:undefined;" +
201
+ "const message=slice(stringify(rejection?(reason?.message??reason):(event.error?.message??event.message)),0,500);" +
202
+ "if(has(message))return;" +
203
+ 'const data={type:"openclaw:widget-runtime-error",message};' +
204
+ 'if(typeof event.filename==="string"){const source=slice(replace(replace(event.filename,/[?#].*$/,""),/^.*[\\\\/]/,""),0,200);if(source)data.source=source;}' +
205
+ "if(integer(event.lineno))data.line=event.lineno;if(integer(event.colno))data.column=event.colno;" +
206
+ 'add(message);count++;post(data,"*");}catch{}};' +
207
+ 'listen("error",event=>report(event,false),true);listen("unhandledrejection",event=>report(event,true),true);})();</script>';
208
+ /*
209
+ * The host may push a new theme after every theme change. Each message is a
210
+ * full snapshot: omitted or invalid tokens are removed so a theme switch
211
+ * falls back to the baked palette instead of keeping stale inline values.
212
+ * Only the fixed widget token allowlist crosses the frame boundary, and the
213
+ * native style setters are captured before widget code can patch them.
214
+ */
215
+ const themeBridge =
216
+ "<script>(()=>{if(!window.parent||window.parent===window)return;" +
217
+ "const root=document.documentElement;const set=root.style.setProperty.bind(root.style);" +
218
+ "const rm=root.style.removeProperty.bind(root.style);" +
219
+ `const keys=${JSON.stringify(WIDGET_THEME_TOKENS)};` +
220
+ 'addEventListener("message",event=>{if(event.source!==window.parent)return;' +
221
+ `const data=event.data;if(!data||data.type!==${JSON.stringify(WIDGET_THEME_MESSAGE_TYPE)}||` +
222
+ 'typeof data.tokens!=="object"||data.tokens===null)return;' +
223
+ "for(const key of keys){const raw=data.tokens[key];" +
224
+ 'const value=typeof raw==="string"?raw.trim():"";' +
225
+ 'if(value&&value.length<=256)set("--"+key,value);else rm("--"+key);}' +
226
+ 'if(data.mode==="light"||data.mode==="dark")set("color-scheme",data.mode);});})();</script>';
227
+ const chatHostBridge =
228
+ "<script>(()=>{if(!window.parent||window.parent===window)return;" +
229
+ 'addEventListener("message",event=>{if(event.source===window.parent&&event.data?.type==="openclaw:widget-chat-host")' +
230
+ 'document.documentElement.classList.add("openclaw-chat-host");});})();</script>';
231
+ /*
232
+ * Snapshot requests come from the embedding parent and replies return only
233
+ * there. Capture the response channel and rendering primitives before widget
234
+ * code can patch them; the widget still owns the document being rendered.
235
+ */
236
+ const snapshotBridge =
237
+ "<script>(()=>{if(!window.parent||window.parent===window)return;" +
238
+ "const parent=window.parent;const post=(message,origin)=>parent.postMessage(message,origin);" +
239
+ "const listen=window.addEventListener.bind(window);" +
240
+ "const clone=Node.prototype.cloneNode;const query=Element.prototype.querySelectorAll;" +
241
+ "const queryDocument=document.querySelectorAll.bind(document);" +
242
+ "const item=NodeList.prototype.item;const remove=Node.prototype.removeChild;" +
243
+ "const replace=Node.prototype.replaceChild;" +
244
+ 'const getParent=Object.getOwnPropertyDescriptor(Node.prototype,"parentNode")?.get;' +
245
+ "const names=Element.prototype.getAttributeNames;const getAttr=Element.prototype.getAttribute;" +
246
+ "const setAttr=Element.prototype.setAttribute;" +
247
+ "const serializer=new XMLSerializer();const serialize=serializer.serializeToString.bind(serializer);" +
248
+ "const create=document.createElement.bind(document);const styles=getComputedStyle.bind(window);" +
249
+ "const getProperty=CSSStyleDeclaration.prototype.getPropertyValue;" +
250
+ "const getContext=HTMLCanvasElement.prototype.getContext;" +
251
+ "const toDataURL=HTMLCanvasElement.prototype.toDataURL;" +
252
+ 'const setWidth=Object.getOwnPropertyDescriptor(HTMLCanvasElement.prototype,"width")?.set;' +
253
+ 'const setHeight=Object.getOwnPropertyDescriptor(HTMLCanvasElement.prototype,"height")?.set;' +
254
+ 'const setFill=Object.getOwnPropertyDescriptor(CanvasRenderingContext2D.prototype,"fillStyle")?.set;' +
255
+ "const fill=CanvasRenderingContext2D.prototype.fillRect;" +
256
+ "const draw=CanvasRenderingContext2D.prototype.drawImage;" +
257
+ "const add=EventTarget.prototype.addEventListener;const ImageCtor=Image;" +
258
+ 'const setSrc=Object.getOwnPropertyDescriptor(HTMLImageElement.prototype,"src")?.set;' +
259
+ "const encode=encodeURIComponent;const P=Promise;const ErrorCtor=Error;const stringify=String;" +
260
+ "const max=Math.max.bind(Math);const min=Math.min.bind(Math);const ceil=Math.ceil.bind(Math);" +
261
+ 'listen("message",event=>{if(event.source!==parent)return;' +
262
+ 'const data=event.data;if(!data||data.type!=="openclaw:widget-snapshot-request"||typeof data.id!=="string")return;' +
263
+ "void (async()=>{try{const body=document.body;" +
264
+ "const width=max(1,body.scrollWidth);const height=max(1,body.scrollHeight);" +
265
+ 'const root=clone.call(document.documentElement,true);const scripts=query.call(root,"script");' +
266
+ "for(let index=scripts.length-1;index>=0;index--){const script=item.call(scripts,index);" +
267
+ "if(script&&getParent){const owner=getParent.call(script);if(owner)remove.call(owner,script);}}" +
268
+ 'const liveCanvases=queryDocument("canvas");const clonedCanvases=query.call(root,"canvas");' +
269
+ "for(let index=0;index<liveCanvases.length;index++){const live=item.call(liveCanvases,index);" +
270
+ "const cloned=item.call(clonedCanvases,index);if(!live||!cloned||!getParent||!setSrc)continue;" +
271
+ 'const image=create("img");for(const name of names.call(cloned)){const value=getAttr.call(cloned,name);' +
272
+ 'if(value!==null)setAttr.call(image,name,value);}setSrc.call(image,toDataURL.call(live,"image/png"));' +
273
+ "const owner=getParent.call(cloned);if(owner)replace.call(owner,image,cloned);}" +
274
+ 'const svg=\'<svg xmlns="http://www.w3.org/2000/svg" width="\'+width+\'" height="\'+height+\'"><foreignObject width="100%" height="100%">\'+serialize(root)+"</foreignObject></svg>";' +
275
+ 'const url="data:image/svg+xml;charset=utf-8,"+encode(svg);' +
276
+ "const image=new ImageCtor();await new P((resolve,reject)=>{" +
277
+ 'add.call(image,"load",resolve,{once:true});add.call(image,"error",()=>reject(new ErrorCtor("widget snapshot image failed to load")),{once:true});' +
278
+ 'if(!setSrc)throw new ErrorCtor("widget snapshot image source unavailable");setSrc.call(image,url);});' +
279
+ 'const canvas=create("canvas");const scale=min(window.devicePixelRatio||1,2);' +
280
+ 'if(!setWidth||!setHeight)throw new ErrorCtor("widget snapshot canvas dimensions unavailable");' +
281
+ "const canvasWidth=ceil(width*scale);const canvasHeight=ceil(height*scale);" +
282
+ 'if(canvasWidth>16384||canvasHeight>16384||canvasWidth*canvasHeight>16777216)throw new ErrorCtor("widget snapshot dimensions exceed limit");' +
283
+ "setWidth.call(canvas,canvasWidth);setHeight.call(canvas,canvasHeight);" +
284
+ 'const context=getContext.call(canvas,"2d");if(!context||!setFill)throw new ErrorCtor("widget snapshot canvas unavailable");' +
285
+ 'setFill.call(context,getProperty.call(styles(document.documentElement),"--surface"));' +
286
+ "fill.call(context,0,0,canvasWidth,canvasHeight);draw.call(context,image,0,0,canvasWidth,canvasHeight);" +
287
+ 'post({type:"openclaw:widget-snapshot",id:data.id,dataUrl:toDataURL.call(canvas,"image/png"),width,height},"*");' +
288
+ '}catch(error){post({type:"openclaw:widget-snapshot",id:data.id,error:stringify(error)},"*");}})();});})();</script>';
289
+ const connectSources = options.connectOrigins?.length
290
+ ? options.connectOrigins.join(" ")
291
+ : "'none'";
292
+ const cdnSources = WIDGET_CDN_ORIGINS.join(" ");
293
+ const scriptSources = [...WIDGET_CDN_ORIGINS, ...(options.scriptOrigins ?? [])].join(" ");
294
+ return `<!doctype html>
295
+ <html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="referrer" content="no-referrer"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline' ${cdnSources}; script-src 'unsafe-inline' ${scriptSources}; font-src data: ${cdnSources}; img-src data:; connect-src ${connectSources};"><title>${escapeHtml(title)}</title><style>${WIDGET_BASE_STYLES}</style></head><body${bodyClass}>${widgetBridge}${errorBridge}${themeBridge}${chatHostBridge}${snapshotBridge}${sizeReporter}${widgetCode}</body></html>`;
296
+ }
src/cli/account-selector-boundary.test.ts ADDED
@@ -0,0 +1,212 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Command } from "commander";
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import { registerChannelsCli } from "./channels-cli.js";
4
+ import type { ProgramContext } from "./program/context.js";
5
+ import { registerMessageCommands } from "./program/register.message.js";
6
+
7
+ type AccountOptions = {
8
+ account?: string;
9
+ channel?: string;
10
+ json?: boolean;
11
+ dryRun?: boolean;
12
+ verbose?: boolean;
13
+ };
14
+
15
+ const context: ProgramContext = {
16
+ programVersion: "test",
17
+ messageChannelOptions: "discord",
18
+ agentChannelOptions: "last|discord",
19
+ };
20
+
21
+ const messageRoutes: [string, string[]][] = [
22
+ ["send", ["--target", "room", "--message", "hello"]],
23
+ ["broadcast", ["--targets", "room", "--message", "hello"]],
24
+ ["poll", ["--target", "room", "--poll-question", "Lunch?", "--poll-option", "Soup"]],
25
+ ["react", ["--target", "room", "--message-id", "message"]],
26
+ ["reactions", ["--target", "room", "--message-id", "message"]],
27
+ ["read", ["--target", "room"]],
28
+ ["edit", ["--target", "room", "--message-id", "message", "--message", "hello"]],
29
+ ["delete", ["--target", "room", "--message-id", "message"]],
30
+ ["pin", ["--target", "room", "--message-id", "message"]],
31
+ ["unpin", ["--target", "room", "--message-id", "message"]],
32
+ ["pins", ["--target", "room"]],
33
+ ["permissions", ["--target", "room"]],
34
+ ["search", ["--guild-id", "guild", "--query", "hello"]],
35
+ ["thread create", ["--target", "room", "--thread-name", "topic"]],
36
+ ["thread list", ["--guild-id", "guild"]],
37
+ ["thread reply", ["--target", "room", "--message", "hello"]],
38
+ ["emoji list", []],
39
+ ["emoji upload", ["--guild-id", "guild", "--emoji-name", "smile", "--media", "emoji.png"]],
40
+ ["sticker send", ["--target", "room", "--sticker-id", "sticker"]],
41
+ [
42
+ "sticker upload",
43
+ [
44
+ "--guild-id",
45
+ "guild",
46
+ "--sticker-name",
47
+ "smile",
48
+ "--sticker-desc",
49
+ "hello",
50
+ "--sticker-tags",
51
+ "smile",
52
+ "--media",
53
+ "sticker.png",
54
+ ],
55
+ ],
56
+ ["role info", ["--guild-id", "guild"]],
57
+ ["role add", ["--guild-id", "guild", "--user-id", "user", "--role-id", "role"]],
58
+ ["role remove", ["--guild-id", "guild", "--user-id", "user", "--role-id", "role"]],
59
+ ["channel info", ["--target", "room"]],
60
+ ["channel list", ["--guild-id", "guild"]],
61
+ ["member info", ["--user-id", "user"]],
62
+ ["voice status", ["--guild-id", "guild", "--user-id", "user"]],
63
+ ["event list", ["--guild-id", "guild"]],
64
+ [
65
+ "event create",
66
+ ["--guild-id", "guild", "--event-name", "lunch", "--start-time", "2027-01-01T12:00:00Z"],
67
+ ],
68
+ ["timeout", ["--guild-id", "guild", "--user-id", "user", "--duration-min", "1"]],
69
+ ["kick", ["--guild-id", "guild", "--user-id", "user"]],
70
+ ["ban", ["--guild-id", "guild", "--user-id", "user"]],
71
+ ];
72
+
73
+ const messageCases = messageRoutes.map(([name, required]) => ({
74
+ name: `message ${name}`,
75
+ args: [
76
+ "message",
77
+ ...name.split(" "),
78
+ ...required,
79
+ "--channel",
80
+ "discord",
81
+ "--json",
82
+ "--dry-run",
83
+ "--verbose",
84
+ ],
85
+ }));
86
+ const channelCases = [
87
+ {
88
+ name: "channels capabilities",
89
+ args: ["channels", "capabilities", "--channel", "discord", "--json"],
90
+ },
91
+ {
92
+ name: "channels resolve",
93
+ args: ["channels", "resolve", "room", "--channel", "discord", "--json"],
94
+ },
95
+ ];
96
+ const representativeCases = [
97
+ ...messageCases.filter(({ name }) =>
98
+ ["message send", "message thread create", "message broadcast"].includes(name),
99
+ ),
100
+ ...channelCases,
101
+ ];
102
+
103
+ async function createProgram(args: string[]) {
104
+ const program = new Command().exitOverride().configureOutput({ writeErr: () => undefined });
105
+ if (args[0] === "message") {
106
+ registerMessageCommands(program, context);
107
+ } else {
108
+ await registerChannelsCli(program, ["node", "openclaw", ...args]);
109
+ }
110
+ const startup = vi.fn((_root: Command, _action: Command) => {
111
+ throw new Error("command startup reached");
112
+ });
113
+ program.hook("preAction", startup);
114
+ return { program, startup };
115
+ }
116
+
117
+ describe("account selector option boundaries", () => {
118
+ it("covers every registered message action", () => {
119
+ const program = new Command();
120
+ registerMessageCommands(program, context);
121
+ function leaves(command: Command, prefix: string): string[] {
122
+ return command.commands.flatMap((child) => {
123
+ const name = `${prefix} ${child.name()}`.trim();
124
+ return child.commands.length ? leaves(child, name) : [name];
125
+ });
126
+ }
127
+ expect(leaves(program, "").toSorted()).toEqual(messageCases.map(({ name }) => name).toSorted());
128
+ });
129
+
130
+ describe.each([...messageCases, ...channelCases])("$name", ({ args }) => {
131
+ it("preserves valid omitted input before action", async () => {
132
+ const { program, startup } = await createProgram(args);
133
+ await expect(program.parseAsync(args, { from: "user" })).rejects.toThrow(
134
+ "command startup reached",
135
+ );
136
+ expect(startup.mock.calls.map(([, action]) => action.opts<AccountOptions>().account)).toEqual(
137
+ [undefined],
138
+ );
139
+ });
140
+
141
+ it.each(["", " \t\n "])("rejects blank %j before command startup", async (account) => {
142
+ const { program, startup } = await createProgram(args);
143
+ await expect(
144
+ program.parseAsync([...args, "--account", account], { from: "user" }),
145
+ ).rejects.toThrow("--account must not be blank");
146
+ expect(startup).not.toHaveBeenCalled();
147
+ });
148
+ });
149
+
150
+ describe.each(representativeCases)("$name selector forms", ({ args }) => {
151
+ it.each([undefined, "work", " work "])(
152
+ "preserves nonblank or omitted value %j",
153
+ async (account) => {
154
+ const { program, startup } = await createProgram(args);
155
+ const argv = account === undefined ? args : [...args, "--account", account];
156
+ await expect(program.parseAsync(argv, { from: "user" })).rejects.toThrow(
157
+ "command startup reached",
158
+ );
159
+ expect(
160
+ startup.mock.calls.map(([, action]) => action.opts<AccountOptions>().account),
161
+ ).toEqual([account]);
162
+ expect(startup.mock.calls.map(([, action]) => action.opts<AccountOptions>())).toEqual([
163
+ expect.objectContaining({
164
+ channel: "discord",
165
+ json: true,
166
+ ...(args[0] === "message" ? { dryRun: true, verbose: true } : {}),
167
+ }),
168
+ ]);
169
+ },
170
+ );
171
+
172
+ it("rejects joined empty input before startup", async () => {
173
+ const { program, startup } = await createProgram(args);
174
+ await expect(program.parseAsync([...args, "--account="], { from: "user" })).rejects.toThrow(
175
+ "--account must not be blank",
176
+ );
177
+ expect(startup).not.toHaveBeenCalled();
178
+ });
179
+
180
+ it("keeps the missing option argument error", async () => {
181
+ const { program, startup } = await createProgram(args);
182
+ await expect(program.parseAsync([...args, "--account"], { from: "user" })).rejects.toThrow(
183
+ "argument missing",
184
+ );
185
+ expect(startup).not.toHaveBeenCalled();
186
+ });
187
+ });
188
+
189
+ it.each([undefined, "work"])("keeps the missing resolve entry error for %j", async (account) => {
190
+ const args = [
191
+ "channels",
192
+ "resolve",
193
+ "--channel",
194
+ "discord",
195
+ ...(account === undefined ? [] : ["--account", account]),
196
+ ];
197
+ const { program, startup } = await createProgram(args);
198
+ await expect(program.parseAsync(args, { from: "user" })).rejects.toThrow(
199
+ "missing required argument",
200
+ );
201
+ expect(startup).not.toHaveBeenCalled();
202
+ });
203
+
204
+ it("reports a blank selector before a missing resolve entry", async () => {
205
+ const args = ["channels", "resolve", "--account", ""];
206
+ const { program, startup } = await createProgram(args);
207
+ await expect(program.parseAsync(args, { from: "user" })).rejects.toThrow(
208
+ "--account must not be blank",
209
+ );
210
+ expect(startup).not.toHaveBeenCalled();
211
+ });
212
+ });
src/cli/acp-cli-exit.process.test.ts ADDED
@@ -0,0 +1,219 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Process regression coverage for ACP bridge disconnect and startup-handshake exit paths.
2
+ import type { ChildProcessWithoutNullStreams } from "node:child_process";
3
+ import { createServer } from "node:http";
4
+ import path from "node:path";
5
+ import { stripVTControlCharacters } from "node:util";
6
+ import { describe, expect, it } from "vitest";
7
+ import { type RawData, WebSocketServer } from "ws";
8
+ import { closeOpenClawStateDatabaseByPath } from "../state/openclaw-state-db-cache.js";
9
+ import { openOpenClawStateDatabase } from "../state/openclaw-state-db.js";
10
+ import { createOpenClawTestState } from "../test-utils/openclaw-test-state.js";
11
+ import { runCliProcessChild } from "./cli-process-child.test-helpers.js";
12
+
13
+ const INITIALIZE_FRAME = {
14
+ jsonrpc: "2.0",
15
+ id: 1,
16
+ method: "initialize",
17
+ params: {
18
+ protocolVersion: 1,
19
+ clientCapabilities: {
20
+ fs: { readTextFile: false, writeTextFile: false },
21
+ terminal: false,
22
+ },
23
+ },
24
+ };
25
+
26
+ async function createPreparedAcpProcessState() {
27
+ const state = await createOpenClawTestState({
28
+ applyEnv: false,
29
+ label: "acp-process",
30
+ scenario: "minimal",
31
+ });
32
+ try {
33
+ // These cases assert bridge stderr after normal startup. Prepare canonical
34
+ // shared state so the one-time migration diagnostic is not part of that signal.
35
+ const database = openOpenClawStateDatabase({ env: state.env });
36
+ closeOpenClawStateDatabaseByPath(database.path);
37
+ return state;
38
+ } catch (error) {
39
+ await state.cleanup();
40
+ throw error;
41
+ }
42
+ }
43
+
44
+ function createAcpProcessEnv(baseEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
45
+ return {
46
+ ...baseEnv,
47
+ NODE_ENV: undefined,
48
+ NODE_OPTIONS: "--use-openssl-ca",
49
+ NODE_USE_SYSTEM_CA: "0",
50
+ OPENCLAW_DISABLE_BUNDLED_PLUGINS: "1",
51
+ OPENCLAW_NO_RESPAWN: "1",
52
+ VITEST: undefined,
53
+ };
54
+ }
55
+
56
+ function withoutSqliteTransactionWarnings(stderr: string): string {
57
+ // Slow-hold logger output is load-dependent performance diagnostics, not ACP clean-exit
58
+ // signal; see logSlowTransactionHold in sqlite-transaction.ts.
59
+ return stderr
60
+ .split("\n")
61
+ .filter(
62
+ (line) => !stripVTControlCharacters(line).trimStart().startsWith("[sqlite/transaction]"),
63
+ )
64
+ .join("\n");
65
+ }
66
+
67
+ function waitForJsonLine(child: ChildProcessWithoutNullStreams, id: number) {
68
+ return new Promise<Record<string, unknown>>((resolve, reject) => {
69
+ let stdout = "";
70
+ const onExit = (code: number | null, signal: NodeJS.Signals | null) => {
71
+ reject(new Error(`ACP process exited before response (code=${code}, signal=${signal})`));
72
+ };
73
+ const finish = (response: Record<string, unknown>) => {
74
+ child.off("exit", onExit);
75
+ resolve(response);
76
+ };
77
+
78
+ child.once("exit", onExit);
79
+ child.stdout.on("data", (chunk: Buffer) => {
80
+ stdout += chunk.toString();
81
+ const lines = stdout.split("\n");
82
+ stdout = lines.pop() ?? "";
83
+ for (const line of lines) {
84
+ if (!line.trim()) {
85
+ continue;
86
+ }
87
+ const response = JSON.parse(line) as Record<string, unknown>;
88
+ if (response.id === id) {
89
+ finish(response);
90
+ return;
91
+ }
92
+ }
93
+ });
94
+ });
95
+ }
96
+
97
+ function rawDataToText(data: RawData): string {
98
+ if (Array.isArray(data)) {
99
+ return Buffer.concat(data).toString("utf8");
100
+ }
101
+ if (data instanceof ArrayBuffer) {
102
+ return Buffer.from(new Uint8Array(data)).toString("utf8");
103
+ }
104
+ return Buffer.from(data).toString("utf8");
105
+ }
106
+
107
+ describe("ACP CLI process exit", () => {
108
+ it("exits when the client disconnects after sending an initialize frame", async () => {
109
+ const state = await createPreparedAcpProcessState();
110
+ try {
111
+ const result = await runCliProcessChild({
112
+ nodeArgs: [path.resolve("openclaw.mjs"), "acp", "--require-existing"],
113
+ env: createAcpProcessEnv(state.env),
114
+ input: `${JSON.stringify(INITIALIZE_FRAME)}\n`,
115
+ });
116
+
117
+ expect(result).toMatchObject({ code: 0, signal: null });
118
+ expect(withoutSqliteTransactionWarnings(result.stderr)).toBe("");
119
+ } finally {
120
+ await state.cleanup();
121
+ }
122
+ });
123
+
124
+ it("processes an initialize frame buffered before Gateway hello", async () => {
125
+ const state = await createPreparedAcpProcessState();
126
+ const server = createServer();
127
+ const wss = new WebSocketServer({ server });
128
+
129
+ try {
130
+ wss.on("connection", (socket) => {
131
+ socket.send(
132
+ JSON.stringify({
133
+ type: "event",
134
+ event: "connect.challenge",
135
+ seq: 1,
136
+ payload: { nonce: "acp-process-test", ts: Date.now() },
137
+ }),
138
+ );
139
+ socket.on("message", (data) => {
140
+ const frame = JSON.parse(rawDataToText(data)) as { id: string; method: string };
141
+ if (frame.method !== "connect") {
142
+ return;
143
+ }
144
+ socket.send(
145
+ JSON.stringify({
146
+ type: "res",
147
+ id: frame.id,
148
+ ok: true,
149
+ payload: {
150
+ type: "hello-ok",
151
+ protocol: 4,
152
+ server: { version: "acp-process-test", connId: "acp-process-test" },
153
+ features: { methods: [], events: [] },
154
+ snapshot: {
155
+ presence: [],
156
+ health: {},
157
+ stateVersion: { presence: 1, health: 1 },
158
+ uptimeMs: 1,
159
+ },
160
+ auth: { role: "operator", scopes: ["operator.admin"] },
161
+ policy: {
162
+ maxPayload: 512 * 1024,
163
+ maxBufferedBytes: 1024 * 1024,
164
+ tickIntervalMs: 1000,
165
+ },
166
+ },
167
+ }),
168
+ );
169
+ });
170
+ });
171
+ await new Promise<void>((resolve) => {
172
+ server.listen(0, "127.0.0.1", resolve);
173
+ });
174
+ const address = server.address();
175
+ if (!address || typeof address === "string") {
176
+ throw new Error("ACP process test Gateway did not get a TCP address");
177
+ }
178
+
179
+ let response: Record<string, unknown> | undefined;
180
+ const result = await runCliProcessChild({
181
+ nodeArgs: [
182
+ path.resolve("openclaw.mjs"),
183
+ "acp",
184
+ "--require-existing",
185
+ "--url",
186
+ `ws://127.0.0.1:${address.port}`,
187
+ ],
188
+ env: createAcpProcessEnv(state.env),
189
+ interact: async (runningChild) => {
190
+ const responsePromise = waitForJsonLine(runningChild, INITIALIZE_FRAME.id);
191
+ // Write before the Gateway handshake completes. Startup monitoring must
192
+ // retain this frame for the eventual AgentSideConnection reader.
193
+ runningChild.stdin.write(`${JSON.stringify(INITIALIZE_FRAME)}\n`);
194
+ response = await responsePromise;
195
+ runningChild.stdin.end();
196
+ },
197
+ });
198
+
199
+ expect(response).toMatchObject({
200
+ jsonrpc: "2.0",
201
+ id: INITIALIZE_FRAME.id,
202
+ result: { protocolVersion: INITIALIZE_FRAME.params.protocolVersion },
203
+ });
204
+ expect(result).toMatchObject({ code: 0, signal: null });
205
+ expect(withoutSqliteTransactionWarnings(result.stderr)).toBe("");
206
+ } finally {
207
+ for (const socket of wss.clients) {
208
+ socket.terminate();
209
+ }
210
+ await new Promise<void>((resolve) => {
211
+ wss.close(() => resolve());
212
+ });
213
+ await new Promise<void>((resolve, reject) => {
214
+ server.close((error) => (error ? reject(error) : resolve()));
215
+ });
216
+ await state.cleanup();
217
+ }
218
+ });
219
+ });
src/cli/acp-cli.option-collisions.test.ts ADDED
@@ -0,0 +1,202 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // ACP CLI option collision tests cover ACP command flag registration boundaries.
2
+ import { Command } from "commander";
3
+ import { beforeEach, describe, expect, it, vi } from "vitest";
4
+ import { runRegisteredCli } from "../test-utils/command-runner.js";
5
+ import { withTempSecretFiles } from "../test-utils/secret-file-fixture.js";
6
+ import { registerAcpCli } from "./acp-cli.js";
7
+
8
+ type AcpClientOptions = {
9
+ verbose?: boolean;
10
+ };
11
+
12
+ type AcpGatewayOptions = {
13
+ gatewayPassword?: string;
14
+ gatewayToken?: string;
15
+ prefixCwd?: boolean;
16
+ };
17
+
18
+ const mocks = vi.hoisted(() => ({
19
+ runAcpClientInteractive: vi.fn(async (_opts: AcpClientOptions) => {}),
20
+ serveAcpGateway: vi.fn(async (_opts: AcpGatewayOptions) => {}),
21
+ defaultRuntime: {
22
+ log: vi.fn(),
23
+ error: vi.fn(),
24
+ writeStdout: vi.fn(),
25
+ writeJson: vi.fn(),
26
+ exit: vi.fn(),
27
+ },
28
+ }));
29
+
30
+ const { runAcpClientInteractive, serveAcpGateway, defaultRuntime } = mocks;
31
+
32
+ const passwordKey = () => ["pass", "word"].join("");
33
+
34
+ vi.mock("../acp/client.js", () => ({
35
+ runAcpClientInteractive: (opts: AcpClientOptions) => mocks.runAcpClientInteractive(opts),
36
+ }));
37
+
38
+ vi.mock("../acp/server.js", () => ({
39
+ serveAcpGateway: (opts: AcpGatewayOptions) => mocks.serveAcpGateway(opts),
40
+ }));
41
+
42
+ vi.mock("../runtime.js", () => ({
43
+ defaultRuntime: mocks.defaultRuntime,
44
+ }));
45
+
46
+ describe("acp cli option collisions", () => {
47
+ function createAcpProgram() {
48
+ const program = new Command();
49
+ registerAcpCli(program);
50
+ return program;
51
+ }
52
+
53
+ async function parseAcp(args: string[]) {
54
+ const program = createAcpProgram();
55
+ await program.parseAsync(["acp", ...args], { from: "user" });
56
+ }
57
+
58
+ function expectCliError(pattern: RegExp) {
59
+ expect(serveAcpGateway).not.toHaveBeenCalled();
60
+ const errors = defaultRuntime.error.mock.calls.map(([message]) => String(message));
61
+ expect(errors.some((message) => pattern.test(message))).toBe(true);
62
+ expect(defaultRuntime.exit).toHaveBeenCalledWith(1);
63
+ }
64
+
65
+ function requireFirstMockArg(mock: { mock: { calls: ReadonlyArray<ReadonlyArray<unknown>> } }) {
66
+ const call = mock.mock.calls[0];
67
+ if (!call) {
68
+ throw new Error("expected mock to have at least one call");
69
+ }
70
+ return call[0];
71
+ }
72
+
73
+ beforeEach(() => {
74
+ runAcpClientInteractive.mockClear();
75
+ serveAcpGateway.mockClear();
76
+ defaultRuntime.log.mockClear();
77
+ defaultRuntime.error.mockClear();
78
+ defaultRuntime.writeStdout.mockClear();
79
+ defaultRuntime.writeJson.mockClear();
80
+ defaultRuntime.exit.mockClear();
81
+ });
82
+
83
+ it("forwards --verbose to `acp client` when parent and child option names collide", async () => {
84
+ await runRegisteredCli({
85
+ register: registerAcpCli as (program: Command) => void,
86
+ argv: ["acp", "client", "--verbose"],
87
+ });
88
+
89
+ expect(runAcpClientInteractive).toHaveBeenCalledTimes(1);
90
+ const clientOptions = requireFirstMockArg(runAcpClientInteractive) as { verbose?: boolean };
91
+ expect(clientOptions?.verbose).toBe(true);
92
+ });
93
+
94
+ it("forwards --no-prefix-cwd to the ACP bridge", async () => {
95
+ await parseAcp(["--no-prefix-cwd"]);
96
+
97
+ expect(serveAcpGateway).toHaveBeenCalledTimes(1);
98
+ const gatewayOptions = requireFirstMockArg(serveAcpGateway) as {
99
+ prefixCwd?: boolean;
100
+ };
101
+ expect(gatewayOptions?.prefixCwd).toBe(false);
102
+ });
103
+
104
+ it("defaults to prefixing the working directory", async () => {
105
+ await parseAcp([]);
106
+
107
+ expect(serveAcpGateway).toHaveBeenCalledTimes(1);
108
+ const gatewayOptions = requireFirstMockArg(serveAcpGateway) as {
109
+ prefixCwd?: boolean;
110
+ };
111
+ expect(gatewayOptions?.prefixCwd).toBe(true);
112
+ });
113
+
114
+ it("loads gateway token/password from files", async () => {
115
+ await withTempSecretFiles(
116
+ "openclaw-acp-cli-",
117
+ { token: "tok_file\n", [passwordKey()]: "pw_file\n" },
118
+ async (files) => {
119
+ // pragma: allowlist secret
120
+ await parseAcp([
121
+ "--token-file",
122
+ files.tokenFile ?? "",
123
+ "--password-file",
124
+ files.passwordFile ?? "",
125
+ ]);
126
+ },
127
+ );
128
+
129
+ expect(serveAcpGateway).toHaveBeenCalledTimes(1);
130
+ const gatewayOptions = requireFirstMockArg(serveAcpGateway) as {
131
+ gatewayPassword?: string;
132
+ gatewayToken?: string;
133
+ };
134
+ expect(gatewayOptions?.gatewayToken).toBe("tok_file");
135
+ expect(gatewayOptions?.gatewayPassword).toBe("pw_file"); // pragma: allowlist secret
136
+ });
137
+
138
+ it.each([
139
+ {
140
+ name: "rejects mixed secret flags and file flags",
141
+ files: { token: "tok_file\n" },
142
+ args: (tokenFile: string) => ["--token", "tok_inline", "--token-file", tokenFile],
143
+ expected: /Use either --token .*--token-file for Gateway token\./,
144
+ },
145
+ {
146
+ name: "rejects mixed password flags and file flags",
147
+ files: { password: "pw_file\n" }, // pragma: allowlist secret
148
+ args: (_tokenFile: string, passwordFile: string) => [
149
+ "--password",
150
+ "pw_inline",
151
+ "--password-file",
152
+ passwordFile,
153
+ ],
154
+ expected: /Use either --passw.*d .*--password-file for Gateway password\./,
155
+ },
156
+ ])("$name", async ({ files, args, expected }) => {
157
+ await withTempSecretFiles("openclaw-acp-cli-", files, async ({ tokenFile, passwordFile }) => {
158
+ await parseAcp(args(tokenFile ?? "", passwordFile ?? ""));
159
+ });
160
+
161
+ expectCliError(expected);
162
+ });
163
+
164
+ it("warns when inline secret flags are used", async () => {
165
+ await parseAcp(["--token", "tok_inline", "--password", "pw_inline"]);
166
+
167
+ const errors = defaultRuntime.error.mock.calls.map(([message]) => String(message));
168
+ expect(errors).toContain(
169
+ "Warning: --token can be exposed via process listings. Prefer --token-file or environment variables.",
170
+ );
171
+ expect(errors).toContain(
172
+ "Warning: --password can be exposed via process listings. Prefer --password-file or environment variables.",
173
+ );
174
+ });
175
+
176
+ it("trims token file path before reading", async () => {
177
+ await withTempSecretFiles("openclaw-acp-cli-", { token: "tok_file\n" }, async (files) => {
178
+ await parseAcp(["--token-file", ` ${files.tokenFile ?? ""} `]);
179
+ });
180
+
181
+ expect(serveAcpGateway).toHaveBeenCalledTimes(1);
182
+ const gatewayOptions = requireFirstMockArg(serveAcpGateway) as { gatewayToken?: string };
183
+ expect(gatewayOptions?.gatewayToken).toBe("tok_file");
184
+ });
185
+
186
+ it("reports missing token-file read errors", async () => {
187
+ await parseAcp(["--token-file", "/tmp/openclaw-acp-missing-token.txt"]);
188
+ expectCliError(/Failed to (inspect|read) Gateway token file/);
189
+ });
190
+
191
+ it("formats client errors with formatErrorMessage instead of String(err) (#83904)", async () => {
192
+ runAcpClientInteractive.mockImplementationOnce(async () => {
193
+ throw { code: 42, why: "boom" } as unknown as Error;
194
+ });
195
+ const program = createAcpProgram();
196
+ await program.parseAsync(["acp", "client"], { from: "user" });
197
+
198
+ const errors = defaultRuntime.error.mock.calls.map(([message]) => String(message));
199
+ expect(errors).toContain('{"code":42,"why":"boom"}');
200
+ expect(defaultRuntime.exit).toHaveBeenCalledWith(1);
201
+ });
202
+ });
src/cli/acp-cli.ts ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Commander registration for ACP bridge and interactive ACP client commands.
2
+ import type { Command } from "commander";
3
+ import { formatDocsLink } from "../../packages/terminal-core/src/links.js";
4
+ import { theme } from "../../packages/terminal-core/src/theme.js";
5
+ import { normalizeAcpProvenanceMode } from "../acp/types.js";
6
+ import { formatErrorMessage } from "../infra/errors.js";
7
+ import { defaultRuntime } from "../runtime.js";
8
+ import { inheritOptionFromParent } from "./command-options.js";
9
+ import { resolveGatewayAuthOptions } from "./gateway-secret-options.js";
10
+
11
+ export function registerAcpCli(program: Command) {
12
+ const acp = program.command("acp").description("Run an ACP bridge backed by the Gateway");
13
+
14
+ acp
15
+ .option("--url <url>", "Gateway WebSocket URL (defaults to gateway.remote.url when configured)")
16
+ .option("--token <token>", "Gateway token (if required)")
17
+ .option("--token-file <path>", "Read gateway token from file")
18
+ .option("--password <password>", "Gateway password (if required)")
19
+ .option("--password-file <path>", "Read gateway password from file")
20
+ .option("--session <key>", "Default session key (e.g. agent:main:main)")
21
+ .option("--session-label <label>", "Default session label to resolve")
22
+ .option("--require-existing", "Fail if the session key/label does not exist", false)
23
+ .option("--reset-session", "Reset the session key before first use", false)
24
+ .option("--no-prefix-cwd", "Do not prefix prompts with the working directory")
25
+ .option("--provenance <mode>", "ACP provenance mode: off, meta, or meta+receipt")
26
+ .option("-v, --verbose", "Verbose logging to stderr", false)
27
+ .addHelpText(
28
+ "after",
29
+ () => `\n${theme.muted("Docs:")} ${formatDocsLink("/cli/acp", "docs.openclaw.ai/cli/acp")}\n`,
30
+ )
31
+ .action(async (opts) => {
32
+ try {
33
+ const { gatewayToken, gatewayPassword } = resolveGatewayAuthOptions(opts);
34
+ const provenanceMode = normalizeAcpProvenanceMode(opts.provenance as string | undefined);
35
+ if (opts.provenance && !provenanceMode) {
36
+ throw new Error('Invalid --provenance. Use "off", "meta", or "meta+receipt".');
37
+ }
38
+ const { serveAcpGateway } = await import("../acp/server.js");
39
+ await serveAcpGateway({
40
+ gatewayUrl: opts.url as string | undefined,
41
+ gatewayToken,
42
+ gatewayPassword,
43
+ defaultSessionKey: opts.session as string | undefined,
44
+ defaultSessionLabel: opts.sessionLabel as string | undefined,
45
+ requireExistingSession: Boolean(opts.requireExisting),
46
+ resetSession: Boolean(opts.resetSession),
47
+ prefixCwd: opts.prefixCwd !== false,
48
+ provenanceMode,
49
+ verbose: Boolean(opts.verbose),
50
+ });
51
+ } catch (err) {
52
+ defaultRuntime.error(`ACP bridge failed: ${formatErrorMessage(err)}`);
53
+ defaultRuntime.exit(1);
54
+ }
55
+ });
56
+
57
+ acp
58
+ .command("client")
59
+ .description("Run an interactive ACP client against the local ACP bridge")
60
+ .option("--cwd <dir>", "Working directory for the ACP session")
61
+ .option("--server <command>", "ACP server command (default: openclaw)")
62
+ .option("--server-args <args...>", "Extra arguments for the ACP server")
63
+ .option("--server-verbose", "Enable verbose logging on the ACP server", false)
64
+ .option("-v, --verbose", "Verbose client logging", false)
65
+ .action(async (opts, command) => {
66
+ const inheritedVerbose = inheritOptionFromParent<boolean>(command, "verbose");
67
+ try {
68
+ const { runAcpClientInteractive } = await import("../acp/client.js");
69
+ await runAcpClientInteractive({
70
+ cwd: opts.cwd as string | undefined,
71
+ serverCommand: opts.server as string | undefined,
72
+ serverArgs: opts.serverArgs as string[] | undefined,
73
+ serverVerbose: Boolean(opts.serverVerbose),
74
+ verbose: Boolean(opts.verbose || inheritedVerbose),
75
+ });
76
+ } catch (err) {
77
+ defaultRuntime.error(formatErrorMessage(err));
78
+ defaultRuntime.exit(1);
79
+ }
80
+ });
81
+ }
src/cli/agent-cli-recovery.process.test.ts ADDED
@@ -0,0 +1,250 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { afterEach, describe, expect, it } from "vitest";
4
+ import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
5
+ import { setCliSessionBinding } from "../agents/cli-session.js";
6
+ import {
7
+ loadSessionEntryReadOnly,
8
+ replaceSessionEntry,
9
+ } from "../config/sessions/session-accessor.js";
10
+ import type { InternalSessionEntry } from "../config/sessions/types.js";
11
+ import { resolveRuntimeWorkerArgv, resolveRuntimeWorkerUrl } from "../infra/runtime-worker-url.js";
12
+ import { closeOpenClawAgentDatabasesForTest } from "../state/openclaw-agent-db.js";
13
+ import { cliRecoveryEntrypoints } from "./cli-entrypoint.test-support.js";
14
+ import { runCliProcessChild } from "./cli-process-child.test-helpers.js";
15
+
16
+ const tempDirs = useAutoCleanupTempDirTracker(afterEach);
17
+
18
+ afterEach(() => {
19
+ closeOpenClawAgentDatabasesForTest();
20
+ });
21
+
22
+ describe("CLI fork recovery process", () => {
23
+ it("keeps a concurrent durable rebind when a stale fork reports its successor", async () => {
24
+ const root = tempDirs.make("openclaw-cli-fork-recovery-");
25
+ const stateDir = path.join(root, "state");
26
+ const tmpDir = path.join(root, "tmp");
27
+ const workspaceDir = path.join(root, "workspace");
28
+ const pluginDir = path.join(root, "plugin");
29
+ const storePath = path.join(stateDir, "agents/main/sessions/sessions.json");
30
+ const sessionKey = "agent:main:cli-fork-process";
31
+ const sourceCliSessionId = "source-cli-session";
32
+ const newerCliSessionId = "newer-cli-session";
33
+ const successorCliSessionId = "stale-successor-session";
34
+ const checkpointId = "source-checkpoint";
35
+ const backendScript = path.join(root, "backend.mjs");
36
+ const rebindScript = path.join(root, "rebind.mjs");
37
+ const spawnLog = path.join(root, "spawn.jsonl");
38
+ await Promise.all([
39
+ fs.mkdir(tmpDir, { recursive: true }),
40
+ fs.mkdir(workspaceDir, { recursive: true }),
41
+ fs.mkdir(pluginDir, { recursive: true }),
42
+ ]);
43
+
44
+ await writeProofPlugin({
45
+ pluginDir,
46
+ backendScript,
47
+ rebindScript,
48
+ });
49
+ const configPath = path.join(root, "openclaw.json");
50
+ await fs.writeFile(
51
+ configPath,
52
+ JSON.stringify({
53
+ session: { store: path.join(stateDir, "agents/{agentId}/sessions/sessions.json") },
54
+ plugins: {
55
+ allow: ["cli-fork-process-proof"],
56
+ load: { paths: [pluginDir] },
57
+ entries: { "cli-fork-process-proof": { enabled: true } },
58
+ },
59
+ agents: {
60
+ defaults: {
61
+ workspace: workspaceDir,
62
+ model: { primary: "proof-cli/proof-model" },
63
+ models: { "proof-cli/proof-model": { agentRuntime: { id: "proof-cli" } } },
64
+ },
65
+ },
66
+ }),
67
+ );
68
+
69
+ const entry: InternalSessionEntry = {
70
+ sessionId: "openclaw-process-session",
71
+ lifecycleRevision: "process-lifecycle",
72
+ activeWriterRunId: "process-writer",
73
+ updatedAt: 1,
74
+ };
75
+ setCliSessionBinding(entry, "proof-cli", {
76
+ sessionId: sourceCliSessionId,
77
+ forceReuse: true,
78
+ forkNextResume: true,
79
+ resumeCheckpointId: checkpointId,
80
+ });
81
+ await replaceSessionEntry({ sessionKey, storePath }, entry);
82
+ closeOpenClawAgentDatabasesForTest();
83
+
84
+ const result = await runCliProcessChild({
85
+ nodeArgs: [
86
+ ...resolveRuntimeWorkerArgv(resolveRuntimeWorkerUrl(cliRecoveryEntrypoints.cli)),
87
+ "agent",
88
+ "--local",
89
+ "--session-key",
90
+ sessionKey,
91
+ "--model",
92
+ "proof-cli/proof-model",
93
+ "--message",
94
+ "prove stale fork ownership",
95
+ "--json",
96
+ ],
97
+ env: {
98
+ ...process.env,
99
+ HOME: root,
100
+ USERPROFILE: root,
101
+ TMPDIR: tmpDir,
102
+ NODE_DISABLE_COMPILE_CACHE: "1",
103
+ NODE_ENV: undefined,
104
+ NODE_OPTIONS: undefined,
105
+ OPENCLAW_CONFIG_PATH: configPath,
106
+ OPENCLAW_DISABLE_BUNDLED_PLUGINS: "1",
107
+ OPENCLAW_HOME: root,
108
+ OPENCLAW_NO_RESPAWN: "1",
109
+ OPENCLAW_STATE_DIR: stateDir,
110
+ PR135168_BACKEND_SCRIPT: backendScript,
111
+ PR135168_NEWER_CLI_SESSION_ID: newerCliSessionId,
112
+ PR135168_REBIND_SCRIPT: rebindScript,
113
+ PR135168_SESSION_KEY: sessionKey,
114
+ PR135168_SPAWN_LOG: spawnLog,
115
+ PR135168_STORE_PATH: storePath,
116
+ PR135168_SUCCESSOR_CLI_SESSION_ID: successorCliSessionId,
117
+ VITEST: undefined,
118
+ },
119
+ });
120
+
121
+ expect(result, JSON.stringify(result)).toMatchObject({ code: 1, signal: null });
122
+ expect(result.stderr).toContain(
123
+ "CLI turn failed and its fork successor could not be persisted",
124
+ );
125
+ closeOpenClawAgentDatabasesForTest();
126
+ expect(
127
+ loadSessionEntryReadOnly({ sessionKey, storePath })?.cliSessionBindings?.["proof-cli"]
128
+ ?.sessionId,
129
+ ).toBe(newerCliSessionId);
130
+ const spawn = JSON.parse((await fs.readFile(spawnLog, "utf8")).trim()) as {
131
+ argv: string[];
132
+ };
133
+ expect(spawn.argv).toEqual([
134
+ "--resume",
135
+ sourceCliSessionId,
136
+ "--fork-session",
137
+ "--resume-session-at",
138
+ checkpointId,
139
+ ]);
140
+ });
141
+ });
142
+
143
+ async function writeProofPlugin(params: {
144
+ pluginDir: string;
145
+ backendScript: string;
146
+ rebindScript: string;
147
+ }): Promise<void> {
148
+ await Promise.all([
149
+ fs.writeFile(
150
+ path.join(params.pluginDir, "package.json"),
151
+ JSON.stringify({
152
+ name: "cli-fork-process-proof",
153
+ private: true,
154
+ type: "module",
155
+ openclaw: { extensions: ["./index.js"] },
156
+ }),
157
+ ),
158
+ fs.writeFile(
159
+ path.join(params.pluginDir, "openclaw.plugin.json"),
160
+ JSON.stringify({
161
+ id: "cli-fork-process-proof",
162
+ name: "CLI fork process proof",
163
+ cliBackends: ["proof-cli"],
164
+ activation: { onStartup: false },
165
+ modelCatalog: {
166
+ providers: {
167
+ "proof-cli": {
168
+ models: [
169
+ {
170
+ id: "proof-model",
171
+ name: "Proof model",
172
+ reasoning: false,
173
+ input: ["text"],
174
+ contextWindow: 8192,
175
+ maxTokens: 1024,
176
+ },
177
+ ],
178
+ },
179
+ },
180
+ discovery: { "proof-cli": "static" },
181
+ },
182
+ configSchema: { type: "object", additionalProperties: false },
183
+ }),
184
+ ),
185
+ fs.writeFile(
186
+ path.join(params.pluginDir, "index.js"),
187
+ `import { execFile } from "node:child_process";
188
+ import { promisify } from "node:util";
189
+ const execFileAsync = promisify(execFile);
190
+ const need = (name) => {
191
+ const value = process.env[name]?.trim();
192
+ if (!value) throw new Error("missing " + name);
193
+ return value;
194
+ };
195
+ export default {
196
+ id: "cli-fork-process-proof",
197
+ name: "CLI fork process proof",
198
+ register(api) {
199
+ api.registerCliBackend({
200
+ id: "proof-cli",
201
+ modelProvider: "proof-cli",
202
+ config: {
203
+ command: process.execPath,
204
+ args: [need("PR135168_BACKEND_SCRIPT")],
205
+ resumeArgs: [need("PR135168_BACKEND_SCRIPT"), "--resume", "{sessionId}"],
206
+ output: "json",
207
+ resumeOutput: "json",
208
+ input: "stdin",
209
+ sessionMode: "existing",
210
+ sessionIdFields: ["session_id"],
211
+ forkArg: "--fork-session",
212
+ resumeAtArg: "--resume-session-at",
213
+ systemPromptWhen: "never",
214
+ serialize: true
215
+ },
216
+ async prepareExecution() {
217
+ return {
218
+ beforeExecution: async () => {
219
+ await execFileAsync(process.execPath, ["--import", "tsx", need("PR135168_REBIND_SCRIPT")]);
220
+ }
221
+ };
222
+ }
223
+ });
224
+ }
225
+ };
226
+ `,
227
+ ),
228
+ fs.writeFile(
229
+ params.backendScript,
230
+ `import fs from "node:fs/promises";
231
+ const stdin = [];
232
+ for await (const chunk of process.stdin) stdin.push(chunk);
233
+ await fs.appendFile(process.env.PR135168_SPAWN_LOG, JSON.stringify({ argv: process.argv.slice(2) }) + "\\n");
234
+ process.stdout.write(JSON.stringify({ result: "stale recovery ran", session_id: process.env.PR135168_SUCCESSOR_CLI_SESSION_ID }) + "\\n");
235
+ `,
236
+ ),
237
+ fs.writeFile(
238
+ params.rebindScript,
239
+ `const accessor = await import(${JSON.stringify(resolveRuntimeWorkerUrl(cliRecoveryEntrypoints.sessionAccessor).href)});
240
+ const cliSession = await import(${JSON.stringify(resolveRuntimeWorkerUrl(cliRecoveryEntrypoints.cliSession).href)});
241
+ const scope = { sessionKey: process.env.PR135168_SESSION_KEY, storePath: process.env.PR135168_STORE_PATH };
242
+ const current = accessor.loadSessionEntry({ ...scope, readConsistency: "latest" });
243
+ if (!current) throw new Error("proof session row missing");
244
+ const rebound = structuredClone(current);
245
+ cliSession.setCliSessionBinding(rebound, "proof-cli", { sessionId: process.env.PR135168_NEWER_CLI_SESSION_ID, forceReuse: true });
246
+ if (!await accessor.replaceSessionEntry(scope, rebound)) throw new Error("proof rebind failed");
247
+ `,
248
+ ),
249
+ ]);
250
+ }
src/cli/argv-invocation.test.ts ADDED
@@ -0,0 +1,196 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Argv invocation tests cover CLI argv normalization before command dispatch.
2
+ import { Command } from "commander";
3
+ import { describe, expect, it } from "vitest";
4
+ import { resolveCliArgvInvocation } from "./argv-invocation.js";
5
+ import { getCommanderCommandPath } from "./program/commander-parse-facts.js";
6
+
7
+ describe("argv-invocation", () => {
8
+ it("resolves root help and empty command path", () => {
9
+ expect(resolveCliArgvInvocation(["node", "openclaw", "--help"])).toEqual({
10
+ argv: ["node", "openclaw", "--help"],
11
+ commandPath: [],
12
+ primary: null,
13
+ hasHelpOrVersion: true,
14
+ isRootHelpInvocation: true,
15
+ });
16
+ });
17
+
18
+ it("resolves command path and primary with root options", () => {
19
+ expect(
20
+ resolveCliArgvInvocation(["node", "openclaw", "--profile", "work", "gateway", "status"]),
21
+ ).toEqual({
22
+ argv: ["node", "openclaw", "--profile", "work", "gateway", "status"],
23
+ commandPath: ["gateway", "status"],
24
+ primary: "gateway",
25
+ hasHelpOrVersion: false,
26
+ isRootHelpInvocation: false,
27
+ });
28
+ });
29
+
30
+ it.each([
31
+ {
32
+ name: "version-pinned install",
33
+ argv: ["node", "openclaw", "skills", "install", "@owner/weather", "--version", "1.2.3"],
34
+ commandPath: ["skills", "install"],
35
+ },
36
+ {
37
+ name: "version-pinned verification",
38
+ argv: ["node", "openclaw", "skills", "verify", "@owner/weather", "--version", "1.2.3"],
39
+ commandPath: ["skills", "verify"],
40
+ },
41
+ {
42
+ name: "equals-form version-pinned install",
43
+ argv: ["node", "openclaw", "skills", "install", "@owner/weather", "--version=1.2.3"],
44
+ commandPath: ["skills", "install"],
45
+ },
46
+ {
47
+ name: "profiled version-pinned verification",
48
+ argv: [
49
+ "node",
50
+ "openclaw",
51
+ "--profile",
52
+ "work",
53
+ "skills",
54
+ "verify",
55
+ "@owner/weather",
56
+ "--version",
57
+ "1.2.3",
58
+ ],
59
+ commandPath: ["skills", "verify"],
60
+ },
61
+ ])("keeps $name in command execution mode", ({ argv, commandPath }) => {
62
+ expect(resolveCliArgvInvocation(argv)).toEqual({
63
+ argv,
64
+ commandPath,
65
+ primary: "skills",
66
+ hasHelpOrVersion: false,
67
+ isRootHelpInvocation: false,
68
+ });
69
+ });
70
+
71
+ it("consumes agent parent option values before the exec subcommand", () => {
72
+ expect(
73
+ resolveCliArgvInvocation([
74
+ "node",
75
+ "openclaw",
76
+ "agent",
77
+ "--model",
78
+ "openai/gpt-5.6-sol",
79
+ "exec",
80
+ "fix it",
81
+ ]).commandPath,
82
+ ).toEqual(["agent", "exec"]);
83
+ });
84
+
85
+ it("does not treat an exec-valued parent option as the subcommand", () => {
86
+ expect(
87
+ resolveCliArgvInvocation(["node", "openclaw", "agent", "--message", "exec"]).commandPath,
88
+ ).toEqual(["agent"]);
89
+ });
90
+
91
+ it("consumes root options between the agent parent and exec", () => {
92
+ expect(
93
+ resolveCliArgvInvocation([
94
+ "node",
95
+ "openclaw",
96
+ "agent",
97
+ "--no-color",
98
+ "--model",
99
+ "openai/gpt-5.6-sol",
100
+ "exec",
101
+ "fix it",
102
+ ]).commandPath,
103
+ ).toEqual(["agent", "exec"]);
104
+ });
105
+
106
+ it.each([
107
+ ["separate agent value", ["models", "--agent", "main", "--status-json"]],
108
+ ["inline agent value", ["models", "--agent=main", "--status-json"]],
109
+ ["status alias before agent", ["models", "--status-json", "--agent", "main"]],
110
+ ])("keeps models parent status options on the parent path: %s", (_name, args) => {
111
+ expect(resolveCliArgvInvocation(["node", "openclaw", ...args]).commandPath).toEqual(["models"]);
112
+ });
113
+
114
+ it("still resolves a models child after parent options", () => {
115
+ expect(
116
+ resolveCliArgvInvocation(["node", "openclaw", "models", "--agent", "main", "status"])
117
+ .commandPath,
118
+ ).toEqual(["models", "status"]);
119
+ });
120
+
121
+ it.each([
122
+ ["config", ["--section", "model"], ["config"]],
123
+ ["config", ["--section", "get"], ["config"]],
124
+ ["config", ["--section=model", "get"], ["config", "get"]],
125
+ ["config", ["--", "get"], ["config", "get"]],
126
+ ["skills", ["--agent", "main", "verify"], ["skills", "verify"]],
127
+ ["skills", ["--agent=main", "verify"], ["skills", "verify"]],
128
+ ["skills", ["--agent", "verify"], ["skills"]],
129
+ ["skills", ["--json", "--agent", "main", "verify"], ["skills", "verify"]],
130
+ ["skills", ["--", "verify"], ["skills", "verify"]],
131
+ ])("matches Commander for %s %j", async (rootName, args, expectedPath) => {
132
+ const program = new Command().name("openclaw").enablePositionalOptions();
133
+ const root = program.command(rootName);
134
+ if (rootName === "config") {
135
+ root.option("--section <section>");
136
+ } else {
137
+ root.option("--agent <id>").option("--json");
138
+ }
139
+ const child = root.command(rootName === "config" ? "get" : "verify");
140
+ let parsedPath: string[] = [];
141
+ for (const command of [root, child]) {
142
+ command.action(() => {
143
+ parsedPath = getCommanderCommandPath(command);
144
+ });
145
+ }
146
+ const argv = ["node", "openclaw", rootName, ...args];
147
+
148
+ await program.parseAsync(argv);
149
+
150
+ expect(parsedPath).toEqual(expectedPath);
151
+ expect(resolveCliArgvInvocation(argv).commandPath).toEqual(parsedPath);
152
+ });
153
+
154
+ it.each(["cleanup", "status", "repair", "finalize", "wizard"])(
155
+ "resolves update %s after parent options and interleaved root options",
156
+ (child) => {
157
+ for (const args of [
158
+ ["--channel", "beta", "--tag", "latest", "--timeout", "5", child],
159
+ ["--channel=beta", "--no-color", "--timeout=5", "--yes", child],
160
+ ["--", child],
161
+ ]) {
162
+ expect(
163
+ resolveCliArgvInvocation(["node", "openclaw", "--profile", "work", "update", ...args])
164
+ .commandPath,
165
+ ).toEqual(["update", child]);
166
+ }
167
+ },
168
+ );
169
+
170
+ it.each(["--channel", "--tag", "--timeout"])(
171
+ "does not mistake a cleanup-valued %s for a child command",
172
+ (flag) => {
173
+ for (const args of [[flag, "cleanup"], [`${flag}=cleanup`], [flag]]) {
174
+ expect(
175
+ resolveCliArgvInvocation(["node", "openclaw", "update", ...args]).commandPath,
176
+ ).toEqual(["update"]);
177
+ }
178
+ },
179
+ );
180
+
181
+ it.each([
182
+ ["update", "--channel=beta", "cleanup", "--help"],
183
+ ["update", "--help", "cleanup"],
184
+ ["help", "update", "cleanup"],
185
+ ])("recognizes update help without promoting scoped version flags: %j", (...args) => {
186
+ expect(resolveCliArgvInvocation(["node", "openclaw", ...args]).hasHelpOrVersion).toBe(true);
187
+ });
188
+
189
+ it.each([
190
+ ["update", "cleanup", "--version"],
191
+ ["update", "cleanup", "--", "--help"],
192
+ ["update", "--channel=--help", "cleanup"],
193
+ ])("leaves scoped or literal help/version tokens to Commander: %j", (...args) => {
194
+ expect(resolveCliArgvInvocation(["node", "openclaw", ...args]).hasHelpOrVersion).toBe(false);
195
+ });
196
+ });
src/cli/argv-invocation.ts ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Normalized argv invocation summary used before Commander command dispatch.
2
+ import {
3
+ getCommandPathWithRootOptions,
4
+ getPrimaryCommand,
5
+ isHelpOrVersionInvocation,
6
+ isRootHelpInvocation,
7
+ } from "./argv.js";
8
+ import { resolveGatewayCatalogCommandPath } from "./gateway-run-argv.js";
9
+ import { resolveCliParentCommandPath } from "./parent-command-path.js";
10
+
11
+ type CliArgvInvocation = {
12
+ argv: string[];
13
+ commandPath: string[];
14
+ primary: string | null;
15
+ hasHelpOrVersion: boolean;
16
+ isRootHelpInvocation: boolean;
17
+ };
18
+
19
+ /** Resolves command path and help/version mode from a raw process argv array. */
20
+ export function resolveCliArgvInvocation(argv: string[]): CliArgvInvocation {
21
+ return {
22
+ argv,
23
+ commandPath:
24
+ resolveGatewayCatalogCommandPath(argv) ??
25
+ resolveCliParentCommandPath(argv) ??
26
+ getCommandPathWithRootOptions(argv, 2),
27
+ primary: getPrimaryCommand(argv),
28
+ hasHelpOrVersion: isHelpOrVersionInvocation(argv),
29
+ isRootHelpInvocation: isRootHelpInvocation(argv),
30
+ };
31
+ }
src/cli/argv.test.ts ADDED
@@ -0,0 +1,583 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Argv tests cover CLI argument parsing helpers and platform-specific normalization.
2
+ import { Command } from "commander";
3
+ import { describe, expect, it } from "vitest";
4
+ import {
5
+ buildParseArgv,
6
+ getFlagValue,
7
+ getCommandPositionalsWithRootOptions,
8
+ getCommandPathWithRootOptions,
9
+ getPrimaryCommand,
10
+ getPositiveIntFlagValue,
11
+ getVerboseFlag,
12
+ hasFlag,
13
+ isHelpOrVersionInvocation,
14
+ isRootHelpInvocation,
15
+ isRootVersionInvocation,
16
+ isSimpleCommandHelpInvocation,
17
+ normalizeGeneratedHelpCommandArgv,
18
+ normalizeRootHelpTargetArgv,
19
+ normalizeRootLogLevelArgv,
20
+ normalizeRootNoColorArgv,
21
+ } from "./argv.js";
22
+
23
+ describe("argv helpers", () => {
24
+ it.each([
25
+ [
26
+ "known command group help command help flag",
27
+ ["node", "openclaw", "backup", "help", "--help"],
28
+ ["node", "openclaw", "backup", "help"],
29
+ ],
30
+ [
31
+ "known command group help command short help flag",
32
+ ["node", "openclaw", "--profile", "work", "backup", "help", "-h"],
33
+ ["node", "openclaw", "--profile", "work", "backup", "help"],
34
+ ],
35
+ [
36
+ "leaf positional help remains untouched",
37
+ ["node", "openclaw", "docs", "help", "--help"],
38
+ ["node", "openclaw", "docs", "help", "--help"],
39
+ ],
40
+ [
41
+ "known command group help target",
42
+ ["node", "openclaw", "plugins", "help", "list"],
43
+ ["node", "openclaw", "plugins", "list", "--help"],
44
+ ],
45
+ [
46
+ "known command group help target help flag",
47
+ ["node", "openclaw", "plugins", "help", "list", "--help"],
48
+ ["node", "openclaw", "plugins", "list", "--help"],
49
+ ],
50
+ [
51
+ "unknown plugin command group help target",
52
+ ["node", "openclaw", "external-plugin", "help", "inspect"],
53
+ ["node", "openclaw", "external-plugin", "inspect", "--help"],
54
+ ],
55
+ [
56
+ "unknown plugin command group help target help flag",
57
+ ["node", "openclaw", "external-plugin", "help", "inspect", "--help"],
58
+ ["node", "openclaw", "external-plugin", "inspect", "--help"],
59
+ ],
60
+ [
61
+ "generated help target with trailing root option",
62
+ ["node", "openclaw", "memory", "help", "status", "--no-color"],
63
+ ["node", "openclaw", "--no-color", "memory", "status", "--help"],
64
+ ],
65
+ [
66
+ "extra help positionals remain untouched",
67
+ ["node", "openclaw", "backup", "help", "missing", "extra", "--help"],
68
+ ["node", "openclaw", "backup", "help", "missing", "extra", "--help"],
69
+ ],
70
+ [
71
+ "terminator help flag remains untouched",
72
+ ["node", "openclaw", "backup", "help", "--", "--help"],
73
+ ["node", "openclaw", "backup", "help", "--", "--help"],
74
+ ],
75
+ ])("normalizes generated help commands: %s", (_name, argv, expected) => {
76
+ expect(normalizeGeneratedHelpCommandArgv(argv)).toEqual(expected);
77
+ });
78
+
79
+ it.each([
80
+ [
81
+ "root help target",
82
+ ["node", "openclaw", "help", "plugins"],
83
+ ["node", "openclaw", "plugins", "--help"],
84
+ ],
85
+ [
86
+ "root help target with help flag",
87
+ ["node", "openclaw", "help", "plugins", "--help"],
88
+ ["node", "openclaw", "plugins", "--help"],
89
+ ],
90
+ [
91
+ "root option before help target",
92
+ ["node", "openclaw", "--profile", "work", "help", "memory"],
93
+ ["node", "openclaw", "--profile", "work", "memory", "--help"],
94
+ ],
95
+ [
96
+ "bare root help remains untouched",
97
+ ["node", "openclaw", "help"],
98
+ ["node", "openclaw", "help"],
99
+ ],
100
+ [
101
+ "root help self-help remains untouched",
102
+ ["node", "openclaw", "help", "--help"],
103
+ ["node", "openclaw", "help", "--help"],
104
+ ],
105
+ [
106
+ "nested root help target",
107
+ ["node", "openclaw", "help", "plugins", "list"],
108
+ ["node", "openclaw", "plugins", "list", "--help"],
109
+ ],
110
+ [
111
+ "nested root help target with help flag",
112
+ ["node", "openclaw", "help", "plugins", "list", "--help"],
113
+ ["node", "openclaw", "plugins", "list", "--help"],
114
+ ],
115
+ [
116
+ "nested root help target with trailing root option",
117
+ ["node", "openclaw", "help", "memory", "status", "--no-color"],
118
+ ["node", "openclaw", "--no-color", "memory", "status", "--help"],
119
+ ],
120
+ ])("normalizes root help targets: %s", (_name, argv, expected) => {
121
+ expect(normalizeRootHelpTargetArgv(argv)).toEqual(expected);
122
+ });
123
+
124
+ it.each([
125
+ [
126
+ "subcommand trailing no-color",
127
+ ["node", "openclaw", "doctor", "--no-color", "--post-upgrade", "--json"],
128
+ ["node", "openclaw", "--no-color", "doctor", "--post-upgrade", "--json"],
129
+ ],
130
+ [
131
+ "keeps existing root options first",
132
+ ["node", "openclaw", "--profile", "work", "doctor", "--no-color", "--lint", "--json"],
133
+ ["node", "openclaw", "--profile", "work", "--no-color", "doctor", "--lint", "--json"],
134
+ ],
135
+ [
136
+ "keeps no-color after possible command option value",
137
+ ["node", "openclaw", "doctor", "--lint", "--json", "--no-color"],
138
+ ["node", "openclaw", "doctor", "--lint", "--json", "--no-color"],
139
+ ],
140
+ [
141
+ "flag terminator leaves no-color positional",
142
+ ["node", "openclaw", "doctor", "--", "--no-color"],
143
+ ["node", "openclaw", "doctor", "--", "--no-color"],
144
+ ],
145
+ [
146
+ "command option value remains literal",
147
+ ["node", "openclaw", "agent", "--message", "--no-color"],
148
+ ["node", "openclaw", "agent", "--message", "--no-color"],
149
+ ],
150
+ [
151
+ "assigned command option value does not block no-color",
152
+ ["node", "openclaw", "agent", "--message=hello", "--no-color"],
153
+ ["node", "openclaw", "--no-color", "agent", "--message=hello"],
154
+ ],
155
+ ])("normalizes root --no-color before command parsing: %s", (_name, argv, expected) => {
156
+ expect(normalizeRootNoColorArgv(argv)).toEqual(expected);
157
+ });
158
+
159
+ it("allows final command metadata to lift no-color after boolean command flags", () => {
160
+ const argv = ["node", "openclaw", "doctor", "--lint", "--json", "--no-color"];
161
+
162
+ expect(
163
+ normalizeRootNoColorArgv(argv, {
164
+ shouldPreserveNoColor: ({ remainingArgs, noColorIndex }) =>
165
+ remainingArgs[noColorIndex - 1] === "--message",
166
+ }),
167
+ ).toEqual(["node", "openclaw", "--no-color", "doctor", "--lint", "--json"]);
168
+ });
169
+
170
+ it.each([
171
+ [
172
+ "subcommand trailing log-level",
173
+ ["node", "openclaw", "doctor", "--log-level", "debug", "--json"],
174
+ ["node", "openclaw", "--log-level", "debug", "doctor", "--json"],
175
+ ],
176
+ [
177
+ "subcommand trailing log-level equals form",
178
+ ["node", "openclaw", "doctor", "--log-level=trace", "--json"],
179
+ ["node", "openclaw", "--log-level=trace", "doctor", "--json"],
180
+ ],
181
+ [
182
+ "keeps existing root options first",
183
+ ["node", "openclaw", "--profile", "work", "doctor", "--log-level", "debug"],
184
+ ["node", "openclaw", "--profile", "work", "--log-level", "debug", "doctor"],
185
+ ],
186
+ [
187
+ "keeps log-level after possible command option value",
188
+ ["node", "openclaw", "agent", "--message", "--log-level", "debug"],
189
+ ["node", "openclaw", "agent", "--message", "--log-level", "debug"],
190
+ ],
191
+ [
192
+ "flag terminator leaves log-level positional",
193
+ ["node", "openclaw", "nodes", "run", "--", "--log-level", "debug"],
194
+ ["node", "openclaw", "nodes", "run", "--", "--log-level", "debug"],
195
+ ],
196
+ [
197
+ "missing value remains command scoped",
198
+ ["node", "openclaw", "doctor", "--log-level", "--json"],
199
+ ["node", "openclaw", "doctor", "--log-level", "--json"],
200
+ ],
201
+ ])("normalizes root --log-level before command parsing: %s", (_name, argv, expected) => {
202
+ expect(normalizeRootLogLevelArgv(argv)).toEqual(expected);
203
+ });
204
+
205
+ it("allows final command metadata to lift log-level after boolean command flags", () => {
206
+ const argv = ["node", "openclaw", "doctor", "--lint", "--json", "--log-level", "debug"];
207
+
208
+ expect(
209
+ normalizeRootLogLevelArgv(argv, {
210
+ shouldPreserveLogLevel: ({ remainingArgs, logLevelIndex }) =>
211
+ remainingArgs[logLevelIndex - 1] === "--message",
212
+ }),
213
+ ).toEqual(["node", "openclaw", "--log-level", "debug", "doctor", "--lint", "--json"]);
214
+ });
215
+
216
+ it("preserves log-level when final command metadata owns the option", () => {
217
+ const argv = ["node", "openclaw", "plugin-cmd", "--log-level", "debug"];
218
+
219
+ expect(
220
+ normalizeRootLogLevelArgv(argv, {
221
+ shouldPreserveLogLevel: ({ remainingArgs, logLevelIndex }) =>
222
+ remainingArgs[logLevelIndex] === "--log-level",
223
+ }),
224
+ ).toEqual(argv);
225
+ });
226
+
227
+ it.each([
228
+ ["root help command", ["node", "openclaw", "help"], true],
229
+ ["root help command with target", ["node", "openclaw", "help", "matrix"], true],
230
+ ["nested help command", ["node", "openclaw", "matrix", "encryption", "help"], true],
231
+ ["known subcommand root help command", ["node", "openclaw", "config", "help"], true],
232
+ ["known leaf command positional help", ["node", "openclaw", "docs", "help"], false],
233
+ [
234
+ "known subcommand leaf positional help",
235
+ ["node", "openclaw", "config", "set", "some.path", "help"],
236
+ false,
237
+ ],
238
+ ["unknown plugin command help", ["node", "openclaw", "external-plugin", "tools", "help"], true],
239
+ ["help flag", ["node", "openclaw", "matrix", "encryption", "--help"], true],
240
+ ["help as option value", ["node", "openclaw", "agent", "--message", "help"], false],
241
+ ["help after terminator", ["node", "openclaw", "nodes", "invoke", "--", "help"], false],
242
+ [
243
+ "implicit root help command after terminator",
244
+ ["node", "openclaw", "--", "help", "config"],
245
+ true,
246
+ ],
247
+ [
248
+ "implicit parent help command after terminator",
249
+ ["node", "openclaw", "config", "--", "help"],
250
+ true,
251
+ ],
252
+ ["literal root help-looking command", ["node", "openclaw", "--", "--help"], false],
253
+ ["literal parent help-looking command", ["node", "openclaw", "--", "config", "--help"], false],
254
+ ["help flag after terminator", ["node", "openclaw", "nodes", "invoke", "--", "--help"], false],
255
+ [
256
+ "version flag after terminator",
257
+ ["node", "openclaw", "nodes", "invoke", "--", "--version"],
258
+ false,
259
+ ],
260
+ ["root version flag", ["node", "openclaw", "--version"], true],
261
+ ["root short version flag", ["node", "openclaw", "-V"], true],
262
+ ["root version alias after profile", ["node", "openclaw", "--profile", "work", "-v"], true],
263
+ [
264
+ "root version flag after profile",
265
+ ["node", "openclaw", "--profile", "work", "--version"],
266
+ true,
267
+ ],
268
+ [
269
+ "version-pinned skill install",
270
+ ["node", "openclaw", "skills", "install", "@owner/weather", "--version", "1.2.3"],
271
+ false,
272
+ ],
273
+ [
274
+ "version-pinned skill verification",
275
+ ["node", "openclaw", "skills", "verify", "@owner/weather", "--version", "1.2.3"],
276
+ false,
277
+ ],
278
+ [
279
+ "equals-form version-pinned skill install",
280
+ ["node", "openclaw", "skills", "install", "@owner/weather", "--version=1.2.3"],
281
+ false,
282
+ ],
283
+ [
284
+ "profiled version-pinned skill verification",
285
+ [
286
+ "node",
287
+ "openclaw",
288
+ "--profile",
289
+ "work",
290
+ "skills",
291
+ "verify",
292
+ "@owner/weather",
293
+ "--version",
294
+ "1.2.3",
295
+ ],
296
+ false,
297
+ ],
298
+ [
299
+ "help for a version-pinned skill command",
300
+ ["node", "openclaw", "skills", "verify", "@owner/weather", "--version", "1.2.3", "--help"],
301
+ true,
302
+ ],
303
+ [
304
+ "unknown root option does not turn version into root help",
305
+ ["node", "openclaw", "--unknown", "--version"],
306
+ false,
307
+ ],
308
+ ])("detects help/version invocations: %s", (_name, argv, expected) => {
309
+ expect(isHelpOrVersionInvocation(argv)).toBe(expected);
310
+ });
311
+
312
+ it.each([
313
+ { path: ["skills", "verify"], option: "tag" },
314
+ { path: ["skills", "verify"], option: "version" },
315
+ { path: ["models", "list"], option: "provider" },
316
+ { path: ["agent"], option: "message" },
317
+ ])("keeps actual help after a root-looking $option value on $path", async ({ path, option }) => {
318
+ const program = new Command()
319
+ .name("openclaw")
320
+ .enablePositionalOptions()
321
+ .option("--log-level <level>")
322
+ .exitOverride();
323
+ program.configureOutput({ writeOut: () => {}, writeErr: () => {} });
324
+ const leaf = path.reduce((parent, name) => parent.command(name), program);
325
+ leaf.option(`--${option} <value>`);
326
+ const argv = ["node", "openclaw", ...path, `--${option}`, "--log-level", "--help"];
327
+
328
+ await expect(program.parseAsync(argv)).rejects.toMatchObject({
329
+ code: "commander.helpDisplayed",
330
+ exitCode: 0,
331
+ });
332
+ expect(leaf.opts()[option]).toBe("--log-level");
333
+ expect(isHelpOrVersionInvocation(argv)).toBe(true);
334
+ });
335
+
336
+ it.each([
337
+ ["root --version", ["node", "openclaw", "--version"], true],
338
+ ["root -V", ["node", "openclaw", "-V"], true],
339
+ ["root -v alias with profile", ["node", "openclaw", "--profile", "work", "-v"], true],
340
+ ["subcommand version flag", ["node", "openclaw", "status", "--version"], false],
341
+ ["unknown root flag with version", ["node", "openclaw", "--unknown", "--version"], false],
342
+ ])("detects root-only version invocations: %s", (_name, argv, expected) => {
343
+ expect(isRootVersionInvocation(argv)).toBe(expected);
344
+ });
345
+
346
+ it.each([
347
+ ["root --help", ["node", "openclaw", "--help"], true],
348
+ ["root -h", ["node", "openclaw", "-h"], true],
349
+ ["root --help with profile", ["node", "openclaw", "--profile", "work", "--help"], true],
350
+ ["subcommand --help", ["node", "openclaw", "status", "--help"], false],
351
+ ["help before subcommand token", ["node", "openclaw", "--help", "status"], false],
352
+ [
353
+ "help after -- terminator",
354
+ ["node", "openclaw", "nodes", "invoke", "--", "device.status", "--help"],
355
+ false,
356
+ ],
357
+ ["unknown root flag before help", ["node", "openclaw", "--unknown", "--help"], false],
358
+ ["unknown root flag after help", ["node", "openclaw", "--help", "--unknown"], false],
359
+ ])("detects root-only help invocations: %s", (_name, argv, expected) => {
360
+ expect(isRootHelpInvocation(argv)).toBe(expected);
361
+ });
362
+
363
+ it.each([
364
+ ["single command with trailing flag", ["node", "openclaw", "status", "--json"], ["status"]],
365
+ ["two-part command", ["node", "openclaw", "agents", "list"], ["agents", "list"]],
366
+ ["terminator cuts parsing", ["node", "openclaw", "status", "--", "ignored"], ["status"]],
367
+ ])("extracts command path: %s", (_name, argv, expected) => {
368
+ expect(getCommandPathWithRootOptions(argv, 2)).toEqual(expected);
369
+ });
370
+
371
+ it("extracts command path while skipping known root option values", () => {
372
+ expect(
373
+ getCommandPathWithRootOptions(
374
+ [
375
+ "node",
376
+ "openclaw",
377
+ "--profile",
378
+ "work",
379
+ "--container",
380
+ "demo",
381
+ "--no-color",
382
+ "config",
383
+ "validate",
384
+ ],
385
+ 2,
386
+ ),
387
+ ).toEqual(["config", "validate"]);
388
+ });
389
+
390
+ it("limits simple help fast paths to root options, a command, and help", () => {
391
+ const commands = new Set(["setup"]);
392
+ expect(
393
+ isSimpleCommandHelpInvocation(
394
+ ["node", "openclaw", "--profile", "work", "setup", "--help"],
395
+ commands,
396
+ ),
397
+ ).toBe(true);
398
+ expect(
399
+ isSimpleCommandHelpInvocation(
400
+ ["node", "openclaw", "setup", "--workspace", "--help"],
401
+ commands,
402
+ ),
403
+ ).toBe(false);
404
+ expect(
405
+ isSimpleCommandHelpInvocation(
406
+ ["node", "openclaw", "setup", "--profile", "work", "--help"],
407
+ commands,
408
+ ),
409
+ ).toBe(false);
410
+ expect(isSimpleCommandHelpInvocation(["node", "openclaw", "--help", "setup"], commands)).toBe(
411
+ false,
412
+ );
413
+ });
414
+
415
+ it("extracts routed config get positionals with interleaved root options", () => {
416
+ expect(
417
+ getCommandPositionalsWithRootOptions(
418
+ ["node", "openclaw", "config", "get", "--log-level", "debug", "update.channel", "--json"],
419
+ {
420
+ commandPath: ["config", "get"],
421
+ booleanFlags: ["--json"],
422
+ },
423
+ ),
424
+ ).toEqual(["update.channel"]);
425
+ });
426
+
427
+ it("extracts routed config unset positionals with interleaved root options", () => {
428
+ expect(
429
+ getCommandPositionalsWithRootOptions(
430
+ ["node", "openclaw", "config", "unset", "--profile", "work", "update.channel"],
431
+ {
432
+ commandPath: ["config", "unset"],
433
+ },
434
+ ),
435
+ ).toEqual(["update.channel"]);
436
+ });
437
+
438
+ it("returns null when routed command sees unknown options", () => {
439
+ expect(
440
+ getCommandPositionalsWithRootOptions(
441
+ ["node", "openclaw", "config", "get", "--mystery", "value", "update.channel"],
442
+ {
443
+ commandPath: ["config", "get"],
444
+ booleanFlags: ["--json"],
445
+ },
446
+ ),
447
+ ).toBeNull();
448
+ });
449
+
450
+ it.each([
451
+ ["returns first command token", ["node", "openclaw", "agents", "list"], "agents"],
452
+ ["returns null when no command exists", ["node", "openclaw"], null],
453
+ [
454
+ "skips known root option values",
455
+ ["node", "openclaw", "--log-level", "debug", "status"],
456
+ "status",
457
+ ],
458
+ ])("returns primary command: %s", (_name, argv, expected) => {
459
+ expect(getPrimaryCommand(argv)).toBe(expected);
460
+ });
461
+
462
+ it.each([
463
+ ["detects flag before terminator", ["node", "openclaw", "status", "--json"], "--json", true],
464
+ ["ignores flag after terminator", ["node", "openclaw", "--", "--json"], "--json", false],
465
+ ])("parses boolean flags: %s", (_name, argv, flag, expected) => {
466
+ expect(hasFlag(argv, flag)).toBe(expected);
467
+ });
468
+
469
+ it.each([
470
+ ["value in next token", ["node", "openclaw", "status", "--timeout", "5000"], "5000"],
471
+ ["value in equals form", ["node", "openclaw", "status", "--timeout=2500"], "2500"],
472
+ ["missing value", ["node", "openclaw", "status", "--timeout"], null],
473
+ ["next token is another flag", ["node", "openclaw", "status", "--timeout", "--json"], null],
474
+ ["flag appears after terminator", ["node", "openclaw", "--", "--timeout=99"], undefined],
475
+ [
476
+ "repeated flag uses final value",
477
+ ["node", "openclaw", "status", "--timeout", "100", "--timeout=200"],
478
+ "200",
479
+ ],
480
+ [
481
+ "missing repeated value remains invalid",
482
+ ["node", "openclaw", "status", "--timeout", "--timeout", "200"],
483
+ null,
484
+ ],
485
+ ])("extracts flag values: %s", (_name, argv, expected) => {
486
+ expect(getFlagValue(argv, "--timeout")).toBe(expected);
487
+ });
488
+
489
+ it("parses verbose flags", () => {
490
+ expect(getVerboseFlag(["node", "openclaw", "status", "--verbose"])).toBe(true);
491
+ expect(getVerboseFlag(["node", "openclaw", "status", "--debug"])).toBe(false);
492
+ expect(getVerboseFlag(["node", "openclaw", "status", "--debug"], { includeDebug: true })).toBe(
493
+ true,
494
+ );
495
+ });
496
+
497
+ it.each([
498
+ ["missing flag", ["node", "openclaw", "status"], undefined],
499
+ ["missing value", ["node", "openclaw", "status", "--timeout"], null],
500
+ ["valid positive integer", ["node", "openclaw", "status", "--timeout", "5000"], 5000],
501
+ [
502
+ "valid signed decimal positive integer",
503
+ ["node", "openclaw", "status", "--timeout", "+5000"],
504
+ 5000,
505
+ ],
506
+ ["invalid integer", ["node", "openclaw", "status", "--timeout", "nope"], null],
507
+ ["non-decimal integer", ["node", "openclaw", "status", "--timeout", "0x10"], null],
508
+ ["partial integer", ["node", "openclaw", "status", "--timeout", "5s"], null],
509
+ ["zero", ["node", "openclaw", "status", "--timeout", "0"], null],
510
+ ["negative integer", ["node", "openclaw", "status", "--timeout", "-5"], null],
511
+ [
512
+ "repeated value uses final valid integer",
513
+ ["node", "openclaw", "status", "--timeout", "nope", "--timeout", "5000"],
514
+ 5000,
515
+ ],
516
+ [
517
+ "repeated value rejects final invalid integer",
518
+ ["node", "openclaw", "status", "--timeout", "5000", "--timeout", "nope"],
519
+ null,
520
+ ],
521
+ ])("parses positive integer flag values: %s", (_name, argv, expected) => {
522
+ expect(getPositiveIntFlagValue(argv, "--timeout")).toBe(expected);
523
+ });
524
+
525
+ it.each([
526
+ ["keeps plain node argv", ["node", "openclaw", "status"], ["node", "openclaw", "status"]],
527
+ [
528
+ "keeps version-suffixed node binary",
529
+ ["node-22", "openclaw", "status"],
530
+ ["node-22", "openclaw", "status"],
531
+ ],
532
+ [
533
+ "keeps windows versioned node exe",
534
+ ["node-22.2.0.exe", "openclaw", "status"],
535
+ ["node-22.2.0.exe", "openclaw", "status"],
536
+ ],
537
+ [
538
+ "keeps dotted node binary",
539
+ ["node-22.2", "openclaw", "status"],
540
+ ["node-22.2", "openclaw", "status"],
541
+ ],
542
+ [
543
+ "keeps dotted node exe",
544
+ ["node-22.2.exe", "openclaw", "status"],
545
+ ["node-22.2.exe", "openclaw", "status"],
546
+ ],
547
+ [
548
+ "keeps absolute versioned node path",
549
+ ["/usr/bin/node-22.2.0", "openclaw", "status"],
550
+ ["/usr/bin/node-22.2.0", "openclaw", "status"],
551
+ ],
552
+ ["keeps node24 shorthand", ["node24", "openclaw", "status"], ["node24", "openclaw", "status"]],
553
+ [
554
+ "keeps absolute node24 shorthand",
555
+ ["/usr/bin/node24", "openclaw", "status"],
556
+ ["/usr/bin/node24", "openclaw", "status"],
557
+ ],
558
+ [
559
+ "keeps windows node24 exe",
560
+ ["node24.exe", "openclaw", "status"],
561
+ ["node24.exe", "openclaw", "status"],
562
+ ],
563
+ ["keeps nodejs binary", ["nodejs", "openclaw", "status"], ["nodejs", "openclaw", "status"]],
564
+ [
565
+ "prefixes fallback when first arg is not a node launcher",
566
+ ["node-dev", "openclaw", "status"],
567
+ ["node", "openclaw", "node-dev", "openclaw", "status"],
568
+ ],
569
+ [
570
+ "prefixes fallback when raw args start at program name",
571
+ ["openclaw", "status"],
572
+ ["node", "openclaw", "status"],
573
+ ],
574
+ [
575
+ "keeps bun execution argv",
576
+ ["bun", "src/entry.ts", "status"],
577
+ ["bun", "src/entry.ts", "status"],
578
+ ],
579
+ ] as const)("builds parse argv from raw args: %s", (_name, rawArgs, expected) => {
580
+ const parsed = buildParseArgv([...rawArgs]);
581
+ expect(parsed).toEqual([...expected]);
582
+ });
583
+ });
src/cli/argv.ts ADDED
@@ -0,0 +1,478 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { parseStrictPositiveInteger } from "@openclaw/normalization-core/number-coercion";
2
+ // Low-level CLI argv helpers for root options, help/version detection, and command paths.
3
+ import { isExperimentalClawsEnabled } from "../claws/experimental.js";
4
+ import { isBunRuntime, isNodeRuntime } from "../daemon/runtime-binary.js";
5
+ import {
6
+ consumeRootOptionToken,
7
+ FLAG_TERMINATOR,
8
+ getRootOptionAwareCommandPath,
9
+ isValueToken,
10
+ } from "../infra/cli-root-options.js";
11
+ import { CORE_CLI_COMMAND_DESCRIPTORS } from "./program/core-command-descriptors.js";
12
+ import { SUB_CLI_DESCRIPTORS } from "./program/subcli-descriptors.js";
13
+
14
+ const HELP_FLAGS = new Set(["-h", "--help"]);
15
+ const ROOT_VERSION_ALIAS_FLAGS = new Set(["-v"]);
16
+ const VERSION_FLAGS = new Set(["-V", "--version", ...ROOT_VERSION_ALIAS_FLAGS]);
17
+ const ROOT_COMMAND_DESCRIPTORS = [
18
+ ...CORE_CLI_COMMAND_DESCRIPTORS.filter(
19
+ (descriptor) => descriptor.name !== "claws" || isExperimentalClawsEnabled(),
20
+ ),
21
+ ...SUB_CLI_DESCRIPTORS,
22
+ ];
23
+ const KNOWN_ROOT_COMMANDS: ReadonlySet<string> = new Set(
24
+ ROOT_COMMAND_DESCRIPTORS.map((descriptor) => descriptor.name),
25
+ );
26
+ const ROOT_COMMANDS_WITH_SUBCOMMANDS: ReadonlySet<string> = new Set(
27
+ ROOT_COMMAND_DESCRIPTORS.filter((descriptor) => descriptor.hasSubcommands).map(
28
+ (descriptor) => descriptor.name,
29
+ ),
30
+ );
31
+
32
+ export function isHelpOrVersionInvocation(argv: string[]): boolean {
33
+ if (isRootVersionInvocation(argv)) {
34
+ return true;
35
+ }
36
+
37
+ const args = argv.slice(2);
38
+ let sawCommandOption = false;
39
+ let literal = false;
40
+ const positionals: string[] = [];
41
+ for (let i = 0; i < args.length; i += 1) {
42
+ const arg = args[i];
43
+ if (!arg) {
44
+ break;
45
+ }
46
+ if (!literal && arg === FLAG_TERMINATOR) {
47
+ literal = true;
48
+ continue;
49
+ }
50
+ // Command option roles are not registered yet; keep possible help flags visible.
51
+ const rootConsumed = literal ? 0 : consumeRootOptionToken(args, i);
52
+ if (rootConsumed > 0) {
53
+ i += rootConsumed - 1;
54
+ continue;
55
+ }
56
+ if (!literal && HELP_FLAGS.has(arg)) {
57
+ return true;
58
+ }
59
+ if (!literal && arg.startsWith("-")) {
60
+ sawCommandOption = true;
61
+ continue;
62
+ }
63
+ positionals.push(arg);
64
+ if (arg !== "help") {
65
+ continue;
66
+ }
67
+ if (sawCommandOption) {
68
+ return false;
69
+ }
70
+ if (positionals.length === 1) {
71
+ return true;
72
+ }
73
+ const [primary] = positionals;
74
+ // Positional `help` may be a command argument for known leaf commands.
75
+ // Unknown roots stay help-compatible because plugins may register namespaces later.
76
+ if (!primary || !KNOWN_ROOT_COMMANDS.has(primary)) {
77
+ return true;
78
+ }
79
+ if (positionals.length === 2 && ROOT_COMMANDS_WITH_SUBCOMMANDS.has(primary)) {
80
+ return true;
81
+ }
82
+ return false;
83
+ }
84
+ return false;
85
+ }
86
+
87
+ export function hasFlag(argv: string[], name: string): boolean {
88
+ const args = argv.slice(2);
89
+ for (const arg of args) {
90
+ if (arg === FLAG_TERMINATOR) {
91
+ break;
92
+ }
93
+ if (arg === name) {
94
+ return true;
95
+ }
96
+ }
97
+ return false;
98
+ }
99
+
100
+ export function hasRootVersionAlias(argv: string[]): boolean {
101
+ return isRootInvocationForFlags(argv, ROOT_VERSION_ALIAS_FLAGS);
102
+ }
103
+
104
+ export function isRootVersionInvocation(argv: string[]): boolean {
105
+ return isRootInvocationForFlags(argv, VERSION_FLAGS);
106
+ }
107
+
108
+ function isRootInvocationForFlags(argv: string[], targetFlags: ReadonlySet<string>): boolean {
109
+ const args = argv.slice(2);
110
+ let hasTarget = false;
111
+ for (let i = 0; i < args.length; i += 1) {
112
+ const arg = args[i];
113
+ if (!arg) {
114
+ continue;
115
+ }
116
+ if (arg === FLAG_TERMINATOR) {
117
+ break;
118
+ }
119
+ if (targetFlags.has(arg)) {
120
+ hasTarget = true;
121
+ continue;
122
+ }
123
+ const consumed = consumeRootOptionToken(args, i);
124
+ if (consumed > 0) {
125
+ i += consumed - 1;
126
+ continue;
127
+ }
128
+ // Unknown flags and subcommand-scoped help/version should fall back to Commander.
129
+ return false;
130
+ }
131
+ return hasTarget;
132
+ }
133
+
134
+ export function isRootHelpInvocation(argv: string[]): boolean {
135
+ return isRootInvocationForFlags(argv, HELP_FLAGS);
136
+ }
137
+
138
+ /** Match fast-path command help only when no command option can own the help token as a value. */
139
+ export function isSimpleCommandHelpInvocation(
140
+ argv: string[],
141
+ commandNames: ReadonlySet<string>,
142
+ ): boolean {
143
+ const args = argv.slice(2);
144
+ let commandSeen = false;
145
+ let helpSeen = false;
146
+ for (let index = 0; index < args.length; index += 1) {
147
+ const arg = args[index];
148
+ if (!arg || arg === FLAG_TERMINATOR) {
149
+ return false;
150
+ }
151
+ const rootConsumed = commandSeen ? 0 : consumeRootOptionToken(args, index);
152
+ if (rootConsumed > 0) {
153
+ index += rootConsumed - 1;
154
+ continue;
155
+ }
156
+ if (HELP_FLAGS.has(arg)) {
157
+ if (!commandSeen) {
158
+ return false;
159
+ }
160
+ helpSeen = true;
161
+ continue;
162
+ }
163
+ if (arg.startsWith("-") || commandSeen) {
164
+ return false;
165
+ }
166
+ if (!commandNames.has(arg)) {
167
+ return false;
168
+ }
169
+ commandSeen = true;
170
+ }
171
+ return commandSeen && helpSeen;
172
+ }
173
+
174
+ type HelpNormalizationPositional = { value: string; index: number };
175
+
176
+ type HelpNormalizationScanResult =
177
+ | {
178
+ ok: true;
179
+ positionals: HelpNormalizationPositional[];
180
+ rootOptions: string[];
181
+ helpFlagIndex: number | null;
182
+ }
183
+ | { ok: false };
184
+
185
+ function scanHelpNormalizationArgv(argv: string[]): HelpNormalizationScanResult {
186
+ const positionals: HelpNormalizationPositional[] = [];
187
+ const rootOptions: string[] = [];
188
+ let helpFlagIndex: number | null = null;
189
+
190
+ for (let index = 2; index < argv.length; index += 1) {
191
+ const arg = argv[index];
192
+ if (!arg || arg === FLAG_TERMINATOR) {
193
+ break;
194
+ }
195
+ const consumed = consumeRootOptionToken(argv, index);
196
+ if (consumed > 0) {
197
+ rootOptions.push(...argv.slice(index, index + consumed));
198
+ index += consumed - 1;
199
+ continue;
200
+ }
201
+ if (HELP_FLAGS.has(arg)) {
202
+ helpFlagIndex = index;
203
+ continue;
204
+ }
205
+ if (arg.startsWith("-")) {
206
+ return { ok: false };
207
+ }
208
+ positionals.push({ value: arg, index });
209
+ }
210
+
211
+ return { ok: true, positionals, rootOptions, helpFlagIndex };
212
+ }
213
+
214
+ export function normalizeGeneratedHelpCommandArgv(argv: string[]): string[] {
215
+ const scan = scanHelpNormalizationArgv(argv);
216
+ if (!scan.ok) {
217
+ return argv;
218
+ }
219
+ const { positionals, rootOptions, helpFlagIndex } = scan;
220
+ const [primary, secondary, target] = positionals;
221
+ if (
222
+ !primary ||
223
+ secondary?.value !== "help" ||
224
+ (KNOWN_ROOT_COMMANDS.has(primary.value) && !ROOT_COMMANDS_WITH_SUBCOMMANDS.has(primary.value))
225
+ ) {
226
+ return argv;
227
+ }
228
+
229
+ if (positionals.length === 2 && helpFlagIndex === secondary.index + 1) {
230
+ return argv.toSpliced(helpFlagIndex, 1);
231
+ }
232
+
233
+ if (
234
+ !target ||
235
+ positionals.length !== 3 ||
236
+ (helpFlagIndex !== null && helpFlagIndex !== target.index + 1)
237
+ ) {
238
+ return argv;
239
+ }
240
+ const [runtimePath, entryPath] = argv;
241
+ if (runtimePath === undefined || entryPath === undefined) {
242
+ return argv;
243
+ }
244
+
245
+ return [runtimePath, entryPath, ...rootOptions, primary.value, target.value, "--help"];
246
+ }
247
+
248
+ export function normalizeRootHelpTargetArgv(argv: string[]): string[] {
249
+ const scan = scanHelpNormalizationArgv(argv);
250
+ if (!scan.ok) {
251
+ return argv;
252
+ }
253
+ const { positionals, rootOptions, helpFlagIndex } = scan;
254
+
255
+ const [help, target] = positionals;
256
+ // The --help flag must trail the LAST positional so nested targets like
257
+ // `help plugins list --help` still normalize (target is only the first one).
258
+ const lastPositional = positionals.at(-1);
259
+ if (
260
+ help?.value !== "help" ||
261
+ !target ||
262
+ !lastPositional ||
263
+ (helpFlagIndex !== null && helpFlagIndex !== lastPositional.index + 1)
264
+ ) {
265
+ return argv;
266
+ }
267
+ const [runtimePath, entryPath] = argv;
268
+ if (runtimePath === undefined || entryPath === undefined) {
269
+ return argv;
270
+ }
271
+
272
+ const targetPath = positionals.slice(1).map((positional) => positional.value);
273
+ return [runtimePath, entryPath, ...rootOptions, ...targetPath, "--help"];
274
+ }
275
+
276
+ type NormalizeRootNoColorArgvOptions = {
277
+ shouldPreserveNoColor?: (params: {
278
+ remainingArgs: readonly string[];
279
+ noColorIndex: number;
280
+ }) => boolean;
281
+ };
282
+
283
+ type NormalizeRootLogLevelArgvOptions = {
284
+ shouldPreserveLogLevel?: (params: {
285
+ remainingArgs: readonly string[];
286
+ logLevelIndex: number;
287
+ consumed: number;
288
+ }) => boolean;
289
+ };
290
+
291
+ function isPossibleCommandOptionValue(
292
+ remainingArgs: readonly string[],
293
+ optionIndex: number,
294
+ ): boolean {
295
+ const previous = remainingArgs[optionIndex - 1];
296
+ if (!previous?.startsWith("-") || previous === FLAG_TERMINATOR) {
297
+ return false;
298
+ }
299
+ return !previous.includes("=");
300
+ }
301
+
302
+ function consumeRootLogLevelToken(args: readonly string[], index: number): number {
303
+ const arg = args[index];
304
+ if (!arg || arg === FLAG_TERMINATOR) {
305
+ return 0;
306
+ }
307
+ if (arg.startsWith("--log-level=")) {
308
+ return arg.slice("--log-level=".length).trim() ? 1 : 0;
309
+ }
310
+ if (arg === "--log-level") {
311
+ return isValueToken(args[index + 1]) ? 2 : 0;
312
+ }
313
+ return 0;
314
+ }
315
+
316
+ function normalizeRootOptionArgv(
317
+ argv: string[],
318
+ consumeOption: (args: readonly string[], index: number) => number,
319
+ shouldPreserve: (
320
+ remainingArgs: readonly string[],
321
+ index: number,
322
+ consumed: number,
323
+ ) => boolean | undefined,
324
+ ): string[] {
325
+ const prefix = argv.slice(0, 2);
326
+ const args = argv.slice(2);
327
+ let rootPrefixEnd = 0;
328
+ for (let index = 0; index < args.length; index += 1) {
329
+ const arg = args[index];
330
+ if (!arg || arg === FLAG_TERMINATOR) {
331
+ break;
332
+ }
333
+ const consumed = consumeRootOptionToken(args, index);
334
+ if (consumed <= 0) {
335
+ break;
336
+ }
337
+ rootPrefixEnd = index + consumed;
338
+ index += consumed - 1;
339
+ }
340
+ const rootPrefix = args.slice(0, rootPrefixEnd);
341
+ const remainingArgs = args.slice(rootPrefixEnd);
342
+ const movedArgs: string[] = [];
343
+ const nextArgs: string[] = [];
344
+ for (let index = 0; index < remainingArgs.length; index += 1) {
345
+ const arg = remainingArgs.at(index);
346
+ if (arg === undefined) {
347
+ break;
348
+ }
349
+ if (arg === FLAG_TERMINATOR) {
350
+ nextArgs.push(...remainingArgs.slice(index));
351
+ break;
352
+ }
353
+ const consumed = consumeOption(remainingArgs, index);
354
+ if (consumed > 0) {
355
+ // Commander accepts dash-prefixed option values. Early callers preserve
356
+ // possible values; the final parse uses the registered command metadata.
357
+ const preserve =
358
+ shouldPreserve(remainingArgs, index, consumed) ??
359
+ isPossibleCommandOptionValue(remainingArgs, index);
360
+ const tokens = remainingArgs.slice(index, index + consumed);
361
+ if (preserve) {
362
+ nextArgs.push(...tokens);
363
+ } else {
364
+ movedArgs.push(...tokens);
365
+ }
366
+ index += consumed - 1;
367
+ continue;
368
+ }
369
+ nextArgs.push(arg);
370
+ }
371
+
372
+ if (movedArgs.length === 0) {
373
+ return argv;
374
+ }
375
+ return [...prefix, ...rootPrefix, ...movedArgs, ...nextArgs];
376
+ }
377
+
378
+ export function normalizeRootNoColorArgv(
379
+ argv: string[],
380
+ options: NormalizeRootNoColorArgvOptions = {},
381
+ ): string[] {
382
+ return normalizeRootOptionArgv(
383
+ argv,
384
+ (args, index) => (args[index] === "--no-color" ? 1 : 0),
385
+ (remainingArgs, noColorIndex) =>
386
+ options.shouldPreserveNoColor?.({ remainingArgs, noColorIndex }),
387
+ );
388
+ }
389
+
390
+ export function normalizeRootLogLevelArgv(
391
+ argv: string[],
392
+ options: NormalizeRootLogLevelArgvOptions = {},
393
+ ): string[] {
394
+ return normalizeRootOptionArgv(
395
+ argv,
396
+ consumeRootLogLevelToken,
397
+ (remainingArgs, logLevelIndex, consumed) =>
398
+ options.shouldPreserveLogLevel?.({ remainingArgs, logLevelIndex, consumed }),
399
+ );
400
+ }
401
+
402
+ export function getFlagValue(argv: string[], name: string): string | null | undefined {
403
+ const args = argv.slice(2);
404
+ let value: string | undefined;
405
+ for (let i = 0; i < args.length; i += 1) {
406
+ const arg = args.at(i);
407
+ if (arg === undefined) {
408
+ break;
409
+ }
410
+ if (arg === FLAG_TERMINATOR) {
411
+ break;
412
+ }
413
+ if (arg === name) {
414
+ const next = args[i + 1];
415
+ if (!isValueToken(next)) {
416
+ return null;
417
+ }
418
+ value = next;
419
+ i += 1;
420
+ continue;
421
+ }
422
+ if (arg.startsWith(`${name}=`)) {
423
+ const assigned = arg.slice(name.length + 1);
424
+ if (!assigned) {
425
+ return null;
426
+ }
427
+ value = assigned;
428
+ }
429
+ }
430
+ return value;
431
+ }
432
+
433
+ export function getVerboseFlag(argv: string[], options?: { includeDebug?: boolean }): boolean {
434
+ if (hasFlag(argv, "--verbose")) {
435
+ return true;
436
+ }
437
+ if (options?.includeDebug && hasFlag(argv, "--debug")) {
438
+ return true;
439
+ }
440
+ return false;
441
+ }
442
+
443
+ export function getPositiveIntFlagValue(argv: string[], name: string): number | null | undefined {
444
+ const raw = getFlagValue(argv, name);
445
+ if (raw === null || raw === undefined) {
446
+ return raw;
447
+ }
448
+ // Keep absent distinct from present-but-invalid so route-first callers can
449
+ // defer invalid input to Commander instead of silently applying defaults.
450
+ return parseStrictPositiveInteger(raw) ?? null;
451
+ }
452
+
453
+ export function getCommandPathWithRootOptions(argv: string[], depth = 2): string[] {
454
+ return getRootOptionAwareCommandPath(argv, depth);
455
+ }
456
+
457
+ export function getPrimaryCommand(argv: string[]): string | null {
458
+ const [primary] = getCommandPathWithRootOptions(argv, 1);
459
+ return primary ?? null;
460
+ }
461
+
462
+ export { getCommandPositionalsWithRootOptions } from "../infra/cli-root-options.js";
463
+
464
+ export function buildParseArgv(rawArgs: string[], programName = "openclaw"): string[] {
465
+ const normalizedArgv =
466
+ rawArgs[0] === programName
467
+ ? rawArgs.slice(1)
468
+ : rawArgs[0]?.endsWith("openclaw")
469
+ ? rawArgs.slice(1)
470
+ : rawArgs;
471
+ const looksLikeNode =
472
+ normalizedArgv.length >= 2 &&
473
+ (isNodeRuntime(normalizedArgv[0] ?? "") || isBunRuntime(normalizedArgv[0] ?? ""));
474
+ if (looksLikeNode) {
475
+ return normalizedArgv;
476
+ }
477
+ return ["node", programName, ...normalizedArgv];
478
+ }
src/cli/attach-cli.action.test.ts ADDED
@@ -0,0 +1,425 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { EventEmitter } from "node:events";
2
+ import { Command } from "commander";
3
+ import { beforeEach, describe, expect, it, vi } from "vitest";
4
+ import type { SessionsResolveResult } from "../../packages/gateway-protocol/src/index.js";
5
+
6
+ const spawnedChild = Object.assign(new EventEmitter(), { kill: vi.fn() });
7
+ vi.mock("node:child_process", () => ({ spawn: vi.fn(() => spawnedChild) }));
8
+
9
+ const gatewayCalls: Array<{
10
+ method: string;
11
+ params: Record<string, unknown>;
12
+ mode?: string;
13
+ url?: string;
14
+ token?: string;
15
+ useStoredDeviceAuth?: boolean;
16
+ requiredStoredDeviceAuthScopes?: string[];
17
+ hasDeviceIdentityKey: boolean;
18
+ }> = [];
19
+
20
+ function gatewayParams(params: unknown): Record<string, unknown> {
21
+ if (typeof params !== "object" || params === null || Array.isArray(params)) {
22
+ throw new TypeError("Expected gateway params to be an object");
23
+ }
24
+ return params as Record<string, unknown>;
25
+ }
26
+
27
+ vi.mock("../gateway/call.js", () => ({
28
+ callGateway: vi.fn(
29
+ async (p: {
30
+ method: string;
31
+ params: Record<string, unknown>;
32
+ mode?: string;
33
+ url?: string;
34
+ token?: string;
35
+ useStoredDeviceAuth?: boolean;
36
+ requiredStoredDeviceAuthScopes?: string[];
37
+ }) => {
38
+ gatewayCalls.push({
39
+ method: p.method,
40
+ params: gatewayParams(p.params),
41
+ mode: p.mode,
42
+ url: p.url,
43
+ token: p.token,
44
+ useStoredDeviceAuth: p.useStoredDeviceAuth,
45
+ requiredStoredDeviceAuthScopes: p.requiredStoredDeviceAuthScopes,
46
+ hasDeviceIdentityKey: "deviceIdentity" in p,
47
+ });
48
+ if (p.method === "sessions.resolve") {
49
+ return { ok: true, key: "agent:ops:thread:resolved" };
50
+ }
51
+ if (p.method === "agents.list") {
52
+ return { defaultId: "main", mainKey: "main", scope: "global", agents: [] };
53
+ }
54
+ if (p.method === "attach.grant") {
55
+ const sessionKey = (p.params.sessionKey as string) ?? "agent:main:main";
56
+ return {
57
+ sessionKey,
58
+ token: "tok-123",
59
+ expiresAtMs: 2_000_000_000_000,
60
+ mcpConfig: {
61
+ mcpServers: {
62
+ openclaw: {
63
+ type: "http",
64
+ url: "http://127.0.0.1:9999/mcp",
65
+ headers: { Authorization: "Bearer ${OPENCLAW_MCP_TOKEN}" },
66
+ },
67
+ },
68
+ },
69
+ env: { OPENCLAW_MCP_TOKEN: "tok-123" },
70
+ };
71
+ }
72
+ return {};
73
+ },
74
+ ),
75
+ GatewayStoredDeviceAuthUnavailableError: class extends Error {},
76
+ GatewayTransportError: class extends Error {},
77
+ }));
78
+
79
+ const logs: string[] = [];
80
+ let exitCode: number | undefined;
81
+ vi.mock("../runtime.js", () => ({
82
+ defaultRuntime: {
83
+ log: (m: string) => logs.push(m),
84
+ error: (m: string) => logs.push(`ERR:${m}`),
85
+ exit: (c: number) => {
86
+ exitCode = c;
87
+ },
88
+ },
89
+ }));
90
+ vi.mock("../config/io.js", () => ({ getRuntimeConfig: () => ({}) }));
91
+
92
+ import { callGateway } from "../gateway/call.js";
93
+ import { registerAttachCli } from "./attach-cli.js";
94
+
95
+ async function runAttach(...args: string[]) {
96
+ const program = new Command().name("openclaw").exitOverride();
97
+ await registerAttachCli(program);
98
+ await program.parseAsync(["node", "openclaw", "attach", ...args]);
99
+ }
100
+ const tick = () =>
101
+ new Promise<void>((resolve) => {
102
+ setImmediate(resolve);
103
+ });
104
+
105
+ describe("openclaw attach (action)", () => {
106
+ beforeEach(() => {
107
+ gatewayCalls.length = 0;
108
+ logs.length = 0;
109
+ exitCode = undefined;
110
+ spawnedChild.removeAllListeners();
111
+ spawnedChild.kill.mockClear();
112
+ });
113
+
114
+ it.each([
115
+ {
116
+ name: "fitting ASCII labels",
117
+ labels: ["Alpha", " Beta "],
118
+ expectedLines: [
119
+ "SESSION ID PREFIX",
120
+ "Alpha 123456780aaa4000",
121
+ "Beta 123456780bbb4000",
122
+ ],
123
+ },
124
+ {
125
+ name: "sanitized wide labels",
126
+ labels: ["\u001b[31m界界界\u001b[0m", "Alpha"],
127
+ expectedLines: [
128
+ "SESSION ID PREFIX",
129
+ "界界界 123456780aaa4000",
130
+ "Alpha 123456780bbb4000",
131
+ ],
132
+ },
133
+ {
134
+ name: "an emoji crossing the name limit",
135
+ labels: ["A".repeat(39) + "😀", "Alpha"],
136
+ expectedLines: [
137
+ `SESSION${" ".repeat(35)}ID PREFIX`,
138
+ `${"A".repeat(39)}… 123456780aaa4000`,
139
+ `Alpha${" ".repeat(37)}123456780bbb4000`,
140
+ ],
141
+ },
142
+ {
143
+ name: "a combining label that exactly fits",
144
+ labels: ["A".repeat(39) + "e\u0301", "Alpha"],
145
+ expectedLines: [
146
+ `SESSION${" ".repeat(35)}ID PREFIX`,
147
+ `${"A".repeat(39)}e\u0301 123456780aaa4000`,
148
+ `Alpha${" ".repeat(37)}123456780bbb4000`,
149
+ ],
150
+ },
151
+ {
152
+ name: "a bounded zero-width label",
153
+ labels: ["\u200b".repeat(512), "Alpha"],
154
+ expectedLines: [
155
+ "SESSION ID PREFIX",
156
+ `${"\u200b".repeat(49)}… 123456780aaa4000`,
157
+ "Alpha 123456780bbb4000",
158
+ ],
159
+ },
160
+ {
161
+ name: "an oversized combining grapheme",
162
+ labels: ["e" + "\u0301".repeat(512), "Alpha"],
163
+ expectedLines: [
164
+ "SESSION ID PREFIX",
165
+ "… 123456780aaa4000",
166
+ "Alpha 123456780bbb4000",
167
+ ],
168
+ },
169
+ {
170
+ name: "an oversized ZWJ grapheme",
171
+ labels: ["👩" + "\u200d👩".repeat(128), "Alpha"],
172
+ expectedLines: [
173
+ "SESSION ID PREFIX",
174
+ "… 123456780aaa4000",
175
+ "Alpha 123456780bbb4000",
176
+ ],
177
+ },
178
+ {
179
+ name: "ordinary multi-person emoji",
180
+ labels: ["👨‍👩‍👧‍👦".repeat(5), "Alpha"],
181
+ expectedLines: [
182
+ "SESSION ID PREFIX",
183
+ `${"👨‍👩‍👧‍👦".repeat(5)} 123456780aaa4000`,
184
+ "Alpha 123456780bbb4000",
185
+ ],
186
+ },
187
+ ])("renders ambiguous session candidates with $name", async ({ labels, expectedLines }) => {
188
+ const response = {
189
+ ok: false,
190
+ candidates: [
191
+ {
192
+ key: "agent:main:thread:12345678-0aaa-4000-8000-000000000001",
193
+ agentId: "main",
194
+ displayName: labels[0],
195
+ },
196
+ {
197
+ key: "agent:main:thread:12345678-0bbb-4000-8000-000000000002",
198
+ agentId: "main",
199
+ displayName: labels[1],
200
+ },
201
+ ],
202
+ } satisfies SessionsResolveResult;
203
+ const gateway = vi.mocked(callGateway);
204
+ const originalImplementation = gateway.getMockImplementation();
205
+ const { spawn } = await import("node:child_process");
206
+ const spawnCount = vi.mocked(spawn).mock.calls.length;
207
+ gateway.mockReset();
208
+ // Any request after resolution must fail before a grant can reach config writing or spawn.
209
+ gateway.mockRejectedValue(new Error("Unexpected Gateway request after session resolution"));
210
+ gateway.mockResolvedValueOnce(response);
211
+ try {
212
+ const error = await runAttach("12345678").catch((caught: unknown) => caught);
213
+ if (!(error instanceof Error)) {
214
+ throw new Error("Expected ambiguous session target rejection");
215
+ }
216
+ expect(error.message).toBe(
217
+ [
218
+ "Session reference is ambiguous:",
219
+ ...expectedLines,
220
+ "Pass a longer reference. Run `openclaw sessions list` to choose a full session key.",
221
+ ].join("\n"),
222
+ );
223
+ expect(Buffer.from(error.message, "utf8").toString("utf8")).toBe(error.message);
224
+ expect(gateway).toHaveBeenCalledTimes(1);
225
+ expect(gateway).toHaveBeenCalledWith(
226
+ expect.objectContaining({ method: "sessions.resolve", params: { shortId: "12345678" } }),
227
+ );
228
+ expect(vi.mocked(spawn).mock.calls.length).toBe(spawnCount);
229
+ } finally {
230
+ gateway.mockReset();
231
+ if (originalImplementation) {
232
+ gateway.mockImplementation(originalImplementation);
233
+ }
234
+ }
235
+ });
236
+
237
+ it("--print-config: mints + writes config + prints launch, does NOT revoke or name a nonexistent command", async () => {
238
+ await runAttach("--print-config", "--session", "agent:main:cli");
239
+ expect(gatewayCalls.find((c) => c.method === "attach.grant")?.params.sessionKey).toBe(
240
+ "agent:main:cli",
241
+ );
242
+ // setup mode leaves the grant live (no revoke) and must not point at a revoke command that does not exist
243
+ expect(gatewayCalls.find((c) => c.method === "attach.revoke")).toBeUndefined();
244
+ const out = logs.join("\n");
245
+ expect(out).toContain("agent:main:cli");
246
+ expect(out).toContain("--mcp-config");
247
+ expect(out).toContain("--strict-mcp-config");
248
+ expect(out).toContain("OPENCLAW_MCP_TOKEN");
249
+ expect(out).not.toContain("attach.revoke");
250
+ });
251
+
252
+ it("calls attach.grant in CLI mode with an auto-resolved device identity (operator.admin regression guard)", async () => {
253
+ // Regression guard: attach.grant is operator.admin-scoped. mode BACKEND or an explicit
254
+ // deviceIdentity:null drops the operator device identity → the gateway rejects with
255
+ // "missing scope: operator.admin". This was a real bug found via a live-gateway proof.
256
+ await runAttach("--print-config", "--session", "agent:main:cli");
257
+ const grant = gatewayCalls.find((c) => c.method === "attach.grant");
258
+ expect(grant?.mode).toBe("cli");
259
+ expect(grant?.hasDeviceIdentityKey).toBe(false);
260
+ });
261
+
262
+ it("resolves a URL target before granting on the same origin", async () => {
263
+ await runAttach(
264
+ "https://gateway.example/base/dashboard/ops/movies-a1166b81",
265
+ "--token",
266
+ "explicit-token",
267
+ "--print-config",
268
+ );
269
+
270
+ const resolve = gatewayCalls.find((call) => call.method === "sessions.resolve");
271
+ expect(resolve).toMatchObject({
272
+ url: "wss://gateway.example/base",
273
+ token: "explicit-token",
274
+ useStoredDeviceAuth: true,
275
+ requiredStoredDeviceAuthScopes: ["operator.read"],
276
+ params: { shortId: "a1166b81", slugHint: "movies" },
277
+ });
278
+ expect(gatewayCalls.find((call) => call.method === "attach.grant")).toMatchObject({
279
+ url: "wss://gateway.example/base",
280
+ token: "explicit-token",
281
+ useStoredDeviceAuth: true,
282
+ requiredStoredDeviceAuthScopes: ["operator.admin"],
283
+ params: { sessionKey: "agent:ops:thread:resolved" },
284
+ });
285
+ });
286
+
287
+ it("preserves a global-scope URL main session when granting attach access", async () => {
288
+ await runAttach(
289
+ "https://gateway.example/base/dashboard/ops",
290
+ "--token",
291
+ "explicit-token",
292
+ "--print-config",
293
+ );
294
+
295
+ expect(gatewayCalls.find((call) => call.method === "agents.list")).toMatchObject({
296
+ url: "wss://gateway.example/base",
297
+ token: "explicit-token",
298
+ useStoredDeviceAuth: true,
299
+ requiredStoredDeviceAuthScopes: ["operator.read"],
300
+ params: {},
301
+ });
302
+ expect(gatewayCalls.find((call) => call.method === "attach.grant")).toMatchObject({
303
+ url: "wss://gateway.example/base",
304
+ token: "explicit-token",
305
+ useStoredDeviceAuth: true,
306
+ requiredStoredDeviceAuthScopes: ["operator.admin"],
307
+ params: { sessionKey: "global", agentId: "ops" },
308
+ });
309
+ });
310
+
311
+ it("rejects a non-positive --ttl before minting", async () => {
312
+ await runAttach("--ttl", "-5", "--print-config");
313
+ expect(exitCode).toBe(1);
314
+ expect(gatewayCalls.find((c) => c.method === "attach.grant")).toBeUndefined();
315
+ });
316
+
317
+ it.each(["0x10", "1.5", "1e3"])("rejects malformed --ttl %s before minting", async (ttl) => {
318
+ await runAttach("--ttl", ttl, "--print-config");
319
+ expect(exitCode).toBe(1);
320
+ expect(logs.join("\n")).toContain("--ttl must be a positive integer of milliseconds");
321
+ expect(gatewayCalls.find((c) => c.method === "attach.grant")).toBeUndefined();
322
+ });
323
+
324
+ it("rejects an empty --ttl rather than silently defaulting", async () => {
325
+ await runAttach("--ttl", "", "--print-config");
326
+ expect(exitCode).toBe(1);
327
+ expect(gatewayCalls.find((c) => c.method === "attach.grant")).toBeUndefined();
328
+ });
329
+
330
+ it("passes a positive --ttl through to attach.grant", async () => {
331
+ await runAttach("--ttl", "600000", "--print-config");
332
+ expect(gatewayCalls.find((c) => c.method === "attach.grant")?.params.ttlMs).toBe(600_000);
333
+ });
334
+
335
+ it("errors on a malformed attach.grant response instead of crashing", async () => {
336
+ vi.mocked(callGateway).mockResolvedValueOnce({} as never);
337
+ await runAttach("--print-config");
338
+ expect(exitCode).toBe(1);
339
+ });
340
+
341
+ it("spawns Claude Code and revokes the grant when the child exits", async () => {
342
+ await runAttach("--session", "agent:main:spawn");
343
+ expect(gatewayCalls.find((c) => c.method === "attach.grant")).toBeTruthy();
344
+ const { spawn } = await import("node:child_process");
345
+ expect(vi.mocked(spawn).mock.calls[0]?.[1]).toEqual([
346
+ "--strict-mcp-config",
347
+ "--mcp-config",
348
+ expect.stringContaining(".mcp.json"),
349
+ ]);
350
+ spawnedChild.emit("exit", 0, null);
351
+ await tick();
352
+ await tick();
353
+ expect(gatewayCalls.find((c) => c.method === "attach.revoke")?.params.token).toBe("tok-123");
354
+ expect(exitCode).toBe(0);
355
+ });
356
+
357
+ it("revokes once and surfaces a launch failure when the child errors", async () => {
358
+ await runAttach("--session", "agent:main:spawn-err");
359
+ spawnedChild.emit("error", new Error("ENOENT"));
360
+ await tick();
361
+ await tick();
362
+ expect(gatewayCalls.filter((c) => c.method === "attach.revoke")).toHaveLength(1);
363
+ expect(exitCode).toBe(1);
364
+ expect(logs.join("\n")).toContain("Failed to launch");
365
+ });
366
+
367
+ it("warns when revoke fails but still exits with the child status", async () => {
368
+ vi.mocked(callGateway).mockImplementationOnce(async (p) => {
369
+ gatewayCalls.push({
370
+ method: p.method,
371
+ params: gatewayParams(p.params),
372
+ mode: p.mode,
373
+ hasDeviceIdentityKey: "deviceIdentity" in p,
374
+ });
375
+ return {
376
+ sessionKey: "agent:main:spawn",
377
+ token: "tok-123",
378
+ expiresAtMs: 2_000_000_000_000,
379
+ mcpConfig: { mcpServers: { openclaw: {} } },
380
+ env: { OPENCLAW_MCP_TOKEN: "tok-123" },
381
+ } as never;
382
+ });
383
+ vi.mocked(callGateway).mockImplementationOnce(async (p) => {
384
+ gatewayCalls.push({
385
+ method: p.method,
386
+ params: gatewayParams(p.params),
387
+ mode: p.mode,
388
+ hasDeviceIdentityKey: "deviceIdentity" in p,
389
+ });
390
+ throw new Error("gateway down");
391
+ });
392
+
393
+ await runAttach("--session", "agent:main:spawn");
394
+ spawnedChild.emit("exit", 0, null);
395
+ await tick();
396
+ await tick();
397
+
398
+ expect(exitCode).toBe(0);
399
+ expect(logs.join("\n")).toContain("failed to revoke attach grant");
400
+ });
401
+
402
+ it("detaches its signal handlers after the child exits (no listener leak)", async () => {
403
+ const baseInt = process.listenerCount("SIGINT");
404
+ const baseTerm = process.listenerCount("SIGTERM");
405
+ await runAttach("--session", "agent:main:spawn");
406
+ expect(process.listenerCount("SIGINT")).toBe(baseInt + 1);
407
+ spawnedChild.emit("exit", 0, null);
408
+ await tick();
409
+ await tick();
410
+ expect(process.listenerCount("SIGINT")).toBe(baseInt);
411
+ expect(process.listenerCount("SIGTERM")).toBe(baseTerm);
412
+ });
413
+
414
+ it("errors on a grant with a non-numeric expiresAtMs instead of crashing on toISOString", async () => {
415
+ vi.mocked(callGateway).mockResolvedValueOnce({
416
+ sessionKey: "agent:main:x",
417
+ token: "tok-123",
418
+ expiresAtMs: "soon",
419
+ mcpConfig: { mcpServers: { openclaw: {} } },
420
+ env: {},
421
+ } as never);
422
+ await runAttach("--print-config");
423
+ expect(exitCode).toBe(1);
424
+ });
425
+ });