@evoclock/pi-agentic-driver 0.4.3 → 0.5.0
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/PROVENANCE.md +5 -0
- package/README.md +125 -31
- package/config/microvm-target.v1.example.json +5 -0
- package/config/microvm-target.v1.json +5 -0
- package/extensions/herdr-dispatch.ts +27 -0
- package/extensions/linux-microvm.ts +11 -7
- package/package.json +30 -2
- package/scripts/enforcement/herdr_async_dispatch_pi.js +402 -0
- package/scripts/enforcement/linux_microvm_cutover_pi.js +336 -47
package/PROVENANCE.md
CHANGED
|
@@ -67,3 +67,8 @@ This repository is the public package source for `@evoclock/pi-agentic-driver`.
|
|
|
67
67
|
native-TUI predicate and remain fail-closed. This release also adds the
|
|
68
68
|
portable `templates/AGENTS.md` repository contract. The release artifact is
|
|
69
69
|
built from this repository revision.
|
|
70
|
+
- Release 0.5.0 adds session-scoped microVM activation. Users enable or disable
|
|
71
|
+
the switch from the native Pi TUI for the current session. The model cannot
|
|
72
|
+
change it, the switch is not persisted, and each microVM run still requires
|
|
73
|
+
native confirmation. Headless sessions fail closed. The existing trusted
|
|
74
|
+
facts, fixed argv, receipt validation, and cleanup checks remain unchanged.
|
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# pi-agentic-driver v0.
|
|
1
|
+
# pi-agentic-driver v0.5.0
|
|
2
2
|
|
|
3
3
|
<p align="center">
|
|
4
4
|
<img src="assets/Yamagane-origami.png" alt="pi-agentic-driver — Yamagane origami mark" width="140"/>
|
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
|
|
7
7
|
<p align="center">
|
|
8
8
|
<a href="LICENSE"><img src="https://img.shields.io/badge/license-AGPL%20v3-blue?style=flat" alt="License: AGPL v3"/></a>
|
|
9
|
-
<img src="https://img.shields.io/
|
|
9
|
+
<a href="https://www.npmjs.com/package/@evoclock/pi-agentic-driver"><img src="https://img.shields.io/npm/v/@evoclock/pi-agentic-driver?style=flat" alt="npm version"/></a>
|
|
10
|
+
<img src="https://img.shields.io/badge/version-0.5.0-blue?style=flat" alt="Version 0.5.0"/>
|
|
10
11
|
<img src="https://img.shields.io/badge/status-active%20development%20%26%20testing-orange?style=flat" alt="Status"/>
|
|
11
12
|
<img src="https://img.shields.io/badge/JavaScript-F7DF1E?style=flat&logo=javascript&logoColor=black" alt="JavaScript"/>
|
|
12
13
|
<img src="https://img.shields.io/badge/TypeScript-3178C6?style=flat&logo=typescript&logoColor=white" alt="TypeScript"/>
|
|
@@ -32,12 +33,23 @@ Extensions for [Pi](https://github.com/earendil-works/pi-coding-agent):
|
|
|
32
33
|
advisory code review, bounded role communication, and governed isolation
|
|
33
34
|
proofs for agentic workflows.
|
|
34
35
|
|
|
36
|
+
## Shipped features
|
|
37
|
+
|
|
38
|
+
| Tool | What it does | Status |
|
|
39
|
+
|------|--------------|--------|
|
|
40
|
+
| `code_phage` | Reviews a plan against a stated goal before code is written. | shipped |
|
|
41
|
+
| `agentic_herdr_communication` | Exchanges marked reports with worker agents; never grants authority. | shipped |
|
|
42
|
+
| `agentic_herdr_spawn_worker` | Starts one Pi worker in a pane or tab, with native confirmation. | shipped |
|
|
43
|
+
| `agentic_aidr` | Reviews writing for clarity, simplicity, brevity, and humanity. | shipped |
|
|
44
|
+
| `agentic_linux_microvm_cutover` | Runs one job in a throwaway QEMU/KVM virtual machine on a Linux host. | user-enabled, native confirmation |
|
|
45
|
+
| `agentic_worker_dispatch` | Runs bounded worker journeys and observes worker liveness. | shipped |
|
|
46
|
+
|
|
35
47
|
**Status: active development and testing.** Each extension ships only after
|
|
36
48
|
it passes fixture-based acceptance, native tests, live-session checks, and
|
|
37
49
|
independent model review. You can install released components. This README
|
|
38
50
|
lists pending components for transparency; pending components are not packaged.
|
|
39
51
|
|
|
40
|
-
##
|
|
52
|
+
## Code review and planning
|
|
41
53
|
|
|
42
54
|
*Extensions that review, route, and bound what an agent does.*
|
|
43
55
|
|
|
@@ -83,9 +95,9 @@ only, not a runtime dependency).
|
|
|
83
95
|
- **inventory refresh** — Git-aware codebase inventory regeneration with
|
|
84
96
|
verification receipts, so prior-art matching stays honest.
|
|
85
97
|
|
|
86
|
-
##
|
|
98
|
+
## Multi-agent communication
|
|
87
99
|
|
|
88
|
-
*Extensions for bounded coordination between agents
|
|
100
|
+
*Extensions for bounded coordination between agents.*
|
|
89
101
|
|
|
90
102
|
<details>
|
|
91
103
|
<summary><strong>herdr-communication — bounded role communication</strong> <em>(released, 0.2.1)</em></summary>
|
|
@@ -119,7 +131,6 @@ configured Pi worker roles running under [Herdr](https://herdr.dev/)
|
|
|
119
131
|
|
|
120
132
|
<details>
|
|
121
133
|
<summary><strong>herdr-lifecycle — role-labelled worker dispatch</strong> <em>(released, 0.2.1)</em></summary>
|
|
122
|
-
|
|
123
134
|
`agentic_herdr_spawn_worker` turns one natural-language request into Herdr's
|
|
124
135
|
native documented lifecycle. Select `right`, `below`, or `tab`; give the
|
|
125
136
|
worker a safe role label; choose a model from the active Pi model roster; and
|
|
@@ -161,15 +172,43 @@ for the wider task/model-routing and remote-session workflow.
|
|
|
161
172
|
affinity is an optimisation, never authority: an incompatible or
|
|
162
173
|
unavailable lane yields an explicit review-required result, never silent
|
|
163
174
|
model substitution. Routing grants no dispatch or shell authority.
|
|
164
|
-
- **worker pulse** —
|
|
165
|
-
|
|
166
|
-
are in, and what is ready for work. This observation grants no authority;
|
|
167
|
-
the system cannot dispatch planned work without it.
|
|
175
|
+
- **worker pulse** — now part of the shipped `agentic_worker_dispatch` tool
|
|
176
|
+
(see below). Broader role-lane observation remains future work.
|
|
168
177
|
- **task-ledger integration for planned work** — agents read and act
|
|
169
178
|
within the task ledger's card states (what is dispatchable, in progress,
|
|
170
179
|
blocked) without owning board authority: no admission, completion,
|
|
171
180
|
reconciliation, or migration by the agent itself.
|
|
172
181
|
|
|
182
|
+
<details>
|
|
183
|
+
<summary><strong>herdr-dispatch — continuous worker journeys</strong> <em>(released, 0.5.0)</em></summary>
|
|
184
|
+
|
|
185
|
+
`agentic_worker_dispatch` runs bounded worker journeys and observes worker
|
|
186
|
+
liveness. Two actions:
|
|
187
|
+
|
|
188
|
+
- **pulse** reports whether a worker role is alive, its current state, and
|
|
189
|
+
whether it is dispatch-eligible.
|
|
190
|
+
- **dispatch** runs one journey. The worker works through the existing task
|
|
191
|
+
sequence (pending, unblocked, unowned items) and handles one
|
|
192
|
+
prompt-and-report exchange per task. A journey runs at most `maxSteps`
|
|
193
|
+
steps (default 50, cap 200).
|
|
194
|
+
|
|
195
|
+
Continuous mode is the default: the journey keeps going until the worker
|
|
196
|
+
finishes the queue or reaches the step bound. Turn-by-turn mode stops after
|
|
197
|
+
each step and is explicit opt-in. Each journey emits one collated marked
|
|
198
|
+
report that covers every step.
|
|
199
|
+
|
|
200
|
+
A hung worker ends the journey with an explicit `worker-hung` state. A
|
|
201
|
+
worker hangs when it never reaches `idle` across the observed exchange
|
|
202
|
+
cycle, or when its exchange stalls. You can then spawn a replacement agent
|
|
203
|
+
through the guarded lifecycle boundary. The replacement resumes the same
|
|
204
|
+
pending tasks; the journey reuses existing task cards and never duplicates
|
|
205
|
+
them. The stuck exchange is never resent to the same worker.
|
|
206
|
+
|
|
207
|
+
Journeys never create, own, or complete task cards themselves, never retry
|
|
208
|
+
silently, and return results as untrusted evidence.
|
|
209
|
+
|
|
210
|
+
</details>
|
|
211
|
+
|
|
173
212
|
## Writing clearly
|
|
174
213
|
|
|
175
214
|
*AI;DR (AI; Didn't Read) keeps technical writing clear without flattening the writer's voice.*
|
|
@@ -205,23 +244,47 @@ bounded inputs, atomic replacement, drift checks, and exact write verification.
|
|
|
205
244
|
|
|
206
245
|
</details>
|
|
207
246
|
|
|
208
|
-
##
|
|
247
|
+
## Sandboxed execution
|
|
209
248
|
|
|
210
|
-
*Extensions that
|
|
249
|
+
*Extensions that run agent jobs in a sealed environment and prove it.*
|
|
211
250
|
|
|
212
|
-
**
|
|
251
|
+
**Available in this release:**
|
|
213
252
|
|
|
214
253
|
<details>
|
|
215
|
-
<summary><strong>microVM
|
|
216
|
-
|
|
217
|
-
`agentic_linux_microvm_cutover`
|
|
218
|
-
QEMU/KVM
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
254
|
+
<summary><strong>microVM isolation</strong> <em>(user-enabled, native confirmation)</em></summary>
|
|
255
|
+
|
|
256
|
+
The `agentic_linux_microvm_cutover` tool runs a single job inside a throwaway
|
|
257
|
+
QEMU/KVM virtual machine on a Linux host. The job cannot reach the host, the
|
|
258
|
+
network, or anything else outside the machine. The system deletes the machine
|
|
259
|
+
after the job ends.
|
|
260
|
+
|
|
261
|
+
Each session starts with isolation disabled. To enable it, run
|
|
262
|
+
`/agentic-isolation-enable` in the interactive Pi TUI and confirm. The model
|
|
263
|
+
cannot run this command. Each microVM run asks for its own confirmation. To
|
|
264
|
+
turn isolation off, run `/agentic-isolation-disable`. The switch never saves
|
|
265
|
+
to settings. Headless sessions stay blocked.
|
|
266
|
+
|
|
267
|
+
**Setup and run.** You choose where the microVM runs. The model cannot choose
|
|
268
|
+
it silently — it only relays your words, and every target choice requires your
|
|
269
|
+
native confirmation dialog before anything is saved or run.
|
|
270
|
+
|
|
271
|
+
1. Ask your agent to run the microVM cutover and tell it which machine to use
|
|
272
|
+
(e.g. "run the microVM proof on deploy@192.168.1.50", or "...on this
|
|
273
|
+
machine"). The first run asks you to confirm natively: it names the
|
|
274
|
+
machine and states that your choice is saved to your Pi config. On your
|
|
275
|
+
Yes, the choice is saved and the run continues in the same invocation.
|
|
276
|
+
2. Run `/agentic-isolation-enable` to enable isolation for this session
|
|
277
|
+
(native confirmation; not saved to settings).
|
|
278
|
+
3. Each cutover run asks for its own confirmation. Saved targets are reused
|
|
279
|
+
without asking again where the microVM runs.
|
|
280
|
+
|
|
281
|
+
The tool discovers the technical details from the machine itself. It reads the
|
|
282
|
+
architecture, kernel, and libvirt URI at probe time. You configure nothing.
|
|
283
|
+
Before any run, the probe checks three requirements: `/dev/kvm` exists and is
|
|
284
|
+
accessible. Libvirt reports the system-level driver (`qemu:///system`). A
|
|
285
|
+
matching `qemu-system-<arch>` binary is present. A failed check denies the run
|
|
286
|
+
before anything happens. Note: the shipped fixture guest is x86_64-built, so
|
|
287
|
+
the full proof currently needs an x86_64 KVM host.
|
|
225
288
|
|
|
226
289
|
</details>
|
|
227
290
|
|
|
@@ -241,9 +304,9 @@ activation remains deferred until the system enables those paths.
|
|
|
241
304
|
headless runs where no human can confirm, the system refuses destructive
|
|
242
305
|
commands rather than silently allowing them.
|
|
243
306
|
|
|
244
|
-
##
|
|
307
|
+
## Session continuity
|
|
245
308
|
|
|
246
|
-
*
|
|
309
|
+
*Managing context pressure, compaction, and avoiding lossy handover.*
|
|
247
310
|
|
|
248
311
|
**In development:**
|
|
249
312
|
|
|
@@ -283,7 +346,7 @@ activation remains deferred until the system enables those paths.
|
|
|
283
346
|
to a verified assignment, so consequential Git operations carry their
|
|
284
347
|
own recorded provenance.
|
|
285
348
|
|
|
286
|
-
##
|
|
349
|
+
## Package integrity
|
|
287
350
|
|
|
288
351
|
*Extensions that keep the installed set honest and the record bounded.*
|
|
289
352
|
|
|
@@ -306,19 +369,50 @@ acceptance with all prohibited effects absent.
|
|
|
306
369
|
|
|
307
370
|
### Portable repository contract
|
|
308
371
|
|
|
309
|
-
`templates/AGENTS.md` is a
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
372
|
+
`templates/AGENTS.md` is a starting contract you can copy into any repository
|
|
373
|
+
that uses Pi agents. It asks for bounded tasks and realistic tests, and
|
|
374
|
+
treats agent reports as untrusted evidence. It never overwrites an existing
|
|
375
|
+
`AGENTS.md`; add your project rules below it.
|
|
313
376
|
|
|
314
377
|
## Install
|
|
315
378
|
|
|
379
|
+
From npm:
|
|
380
|
+
|
|
316
381
|
```sh
|
|
317
|
-
pi install
|
|
382
|
+
pi install npm:@evoclock/pi-agentic-driver
|
|
383
|
+
```
|
|
384
|
+
|
|
385
|
+
Or from Git at a pinned tag:
|
|
386
|
+
|
|
387
|
+
```sh
|
|
388
|
+
pi install git:github.com/evoclock/pi-agentic-driver@v0.5.0
|
|
318
389
|
```
|
|
319
390
|
|
|
320
391
|
Released extensions load standalone; neither requires the other.
|
|
321
392
|
|
|
393
|
+
### Choose which extensions load
|
|
394
|
+
|
|
395
|
+
A normal install loads all five extensions by default. You can load only the
|
|
396
|
+
extensions you want by using the object form in your Pi settings:
|
|
397
|
+
|
|
398
|
+
```json
|
|
399
|
+
{
|
|
400
|
+
"packages": [
|
|
401
|
+
{
|
|
402
|
+
"source": "npm:@evoclock/pi-agentic-driver@0.5.0",
|
|
403
|
+
"extensions": [
|
|
404
|
+
"extensions/aidr.ts",
|
|
405
|
+
"extensions/code-phage.js"
|
|
406
|
+
]
|
|
407
|
+
}
|
|
408
|
+
]
|
|
409
|
+
}
|
|
410
|
+
```
|
|
411
|
+
|
|
412
|
+
The package also includes `herdr-communication.ts`, `herdr-lifecycle.ts`, and
|
|
413
|
+
`linux-microvm.ts`. Use `pi config` to enable or disable individual resources
|
|
414
|
+
from an installed package. You are not required to use every extension.
|
|
415
|
+
|
|
322
416
|
## License
|
|
323
417
|
|
|
324
418
|
AGPL-3.0-or-later with author-attribution additional terms (Section 7(b));
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_comment": "One decision: where the microVM runs. The target is configured through the cutover tool's native confirmation (the optional target parameter: user@host, ip, or local) and saved into the resolved Pi config directory. Arch, libvirt URI, and kernel are auto-discovered from the target at probe time; only honest safety checks are enforced (/dev/kvm accessible, system-level libvirt driver, qemu-system binary matching the discovered arch). Note: the shipped fixture guest is built for x86_64, so the full proof currently requires an x86_64 KVM host.",
|
|
3
|
+
"schema": "agentic-driver.microvm-target.v1",
|
|
4
|
+
"sshTarget": "REPLACE-WITH-user@host-or-ip"
|
|
5
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_comment": "One decision: where the microVM runs. The target is configured through the cutover tool's native confirmation (the optional target parameter: user@host, ip, or local) and saved into the resolved Pi config directory. Arch, libvirt URI, and kernel are auto-discovered from the target at probe time; only honest safety checks are enforced (/dev/kvm accessible, system-level libvirt driver, qemu-system binary matching the discovered arch). Note: the shipped fixture guest is built for x86_64, so the full proof currently requires an x86_64 KVM host.",
|
|
3
|
+
"schema": "agentic-driver.microvm-target.v1",
|
|
4
|
+
"sshTarget": "REPLACE-WITH-user@host-or-ip"
|
|
5
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Julen Gamboa <j.a.r.gamboa@gmail.com>
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
|
|
4
|
+
export default async function herdrDispatchPi(pi) {
|
|
5
|
+
const dispatch = await import(new URL("../scripts/enforcement/herdr_async_dispatch_pi.js", import.meta.url).href);
|
|
6
|
+
const lifecycle = await import(new URL("../scripts/enforcement/herdr_lifecycle_pi.js", import.meta.url).href);
|
|
7
|
+
|
|
8
|
+
// Production replacement seam: an unresponsive-session handoff spawns through the
|
|
9
|
+
// existing guarded herdr-lifecycle boundary only — executeHerdrSpawnWorker
|
|
10
|
+
// performs native confirmation, trusted-repository resolution, installed
|
|
11
|
+
// model-roll resolution, fixed argv, and shell:false. No raw pane/agent
|
|
12
|
+
// management is added; the receipt is observed state, never authority.
|
|
13
|
+
const spawnReplacement = ({ role, repository, model, context, signal }) =>
|
|
14
|
+
lifecycle.executeHerdrSpawnWorker(
|
|
15
|
+
{
|
|
16
|
+
placement: "tab",
|
|
17
|
+
role,
|
|
18
|
+
model,
|
|
19
|
+
repository,
|
|
20
|
+
},
|
|
21
|
+
context,
|
|
22
|
+
{},
|
|
23
|
+
signal,
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
return dispatch.registerWorkerDispatchInterface(pi, { spawnReplacement });
|
|
27
|
+
}
|
|
@@ -1,10 +1,14 @@
|
|
|
1
|
-
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Julen Gamboa <j.a.r.gamboa@gmail.com>
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
|
|
4
|
+
import { registerLinuxMicroVMCutoverInterface, registerIsolationSwitchCommands, createIsolationSwitch } from "../scripts/enforcement/linux_microvm_cutover_pi.js";
|
|
2
5
|
|
|
3
6
|
export default function registerLinuxMicroVMCutover(pi: any) {
|
|
4
|
-
// Registration is limited to the activation-deferred cutover interface
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
|
|
9
|
-
|
|
7
|
+
// Registration is limited to the activation-deferred cutover interface and
|
|
8
|
+
// the session-scoped isolation switch commands. The legacy work-mode
|
|
9
|
+
// lifecycle is not registered: agentic_work_mode is not exposed.
|
|
10
|
+
// The isolation switch state is created per registration, so each fresh
|
|
11
|
+
// extension registration (session) starts disabled.
|
|
12
|
+
const isolationSwitch = registerIsolationSwitchCommands(pi, { isolationSwitch: createIsolationSwitch() });
|
|
13
|
+
registerLinuxMicroVMCutoverInterface(pi, { isolationSwitch });
|
|
10
14
|
}
|
package/package.json
CHANGED
|
@@ -1,17 +1,42 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@evoclock/pi-agentic-driver",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Guardrail extensions for Agentic Driver: advisory review, bounded Herdr communication, and guarded worker lifecycle.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "AGPL-3.0-or-later",
|
|
7
7
|
"keywords": [
|
|
8
8
|
"pi-package",
|
|
9
9
|
"pi-extension",
|
|
10
|
-
"
|
|
10
|
+
"pi",
|
|
11
|
+
"pi-coding-agent",
|
|
12
|
+
"agentic-driver",
|
|
13
|
+
"agentic-workflows",
|
|
14
|
+
"multi-agent",
|
|
15
|
+
"sub-agents",
|
|
16
|
+
"agent-coordination",
|
|
17
|
+
"worker-dispatch",
|
|
18
|
+
"continuous-agents",
|
|
19
|
+
"task-orchestration",
|
|
20
|
+
"herdr",
|
|
21
|
+
"code-review",
|
|
22
|
+
"writing-review",
|
|
23
|
+
"ai-dr",
|
|
24
|
+
"microvm",
|
|
25
|
+
"isolation",
|
|
26
|
+
"containers",
|
|
27
|
+
"git-workflow",
|
|
28
|
+
"git-safety",
|
|
29
|
+
"context-management",
|
|
30
|
+
"compaction",
|
|
31
|
+
"checkpoint-recovery",
|
|
32
|
+
"developer-tools",
|
|
33
|
+
"automation",
|
|
34
|
+
"governance"
|
|
11
35
|
],
|
|
12
36
|
"files": [
|
|
13
37
|
"extensions/code-phage.js",
|
|
14
38
|
"extensions/herdr-communication.ts",
|
|
39
|
+
"extensions/herdr-dispatch.ts",
|
|
15
40
|
"lib/adapters/diff-scope.mjs",
|
|
16
41
|
"lib/adapters/evidence.mjs",
|
|
17
42
|
"lib/adapters/narrative.mjs",
|
|
@@ -21,10 +46,13 @@
|
|
|
21
46
|
"lib/python_ast_metrics.py",
|
|
22
47
|
"lib/typescript_ast_metrics.mjs",
|
|
23
48
|
"scripts/enforcement/herdr_communication_pi.js",
|
|
49
|
+
"scripts/enforcement/herdr_async_dispatch_pi.js",
|
|
24
50
|
"README.md",
|
|
25
51
|
"LICENSE",
|
|
26
52
|
"extensions/herdr-lifecycle.ts",
|
|
27
53
|
"scripts/enforcement/herdr_lifecycle_pi.js",
|
|
54
|
+
"config/microvm-target.v1.example.json",
|
|
55
|
+
"config/microvm-target.v1.json",
|
|
28
56
|
"config/herdr-worker-repositories.v1.json",
|
|
29
57
|
"extensions/linux-microvm.ts",
|
|
30
58
|
"scripts/enforcement/linux_microvm_cutover_pi.js",
|
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Julen Gamboa <j.a.r.gamboa@gmail.com>
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
|
|
4
|
+
// Async continuous worker dispatch and pulse. One bounded step on top of the
|
|
5
|
+
// shipped herdr-communication interface: a worker progresses through an
|
|
6
|
+
// existing task sequence (observed, never created) and emits one collated
|
|
7
|
+
// marked report. Continuous mode is the default; interactive turn-by-turn is
|
|
8
|
+
// explicit opt-in. Journeys are bounded by step count, never by a wall-clock
|
|
9
|
+
// timeout; cancellation and terminal failure are explicit and there are no
|
|
10
|
+
// invisible retries or resends.
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
executeHerdrCommunication,
|
|
14
|
+
HERDR_REPORT_MARKERS,
|
|
15
|
+
HERDR_COMMUNICATION_SCHEMA,
|
|
16
|
+
HERDR_COMMUNICATION_ACTIONS,
|
|
17
|
+
} from "./herdr_communication_pi.js";
|
|
18
|
+
import { isNativeTuiContext } from "./native_tui_context.js";
|
|
19
|
+
|
|
20
|
+
export const WORKER_DISPATCH_TOOL = "agentic_worker_dispatch";
|
|
21
|
+
export const WORKER_DISPATCH_SCHEMA = "agentic-driver.worker-dispatch.v1";
|
|
22
|
+
export const WORKER_DISPATCH_MODES = Object.freeze(["continuous", "turn-by-turn"]);
|
|
23
|
+
export const DEFAULT_MODE = "continuous";
|
|
24
|
+
const DEFAULT_JOURNEY_STEPS = 50;
|
|
25
|
+
const MAX_JOURNEY_STEPS = 200;
|
|
26
|
+
const MAX_REPORT_BYTES = 32 * 1024;
|
|
27
|
+
const REGISTRATIONS = new WeakSet();
|
|
28
|
+
|
|
29
|
+
// Terminal journey states. Only `cancelled` and `failed` are failures; every
|
|
30
|
+
// other terminal state is an observed outcome, and no state is retried.
|
|
31
|
+
export const WORKER_DISPATCH_TERMINAL_STATES = Object.freeze([
|
|
32
|
+
"completed", "exhausted", "role-blocked", "cancelled", "failed", "waiting-approval", "worker-unresponsive",
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
export const WORKER_DISPATCH_PARAMETERS = Object.freeze({
|
|
36
|
+
type: "object",
|
|
37
|
+
additionalProperties: false,
|
|
38
|
+
properties: {
|
|
39
|
+
action: { type: "string", enum: ["dispatch", "pulse"] },
|
|
40
|
+
role: { type: "string", pattern: "^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$", maxLength: 64 },
|
|
41
|
+
mode: { type: "string", enum: WORKER_DISPATCH_MODES },
|
|
42
|
+
maxSteps: { type: "integer", minimum: 1, maximum: MAX_JOURNEY_STEPS },
|
|
43
|
+
stepPrompt: { type: "string", minLength: 1, maxLength: 8192 },
|
|
44
|
+
model: { type: "string", pattern: "^[a-z0-9][a-z0-9._-]{0,63}(?:\\/[a-z0-9][a-z0-9._-]{0,127})*$", maxLength: 192 },
|
|
45
|
+
},
|
|
46
|
+
required: ["action", "role"],
|
|
47
|
+
allOf: [
|
|
48
|
+
{
|
|
49
|
+
if: { properties: { action: { const: "dispatch" } }, required: ["action"] },
|
|
50
|
+
then: { required: ["stepPrompt"] },
|
|
51
|
+
},
|
|
52
|
+
],
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
class WorkerDispatchError extends Error {
|
|
56
|
+
constructor(code, message, status = "blocked") {
|
|
57
|
+
super(message);
|
|
58
|
+
this.name = "WorkerDispatchError";
|
|
59
|
+
this.code = code;
|
|
60
|
+
this.status = status;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function dispatchError(code, message, status = "blocked") {
|
|
65
|
+
return new WorkerDispatchError(code, message, status);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function result(details) {
|
|
69
|
+
return { ok: details.ok === true, ...details };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function failure(action, error) {
|
|
73
|
+
const code = error?.code || "dispatch-failed";
|
|
74
|
+
return {
|
|
75
|
+
schema: WORKER_DISPATCH_SCHEMA,
|
|
76
|
+
ok: false,
|
|
77
|
+
action,
|
|
78
|
+
status: error?.status || "blocked",
|
|
79
|
+
code,
|
|
80
|
+
error: String(error?.message || error).slice(0, 512),
|
|
81
|
+
nonAuthorizing: true,
|
|
82
|
+
persisted: false,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Worker pulse: liveness, current state, and dispatch eligibility, observed
|
|
87
|
+
// through the existing non-authorizing get seam. Grants no authority.
|
|
88
|
+
export async function workerPulse(role, context, options = {}, signal) {
|
|
89
|
+
if (typeof role !== "string" || !/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/.test(role) || role.length > 64) {
|
|
90
|
+
throw dispatchError("role-invalid", "worker pulse requires a valid non-coordinator role name", "denied");
|
|
91
|
+
}
|
|
92
|
+
const observation = await executeHerdrCommunication(
|
|
93
|
+
{ action: "get", role },
|
|
94
|
+
context,
|
|
95
|
+
options.communication ?? options,
|
|
96
|
+
signal,
|
|
97
|
+
);
|
|
98
|
+
if (observation.ok !== true) {
|
|
99
|
+
throw dispatchError(observation.code || "pulse-failed",
|
|
100
|
+
observation.reason || observation.error || "worker pulse could not observe the role",
|
|
101
|
+
observation.status || "blocked");
|
|
102
|
+
}
|
|
103
|
+
const agent = observation.observation ?? {};
|
|
104
|
+
const status = typeof agent.status === "string" ? agent.status : "unknown";
|
|
105
|
+
return {
|
|
106
|
+
role,
|
|
107
|
+
alive: status !== "unknown" && status !== "gone",
|
|
108
|
+
status,
|
|
109
|
+
dispatchEligible: status === "idle",
|
|
110
|
+
observed: true,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Queue progression observes the next dispatchable item without creating
|
|
115
|
+
// duplicate task cards. The injected task store is read-only here; marking a
|
|
116
|
+
// dispatched task done is the worker's job through its own task tools.
|
|
117
|
+
export function nextDispatchableTask(taskStore) {
|
|
118
|
+
if (!taskStore || typeof taskStore.list !== "function") {
|
|
119
|
+
throw dispatchError("task-store-invalid", "a read-only task store is required to observe the queue", "denied");
|
|
120
|
+
}
|
|
121
|
+
const tasks = taskStore.list();
|
|
122
|
+
if (!Array.isArray(tasks)) throw dispatchError("task-store-invalid", "the task store did not return a task list", "denied");
|
|
123
|
+
return tasks.find((task) => task
|
|
124
|
+
&& task.status === "pending"
|
|
125
|
+
&& !(Array.isArray(task.blockedBy) && task.blockedBy.length > 0)
|
|
126
|
+
&& !task.owner) ?? null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Tasks already dispatched during this journey are excluded from re-selection
|
|
130
|
+
// without mutating the store: the journey keeps its own dispatched set.
|
|
131
|
+
function selectNextTask(taskStore, dispatched) {
|
|
132
|
+
for (let attempt = 0; attempt < 32; attempt += 1) {
|
|
133
|
+
const task = nextDispatchableTask(taskStore);
|
|
134
|
+
if (!task) return null;
|
|
135
|
+
if (!dispatched.has(task.id)) return task;
|
|
136
|
+
// The observed head is already dispatched on this journey; ask the store
|
|
137
|
+
// to advance by observing its next state. If the store never changes, the
|
|
138
|
+
// loop exits via the attempt bound and the journey ends as exhausted.
|
|
139
|
+
if (typeof taskStore.observeAdvance === "function") taskStore.observeAdvance(task.id);
|
|
140
|
+
else return null;
|
|
141
|
+
}
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function journeyReceipt(journey) {
|
|
146
|
+
const body = [
|
|
147
|
+
"[WORKER_JOURNEY_REPORT_BEGIN]",
|
|
148
|
+
`mode: ${journey.mode}`,
|
|
149
|
+
`role: ${journey.role}`,
|
|
150
|
+
`steps: ${journey.steps.length}`,
|
|
151
|
+
`status: ${journey.status}`,
|
|
152
|
+
...(journey.handoff ? [`handoff: attempted=${journey.handoff.attempted} ok=${journey.handoff.ok ?? false} role=${journey.handoff.role ?? journey.role} reason=${journey.handoff.reason ?? "none"}`] : []),
|
|
153
|
+
...journey.steps.map((step, index) =>
|
|
154
|
+
`step ${index + 1}: task=${step.taskId ?? "none"} status=${step.status} report=${step.report ?? "(none)"}`),
|
|
155
|
+
"[WORKER_JOURNEY_REPORT_END]",
|
|
156
|
+
].join("\n");
|
|
157
|
+
if (Buffer.byteLength(body, "utf8") > MAX_REPORT_BYTES) {
|
|
158
|
+
return `${body.slice(0, MAX_REPORT_BYTES)}\n[WORKER_JOURNEY_REPORT_TRUNCATED]`;
|
|
159
|
+
}
|
|
160
|
+
return body;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// One continuous journey. Each step: pulse (liveness + eligibility), observe
|
|
164
|
+
// the next dispatchable item, one prompt exchange (no retry on any failure),
|
|
165
|
+
// read the marked report, collate. Bounded by maxSteps, never wall-clock.
|
|
166
|
+
// A worker that is not idle within an observed exchange cycle is unresponsive for
|
|
167
|
+
// dispatch purposes and ends the journey explicitly as worker-unresponsive.
|
|
168
|
+
export async function runWorkerJourney(params, context, options = {}, signal) {
|
|
169
|
+
const mode = params.mode ?? DEFAULT_MODE;
|
|
170
|
+
const maxSteps = params.maxSteps ?? DEFAULT_JOURNEY_STEPS;
|
|
171
|
+
if (!Number.isInteger(maxSteps) || maxSteps < 1 || maxSteps > MAX_JOURNEY_STEPS) {
|
|
172
|
+
return failure("dispatch", dispatchError("max-steps-invalid",
|
|
173
|
+
`maxSteps must be an integer between 1 and ${MAX_JOURNEY_STEPS}`, "denied"));
|
|
174
|
+
}
|
|
175
|
+
const role = params.role;
|
|
176
|
+
const stepPrompt = params.stepPrompt;
|
|
177
|
+
const taskStore = options.taskStore;
|
|
178
|
+
const spawnReplacement = typeof options.spawnReplacement === "function" ? options.spawnReplacement : null;
|
|
179
|
+
const journey = { mode, role, steps: [], status: "failed", code: null, handoff: null };
|
|
180
|
+
const dispatched = new Set();
|
|
181
|
+
const communicationOptions = options.communication ?? options;
|
|
182
|
+
|
|
183
|
+
const finish = (status) => ({
|
|
184
|
+
schema: WORKER_DISPATCH_SCHEMA,
|
|
185
|
+
ok: status === "completed" || status === "exhausted" || status === "waiting-approval",
|
|
186
|
+
action: "dispatch",
|
|
187
|
+
mode,
|
|
188
|
+
role,
|
|
189
|
+
status,
|
|
190
|
+
steps: journey.steps,
|
|
191
|
+
stepCount: journey.steps.filter((step) => step.status === "done").length,
|
|
192
|
+
code: journey.code,
|
|
193
|
+
report: journeyReceipt(journey),
|
|
194
|
+
reportMarkers: { open: "[WORKER_JOURNEY_REPORT_BEGIN]", close: "[WORKER_JOURNEY_REPORT_END]" },
|
|
195
|
+
handoff: journey.handoff,
|
|
196
|
+
nonAuthorizing: true,
|
|
197
|
+
persisted: false,
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
// Explicit unstuck path: with native confirmation, spin up a replacement
|
|
201
|
+
// worker for the same trusted repository/role through the existing
|
|
202
|
+
// herdr-lifecycle spawn boundary (fixed argv, shell:false) and resume the
|
|
203
|
+
// pending task sequence. Reuses the same task cards; never duplicates them.
|
|
204
|
+
const handoffToReplacement = async (reason, taskId = null) => {
|
|
205
|
+
journey.status = "worker-unresponsive";
|
|
206
|
+
journey.code = "worker-unresponsive";
|
|
207
|
+
journey.steps.push({ step: journey.steps.length + 1, taskId, status: "worker-unresponsive", error: reason });
|
|
208
|
+
if (!spawnReplacement) {
|
|
209
|
+
journey.handoff = { attempted: false, reason: "replacement spawn is not available in this context" };
|
|
210
|
+
return finish("worker-unresponsive");
|
|
211
|
+
}
|
|
212
|
+
if (!isNativeTuiContext(context) || typeof context?.ui?.confirm !== "function") {
|
|
213
|
+
journey.handoff = { attempted: false, reason: "native TUI confirmation unavailable for replacement spawn" };
|
|
214
|
+
return finish("worker-unresponsive");
|
|
215
|
+
}
|
|
216
|
+
let confirmed;
|
|
217
|
+
try {
|
|
218
|
+
confirmed = await context.ui.confirm("Spin up replacement worker", [
|
|
219
|
+
`Agent session for role ${role} became unresponsive (${reason}).`,
|
|
220
|
+
"Spin up one replacement worker through the guarded herdr-lifecycle spawn boundary?",
|
|
221
|
+
"The replacement resumes the same pending task sequence; existing task cards are reused, never duplicated.",
|
|
222
|
+
].join("\n"));
|
|
223
|
+
} catch (error) {
|
|
224
|
+
journey.handoff = { attempted: false, reason: `confirmation failed: ${error.message}` };
|
|
225
|
+
return finish("worker-unresponsive");
|
|
226
|
+
}
|
|
227
|
+
if (confirmed !== true) {
|
|
228
|
+
journey.handoff = { attempted: false, reason: "native confirmation was not granted for the replacement spawn" };
|
|
229
|
+
return finish("worker-unresponsive");
|
|
230
|
+
}
|
|
231
|
+
let spawned;
|
|
232
|
+
try {
|
|
233
|
+
spawned = await spawnReplacement({ role, repository: options.repository, model: options.model, context, signal });
|
|
234
|
+
} catch (error) {
|
|
235
|
+
journey.handoff = { attempted: true, ok: false, error: String(error?.message || error).slice(0, 256) };
|
|
236
|
+
return finish("worker-unresponsive");
|
|
237
|
+
}
|
|
238
|
+
journey.handoff = {
|
|
239
|
+
attempted: true,
|
|
240
|
+
ok: spawned?.ok === true,
|
|
241
|
+
role: spawned?.role ?? role,
|
|
242
|
+
repository: spawned?.repository,
|
|
243
|
+
modelArgv: spawned?.modelArgv,
|
|
244
|
+
nonAuthorizing: true,
|
|
245
|
+
};
|
|
246
|
+
return finish("worker-unresponsive");
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
if (!HERDR_COMMUNICATION_ACTIONS.includes) { /* unreachable guard */ }
|
|
250
|
+
if (mode !== "continuous" && mode !== "turn-by-turn") {
|
|
251
|
+
return failure("dispatch", dispatchError("mode-invalid", "dispatch mode must be continuous or turn-by-turn", "denied"));
|
|
252
|
+
}
|
|
253
|
+
if (!taskStore || typeof taskStore.list !== "function") {
|
|
254
|
+
return failure("dispatch", dispatchError("task-store-invalid", "a read-only task store is required", "denied"));
|
|
255
|
+
}
|
|
256
|
+
if (signal?.aborted) return finish("cancelled");
|
|
257
|
+
|
|
258
|
+
for (let stepIndex = 1; stepIndex <= maxSteps; stepIndex += 1) {
|
|
259
|
+
if (signal?.aborted) { journey.status = "cancelled"; return finish("cancelled"); }
|
|
260
|
+
|
|
261
|
+
// Pulse: liveness and dispatch eligibility, no authority.
|
|
262
|
+
let pulse;
|
|
263
|
+
try {
|
|
264
|
+
pulse = await workerPulse(role, context, communicationOptions, signal);
|
|
265
|
+
} catch (error) {
|
|
266
|
+
journey.status = error?.code === "role_blocked" ? "role-blocked" : "failed";
|
|
267
|
+
journey.code = error?.code || "pulse-failed";
|
|
268
|
+
journey.steps.push({ step: stepIndex, taskId: null, status: journey.status, error: String(error?.message || error).slice(0, 256) });
|
|
269
|
+
return finish(journey.status);
|
|
270
|
+
}
|
|
271
|
+
if (!pulse.alive) {
|
|
272
|
+
return handoffToReplacement("worker role is not alive");
|
|
273
|
+
}
|
|
274
|
+
if (!pulse.dispatchEligible) {
|
|
275
|
+
if (pulse.status === "blocked") {
|
|
276
|
+
journey.status = "role-blocked";
|
|
277
|
+
journey.code = "role_blocked";
|
|
278
|
+
journey.steps.push({ step: stepIndex, taskId: null, status: "role-blocked", workerStatus: pulse.status });
|
|
279
|
+
return finish("role-blocked");
|
|
280
|
+
}
|
|
281
|
+
// Not idle within an observed exchange cycle: unresponsive for dispatch.
|
|
282
|
+
return handoffToReplacement(`worker not idle in the observed exchange cycle (status: ${pulse.status})`);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Observe the next dispatchable item; never create or mutate cards.
|
|
286
|
+
let task;
|
|
287
|
+
try {
|
|
288
|
+
task = selectNextTask(taskStore, dispatched);
|
|
289
|
+
} catch (error) {
|
|
290
|
+
journey.status = "failed";
|
|
291
|
+
journey.steps.push({ step: stepIndex, taskId: null, status: "failed", error: String(error?.message || error).slice(0, 256) });
|
|
292
|
+
return finish("failed");
|
|
293
|
+
}
|
|
294
|
+
if (!task) {
|
|
295
|
+
journey.status = "exhausted";
|
|
296
|
+
journey.steps.push({ step: stepIndex, taskId: null, status: "exhausted" });
|
|
297
|
+
return finish("exhausted");
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Interactive opt-in: stop after each step for explicit approval.
|
|
301
|
+
if (mode === "turn-by-turn" && stepIndex > 1) {
|
|
302
|
+
journey.status = "waiting-approval";
|
|
303
|
+
journey.steps.push({ step: stepIndex, taskId: task.id, status: "waiting-approval" });
|
|
304
|
+
return finish("waiting-approval");
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Consequential dispatch requires native confirmation, once per step.
|
|
308
|
+
if (!isNativeTuiContext(context) || typeof context?.ui?.confirm !== "function") {
|
|
309
|
+
journey.status = "failed";
|
|
310
|
+
journey.steps.push({ step: stepIndex, taskId: task.id, status: "failed", error: "native TUI confirmation unavailable" });
|
|
311
|
+
return finish("failed");
|
|
312
|
+
}
|
|
313
|
+
let confirmed;
|
|
314
|
+
try {
|
|
315
|
+
confirmed = await context.ui.confirm("Dispatch task to worker", [
|
|
316
|
+
`Dispatch one bounded step to role ${role}?`,
|
|
317
|
+
`Task: ${task.id}${task.subject ? ` — ${task.subject}` : ""}`,
|
|
318
|
+
`Mode: ${mode} (step ${stepIndex} of at most ${maxSteps})`,
|
|
319
|
+
"One prompt exchange, no retries; the worker returns one marked report.",
|
|
320
|
+
].join("\n"));
|
|
321
|
+
} catch (error) {
|
|
322
|
+
journey.status = "failed";
|
|
323
|
+
journey.steps.push({ step: stepIndex, taskId: task.id, status: "failed", error: `confirmation failed: ${error.message}` });
|
|
324
|
+
return finish("failed");
|
|
325
|
+
}
|
|
326
|
+
if (confirmed !== true) {
|
|
327
|
+
journey.status = "cancelled";
|
|
328
|
+
journey.steps.push({ step: stepIndex, taskId: task.id, status: "cancelled", error: "native confirmation was not granted" });
|
|
329
|
+
return finish("cancelled");
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// One prompt exchange. Any failure is terminal for the journey; there is
|
|
333
|
+
// no invisible retry or resend.
|
|
334
|
+
const exchange = await executeHerdrCommunication(
|
|
335
|
+
{ action: "prompt", role, prompt: `${stepPrompt}\nTask: ${task.id}${task.subject ? ` — ${task.subject}` : ""}`, timeoutMs: 120000 },
|
|
336
|
+
context,
|
|
337
|
+
communicationOptions,
|
|
338
|
+
signal,
|
|
339
|
+
);
|
|
340
|
+
if (exchange.ok !== true) {
|
|
341
|
+
const unresponsive = exchange.code === "prompt_stalled" || exchange.code === "process_timeout";
|
|
342
|
+
if (unresponsive) {
|
|
343
|
+
return handoffToReplacement(`exchange ended with ${exchange.code}`, task.id);
|
|
344
|
+
}
|
|
345
|
+
journey.status = exchange.code === "role_blocked" ? "role-blocked" : "failed";
|
|
346
|
+
journey.code = exchange.code || "exchange-failed";
|
|
347
|
+
journey.steps.push({ step: stepIndex, taskId: task.id, status: journey.status, error: exchange.reason || exchange.error || exchange.code });
|
|
348
|
+
return finish(journey.status);
|
|
349
|
+
}
|
|
350
|
+
journey.steps.push({
|
|
351
|
+
step: stepIndex,
|
|
352
|
+
taskId: task.id,
|
|
353
|
+
status: "done",
|
|
354
|
+
workerStatus: exchange.agentStatus,
|
|
355
|
+
report: exchange.report,
|
|
356
|
+
});
|
|
357
|
+
dispatched.add(task.id);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
journey.status = "completed";
|
|
361
|
+
return finish("completed");
|
|
362
|
+
// Unreachable in correct use: every step either dispatches one pending
|
|
363
|
+
// task, or the queue observation returns null and the journey ends with
|
|
364
|
+
// "exhausted". A task store that never drains hits maxSteps and lands here;
|
|
365
|
+
// "completed" then reflects the step bound, and callers inspect stepCount.
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
export function registerWorkerDispatchInterface(pi, options = {}) {
|
|
369
|
+
if (typeof pi?.registerTool !== "function" || REGISTRATIONS.has(pi)) return;
|
|
370
|
+
REGISTRATIONS.add(pi);
|
|
371
|
+
pi.registerTool({
|
|
372
|
+
name: WORKER_DISPATCH_TOOL,
|
|
373
|
+
label: "Worker Dispatch And Pulse",
|
|
374
|
+
description: "Observe worker liveness (pulse) or run one bounded continuous worker journey over the existing task sequence. Continuous mode is the default; turn-by-turn is explicit opt-in. Journeys emit one collated marked report, never create task cards, never retry, and are bounded by step count, not wall-clock.",
|
|
375
|
+
promptSnippet: "Use agentic_worker_dispatch to pulse a worker or run one bounded continuous journey over the existing task sequence; it observes dispatchable tasks without creating cards and grants no authority.",
|
|
376
|
+
promptGuidelines: [
|
|
377
|
+
"agentic_worker_dispatch pulse observes liveness, state, and dispatch eligibility without granting authority.",
|
|
378
|
+
"agentic_worker_dispatch dispatch runs at most maxSteps single-exchange steps; any exchange failure ends the journey explicitly with no retry or resend.",
|
|
379
|
+
],
|
|
380
|
+
parameters: WORKER_DISPATCH_PARAMETERS,
|
|
381
|
+
async execute(_id, params, signal, _update, context) {
|
|
382
|
+
let value;
|
|
383
|
+
if (params?.action === "pulse") {
|
|
384
|
+
try {
|
|
385
|
+
const pulse = await workerPulse(params.role, context, options, signal);
|
|
386
|
+
value = { schema: WORKER_DISPATCH_SCHEMA, ok: true, action: "pulse", ...pulse, nonAuthorizing: true, persisted: false };
|
|
387
|
+
} catch (error) {
|
|
388
|
+
value = failure("pulse", error);
|
|
389
|
+
}
|
|
390
|
+
} else {
|
|
391
|
+
value = await runWorkerJourney(params ?? {}, context, options, signal);
|
|
392
|
+
}
|
|
393
|
+
return {
|
|
394
|
+
content: [{ type: "text", text: JSON.stringify(value, null, 2) }],
|
|
395
|
+
details: value,
|
|
396
|
+
};
|
|
397
|
+
},
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
export { HERDR_REPORT_MARKERS, HERDR_COMMUNICATION_SCHEMA };
|
|
402
|
+
export default registerWorkerDispatchInterface;
|
|
@@ -2,20 +2,46 @@
|
|
|
2
2
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
3
|
|
|
4
4
|
import { createHash, randomBytes } from "node:crypto";
|
|
5
|
-
import { readFileSync } from "node:fs";
|
|
5
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
6
6
|
import { dirname, join } from "node:path";
|
|
7
7
|
import { fileURLToPath } from "node:url";
|
|
8
8
|
import { spawnSync } from "node:child_process";
|
|
9
|
+
import { homedir } from "node:os";
|
|
9
10
|
import { isNativeTuiContext } from "./native_tui_context.js";
|
|
10
11
|
|
|
11
12
|
export const LINUX_MICROVM_CUTOVER_TOOL = "agentic_linux_microvm_cutover";
|
|
12
13
|
export const LINUX_MICROVM_CUTOVER_SCHEMA = "agentic-driver.linux-microvm-cutover.v1";
|
|
13
14
|
const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
|
|
14
15
|
const REMOTE_FIXTURE = join(SCRIPT_DIR, "linux_microvm_remote_fixture.sh");
|
|
16
|
+
const TARGET_EXAMPLE = join(SCRIPT_DIR, "..", "..", "config", "microvm-target.v1.example.json");
|
|
17
|
+
const TARGET_PACKAGE_CONFIG = join(SCRIPT_DIR, "..", "..", "config", "microvm-target.v1.json");
|
|
18
|
+
// The saved target config lives in the Pi coding-agent config directory
|
|
19
|
+
// (PI_CODING_AGENT_DIR when set, otherwise ~/.pi/agent), matching how Pi
|
|
20
|
+
// resolves its own config.
|
|
21
|
+
function resolveTargetUserConfigPath(env = process.env) {
|
|
22
|
+
const agentDir = env.PI_CODING_AGENT_DIR?.trim()
|
|
23
|
+
? join(env.PI_CODING_AGENT_DIR.trim(), "config")
|
|
24
|
+
: join(homedir(), ".pi", "agent", "config");
|
|
25
|
+
return join(agentDir, "microvm-target.v1.json");
|
|
26
|
+
}
|
|
27
|
+
const TARGET_USER_CONFIG = resolveTargetUserConfigPath();
|
|
28
|
+
const TARGET_SCHEMA = "agentic-driver.microvm-target.v1";
|
|
15
29
|
const REGISTRATIONS = new WeakSet();
|
|
16
|
-
const
|
|
30
|
+
const SWITCH_REGISTRATIONS = new WeakSet();
|
|
17
31
|
const HASH = /^[0-9a-f]{64}$/;
|
|
18
32
|
const MAX_DETAIL = 512;
|
|
33
|
+
// Isolation switch state is per-registration: each registerIsolationSwitchCommands
|
|
34
|
+
// call creates a fresh flag in the registration closure, so a new extension
|
|
35
|
+
// registration (session) starts disabled. It is held in memory only, never
|
|
36
|
+
// read from or written to settings, and never settable by the model (no tool
|
|
37
|
+
// exposes it; only the native TUI enable/disable commands mutate it).
|
|
38
|
+
export function createIsolationSwitch() {
|
|
39
|
+
let enabled = false;
|
|
40
|
+
return {
|
|
41
|
+
get() { return enabled === true; },
|
|
42
|
+
set(value) { enabled = value === true; },
|
|
43
|
+
};
|
|
44
|
+
}
|
|
19
45
|
let inFlight = false;
|
|
20
46
|
|
|
21
47
|
function boundedText(value, fallback = "unknown failure") {
|
|
@@ -61,7 +87,84 @@ function fixtureDomainForId(fixtureId) {
|
|
|
61
87
|
}
|
|
62
88
|
return `agentic-driver-${fixtureId}`;
|
|
63
89
|
}
|
|
64
|
-
|
|
90
|
+
// --- User-configured trusted target (deny-by-default) -----------------------
|
|
91
|
+
// The user makes one decision: where the microVM runs. Either
|
|
92
|
+
// { "sshTarget": "user@host-or-ip" } for a remote machine, or { "local": true }
|
|
93
|
+
// when this session runs directly on a Linux machine. Nothing else is
|
|
94
|
+
// user-supplied: arch, libvirt URI, and kernel are auto-discovered from the
|
|
95
|
+
// target at probe time (informational, not configured). The model cannot
|
|
96
|
+
// choose or change the target. Read order: the user's own config
|
|
97
|
+
// (~/.pi/pi/config/microvm-target.v1.json) first, then the package-local
|
|
98
|
+
// config/microvm-target.v1.json (shipped as a REPLACE-WITH template).
|
|
99
|
+
const TARGET_FIELDS = new Set(["schema", "sshTarget", "local", "_comment"]);
|
|
100
|
+
export function loadMicroVMTarget(options = {}) {
|
|
101
|
+
if (options.target && typeof options.target === "object") {
|
|
102
|
+
return normalizeTarget(options.target);
|
|
103
|
+
}
|
|
104
|
+
const paths = [
|
|
105
|
+
...(typeof options.targetPath === "string" ? [options.targetPath] : []),
|
|
106
|
+
...(typeof options.targetUserConfigPath === "string" ? [options.targetUserConfigPath] : []),
|
|
107
|
+
// A test/override seam can replace the default user-config location so
|
|
108
|
+
// tests stay hermetic regardless of the developer's own machine.
|
|
109
|
+
...(typeof options.userConfigPath === "string" ? [options.userConfigPath] : [TARGET_USER_CONFIG]),
|
|
110
|
+
TARGET_PACKAGE_CONFIG,
|
|
111
|
+
];
|
|
112
|
+
for (const path of paths) {
|
|
113
|
+
try {
|
|
114
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
115
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) continue;
|
|
116
|
+
if (Object.keys(parsed).some((key) => !TARGET_FIELDS.has(key))) continue;
|
|
117
|
+
if (parsed.schema !== TARGET_SCHEMA) continue;
|
|
118
|
+
const normalized = normalizeTarget(parsed);
|
|
119
|
+
if (!normalized) continue;
|
|
120
|
+
return Object.freeze({ ...normalized, __path: path });
|
|
121
|
+
} catch {
|
|
122
|
+
// Missing or unreadable candidate: fall through to the next path.
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Exactly one user decision: sshTarget XOR local:true. Returns the frozen
|
|
129
|
+
// normalized target or null when the decision is absent, ambiguous, or a
|
|
130
|
+
// REPLACE-WITH template placeholder.
|
|
131
|
+
function normalizeTarget(parsed) {
|
|
132
|
+
const hasSshTarget = typeof parsed.sshTarget === "string" && parsed.sshTarget.trim()
|
|
133
|
+
&& !parsed.sshTarget.includes("REPLACE-WITH-");
|
|
134
|
+
const hasLocal = parsed.local === true;
|
|
135
|
+
if (hasSshTarget === hasLocal) return null;
|
|
136
|
+
return Object.freeze(hasSshTarget
|
|
137
|
+
? { mode: "ssh", sshTarget: parsed.sshTarget.trim() }
|
|
138
|
+
: { mode: "local" });
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Shape validation for the user-relayed target parameter (untrusted input).
|
|
142
|
+
// Accepts user@host, an ip (optionally :port), a plain hostname, or "local".
|
|
143
|
+
export function targetArgumentError(value) {
|
|
144
|
+
const text = typeof value === "string" ? value.trim() : "";
|
|
145
|
+
if (!text) {
|
|
146
|
+
return { code: "target-argument-required", detail: "Provide the target: user@host, an ip, or local." };
|
|
147
|
+
}
|
|
148
|
+
if (text.includes("REPLACE-WITH-")) {
|
|
149
|
+
return { code: "target-argument-placeholder", detail: "Placeholder values are not valid targets." };
|
|
150
|
+
}
|
|
151
|
+
if (text === "local") return null;
|
|
152
|
+
// Accepts a bare hostname/ssh-config alias, user@host, user@ip, ip[:port].
|
|
153
|
+
// ssh config resolves aliases; the string is passed verbatim in fixed argv.
|
|
154
|
+
const pattern = /^(?:[a-zA-Z0-9._-]+@)?(?:\d{1,3}(?:\.\d{1,3}){3}|[a-zA-Z0-9]([a-zA-Z0-9.-]*[a-zA-Z0-9])?)(?::\d{1,5})?$/;
|
|
155
|
+
const bareToken = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
|
|
156
|
+
if (/\s/.test(text) || (!pattern.test(text) && !bareToken.test(text))) {
|
|
157
|
+
return { code: "target-argument-invalid", detail: `${JSON.stringify(text)} is not a plausible ssh target (alias, user@host, or ip) or the literal 'local'.` };
|
|
158
|
+
}
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function targetNotConfigured() {
|
|
163
|
+
return denied("blocked", reason("policy", "target-not-configured",
|
|
164
|
+
"No trusted microVM target is configured. Copy config/microvm-target.v1.example.json to ~/.pi/pi/config/microvm-target.v1.json and set either sshTarget (user@host or ip of the machine that runs the microVM) or local:true (this session runs on a Linux machine). The model cannot choose the target."));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function parseFacts(stdout, fixtureId, target) {
|
|
65
168
|
const values = {};
|
|
66
169
|
for (const line of String(stdout || "").split("\n")) {
|
|
67
170
|
if (!line) continue;
|
|
@@ -75,37 +178,76 @@ function parseFacts(stdout, fixtureId) {
|
|
|
75
178
|
kernel: values.kernel,
|
|
76
179
|
libvirt: values.libvirt,
|
|
77
180
|
qemu: values.qemu,
|
|
181
|
+
qemuBinaryPath: values.qemu_binary_path,
|
|
78
182
|
fixtureDomain: values.fixture_domain_name,
|
|
79
183
|
fixtureDomainState: values.fixture_domain_state,
|
|
184
|
+
kvmAccessible: values.kvm_accessible === "yes",
|
|
80
185
|
};
|
|
81
186
|
const domain = fixtureDomainForId(fixtureId);
|
|
82
|
-
if (facts.
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
187
|
+
if (facts.fixtureDomain !== domain || facts.fixtureDomainState !== "absent") {
|
|
188
|
+
throw phaseError("preflight", "facts-unexpected", "the exact fixture-domain absence check failed");
|
|
189
|
+
}
|
|
190
|
+
return validateFacts(facts, fixtureId);
|
|
191
|
+
}
|
|
192
|
+
// Honest safety checks over auto-discovered facts: the target must expose
|
|
193
|
+
// KVM, a system-level libvirt connection, and a qemu-system binary matching
|
|
194
|
+
// the discovered architecture. No user-predicted values are involved.
|
|
195
|
+
function validateFacts(facts, _fixtureId) {
|
|
196
|
+
if (!facts || typeof facts !== "object" || Array.isArray(facts)) {
|
|
197
|
+
throw phaseError("preflight", "facts-invalid", "trusted host facts are not an object");
|
|
198
|
+
}
|
|
199
|
+
if (typeof facts.host !== "string" || !facts.host || typeof facts.arch !== "string" || !facts.arch
|
|
200
|
+
|| typeof facts.kernel !== "string" || !facts.kernel || typeof facts.qemu !== "string" || !facts.qemu
|
|
201
|
+
|| typeof facts.libvirt !== "string" || !facts.libvirt) {
|
|
202
|
+
throw phaseError("preflight", "facts-unexpected", "the target did not report a complete set of discoverable facts");
|
|
203
|
+
}
|
|
204
|
+
if (facts.kvmAccessible !== true) {
|
|
205
|
+
throw phaseError("preflight", "kvm-unavailable", "the target does not expose an accessible /dev/kvm; hardware virtualization is required");
|
|
206
|
+
}
|
|
207
|
+
if (!facts.libvirt.startsWith("qemu:///system")) {
|
|
208
|
+
throw phaseError("preflight", "libvirt-user-level",
|
|
209
|
+
`the target libvirt connection is '${facts.libvirt}', not the system driver (qemu:///system); the microVM proof requires the system-level libvirt driver`);
|
|
210
|
+
}
|
|
211
|
+
// The decisive evidence is WHICH binary resolved, not an arch token inside
|
|
212
|
+
// the version string (Debian builds omit it there).
|
|
213
|
+
if (typeof facts.qemuBinaryPath !== "string" || !facts.qemuBinaryPath.endsWith(`qemu-system-${facts.arch}`)
|
|
214
|
+
|| !/^QEMU/.test(facts.qemu)) {
|
|
215
|
+
throw phaseError("preflight", "qemu-binary-missing",
|
|
216
|
+
`the target did not prove a working qemu-system-${facts.arch}: resolved binary '${facts.qemuBinaryPath || "(none)"}', version output '${facts.qemu.slice(0, 80)}'`);
|
|
87
217
|
}
|
|
88
218
|
return facts;
|
|
89
219
|
}
|
|
90
|
-
function sshProbe(execute = run, fixtureId) {
|
|
220
|
+
function sshProbe(execute = run, fixtureId, target) {
|
|
91
221
|
const domain = fixtureDomainForId(fixtureId);
|
|
92
222
|
const quotedDomain = shellQuote(domain);
|
|
223
|
+
// All technical expectations are auto-discovered from the target itself;
|
|
224
|
+
// validation checks honest safety properties (KVM, system libvirt driver,
|
|
225
|
+
// qemu binary for the discovered arch) with no user-predicted values.
|
|
93
226
|
const command = [
|
|
94
|
-
"set -eu",
|
|
95
|
-
"test -
|
|
96
|
-
"test -x /usr/bin/
|
|
97
|
-
"test -x /usr/bin/
|
|
227
|
+
"set -eu",
|
|
228
|
+
"test -r /dev/kvm -a -w /dev/kvm",
|
|
229
|
+
"test -x /usr/bin/qemu-system-$(uname -m)",
|
|
230
|
+
"test -x /usr/bin/busybox", "test -x /usr/bin/cpio", "test -x /usr/bin/gzip",
|
|
231
|
+
"test -x /usr/bin/setfacl", "test -x /usr/bin/getfacl", "test -n \"$(virsh uri)\"",
|
|
98
232
|
"printf 'host=%s\\n' \"$(hostname)\"", "printf 'arch=%s\\n' \"$(uname -m)\"",
|
|
99
233
|
"printf 'kernel=%s\\n' \"$(uname -r)\"", "printf 'libvirt=%s\\n' \"$(virsh uri)\"",
|
|
100
|
-
|
|
234
|
+
`printf 'qemu_binary_path=%s\\n' "$(command -v qemu-system-$(uname -m))"`,
|
|
235
|
+
`printf 'qemu=%s\\n' "$(qemu-system-$(uname -m) --version | head -1)"`,
|
|
236
|
+
"printf 'kvm_accessible=%s\\n' \"$( test -r /dev/kvm -a -w /dev/kvm && echo yes || echo no )\"",
|
|
101
237
|
`printf 'fixture_domain_name=%s\\n' ${quotedDomain}`,
|
|
102
238
|
`if virsh dominfo ${quotedDomain} >/dev/null 2>&1; then printf 'fixture_domain_state=present\\n'; else names=$(virsh list --all --name); if printf '%s\\n' \"$names\" | grep -F -x -- ${quotedDomain} >/dev/null; then printf 'fixture_domain_state=present\\n'; else match_status=$?; if [ \"$match_status\" -eq 1 ]; then printf 'fixture_domain_state=absent\\n'; else exit 1; fi; fi; fi`,
|
|
103
239
|
].join("; ");
|
|
104
|
-
const result =
|
|
240
|
+
const result = target.mode === "local"
|
|
241
|
+
? execute("bash", ["-c", command], { timeout: 30000 })
|
|
242
|
+
: execute("ssh", [target.sshTarget, command], { timeout: 30000 });
|
|
105
243
|
if (result.code !== 0) {
|
|
106
|
-
|
|
244
|
+
const text = result.stderr || result.error || "";
|
|
245
|
+
if (/\/dev\/kvm/i.test(text)) {
|
|
246
|
+
throw phaseError("preflight", "kvm-unavailable", "the target does not expose an accessible /dev/kvm; hardware virtualization is required");
|
|
247
|
+
}
|
|
248
|
+
throw phaseError("preflight", "probe-failed", text || "configured microVM target capability probe failed");
|
|
107
249
|
}
|
|
108
|
-
return parseFacts(result.stdout, fixtureId);
|
|
250
|
+
return parseFacts(result.stdout, fixtureId, target);
|
|
109
251
|
}
|
|
110
252
|
function exactKeys(value, keys, label) {
|
|
111
253
|
if (!value || typeof value !== "object" || Array.isArray(value)
|
|
@@ -121,18 +263,6 @@ function requireHash(value, label) {
|
|
|
121
263
|
function requireBoolean(value, label) {
|
|
122
264
|
if (typeof value !== "boolean") throw phaseError("evidence", "receipt-invalid", `${label} is not boolean evidence`);
|
|
123
265
|
}
|
|
124
|
-
function validateFacts(facts, fixtureId) {
|
|
125
|
-
const domain = fixtureDomainForId(fixtureId);
|
|
126
|
-
if (!facts || typeof facts !== "object" || Array.isArray(facts)) {
|
|
127
|
-
throw phaseError("preflight", "facts-invalid", "trusted host facts are not an object");
|
|
128
|
-
}
|
|
129
|
-
if (facts.host !== "ubuntu-backend" || facts.arch !== "x86_64" || facts.libvirt !== "qemu:///system"
|
|
130
|
-
|| facts.fixtureDomain !== domain || facts.fixtureDomainState !== "absent"
|
|
131
|
-
|| typeof facts.kernel !== "string" || !facts.kernel || typeof facts.qemu !== "string" || !facts.qemu) {
|
|
132
|
-
throw phaseError("preflight", "facts-unexpected", "fixed host facts or the exact fixture-domain absence check failed");
|
|
133
|
-
}
|
|
134
|
-
return facts;
|
|
135
|
-
}
|
|
136
266
|
function parseReceipt(stdout) {
|
|
137
267
|
const candidates = [];
|
|
138
268
|
const unexpected = [];
|
|
@@ -236,15 +366,62 @@ function normalizedForwardedStderr(result, fallbackPhase, fallbackCode, fallback
|
|
|
236
366
|
}
|
|
237
367
|
|
|
238
368
|
export async function runLinuxMicroVMCutover(context, options = {}) {
|
|
239
|
-
|
|
240
|
-
|
|
369
|
+
// Session-scoped user switch: only the explicit enable command can set this
|
|
370
|
+
// flag in memory; it never persists to settings and the model cannot set it.
|
|
371
|
+
if (options.isolationSwitch?.get() !== true) {
|
|
372
|
+
return denied("blocked", reason("policy", "isolation-not-enabled",
|
|
373
|
+
"Isolation activation is not enabled in this session. Run the agentic-isolation-enable command in the Pi TUI."));
|
|
374
|
+
}
|
|
375
|
+
// Target selection: the only way user intent reaches setup is the optional
|
|
376
|
+
// `target` parameter, relayed by the agent from the user's words. It is
|
|
377
|
+
// untrusted: nothing is written or run without the user's native
|
|
378
|
+
// confirmation dialog naming the target explicitly.
|
|
379
|
+
const targetParam = typeof options.target === "string" ? options.target.trim() : "";
|
|
380
|
+
if (targetParam) {
|
|
381
|
+
const shapeError = targetArgumentError(targetParam);
|
|
382
|
+
if (shapeError) return denied("denied", reason("input", shapeError.code, shapeError.detail));
|
|
383
|
+
// Headless setups are refused: only pre-configured targets run.
|
|
384
|
+
if (!isNativeTuiContext(context) || typeof context?.ui?.confirm !== "function") {
|
|
385
|
+
return denied("blocked", reason("policy", "native-tui-required",
|
|
386
|
+
"Configuring a new microVM target requires the interactive Pi TUI; headless sessions may only use a pre-configured target."));
|
|
387
|
+
}
|
|
388
|
+
const writePath = options.targetUserConfigPath?.trim() || TARGET_USER_CONFIG;
|
|
389
|
+
let confirmed;
|
|
390
|
+
try {
|
|
391
|
+
confirmed = await context.ui.confirm("Use this microVM host?", [
|
|
392
|
+
`Use ${targetParam === "local" ? "THIS machine (local)" : targetParam} as the microVM host? This saves it to your Pi config.`,
|
|
393
|
+
`Config file: ${writePath}`,
|
|
394
|
+
"The model relayed your words; this confirmation is what authorizes the choice.",
|
|
395
|
+
].join("\n"));
|
|
396
|
+
} catch (error) {
|
|
397
|
+
return denied("blocked", reason("confirmation", "confirmation-failed", `Native confirmation failed: ${error.message}`));
|
|
398
|
+
}
|
|
399
|
+
if (confirmed !== true) {
|
|
400
|
+
return denied("stopped", reason("confirmation", "not-granted", "No target was saved; native confirmation was not granted."));
|
|
401
|
+
}
|
|
402
|
+
const saved = targetParam === "local"
|
|
403
|
+
? { schema: TARGET_SCHEMA, local: true }
|
|
404
|
+
: { schema: TARGET_SCHEMA, sshTarget: targetParam };
|
|
405
|
+
try {
|
|
406
|
+
mkdirSync(dirname(writePath), { recursive: true });
|
|
407
|
+
writeFileSync(writePath, `${JSON.stringify(saved, null, 2)}\n`);
|
|
408
|
+
} catch (error) {
|
|
409
|
+
return denied("blocked", reason("policy", "target-write-failed", `Could not write ${writePath}: ${error.message}`));
|
|
410
|
+
}
|
|
241
411
|
}
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
412
|
+
// Saved config (possibly just written above) drives the run; deny-by-default
|
|
413
|
+
// when neither a target param nor saved config exists.
|
|
414
|
+
const target = loadMicroVMTarget({
|
|
415
|
+
...options,
|
|
416
|
+
...(options.userConfigPath ? {} : { userConfigPath: options.userConfigPath }),
|
|
417
|
+
});
|
|
418
|
+
if (!target) {
|
|
419
|
+
return denied("blocked", reason("policy", "target-not-configured",
|
|
420
|
+
"No microVM target is configured. Ask the user which machine the microVM should run on and pass it as the `target` parameter (user@host, ip, or local)."));
|
|
245
421
|
}
|
|
246
|
-
if (!isNativeTuiContext(context)
|
|
247
|
-
return denied("blocked", reason("policy", "native-tui-required",
|
|
422
|
+
if (!isNativeTuiContext(context)) {
|
|
423
|
+
return denied("blocked", reason("policy", "native-tui-required",
|
|
424
|
+
"Open the Linux microVM cutover in the interactive Pi TUI; headless runs are denied."));
|
|
248
425
|
}
|
|
249
426
|
if (inFlight) return denied("denied", reason("execution", "already-active", "another Linux microVM cutover is active in this host session"));
|
|
250
427
|
|
|
@@ -259,13 +436,13 @@ export async function runLinuxMicroVMCutover(context, options = {}) {
|
|
|
259
436
|
return denied("denied", reasonFromError(error, "payload", "payload-read-failed"));
|
|
260
437
|
}
|
|
261
438
|
const execute = options.execute || run;
|
|
262
|
-
const observe = options.observeFacts || ((executeArg, id) => sshProbe(executeArg, id));
|
|
439
|
+
const observe = options.observeFacts || ((executeArg, id) => sshProbe(executeArg, id, target));
|
|
263
440
|
let facts;
|
|
264
441
|
try { facts = validateFacts(observe(execute, fixtureId), fixtureId); }
|
|
265
442
|
catch (error) { return denied("denied", reasonFromError(error, "preflight", "facts-observation-failed")); }
|
|
266
443
|
const body = [
|
|
267
|
-
"Run one live transient QEMU/KVM microVM proof on
|
|
268
|
-
`
|
|
444
|
+
"Run one live transient QEMU/KVM microVM proof on the configured trusted target?",
|
|
445
|
+
`Target: ${target.mode === "local" ? "this machine (local)" : target.sshTarget} — discovered host: ${facts.host} (${facts.arch}, kernel ${facts.kernel})`,
|
|
269
446
|
`Backend: ${facts.libvirt}; ${facts.qemu}`,
|
|
270
447
|
`Fixture: ${fixtureId}; domain: ${fixtureDomain} (preflight absent)`,
|
|
271
448
|
`Versioned fixture SHA-256: ${scriptHash}`,
|
|
@@ -288,7 +465,9 @@ export async function runLinuxMicroVMCutover(context, options = {}) {
|
|
|
288
465
|
}
|
|
289
466
|
inFlight = true;
|
|
290
467
|
try {
|
|
291
|
-
const result =
|
|
468
|
+
const result = target.mode === "local"
|
|
469
|
+
? execute("bash", ["-c", "bash -s -- " + shellQuote(fixtureId) + " " + shellQuote(scriptHash)], { input: script, timeout: 180000 })
|
|
470
|
+
: execute("ssh", [target.sshTarget, "bash", "-s", "--", fixtureId, scriptHash], { input: script, timeout: 180000 });
|
|
292
471
|
if (!result || result.code !== 0) {
|
|
293
472
|
return denied("blocked", normalizedForwardedStderr(result, "fixture", "execution-failed", "fixed microVM fixture failed"));
|
|
294
473
|
}
|
|
@@ -307,22 +486,132 @@ function commandArgumentsPresent(args) {
|
|
|
307
486
|
export function registerLinuxMicroVMCutoverInterface(pi, options = {}) {
|
|
308
487
|
if (typeof pi?.registerTool !== "function" || REGISTRATIONS.has(pi)) return;
|
|
309
488
|
REGISTRATIONS.add(pi);
|
|
489
|
+
// Prominent outcome presentation. The first line of the tool output is a
|
|
490
|
+
// concise final status (VERIFIED / DENIED / STOPPED / BLOCKED) with fixture
|
|
491
|
+
// id and one-line evidence or reason summary; the full JSON receipt follows
|
|
492
|
+
// unchanged. When the interactive TUI exposes the notify surface, the same
|
|
493
|
+
// status is raised as a fire-and-forget notification.
|
|
494
|
+
const outcomeLine = (value) => {
|
|
495
|
+
if (value?.ok === true && value?.status === "VERIFIED") {
|
|
496
|
+
return `MICROVM CUTOVER: VERIFIED — fixture ${value.identity?.fixtureId ?? "unknown"} on ${value.identity?.remoteHost ?? "unknown host"}; domain ${value.identity?.domain ?? "?"} transient+gone, teardown proofed, isolation context closed.`;
|
|
497
|
+
}
|
|
498
|
+
const status = String(value?.status ?? "DENIED").toUpperCase();
|
|
499
|
+
const code = value?.reason?.code ?? value?.code ?? "unknown";
|
|
500
|
+
const detail = value?.reason?.detail ?? value?.error ?? "";
|
|
501
|
+
return `MICROVM CUTOVER: ${status} — reason ${code}${detail ? `: ${detail}` : ""}`;
|
|
502
|
+
};
|
|
503
|
+
const notifyOutcome = (context, value) => {
|
|
504
|
+
const notify = context?.ui?.notify;
|
|
505
|
+
if (typeof notify !== "function") return;
|
|
506
|
+
const line = outcomeLine(value);
|
|
507
|
+
notify(line, value?.ok === true ? "info" : value?.status === "stopped" ? "warning" : "error");
|
|
508
|
+
};
|
|
310
509
|
const execute = async (_id, params, _signal, _update, context) => {
|
|
311
|
-
const
|
|
312
|
-
?
|
|
510
|
+
const closedParams = params && typeof params === "object" && !Array.isArray(params)
|
|
511
|
+
? Object.keys(params).filter((key) => key !== "target")
|
|
512
|
+
: [];
|
|
513
|
+
const value = closedParams.length
|
|
514
|
+
? denied("denied", reason("input", "model-parameters-not-allowed", "Linux microVM cutover accepts only the optional target parameter"))
|
|
313
515
|
: await runLinuxMicroVMCutover(context, {
|
|
314
516
|
...options,
|
|
315
|
-
|
|
316
|
-
isolationEnabled: options.runtime?.isolationEnabled,
|
|
517
|
+
...(typeof params?.target === "string" ? { target: params.target } : {}),
|
|
317
518
|
});
|
|
318
|
-
|
|
519
|
+
notifyOutcome(context, value);
|
|
520
|
+
return { content: [{ type: "text", text: `${outcomeLine(value)}\n${JSON.stringify(value, null, 2)}` }], details: value };
|
|
319
521
|
};
|
|
320
522
|
pi.registerTool({ name: LINUX_MICROVM_CUTOVER_TOOL, label: "Verify Linux microVM",
|
|
321
|
-
description: "Run one native-confirmed transient QEMU/KVM microVM proof on
|
|
322
|
-
parameters: {
|
|
523
|
+
description: "Run one native-confirmed transient QEMU/KVM microVM proof on the trusted target. The optional target parameter (user@host, ip, or local) relays the user's machine choice; saving a new target requires the user's native confirmation. Requires the session isolation switch.",
|
|
524
|
+
parameters: {
|
|
525
|
+
type: "object",
|
|
526
|
+
additionalProperties: false,
|
|
527
|
+
properties: {
|
|
528
|
+
target: { type: "string", maxLength: 255, description: "Where the microVM runs: user@host, an ip, or the literal local. Relays the user's explicit choice; a new target is saved only after the user's native confirmation." },
|
|
529
|
+
},
|
|
530
|
+
}, execute });
|
|
323
531
|
pi.registerCommand?.("agentic-linux-microvm-cutover", { description: "Run the native-confirmed Linux microVM proof",
|
|
324
532
|
handler: async (args, context) => commandArgumentsPresent(args)
|
|
325
533
|
? denied("denied", reason("input", "command-arguments-not-allowed", "Linux microVM cutover accepts no command arguments"))
|
|
326
534
|
: (await execute("command", {}, undefined, undefined, context)).details });
|
|
327
535
|
}
|
|
536
|
+
|
|
537
|
+
// Session-scoped isolation switch commands. The flag lives only in this
|
|
538
|
+
// module's memory: the model has no tool to set or read it, it is never
|
|
539
|
+
// written to settings, and each command itself requires a native TUI
|
|
540
|
+
// confirmation. Disabling always succeeds once confirmed; enabling requires
|
|
541
|
+
// the interactive Pi TUI.
|
|
542
|
+
const ISOLATION_ENABLE_COMMAND = "agentic-isolation-enable";
|
|
543
|
+
const ISOLATION_DISABLE_COMMAND = "agentic-isolation-disable";
|
|
544
|
+
export const ISOLATION_COMMANDS = { enable: ISOLATION_ENABLE_COMMAND, disable: ISOLATION_DISABLE_COMMAND };
|
|
545
|
+
|
|
546
|
+
export function registerIsolationSwitchCommands(pi, options = {}) {
|
|
547
|
+
if (typeof pi?.registerCommand !== "function" || SWITCH_REGISTRATIONS.has(pi)) return;
|
|
548
|
+
SWITCH_REGISTRATIONS.add(pi);
|
|
549
|
+
// Fresh, registration-scoped switch state: a new registration starts disabled.
|
|
550
|
+
const isolationSwitch = options.isolationSwitch ?? createIsolationSwitch();
|
|
551
|
+
const switchNotice = () => isolationSwitch.get()
|
|
552
|
+
? "Isolation activation is ENABLED for this session only. It does not persist to settings and resets when the session ends."
|
|
553
|
+
: "Isolation activation is DISABLED. No microVM proof can run in this session until it is enabled.";
|
|
554
|
+
const switchResult = (ok, status, code, detail) => ({
|
|
555
|
+
schema: LINUX_MICROVM_CUTOVER_SCHEMA, ok, status,
|
|
556
|
+
reason: ok ? undefined : reason("policy", code, detail),
|
|
557
|
+
isolationEnabled: isolationSwitch.get(),
|
|
558
|
+
persisted: false,
|
|
559
|
+
});
|
|
560
|
+
const rejectArguments = () => switchResult(false, "denied", "command-arguments-not-allowed",
|
|
561
|
+
"The isolation switch commands accept no command arguments.");
|
|
562
|
+
pi.registerCommand(ISOLATION_ENABLE_COMMAND, {
|
|
563
|
+
description: "Enable the Linux microVM isolation switch for this session (native confirmation required)",
|
|
564
|
+
handler: async (args, context) => {
|
|
565
|
+
if (commandArgumentsPresent(args)) return rejectArguments();
|
|
566
|
+
if (!isNativeTuiContext(context) || typeof context?.ui?.confirm !== "function") {
|
|
567
|
+
return switchResult(false, "blocked", "native-tui-required",
|
|
568
|
+
"Open the interactive Pi TUI to enable isolation; headless sessions cannot enable it.");
|
|
569
|
+
}
|
|
570
|
+
if (isolationSwitch.get()) {
|
|
571
|
+
return switchResult(true, "ALREADY_ENABLED", "", "");
|
|
572
|
+
}
|
|
573
|
+
let confirmed;
|
|
574
|
+
try {
|
|
575
|
+
confirmed = await context.ui.confirm("Enable Linux microVM isolation", [
|
|
576
|
+
"Enable the isolation-activation switch for this session?",
|
|
577
|
+
switchNotice(),
|
|
578
|
+
"Effect: the agentic_linux_microvm_cutover tool may run one native-confirmed transient QEMU/KVM microVM proof on the configured trusted target per invocation.",
|
|
579
|
+
"The switch is session-scoped: it never persists to settings and the model cannot change it.",
|
|
580
|
+
].join("\n"));
|
|
581
|
+
} catch (error) {
|
|
582
|
+
return switchResult(false, "blocked", "confirmation-failed", `Native confirmation failed: ${error.message}`);
|
|
583
|
+
}
|
|
584
|
+
if (confirmed !== true) {
|
|
585
|
+
return switchResult(false, "stopped", "not-granted", "Isolation activation was not enabled; native confirmation was not granted.");
|
|
586
|
+
}
|
|
587
|
+
isolationSwitch.set(true);
|
|
588
|
+
return switchResult(true, "ENABLED", "", "");
|
|
589
|
+
},
|
|
590
|
+
});
|
|
591
|
+
pi.registerCommand(ISOLATION_DISABLE_COMMAND, {
|
|
592
|
+
description: "Disable the Linux microVM isolation switch for this session (native confirmation required)",
|
|
593
|
+
handler: async (args, context) => {
|
|
594
|
+
if (commandArgumentsPresent(args)) return rejectArguments();
|
|
595
|
+
if (!isNativeTuiContext(context) || typeof context?.ui?.confirm !== "function") {
|
|
596
|
+
return switchResult(false, "blocked", "native-tui-required",
|
|
597
|
+
"Open the interactive Pi TUI to disable isolation; headless sessions cannot change the switch.");
|
|
598
|
+
}
|
|
599
|
+
let confirmed;
|
|
600
|
+
try {
|
|
601
|
+
confirmed = await context.ui.confirm("Disable Linux microVM isolation", [
|
|
602
|
+
"Disable the isolation-activation switch for this session?",
|
|
603
|
+
switchNotice(),
|
|
604
|
+
].join("\n"));
|
|
605
|
+
} catch (error) {
|
|
606
|
+
return switchResult(false, "blocked", "confirmation-failed", `Native confirmation failed: ${error.message}`);
|
|
607
|
+
}
|
|
608
|
+
if (confirmed !== true) {
|
|
609
|
+
return switchResult(false, "stopped", "not-granted", "Isolation activation remains enabled; native confirmation was not granted.");
|
|
610
|
+
}
|
|
611
|
+
isolationSwitch.set(false);
|
|
612
|
+
return switchResult(true, "DISABLED", "", "");
|
|
613
|
+
},
|
|
614
|
+
});
|
|
615
|
+
return isolationSwitch;
|
|
616
|
+
}
|
|
328
617
|
export default registerLinuxMicroVMCutoverInterface;
|