@peac/adapter-openclaw 0.10.9 → 0.10.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +1 -1
- package/dist/index.cjs +1296 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.mjs +1273 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +19 -14
- package/dist/emitter.js +0 -197
- package/dist/emitter.js.map +0 -1
- package/dist/hooks.js +0 -157
- package/dist/hooks.js.map +0 -1
- package/dist/index.js +0 -53
- package/dist/index.js.map +0 -1
- package/dist/mapper.js +0 -320
- package/dist/mapper.js.map +0 -1
- package/dist/plugin.js +0 -331
- package/dist/plugin.js.map +0 -1
- package/dist/tools.js +0 -732
- package/dist/tools.js.map +0 -1
- package/dist/types.js +0 -57
- package/dist/types.js.map +0 -1
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,1273 @@
|
|
|
1
|
+
import { canonicalize, base64urlDecode, sign } from '@peac/crypto';
|
|
2
|
+
import { toInteractionEvidence, createHasher, createCaptureSession } from '@peac/capture-core';
|
|
3
|
+
|
|
4
|
+
// src/types.ts
|
|
5
|
+
var OPENCLAW_EXTENSION_KEYS = {
|
|
6
|
+
/** OpenClaw context extension */
|
|
7
|
+
CONTEXT: "org.openclaw/context",
|
|
8
|
+
/** Security audit digest */
|
|
9
|
+
AUDIT_DIGEST: "org.openclaw/audit_digest"
|
|
10
|
+
};
|
|
11
|
+
var OPENCLAW_ERROR_CODES = {
|
|
12
|
+
/** Missing required field in OpenClaw event */
|
|
13
|
+
MISSING_FIELD: "E_OPENCLAW_MISSING_FIELD",
|
|
14
|
+
/** Invalid field value in OpenClaw event */
|
|
15
|
+
INVALID_FIELD: "E_OPENCLAW_INVALID_FIELD",
|
|
16
|
+
/** Payload serialization failed */
|
|
17
|
+
SERIALIZATION_FAILED: "E_OPENCLAW_SERIALIZATION_FAILED",
|
|
18
|
+
/** Signing failed */
|
|
19
|
+
SIGNING_FAILED: "E_OPENCLAW_SIGNING_FAILED",
|
|
20
|
+
/** Emitter not configured */
|
|
21
|
+
EMITTER_NOT_CONFIGURED: "E_OPENCLAW_EMITTER_NOT_CONFIGURED"
|
|
22
|
+
};
|
|
23
|
+
var OPENCLAW_WARNING_CODES = {
|
|
24
|
+
/** Payload exceeded size limit and was truncated for hashing */
|
|
25
|
+
PAYLOAD_TRUNCATED: "W_OPENCLAW_PAYLOAD_TRUNCATED",
|
|
26
|
+
/** Payload serialization failed (circular refs, BigInt, etc.) */
|
|
27
|
+
SERIALIZATION_FAILED: "W_OPENCLAW_SERIALIZATION_FAILED",
|
|
28
|
+
/** Optional field missing (non-critical) */
|
|
29
|
+
OPTIONAL_FIELD_MISSING: "W_OPENCLAW_OPTIONAL_FIELD_MISSING",
|
|
30
|
+
/** Unknown tool provider */
|
|
31
|
+
UNKNOWN_PROVIDER: "W_OPENCLAW_UNKNOWN_PROVIDER",
|
|
32
|
+
/** Payload dropped due to redaction policy (hash_only mode for non-allowlisted tool) */
|
|
33
|
+
PAYLOAD_REDACTED: "W_OPENCLAW_PAYLOAD_REDACTED"
|
|
34
|
+
};
|
|
35
|
+
var DEFAULT_PLATFORM = "openclaw";
|
|
36
|
+
var DEFAULT_KIND = "tool.call";
|
|
37
|
+
function mapToolCallEvent(event, config) {
|
|
38
|
+
const warnings = [];
|
|
39
|
+
const validationError = validateRequiredFields(event);
|
|
40
|
+
if (validationError) {
|
|
41
|
+
return {
|
|
42
|
+
success: false,
|
|
43
|
+
error_code: validationError.code,
|
|
44
|
+
error_message: validationError.message
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
const interactionId = buildInteractionId(event);
|
|
48
|
+
const captureMode = config?.capture?.mode ?? "hash_only";
|
|
49
|
+
const allowlist = config?.capture?.allowlist ?? [];
|
|
50
|
+
const maxPayloadSize = config?.capture?.max_payload_size ?? 1024 * 1024;
|
|
51
|
+
const isAllowlisted = captureMode === "allowlist" && allowlist.includes(event.tool_name);
|
|
52
|
+
const inputResult = serializePayloadWithPolicy(
|
|
53
|
+
event.input,
|
|
54
|
+
maxPayloadSize,
|
|
55
|
+
isAllowlisted,
|
|
56
|
+
"input"
|
|
57
|
+
);
|
|
58
|
+
const outputResult = event.output !== void 0 ? serializePayloadWithPolicy(event.output, maxPayloadSize, isAllowlisted, "output") : void 0;
|
|
59
|
+
if (inputResult.warning) {
|
|
60
|
+
warnings.push(inputResult.warning);
|
|
61
|
+
}
|
|
62
|
+
if (outputResult?.warning) {
|
|
63
|
+
warnings.push(outputResult.warning);
|
|
64
|
+
}
|
|
65
|
+
const inputBytes = inputResult.bytes;
|
|
66
|
+
const outputBytes = outputResult?.bytes;
|
|
67
|
+
const platform = config?.platform ?? DEFAULT_PLATFORM;
|
|
68
|
+
const platformVersion = config?.platform_version;
|
|
69
|
+
const pluginId = config?.plugin_id;
|
|
70
|
+
const extensions = buildExtensions(event);
|
|
71
|
+
const action = {
|
|
72
|
+
id: interactionId,
|
|
73
|
+
kind: DEFAULT_KIND,
|
|
74
|
+
platform,
|
|
75
|
+
started_at: event.started_at,
|
|
76
|
+
completed_at: event.completed_at,
|
|
77
|
+
tool_name: event.tool_name,
|
|
78
|
+
tool_provider: event.tool_provider,
|
|
79
|
+
input_bytes: inputBytes,
|
|
80
|
+
output_bytes: outputBytes,
|
|
81
|
+
status: event.status,
|
|
82
|
+
error_code: event.error_code,
|
|
83
|
+
retryable: event.retryable,
|
|
84
|
+
metadata: {
|
|
85
|
+
// Platform metadata
|
|
86
|
+
...platformVersion && { platform_version: platformVersion },
|
|
87
|
+
...pluginId && { plugin_id: pluginId },
|
|
88
|
+
// Tool version (not a standard field, stored in metadata)
|
|
89
|
+
...event.tool_version && { tool_version: event.tool_version },
|
|
90
|
+
// Policy context
|
|
91
|
+
...event.policy && {
|
|
92
|
+
policy_decision: event.policy.decision,
|
|
93
|
+
sandbox_enabled: event.policy.sandbox_enabled,
|
|
94
|
+
elevated: event.policy.elevated,
|
|
95
|
+
policy_hash: event.policy.policy_hash
|
|
96
|
+
},
|
|
97
|
+
// OpenClaw extensions (will be mapped to interaction.extensions)
|
|
98
|
+
...extensions
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
return {
|
|
102
|
+
success: true,
|
|
103
|
+
action,
|
|
104
|
+
warnings: warnings.length > 0 ? warnings : void 0
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
function mapToolCallEventBatch(events, config) {
|
|
108
|
+
return events.map((event) => mapToolCallEvent(event, config));
|
|
109
|
+
}
|
|
110
|
+
function validateRequiredFields(event) {
|
|
111
|
+
if (!event.tool_call_id) {
|
|
112
|
+
return {
|
|
113
|
+
code: OPENCLAW_ERROR_CODES.MISSING_FIELD,
|
|
114
|
+
message: "Missing required field: tool_call_id"
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
if (!event.run_id) {
|
|
118
|
+
return {
|
|
119
|
+
code: OPENCLAW_ERROR_CODES.MISSING_FIELD,
|
|
120
|
+
message: "Missing required field: run_id"
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
if (!event.tool_name) {
|
|
124
|
+
return {
|
|
125
|
+
code: OPENCLAW_ERROR_CODES.MISSING_FIELD,
|
|
126
|
+
message: "Missing required field: tool_name"
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
if (!event.started_at) {
|
|
130
|
+
return {
|
|
131
|
+
code: OPENCLAW_ERROR_CODES.MISSING_FIELD,
|
|
132
|
+
message: "Missing required field: started_at"
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
if (!event.status) {
|
|
136
|
+
return {
|
|
137
|
+
code: OPENCLAW_ERROR_CODES.MISSING_FIELD,
|
|
138
|
+
message: "Missing required field: status"
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
const validStatuses = ["ok", "error", "timeout", "canceled"];
|
|
142
|
+
if (!validStatuses.includes(event.status)) {
|
|
143
|
+
return {
|
|
144
|
+
code: OPENCLAW_ERROR_CODES.INVALID_FIELD,
|
|
145
|
+
message: `Invalid status: ${event.status}. Expected one of: ${validStatuses.join(", ")}`
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
if (!isValidRfc3339(event.started_at)) {
|
|
149
|
+
return {
|
|
150
|
+
code: OPENCLAW_ERROR_CODES.INVALID_FIELD,
|
|
151
|
+
message: `Invalid started_at timestamp: ${event.started_at}. Expected RFC 3339 format.`
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
if (event.completed_at && !isValidRfc3339(event.completed_at)) {
|
|
155
|
+
return {
|
|
156
|
+
code: OPENCLAW_ERROR_CODES.INVALID_FIELD,
|
|
157
|
+
message: `Invalid completed_at timestamp: ${event.completed_at}. Expected RFC 3339 format.`
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
function buildInteractionId(event) {
|
|
163
|
+
const runIdEncoded = base64urlEncode(event.run_id);
|
|
164
|
+
const toolCallIdEncoded = base64urlEncode(event.tool_call_id);
|
|
165
|
+
return `openclaw/${runIdEncoded}/${toolCallIdEncoded}`;
|
|
166
|
+
}
|
|
167
|
+
function base64urlEncode(str) {
|
|
168
|
+
const bytes = new TextEncoder().encode(str);
|
|
169
|
+
const base64 = btoa(String.fromCharCode(...bytes));
|
|
170
|
+
return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
171
|
+
}
|
|
172
|
+
function serializePayloadWithPolicy(payload, maxPayloadSize, isAllowlisted, fieldName) {
|
|
173
|
+
if (payload === void 0) {
|
|
174
|
+
return {};
|
|
175
|
+
}
|
|
176
|
+
let bytes;
|
|
177
|
+
try {
|
|
178
|
+
const json = canonicalize(payload);
|
|
179
|
+
bytes = new TextEncoder().encode(json);
|
|
180
|
+
} catch {
|
|
181
|
+
return {
|
|
182
|
+
warning: {
|
|
183
|
+
code: OPENCLAW_WARNING_CODES.SERIALIZATION_FAILED,
|
|
184
|
+
message: `${fieldName} payload could not be serialized (circular ref, BigInt, etc.)`,
|
|
185
|
+
field: fieldName
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
let truncated = false;
|
|
190
|
+
if (bytes.length > maxPayloadSize) {
|
|
191
|
+
bytes = bytes.slice(0, maxPayloadSize);
|
|
192
|
+
truncated = true;
|
|
193
|
+
}
|
|
194
|
+
const result = { bytes, truncated };
|
|
195
|
+
if (truncated) {
|
|
196
|
+
result.warning = {
|
|
197
|
+
code: OPENCLAW_WARNING_CODES.PAYLOAD_TRUNCATED,
|
|
198
|
+
message: `${fieldName} payload exceeded ${maxPayloadSize} bytes and was truncated`,
|
|
199
|
+
field: fieldName
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
return result;
|
|
203
|
+
}
|
|
204
|
+
function buildExtensions(event) {
|
|
205
|
+
const extensions = {};
|
|
206
|
+
if (event.context) {
|
|
207
|
+
const contextExt = {
|
|
208
|
+
...event.context.channel && { channel: event.context.channel },
|
|
209
|
+
...event.context.workspace_digest && { workspace_digest: event.context.workspace_digest },
|
|
210
|
+
...event.context.gateway_version && { gateway_version: event.context.gateway_version },
|
|
211
|
+
tool_call_id: event.tool_call_id
|
|
212
|
+
};
|
|
213
|
+
if (Object.keys(contextExt).length > 0) {
|
|
214
|
+
extensions[OPENCLAW_EXTENSION_KEYS.CONTEXT] = contextExt;
|
|
215
|
+
}
|
|
216
|
+
} else {
|
|
217
|
+
extensions[OPENCLAW_EXTENSION_KEYS.CONTEXT] = {
|
|
218
|
+
tool_call_id: event.tool_call_id
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
if (event.context?.audit_digest) {
|
|
222
|
+
extensions[OPENCLAW_EXTENSION_KEYS.AUDIT_DIGEST] = event.context.audit_digest;
|
|
223
|
+
}
|
|
224
|
+
return extensions;
|
|
225
|
+
}
|
|
226
|
+
function isValidRfc3339(timestamp) {
|
|
227
|
+
const rfc3339Pattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/;
|
|
228
|
+
if (!rfc3339Pattern.test(timestamp)) {
|
|
229
|
+
return false;
|
|
230
|
+
}
|
|
231
|
+
const date = new Date(timestamp);
|
|
232
|
+
return !isNaN(date.getTime());
|
|
233
|
+
}
|
|
234
|
+
function extractWorkflowId(event) {
|
|
235
|
+
return event.session_key;
|
|
236
|
+
}
|
|
237
|
+
function buildWorkflowContext(event) {
|
|
238
|
+
if (!event.session_key) {
|
|
239
|
+
return void 0;
|
|
240
|
+
}
|
|
241
|
+
return {
|
|
242
|
+
workflow_id: event.session_key,
|
|
243
|
+
step_id: event.tool_call_id,
|
|
244
|
+
tool_name: event.tool_name,
|
|
245
|
+
framework: "openclaw"
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// src/hooks.ts
|
|
250
|
+
function createHookHandler(handlerConfig) {
|
|
251
|
+
const { session, config, onCapture, onMappingError } = handlerConfig;
|
|
252
|
+
return {
|
|
253
|
+
async afterToolCall(event) {
|
|
254
|
+
const mappingResult = mapToolCallEvent(event, config);
|
|
255
|
+
if (!mappingResult.success || !mappingResult.action) {
|
|
256
|
+
if (onMappingError) {
|
|
257
|
+
onMappingError(mappingResult, event);
|
|
258
|
+
}
|
|
259
|
+
return {
|
|
260
|
+
success: false,
|
|
261
|
+
code: mappingResult.error_code ?? "E_OPENCLAW_MAPPING_FAILED",
|
|
262
|
+
message: mappingResult.error_message ?? "Failed to map OpenClaw event"
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
const captureResult = await session.capture(mappingResult.action);
|
|
266
|
+
const hookResult = captureResult.success ? { success: true, entry: captureResult.entry } : { success: false, code: captureResult.code, message: captureResult.message };
|
|
267
|
+
if (onCapture) {
|
|
268
|
+
onCapture(hookResult, event);
|
|
269
|
+
}
|
|
270
|
+
return hookResult;
|
|
271
|
+
},
|
|
272
|
+
getSession() {
|
|
273
|
+
return session;
|
|
274
|
+
},
|
|
275
|
+
async close() {
|
|
276
|
+
await session.close();
|
|
277
|
+
}
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
async function captureBatch(handler, events) {
|
|
281
|
+
const results = [];
|
|
282
|
+
for (const event of events) {
|
|
283
|
+
const result = await handler.afterToolCall(event);
|
|
284
|
+
results.push(result);
|
|
285
|
+
}
|
|
286
|
+
return results;
|
|
287
|
+
}
|
|
288
|
+
async function captureParallel(handler, events) {
|
|
289
|
+
return Promise.all(events.map((event) => handler.afterToolCall(event)));
|
|
290
|
+
}
|
|
291
|
+
function createSessionHistoryTailer(tailerConfig) {
|
|
292
|
+
const { handler, sessionId, pollIntervalMs = 1e3, fetchHistory, onError } = tailerConfig;
|
|
293
|
+
let running = false;
|
|
294
|
+
let inFlight = false;
|
|
295
|
+
let lastEventId;
|
|
296
|
+
let intervalHandle;
|
|
297
|
+
const poll = async () => {
|
|
298
|
+
if (inFlight) return;
|
|
299
|
+
inFlight = true;
|
|
300
|
+
try {
|
|
301
|
+
const events = await fetchHistory(sessionId, lastEventId);
|
|
302
|
+
for (const event of events) {
|
|
303
|
+
if (!running) break;
|
|
304
|
+
await handler.afterToolCall(event);
|
|
305
|
+
lastEventId = event.tool_call_id;
|
|
306
|
+
}
|
|
307
|
+
} catch (error) {
|
|
308
|
+
if (onError) {
|
|
309
|
+
onError(error instanceof Error ? error : new Error(String(error)));
|
|
310
|
+
}
|
|
311
|
+
} finally {
|
|
312
|
+
inFlight = false;
|
|
313
|
+
}
|
|
314
|
+
};
|
|
315
|
+
return {
|
|
316
|
+
start() {
|
|
317
|
+
if (running) return;
|
|
318
|
+
running = true;
|
|
319
|
+
intervalHandle = setInterval(poll, pollIntervalMs);
|
|
320
|
+
poll();
|
|
321
|
+
},
|
|
322
|
+
stop() {
|
|
323
|
+
if (!running) return;
|
|
324
|
+
running = false;
|
|
325
|
+
if (intervalHandle) {
|
|
326
|
+
clearInterval(intervalHandle);
|
|
327
|
+
intervalHandle = void 0;
|
|
328
|
+
}
|
|
329
|
+
},
|
|
330
|
+
getLastEventId() {
|
|
331
|
+
return lastEventId;
|
|
332
|
+
},
|
|
333
|
+
isRunning() {
|
|
334
|
+
return running;
|
|
335
|
+
}
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
function createReceiptEmitter(emitterConfig) {
|
|
339
|
+
const { signer, writer, onEmit, onError } = emitterConfig;
|
|
340
|
+
let closed = false;
|
|
341
|
+
return {
|
|
342
|
+
async emit(entry) {
|
|
343
|
+
if (closed) {
|
|
344
|
+
return {
|
|
345
|
+
success: false,
|
|
346
|
+
error_code: "E_EMITTER_CLOSED",
|
|
347
|
+
error_message: "Emitter has been closed"
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
try {
|
|
351
|
+
const interaction = toInteractionEvidence(entry);
|
|
352
|
+
const receiptPayload = buildReceiptPayload(interaction, entry, signer);
|
|
353
|
+
const jws = await signer.sign(receiptPayload);
|
|
354
|
+
const signedReceipt = {
|
|
355
|
+
rid: receiptPayload.rid,
|
|
356
|
+
jws,
|
|
357
|
+
interaction_id: entry.action.id,
|
|
358
|
+
entry_digest: entry.entry_digest
|
|
359
|
+
};
|
|
360
|
+
const receiptPath = await writer.write(signedReceipt);
|
|
361
|
+
const result = {
|
|
362
|
+
success: true,
|
|
363
|
+
receipt_path: receiptPath,
|
|
364
|
+
receipt_id: receiptPayload.rid
|
|
365
|
+
};
|
|
366
|
+
if (onEmit) {
|
|
367
|
+
onEmit(result, entry);
|
|
368
|
+
}
|
|
369
|
+
return result;
|
|
370
|
+
} catch (error) {
|
|
371
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
372
|
+
if (onError) {
|
|
373
|
+
onError(error instanceof Error ? error : new Error(errorMessage), entry);
|
|
374
|
+
}
|
|
375
|
+
return {
|
|
376
|
+
success: false,
|
|
377
|
+
error_code: OPENCLAW_ERROR_CODES.SIGNING_FAILED,
|
|
378
|
+
error_message: `Failed to emit receipt: ${errorMessage}`
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
},
|
|
382
|
+
async flush() {
|
|
383
|
+
},
|
|
384
|
+
async close() {
|
|
385
|
+
closed = true;
|
|
386
|
+
await writer.close();
|
|
387
|
+
}
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
function buildReceiptPayload(interaction, entry, signer) {
|
|
391
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
392
|
+
const rid = generateReceiptId(entry.entry_digest);
|
|
393
|
+
const payload = {
|
|
394
|
+
iss: signer.getIssuer(),
|
|
395
|
+
iat: now,
|
|
396
|
+
rid,
|
|
397
|
+
evidence: {
|
|
398
|
+
extensions: {
|
|
399
|
+
"org.peacprotocol/interaction@0.1": interaction
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
};
|
|
403
|
+
const audience = signer.getAudience();
|
|
404
|
+
if (audience) {
|
|
405
|
+
payload.aud = audience;
|
|
406
|
+
}
|
|
407
|
+
return payload;
|
|
408
|
+
}
|
|
409
|
+
function generateReceiptId(entryDigest) {
|
|
410
|
+
return `r_${entryDigest.slice(0, 32)}`;
|
|
411
|
+
}
|
|
412
|
+
function createBackgroundService(serviceConfig) {
|
|
413
|
+
const {
|
|
414
|
+
emitter,
|
|
415
|
+
getPendingEntries,
|
|
416
|
+
markEmitted,
|
|
417
|
+
drainIntervalMs = 1e3,
|
|
418
|
+
onError
|
|
419
|
+
} = serviceConfig;
|
|
420
|
+
let running = false;
|
|
421
|
+
let stopping = false;
|
|
422
|
+
let inFlight = false;
|
|
423
|
+
let intervalHandle;
|
|
424
|
+
const stats = {
|
|
425
|
+
emitted: 0,
|
|
426
|
+
failed: 0
|
|
427
|
+
};
|
|
428
|
+
const drainOnce = async () => {
|
|
429
|
+
if (inFlight) return;
|
|
430
|
+
inFlight = true;
|
|
431
|
+
try {
|
|
432
|
+
const entries = await getPendingEntries();
|
|
433
|
+
for (const entry of entries) {
|
|
434
|
+
if (stopping) break;
|
|
435
|
+
const result = await emitter.emit(entry);
|
|
436
|
+
if (result.success) {
|
|
437
|
+
await markEmitted(entry.entry_digest);
|
|
438
|
+
stats.emitted++;
|
|
439
|
+
stats.lastEmitTime = /* @__PURE__ */ new Date();
|
|
440
|
+
} else {
|
|
441
|
+
stats.failed++;
|
|
442
|
+
stats.lastError = result.error_message;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
} catch (error) {
|
|
446
|
+
stats.lastError = error instanceof Error ? error.message : String(error);
|
|
447
|
+
if (onError) {
|
|
448
|
+
onError(error instanceof Error ? error : new Error(String(error)));
|
|
449
|
+
}
|
|
450
|
+
} finally {
|
|
451
|
+
inFlight = false;
|
|
452
|
+
}
|
|
453
|
+
};
|
|
454
|
+
return {
|
|
455
|
+
start() {
|
|
456
|
+
if (running) return;
|
|
457
|
+
running = true;
|
|
458
|
+
intervalHandle = setInterval(drainOnce, drainIntervalMs);
|
|
459
|
+
drainOnce();
|
|
460
|
+
},
|
|
461
|
+
stop() {
|
|
462
|
+
if (!running) return;
|
|
463
|
+
stopping = true;
|
|
464
|
+
running = false;
|
|
465
|
+
if (intervalHandle) {
|
|
466
|
+
clearInterval(intervalHandle);
|
|
467
|
+
intervalHandle = void 0;
|
|
468
|
+
}
|
|
469
|
+
},
|
|
470
|
+
async drain() {
|
|
471
|
+
await drainOnce();
|
|
472
|
+
},
|
|
473
|
+
isRunning() {
|
|
474
|
+
return running;
|
|
475
|
+
},
|
|
476
|
+
getStats() {
|
|
477
|
+
return { ...stats };
|
|
478
|
+
}
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
function createJwkSigner(jwk, issuer, audience) {
|
|
482
|
+
const kid = jwk.kid || generateKeyId(jwk);
|
|
483
|
+
if (!jwk.d) {
|
|
484
|
+
throw new Error("JWK must include private key (d parameter)");
|
|
485
|
+
}
|
|
486
|
+
const privateKeyBytes = base64urlDecode(jwk.d);
|
|
487
|
+
return {
|
|
488
|
+
async sign(payload) {
|
|
489
|
+
return sign(payload, privateKeyBytes, kid);
|
|
490
|
+
},
|
|
491
|
+
getKeyId() {
|
|
492
|
+
return kid;
|
|
493
|
+
},
|
|
494
|
+
getIssuer() {
|
|
495
|
+
return issuer;
|
|
496
|
+
},
|
|
497
|
+
getAudience() {
|
|
498
|
+
return audience;
|
|
499
|
+
}
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
async function resolveSigner(keyRef, issuer, audience) {
|
|
503
|
+
const colonIndex = keyRef.indexOf(":");
|
|
504
|
+
if (colonIndex === -1) {
|
|
505
|
+
throw new Error(`Invalid key reference format: ${keyRef}. Expected scheme:value`);
|
|
506
|
+
}
|
|
507
|
+
const scheme = keyRef.slice(0, colonIndex);
|
|
508
|
+
const value = keyRef.slice(colonIndex + 1);
|
|
509
|
+
switch (scheme) {
|
|
510
|
+
case "env": {
|
|
511
|
+
const keyData = process.env[value];
|
|
512
|
+
if (!keyData) {
|
|
513
|
+
throw new Error(`Environment variable ${value} not set for signing key`);
|
|
514
|
+
}
|
|
515
|
+
const jwk = JSON.parse(keyData);
|
|
516
|
+
return createJwkSigner(jwk, issuer, audience);
|
|
517
|
+
}
|
|
518
|
+
case "file": {
|
|
519
|
+
const fs = await import('fs');
|
|
520
|
+
const keyData = await fs.promises.readFile(value, "utf-8");
|
|
521
|
+
const jwk = JSON.parse(keyData);
|
|
522
|
+
return createJwkSigner(jwk, issuer, audience);
|
|
523
|
+
}
|
|
524
|
+
case "keychain":
|
|
525
|
+
throw new Error("Keychain signing not yet implemented. Use env: for development.");
|
|
526
|
+
case "sidecar":
|
|
527
|
+
throw new Error("Sidecar signing not yet implemented. Use env: for development.");
|
|
528
|
+
default:
|
|
529
|
+
throw new Error(`Unknown key reference scheme: ${scheme}. Use env: or file:`);
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
function generateKeyId(jwk) {
|
|
533
|
+
const publicPart = jwk.x || "";
|
|
534
|
+
let hash = 0;
|
|
535
|
+
for (let i = 0; i < publicPart.length; i++) {
|
|
536
|
+
const char = publicPart.charCodeAt(i);
|
|
537
|
+
hash = (hash << 5) - hash + char;
|
|
538
|
+
hash = hash & hash;
|
|
539
|
+
}
|
|
540
|
+
return `k_${Math.abs(hash).toString(16).padStart(8, "0")}`;
|
|
541
|
+
}
|
|
542
|
+
async function createFileReceiptWriter(outputDir) {
|
|
543
|
+
const fs = await import('fs');
|
|
544
|
+
const pathModule = await import('path');
|
|
545
|
+
const resolvedOutputDir = pathModule.resolve(outputDir);
|
|
546
|
+
await fs.promises.mkdir(resolvedOutputDir, { recursive: true });
|
|
547
|
+
return {
|
|
548
|
+
async write(receipt) {
|
|
549
|
+
const filename = `${receipt.rid}.peac.json`;
|
|
550
|
+
const filepath = pathModule.join(resolvedOutputDir, filename);
|
|
551
|
+
const tempPath = `${filepath}.tmp`;
|
|
552
|
+
const content = JSON.stringify(
|
|
553
|
+
{
|
|
554
|
+
rid: receipt.rid,
|
|
555
|
+
interaction_id: receipt.interaction_id,
|
|
556
|
+
entry_digest: receipt.entry_digest,
|
|
557
|
+
_jws: receipt.jws
|
|
558
|
+
},
|
|
559
|
+
null,
|
|
560
|
+
2
|
|
561
|
+
);
|
|
562
|
+
await fs.promises.writeFile(tempPath, content, "utf-8");
|
|
563
|
+
const fd = await fs.promises.open(tempPath, "r");
|
|
564
|
+
await fd.sync();
|
|
565
|
+
await fd.close();
|
|
566
|
+
await fs.promises.rename(tempPath, filepath);
|
|
567
|
+
try {
|
|
568
|
+
const dirFd = await fs.promises.open(resolvedOutputDir, "r");
|
|
569
|
+
await dirFd.sync();
|
|
570
|
+
await dirFd.close();
|
|
571
|
+
} catch {
|
|
572
|
+
}
|
|
573
|
+
return filepath;
|
|
574
|
+
},
|
|
575
|
+
async close() {
|
|
576
|
+
}
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
async function createPluginInstance(options) {
|
|
580
|
+
const {
|
|
581
|
+
signer,
|
|
582
|
+
writer,
|
|
583
|
+
adapterConfig,
|
|
584
|
+
store,
|
|
585
|
+
dedupe,
|
|
586
|
+
drainIntervalMs = 1e3,
|
|
587
|
+
batchSize = 100,
|
|
588
|
+
onError
|
|
589
|
+
} = options;
|
|
590
|
+
const hasher = await createHasher();
|
|
591
|
+
const session = createCaptureSession({
|
|
592
|
+
store,
|
|
593
|
+
dedupe,
|
|
594
|
+
hasher
|
|
595
|
+
});
|
|
596
|
+
const stats = {
|
|
597
|
+
totalCaptured: 0,
|
|
598
|
+
duplicatesSkipped: 0,
|
|
599
|
+
pendingCount: 0,
|
|
600
|
+
totalEmitted: 0,
|
|
601
|
+
totalErrors: 0,
|
|
602
|
+
keyId: signer.getKeyId(),
|
|
603
|
+
isRunning: false
|
|
604
|
+
};
|
|
605
|
+
const hookHandler = createHookHandler({
|
|
606
|
+
session,
|
|
607
|
+
config: adapterConfig,
|
|
608
|
+
onCapture: (result) => {
|
|
609
|
+
if (result.success) {
|
|
610
|
+
stats.totalCaptured++;
|
|
611
|
+
stats.pendingCount++;
|
|
612
|
+
} else {
|
|
613
|
+
stats.duplicatesSkipped++;
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
});
|
|
617
|
+
const emitter = createReceiptEmitter({
|
|
618
|
+
signer,
|
|
619
|
+
writer,
|
|
620
|
+
onEmit: (result) => {
|
|
621
|
+
if (result.success) {
|
|
622
|
+
stats.totalEmitted++;
|
|
623
|
+
stats.pendingCount = Math.max(0, stats.pendingCount - 1);
|
|
624
|
+
}
|
|
625
|
+
},
|
|
626
|
+
onError: (error) => {
|
|
627
|
+
stats.totalErrors++;
|
|
628
|
+
if (onError) onError(error);
|
|
629
|
+
}
|
|
630
|
+
});
|
|
631
|
+
let lastEmittedSequence = 0;
|
|
632
|
+
let drainInProgress = false;
|
|
633
|
+
const backgroundService = createBackgroundService({
|
|
634
|
+
emitter,
|
|
635
|
+
drainIntervalMs,
|
|
636
|
+
getPendingEntries: async () => {
|
|
637
|
+
if (drainInProgress) return [];
|
|
638
|
+
drainInProgress = true;
|
|
639
|
+
try {
|
|
640
|
+
const currentSequence = await store.getSequence();
|
|
641
|
+
if (currentSequence <= lastEmittedSequence) return [];
|
|
642
|
+
const entries = await store.read(lastEmittedSequence, batchSize);
|
|
643
|
+
const pending = [];
|
|
644
|
+
for (const entry of entries) {
|
|
645
|
+
if (entry.sequence <= lastEmittedSequence) continue;
|
|
646
|
+
const dedupeEntry = await dedupe.get(entry.action.id);
|
|
647
|
+
if (dedupeEntry?.emitted) continue;
|
|
648
|
+
pending.push(entry);
|
|
649
|
+
}
|
|
650
|
+
return pending;
|
|
651
|
+
} finally {
|
|
652
|
+
drainInProgress = false;
|
|
653
|
+
}
|
|
654
|
+
},
|
|
655
|
+
markEmitted: async (digest) => {
|
|
656
|
+
const entries = await store.read(lastEmittedSequence, batchSize);
|
|
657
|
+
const entry = entries.find((e) => e.entry_digest === digest);
|
|
658
|
+
if (entry) {
|
|
659
|
+
if (entry.sequence > lastEmittedSequence) {
|
|
660
|
+
lastEmittedSequence = entry.sequence;
|
|
661
|
+
}
|
|
662
|
+
await dedupe.markEmitted(entry.action.id);
|
|
663
|
+
}
|
|
664
|
+
},
|
|
665
|
+
onError
|
|
666
|
+
});
|
|
667
|
+
return {
|
|
668
|
+
session,
|
|
669
|
+
hookHandler,
|
|
670
|
+
backgroundService,
|
|
671
|
+
start() {
|
|
672
|
+
stats.isRunning = true;
|
|
673
|
+
backgroundService.start();
|
|
674
|
+
},
|
|
675
|
+
async stop() {
|
|
676
|
+
backgroundService.stop();
|
|
677
|
+
await backgroundService.drain();
|
|
678
|
+
await hookHandler.close();
|
|
679
|
+
stats.isRunning = false;
|
|
680
|
+
},
|
|
681
|
+
getStats() {
|
|
682
|
+
return { ...stats };
|
|
683
|
+
}
|
|
684
|
+
};
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// src/tools.ts
|
|
688
|
+
var MAX_STATUS_FILE_SCAN = 1e3;
|
|
689
|
+
var MAX_QUERY_FILE_PARSE = 1e4;
|
|
690
|
+
var MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024;
|
|
691
|
+
var ALLOWED_ALGORITHMS = ["EdDSA", "ES256", "ES384", "ES512", "RS256", "RS384", "RS512"];
|
|
692
|
+
var ALG_KEY_TYPE_MAP = {
|
|
693
|
+
EdDSA: { kty: "OKP", crv: ["Ed25519", "Ed448"] },
|
|
694
|
+
ES256: { kty: "EC", crv: ["P-256"] },
|
|
695
|
+
ES384: { kty: "EC", crv: ["P-384"] },
|
|
696
|
+
ES512: { kty: "EC", crv: ["P-521"] },
|
|
697
|
+
RS256: { kty: "RSA" },
|
|
698
|
+
RS384: { kty: "RSA" },
|
|
699
|
+
RS512: { kty: "RSA" }
|
|
700
|
+
};
|
|
701
|
+
function createStatusTool(stats, outputDir) {
|
|
702
|
+
return {
|
|
703
|
+
name: "peac_receipts.status",
|
|
704
|
+
description: "Show PEAC receipts status: spool size, last receipt time, config summary",
|
|
705
|
+
parameters: {},
|
|
706
|
+
async execute() {
|
|
707
|
+
const fs = await import('fs');
|
|
708
|
+
const pathModule = await import('path');
|
|
709
|
+
const resolvedOutputDir = pathModule.resolve(outputDir);
|
|
710
|
+
let receiptCount = 0;
|
|
711
|
+
let lastReceiptTime = null;
|
|
712
|
+
let oldestReceiptTime = null;
|
|
713
|
+
let countApproximate = false;
|
|
714
|
+
try {
|
|
715
|
+
const files = await fs.promises.readdir(resolvedOutputDir);
|
|
716
|
+
const receiptFiles = files.filter((f) => f.endsWith(".peac.json"));
|
|
717
|
+
receiptCount = receiptFiles.length;
|
|
718
|
+
const filesToScan = receiptFiles.slice(0, MAX_STATUS_FILE_SCAN);
|
|
719
|
+
countApproximate = receiptFiles.length > MAX_STATUS_FILE_SCAN;
|
|
720
|
+
if (filesToScan.length > 0) {
|
|
721
|
+
const fileStats = await Promise.all(
|
|
722
|
+
filesToScan.map(async (f) => {
|
|
723
|
+
const stat = await fs.promises.stat(pathModule.join(resolvedOutputDir, f));
|
|
724
|
+
return { file: f, mtime: stat.mtime };
|
|
725
|
+
})
|
|
726
|
+
);
|
|
727
|
+
fileStats.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
|
|
728
|
+
lastReceiptTime = fileStats[0].mtime.toISOString();
|
|
729
|
+
oldestReceiptTime = fileStats[fileStats.length - 1].mtime.toISOString();
|
|
730
|
+
}
|
|
731
|
+
} catch {
|
|
732
|
+
}
|
|
733
|
+
return {
|
|
734
|
+
status: "ok",
|
|
735
|
+
spool: {
|
|
736
|
+
pending_entries: stats.pendingCount,
|
|
737
|
+
total_captured: stats.totalCaptured,
|
|
738
|
+
duplicates_skipped: stats.duplicatesSkipped
|
|
739
|
+
},
|
|
740
|
+
receipts: {
|
|
741
|
+
count: receiptCount,
|
|
742
|
+
count_approximate: countApproximate,
|
|
743
|
+
output_dir: resolvedOutputDir,
|
|
744
|
+
last_receipt_time: lastReceiptTime,
|
|
745
|
+
oldest_receipt_time: oldestReceiptTime
|
|
746
|
+
},
|
|
747
|
+
emitter: {
|
|
748
|
+
total_emitted: stats.totalEmitted,
|
|
749
|
+
total_errors: stats.totalErrors,
|
|
750
|
+
is_running: stats.isRunning,
|
|
751
|
+
key_id: stats.keyId
|
|
752
|
+
}
|
|
753
|
+
};
|
|
754
|
+
}
|
|
755
|
+
};
|
|
756
|
+
}
|
|
757
|
+
function createExportBundleTool(outputDir, logger) {
|
|
758
|
+
return {
|
|
759
|
+
name: "peac_receipts.export_bundle",
|
|
760
|
+
description: "Export receipts as a bundle directory for audit (manifest.json + receipts/)",
|
|
761
|
+
parameters: {
|
|
762
|
+
type: "object",
|
|
763
|
+
properties: {
|
|
764
|
+
workflow_id: {
|
|
765
|
+
type: "string",
|
|
766
|
+
description: "Filter by workflow ID"
|
|
767
|
+
},
|
|
768
|
+
since: {
|
|
769
|
+
type: "string",
|
|
770
|
+
description: "Include receipts since this RFC 3339 timestamp"
|
|
771
|
+
},
|
|
772
|
+
until: {
|
|
773
|
+
type: "string",
|
|
774
|
+
description: "Include receipts until this RFC 3339 timestamp"
|
|
775
|
+
},
|
|
776
|
+
output_path: {
|
|
777
|
+
type: "string",
|
|
778
|
+
description: "Output path for bundle directory (default: peac-bundle-{timestamp})"
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
},
|
|
782
|
+
async execute(params) {
|
|
783
|
+
const fs = await import('fs');
|
|
784
|
+
const pathModule = await import('path');
|
|
785
|
+
const resolvedOutputDir = pathModule.resolve(outputDir);
|
|
786
|
+
try {
|
|
787
|
+
const files = await fs.promises.readdir(resolvedOutputDir);
|
|
788
|
+
const receiptFiles = files.filter((f) => f.endsWith(".peac.json"));
|
|
789
|
+
if (receiptFiles.length === 0) {
|
|
790
|
+
return {
|
|
791
|
+
status: "ok",
|
|
792
|
+
message: "No receipts to export",
|
|
793
|
+
receipt_count: 0
|
|
794
|
+
};
|
|
795
|
+
}
|
|
796
|
+
const receipts = [];
|
|
797
|
+
for (const file of receiptFiles) {
|
|
798
|
+
const filePath = pathModule.join(resolvedOutputDir, file);
|
|
799
|
+
let stat;
|
|
800
|
+
try {
|
|
801
|
+
stat = await fs.promises.stat(filePath);
|
|
802
|
+
} catch {
|
|
803
|
+
continue;
|
|
804
|
+
}
|
|
805
|
+
if (params.since && stat.mtime < new Date(params.since)) {
|
|
806
|
+
continue;
|
|
807
|
+
}
|
|
808
|
+
if (params.until && stat.mtime > new Date(params.until)) {
|
|
809
|
+
continue;
|
|
810
|
+
}
|
|
811
|
+
let content;
|
|
812
|
+
try {
|
|
813
|
+
content = JSON.parse(await fs.promises.readFile(filePath, "utf-8"));
|
|
814
|
+
} catch {
|
|
815
|
+
logger.warn(`Skipping invalid JSON in ${file}`);
|
|
816
|
+
continue;
|
|
817
|
+
}
|
|
818
|
+
if (params.workflow_id) {
|
|
819
|
+
const workflowExt = content?.auth?.extensions?.["org.peacprotocol/workflow"];
|
|
820
|
+
if (workflowExt?.workflow_id !== params.workflow_id) {
|
|
821
|
+
continue;
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
receipts.push({ file, content, mtime: stat.mtime });
|
|
825
|
+
}
|
|
826
|
+
if (receipts.length === 0) {
|
|
827
|
+
return {
|
|
828
|
+
status: "ok",
|
|
829
|
+
message: "No receipts match the filter criteria",
|
|
830
|
+
receipt_count: 0
|
|
831
|
+
};
|
|
832
|
+
}
|
|
833
|
+
const manifest = {
|
|
834
|
+
version: "1.0",
|
|
835
|
+
format: "peac-bundle-directory",
|
|
836
|
+
created_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
837
|
+
receipt_count: receipts.length,
|
|
838
|
+
filters: {
|
|
839
|
+
workflow_id: params.workflow_id || null,
|
|
840
|
+
since: params.since || null,
|
|
841
|
+
until: params.until || null
|
|
842
|
+
},
|
|
843
|
+
receipts: receipts.map((r) => ({
|
|
844
|
+
file: r.file,
|
|
845
|
+
mtime: r.mtime.toISOString()
|
|
846
|
+
}))
|
|
847
|
+
};
|
|
848
|
+
const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
|
|
849
|
+
const bundleDir = params.output_path ? pathModule.resolve(params.output_path) : pathModule.join(resolvedOutputDir, `peac-bundle-${timestamp}`);
|
|
850
|
+
await fs.promises.mkdir(bundleDir, { recursive: true });
|
|
851
|
+
await fs.promises.writeFile(
|
|
852
|
+
pathModule.join(bundleDir, "manifest.json"),
|
|
853
|
+
JSON.stringify(manifest, null, 2)
|
|
854
|
+
);
|
|
855
|
+
const receiptsDir = pathModule.join(bundleDir, "receipts");
|
|
856
|
+
await fs.promises.mkdir(receiptsDir, { recursive: true });
|
|
857
|
+
for (const receipt of receipts) {
|
|
858
|
+
await fs.promises.writeFile(
|
|
859
|
+
pathModule.join(receiptsDir, receipt.file),
|
|
860
|
+
JSON.stringify(receipt.content, null, 2)
|
|
861
|
+
);
|
|
862
|
+
}
|
|
863
|
+
logger.info(`Exported ${receipts.length} receipts to ${bundleDir}`);
|
|
864
|
+
return {
|
|
865
|
+
status: "ok",
|
|
866
|
+
message: `Exported ${receipts.length} receipts to directory`,
|
|
867
|
+
receipt_count: receipts.length,
|
|
868
|
+
bundle_path: bundleDir
|
|
869
|
+
};
|
|
870
|
+
} catch (error) {
|
|
871
|
+
logger.error("Export bundle failed:", error);
|
|
872
|
+
return {
|
|
873
|
+
status: "error",
|
|
874
|
+
message: error instanceof Error ? error.message : "Unknown error",
|
|
875
|
+
receipt_count: 0
|
|
876
|
+
};
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
};
|
|
880
|
+
}
|
|
881
|
+
function createVerifyTool(logger) {
|
|
882
|
+
return {
|
|
883
|
+
name: "peac_receipts.verify",
|
|
884
|
+
description: "Verify a receipt or bundle offline",
|
|
885
|
+
parameters: {
|
|
886
|
+
type: "object",
|
|
887
|
+
properties: {
|
|
888
|
+
path: {
|
|
889
|
+
type: "string",
|
|
890
|
+
description: "Path to receipt file or bundle directory"
|
|
891
|
+
},
|
|
892
|
+
jwks_path: {
|
|
893
|
+
type: "string",
|
|
894
|
+
description: "Path to JWKS file for key verification (optional)"
|
|
895
|
+
}
|
|
896
|
+
},
|
|
897
|
+
required: ["path"]
|
|
898
|
+
},
|
|
899
|
+
async execute(params) {
|
|
900
|
+
const verifyParams = params;
|
|
901
|
+
const fs = await import('fs');
|
|
902
|
+
const pathModule = await import('path');
|
|
903
|
+
try {
|
|
904
|
+
const stat = await fs.promises.stat(verifyParams.path);
|
|
905
|
+
if (stat.isDirectory()) {
|
|
906
|
+
return await verifyBundle(
|
|
907
|
+
verifyParams.path,
|
|
908
|
+
verifyParams.jwks_path,
|
|
909
|
+
fs,
|
|
910
|
+
pathModule,
|
|
911
|
+
logger
|
|
912
|
+
);
|
|
913
|
+
} else {
|
|
914
|
+
return await verifySingleReceipt(verifyParams.path, verifyParams.jwks_path, fs, logger);
|
|
915
|
+
}
|
|
916
|
+
} catch (error) {
|
|
917
|
+
logger.error("Verification failed:", error);
|
|
918
|
+
return {
|
|
919
|
+
status: "error",
|
|
920
|
+
valid: false,
|
|
921
|
+
message: error instanceof Error ? error.message : "Unknown error",
|
|
922
|
+
errors: [error instanceof Error ? error.message : "Unknown error"]
|
|
923
|
+
};
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
};
|
|
927
|
+
}
|
|
928
|
+
async function verifySingleReceipt(receiptPath, jwksPath, fs, logger) {
|
|
929
|
+
const stat = await fs.promises.stat(receiptPath);
|
|
930
|
+
if (stat.size > MAX_FILE_SIZE_BYTES) {
|
|
931
|
+
return {
|
|
932
|
+
status: "error",
|
|
933
|
+
valid: false,
|
|
934
|
+
message: `File too large: ${stat.size} bytes exceeds limit of ${MAX_FILE_SIZE_BYTES} bytes`,
|
|
935
|
+
errors: [`File size ${stat.size} bytes exceeds ${MAX_FILE_SIZE_BYTES} byte limit`]
|
|
936
|
+
};
|
|
937
|
+
}
|
|
938
|
+
const content = await fs.promises.readFile(receiptPath, "utf-8");
|
|
939
|
+
const receipt = JSON.parse(content);
|
|
940
|
+
const errors = [];
|
|
941
|
+
const warnings = [];
|
|
942
|
+
if (!receipt.auth) {
|
|
943
|
+
errors.push("Missing auth block");
|
|
944
|
+
}
|
|
945
|
+
if (!receipt.evidence) {
|
|
946
|
+
errors.push("Missing evidence block");
|
|
947
|
+
}
|
|
948
|
+
const interaction = receipt.evidence?.extensions?.["org.peacprotocol/interaction@0.1"];
|
|
949
|
+
if (interaction) {
|
|
950
|
+
if (!interaction.interaction_id) {
|
|
951
|
+
errors.push("Missing interaction_id");
|
|
952
|
+
}
|
|
953
|
+
if (!interaction.kind) {
|
|
954
|
+
errors.push("Missing kind");
|
|
955
|
+
}
|
|
956
|
+
if (!interaction.executor?.platform) {
|
|
957
|
+
errors.push("Missing executor.platform");
|
|
958
|
+
}
|
|
959
|
+
if (!interaction.started_at) {
|
|
960
|
+
errors.push("Missing started_at");
|
|
961
|
+
}
|
|
962
|
+
if (interaction.completed_at && interaction.started_at) {
|
|
963
|
+
if (new Date(interaction.completed_at) < new Date(interaction.started_at)) {
|
|
964
|
+
errors.push("completed_at is before started_at");
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
if (interaction.output && !interaction.result?.status) {
|
|
968
|
+
errors.push("output present but result.status missing");
|
|
969
|
+
}
|
|
970
|
+
} else {
|
|
971
|
+
warnings.push("No interaction evidence found");
|
|
972
|
+
}
|
|
973
|
+
if (jwksPath && receipt._jws) {
|
|
974
|
+
try {
|
|
975
|
+
const { verify, base64urlDecode: base64urlDecode2, base64urlDecodeString } = await import('@peac/crypto');
|
|
976
|
+
const jwks = JSON.parse(await fs.promises.readFile(jwksPath, "utf-8"));
|
|
977
|
+
const jwsParts = receipt._jws.split(".");
|
|
978
|
+
if (jwsParts.length !== 3) {
|
|
979
|
+
errors.push("Invalid JWS format (expected 3 parts)");
|
|
980
|
+
} else {
|
|
981
|
+
const headerJson = base64urlDecodeString(jwsParts[0]);
|
|
982
|
+
const header = JSON.parse(headerJson);
|
|
983
|
+
if (header.alg === "none") {
|
|
984
|
+
errors.push('Algorithm "none" is not allowed');
|
|
985
|
+
}
|
|
986
|
+
if (header.alg && !ALLOWED_ALGORITHMS.includes(header.alg)) {
|
|
987
|
+
errors.push(
|
|
988
|
+
`Algorithm "${header.alg}" is not in allowed list: ${ALLOWED_ALGORITHMS.join(", ")}`
|
|
989
|
+
);
|
|
990
|
+
}
|
|
991
|
+
if (header.crit && header.crit.length > 0) {
|
|
992
|
+
const headerObj = header;
|
|
993
|
+
const missingCrit = header.crit.filter((h) => !(h in headerObj));
|
|
994
|
+
if (missingCrit.length > 0) {
|
|
995
|
+
errors.push(`Critical headers declared but missing: ${missingCrit.join(", ")}`);
|
|
996
|
+
}
|
|
997
|
+
errors.push(`Unsupported critical headers: ${header.crit.join(", ")}`);
|
|
998
|
+
}
|
|
999
|
+
const targetKid = header.kid;
|
|
1000
|
+
let keyJwk;
|
|
1001
|
+
if (!Array.isArray(jwks.keys) || jwks.keys.length === 0) {
|
|
1002
|
+
errors.push("JWKS has no keys");
|
|
1003
|
+
} else if (targetKid) {
|
|
1004
|
+
keyJwk = jwks.keys.find((k) => k.kid === targetKid);
|
|
1005
|
+
if (!keyJwk) {
|
|
1006
|
+
errors.push(`Key with kid "${targetKid}" not found in JWKS`);
|
|
1007
|
+
}
|
|
1008
|
+
} else if (jwks.keys.length === 1) {
|
|
1009
|
+
keyJwk = jwks.keys[0];
|
|
1010
|
+
warnings.push("JWS has no kid - using only key from JWKS");
|
|
1011
|
+
} else {
|
|
1012
|
+
errors.push(`JWS missing kid but JWKS has ${jwks.keys.length} keys - cannot select`);
|
|
1013
|
+
}
|
|
1014
|
+
if (keyJwk && header.alg && errors.length === 0) {
|
|
1015
|
+
const expectedKeyType = ALG_KEY_TYPE_MAP[header.alg];
|
|
1016
|
+
if (expectedKeyType) {
|
|
1017
|
+
if (keyJwk.kty !== expectedKeyType.kty) {
|
|
1018
|
+
errors.push(
|
|
1019
|
+
`Algorithm "${header.alg}" requires key type "${expectedKeyType.kty}" but key has "${keyJwk.kty}"`
|
|
1020
|
+
);
|
|
1021
|
+
} else if (expectedKeyType.crv && keyJwk.crv && !expectedKeyType.crv.includes(keyJwk.crv)) {
|
|
1022
|
+
errors.push(
|
|
1023
|
+
`Algorithm "${header.alg}" requires curve ${expectedKeyType.crv.join(" or ")} but key has "${keyJwk.crv}"`
|
|
1024
|
+
);
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
if (keyJwk.use && keyJwk.use !== "sig") {
|
|
1028
|
+
errors.push(
|
|
1029
|
+
`Key use "${keyJwk.use}" is not valid for signature verification (expected "sig")`
|
|
1030
|
+
);
|
|
1031
|
+
}
|
|
1032
|
+
if (keyJwk.key_ops && !keyJwk.key_ops.includes("verify")) {
|
|
1033
|
+
errors.push(`Key operations ${JSON.stringify(keyJwk.key_ops)} do not include "verify"`);
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
if (keyJwk && errors.length === 0) {
|
|
1037
|
+
const publicKeyParam = keyJwk.x || keyJwk.n;
|
|
1038
|
+
if (!publicKeyParam) {
|
|
1039
|
+
errors.push("Selected key missing public key (x or n parameter)");
|
|
1040
|
+
} else {
|
|
1041
|
+
const publicKeyBytes = base64urlDecode2(publicKeyParam);
|
|
1042
|
+
await verify(receipt._jws, publicKeyBytes);
|
|
1043
|
+
logger.info(`Signature verified with key ${keyJwk.kid || "anonymous"}`);
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
} catch (error) {
|
|
1048
|
+
errors.push(
|
|
1049
|
+
`Signature verification failed: ${error instanceof Error ? error.message : "Unknown"}`
|
|
1050
|
+
);
|
|
1051
|
+
}
|
|
1052
|
+
} else if (!jwksPath) {
|
|
1053
|
+
warnings.push("No JWKS provided - signature not verified");
|
|
1054
|
+
}
|
|
1055
|
+
return {
|
|
1056
|
+
status: errors.length === 0 ? "ok" : "error",
|
|
1057
|
+
valid: errors.length === 0,
|
|
1058
|
+
message: errors.length === 0 ? "Receipt is valid" : "Receipt has validation errors",
|
|
1059
|
+
errors: errors.length > 0 ? errors : void 0,
|
|
1060
|
+
warnings: warnings.length > 0 ? warnings : void 0,
|
|
1061
|
+
receipt_id: receipt.auth?.rid,
|
|
1062
|
+
interaction_id: interaction?.interaction_id
|
|
1063
|
+
};
|
|
1064
|
+
}
|
|
1065
|
+
async function verifyBundle(bundlePath, jwksPath, fs, pathModule, logger) {
|
|
1066
|
+
const manifestPath = pathModule.join(bundlePath, "manifest.json");
|
|
1067
|
+
const manifest = JSON.parse(await fs.promises.readFile(manifestPath, "utf-8"));
|
|
1068
|
+
const receiptsDir = pathModule.join(bundlePath, "receipts");
|
|
1069
|
+
const files = await fs.promises.readdir(receiptsDir);
|
|
1070
|
+
let validCount = 0;
|
|
1071
|
+
let invalidCount = 0;
|
|
1072
|
+
const allErrors = [];
|
|
1073
|
+
const allWarnings = [];
|
|
1074
|
+
for (const file of files) {
|
|
1075
|
+
if (!file.endsWith(".peac.json")) continue;
|
|
1076
|
+
const result = await verifySingleReceipt(
|
|
1077
|
+
pathModule.join(receiptsDir, file),
|
|
1078
|
+
jwksPath,
|
|
1079
|
+
fs,
|
|
1080
|
+
logger
|
|
1081
|
+
);
|
|
1082
|
+
if (result.valid) {
|
|
1083
|
+
validCount++;
|
|
1084
|
+
} else {
|
|
1085
|
+
invalidCount++;
|
|
1086
|
+
if (result.errors) {
|
|
1087
|
+
allErrors.push(...result.errors.map((e) => `${file}: ${e}`));
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
if (result.warnings) {
|
|
1091
|
+
allWarnings.push(...result.warnings.map((w) => `${file}: ${w}`));
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
return {
|
|
1095
|
+
status: invalidCount === 0 ? "ok" : "error",
|
|
1096
|
+
valid: invalidCount === 0,
|
|
1097
|
+
message: `Bundle verification: ${validCount} valid, ${invalidCount} invalid`,
|
|
1098
|
+
errors: allErrors.length > 0 ? allErrors : void 0,
|
|
1099
|
+
warnings: allWarnings.length > 0 ? allWarnings : void 0,
|
|
1100
|
+
bundle_stats: {
|
|
1101
|
+
total: validCount + invalidCount,
|
|
1102
|
+
valid: validCount,
|
|
1103
|
+
invalid: invalidCount,
|
|
1104
|
+
manifest_receipt_count: manifest.receipt_count
|
|
1105
|
+
}
|
|
1106
|
+
};
|
|
1107
|
+
}
|
|
1108
|
+
function createQueryTool(outputDir, logger) {
|
|
1109
|
+
return {
|
|
1110
|
+
name: "peac_receipts.query",
|
|
1111
|
+
description: "Query receipts by workflow_id, tool name, or time range",
|
|
1112
|
+
parameters: {
|
|
1113
|
+
type: "object",
|
|
1114
|
+
properties: {
|
|
1115
|
+
workflow_id: {
|
|
1116
|
+
type: "string",
|
|
1117
|
+
description: "Filter by workflow ID"
|
|
1118
|
+
},
|
|
1119
|
+
tool_name: {
|
|
1120
|
+
type: "string",
|
|
1121
|
+
description: "Filter by tool name"
|
|
1122
|
+
},
|
|
1123
|
+
since: {
|
|
1124
|
+
type: "string",
|
|
1125
|
+
description: "Include receipts since this RFC 3339 timestamp"
|
|
1126
|
+
},
|
|
1127
|
+
until: {
|
|
1128
|
+
type: "string",
|
|
1129
|
+
description: "Include receipts until this RFC 3339 timestamp"
|
|
1130
|
+
},
|
|
1131
|
+
status: {
|
|
1132
|
+
type: "string",
|
|
1133
|
+
enum: ["ok", "error", "timeout", "canceled"],
|
|
1134
|
+
description: "Filter by result status"
|
|
1135
|
+
},
|
|
1136
|
+
limit: {
|
|
1137
|
+
type: "integer",
|
|
1138
|
+
description: "Maximum number of results (default: 100)"
|
|
1139
|
+
},
|
|
1140
|
+
offset: {
|
|
1141
|
+
type: "integer",
|
|
1142
|
+
description: "Skip this many results (for pagination)"
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
},
|
|
1146
|
+
async execute(params) {
|
|
1147
|
+
const fs = await import('fs');
|
|
1148
|
+
const pathModule = await import('path');
|
|
1149
|
+
const resolvedOutputDir = pathModule.resolve(outputDir);
|
|
1150
|
+
try {
|
|
1151
|
+
const files = await fs.promises.readdir(resolvedOutputDir);
|
|
1152
|
+
const receiptFiles = files.filter((f) => f.endsWith(".peac.json"));
|
|
1153
|
+
const limit = Math.min(params.limit || 100, MAX_QUERY_FILE_PARSE);
|
|
1154
|
+
const offset = params.offset || 0;
|
|
1155
|
+
const fileInfos = [];
|
|
1156
|
+
for (const file of receiptFiles.slice(0, MAX_QUERY_FILE_PARSE)) {
|
|
1157
|
+
try {
|
|
1158
|
+
const filePath = pathModule.join(resolvedOutputDir, file);
|
|
1159
|
+
const stat = await fs.promises.stat(filePath);
|
|
1160
|
+
if (params.since && stat.mtime < new Date(params.since)) {
|
|
1161
|
+
continue;
|
|
1162
|
+
}
|
|
1163
|
+
if (params.until && stat.mtime > new Date(params.until)) {
|
|
1164
|
+
continue;
|
|
1165
|
+
}
|
|
1166
|
+
fileInfos.push({ file, mtime: stat.mtime });
|
|
1167
|
+
} catch {
|
|
1168
|
+
continue;
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
fileInfos.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
|
|
1172
|
+
const matches = [];
|
|
1173
|
+
let skippedForFilters = 0;
|
|
1174
|
+
let filesSkippedForSize = 0;
|
|
1175
|
+
let filesSkippedForInvalidJson = 0;
|
|
1176
|
+
for (const { file } of fileInfos) {
|
|
1177
|
+
const filePath = pathModule.join(resolvedOutputDir, file);
|
|
1178
|
+
try {
|
|
1179
|
+
const fileStat = await fs.promises.stat(filePath);
|
|
1180
|
+
if (fileStat.size > MAX_FILE_SIZE_BYTES) {
|
|
1181
|
+
filesSkippedForSize++;
|
|
1182
|
+
continue;
|
|
1183
|
+
}
|
|
1184
|
+
} catch {
|
|
1185
|
+
continue;
|
|
1186
|
+
}
|
|
1187
|
+
let content;
|
|
1188
|
+
try {
|
|
1189
|
+
content = JSON.parse(await fs.promises.readFile(filePath, "utf-8"));
|
|
1190
|
+
} catch {
|
|
1191
|
+
filesSkippedForInvalidJson++;
|
|
1192
|
+
continue;
|
|
1193
|
+
}
|
|
1194
|
+
const interaction = content?.evidence?.extensions?.["org.peacprotocol/interaction@0.1"];
|
|
1195
|
+
const workflow = content?.auth?.extensions?.["org.peacprotocol/workflow"];
|
|
1196
|
+
if (params.workflow_id && workflow?.workflow_id !== params.workflow_id) {
|
|
1197
|
+
skippedForFilters++;
|
|
1198
|
+
continue;
|
|
1199
|
+
}
|
|
1200
|
+
if (params.tool_name && interaction?.tool?.name !== params.tool_name) {
|
|
1201
|
+
skippedForFilters++;
|
|
1202
|
+
continue;
|
|
1203
|
+
}
|
|
1204
|
+
if (params.status && interaction?.result?.status !== params.status) {
|
|
1205
|
+
skippedForFilters++;
|
|
1206
|
+
continue;
|
|
1207
|
+
}
|
|
1208
|
+
matches.push({
|
|
1209
|
+
file,
|
|
1210
|
+
receipt_id: content.auth?.rid,
|
|
1211
|
+
interaction_id: interaction?.interaction_id,
|
|
1212
|
+
workflow_id: workflow?.workflow_id,
|
|
1213
|
+
tool_name: interaction?.tool?.name,
|
|
1214
|
+
status: interaction?.result?.status,
|
|
1215
|
+
started_at: interaction?.started_at,
|
|
1216
|
+
completed_at: interaction?.completed_at
|
|
1217
|
+
});
|
|
1218
|
+
if (matches.length >= offset + limit + 100) {
|
|
1219
|
+
break;
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
matches.sort((a, b) => {
|
|
1223
|
+
const aTime = a.started_at ? new Date(a.started_at).getTime() : 0;
|
|
1224
|
+
const bTime = b.started_at ? new Date(b.started_at).getTime() : 0;
|
|
1225
|
+
if (bTime !== aTime) return bTime - aTime;
|
|
1226
|
+
return a.file.localeCompare(b.file);
|
|
1227
|
+
});
|
|
1228
|
+
const paginated = matches.slice(offset, offset + limit);
|
|
1229
|
+
const warnings = [];
|
|
1230
|
+
if (receiptFiles.length > MAX_QUERY_FILE_PARSE) {
|
|
1231
|
+
warnings.push(`Results capped at ${MAX_QUERY_FILE_PARSE} files`);
|
|
1232
|
+
}
|
|
1233
|
+
if (filesSkippedForSize > 0) {
|
|
1234
|
+
warnings.push(
|
|
1235
|
+
`${filesSkippedForSize} files skipped (exceeded ${MAX_FILE_SIZE_BYTES} byte limit)`
|
|
1236
|
+
);
|
|
1237
|
+
}
|
|
1238
|
+
if (filesSkippedForInvalidJson > 0) {
|
|
1239
|
+
warnings.push(`${filesSkippedForInvalidJson} files skipped (invalid JSON)`);
|
|
1240
|
+
}
|
|
1241
|
+
const hasTruncation = receiptFiles.length > MAX_QUERY_FILE_PARSE || filesSkippedForSize > 0 || filesSkippedForInvalidJson > 0;
|
|
1242
|
+
return {
|
|
1243
|
+
status: "ok",
|
|
1244
|
+
total: matches.length,
|
|
1245
|
+
offset,
|
|
1246
|
+
limit,
|
|
1247
|
+
results: paginated,
|
|
1248
|
+
truncated: hasTruncation,
|
|
1249
|
+
skipped: hasTruncation ? {
|
|
1250
|
+
too_large: filesSkippedForSize,
|
|
1251
|
+
invalid_json: filesSkippedForInvalidJson,
|
|
1252
|
+
capped: receiptFiles.length > MAX_QUERY_FILE_PARSE ? receiptFiles.length - MAX_QUERY_FILE_PARSE : 0
|
|
1253
|
+
} : void 0,
|
|
1254
|
+
...warnings.length > 0 && { warning: warnings.join("; ") }
|
|
1255
|
+
};
|
|
1256
|
+
} catch (error) {
|
|
1257
|
+
logger.error("Query failed:", error);
|
|
1258
|
+
return {
|
|
1259
|
+
status: "error",
|
|
1260
|
+
total: 0,
|
|
1261
|
+
offset: 0,
|
|
1262
|
+
limit: params.limit || 100,
|
|
1263
|
+
results: [],
|
|
1264
|
+
error: error instanceof Error ? error.message : "Unknown error"
|
|
1265
|
+
};
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
};
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
export { OPENCLAW_ERROR_CODES, OPENCLAW_EXTENSION_KEYS, OPENCLAW_WARNING_CODES, buildWorkflowContext, captureBatch, captureParallel, createBackgroundService, createExportBundleTool, createFileReceiptWriter, createHookHandler, createJwkSigner, createPluginInstance, createQueryTool, createReceiptEmitter, createSessionHistoryTailer, createStatusTool, createVerifyTool, extractWorkflowId, generateKeyId, mapToolCallEvent, mapToolCallEventBatch, resolveSigner };
|
|
1272
|
+
//# sourceMappingURL=index.mjs.map
|
|
1273
|
+
//# sourceMappingURL=index.mjs.map
|