pi-background-tasks 1.0.3 → 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/README.md +1 -1
- package/TESTING.md +2 -2
- package/TEST_PLAN.md +2 -2
- package/docs/attestations.json +5 -5
- package/docs/manifest.json +4 -4
- package/docs/reference/runtime-contracts.md +27 -27
- package/docs/subsystems/docs-freshness-gate.md +2 -2
- package/docs/subsystems/fusion.md +2 -0
- package/package.json +3 -3
- package/src/core/fusion/pi-child.ts +59 -16
- package/src/fusion-child-extension.ts +248 -53
package/README.md
CHANGED
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
| Fact | Value |
|
|
27
27
|
| --- | --- |
|
|
28
28
|
| Package | `pi-background-tasks` |
|
|
29
|
-
| Version | `1.0.
|
|
29
|
+
| Version | `1.0.4` |
|
|
30
30
|
| Node engine | `>=22.19.0` |
|
|
31
31
|
| Pi entrypoint | `./extensions/background-tasks.ts` |
|
|
32
32
|
| Package image | [logo.png](https://raw.githubusercontent.com/ismailsaleekh/pi-background-tasks/main/logo.png) |
|
package/TESTING.md
CHANGED
|
@@ -87,7 +87,7 @@ Current smoke is `tsx scripts/smoke.ts`. It creates a temporary Pi agent/session
|
|
|
87
87
|
|
|
88
88
|
- the pre-fix full-transcript canonical input and the fact that it is rejected against the panel's smallest route;
|
|
89
89
|
- the post-fix projected canonical input with full omission accounting and a byte-identical rebuild check;
|
|
90
|
-
- the measured candidate, evaluator, evaluation-repair, and merger prompt sizes against the allowed input budget, using the
|
|
90
|
+
- the measured candidate, evaluator, evaluation-repair, and merger prompt sizes against the allowed input budget, using the enforced contract maxima: 48 KiB (49,152 B) for each candidate output and 64 KiB (65,536 B) for evaluator output.
|
|
91
91
|
|
|
92
92
|
It performs no inference and spawns no child, so it is safe to run offline and costs nothing. It exits non-zero if any stage would exceed the budget.
|
|
93
93
|
|
|
@@ -186,7 +186,7 @@ npm run test:rpc
|
|
|
186
186
|
npm run test:agent-loop
|
|
187
187
|
```
|
|
188
188
|
|
|
189
|
-
The Fusion SDK/RPC/scripted-provider tests install a deterministic fake child `pi` in a temp `PATH` from `tests/helpers/fusion-fake-pi.ts`. Parent Pi remains the real SDK/RPC runtime; only direct child `pi --mode text` calls with the package-owned private compact metadata extension are intercepted. `PI_CODING_AGENT_DIR` is pointed at the temp agent directory so `fusion-models.json` is never read from the user's real global Pi directory. Fusion v1 public-surface coverage asserts exactly four tools (`fusion_reason`, `fusion_investigate`, `fusion_research`, `fusion_validate`), no public capability argument, retired-tool active-tool removal, `/fusion` mapping to reason/no-tool candidates, closed schemas with Google-compatible enum status, targeted URL fetch not search, URL exfiltration warnings, strict validation verification rules, historical v4 rendering without old-tool activation, and actionable migration errors for `fusion_validate({prompt})`. Fusion context coverage covers both reason conversation projection and clean-task non-interference invariants, including parent-sentinel absence from every clean downstream prompt/artifact and byte-identical clean inputs across unrelated parent sessions. `tests/unit/fusion-context-prompts.test.ts` verifies that a synthetic session carrying more than 1 MB of tool arguments/results still yields a small canonical input, that user and assistant text survive verbatim, that thinking and tool payloads never appear (including no head/tail/preview sentinel), that omission counts, byte totals, and hashes are exact and stable, that repeated construction is byte-identical, that the active Fusion tool leaf and sibling calls stay scope-excluded, that images remain marker-only or ledger-only with no raw base64 in child prompts, and that every retained source block receives exactly one disposition. `tests/unit/fusion-high-cardinality.test.ts` covers the receipt-cardinality regression: a session of many short interleaved tool events (rather than a few enormous results) pins per-receipt cost, proves the compact `omitted_activity` fields are the only model-facing ones, reconciles every receipt against the ledger through `projection_map`, and proves the whole workflow fits a real route budget. `tests/unit/fusion-budget.test.ts` covers stage budgets: the limiting model is the smallest configured route (including when it is the evaluator rather than a candidate), unknown or too-small capacities fail before spawn, boundary prompts pass at exactly the limit and fail one byte past it, the child system prompt counts as input, dense multi-byte UTF-8 cannot bypass byte accounting, and candidate, evaluator, evaluation-repair, and merger expansions are each rejected before their child is spawned with zero partial launches. `tests/package/typebox-compat.test.ts` pins the TypeBox posture and compiles nullable-array schemas. The release-only `npm run test:compat` packs the package, installs exact supported Pi versions, runs `/jobs`, runs `/fusion` through the installed package entrypoint with the fake child Pi, verifies five child invocations, verifies `/fusion-models` rejects non-TUI mode, asserts the resolved `typebox` is Pi's bundled peer rather than a private or nested copy, and scans the installed package bytes for TypeBox APIs removed in the 1.3.x line. It then drives the current host Pi through a real RPC `fusion_reason` parent-agent loop, checks the persisted tool result carries the complete Pi `Usage.cost` object, invokes `get_session_stats` (the same aggregation boundary used by the TUI footer), reopens the durable session, and verifies identical token/cost totals. All parent and child inference remains deterministic and local.
|
|
189
|
+
The Fusion SDK/RPC/scripted-provider tests install a deterministic fake child `pi` in a temp `PATH` from `tests/helpers/fusion-fake-pi.ts`. Parent Pi remains the real SDK/RPC runtime; only direct child `pi --mode text` calls with the package-owned private compact metadata extension are intercepted. `PI_CODING_AGENT_DIR` is pointed at the temp agent directory so `fusion-models.json` is never read from the user's real global Pi directory. Fusion v1 public-surface coverage asserts exactly four tools (`fusion_reason`, `fusion_investigate`, `fusion_research`, `fusion_validate`), no public capability argument, retired-tool active-tool removal, `/fusion` mapping to reason/no-tool candidates, closed schemas with Google-compatible enum status, targeted URL fetch not search, URL exfiltration warnings, strict validation verification rules, historical v4 rendering without old-tool activation, and actionable migration errors for `fusion_validate({prompt})`. Fusion context coverage covers both reason conversation projection and clean-task non-interference invariants, including parent-sentinel absence from every clean downstream prompt/artifact and byte-identical clean inputs across unrelated parent sessions. `tests/unit/fusion-context-prompts.test.ts` verifies that a synthetic session carrying more than 1 MB of tool arguments/results still yields a small canonical input, that user and assistant text survive verbatim, that thinking and tool payloads never appear (including no head/tail/preview sentinel), that omission counts, byte totals, and hashes are exact and stable, that repeated construction is byte-identical, that the active Fusion tool leaf and sibling calls stay scope-excluded, that images remain marker-only or ledger-only with no raw base64 in child prompts, and that every retained source block receives exactly one disposition. `tests/unit/fusion-high-cardinality.test.ts` covers the receipt-cardinality regression: a session of many short interleaved tool events (rather than a few enormous results) pins per-receipt cost, proves the compact `omitted_activity` fields are the only model-facing ones, reconciles every receipt against the ledger through `projection_map`, and proves the whole workflow fits a real route budget. `tests/unit/fusion-budget.test.ts` covers stage budgets: the limiting model is the smallest configured route (including when it is the evaluator rather than a candidate), unknown or too-small capacities fail before spawn, boundary prompts pass at exactly the limit and fail one byte past it, the child system prompt counts as input, dense multi-byte UTF-8 cannot bypass byte accounting, and candidate, evaluator, evaluation-repair, and merger expansions are each rejected before their child is spawned with zero partial launches. `tests/package/typebox-compat.test.ts` pins the TypeBox posture and compiles nullable-array schemas. The release-only `npm run test:compat` packs the package, installs exact supported Pi versions, runs `/jobs`, runs `/fusion` through the installed package entrypoint with the fake child Pi, verifies five child invocations, verifies `/fusion-models` rejects non-TUI mode, requires each supported Pi line to declare terminal `agent_settled`, asserts the resolved `typebox` is Pi's bundled peer rather than a private or nested copy, and scans the installed package bytes for TypeBox APIs removed in the 1.3.x line. Pi 0.75.5 is intentionally unsupported because it lacks the terminal event required to seal a Fusion audit after retries and compaction. It then drives the current host Pi through a real RPC `fusion_reason` parent-agent loop, checks the persisted tool result carries the complete Pi `Usage.cost` object, invokes `get_session_stats` (the same aggregation boundary used by the TUI footer), reopens the durable session, and verifies identical token/cost totals. All parent and child inference remains deterministic and local.
|
|
190
190
|
|
|
191
191
|
## Coverage summary
|
|
192
192
|
|
package/TEST_PLAN.md
CHANGED
|
@@ -40,7 +40,7 @@ The table below remains the exhaustive acceptance source. Do not replace it with
|
|
|
40
40
|
| Smoke | `npm run smoke` | no | implemented; isolated load-only |
|
|
41
41
|
| Large-context smoke | `npm run smoke:large-context` | release gate | implemented; rebuilds the production failure byte composition, proves the pre-fix input is rejected and the post-fix projection fits all four stages against the smallest configured route, with no inference and no child spawn |
|
|
42
42
|
| Live subscription evidence | `npx tsx scripts/delegate-live-run.ts` | release gate | implemented; one real subscription-OAuth child on the parent's current route, large seeded session, hash-verified answer, and explicit leak checks. Caught two defects no offline gate did (undelivered prompt, seed-not-prompt budgeting), both now pinned by unit and mutation tests |
|
|
43
|
-
| Compatibility | `npm run test:compat` | release gate | implemented; exact Pi `0.
|
|
43
|
+
| Compatibility | `npm run test:compat` | release gate | implemented; exact Pi `0.81.1`, `0.82.1`, `0.83.0` pack/install plus `/jobs`, `/fusion`, and `/fusion-models` surfaces, per-version `agent_settled` event declaration and bundled-TypeBox peer verification, removed-TypeBox-API scan of installed package bytes, followed by a current-host real `fusion_reason` session-stat and replay witness |
|
|
44
44
|
| Docs verification | `npm run docs:verify` | prepack/release gate | implemented; offline docs freshness, generated region/index/manifest/link/reachability/coverage/attestation checks |
|
|
45
45
|
| Docs tests | `npm run test:docs` | targeted docs gate | implemented; docs-gate unit/package tests |
|
|
46
46
|
| Payload policy | `npm run payload:check` | prepack/release gate | implemented; package payload/docs/assets exclusions and inclusions |
|
|
@@ -84,7 +84,7 @@ SDK/RPC/scripted-provider/package/compatibility coverage asserts exactly four to
|
|
|
84
84
|
| Fusion research web fetch | `fusion_web_fetch({ url, extract? })`, `candidate-<slot>.attempt-<n>.tool-calls.jsonl` | yes | | | | | yes | live child | Unit covers the closed schema surface through the child extension, registration only when research mode is enabled, unsupported schemes and URL credentials failing before network access, blocked DNS classes, a mixed public-plus-blocked DNS answer failing loudly, a redirect to a blocked address failing loudly, redirect-loop limit failure, Content-Length and streamed-body response caps, unsupported content types, Markdown extraction preserving links/headings/tables/code blocks, script/style stripping, text/plain passthrough with hash and byte count, UTF-8-safe output truncation, and typed timeout. Unit also proves the resolver is consulted once and the connection is pinned to that vetted address, undeclared research URLs are rejected before fetch with only the attempted URL hash audited, and that audit metadata records `url`, `final_url`, `http_status`, `response_bytes`, and `content_sha256` without raw page content. Package guards require `fusion_web_fetch` registration behind the research env flag. Live verification fetched `https://example.com/`, reported the page H1, read a repository constant, and refused instruction-like text from both a repo file and the fetched page. |
|
|
85
85
|
| Fusion multi-message transcript validation | `*.events.jsonl`, stop reasons `toolUse`/`stop` | yes | | | | | | | Unit accepts a multi-message tool loop, reconstructs stdout from the final assistant message only, and sums usage across all compact metadata records. Non-final records must be `toolUse`, the final record must be `stop`, and `length`, `error`, `aborted`, and `pending` fail loudly instead of being skipped. |
|
|
86
86
|
| Fusion stale-action watchdog | `FUSION_CHILD_IDLE_TIMEOUT_MS`, child process lifecycle | yes | | | | | | | Unit proves a child with no stdout or stderr activity fails as `child_timeout` with the stalled-child message and is terminated; stderr activity resets the watchdog and allows success; the 30-minute absolute timeout path remains distinct and is not reported as a stalled child. |
|
|
87
|
-
| Fusion candidate tool-call audit log | `candidate-<slot>.attempt-<n>.tool-calls.jsonl`, `*.seal.json`, `pi-background-tasks.fusion-tool-call.v1` | yes | | | | | | | Unit proves completed tool calls are logged with tool name, byte counts, and hashes while raw arguments/results containing a secret never appear
|
|
87
|
+
| Fusion candidate tool-call audit log | `candidate-<slot>.attempt-<n>.tool-calls.jsonl`, `*.seal.json`, `pi-background-tasks.fusion-tool-call.v1` | yes | | | | | | | Unit proves completed tool calls are logged with tool name, byte counts, and hashes while raw arguments/results containing a secret never appear. Repeated low-level `agent_end` boundaries publish no seal; terminal `agent_settled` exclusively seals the complete log after all retry/compaction/follow-up work. The 46-call incident regression proves a 22-call prefix is never sealed and duplicate settlement fails without replacement. Shutdown before settlement writes failed evidence and latches process failure. The parent rejects extension diagnostics and missing/failed/mismatched seals and independently rejects aggregate result bytes above 8 MiB. A trailing partial line, ordinal gap, duplicate ordinal, wrong schema version, and inspect-child partial log all fail loudly. |
|
|
88
88
|
| Pi/TypeBox compatibility | `peerDependencies`, packed bytes | yes | | | | | yes | | Unit pins typebox as a `"*"` peer that is neither a runtime nor bundled dependency, requires the resolved TypeBox to be Pi 0.83's 1.3.x line, requires all four supported Pi/TUI lines in the peer range, scans all package TypeScript for the seven removed TypeBox APIs, and compiles the shipped tool schema plus nullable-array/nullable-string and optional-field shapes under TypeBox 1.3. The release compat gate repeats the removed-API scan against installed package bytes per Pi version. |
|
|
89
89
|
| Pi hook contract | `npm run test:hook-contract`, `tests/scripted-provider/pi-hook-contract-evidence.json`, `src/core/delegate/hook-contract-evidence.json` | | | | | | evidence parity | yes | Executes a real Pi agent loop and records, rather than assumes: `context` fires once before every model call in load order; returned messages reach the provider; **throwing** in `context` does NOT block dispatch (Pi catches and continues); `ctx.abort()` does not skip the call site but delivers an already-aborted signal and terminates the run; `tool_result` fires before the transcript entry, chains in load order, replacement reaches the provider while the original does not, and toolCallId/role/`isError` survive. Handler ordering across two separate probe extensions is pinned. The evidence file is compared, never silently regenerated, and a package test asserts the shipped copy is byte-identical. |
|
|
90
90
|
| Delegate context seeding | `bg_delegate` seed `pi-background-tasks.delegate-seed.v1`, `seed.json`, `context-omission-ledger.json` | yes | yes | | | | mutation guard | yes | Unit covers verbatim visible user/assistant text, complete exclusion of thinking/tool-call arguments/tool-result payloads, marker-only images with no raw bytes anywhere in the seed, exclusion of the in-flight `bg_delegate` call **and every sibling call in the same assistant message** (two delegates launched together get byte-identical projected history), the prompt preserved exactly and marked `explicit_text` authority, zero payload-preview bytes, byte-identical construction across repeated builds and across separate processes via a fixed-leaf fixture, and refusal of a blank prompt. Receive-side verification rejects a single mutated byte, a foreign task identity, a structurally malformed seed whose hash matches, a directive whose hash disagrees with its text, and an unsupported capability. SDK proves the projected parent text actually reaches the child. |
|
package/docs/attestations.json
CHANGED
|
@@ -118,7 +118,7 @@
|
|
|
118
118
|
"verdict": "PASS"
|
|
119
119
|
},
|
|
120
120
|
{
|
|
121
|
-
"authored_body_sha256": "sha256:
|
|
121
|
+
"authored_body_sha256": "sha256:6b1afc2083d3f21c704d5af62c6b2259217169002daedb5aa604b8631d4d2fd8",
|
|
122
122
|
"covers_sources": [
|
|
123
123
|
"extensions/fusion-child.ts",
|
|
124
124
|
"src/core/fusion/artifacts.ts",
|
|
@@ -140,8 +140,8 @@
|
|
|
140
140
|
"src/ui/fusion-model-selector.ts"
|
|
141
141
|
],
|
|
142
142
|
"doc_id": "subsystems/fusion",
|
|
143
|
-
"notes": "
|
|
144
|
-
"reviewer": "
|
|
143
|
+
"notes": "Fusion investigate established agent_settled as the terminal audit boundary and required strict parser behavior; final implementation uses exclusive durable journal/seal I/O, rejects extension diagnostics, and focused lifecycle/incident/compatibility tests pass.",
|
|
144
|
+
"reviewer": "fusion-investigate-agent-settled-panel-plus-regression-evidence",
|
|
145
145
|
"schema_version": "pi-background-tasks.docs-attestation.v1",
|
|
146
146
|
"source_sha256": {
|
|
147
147
|
"extensions/fusion-child.ts": "sha256:832937c8431d25f73215951d0446f5fa9e4394f0b03b8a9423fb19561eeba31a",
|
|
@@ -153,13 +153,13 @@
|
|
|
153
153
|
"src/core/fusion/context.ts": "sha256:fefec409354e14acce8e401f7bcb70265eb0c1f4562fa3321508074e7ad1d96a",
|
|
154
154
|
"src/core/fusion/evaluation.ts": "sha256:5ced311dbd03b177701c8acfb1782f48033d3c1d800d9f58fd4dbbfe7dc1fed3",
|
|
155
155
|
"src/core/fusion/orchestrator.ts": "sha256:f0d8437d2c649f70b35ff550fee3b4a18a4e4501c8ee0bd2fd3c1f6984de88c2",
|
|
156
|
-
"src/core/fusion/pi-child.ts": "sha256:
|
|
156
|
+
"src/core/fusion/pi-child.ts": "sha256:bfc4981a9bedca50ff92ac7aaec7dfa93036bbdca2caf0261f4263b489868415",
|
|
157
157
|
"src/core/fusion/prompts.ts": "sha256:0462a496fcf682c6e4cef87bafb1fd6d45f23e367ef83c2c8418126d4a1ee766",
|
|
158
158
|
"src/core/fusion/source-policy.ts": "sha256:17718fe6c55bfacaaed0beebd6c8f4fbf477ec0f8210ae1ba038167231cc5f4d",
|
|
159
159
|
"src/core/fusion/types.ts": "sha256:01a57512044c034aecd9dd304cedcc40fae737d87a3a93cae1027eb8b31a875d",
|
|
160
160
|
"src/core/fusion/web-fetch.ts": "sha256:5aca8d9c8e7bb58f483cc17f60163e431e09e91dc80c299b1e0280a3b96e42aa",
|
|
161
161
|
"src/core/fusion/workflows.ts": "sha256:1960784b7f9fd8cb62bf48c0a04261359e19b59a18146d32583b619db17f7394",
|
|
162
|
-
"src/fusion-child-extension.ts": "sha256:
|
|
162
|
+
"src/fusion-child-extension.ts": "sha256:5ce9cbe5de1a69d791dc8a8a40c05f3ac6ffca607677e150408bf9e385f9aabb",
|
|
163
163
|
"src/fusion-extension.ts": "sha256:ecaf4f6619652029333c626a73e86d25174715864ad0a79a8558700f3eb224e0",
|
|
164
164
|
"src/ui/fusion-model-selector.ts": "sha256:c6c080c7063d1fbf157c720d60dd0b634ef8e5767e004c632c011a7e543e9a34"
|
|
165
165
|
},
|
package/docs/manifest.json
CHANGED
|
@@ -89,7 +89,7 @@
|
|
|
89
89
|
"state": "pass"
|
|
90
90
|
},
|
|
91
91
|
{
|
|
92
|
-
"authored_body_sha256": "sha256:
|
|
92
|
+
"authored_body_sha256": "sha256:6b1afc2083d3f21c704d5af62c6b2259217169002daedb5aa604b8631d4d2fd8",
|
|
93
93
|
"covers_sources": [
|
|
94
94
|
"extensions/fusion-child.ts",
|
|
95
95
|
"src/core/fusion/artifacts.ts",
|
|
@@ -111,10 +111,10 @@
|
|
|
111
111
|
"src/ui/fusion-model-selector.ts"
|
|
112
112
|
],
|
|
113
113
|
"doc_id": "subsystems/fusion",
|
|
114
|
-
"notes": "
|
|
114
|
+
"notes": "Fusion investigate established agent_settled as the terminal audit boundary and required strict parser behavior; final implementation uses exclusive durable journal/seal I/O, rejects extension diagnostics, and focused lifecycle/incident/compatibility tests pass.",
|
|
115
115
|
"rel": "docs/subsystems/fusion.md",
|
|
116
116
|
"required": true,
|
|
117
|
-
"reviewer": "
|
|
117
|
+
"reviewer": "fusion-investigate-agent-settled-panel-plus-regression-evidence",
|
|
118
118
|
"state": "pass"
|
|
119
119
|
},
|
|
120
120
|
{
|
|
@@ -787,7 +787,7 @@
|
|
|
787
787
|
"image": "https://raw.githubusercontent.com/ismailsaleekh/pi-background-tasks/main/logo.png",
|
|
788
788
|
"name": "pi-background-tasks",
|
|
789
789
|
"type": "module",
|
|
790
|
-
"version": "1.0.
|
|
790
|
+
"version": "1.0.4"
|
|
791
791
|
},
|
|
792
792
|
"public_surface_ids": [
|
|
793
793
|
"command:bg",
|
|
@@ -16,27 +16,27 @@ This generated registry lists production environment-variable references, runtim
|
|
|
16
16
|
|
|
17
17
|
| Name | Access | Provenance |
|
|
18
18
|
| --- | --- | --- |
|
|
19
|
-
| `ANTHROPIC_API_KEY` | remove | `src/core/attested-pi-run.ts:135`<br>`src/core/fusion/pi-child.ts:
|
|
20
|
-
| `ANTHROPIC_AUTH_TOKEN` | remove | `src/core/fusion/pi-child.ts:
|
|
21
|
-
| `ANTHROPIC_BASE_URL` | remove | `src/core/attested-pi-run.ts:135`<br>`src/core/fusion/pi-child.ts:
|
|
22
|
-
| `AZURE_OPENAI_AD_TOKEN` | remove | `src/core/fusion/pi-child.ts:
|
|
23
|
-
| `AZURE_OPENAI_API_KEY` | remove | `src/core/fusion/pi-child.ts:
|
|
24
|
-
| `AZURE_OPENAI_API_VERSION` | remove | `src/core/fusion/pi-child.ts:
|
|
25
|
-
| `AZURE_OPENAI_BASE_URL` | remove | `src/core/fusion/pi-child.ts:
|
|
26
|
-
| `AZURE_OPENAI_DEPLOYMENT_NAME_MAP` | remove | `src/core/fusion/pi-child.ts:
|
|
27
|
-
| `AZURE_OPENAI_ENDPOINT` | remove | `src/core/fusion/pi-child.ts:
|
|
28
|
-
| `AZURE_OPENAI_RESOURCE_NAME` | remove | `src/core/fusion/pi-child.ts:
|
|
19
|
+
| `ANTHROPIC_API_KEY` | remove | `src/core/attested-pi-run.ts:135`<br>`src/core/fusion/pi-child.ts:70` |
|
|
20
|
+
| `ANTHROPIC_AUTH_TOKEN` | remove | `src/core/fusion/pi-child.ts:70` |
|
|
21
|
+
| `ANTHROPIC_BASE_URL` | remove | `src/core/attested-pi-run.ts:135`<br>`src/core/fusion/pi-child.ts:70` |
|
|
22
|
+
| `AZURE_OPENAI_AD_TOKEN` | remove | `src/core/fusion/pi-child.ts:70` |
|
|
23
|
+
| `AZURE_OPENAI_API_KEY` | remove | `src/core/fusion/pi-child.ts:70` |
|
|
24
|
+
| `AZURE_OPENAI_API_VERSION` | remove | `src/core/fusion/pi-child.ts:70` |
|
|
25
|
+
| `AZURE_OPENAI_BASE_URL` | remove | `src/core/fusion/pi-child.ts:70` |
|
|
26
|
+
| `AZURE_OPENAI_DEPLOYMENT_NAME_MAP` | remove | `src/core/fusion/pi-child.ts:70` |
|
|
27
|
+
| `AZURE_OPENAI_ENDPOINT` | remove | `src/core/fusion/pi-child.ts:70` |
|
|
28
|
+
| `AZURE_OPENAI_RESOURCE_NAME` | remove | `src/core/fusion/pi-child.ts:70` |
|
|
29
29
|
| `ComSpec` | read | `src/core/common.ts:683`<br>`src/core/common.ts:692` |
|
|
30
|
-
| `OPENAI_API_KEY` | remove | `src/core/attested-pi-run.ts:135`<br>`src/core/fusion/pi-child.ts:
|
|
31
|
-
| `OPENAI_BASE_URL` | remove | `src/core/attested-pi-run.ts:135`<br>`src/core/fusion/pi-child.ts:
|
|
32
|
-
| `OPENROUTER_API_KEY` | remove | `src/core/attested-pi-run.ts:135`<br>`src/core/fusion/pi-child.ts:
|
|
33
|
-
| `OPENROUTER_BASE_URL` | remove | `src/core/attested-pi-run.ts:135`<br>`src/core/fusion/pi-child.ts:
|
|
30
|
+
| `OPENAI_API_KEY` | remove | `src/core/attested-pi-run.ts:135`<br>`src/core/fusion/pi-child.ts:70` |
|
|
31
|
+
| `OPENAI_BASE_URL` | remove | `src/core/attested-pi-run.ts:135`<br>`src/core/fusion/pi-child.ts:70` |
|
|
32
|
+
| `OPENROUTER_API_KEY` | remove | `src/core/attested-pi-run.ts:135`<br>`src/core/fusion/pi-child.ts:70` |
|
|
33
|
+
| `OPENROUTER_BASE_URL` | remove | `src/core/attested-pi-run.ts:135`<br>`src/core/fusion/pi-child.ts:70` |
|
|
34
34
|
| `path` | read | `src/core/common.ts:638` |
|
|
35
35
|
| `Path` | read | `src/core/common.ts:638` |
|
|
36
36
|
| `PATH` | read | `src/core/common.ts:638` |
|
|
37
|
-
| `PI_API_BASE_URL` | remove | `src/core/attested-pi-run.ts:135`<br>`src/core/fusion/pi-child.ts:
|
|
38
|
-
| `PI_API_KEY` | remove | `src/core/attested-pi-run.ts:135`<br>`src/core/fusion/pi-child.ts:
|
|
39
|
-
| `PI_AUTH_FILE` | remove | `src/core/attested-pi-run.ts:135`<br>`src/core/fusion/pi-child.ts:
|
|
37
|
+
| `PI_API_BASE_URL` | remove | `src/core/attested-pi-run.ts:135`<br>`src/core/fusion/pi-child.ts:70` |
|
|
38
|
+
| `PI_API_KEY` | remove | `src/core/attested-pi-run.ts:135`<br>`src/core/fusion/pi-child.ts:70` |
|
|
39
|
+
| `PI_AUTH_FILE` | remove | `src/core/attested-pi-run.ts:135`<br>`src/core/fusion/pi-child.ts:70` |
|
|
40
40
|
| `PI_BG_DELEGATE_ARTIFACT_DIR` | read, write | `src/core/delegate/launch.ts:324`<br>`src/delegate-child-extension.ts:277` |
|
|
41
41
|
| `PI_BG_DELEGATE_LAUNCH_NONCE` | read, write | `src/core/delegate/launch.ts:328`<br>`src/delegate-child-extension.ts:281` |
|
|
42
42
|
| `PI_BG_DELEGATE_SEED_PATH` | read, write | `src/core/delegate/launch.ts:325`<br>`src/delegate-child-extension.ts:278` |
|
|
@@ -48,17 +48,17 @@ This generated registry lists production environment-variable references, runtim
|
|
|
48
48
|
| `PI_BG_REGISTRY_URL` | read | `src/extension.ts:453` |
|
|
49
49
|
| `PI_BG_SHELL` | read | `src/core/common.ts:679` |
|
|
50
50
|
| `PI_BG_SHELL_PATH` | read | `src/core/common.ts:680` |
|
|
51
|
-
| `PI_FUSION_RESEARCH_ENABLED` | read, remove, write | `src/core/fusion/pi-child.ts:
|
|
52
|
-
| `PI_FUSION_SOURCE_POLICY_PATH` | read, remove, write | `src/core/fusion/pi-child.ts:
|
|
53
|
-
| `PI_FUSION_SOURCE_POLICY_SHA256` | read, remove, write | `src/core/fusion/pi-child.ts:
|
|
54
|
-
| `PI_FUSION_TOOL_CALL_LOG_PATH` | read, remove, write | `src/core/fusion/pi-child.ts:
|
|
55
|
-
| `PI_MODEL` | remove | `src/core/delegate/launch.ts:301`<br>`src/core/fusion/pi-child.ts:
|
|
51
|
+
| `PI_FUSION_RESEARCH_ENABLED` | read, remove, write | `src/core/fusion/pi-child.ts:1291`<br>`src/core/fusion/pi-child.ts:70`<br>`src/fusion-child-extension.ts:393` |
|
|
52
|
+
| `PI_FUSION_SOURCE_POLICY_PATH` | read, remove, write | `src/core/fusion/pi-child.ts:1292`<br>`src/core/fusion/pi-child.ts:70`<br>`src/fusion-child-extension.ts:349` |
|
|
53
|
+
| `PI_FUSION_SOURCE_POLICY_SHA256` | read, remove, write | `src/core/fusion/pi-child.ts:1293`<br>`src/core/fusion/pi-child.ts:70`<br>`src/fusion-child-extension.ts:350` |
|
|
54
|
+
| `PI_FUSION_TOOL_CALL_LOG_PATH` | read, remove, write | `src/core/fusion/pi-child.ts:1280`<br>`src/core/fusion/pi-child.ts:70`<br>`src/fusion-child-extension.ts:392` |
|
|
55
|
+
| `PI_MODEL` | remove | `src/core/delegate/launch.ts:301`<br>`src/core/fusion/pi-child.ts:70` |
|
|
56
56
|
| `PI_OFFLINE` | read | `src/extension.ts:445` |
|
|
57
|
-
| `PI_PROVIDER` | remove | `src/core/delegate/launch.ts:301`<br>`src/core/fusion/pi-child.ts:
|
|
58
|
-
| `PI_REASONING_LEVEL` | remove | `src/core/delegate/launch.ts:301`<br>`src/core/fusion/pi-child.ts:
|
|
59
|
-
| `PI_SESSION_FILE` | remove | `src/core/delegate/launch.ts:301`<br>`src/core/fusion/pi-child.ts:
|
|
60
|
-
| `PI_SESSION_ID` | remove | `src/core/delegate/launch.ts:301`<br>`src/core/fusion/pi-child.ts:
|
|
61
|
-
| `PI_SKIP_VERSION_CHECK` | write | `src/core/delegate/launch.ts:323`<br>`src/core/fusion/pi-child.ts:
|
|
57
|
+
| `PI_PROVIDER` | remove | `src/core/delegate/launch.ts:301`<br>`src/core/fusion/pi-child.ts:70` |
|
|
58
|
+
| `PI_REASONING_LEVEL` | remove | `src/core/delegate/launch.ts:301`<br>`src/core/fusion/pi-child.ts:70` |
|
|
59
|
+
| `PI_SESSION_FILE` | remove | `src/core/delegate/launch.ts:301`<br>`src/core/fusion/pi-child.ts:70` |
|
|
60
|
+
| `PI_SESSION_ID` | remove | `src/core/delegate/launch.ts:301`<br>`src/core/fusion/pi-child.ts:70` |
|
|
61
|
+
| `PI_SKIP_VERSION_CHECK` | write | `src/core/delegate/launch.ts:323`<br>`src/core/fusion/pi-child.ts:234` |
|
|
62
62
|
| `SHELL` | read | `src/core/common.ts:675` |
|
|
63
63
|
| `SystemRoot` | read | `src/core/windows-taskkill.ts:96` |
|
|
64
64
|
| `WINDIR` | read | `src/core/windows-taskkill.ts:101` |
|
|
@@ -9,10 +9,10 @@ covers_sources: []
|
|
|
9
9
|
---
|
|
10
10
|
# Docs freshness gate
|
|
11
11
|
|
|
12
|
-
This authored section defines the boundary: documentation facts are extracted from package metadata and TypeScript ASTs, then generated into docs and the manifest. Unsupported syntax fails the gate rather than falling back to regex or stale hand-maintained inventories.
|
|
12
|
+
This authored section defines the boundary: documentation facts are extracted from package metadata and TypeScript ASTs, then generated into docs and the manifest. Unsupported syntax fails the gate rather than falling back to regex or stale hand-maintained inventories. Public registrations must remain unconditional top-level direct calls or use the one validated local tool-wrapper shape; host/method aliases, computed access, nested or conditional registration, wrapper chaining/passing, constructor helpers, ambiguous public metadata, destructured Pi parameters, and repeated imported registrars are rejected.
|
|
13
13
|
|
|
14
14
|
<!-- pi-docs:begin name="docs-freshness-gate" generator="scripts/docs/generate.mjs" -->
|
|
15
|
-
- Canonical package version: `1.0.
|
|
15
|
+
- Canonical package version: `1.0.4`
|
|
16
16
|
- Governed markdown docs: 40
|
|
17
17
|
- Public surfaces extracted: 30
|
|
18
18
|
- Governed production sources: 44
|
|
@@ -105,6 +105,8 @@ Run artifacts are private local evidence under `.pi/fusion/<session-id>-<pid>/<r
|
|
|
105
105
|
|
|
106
106
|
Artifact writes use durable private temp-file/fsync/rename. Manifests enforce legal state transitions and record config, resolved models, fixed capabilities, context policy, tool policy, anonymous map, attempts, artifact refs, cumulative usage, and errors. Successful, failed, and cancelled observed attempts preserve complete Pi usage/cost components; public tool results clone the same `Usage` shape.
|
|
107
107
|
|
|
108
|
+
For tool-enabled children, the private audit journal remains open across every low-level `agent_end`, because Pi may still retry, compact and retry, or process a queued continuation. Only terminal `agent_settled` can exclusively publish the complete hash/count/byte seal. Tool activity after finalization, duplicate settlement, pre-settlement shutdown, extension diagnostics, and missing/failed/stale seals are fatal. This lifecycle requires Pi 0.81.1 or newer; older Pi lines do not expose the required terminal event and are not claimed as compatible.
|
|
109
|
+
|
|
108
110
|
Cancellation and shutdown are loud and durable when a run store exists. The extension tracks active runs, links external abort signals, aborts on session shutdown/reload, and waits for settlement. Child processes have a 30 minute wall timeout, 20 minute idle watchdog, SIGTERM grace, SIGKILL wait, process-group kill on POSIX, bounded stdout/stderr, and cleanup-error propagation.
|
|
109
111
|
|
|
110
112
|
## Troubleshooting
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-background-tasks",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.4",
|
|
4
4
|
"description": "Pi extension for durable background shell tasks, read-only delegated agents, local attested Pi runs, and fixed-purpose Fusion workflows through child Pi processes.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "ISC",
|
|
@@ -82,8 +82,8 @@
|
|
|
82
82
|
]
|
|
83
83
|
},
|
|
84
84
|
"peerDependencies": {
|
|
85
|
-
"@earendil-works/pi-coding-agent": "^0.
|
|
86
|
-
"@earendil-works/pi-tui": "^0.
|
|
85
|
+
"@earendil-works/pi-coding-agent": "^0.81.1 || ^0.82.1 || ^0.83.0",
|
|
86
|
+
"@earendil-works/pi-tui": "^0.81.1 || ^0.82.1 || ^0.83.0",
|
|
87
87
|
"typebox": "*"
|
|
88
88
|
},
|
|
89
89
|
"devDependencies": {
|
|
@@ -64,7 +64,8 @@ export const FUSION_CHILD_TIMEOUT_MS = 30 * 60 * 1000;
|
|
|
64
64
|
export const FUSION_CHILD_IDLE_TIMEOUT_MS = 20 * 60 * 1000;
|
|
65
65
|
export const FUSION_CHILD_KILL_GRACE_MS = 3000;
|
|
66
66
|
export const FUSION_CHILD_SIGKILL_WAIT_MS = 5000;
|
|
67
|
-
const FUSION_PI_CHILD_O_NOFOLLOW =
|
|
67
|
+
const FUSION_PI_CHILD_O_NOFOLLOW =
|
|
68
|
+
typeof constants.O_NOFOLLOW === 'number' ? constants.O_NOFOLLOW : 0;
|
|
68
69
|
|
|
69
70
|
export const FUSION_CHILD_REMOVED_ENV_KEYS = [
|
|
70
71
|
'PI_SESSION_ID',
|
|
@@ -452,6 +453,7 @@ export function buildFusionPiChildArgv(
|
|
|
452
453
|
|
|
453
454
|
const SHA256_HEX_PATTERN = /^[0-9a-f]{64}$/;
|
|
454
455
|
const FUSION_CHILD_RESULT_PREFIX_BYTES = Buffer.from(FUSION_CHILD_RESULT_PREFIX, 'utf8');
|
|
456
|
+
const PI_EXTENSION_ERROR_PREFIX_BYTES = Buffer.from('Extension error (', 'utf8');
|
|
455
457
|
|
|
456
458
|
interface ParsedFusionChildStderr {
|
|
457
459
|
records: FusionChildResultMetadata[];
|
|
@@ -728,36 +730,53 @@ export function parseFusionToolCallLog(bytes: Buffer): FusionToolCallTrace {
|
|
|
728
730
|
};
|
|
729
731
|
}
|
|
730
732
|
|
|
731
|
-
|
|
732
733
|
async function assertCompletedToolPolicy(
|
|
733
734
|
trace: FusionToolCallTrace,
|
|
734
735
|
capability: FusionCapability,
|
|
735
736
|
sourcePolicy: { path: string; sha256: string } | undefined,
|
|
736
737
|
): Promise<void> {
|
|
737
|
-
const allowed =
|
|
738
|
+
const allowed =
|
|
739
|
+
capability === 'inspect'
|
|
740
|
+
? FUSION_INSPECT_TOOLS
|
|
741
|
+
: capability === 'research'
|
|
742
|
+
? FUSION_RESEARCH_TOOLS
|
|
743
|
+
: [];
|
|
738
744
|
const allowedSet = new Set<string>(allowed);
|
|
739
745
|
const declared =
|
|
740
746
|
capability === 'research' && sourcePolicy !== undefined
|
|
741
|
-
? new Set(
|
|
747
|
+
? new Set(
|
|
748
|
+
(await readFusionSourcePolicyFile(sourcePolicy.path, sourcePolicy.sha256)).sources.map(
|
|
749
|
+
(source) => source.canonical_url,
|
|
750
|
+
),
|
|
751
|
+
)
|
|
742
752
|
: undefined;
|
|
743
753
|
for (const record of trace.records) {
|
|
744
754
|
if (!allowedSet.has(record.tool_name)) {
|
|
745
755
|
throw new Error(`fusion child used non-allowlisted tool ${record.tool_name}`);
|
|
746
756
|
}
|
|
747
757
|
if (capability === 'research' && record.tool_name === FUSION_WEB_FETCH_TOOL_NAME) {
|
|
748
|
-
if (sourcePolicy === undefined || declared === undefined)
|
|
758
|
+
if (sourcePolicy === undefined || declared === undefined)
|
|
759
|
+
throw new Error('fusion research source policy missing during audit');
|
|
749
760
|
if (record.status === 'ok') {
|
|
750
761
|
if (record.url === undefined) throw new Error('fusion research fetch audit is missing url');
|
|
751
762
|
const canonicalUrl = canonicalizeFusionPublicUrl(record.url);
|
|
752
|
-
if (record.url !== canonicalUrl)
|
|
753
|
-
|
|
763
|
+
if (record.url !== canonicalUrl)
|
|
764
|
+
throw new Error('fusion research fetch audit URL was not canonical');
|
|
765
|
+
if (!declared.has(canonicalUrl))
|
|
766
|
+
throw new Error('fusion research fetch audit URL was not declared');
|
|
754
767
|
if (record.rejected_url_sha256 !== undefined) {
|
|
755
|
-
throw new Error(
|
|
768
|
+
throw new Error(
|
|
769
|
+
'fusion research successful fetch audit must not include rejected_url_sha256',
|
|
770
|
+
);
|
|
756
771
|
}
|
|
757
|
-
if (record.final_url === undefined)
|
|
758
|
-
|
|
759
|
-
if (record.
|
|
760
|
-
|
|
772
|
+
if (record.final_url === undefined)
|
|
773
|
+
throw new Error('fusion research fetch audit is missing final_url');
|
|
774
|
+
if (record.http_status === undefined)
|
|
775
|
+
throw new Error('fusion research fetch audit is missing http_status');
|
|
776
|
+
if (record.response_bytes === undefined)
|
|
777
|
+
throw new Error('fusion research fetch audit is missing response_bytes');
|
|
778
|
+
if (record.content_sha256 === undefined)
|
|
779
|
+
throw new Error('fusion research fetch audit is missing content_sha256');
|
|
761
780
|
} else {
|
|
762
781
|
if (record.url !== undefined || record.final_url !== undefined) {
|
|
763
782
|
throw new Error('fusion research rejected fetch audit must not persist raw URL');
|
|
@@ -828,7 +847,8 @@ async function assertFusionToolCallLogSeal(
|
|
|
828
847
|
}
|
|
829
848
|
try {
|
|
830
849
|
const stats = await handle.stat();
|
|
831
|
-
if (!stats.isFile())
|
|
850
|
+
if (!stats.isFile())
|
|
851
|
+
throw new Error('fusion tool-call audit completion seal is not a regular file');
|
|
832
852
|
if (stats.size > 4096) throw new Error('fusion tool-call audit completion seal is oversized');
|
|
833
853
|
const bytes = await handle.readFile();
|
|
834
854
|
if (bytes.at(-1) !== 10) throw new Error('fusion tool-call audit completion seal is partial');
|
|
@@ -841,7 +861,13 @@ async function assertFusionToolCallLogSeal(
|
|
|
841
861
|
throw new Error('fusion tool-call audit completion seal must be an object');
|
|
842
862
|
}
|
|
843
863
|
const keys = Object.keys(parsed).sort();
|
|
844
|
-
const expected = [
|
|
864
|
+
const expected = [
|
|
865
|
+
'log_sha256',
|
|
866
|
+
'record_count',
|
|
867
|
+
'schema_version',
|
|
868
|
+
'status',
|
|
869
|
+
'total_result_bytes',
|
|
870
|
+
];
|
|
845
871
|
if (keys.join('\0') !== expected.join('\0')) {
|
|
846
872
|
throw new Error('fusion tool-call audit completion seal keys mismatch');
|
|
847
873
|
}
|
|
@@ -939,6 +965,9 @@ export class FusionPiCompactResultParser {
|
|
|
939
965
|
diagnostics: Buffer;
|
|
940
966
|
} {
|
|
941
967
|
const parsed = parseFusionChildStderr(stderr);
|
|
968
|
+
if (parsed.diagnostics.includes(PI_EXTENSION_ERROR_PREFIX_BYTES)) {
|
|
969
|
+
throw new Error('Pi child reported an extension error diagnostic');
|
|
970
|
+
}
|
|
942
971
|
const final = parsed.records.at(-1);
|
|
943
972
|
if (final === undefined) throw new Error('Pi child emitted no compact result metadata');
|
|
944
973
|
for (const record of parsed.records) this.assertModel(record);
|
|
@@ -1251,7 +1280,13 @@ export async function runPiChild(options: RunPiChildOptions): Promise<FusionChil
|
|
|
1251
1280
|
env[FUSION_TOOL_CALL_LOG_PATH_ENV] = options.toolCallLogPath;
|
|
1252
1281
|
if (capability === 'research') {
|
|
1253
1282
|
if (options.sourcePolicy === undefined) {
|
|
1254
|
-
throw childError(
|
|
1283
|
+
throw childError(
|
|
1284
|
+
'fusion research child requires a source-policy path and hash',
|
|
1285
|
+
'orchestration_failed',
|
|
1286
|
+
options,
|
|
1287
|
+
false,
|
|
1288
|
+
false,
|
|
1289
|
+
);
|
|
1255
1290
|
}
|
|
1256
1291
|
env[FUSION_RESEARCH_ENABLED_ENV] = '1';
|
|
1257
1292
|
env[FUSION_SOURCE_POLICY_PATH_ENV] = options.sourcePolicy.path;
|
|
@@ -1335,7 +1370,15 @@ export async function runPiChild(options: RunPiChildOptions): Promise<FusionChil
|
|
|
1335
1370
|
options,
|
|
1336
1371
|
);
|
|
1337
1372
|
}
|
|
1338
|
-
terminateChild(
|
|
1373
|
+
terminateChild(
|
|
1374
|
+
child,
|
|
1375
|
+
state,
|
|
1376
|
+
platform,
|
|
1377
|
+
killProcess,
|
|
1378
|
+
killGraceMs,
|
|
1379
|
+
sigkillWaitMs,
|
|
1380
|
+
settleClose,
|
|
1381
|
+
);
|
|
1339
1382
|
}, idleTimeoutMs),
|
|
1340
1383
|
);
|
|
1341
1384
|
};
|
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
|
-
import {
|
|
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';
|
|
3
12
|
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
|
4
13
|
import { Type, type Static } from 'typebox';
|
|
5
14
|
import {
|
|
@@ -100,24 +109,115 @@ function utf8JsonBytes(value: unknown, label: string): Buffer {
|
|
|
100
109
|
return Buffer.from(text, 'utf8');
|
|
101
110
|
}
|
|
102
111
|
|
|
103
|
-
function
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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 {
|
|
108
134
|
let fd: number | undefined;
|
|
135
|
+
let primaryFailure: unknown;
|
|
136
|
+
let closeFailure: unknown;
|
|
109
137
|
try {
|
|
110
|
-
fd = openSync(path,
|
|
111
|
-
const
|
|
112
|
-
if (
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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;
|
|
116
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`);
|
|
117
173
|
fsyncSync(fd);
|
|
118
|
-
}
|
|
119
|
-
|
|
174
|
+
} catch (error) {
|
|
175
|
+
primaryFailure = error;
|
|
176
|
+
}
|
|
177
|
+
if (fd !== undefined) {
|
|
178
|
+
try {
|
|
179
|
+
closeSync(fd);
|
|
180
|
+
} catch (error) {
|
|
181
|
+
closeFailure = error;
|
|
182
|
+
}
|
|
120
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
|
+
);
|
|
189
|
+
}
|
|
190
|
+
if (primaryFailure !== undefined) throw throwableError(primaryFailure);
|
|
191
|
+
if (closeFailure !== undefined) throw throwableError(closeFailure);
|
|
192
|
+
}
|
|
193
|
+
|
|
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
|
+
},
|
|
203
|
+
);
|
|
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);
|
|
219
|
+
},
|
|
220
|
+
);
|
|
121
221
|
}
|
|
122
222
|
|
|
123
223
|
function writeToolCallLogSeal(
|
|
@@ -126,7 +226,7 @@ function writeToolCallLogSeal(
|
|
|
126
226
|
totalResultBytes: number,
|
|
127
227
|
complete: boolean,
|
|
128
228
|
): void {
|
|
129
|
-
const logBytes =
|
|
229
|
+
const logBytes = readRegularFileNoSymlinkSync(path, 'fusion tool-call log');
|
|
130
230
|
const seal = {
|
|
131
231
|
schema_version: FUSION_TOOL_CALL_SEAL_SCHEMA_VERSION,
|
|
132
232
|
status: complete ? 'complete' : 'failed',
|
|
@@ -135,17 +235,22 @@ function writeToolCallLogSeal(
|
|
|
135
235
|
log_sha256: sha256(logBytes),
|
|
136
236
|
} as const;
|
|
137
237
|
const bytes = Buffer.from(`${JSON.stringify(seal)}\n`, 'utf8');
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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;
|
|
149
254
|
}
|
|
150
255
|
|
|
151
256
|
async function writeMetadata(record: FusionChildResultMetadata): Promise<void> {
|
|
@@ -165,7 +270,9 @@ function strictFusionWebFetchArgs(args: unknown): FusionWebFetchParamsValue {
|
|
|
165
270
|
const keys = Object.keys(args);
|
|
166
271
|
const unknownKeys = keys.filter((key) => key !== 'url' && key !== 'extract');
|
|
167
272
|
if (unknownKeys.length > 0 || !keys.includes('url')) {
|
|
168
|
-
throw new Error(
|
|
273
|
+
throw new Error(
|
|
274
|
+
`${FUSION_WEB_FETCH_TOOL_NAME} arguments must contain url and optional extract only`,
|
|
275
|
+
);
|
|
169
276
|
}
|
|
170
277
|
const url = Reflect.get(args, 'url');
|
|
171
278
|
if (typeof url !== 'string' || url.trim().length === 0) {
|
|
@@ -189,7 +296,10 @@ function stringField(value: object, key: string): string | undefined {
|
|
|
189
296
|
return typeof field === 'string' && field.length > 0 ? field : undefined;
|
|
190
297
|
}
|
|
191
298
|
|
|
192
|
-
function fetchAuditMetadataFromObject(
|
|
299
|
+
function fetchAuditMetadataFromObject(
|
|
300
|
+
value: object,
|
|
301
|
+
fallbackUrl: string,
|
|
302
|
+
): FusionWebFetchAuditMetadata {
|
|
193
303
|
const metadata: FusionWebFetchAuditMetadata = {
|
|
194
304
|
url: stringField(value, 'url') ?? canonicalizeFusionPublicUrl(fallbackUrl),
|
|
195
305
|
};
|
|
@@ -204,7 +314,10 @@ function fetchAuditMetadataFromObject(value: object, fallbackUrl: string): Fusio
|
|
|
204
314
|
return metadata;
|
|
205
315
|
}
|
|
206
316
|
|
|
207
|
-
function fetchAuditMetadataFromError(
|
|
317
|
+
function fetchAuditMetadataFromError(
|
|
318
|
+
error: unknown,
|
|
319
|
+
attemptedUrl: string,
|
|
320
|
+
): FusionWebFetchAuditMetadata {
|
|
208
321
|
const metadata: FusionWebFetchAuditMetadata = { rejected_url_sha256: sha256(attemptedUrl) };
|
|
209
322
|
if (error instanceof FusionWebFetchError && typeof error === 'object' && error !== null) {
|
|
210
323
|
const status = numberField(error, 'status');
|
|
@@ -213,7 +326,6 @@ function fetchAuditMetadataFromError(error: unknown, attemptedUrl: string): Fusi
|
|
|
213
326
|
return metadata;
|
|
214
327
|
}
|
|
215
328
|
|
|
216
|
-
|
|
217
329
|
function readRegularFileNoSymlinkSync(path: string, label: string): Buffer {
|
|
218
330
|
let fd: number | undefined;
|
|
219
331
|
try {
|
|
@@ -237,13 +349,17 @@ function loadDeclaredResearchUrls(): ReadonlySet<string> {
|
|
|
237
349
|
const policyPath = process.env[FUSION_SOURCE_POLICY_PATH_ENV];
|
|
238
350
|
const expectedHash = process.env[FUSION_SOURCE_POLICY_SHA256_ENV];
|
|
239
351
|
if (policyPath === undefined || expectedHash === undefined) {
|
|
240
|
-
throw new Error(
|
|
352
|
+
throw new Error(
|
|
353
|
+
`${FUSION_WEB_FETCH_TOOL_NAME} research mode requires source policy path and sha256`,
|
|
354
|
+
);
|
|
241
355
|
}
|
|
242
|
-
if (!/^[0-9a-f]{64}$/.test(expectedHash))
|
|
356
|
+
if (!/^[0-9a-f]{64}$/.test(expectedHash))
|
|
357
|
+
throw new Error('fusion source policy hash is malformed');
|
|
243
358
|
const bytes = readRegularFileNoSymlinkSync(policyPath, 'fusion source policy');
|
|
244
359
|
if (sha256(bytes) !== expectedHash) throw new Error('fusion source policy hash mismatch');
|
|
245
360
|
const text = bytes.toString('utf8');
|
|
246
|
-
if (!Buffer.from(text, 'utf8').equals(bytes))
|
|
361
|
+
if (!Buffer.from(text, 'utf8').equals(bytes))
|
|
362
|
+
throw new Error('fusion source policy is not UTF-8');
|
|
247
363
|
const parsed = parseFusionSourcePolicy(JSON.parse(text));
|
|
248
364
|
return new Set(parsed.sources.map((source) => source.canonical_url));
|
|
249
365
|
}
|
|
@@ -279,30 +395,97 @@ export default function fusionChildExtension(pi: ExtensionAPI): void {
|
|
|
279
395
|
throw new Error(`${FUSION_RESEARCH_ENABLED_ENV} must be unset or exactly 1`);
|
|
280
396
|
}
|
|
281
397
|
if (researchEnabled === '1' && toolCallLogPath === undefined) {
|
|
282
|
-
throw new Error(
|
|
398
|
+
throw new Error(
|
|
399
|
+
`${FUSION_WEB_FETCH_TOOL_NAME} research mode requires ${FUSION_TOOL_CALL_LOG_PATH_ENV}`,
|
|
400
|
+
);
|
|
283
401
|
}
|
|
284
402
|
const declaredResearchUrls = researchEnabled === '1' ? loadDeclaredResearchUrls() : undefined;
|
|
285
403
|
const fetchAuditMetadata = new Map<string, FusionWebFetchAuditMetadata>();
|
|
286
404
|
if (toolCallLogPath !== undefined) {
|
|
287
|
-
//
|
|
288
|
-
//
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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';
|
|
292
421
|
let ordinal = 0;
|
|
293
422
|
let totalToolResultBytes = 0;
|
|
294
423
|
let auditFailed = false;
|
|
295
|
-
const starts = new Map<string,
|
|
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
|
+
|
|
296
463
|
pi.on('tool_call', (event) => {
|
|
297
|
-
|
|
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
|
+
}
|
|
298
476
|
});
|
|
299
477
|
pi.on('tool_result', (event) => {
|
|
300
478
|
try {
|
|
479
|
+
requireOpen('tool_result');
|
|
301
480
|
const start = starts.get(event.toolCallId);
|
|
302
481
|
if (start === undefined) {
|
|
303
482
|
throw new Error(`fusion tool-call log missing start for ${event.toolCallId}`);
|
|
304
483
|
}
|
|
305
|
-
|
|
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
|
+
}
|
|
306
489
|
const argumentsBytes = utf8JsonBytes(event.input, 'arguments');
|
|
307
490
|
const resultBytes = utf8JsonBytes(
|
|
308
491
|
{
|
|
@@ -314,7 +497,7 @@ export default function fusionChildExtension(pi: ExtensionAPI): void {
|
|
|
314
497
|
'result',
|
|
315
498
|
);
|
|
316
499
|
const fetchMetadata = fetchAuditMetadata.get(event.toolCallId);
|
|
317
|
-
|
|
500
|
+
const nextTotalToolResultBytes = totalToolResultBytes + resultBytes.length;
|
|
318
501
|
const record: FusionToolCallLogRecord = {
|
|
319
502
|
schema_version: FUSION_TOOL_CALL_LOG_SCHEMA_VERSION,
|
|
320
503
|
ordinal,
|
|
@@ -324,25 +507,33 @@ export default function fusionChildExtension(pi: ExtensionAPI): void {
|
|
|
324
507
|
result_bytes: resultBytes.length,
|
|
325
508
|
result_sha256: sha256(resultBytes),
|
|
326
509
|
status: event.isError === true ? 'error' : 'ok',
|
|
327
|
-
duration_ms: Math.max(0, Date.now() - start),
|
|
510
|
+
duration_ms: Math.max(0, Date.now() - start.startedAt),
|
|
328
511
|
...(fetchMetadata === undefined ? {} : fetchMetadata),
|
|
329
512
|
};
|
|
330
|
-
ordinal += 1;
|
|
331
513
|
appendToolCallLogLine(toolCallLogPath, record);
|
|
332
|
-
|
|
514
|
+
starts.delete(event.toolCallId);
|
|
515
|
+
fetchAuditMetadata.delete(event.toolCallId);
|
|
516
|
+
ordinal += 1;
|
|
517
|
+
totalToolResultBytes = nextTotalToolResultBytes;
|
|
333
518
|
if (totalToolResultBytes > FUSION_CHILD_MAX_TOTAL_TOOL_RESULT_BYTES) {
|
|
334
519
|
throw new Error(
|
|
335
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)}`,
|
|
336
521
|
);
|
|
337
522
|
}
|
|
338
523
|
} catch (error) {
|
|
339
|
-
|
|
340
|
-
|
|
524
|
+
failAudit(error);
|
|
525
|
+
}
|
|
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');
|
|
341
532
|
}
|
|
533
|
+
finalizeAudit(true, 'agent_settled');
|
|
342
534
|
});
|
|
343
|
-
pi.on('
|
|
344
|
-
|
|
345
|
-
writeToolCallLogSeal(toolCallLogPath, ordinal, totalToolResultBytes, complete);
|
|
535
|
+
pi.on('session_shutdown', () => {
|
|
536
|
+
if (phase === 'open') finalizeAudit(false, 'session_shutdown before agent_settled');
|
|
346
537
|
});
|
|
347
538
|
}
|
|
348
539
|
|
|
@@ -366,10 +557,14 @@ export default function fusionChildExtension(pi: ExtensionAPI): void {
|
|
|
366
557
|
try {
|
|
367
558
|
const canonicalUrl = canonicalizeFusionPublicUrl(params.url);
|
|
368
559
|
if (params.url !== canonicalUrl) {
|
|
369
|
-
throw new Error(
|
|
560
|
+
throw new Error(
|
|
561
|
+
`${FUSION_WEB_FETCH_TOOL_NAME} URL must exactly match its declared canonical URL`,
|
|
562
|
+
);
|
|
370
563
|
}
|
|
371
564
|
if (declaredResearchUrls === undefined || !declaredResearchUrls.has(canonicalUrl)) {
|
|
372
|
-
throw new Error(
|
|
565
|
+
throw new Error(
|
|
566
|
+
`${FUSION_WEB_FETCH_TOOL_NAME} URL was not declared in the research source policy`,
|
|
567
|
+
);
|
|
373
568
|
}
|
|
374
569
|
const result = await fusionWebFetch(
|
|
375
570
|
params.extract === undefined
|