pi-background-tasks 0.9.0 → 1.0.4
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/BACKGROUND-TASKS-INSTRUCTIONS.md +63 -0
- package/PUBLISHING.md +43 -29
- package/README.md +233 -441
- package/TESTING.md +16 -10
- package/TEST_PLAN.md +43 -17
- package/docs/INDEX.md +157 -0
- package/docs/api/eventbus-v1.md +166 -0
- package/docs/assets/architecture.svg +78 -0
- package/docs/assets/footer-dock.svg +47 -0
- package/docs/assets/logo.svg +49 -0
- package/docs/attestations.json +189 -0
- package/docs/choose-a-workflow.md +98 -0
- package/docs/commands/bg-clear.md +70 -0
- package/docs/commands/bg-update.md +82 -0
- package/docs/commands/bg.md +90 -0
- package/docs/commands/fusion-models.md +70 -0
- package/docs/commands/fusion.md +69 -0
- package/docs/commands/jobs.md +74 -0
- package/docs/commands/kill.md +82 -0
- package/docs/commands/logs.md +90 -0
- package/docs/commands/task-manager.md +109 -0
- package/docs/concepts/completion-delivery.md +66 -0
- package/docs/concepts/context-projection-and-budgeting.md +79 -0
- package/docs/getting-started.md +122 -0
- package/docs/manifest.json +1825 -0
- package/docs/operations/configuration.md +110 -0
- package/docs/operations/releasing.md +67 -0
- package/docs/operations/testing.md +101 -0
- package/docs/operations/troubleshooting.md +38 -0
- package/docs/read-before-edit.md +94 -0
- package/docs/reference/runtime-contracts.md +213 -0
- package/docs/reference/shortcuts-and-dock.md +70 -0
- package/docs/subsystems/attested-pi-runs.md +141 -0
- package/docs/subsystems/background-task-runtime.md +85 -0
- package/docs/subsystems/child-launch-durability-and-safety.md +57 -0
- package/docs/subsystems/delegation.md +190 -0
- package/docs/subsystems/docs-freshness-gate.md +26 -0
- package/docs/subsystems/fusion.md +123 -0
- package/docs/subsystems/host-ui-and-telemetry.md +83 -0
- package/docs/tools/bg_delegate.md +193 -0
- package/docs/tools/bg_kill.md +114 -0
- package/docs/tools/bg_logs.md +133 -0
- package/docs/tools/bg_result.md +120 -0
- package/docs/tools/bg_run.md +168 -0
- package/docs/tools/bg_run_pi_attested.md +170 -0
- package/docs/tools/bg_status.md +111 -0
- package/docs/tools/fusion_investigate.md +116 -0
- package/docs/tools/fusion_reason.md +75 -0
- package/docs/tools/fusion_research.md +162 -0
- package/docs/tools/fusion_validate.md +206 -0
- package/logo.png +0 -0
- package/package.json +27 -9
- package/src/core/delegate/budget.ts +1 -1
- package/src/core/delegate/launch.ts +5 -0
- package/src/core/fusion/artifacts.ts +34 -4
- package/src/core/fusion/budget.ts +112 -20
- package/src/core/fusion/child-protocol.ts +82 -0
- package/src/core/fusion/clean-context.ts +91 -0
- package/src/core/fusion/config.ts +124 -35
- package/src/core/fusion/context.ts +29 -7
- package/src/core/fusion/evaluation.ts +392 -15
- package/src/core/fusion/orchestrator.ts +217 -23
- package/src/core/fusion/pi-child.ts +227 -24
- package/src/core/fusion/prompts.ts +39 -26
- package/src/core/fusion/source-policy.ts +257 -0
- package/src/core/fusion/types.ts +156 -11
- package/src/core/fusion/web-fetch.ts +104 -15
- package/src/core/fusion/workflows.ts +119 -65
- package/src/extension.ts +3 -3
- package/src/fusion-child-extension.ts +375 -141
- package/src/fusion-extension.ts +585 -240
- package/src/testing/normalize.ts +0 -22
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
|
-
import {
|
|
3
|
-
|
|
2
|
+
import {
|
|
3
|
+
closeSync,
|
|
4
|
+
constants,
|
|
5
|
+
fstatSync,
|
|
6
|
+
fsyncSync,
|
|
7
|
+
openSync,
|
|
8
|
+
readFileSync,
|
|
9
|
+
writeSync,
|
|
10
|
+
} from 'node:fs';
|
|
11
|
+
import { dirname } from 'node:path';
|
|
4
12
|
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
|
5
13
|
import { Type, type Static } from 'typebox';
|
|
6
14
|
import {
|
|
@@ -13,22 +21,40 @@ import {
|
|
|
13
21
|
FusionWebFetchError,
|
|
14
22
|
FUSION_WEB_FETCH_TIMEOUT_MS,
|
|
15
23
|
} from './core/fusion/web-fetch.js';
|
|
24
|
+
import {
|
|
25
|
+
canonicalizeFusionPublicUrl,
|
|
26
|
+
parseFusionSourcePolicy,
|
|
27
|
+
} from './core/fusion/source-policy.js';
|
|
28
|
+
import {
|
|
29
|
+
FUSION_CHILD_MAX_TOTAL_TOOL_RESULT_BYTES,
|
|
30
|
+
FUSION_CHILD_RESULT_PREFIX,
|
|
31
|
+
FUSION_RESEARCH_ENABLED_ENV,
|
|
32
|
+
FUSION_SOURCE_POLICY_PATH_ENV,
|
|
33
|
+
FUSION_SOURCE_POLICY_SHA256_ENV,
|
|
34
|
+
FUSION_TOOL_CALL_LOG_PATH_ENV,
|
|
35
|
+
FUSION_TOOL_CALL_SEAL_SCHEMA_VERSION,
|
|
36
|
+
FUSION_TOOL_CALL_SEAL_SUFFIX,
|
|
37
|
+
buildFusionChildResultMetadata,
|
|
38
|
+
type FusionChildResultMetadata,
|
|
39
|
+
} from './core/fusion/child-protocol.js';
|
|
16
40
|
|
|
17
|
-
export
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
41
|
+
export {
|
|
42
|
+
FUSION_CHILD_MAX_TOTAL_TOOL_RESULT_BYTES,
|
|
43
|
+
FUSION_CHILD_RESULT_PREFIX,
|
|
44
|
+
FUSION_CHILD_RESULT_SCHEMA_VERSION,
|
|
45
|
+
FUSION_RESEARCH_ENABLED_ENV,
|
|
46
|
+
FUSION_SOURCE_POLICY_PATH_ENV,
|
|
47
|
+
FUSION_SOURCE_POLICY_SHA256_ENV,
|
|
48
|
+
FUSION_TOOL_CALL_LOG_PATH_ENV,
|
|
49
|
+
FUSION_TOOL_CALL_SEAL_SCHEMA_VERSION,
|
|
50
|
+
FUSION_TOOL_CALL_SEAL_SUFFIX,
|
|
51
|
+
buildFusionChildResultMetadata,
|
|
52
|
+
type FusionChildResultMetadata,
|
|
53
|
+
type FusionChildResultUsageMetadata,
|
|
54
|
+
type FusionChildTextBlockMetadata,
|
|
55
|
+
} from './core/fusion/child-protocol.js';
|
|
22
56
|
|
|
23
|
-
|
|
24
|
-
* Aggregate ceiling on tool-result bytes a single candidate child may accumulate.
|
|
25
|
-
*
|
|
26
|
-
* v1 deliberately has no tool-CALL cap, so this byte budget is the only bound on how much
|
|
27
|
-
* a read-only candidate can pull into its context. 8 MiB is generous for targeted
|
|
28
|
-
* grep/read investigation while still preventing an unbounded read loop from degrading
|
|
29
|
-
* into an opaque provider-side context failure.
|
|
30
|
-
*/
|
|
31
|
-
export const FUSION_CHILD_MAX_TOTAL_TOOL_RESULT_BYTES = 8 * 1024 * 1024;
|
|
57
|
+
const FUSION_CHILD_O_NOFOLLOW = typeof constants.O_NOFOLLOW === 'number' ? constants.O_NOFOLLOW : 0;
|
|
32
58
|
|
|
33
59
|
const FusionWebFetchParams = Type.Object(
|
|
34
60
|
{
|
|
@@ -58,30 +84,14 @@ interface FusionWebFetchDetails {
|
|
|
58
84
|
}
|
|
59
85
|
|
|
60
86
|
interface FusionWebFetchAuditMetadata {
|
|
61
|
-
url
|
|
87
|
+
url?: string | undefined;
|
|
88
|
+
rejected_url_sha256?: string | undefined;
|
|
62
89
|
final_url?: string | undefined;
|
|
63
90
|
http_status?: number | undefined;
|
|
64
91
|
response_bytes?: number | undefined;
|
|
65
92
|
content_sha256?: string | undefined;
|
|
66
93
|
}
|
|
67
94
|
|
|
68
|
-
export interface FusionChildTextBlockMetadata {
|
|
69
|
-
utf8_bytes: number;
|
|
70
|
-
sha256: string;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
export type FusionChildResultUsageMetadata = Usage;
|
|
74
|
-
|
|
75
|
-
export interface FusionChildResultMetadata {
|
|
76
|
-
schema_version: typeof FUSION_CHILD_RESULT_SCHEMA_VERSION;
|
|
77
|
-
provider: string;
|
|
78
|
-
model: string;
|
|
79
|
-
stop_reason: string;
|
|
80
|
-
text_blocks: FusionChildTextBlockMetadata[];
|
|
81
|
-
text_sha256: string;
|
|
82
|
-
usage: FusionChildResultUsageMetadata;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
95
|
function sha256(value: string | Buffer): string {
|
|
86
96
|
return createHash('sha256').update(value).digest('hex');
|
|
87
97
|
}
|
|
@@ -99,62 +109,148 @@ function utf8JsonBytes(value: unknown, label: string): Buffer {
|
|
|
99
109
|
return Buffer.from(text, 'utf8');
|
|
100
110
|
}
|
|
101
111
|
|
|
102
|
-
function
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
112
|
+
function throwableError(value: unknown): Error {
|
|
113
|
+
return value instanceof Error ? value : new Error(String(value));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function writeAllSync(fd: number, bytes: Buffer, label: string): void {
|
|
117
|
+
let offset = 0;
|
|
118
|
+
while (offset < bytes.length) {
|
|
119
|
+
const written = writeSync(fd, bytes, offset, bytes.length - offset, null);
|
|
120
|
+
if (written <= 0) {
|
|
121
|
+
throw new Error(`${label} made no write progress at byte ${String(offset)}`);
|
|
122
|
+
}
|
|
123
|
+
offset += written;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function withRegularFileDescriptorSync(
|
|
128
|
+
path: string,
|
|
129
|
+
flags: number,
|
|
130
|
+
mode: number | undefined,
|
|
131
|
+
label: string,
|
|
132
|
+
operation: (fd: number) => void,
|
|
133
|
+
): void {
|
|
107
134
|
let fd: number | undefined;
|
|
135
|
+
let primaryFailure: unknown;
|
|
136
|
+
let closeFailure: unknown;
|
|
108
137
|
try {
|
|
109
|
-
fd = openSync(path,
|
|
110
|
-
const
|
|
111
|
-
if (
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
138
|
+
fd = mode === undefined ? openSync(path, flags) : openSync(path, flags, mode);
|
|
139
|
+
const stats = fstatSync(fd);
|
|
140
|
+
if (!stats.isFile()) throw new Error(`${label} at ${path} is not a regular file`);
|
|
141
|
+
operation(fd);
|
|
142
|
+
} catch (error) {
|
|
143
|
+
primaryFailure = error;
|
|
144
|
+
}
|
|
145
|
+
if (fd !== undefined) {
|
|
146
|
+
try {
|
|
147
|
+
closeSync(fd);
|
|
148
|
+
} catch (error) {
|
|
149
|
+
closeFailure = error;
|
|
115
150
|
}
|
|
151
|
+
}
|
|
152
|
+
if (primaryFailure !== undefined && closeFailure !== undefined) {
|
|
153
|
+
throw new AggregateError(
|
|
154
|
+
[primaryFailure, closeFailure],
|
|
155
|
+
`${label} operation and descriptor close both failed`,
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
if (primaryFailure !== undefined) throw throwableError(primaryFailure);
|
|
159
|
+
if (closeFailure !== undefined) throw throwableError(closeFailure);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function fsyncParentDirectorySync(path: string): void {
|
|
163
|
+
if (process.platform === 'win32') return;
|
|
164
|
+
const parent = dirname(path);
|
|
165
|
+
let fd: number | undefined;
|
|
166
|
+
let primaryFailure: unknown;
|
|
167
|
+
let closeFailure: unknown;
|
|
168
|
+
try {
|
|
169
|
+
fd = openSync(parent, constants.O_RDONLY | FUSION_CHILD_O_NOFOLLOW);
|
|
170
|
+
const stats = fstatSync(fd);
|
|
171
|
+
if (!stats.isDirectory())
|
|
172
|
+
throw new Error(`fusion audit parent at ${parent} is not a directory`);
|
|
116
173
|
fsyncSync(fd);
|
|
117
|
-
}
|
|
118
|
-
|
|
174
|
+
} catch (error) {
|
|
175
|
+
primaryFailure = error;
|
|
176
|
+
}
|
|
177
|
+
if (fd !== undefined) {
|
|
178
|
+
try {
|
|
179
|
+
closeSync(fd);
|
|
180
|
+
} catch (error) {
|
|
181
|
+
closeFailure = error;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
if (primaryFailure !== undefined && closeFailure !== undefined) {
|
|
185
|
+
throw new AggregateError(
|
|
186
|
+
[primaryFailure, closeFailure],
|
|
187
|
+
'fusion audit directory sync and descriptor close both failed',
|
|
188
|
+
);
|
|
119
189
|
}
|
|
190
|
+
if (primaryFailure !== undefined) throw throwableError(primaryFailure);
|
|
191
|
+
if (closeFailure !== undefined) throw throwableError(closeFailure);
|
|
120
192
|
}
|
|
121
193
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
194
|
+
function createToolCallLog(path: string): void {
|
|
195
|
+
withRegularFileDescriptorSync(
|
|
196
|
+
path,
|
|
197
|
+
constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | FUSION_CHILD_O_NOFOLLOW,
|
|
198
|
+
0o600,
|
|
199
|
+
'fusion tool-call log',
|
|
200
|
+
(fd) => {
|
|
201
|
+
fsyncSync(fd);
|
|
202
|
+
},
|
|
131
203
|
);
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
204
|
+
fsyncParentDirectorySync(path);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function appendToolCallLogLine(path: string, record: FusionToolCallLogRecord): void {
|
|
208
|
+
// The log is an audit trail, not a payload copy: raw tool arguments/results may
|
|
209
|
+
// contain secrets, so only byte counts and SHA-256 digests are persisted.
|
|
210
|
+
const bytes = Buffer.from(`${JSON.stringify(record)}\n`, 'utf8');
|
|
211
|
+
withRegularFileDescriptorSync(
|
|
212
|
+
path,
|
|
213
|
+
constants.O_WRONLY | constants.O_APPEND | FUSION_CHILD_O_NOFOLLOW,
|
|
214
|
+
undefined,
|
|
215
|
+
'fusion tool-call log',
|
|
216
|
+
(fd) => {
|
|
217
|
+
writeAllSync(fd, bytes, 'fusion tool-call log append');
|
|
218
|
+
fsyncSync(fd);
|
|
144
219
|
},
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function writeToolCallLogSeal(
|
|
224
|
+
path: string,
|
|
225
|
+
recordCount: number,
|
|
226
|
+
totalResultBytes: number,
|
|
227
|
+
complete: boolean,
|
|
228
|
+
): void {
|
|
229
|
+
const logBytes = readRegularFileNoSymlinkSync(path, 'fusion tool-call log');
|
|
230
|
+
const seal = {
|
|
231
|
+
schema_version: FUSION_TOOL_CALL_SEAL_SCHEMA_VERSION,
|
|
232
|
+
status: complete ? 'complete' : 'failed',
|
|
233
|
+
record_count: recordCount,
|
|
234
|
+
total_result_bytes: totalResultBytes,
|
|
235
|
+
log_sha256: sha256(logBytes),
|
|
236
|
+
} as const;
|
|
237
|
+
const bytes = Buffer.from(`${JSON.stringify(seal)}\n`, 'utf8');
|
|
238
|
+
const sealPath = `${path}${FUSION_TOOL_CALL_SEAL_SUFFIX}`;
|
|
239
|
+
withRegularFileDescriptorSync(
|
|
240
|
+
sealPath,
|
|
241
|
+
constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | FUSION_CHILD_O_NOFOLLOW,
|
|
242
|
+
0o600,
|
|
243
|
+
'fusion tool-call audit completion seal',
|
|
244
|
+
(fd) => {
|
|
245
|
+
writeAllSync(fd, bytes, 'fusion tool-call audit completion seal');
|
|
246
|
+
fsyncSync(fd);
|
|
247
|
+
},
|
|
248
|
+
);
|
|
249
|
+
fsyncParentDirectorySync(sealPath);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function latchAuditProcessFailure(): void {
|
|
253
|
+
if (process.exitCode === undefined || process.exitCode === 0) process.exitCode = 1;
|
|
158
254
|
}
|
|
159
255
|
|
|
160
256
|
async function writeMetadata(record: FusionChildResultMetadata): Promise<void> {
|
|
@@ -174,7 +270,9 @@ function strictFusionWebFetchArgs(args: unknown): FusionWebFetchParamsValue {
|
|
|
174
270
|
const keys = Object.keys(args);
|
|
175
271
|
const unknownKeys = keys.filter((key) => key !== 'url' && key !== 'extract');
|
|
176
272
|
if (unknownKeys.length > 0 || !keys.includes('url')) {
|
|
177
|
-
throw new Error(
|
|
273
|
+
throw new Error(
|
|
274
|
+
`${FUSION_WEB_FETCH_TOOL_NAME} arguments must contain url and optional extract only`,
|
|
275
|
+
);
|
|
178
276
|
}
|
|
179
277
|
const url = Reflect.get(args, 'url');
|
|
180
278
|
if (typeof url !== 'string' || url.trim().length === 0) {
|
|
@@ -198,8 +296,13 @@ function stringField(value: object, key: string): string | undefined {
|
|
|
198
296
|
return typeof field === 'string' && field.length > 0 ? field : undefined;
|
|
199
297
|
}
|
|
200
298
|
|
|
201
|
-
function fetchAuditMetadataFromObject(
|
|
202
|
-
|
|
299
|
+
function fetchAuditMetadataFromObject(
|
|
300
|
+
value: object,
|
|
301
|
+
fallbackUrl: string,
|
|
302
|
+
): FusionWebFetchAuditMetadata {
|
|
303
|
+
const metadata: FusionWebFetchAuditMetadata = {
|
|
304
|
+
url: stringField(value, 'url') ?? canonicalizeFusionPublicUrl(fallbackUrl),
|
|
305
|
+
};
|
|
203
306
|
const finalUrl = stringField(value, 'final_url');
|
|
204
307
|
if (finalUrl !== undefined) metadata.final_url = finalUrl;
|
|
205
308
|
const status = numberField(value, 'status');
|
|
@@ -211,15 +314,54 @@ function fetchAuditMetadataFromObject(value: object, fallbackUrl: string): Fusio
|
|
|
211
314
|
return metadata;
|
|
212
315
|
}
|
|
213
316
|
|
|
214
|
-
function fetchAuditMetadataFromError(
|
|
215
|
-
|
|
216
|
-
|
|
317
|
+
function fetchAuditMetadataFromError(
|
|
318
|
+
error: unknown,
|
|
319
|
+
attemptedUrl: string,
|
|
320
|
+
): FusionWebFetchAuditMetadata {
|
|
321
|
+
const metadata: FusionWebFetchAuditMetadata = { rejected_url_sha256: sha256(attemptedUrl) };
|
|
322
|
+
if (error instanceof FusionWebFetchError && typeof error === 'object' && error !== null) {
|
|
323
|
+
const status = numberField(error, 'status');
|
|
324
|
+
if (status !== undefined) metadata.http_status = status;
|
|
325
|
+
}
|
|
326
|
+
return metadata;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function readRegularFileNoSymlinkSync(path: string, label: string): Buffer {
|
|
330
|
+
let fd: number | undefined;
|
|
331
|
+
try {
|
|
332
|
+
fd = openSync(path, constants.O_RDONLY | FUSION_CHILD_O_NOFOLLOW);
|
|
333
|
+
} catch (error) {
|
|
334
|
+
if (typeof error === 'object' && error !== null && Reflect.get(error, 'code') === 'ELOOP') {
|
|
335
|
+
throw new Error(`${label} at ${path} is a symlink; refusing to follow it`);
|
|
336
|
+
}
|
|
337
|
+
throw error;
|
|
338
|
+
}
|
|
339
|
+
try {
|
|
340
|
+
const stats = fstatSync(fd);
|
|
341
|
+
if (!stats.isFile()) throw new Error(`${label} at ${path} is not a regular file`);
|
|
342
|
+
return readFileSync(fd);
|
|
343
|
+
} finally {
|
|
344
|
+
if (fd !== undefined) closeSync(fd);
|
|
217
345
|
}
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function loadDeclaredResearchUrls(): ReadonlySet<string> {
|
|
349
|
+
const policyPath = process.env[FUSION_SOURCE_POLICY_PATH_ENV];
|
|
350
|
+
const expectedHash = process.env[FUSION_SOURCE_POLICY_SHA256_ENV];
|
|
351
|
+
if (policyPath === undefined || expectedHash === undefined) {
|
|
352
|
+
throw new Error(
|
|
353
|
+
`${FUSION_WEB_FETCH_TOOL_NAME} research mode requires source policy path and sha256`,
|
|
354
|
+
);
|
|
221
355
|
}
|
|
222
|
-
|
|
356
|
+
if (!/^[0-9a-f]{64}$/.test(expectedHash))
|
|
357
|
+
throw new Error('fusion source policy hash is malformed');
|
|
358
|
+
const bytes = readRegularFileNoSymlinkSync(policyPath, 'fusion source policy');
|
|
359
|
+
if (sha256(bytes) !== expectedHash) throw new Error('fusion source policy hash mismatch');
|
|
360
|
+
const text = bytes.toString('utf8');
|
|
361
|
+
if (!Buffer.from(text, 'utf8').equals(bytes))
|
|
362
|
+
throw new Error('fusion source policy is not UTF-8');
|
|
363
|
+
const parsed = parseFusionSourcePolicy(JSON.parse(text));
|
|
364
|
+
return new Set(parsed.sources.map((source) => source.canonical_url));
|
|
223
365
|
}
|
|
224
366
|
|
|
225
367
|
function fusionWebFetchResultText(result: Awaited<ReturnType<typeof fusionWebFetch>>): string {
|
|
@@ -253,65 +395,146 @@ export default function fusionChildExtension(pi: ExtensionAPI): void {
|
|
|
253
395
|
throw new Error(`${FUSION_RESEARCH_ENABLED_ENV} must be unset or exactly 1`);
|
|
254
396
|
}
|
|
255
397
|
if (researchEnabled === '1' && toolCallLogPath === undefined) {
|
|
256
|
-
throw new Error(
|
|
398
|
+
throw new Error(
|
|
399
|
+
`${FUSION_WEB_FETCH_TOOL_NAME} research mode requires ${FUSION_TOOL_CALL_LOG_PATH_ENV}`,
|
|
400
|
+
);
|
|
257
401
|
}
|
|
402
|
+
const declaredResearchUrls = researchEnabled === '1' ? loadDeclaredResearchUrls() : undefined;
|
|
258
403
|
const fetchAuditMetadata = new Map<string, FusionWebFetchAuditMetadata>();
|
|
259
404
|
if (toolCallLogPath !== undefined) {
|
|
260
|
-
//
|
|
261
|
-
//
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
405
|
+
// Establish the audit file before tools can run. Exclusive creation makes a reused
|
|
406
|
+
// attempt path or redirected file loud instead of appending to untrusted history.
|
|
407
|
+
try {
|
|
408
|
+
createToolCallLog(toolCallLogPath);
|
|
409
|
+
} catch (error) {
|
|
410
|
+
latchAuditProcessFailure();
|
|
411
|
+
throw error;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
type AuditPhase = 'open' | 'finalizing' | 'sealed-complete' | 'sealed-failed';
|
|
415
|
+
interface ToolStart {
|
|
416
|
+
startedAt: number;
|
|
417
|
+
toolName: string;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
let phase: AuditPhase = 'open';
|
|
265
421
|
let ordinal = 0;
|
|
266
422
|
let totalToolResultBytes = 0;
|
|
267
|
-
|
|
423
|
+
let auditFailed = false;
|
|
424
|
+
const starts = new Map<string, ToolStart>();
|
|
425
|
+
|
|
426
|
+
const failAudit = (error: unknown): never => {
|
|
427
|
+
auditFailed = true;
|
|
428
|
+
latchAuditProcessFailure();
|
|
429
|
+
throw error instanceof Error ? error : new Error(String(error));
|
|
430
|
+
};
|
|
431
|
+
const requireOpen = (eventName: string): void => {
|
|
432
|
+
if (phase !== 'open') {
|
|
433
|
+
failAudit(`fusion tool-call audit received ${eventName} while ${phase}`);
|
|
434
|
+
}
|
|
435
|
+
};
|
|
436
|
+
const finalizeAudit = (normalSettlement: boolean, trigger: string): void => {
|
|
437
|
+
if (phase !== 'open') {
|
|
438
|
+
failAudit(
|
|
439
|
+
`fusion tool-call audit received duplicate finalization from ${trigger} while ${phase}`,
|
|
440
|
+
);
|
|
441
|
+
}
|
|
442
|
+
phase = 'finalizing';
|
|
443
|
+
const unmatchedStarts = starts.size;
|
|
444
|
+
const complete = normalSettlement && !auditFailed && unmatchedStarts === 0;
|
|
445
|
+
if (!complete) {
|
|
446
|
+
auditFailed = true;
|
|
447
|
+
latchAuditProcessFailure();
|
|
448
|
+
}
|
|
449
|
+
try {
|
|
450
|
+
writeToolCallLogSeal(toolCallLogPath, ordinal, totalToolResultBytes, complete);
|
|
451
|
+
} catch (error) {
|
|
452
|
+
phase = 'sealed-failed';
|
|
453
|
+
failAudit(error);
|
|
454
|
+
}
|
|
455
|
+
phase = complete ? 'sealed-complete' : 'sealed-failed';
|
|
456
|
+
if (!complete) {
|
|
457
|
+
failAudit(
|
|
458
|
+
`fusion tool-call audit finalized as failed from ${trigger}: ${String(unmatchedStarts)} unmatched tool start(s)`,
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
};
|
|
462
|
+
|
|
268
463
|
pi.on('tool_call', (event) => {
|
|
269
|
-
|
|
464
|
+
try {
|
|
465
|
+
requireOpen('tool_call');
|
|
466
|
+
if (starts.has(event.toolCallId)) {
|
|
467
|
+
throw new Error(`fusion tool-call log duplicate start for ${event.toolCallId}`);
|
|
468
|
+
}
|
|
469
|
+
starts.set(event.toolCallId, {
|
|
470
|
+
startedAt: Date.now(),
|
|
471
|
+
toolName: event.toolName,
|
|
472
|
+
});
|
|
473
|
+
} catch (error) {
|
|
474
|
+
failAudit(error);
|
|
475
|
+
}
|
|
270
476
|
});
|
|
271
477
|
pi.on('tool_result', (event) => {
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
tool_name: event.toolName,
|
|
293
|
-
arguments_sha256: sha256(argumentsBytes),
|
|
294
|
-
arguments_bytes: argumentsBytes.length,
|
|
295
|
-
result_bytes: resultBytes.length,
|
|
296
|
-
result_sha256: sha256(resultBytes),
|
|
297
|
-
status: event.isError === true ? 'error' : 'ok',
|
|
298
|
-
duration_ms: Math.max(0, Date.now() - start),
|
|
299
|
-
...(fetchMetadata === undefined ? {} : fetchMetadata),
|
|
300
|
-
};
|
|
301
|
-
ordinal += 1;
|
|
302
|
-
appendToolCallLogLine(toolCallLogPath, record);
|
|
303
|
-
// Aggregate output ceiling. There is no tool-CALL cap in v1 by design, so bytes are
|
|
304
|
-
// the only bound on how much a read-only candidate can pull into its context. The
|
|
305
|
-
// record is durable before this check, so the offending call stays auditable; the
|
|
306
|
-
// failure is loud rather than a truncation, because a silently shortened tool result
|
|
307
|
-
// would corrupt the candidate's reasoning with no signal at all.
|
|
308
|
-
totalToolResultBytes += resultBytes.length;
|
|
309
|
-
if (totalToolResultBytes > FUSION_CHILD_MAX_TOTAL_TOOL_RESULT_BYTES) {
|
|
310
|
-
throw new Error(
|
|
311
|
-
`fusion candidate exceeded the aggregate tool-output budget: ${String(totalToolResultBytes)} bytes across ${String(ordinal)} calls exceeds ${String(FUSION_CHILD_MAX_TOTAL_TOOL_RESULT_BYTES)}`,
|
|
478
|
+
try {
|
|
479
|
+
requireOpen('tool_result');
|
|
480
|
+
const start = starts.get(event.toolCallId);
|
|
481
|
+
if (start === undefined) {
|
|
482
|
+
throw new Error(`fusion tool-call log missing start for ${event.toolCallId}`);
|
|
483
|
+
}
|
|
484
|
+
if (start.toolName !== event.toolName) {
|
|
485
|
+
throw new Error(
|
|
486
|
+
`fusion tool-call log tool mismatch for ${event.toolCallId}: started ${start.toolName}, completed ${event.toolName}`,
|
|
487
|
+
);
|
|
488
|
+
}
|
|
489
|
+
const argumentsBytes = utf8JsonBytes(event.input, 'arguments');
|
|
490
|
+
const resultBytes = utf8JsonBytes(
|
|
491
|
+
{
|
|
492
|
+
content: event.content,
|
|
493
|
+
details: event.details,
|
|
494
|
+
isError: event.isError,
|
|
495
|
+
usage: event.usage,
|
|
496
|
+
},
|
|
497
|
+
'result',
|
|
312
498
|
);
|
|
499
|
+
const fetchMetadata = fetchAuditMetadata.get(event.toolCallId);
|
|
500
|
+
const nextTotalToolResultBytes = totalToolResultBytes + resultBytes.length;
|
|
501
|
+
const record: FusionToolCallLogRecord = {
|
|
502
|
+
schema_version: FUSION_TOOL_CALL_LOG_SCHEMA_VERSION,
|
|
503
|
+
ordinal,
|
|
504
|
+
tool_name: event.toolName,
|
|
505
|
+
arguments_sha256: sha256(argumentsBytes),
|
|
506
|
+
arguments_bytes: argumentsBytes.length,
|
|
507
|
+
result_bytes: resultBytes.length,
|
|
508
|
+
result_sha256: sha256(resultBytes),
|
|
509
|
+
status: event.isError === true ? 'error' : 'ok',
|
|
510
|
+
duration_ms: Math.max(0, Date.now() - start.startedAt),
|
|
511
|
+
...(fetchMetadata === undefined ? {} : fetchMetadata),
|
|
512
|
+
};
|
|
513
|
+
appendToolCallLogLine(toolCallLogPath, record);
|
|
514
|
+
starts.delete(event.toolCallId);
|
|
515
|
+
fetchAuditMetadata.delete(event.toolCallId);
|
|
516
|
+
ordinal += 1;
|
|
517
|
+
totalToolResultBytes = nextTotalToolResultBytes;
|
|
518
|
+
if (totalToolResultBytes > FUSION_CHILD_MAX_TOTAL_TOOL_RESULT_BYTES) {
|
|
519
|
+
throw new Error(
|
|
520
|
+
`fusion candidate exceeded the aggregate tool-output budget: ${String(totalToolResultBytes)} bytes across ${String(ordinal)} calls exceeds ${String(FUSION_CHILD_MAX_TOTAL_TOOL_RESULT_BYTES)}`,
|
|
521
|
+
);
|
|
522
|
+
}
|
|
523
|
+
} catch (error) {
|
|
524
|
+
failAudit(error);
|
|
313
525
|
}
|
|
314
526
|
});
|
|
527
|
+
// agent_end is only the end of one low-level run. Pi may still retry, compact and
|
|
528
|
+
// retry, or consume queued continuations. Sealing there created stale prefix seals.
|
|
529
|
+
pi.on('agent_settled', (_event, ctx) => {
|
|
530
|
+
if (!ctx.isIdle()) {
|
|
531
|
+
failAudit('fusion child emitted agent_settled while the agent was not idle');
|
|
532
|
+
}
|
|
533
|
+
finalizeAudit(true, 'agent_settled');
|
|
534
|
+
});
|
|
535
|
+
pi.on('session_shutdown', () => {
|
|
536
|
+
if (phase === 'open') finalizeAudit(false, 'session_shutdown before agent_settled');
|
|
537
|
+
});
|
|
315
538
|
}
|
|
316
539
|
|
|
317
540
|
if (researchEnabled === '1') {
|
|
@@ -332,10 +555,21 @@ export default function fusionChildExtension(pi: ExtensionAPI): void {
|
|
|
332
555
|
},
|
|
333
556
|
async execute(toolCallId, params) {
|
|
334
557
|
try {
|
|
558
|
+
const canonicalUrl = canonicalizeFusionPublicUrl(params.url);
|
|
559
|
+
if (params.url !== canonicalUrl) {
|
|
560
|
+
throw new Error(
|
|
561
|
+
`${FUSION_WEB_FETCH_TOOL_NAME} URL must exactly match its declared canonical URL`,
|
|
562
|
+
);
|
|
563
|
+
}
|
|
564
|
+
if (declaredResearchUrls === undefined || !declaredResearchUrls.has(canonicalUrl)) {
|
|
565
|
+
throw new Error(
|
|
566
|
+
`${FUSION_WEB_FETCH_TOOL_NAME} URL was not declared in the research source policy`,
|
|
567
|
+
);
|
|
568
|
+
}
|
|
335
569
|
const result = await fusionWebFetch(
|
|
336
570
|
params.extract === undefined
|
|
337
|
-
? { url:
|
|
338
|
-
: { url:
|
|
571
|
+
? { url: canonicalUrl }
|
|
572
|
+
: { url: canonicalUrl, extract: params.extract },
|
|
339
573
|
);
|
|
340
574
|
fetchAuditMetadata.set(toolCallId, fetchAuditMetadataFromObject(result, params.url));
|
|
341
575
|
return {
|