Skip to content

Commit 4819a0d

Browse files
authored
Merge pull request #45 from edgehero/feat/easy-install
feat: easier install — pi-dispatch init + doctor, prebuilt GHCR image, and clearer 'add the panel via pi' docs
2 parents 85f2bc9 + 4b83cd0 commit 4819a0d

30 files changed

Lines changed: 1472 additions & 45 deletions

.env.example

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
# Copy to .env and fill in. Never commit a real .env (it is gitignored).
22

3-
# --- Provider credential (required) ---
3+
# --- Provider credential ---
44
# pi supports ~30 providers; set the key for the one you use, under the variable name pi expects.
55
# The worker forwards ONLY the configured provider's key into the job container -- nothing else.
66
# Anthropic: ANTHROPIC_API_KEY (or ANTHROPIC_OAUTH_TOKEN, which takes precedence)
77
# OpenAI: OPENAI_API_KEY Google: GEMINI_API_KEY Groq: GROQ_API_KEY ... etc.
8+
# You can LEAVE THIS BLANK if you are already logged into pi: when the env has no key, the worker reads the
9+
# API key from ~/.pi/agent/auth.json (host-side) and env-injects it -- on by default, nothing to set.
10+
# API-key logins only; an OAuth/subscription login is refused (it expires; use an API key for a service).
811
ANTHROPIC_API_KEY=
12+
# PI_AUTH_FROM_PI=0 # uncomment to force env-only (fail loudly on a missing env key instead of using your pi login)
913

1014
# --- Which provider/model to run by default (override per job with --provider / --model) ---
1115
PI_PROVIDER=anthropic
@@ -28,7 +32,7 @@ PI_CONCURRENCY=3 # how many jobs run in parallel
2832

2933
# --- Infrastructure ---
3034
VALKEY_URL=redis://127.0.0.1:6379
31-
PI_JOB_IMAGE=pi-job:latest # the image you built: docker build -f image/Dockerfile -t pi-job:latest .
35+
PI_JOB_IMAGE=pi-job:latest # docker pull ghcr.io/edgehero/pi-job:latest && docker tag ghcr.io/edgehero/pi-job:latest pi-job:latest (or build image/Dockerfile)
3236
# PI_JOBS_DIR= # where per-job /job inputs live (default: your OS temp dir)
3337
# PI_LOGS_DIR= # where per-job status records (and optional raw logs) land (default: OS temp /pi-dispatch/logs)
3438
# PI_CAPTURE_JOB_LOGS= # default 0; set 1 to ALSO write raw container output to logs/<jobId>.log -- PII-bearing (issue/comment text), host-only (never mounted into the container), off by default (opt-in)
@@ -37,6 +41,12 @@ PI_JOB_IMAGE=pi-job:latest # the image you built: docker build -f imag
3741
# Unset = cron disabled for the worker; the receiver REQUIRES it (it holds the label/comment/pull_request trigger config)
3842
# PI_PAUSE_WINDOWS_FILE= # ABSOLUTE path to pause-windows.json — "quiet hours" per folder/repo (pause runs between certain times/days/dates, auto-resume). Unset = feature off. See docs/pause-windows.md
3943
# PI_SETTINGS_FILE= # ABSOLUTE path to the runtime settings overlay (default: OS temp /pi-dispatch/settings.json); edited by the admin extension, read by the worker per job
44+
45+
# --- Reuse your existing pi setup in every job (see docs/global-pi-overlay.md) ---
46+
# PI_GLOBAL_PI_DIR= # dir with your host pi setup (models.json/skills/APPEND_SYSTEM.md), mounted /opt/pi-global:ro into every job, layered UNDER each repo's .pi/. Unset = off. Stage it with: pi-dispatch import-pi
47+
# PI_GLOBAL_ALLOW_EXTENSIONS= # set 1 to LOAD the overlay's extensions (default off). They run code against adversarial input with open egress — vet each; never the admin extension.
48+
# PI_FORWARD_ENV= # comma-separated extra env var NAMES to forward into the container (e.g. a CUSTOM provider's key). Explicit allowlist, not a pass-through.
49+
4050
PI_SCHEDULER_STALL_MAX=2 # tear down a scheduler after N consecutive stalls (money backstop)
4151
# PI_DISPATCH_RUN_ROOTS= # OS-path-delimited allowlist (; on Windows, : elsewhere) of folders the model-callable dispatch_run may target; default empty = fail-closed (dispatch_run refuses every folder until you opt in)
4252
# PI_DISPATCH_RUN_PER_HOUR=3 # per-hour cap on model-invoked dispatch_run enqueues; 0 disables the tool

.github/workflows/image.yml

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
# Build and publish the pi-dispatch job image to GHCR so operators `docker pull` instead of the slow local
2+
# build (Chromium + Playwright + fonts, minutes). main is branch-protected (PR + 1 approving review), so a
3+
# push here is an already-approved merge — nothing in this workflow merges anything (CONST-MERGE-NEVER-AUTOMATIC).
4+
#
5+
# The pushed image is a snapshot of THIS repo's runner + guardrails at the built commit. Operators who bake
6+
# their own toolchain into image/Dockerfile still build locally; the pull is only the fast default path.
7+
#
8+
# No secret required: GHCR auth uses the built-in GITHUB_TOKEN (packages: write). One-time after the first
9+
# successful run: make the ghcr.io/edgehero/pi-job package Public in the org's package settings, otherwise
10+
# `docker pull` needs auth.
11+
12+
name: image
13+
14+
on:
15+
push:
16+
branches: [main]
17+
paths:
18+
- "image/**"
19+
- ".github/workflows/image.yml"
20+
# Manual re-run (available once this file is on the default branch).
21+
workflow_dispatch: {}
22+
23+
permissions:
24+
contents: read
25+
packages: write # push to ghcr.io/edgehero/*
26+
27+
concurrency:
28+
group: image
29+
cancel-in-progress: false
30+
31+
env:
32+
IMAGE: ghcr.io/edgehero/pi-job
33+
34+
jobs:
35+
build-push:
36+
runs-on: ubuntu-latest
37+
steps:
38+
- uses: actions/checkout@v4
39+
40+
- uses: docker/setup-qemu-action@v3 # arm64 emulation for the multi-arch build
41+
- uses: docker/setup-buildx-action@v3
42+
43+
- uses: docker/login-action@v3
44+
with:
45+
registry: ghcr.io
46+
username: ${{ github.actor }}
47+
password: ${{ secrets.GITHUB_TOKEN }}
48+
49+
- name: Tags + labels
50+
id: meta
51+
uses: docker/metadata-action@v5
52+
with:
53+
images: ${{ env.IMAGE }}
54+
tags: |
55+
type=raw,value=latest
56+
type=sha
57+
58+
- name: Build + push (amd64 + arm64)
59+
uses: docker/build-push-action@v6
60+
with:
61+
context: . # repo root — the Dockerfile copies image/runner + guardrails from here
62+
file: image/Dockerfile
63+
platforms: linux/amd64,linux/arm64
64+
push: true
65+
tags: ${{ steps.meta.outputs.tags }}
66+
labels: ${{ steps.meta.outputs.labels }}
67+
cache-from: type=gha
68+
cache-to: type=gha,mode=max

README.md

Lines changed: 52 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -32,15 +32,20 @@ system. **pi-dispatch is exactly that missing operational layer, and nothing els
3232
You need **Docker** and **Node ≥ 22.19**, and a provider API key (e.g. Anthropic).
3333

3434
```bash
35-
# 1. Build the job image (once)
36-
docker build -f image/Dockerfile -t pi-job:latest .
35+
# 1. Get the job image — pull the prebuilt one (fast)...
36+
docker pull ghcr.io/edgehero/pi-job:latest && docker tag ghcr.io/edgehero/pi-job:latest pi-job:latest
37+
# ...or bake your own toolchain into it instead (slower, fully yours):
38+
# docker build -f image/Dockerfile -t pi-job:latest .
3739

3840
# 2. Start Valkey (the durable job queue)
3941
docker compose -f deploy/docker-compose.yml up -d
4042

41-
# 3. Configure
42-
cp .env.example .env # then set ANTHROPIC_API_KEY (or your provider's key)
43+
# 3. Install, scaffold, and check your setup
4344
npm ci
45+
npx pi-dispatch init # writes .env + triggers.json + pause-windows.json (never clobbers)
46+
# edit .env — set ANTHROPIC_API_KEY (or your provider's key)
47+
# already logged into pi? leave it blank — the worker reuses the key from ~/.pi/agent/auth.json by default
48+
npx pi-dispatch doctor # ✓/✗ preflight: Docker, Valkey, the image, and your provider key
4449

4550
# 4. Run the worker in one terminal
4651
npx pi-dispatch worker # (or: npm --workspace worker start)
@@ -49,6 +54,9 @@ npx pi-dispatch worker # (or: npm --workspace worker start)
4954
npx pi-dispatch run ./my-project --task "add type hints to utils.py" --flow tidy
5055
```
5156

57+
> **The prebuilt image is a snapshot** of this repo's runner + guardrails at its build. To bake a project's
58+
> toolchain in (the edge cron/visual flows rely on), build `image/Dockerfile` yourself — step 1's second form.
59+
5260
> **Heads-up on the CLI name.** `pi-dispatch` here is *this repo's* workspace CLI (`worker/src/cli.mjs`),
5361
> which `npx` resolves from the local `node_modules/.bin` after `npm ci` — run these from the repo root. It
5462
> is **not** the unrelated npm package `pi-dispatch` (see [License](#license)); this project isn't published
@@ -77,6 +85,32 @@ A container boundary, spend bounded *before* a container starts, nothing dropped
7785
enforces. Read [`SECURITY.md`](SECURITY.md) before you rely on it: it states plainly what is and is not
7886
defended.
7987

88+
## Reuse your existing pi setup
89+
90+
Already run `pi`? Give every job your host setup — custom models, global skills, a global persona — **layered
91+
under each repo's own `.pi/`** (the repo still wins). Works with the pulled image; it's a read-only mount, not
92+
a rebuild.
93+
94+
```bash
95+
pi-dispatch import-pi # stage a credential-free copy of ~/.pi/agent into ./pi-global
96+
# then set PI_GLOBAL_PI_DIR=/abs/path/to/pi-global in .env, and:
97+
pi-dispatch doctor # verifies the overlay carries no credential
98+
```
99+
100+
The overlay is mounted `/opt/pi-global:ro` into every container. Skills merge with the repo's (a repo skill
101+
of the same name overrides the global one); the prompt layers `guardrails → global persona → repo persona`,
102+
the safety floor always first and unremovable. `import-pi` **refuses** a `models.json` with a literal key and
103+
**never** copies `auth.json` — your credential stays in the environment. Extensions are opt-in and armed
104+
separately (`--with-extensions` + `PI_GLOBAL_ALLOW_EXTENSIONS=1`) because they run code against adversarial
105+
input; the admin extension is hard-blocked. Full reference: [`docs/global-pi-overlay.md`](docs/global-pi-overlay.md).
106+
107+
**Already logged into pi? The key just works — by default.** When the provider key is absent from the
108+
worker's environment, the worker reads it **host-side** from `~/.pi/agent/auth.json` and env-injects it into
109+
the job — a host-side read of a host-held secret, never a file mounted into the container. Nothing to set;
110+
`PI_AUTH_FROM_PI=0` forces env-only if you'd rather fail loudly on a missing env key. **API-key logins only**:
111+
an OAuth/subscription login (`pi login`) is refused — those tokens expire and can't be refreshed in the
112+
container, and a subscription isn't the credential for an unattended service; use an API key with a spend limit.
113+
80114
## Run as a service
81115

82116
`pi-dispatch worker` is a long-running process — run it in a terminal, or hand it to your OS's service
@@ -161,9 +195,20 @@ worker's **boot reaper** clears on the next start, rather than draining cleanly.
161195

162196
The admin surface — the dashboard and command transcript shown at the top of this README — is a **pi
163197
extension** in [`admin/`](admin/) that loads into *your own* interactive pi session — no daemon, no web
164-
app, **no network port at all**. Load it with `pi -e admin/src/index.ts` from this checkout, add that path
165-
to the `"extensions"` array in `~/.pi/agent/settings.json`, or just run pi inside this checkout: the
166-
in-repo `.pi/extensions` shim auto-loads once you've trusted the project.
198+
app, **no network port at all**.
199+
200+
**Install it through pi** — the published package, then open the panel:
201+
202+
```bash
203+
pi install npm:@edgehero/pi-dispatch-admin # then, in pi: /dispatch
204+
```
205+
206+
Two other ways to load it: from a clone, the in-repo `.pi/extensions` shim auto-loads once you've trusted
207+
the project; or point pi at the source with `pi -e admin/src/index.ts` (add that path to the `"extensions"`
208+
array in `~/.pi/agent/settings.json` to make it permanent). To operate a **live** deployment, give the pi
209+
session the same `VALKEY_URL`, `PI_SETTINGS_FILE`, `PI_TRIGGERS_FILE`, `PI_PAUSE_WINDOWS_FILE`, and
210+
`PI_LOGS_DIR` the worker uses — the panel reads and writes those same files and queue, so both act on one
211+
deployment.
167212

168213
Bare `/dispatch` opens the live dashboard overlay — one snapshot per second, `p`/`r` to pause/resume the
169214
queue in place, ``/`` to move across the triggers and runs, `Enter` to drill into either. **Triggers are

SECURITY.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,8 +169,22 @@ Stated openly rather than discovered later:
169169
provider needs. In particular `ANTHROPIC_OAUTH_TOKEN` silently takes precedence over
170170
`ANTHROPIC_API_KEY`, so a stray variable in the host environment can quietly redirect which credential
171171
a job spends.
172+
- **By default the worker sources the provider key from pi's `~/.pi/agent/auth.json` when the env has none.**
173+
It is a host-side read env-injected into the container — never a credential file mounted in — and accepts
174+
**API-key** logins only; an OAuth/subscription login is refused. The env always wins when set; set
175+
`PI_AUTH_FROM_PI=0` to force env-only (fail loudly on a missing env key rather than fall back to a pi login).
176+
Prefer an API key with a provider-side spend limit for an unattended service; a subscription token is
177+
neither refreshable in the container nor intended for automation.
172178
- **Treat `.pi/` on your default branch as production code**, because it is: it goes into the agent's
173179
system prompt. Review changes to it with the same care as `.github/workflows/`.
180+
- **The global pi overlay (`PI_GLOBAL_PI_DIR`) is production code too**, and it must be credential-free. It
181+
is mounted `:ro` into every job — a container that runs adversarial input — so a secret in it is a secret
182+
in the box. Stage it with `pi-dispatch import-pi` (it refuses a `models.json` with a literal key and never
183+
copies `auth.json`) and let `pi-dispatch doctor` re-check it; the provider key belongs in the environment,
184+
never a mounted file. Overlay **extensions run arbitrary code against adversarial input with open network
185+
egress** and are not scanned for secrets: keep `PI_GLOBAL_ALLOW_EXTENSIONS` unset until you have vetted
186+
every one, and never place the admin extension in the overlay (it can enqueue paid jobs — a recursion
187+
vector; `import-pi` blocks it).
174188
- **The admin surface is not a network service.** It is a pi extension in your own terminal session plus
175189
a `settings.json` file — it binds no port. Whoever can run pi with the extension loaded, or write
176190
`PI_SETTINGS_FILE`, holds operator power: the same trust as shell access on the host. Treat it that way.

docs/global-pi-overlay.md

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
# Reuse your existing pi setup — the global overlay
2+
3+
If you already run `pi`, you have a configured `~/.pi/agent`: custom models, global skills, a global persona.
4+
Point pi-dispatch at a **credential-free copy** of it and every job gets it — **layered under each repo's own
5+
`.pi/`**, so a repo can still override or add on top. It works with the **pulled** prebuilt image: this is a
6+
read-only mount, not an image rebuild.
7+
8+
## Enable it
9+
10+
```bash
11+
pi-dispatch import-pi # stage the safe subset of ~/.pi/agent into ./pi-global
12+
# then in .env:
13+
PI_GLOBAL_PI_DIR=/absolute/path/to/pi-global
14+
pi-dispatch doctor # verifies the overlay is credential-free
15+
```
16+
17+
`import-pi` reads your host agent dir (`$PI_CODING_AGENT_DIR`, else `~/.pi/agent`) and copies a **curated,
18+
credential-free** subset into the overlay dir. Re-run it whenever you change your host setup. Flags:
19+
`--with-extensions` (see below), `--from <agentDir>`, `--to <overlayDir>`.
20+
21+
## What layers, and who wins
22+
23+
Four tiers, most-trusted first; each refines but never removes the one above:
24+
25+
| Tier | Source | Trust | Mutable? |
26+
|---|---|---|---|
27+
| 1. Safety floor | baked `HARD_RULES.md` | image, root-owned | no (immutable) |
28+
| 2. **Global overlay** | `PI_GLOBAL_PI_DIR``/opt/pi-global:ro` | **operator, deploy-time** | re-run `import-pi` |
29+
| 3. Per-repo `.pi/` | repo's committed `.pi/` (default-branch SHA) | trusted-by-merge | per PR |
30+
| 4. Task/issue text | the webhook / CLI input | **adversarial — never instructions** ||
31+
32+
- **Skills**: repo skills are listed **first**, so a repo skill **overrides** a global one of the same name
33+
(pi is first-path-wins); names that don't collide all load.
34+
- **Persona**: the assembled prompt is `guardrails → global persona → repo persona`. The floor is always
35+
first and cannot be removed; global is your baseline; the repo's `.pi/APPEND_SYSTEM.md` is most specific.
36+
- **Models**: the overlay's `models.json` makes a **custom provider/model** resolvable. Definitions only —
37+
the credential still comes from the environment, never the overlay.
38+
39+
## What is copied — and what never is
40+
41+
| Copied into the overlay | Never copied |
42+
|---|---|
43+
| `models.json` (definitions; **refused if it embeds a literal key**) | `auth.json` — your credential stays in env/auth.json |
44+
| `skills/<name>/` | `settings.json`, `sessions/`, `themes/`, `prompts/` |
45+
| `APPEND_SYSTEM.md` (global persona) | anything holding a secret |
46+
| `extensions/` — only with `--with-extensions` | the admin extension (hard-blocked) |
47+
48+
The overlay is mounted **read-only** into a container that runs adversarial input, so it must hold **no
49+
secret**. `import-pi` refuses a `models.json` with a literal `apiKey` (move it to `auth.json`, or reference
50+
the environment as `"$MY_KEY"`), and `doctor` re-checks the overlay for `auth.json` and literal keys.
51+
52+
### Custom providers
53+
54+
If your model uses a provider whose key variable pi's built-in table doesn't know, forward it explicitly:
55+
56+
```bash
57+
# .env
58+
PI_FORWARD_ENV=MY_PROVIDER_KEY # comma-separated NAMES; forwarded by exact -e NAME=VALUE, never a pass-through
59+
```
60+
61+
### The key is already in pi (on by default)
62+
63+
Logged into pi already? You don't have to restate the key in `.env`. When the provider key is absent from
64+
the worker's environment, the worker reads it **host-side** from `~/.pi/agent/auth.json` and env-injects it
65+
under the variable pi expects — a host-side read of a host-held secret, injected via env exactly like `.env`,
66+
**never a file mounted into the container**. This is **on by default**; the environment still wins when
67+
present. Set `PI_AUTH_FROM_PI=0` to force env-only (fail loudly on a missing env key instead of falling back).
68+
69+
**API-key logins only.** An OAuth/subscription login (`pi login`) is refused: those tokens expire and the
70+
container can't refresh them, and a subscription isn't the credential for an unattended paid service —
71+
configure an API key (with a spend limit) instead.
72+
73+
## Extensions (the sharp edge — opt-in and armed separately)
74+
75+
Extensions run **code against adversarial input with open network egress**, and host extensions often carry
76+
MCP-server credentials. They are therefore **doubly gated**:
77+
78+
1. `pi-dispatch import-pi --with-extensions` copies them (verbatim — they are **not** scanned for secrets;
79+
the admin extension is refused).
80+
2. They load **only** when you set `PI_GLOBAL_ALLOW_EXTENSIONS=1`. Unset = present but dormant.
81+
82+
Vet every extension before arming, and never place the admin extension in the overlay (it can enqueue paid
83+
jobs — a recursion vector; `import-pi` blocks it, but treat it as a rule).
84+
85+
## Reference
86+
87+
`REQ-GLOBAL-PI-OVERLAY` ([requirements](../specs/requirements.md)),
88+
`DES-OPERATOR-GLOBAL-OVERLAY` ([design](../specs/design.md)),
89+
`INT-CONTAINER-RUNTIME-CONTRACT` / `INT-SDK-SESSION-OPTIONS` ([interfaces](../specs/interfaces.md)).

0 commit comments

Comments
 (0)