Skip to content

Commit c5cabbf

Browse files
committed
release: v0.9.0 (worker 0.2.0, receiver 0.2.0, admin 0.6.0)
Thirteen commits since v0.8.0: issue #60 in four PRs (whole-directory skill materialisation, the import-pi symlink repair, `run.skillsDir`, `run.instructions`), issue #66 (`pull_request_review` ingest), issue #102 (import-pi discovers the host's own pi packages), #103, #104 and #105. THE RECEIVER'S WORKER RANGE MOVES WITH THE WORKER, and it is the reason this is not a one-line version bump. `receiver` declared `"@edgehero/pi-dispatch": "^0.1.0"`, and on a 0.x version caret means `>=0.1.0 <0.2.0`. Bumping the worker to 0.2.0 alone would have resolved an installed receiver against worker 0.1.2 -- and the receiver imports `@edgehero/pi-dispatch/triggers` and runs the SHARED validator, whose old copy drops unknown `run.*` fields by reconstruction. So `run.skillsDir` and `run.instructions` would have been silently absent on the webhook path, with no error anywhere, on a release whose headline is those two fields. The existing test asserted only the range's SHAPE (`/^\^\d+\.\d+\.\d+$/`), which is why this passed for as long as the minor never moved. A new anti-drift test asserts the range actually SATISFIES the in-repo worker version, hand-rolling caret's rule rather than taking a `semver` dev dep for six lines. Mutation-checked: restoring `^0.1.0` turns it red while the old shape test stays green. Two other literals had to follow, both caught by the suite rather than by reading: the wizard's RUNTIME_VERSION/RECEIVER_VERSION pins, and two spelled-out `@0.1.1` tokens in the wizard tests that exist precisely to be belt-and-braces against the constant. UPGRADE ACTION, and it is the one that fails silently: REBUILD OR REPULL THE JOB IMAGE. The runner changed (the loader's third skill path, PROTECTED_SKILL_ROOTS, run-job's protected roots). A 0.2.0 worker paired with an older job image copies a trigger's skills to /job/trigger-skills and the old runner never looks there, so the flow runs without the skills it was written against and exits 0. No `service install --force` is needed this cycle: deploy/ is unchanged apart from an example file. Lockfile edited by hand, six lines. `npm install --package-lock-only` under this npm rewrites unrelated normalization (`./dist/cli.js` -> `dist/cli.js`, dropped `libc` arrays), which does not belong in a release commit. Signed-off-by: Rob Boerman <robboerman@live.nl>
1 parent 4460c24 commit c5cabbf

12 files changed

Lines changed: 68 additions & 18 deletions

File tree

SECURITY.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ Jobs are a **trigger × target** matrix, and the triggers do not share a threat
5454
| A serviced repo's `.pi/` on the **default branch** | **Maintainer-level** | It is read into the system prompt, from a pinned SHA, on purpose — same trust as `.github/workflows/`. Only someone who can merge can change it. |
5555
| A serviced repo's contents on **any other branch** | **None** | A fork PR can contain anything. Never read for instructions. |
5656
| A local folder's `.pi/` | **Whatever can write that folder** | No merge gate, no reviewer, no history |
57+
| A trigger's `run.skillsDir` and `run.instructions` | **Operator — the same trust as the triggers file** | Both are instructions, and both come from the reviewed `triggers.json` on the worker host rather than from any payload. Nothing reachable from a webhook, an issue or comment body, or `dispatch_run` can set either, and no panel key or AI tool writes them. The skills are copied per job into `/job` (adding no mount) and are layered UNDER the repo's own `.pi/`, so a serviced repo still wins a name collision; the instruction text lands in the user prompt above the issue text and never in the system prompt |
5758
| The job image (`PI_JOB_IMAGE`, or a trigger's `run.image`) | **Operator — the same trust as baking it** | It *is* the code every job executes: the pi version, the runner and its exit codes, the guardrail floor, the loader's discovery posture and the non-root user all come from it. Nothing here verifies an image this project did not build. The isolation flags are applied by the worker's argv and hold for **any** image; the **contents** do not. |
5859
| The job container | **None** — it is the untrusted side | It runs the agent |
5960
| A job container's `/outbox` request file | **None** — agent-authored | An agent-initiated signal channel back to the host; validated host-side before anything is enqueued. **Local jobs only** — a github job has no `/outbox` mount at all |

admin/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@edgehero/pi-dispatch-admin",
3-
"version": "0.5.0",
3+
"version": "0.6.0",
44
"description": "Operator console (a pi extension) + skill for pi-dispatch: run the pi coding agent as a self-hosted service. A /dispatch TUI for the queue, spend caps, run history, editable GitHub, GitLab, Forgejo and Azure DevOps triggers (cron/label/comment/PR/MR/work item), and scheduled pause windows — plus AI-operable, human-confirmed controls. Runs against a live pi-dispatch deployment.",
55
"keywords": [
66
"pi-package",

admin/src/setup-wizard.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ type Notify = ((message: string, type?: string) => void) | undefined;
5252
* version, so a release bump stays atomic: bump the worker and the test fails here until this literal
5353
* follows in the same change.
5454
*/
55-
export const RUNTIME_VERSION = "0.1.2";
55+
export const RUNTIME_VERSION = "0.2.0";
5656

5757
/**
5858
* The `@edgehero/pi-dispatch-receiver` version the trigger-edge step installs -- pinned for exactly the
@@ -62,7 +62,7 @@ export const RUNTIME_VERSION = "0.1.2";
6262
* independently (the receiver's dependency range on the runtime is `^`), and pretending otherwise would
6363
* install a version that does not exist the first time they diverge.
6464
*/
65-
export const RECEIVER_VERSION = "0.1.1";
65+
export const RECEIVER_VERSION = "0.2.0";
6666

6767
/** The two npm package names, spelled once. Literals of this module -- see npmInstallArgsFor's argument. */
6868
const RUNTIME_PKG = "@edgehero/pi-dispatch";

admin/test/setup-wizard.test.mjs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -803,7 +803,7 @@ test("wizard: the edge's service answer installs the PINNED receiver, then the -
803803
assert.equal(attached.length, 2, "the npm install and the unit install — nothing else");
804804
assert.equal(attached[0].argv0, "npm", "posix npm, per npmSpawnOptions");
805805
assert.deepEqual(attached[0].args, mod.npmInstallArgsFor(RECEIVER_PKG, mod.RECEIVER_VERSION));
806-
assert.ok(attached[0].args.includes(`${RECEIVER_PKG}@0.1.1`), "the pinned name@version token, spelled out");
806+
assert.ok(attached[0].args.includes(`${RECEIVER_PKG}@0.2.0`), "the pinned name@version token, spelled out");
807807
assert.equal(attached[0].cwd, dir, "installed into the deployment dir, by cwd");
808808
assert.deepEqual(
809809
JSON.parse(readFileSync(join(dir, "package.json"), "utf8")),
@@ -817,7 +817,7 @@ test("wizard: the edge's service answer installs the PINNED receiver, then the -
817817
assert.equal(attached[1].cwd, dir);
818818

819819
const c = seen.confirm.find((x) => /receiver/i.test(x.title));
820-
assert.match(c.message, new RegExp(`${RECEIVER_PKG.replace("/", "\\/")}@0\\.1\\.1`), "the confirm shows the exact pin");
820+
assert.match(c.message, new RegExp(`${RECEIVER_PKG.replace("/", "\\/")}@0\\.2\\.0`), "the confirm shows the exact pin");
821821
assert.match(c.message, /service install --receiver/, "and the unit command it will run after");
822822
assert.ok(c.message.includes(dir), "and names the cwd");
823823
assert.ok(reachedFirstTrigger(seen), "the wizard continued to step 11");

docs/global-pi-overlay.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,8 @@ always meant.
2727

2828
## What layers, and who wins
2929

30-
Four tiers, most-trusted first; each refines but never removes the one above:
30+
Four trust levels, most-trusted first, with sub-tiers where one level has more than one source. Each
31+
refines but never removes the one above:
3132

3233
| Tier | Source | Trust | Mutable? |
3334
|---|---|---|---|

package-lock.json

Lines changed: 6 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "pi-dispatch",
3-
"version": "0.8.0",
3+
"version": "0.9.0",
44
"private": true,
55
"description": "A containerized job harness for the pi coding agent",
66
"license": "MIT",

receiver/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@edgehero/pi-dispatch-receiver",
3-
"version": "0.1.1",
3+
"version": "0.2.0",
44
"type": "module",
55
"description": "Webhook receiver for pi-dispatch: the always-on edge that verifies GitHub, GitLab, Forgejo and Azure DevOps deliveries and enqueues (at most) one job per event for the worker.",
66
"keywords": [
@@ -45,7 +45,7 @@
4545
},
4646
"dependencies": {
4747
"@octokit/webhooks": "14.2.0",
48-
"@edgehero/pi-dispatch": "^0.1.0",
48+
"@edgehero/pi-dispatch": "^0.2.0",
4949
"bullmq": "5.80.4",
5050
"ioredis": "5.11.1"
5151
}

specs/interfaces.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,8 +54,11 @@ Evidence convention as in `constitution.md`.
5454
// arrive at /job/pi/skills from the pinned sha, and discovery would re-register them under a second
5555
// path that WINS the first-path-wins collision. See (k).
5656
noSkills: true,
57-
// Repo path FIRST so a repo skill overrides a global one of the same name (pi is first-path-wins).
58-
additionalSkillPaths: ["/job/pi/skills", ...(existsSync("/opt/pi-global/skills") ? ["/opt/pi-global/skills"] : [])],
57+
// Repo FIRST, then the trigger's injected skills, then the overlay: repo > injected > overlay
58+
// (REQ-PER-TRIGGER-SKILLS). pi is first-path-wins and the order within this array is ours.
59+
additionalSkillPaths: ["/job/pi/skills",
60+
...(existsSync("/job/trigger-skills") ? ["/job/trigger-skills"] : []),
61+
...(existsSync("/opt/pi-global/skills") ? ["/opt/pi-global/skills"] : [])],
5962
// Overlay extensions load unless the operator opted OUT (PI_GLOBAL_ALLOW_EXTENSIONS=0) AND the dir
6063
// is present. Operator-staged pi packages (REQ-GLOBAL-PI-OVERLAY) come LAST and do NOT ride that
6164
// option — the spread is unconditional, because the worker already applied the per-trigger
@@ -2220,6 +2223,7 @@ recorded repair is re-running `/dispatch setup` (or editing the pointer by hand)
22202223
22212224
| Date | Change |
22222225
|---|---|
2226+
| 2026-08-09 | Follow-up audit after issue #60. **INT-SDK-SESSION-OPTIONS AMENDED**, a correction rather than an addition: the option block still showed the TWO-path `additionalSkillPaths` literal, while the prose beside it had already been updated to three protected roots. A contract block that disagrees with its own note is worse than either being wrong alone, since a reader checking the code against the spec would have found the spec confirming the old shape. Now shows repo, injected, overlay. No behaviour changed; the literal had been stale since the injected tier landed hours earlier. |
22232227
| 2026-08-09 | Issue #60 (Gap 3: `run.instructions`). **INT-TRIGGERS-FILE-CONTRACT AMENDED**: a new optional field on the three webhook types, refused on cron with a message naming `run.task`, capped at 2000 characters and refused rather than truncated. Surrounding whitespace is deliberately NOT refused here, unlike `run.image`, and the divergence is recorded: that rule exists because whitespace changes what an image REFERENCE means, and it does not change what prose means. **INT-CONTAINER-JOB-INPUTS AMENDED**: `prompt.md` may now carry an operator standing-instruction block in the envelope above the data region; it reaches no other file. **INT-WEBHOOK-PAYLOAD-SUBSET UNCHANGED, checked**: the field is operator config and is not a webhook body field, so the subset is untouched and the value never appears in `event.json`. |
22242228
| 2026-08-09 | Issue #60 (Gap 2: `run.skillsDir`, a per-trigger operator skills directory). **INT-TRIGGERS-FILE-CONTRACT AMENDED**: a new optional field on all four run kinds, with the validation SPLIT written out because both halves are load-bearing. Existence is not checked in the shared validator because BOTH services parse this file and the receiver may run on another host (the `run.folder` precedent); absoluteness is not checked there either, and that one is subtler, because `path.isAbsolute` is OS-dependent, so a shared check would let a Windows worker and a Linux receiver disagree about the same reviewed file. The worker enforces both where the answer is knowable: at boot for cron, and pre-spend per job for every kind. Also records that the value never reaches `/job/event.json` (it rides at JOB level, never inside `trigger`, which is what the subset is built from), and that injected skills are trigger-reachable and never AI-reachable. **INT-CONTAINER-JOB-INPUTS AMENDED**: `/job/trigger-skills/<name>/**` joins the layout as the one `/job` input that does NOT come from git, with the asymmetry argued rather than left to be noticed -- `.pi/` is read by oid because the serviced repo is only maintainer-trusted and an attacker can shape that tree, while `run.skillsDir` is operator-authored deploy-time config named in a reviewed file, so what remains is the ordinary filesystem hazard and the copier answers it the same way (lstat never stat, regular files only, destinations rebuilt from validated segments, bounded). It arrives on the EXISTING `/job:ro` bind. **INT-RUN-HISTORY-FILE-CONTRACT AMENDED**: six `skills-dir-*` reasons. **INT-SDK-SESSION-OPTIONS AMENDED**: the protected-root list goes to three, `/job/pi/skills` then `/job/trigger-skills` then `/opt/pi-global/skills`, consulted in that order. **INT-CONTAINER-RUNTIME-CONTRACT UNCHANGED, checked** -- and this is the entry the change was designed around: no mount is added, no flag, no env var, so a job with an injected skills dir has a docker argv byte-identical to one without, pinned by a test. |
22252229
| 2026-08-08 | Issue #60 (Gap 1: a repo skill's supporting files were silently dropped). **INT-CONTAINER-JOB-INPUTS AMENDED**: the materialiser's allowlist accepted exactly `.pi/APPEND_SYSTEM.md` and `.pi/skills/<name>/SKILL.md`, so a skill shipping `references/`, `scripts/` or templates had those files dropped by a bare `continue` with no error anywhere. That is worse than it sounds, and the reason is upstream: at the 0.80.7 pin `core/skills.js` instructs the model to "resolve it against the skill directory", so the skill loaded, read correctly, and pointed the agent at files that were not in the container. A confidently wrong agent, not a failure. The entry now documents whole-directory materialisation, the split-and-validate-every-segment grammar that replaced the single regex (STRONGER than the fixed template it replaces, because any separator other than `/` survives inside a piece and is refused by the anchored charset, and `..` is refused by the leading-alphanumeric rule), the two-tier charset (the skill NAME keeps the lowercase-only `SKILL_NAME_RE` it shares with the flow gate; only the segments below it are case-insensitive, because `SKILL.md` and `README.md` are the point), the Windows device-name refusal, the documented skips, and the six caps with the refuse-before-write ordering. Three sub-decisions are recorded rather than left implicit. **A subtree declaring no `SKILL.md` anywhere beneath it is not materialised**, because pi registers a skill only where a literal `SKILL.md` exists, so those bytes could never be referenced and copying them would be a data-dump channel that never has to look like a skill; the test is "anywhere beneath" and not "at the root" because pi keeps recursing while a directory has no `SKILL.md`, and a root-only rule would have recreated this very defect one level down. **`100755` stays rejected**, so a skill's scripts are invoked as `bash script.sh`: `/job` is `:ro` and files land `0444`, so accepting the mode and writing `0444` anyway would accept what the repo asked for and silently strip it, while `0555` would have the worker grant execve on repo bytes. **A cap breach REFUSES the job** rather than truncating, because a truncated skill IS this defect and which files survived would be decided by git's tree order. The `/job/pi/extensions` bullet's PREMISE was rewritten (it asserted the materialiser "only ever emits ... SKILL.md", now false) while its CONCLUSION is untouched: `extensions/` is still never written, so discovery remains the only path a repo extension has ever had. Acceptance gains the symlink-inside-a-skill-subdirectory case and the cap cases. **INT-RUN-HISTORY-FILE-CONTRACT AMENDED**: four terminal reasons (`pi-too-many-files`, `pi-file-too-large`, `pi-too-large`, `pi-path-collision`), plus `sha-gone`, which the enum had always omitted. The `pi-` prefix is load-bearing rather than decorative: the nested `session.reason` enum already carries a bare `too-large`, and two enums in one record sharing a token is how a reader misattributes a refusal. **INT-SDK-SESSION-OPTIONS UNCHANGED, checked**, and the check is the interesting one: it has always written the layout as `.pi/skills/**/SKILL.md`, one level deeper than the code implemented, so the spec was right and the code has now caught up to it rather than the other way round. **INT-CONTAINER-RUNTIME-CONTRACT UNCHANGED, checked** — no mount, no flag, no env var; the widened content rides the `/job:ro` bind that already existed. Repaired in passing, because this change made it unavoidable: `makePrepareWorkspace` leaked its `mkdtemp`'d job dir on EVERY policy refusal, since a refusal carries no `jobDir` and both teardown paths guard on `prepared?.jobDir` — true of `sha-gone` since it shipped, and about to be hit on every delivery by a repo that breaches a cap. |

0 commit comments

Comments
 (0)