@edgehero/pi-dispatch 1.10.2 → 2.0.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.
Files changed (97) hide show
  1. package/.env.example +300 -148
  2. package/README.md +50 -0
  3. package/deploy/com.pi-dispatch.worker.plist +9 -3
  4. package/deploy/docker-compose.yml +49 -16
  5. package/deploy/egress-proxy.conf +32 -2
  6. package/deploy/nssm-install.cmd +12 -6
  7. package/deploy/pi-dispatch-egress-out.network +10 -0
  8. package/deploy/pi-dispatch-egress-proxy.container +50 -0
  9. package/deploy/pi-dispatch-netns-keeper.container +80 -0
  10. package/deploy/pi-dispatch-netns-keeper.network +18 -0
  11. package/deploy/pi-dispatch-valkey.container +51 -0
  12. package/deploy/pi-dispatch-valkey.network +16 -0
  13. package/deploy/receiver.service +6 -0
  14. package/deploy/worker-env-wrapper.cmd +11 -0
  15. package/deploy/worker-env-wrapper.sh +60 -34
  16. package/deploy/worker.service +18 -8
  17. package/package.json +14 -4
  18. package/src/azure-host.mjs +19 -0
  19. package/src/azure-identity.mjs +18 -2
  20. package/src/backend-conformance.mjs +71 -18
  21. package/src/backend-local.mjs +637 -21
  22. package/src/backend-podman.mjs +1168 -0
  23. package/src/backend-registry.mjs +86 -3
  24. package/src/backends.mjs +489 -37
  25. package/src/branch.mjs +7 -2
  26. package/src/cancel-cli.mjs +174 -0
  27. package/src/cancel-state.mjs +125 -0
  28. package/src/cli.mjs +188 -90
  29. package/src/config.mjs +503 -43
  30. package/src/connection.mjs +374 -8
  31. package/src/container-spec.mjs +102 -7
  32. package/src/daemon-facts.mjs +167 -0
  33. package/src/deployment-venue.mjs +158 -0
  34. package/src/docker-run.mjs +146 -15
  35. package/src/doctor.mjs +4701 -414
  36. package/src/egress-conf-copy.mjs +166 -0
  37. package/src/egress-proxy-state.mjs +151 -0
  38. package/src/egress.mjs +455 -25
  39. package/src/entry.mjs +27 -0
  40. package/src/env-allowlist.mjs +222 -40
  41. package/src/env-file.mjs +1869 -33
  42. package/src/exit-code.mjs +15 -0
  43. package/src/flow-gate.mjs +5 -3
  44. package/src/forgejo-host.mjs +19 -0
  45. package/src/forgejo-identity.mjs +21 -2
  46. package/src/get-token.mjs +67 -18
  47. package/src/git-dirty.mjs +9 -1
  48. package/src/git-hardening.mjs +33 -0
  49. package/src/github-app-setup.mjs +29 -12
  50. package/src/github-prompt.mjs +4 -1
  51. package/src/gitlab-host.mjs +19 -0
  52. package/src/gitlab-identity.mjs +19 -2
  53. package/src/host-registry.mjs +32 -5
  54. package/src/identity.mjs +29 -4
  55. package/src/image-preflight.mjs +46 -11
  56. package/src/image-ref.mjs +21 -0
  57. package/src/index.mjs +387 -17
  58. package/src/init.mjs +197 -38
  59. package/src/job-user.mjs +252 -0
  60. package/src/json-duplicates.mjs +204 -0
  61. package/src/live-probes.mjs +1020 -0
  62. package/src/materialize.mjs +4 -11
  63. package/src/netns-keeper.mjs +264 -0
  64. package/src/on-failure.mjs +119 -0
  65. package/src/outbox.mjs +7 -0
  66. package/src/podman-stack.mjs +1304 -0
  67. package/src/prepare-github.mjs +6 -6
  68. package/src/prepare-local.mjs +51 -17
  69. package/src/prepare.mjs +27 -6
  70. package/src/processor.mjs +505 -26
  71. package/src/provider-key.mjs +41 -0
  72. package/src/provider-steering.mjs +144 -0
  73. package/src/queue.mjs +35 -8
  74. package/src/redact.mjs +84 -0
  75. package/src/reserved-env.mjs +7 -3
  76. package/src/retention-sweep.mjs +178 -0
  77. package/src/run-container.mjs +181 -14
  78. package/src/run-history.mjs +105 -16
  79. package/src/runtime-observations.mjs +1152 -0
  80. package/src/runtime-settings.mjs +13 -8
  81. package/src/sandbox-cli.mjs +100 -95
  82. package/src/sandbox-store.mjs +612 -45
  83. package/src/sandbox.mjs +1459 -37
  84. package/src/schedules.mjs +16 -3
  85. package/src/secret-profiles.mjs +2 -1
  86. package/src/secrets.mjs +23 -6
  87. package/src/service-env.mjs +247 -0
  88. package/src/service.mjs +618 -28
  89. package/src/session-store.mjs +678 -53
  90. package/src/start.mjs +1395 -268
  91. package/src/transient.mjs +240 -0
  92. package/src/triggers-file.mjs +71 -15
  93. package/src/triggers.mjs +176 -19
  94. package/src/up.mjs +1399 -85
  95. package/src/valkey-auth.mjs +529 -0
  96. package/src/valkey-endpoint.mjs +367 -0
  97. package/src/watch-closer.mjs +158 -0
package/.env.example CHANGED
@@ -1,4 +1,22 @@
1
1
  # Copy to .env and fill in. Never commit a real .env (it is gitignored).
2
+ #
3
+ # A value in 'single quotes' is quoted because it NEEDS to be: `pi-dispatch up` writes bare values and quotes
4
+ # only what it must. Do not tidy the quotes away. On the POSIX wrapper deployments this file is sourced by a
5
+ # shell (`set -a; . ./.env`), where an unquoted value with a space in it is a command-prefix assignment: the
6
+ # key ends up UNSET and the rest of the line is EXECUTED (measured in sh, bash and zsh). systemd's
7
+ # EnvironmentFile= honours single quotes too.
8
+ # The exception is Windows: deploy/worker-env-wrapper.cmd keeps surrounding quotes as PART of the value,
9
+ # which is why `up` never writes a quoted value there and why those paths are written bare.
10
+ # On Windows, also start each line with the key exactly as spelled here: no indent, no blank before the =,
11
+ # and the same case (cmd's `set` ignores case, so a later `webhook_secret=` replaces WEBHOOK_SECRET). Keep
12
+ # " % ! ^, characters outside ASCII and a leading = out of values there: the wrapper reads the file with
13
+ # `for /f` and cannot be shown to carry them. `up` and the setup wizard refuse to edit such a line and name it.
14
+ #
15
+ # VALUES ARE LITERAL UNDER systemd, and `$` is where this file's four consumers stop agreeing.
16
+ # `PI_JOBS_DIR=$HOME/jobs` is read as the literal characters `$HOME/jobs` by systemd's EnvironmentFile= and
17
+ # by the Windows cmd wrapper, and as your home directory by a sourcing shell and by compose's env_file
18
+ # (measured: systemd 252, compose v2.31.0, sh/bash/zsh/dash). Nothing refuses it, so one file puts the jobs
19
+ # in two different places depending on which deployment reads it. Write the path out in full, not $HOME.
2
20
 
3
21
  # --- Provider credential ---
4
22
  # pi supports ~30 providers; set the key for the one you use, under the variable name pi expects.
@@ -9,7 +27,8 @@
9
27
  # API key from ~/.pi/agent/auth.json (host-side) and env-injects it -- on by default, nothing to set.
10
28
  # API-key logins only; an OAuth/subscription login is refused (it expires; use an API key for a service).
11
29
  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)
30
+ # uncomment to force env-only (fail loudly on a missing env key instead of using your pi login)
31
+ # PI_AUTH_FROM_PI=0
13
32
 
14
33
  # --- Which provider/model to run by default (override per job with --provider / --model) ---
15
34
  PI_PROVIDER=anthropic
@@ -17,107 +36,188 @@ PI_PROVIDER=anthropic
17
36
  PI_MODEL=claude-sonnet-4-5-20250929
18
37
 
19
38
  # --- Spend + concurrency guards (money bounds; all have conservative defaults) ---
20
- PI_MAX_TURNS=30 # per-job turn cap -- pi has none of its own, so the harness imposes one
21
- PI_DAILY_CAP=25 # max job containers started per day (mandatory window)
22
- # PI_WEEKLY_CAP=100 # optional weekly ceiling on container starts; unset = weekly window disabled
23
- # PI_MONTHLY_CAP=400 # optional monthly ceiling on container starts; unset = monthly window disabled
24
- # PI_SOFT_HOLD_PCT=80 # optional soft-hold band (1-99): once any window hits this % of its cap, new starts
25
- # pause (in-flight jobs finish) and the panel meter turns amber; unset = disabled
26
- # PI_MAX_TOKENS= # optional per-job token budget: the runner aborts the agent once cumulative usage exceeds it.
27
- # LAGGING (spends before it can see the total) -- a single-job runaway backstop, not before-the-spend;
28
- # PI_MAX_TURNS stays the proactive lever. Unset = per-job token budget disabled (usage is still recorded).
29
- # PI_DAILY_TOKEN_CAP= # optional daily token cap: once a day's recorded spend reaches it, the NEXT job is refused pre-container.
30
- # Check-AFTER by nature (token cost is only known post-run), unlike PI_DAILY_CAP; unset = disabled
31
- PI_CONCURRENCY=3 # how many jobs run in parallel
39
+ # per-job turn cap -- pi has none of its own, so the harness imposes one
40
+ PI_MAX_TURNS=30
41
+ # max job containers started per day (mandatory window)
42
+ PI_DAILY_CAP=25
43
+ # optional weekly ceiling on container starts; unset = weekly window disabled
44
+ # PI_WEEKLY_CAP=100
45
+ # optional monthly ceiling on container starts; unset = monthly window disabled
46
+ # PI_MONTHLY_CAP=400
47
+ # optional soft-hold band (1-99): once any window hits this % of its cap, new starts
48
+ # pause (in-flight jobs finish) and the panel meter turns amber; unset = disabled
49
+ # PI_SOFT_HOLD_PCT=80
50
+ # optional per-job token budget: the runner aborts the agent once cumulative usage exceeds it.
51
+ # LAGGING (spends before it can see the total) -- a single-job runaway backstop, not before-the-spend;
52
+ # PI_MAX_TURNS stays the proactive lever. Unset = per-job token budget disabled (usage is still recorded).
53
+ # PI_MAX_TOKENS=
54
+ # optional daily token cap: once a day's recorded spend reaches it, the NEXT job is refused pre-container.
55
+ # Check-AFTER by nature (token cost is only known post-run), unlike PI_DAILY_CAP; unset = disabled
56
+ # PI_DAILY_TOKEN_CAP=
57
+ # how many jobs run in parallel
58
+ PI_CONCURRENCY=3
32
59
 
33
60
  # --- Infrastructure ---
34
61
  VALKEY_URL=redis://127.0.0.1:6379
35
- # PI_WORKER_NAME= # what this machine calls itself. Default: your hostname, lowercased and reduced to [A-Za-z0-9._-]
36
- # Lands on every worker log line and in every run record, and identifies this host to the others when you run more than one
37
- # Set it if your hostname is something you would rather not have in your own run history (a laptop often carries a person's name)
38
- # Refused at boot if it is not [A-Za-z0-9._-], does not start with a letter or digit, is over 64 characters, or ends in .json or .log
39
- # SETTING IT TURNS ON MULTI-HOST ROUTING (docs/multi-host.md): work only this machine can do (a cron folder, a chained child,
40
- # a manual run) is enqueued to this host's own queue instead of the shared one, and the wait-check and scoped-concurrency
41
- # ceilings become fleet-wide instead of per process. Leave it unset on a single-machine deployment and nothing changes
42
- PI_JOB_IMAGE=pi-job:latest # the DEFAULT job image. Any trigger may name its own with "image" in triggers.json (docs/job-image.md)
43
- # Jobs run with --pull=never: pull or BUILD every image you name -- the worker never fetches one at job time, and doctor checks presence
44
- # docker pull ghcr.io/edgehero/pi-job:latest && docker tag ghcr.io/edgehero/pi-job:latest pi-job:latest (or build image/Dockerfile)
45
- # PI_JOBS_DIR= # where per-job /job inputs live (default: your OS temp dir)
46
- # PI_LOGS_DIR= # where per-job status records (and optional raw logs) land (default: OS temp /pi-dispatch/logs)
47
- # 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)
48
- # PI_LOG_RETENTION_DAYS= # default 30; boot-time prune of logs older than N days; 0 = keep forever
49
- # PI_SANDBOX_DIR= # where a finished run's directory is kept so you can re-open it (default: <PI_JOBS_DIR>/sandboxes, mode 0700)
50
- # `pi-dispatch sandbox <jobId>` starts a fresh container from the same image with the same workspace and NO credentials (docs/sandbox.md)
51
- # A retained directory holds the run's clone plus its prompt.md/event.json -- so issue text. Host-only; never mounted into a job
52
- # PI_SANDBOX_RETENTION_HOURS= # default 24. NOTE: 0 means OFF here -- nothing is retained and cleanup deletes as it always did
53
- # This is the OPPOSITE of PI_LOG_RETENTION_DAYS/PI_SESSIONS_TTL_DAYS, where 0 means keep forever
54
- # There is deliberately no keep-forever value: one repo clone per run with no ceiling is a disk bomb. Use --pin for the one run worth keeping
55
- # PI_SANDBOX_PIN_DAYS= # default 7; `pi-dispatch sandbox <jobId> --pin` extends THAT run to now + this many days. Still swept afterwards
56
- # PI_SANDBOX_IDLE_MINUTES= # default 30; bash's own TMOUT inside a sandbox, so a forgotten shell closes itself; 0 = no idle logout
57
- # Honest gap: TMOUT does not tick while a foreground command runs, so a sandbox left serving an app stays up (`pi-dispatch sandbox --list` finds it)
58
- # PI_SESSIONS_DIR= # NO DEFAULT, on purpose. Where persisted agent transcripts live, so a trigger with "resume": true can continue the session that opened the PR (docs/sessions.md)
59
- # Unset = the feature is unavailable and an armed trigger refuses PRE-SPEND rather than running unpersisted and looking like it worked
60
- # A transcript is the most PII-bearing thing this system stores -- issue text, file contents, tool output, the agent's own reasoning. Mode 0700, host-only, OUTSIDE any git repo
61
- # Deliberately not defaulted into the OS temp dir the way PI_LOGS_DIR is: that is mode 1777 on POSIX
62
- # PI_SESSIONS_TTL_DAYS= # default 14; a transcript older than this is not resumed AND is swept at boot; 0 = keep forever
63
- # Enforced at OPEN as well as at boot: a stale transcript is a live input to a future job, not debris
64
- # PI_SESSION_MAX_BYTES= # default 8388608 (8 MiB); a transcript larger than this is not resumed; 0 = no cap
65
- # Not disk hygiene -- an oversized transcript is a prefill nobody sized PI_MAX_TOKENS for
66
- # PI_SESSION_MAX_AGE_DAYS= # unset/0 = no bound. How old the CONVERSATION may be, read from the session header's own timestamp
67
- # A DIFFERENT CLOCK from PI_SESSIONS_TTL_DAYS, not a finer setting of it: that one reads mtime, which every COMPLETED run refreshes,
68
- # so a lineage that keeps finishing work never expires however old its first turn is. This one measures from the first turn
69
- # A header with no readable timestamp is refused rather than assumed young (reason: conversation-too-old)
70
- # PI_SESSION_MAX_RESUME_CHAIN= # unset/0 = no bound. How many times in a row one key may be resumed before the next job starts fresh
71
- # The bound a long lineage actually needs: age and size grow slowly, a chain grows once per run
72
- # The count is kept whether or not the bound is set, so setting it later takes effect on the next job rather than N jobs later
73
- # PI_SESSION_MAX_CONTEXT_PCT= # unset = no bound; 1-100. Refuse a resume when the saved session's context is already this full, e.g. 80
74
- # A SAFETY bound before an economic one: past pi's compaction threshold a resumed job replays a model-written summary of the transcript,
75
- # written while that model was reading attacker-authored text (specs/open-questions.md, OQ-003). This ceiling is the host's own, and pi's threshold stays pi's
76
- # The measurement comes from the job image's runner, so it is inert until you are running an image that reports it and each key has completed one run since
77
- # PI_SESSIONS_ALLOW_GH_SOURCE= # unset = a run.resume job REFUSES to mint under GITHUB_AUTH_SOURCE=gh, pre-spend
78
- # That source is your whole gh login: full-scope and non-expiring, and a transcript is a FILE -- any command that echoed an auth header persists it
79
- # Prefer GITHUB_AUTH_SOURCE=app or a short-expiry fine-grained PAT. Set exactly 1 to accept the trade explicitly (SECURITY.md, docs/sessions.md)
80
- # PI_TRIGGERS_FILE= # ABSOLUTE path to the unified triggers.json, read by BOTH worker and receiver (a relative path resolves against the service's WorkingDirectory).
81
- # Unset = cron disabled for the worker; the receiver falls back to ./triggers.json in the folder it starts from (what `pi-dispatch init` scaffolds)
82
- # and refuses to start when neither exists (it holds the label/comment/pull_request trigger config)
83
- # 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
84
- # PI_SCOPED_LIMITS_FILE= # ABSOLUTE path to scoped-limits.json — per repo/folder job-count caps (day/week/month, refused pre-spend as scope-cap) and max concurrent jobs per scope (excess deferred, never dropped).
85
- # Unset = no scoped caps or concurrency; the one-job-per-folder mutex for local jobs is always on and needs no file. See docs/scoped-limits.md
86
- # PI_SUBSCRIPTIONS_FILE= # path to subscriptions.json — operator-declared subscription plan prices (the admin defaults to ./subscriptions.json in its working directory). Read by the ADMIN EXTENSION only, never at job time.
87
- # Subscription-backed providers bill 0 per run (their rate tables are all zeros), so this file is where the real price lives — cost analytics only; it changes no routing, auth, or job behavior
88
- # 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
89
- # PI_DISPATCH_DEPLOYMENT_FILE= # ABSOLUTE path to the deployment pointer the /dispatch panel reads to find a deployment built elsewhere
90
- # (default: <your pi agent dir>/pi-dispatch-deployment.json). Read by the ADMIN EXTENSION only; the worker and receiver never look at it.
91
- # Your own environment still wins key by key, so this points the panel at a deployment, it does not override one
92
- # PI_GRAPH_DIR= # where `/dispatch insights` writes its HTML artifact (default: under the OS temp dir). Admin extension only. See docs/insights.md
62
+ # The Valkey's password, per deployment: `pi-dispatch init` writes a new one here (and creates this file readable by you
63
+ # alone), and `up` / `service install` add one to a deployment that has none and restart its Valkey with it (the queue is
64
+ # kept). Every client sends it: the worker, the receiver, the CLI, the panel and doctor. Without it any local account can
65
+ # read, enqueue or delete this deployment's jobs. Hex or base64url only (A-Z a-z 0-9 - _), at least 16 characters.
66
+ # A key of its own, never inside VALKEY_URL, which doctor prints and the panel's pointer file stores; a VALKEY_URL with
67
+ # its own password (a managed Valkey) is left as it is, and that password wins. With PI_VALKEY_SHARED=1 below, set it to
68
+ # the password of the account whose Valkey it is. docker compose reads it with --env-file .env (docs/podman.md, docs/github.md).
69
+ # VALKEY_PASSWORD=
70
+ # docker compose only: the port deploy/docker-compose.yml publishes its Valkey on, 127.0.0.1:<this>. Default 6379. Keep it
71
+ # equal to VALKEY_URL's port: `pi-dispatch up` and /dispatch setup write it here when that is not 6379 (never over a value
72
+ # already set), and doctor flags one that disagrees. Not read by the worker, the receiver or the CLI, which dial VALKEY_URL.
73
+ # PI_VALKEY_PORT=
74
+ # podman venue: the worker (at every start), `service install`, `up` and doctor take a Valkey that answers VALKEY_URL only when THIS account
75
+ # (or one of its containers) holds it. One held by another account, by root (docker-proxy, a rootful container) or by a
76
+ # system service is refused, naming its owner: give this account its own port instead (VALKEY_URL=redis://127.0.0.1:6380).
77
+ # Set this to 1 only when that Valkey is shared ON PURPOSE: every account using it can read and drain the others' jobs
78
+ # Read from this file ONLY: exporting it in a shell does nothing, and the commands say so
79
+ # PI_VALKEY_SHARED=1
80
+ # what this machine calls itself. Default: your hostname, lowercased and reduced to [A-Za-z0-9._-]
81
+ # Lands on every worker log line and in every run record, and identifies this host to the others when you run more than one
82
+ # Set it if your hostname is something you would rather not have in your own run history (a laptop often carries a person's name)
83
+ # Refused at boot if it is not [A-Za-z0-9._-], does not start with a letter or digit, is over 64 characters, or ends in .json or .log
84
+ # SETTING IT TURNS ON MULTI-HOST ROUTING (docs/multi-host.md): work only this machine can do (a cron folder, a chained child,
85
+ # a manual run) is enqueued to this host's own queue instead of the shared one, and the wait-check and scoped-concurrency
86
+ # ceilings become fleet-wide instead of per process. Leave it unset on a single-machine deployment and nothing changes
87
+ # PI_WORKER_NAME=
88
+ # the DEFAULT job image. Any trigger may name its own with "image" in triggers.json (docs/job-image.md)
89
+ # Jobs run with --pull=never: pull or BUILD every image you name -- the worker never fetches one at job time, and doctor checks presence
90
+ # docker pull ghcr.io/edgehero/pi-job:latest && docker tag ghcr.io/edgehero/pi-job:latest pi-job:latest (or build image/Dockerfile)
91
+ # On rootful Podman, run these and every docker command through a docker context pointed at podman.sock, with the real docker CLI (docs/podman.md)
92
+ PI_JOB_IMAGE=pi-job:latest
93
+ # where per-job /job inputs live (default: <OS temp dir>/pi-dispatch-<your uid>/jobs, one per account, created 0700)
94
+ # Another account's directory there, or one you set that another account owns, stops the worker at boot (exit 2), and doctor names it
95
+ # Pin it (and PI_SANDBOX_DIR) yourself if the worker runs as a different account than your /dispatch panel or `pi-dispatch sandbox`: the default is per account
96
+ # PI_JOBS_DIR=
97
+ # where per-job status records (and optional raw logs) land (default: ~/.pi-dispatch/logs)
98
+ # DURABLE by default, outside any repo and outside the OS temp dir, so a reboot keeps your run history. `pi-dispatch up` writes the resolved default here explicitly
99
+ # Unless YOUR shell already sets PI_LOGS_DIR to a relative path or one inside the deployment folder, which `up` refuses to persist and says so
100
+ # It pins whatever the account default RESOLVES to, and refuses only a value YOUR shell exported that is relative or inside this folder
101
+ # That refusal exists because the retention sweep below deletes every .log and .json past its window, and a deployment folder holds triggers.json
102
+ # Pin it yourself if the worker runs as a different account than your /dispatch panel: the default is per USER, and the two must agree or the panel shows an empty history
103
+ # PI_LOGS_DIR=
104
+ # 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)
105
+ # PI_CAPTURE_JOB_LOGS=
106
+ # default 30; prunes logs older than N days at boot AND on the retention timer below; 0 = keep forever
107
+ # PI_LOG_RETENTION_DAYS=
108
+ # default 24; how often the three retention sweeps (logs, sandboxes, sessions) re-run while the worker is up
109
+ # The sandbox one covers two things since issue #337: the retained directories, and the pi-sandbox-<id>-net network of a run whose directory is already gone
110
+ # 0 = BOOT-ONLY, which is what every version before this one did: a worker that never restarts never re-sweeps, so a configured window described nothing
111
+ # At 0 that network waits for the boot AFTER the one that removes the directory, so on a worker that never restarts `docker network rm` stays the only route
112
+ # The window is a FLOOR, not a ceiling: a file dies on the first sweep AFTER its window closes, so the real ceiling is its window plus this interval (up to 48h for a sandbox at both defaults)
113
+ # Refused above 168 (one week): setInterval clamps a longer delay to 1ms, and a sweep slower than a week is 0 with extra steps
114
+ # PI_SWEEP_INTERVAL_HOURS=
115
+ # where a finished run's directory is kept so you can re-open it (default: <PI_JOBS_DIR>/sandboxes, mode 0700)
116
+ # `pi-dispatch sandbox <jobId>` starts a fresh container from the same image with the same workspace and NO credentials (docs/sandbox.md)
117
+ # A retained directory holds the run's clone plus its prompt.md/event.json -- so issue text. Host-only; never mounted into a job
118
+ # PI_SANDBOX_DIR=
119
+ # default 24. NOTE: 0 means OFF here -- nothing is retained and cleanup deletes as it always did
120
+ # This is the OPPOSITE of PI_LOG_RETENTION_DAYS/PI_SESSIONS_TTL_DAYS, where 0 means keep forever
121
+ # There is deliberately no keep-forever value: one repo clone per run with no ceiling is a disk bomb. Use --pin for the one run worth keeping
122
+ # PI_SANDBOX_RETENTION_HOURS=
123
+ # default 7; `pi-dispatch sandbox <jobId> --pin` extends THAT run to now + this many days. Still swept afterwards
124
+ # PI_SANDBOX_PIN_DAYS=
125
+ # default 30; bash's own TMOUT inside a sandbox, so a forgotten shell closes itself; 0 = no idle logout
126
+ # Honest gap: TMOUT does not tick while a foreground command runs, so a sandbox left serving an app stays up (`pi-dispatch sandbox --list` finds it)
127
+ # PI_SANDBOX_IDLE_MINUTES=
128
+ # NO DEFAULT, on purpose. Where persisted agent transcripts live, so a trigger with "resume": true can continue the session that opened the PR (docs/sessions.md)
129
+ # Unset = the feature is unavailable and an armed trigger refuses PRE-SPEND rather than running unpersisted and looking like it worked
130
+ # A transcript is the most PII-bearing thing this system stores -- issue text, file contents, tool output, the agent's own reasoning. Mode 0700, host-only, OUTSIDE any git repo
131
+ # Deliberately not defaulted AT ALL, unlike PI_LOGS_DIR: unset means the feature is unavailable and an armed trigger refuses pre-spend rather than running unpersisted
132
+ # PI_SESSIONS_DIR=
133
+ # default 14; a transcript older than this is not resumed AND is swept at boot and on the retention timer; 0 = keep forever
134
+ # Enforced at OPEN as well as at boot: a stale transcript is a live input to a future job, not debris
135
+ # PI_SESSIONS_TTL_DAYS=
136
+ # default 8388608 (8 MiB); a transcript larger than this is not resumed; 0 = no cap
137
+ # Not disk hygiene -- an oversized transcript is a prefill nobody sized PI_MAX_TOKENS for
138
+ # PI_SESSION_MAX_BYTES=
139
+ # unset/0 = no bound. How old the CONVERSATION may be, read from the session header's own timestamp
140
+ # A DIFFERENT CLOCK from PI_SESSIONS_TTL_DAYS, not a finer setting of it: that one reads mtime, which every COMPLETED run refreshes,
141
+ # so a lineage that keeps finishing work never expires however old its first turn is. This one measures from the first turn
142
+ # A header with no readable timestamp is refused rather than assumed young (reason: conversation-too-old)
143
+ # PI_SESSION_MAX_AGE_DAYS=
144
+ # unset/0 = no bound. How many times in a row one key may be resumed before the next job starts fresh
145
+ # The bound a long lineage actually needs: age and size grow slowly, a chain grows once per run
146
+ # The count is kept whether or not the bound is set, so setting it later takes effect on the next job rather than N jobs later
147
+ # PI_SESSION_MAX_RESUME_CHAIN=
148
+ # unset = no bound; 1-100. Refuse a resume when the saved session's context is already this full, e.g. 80
149
+ # A SAFETY bound before an economic one: past pi's compaction threshold a resumed job replays a model-written summary of the transcript,
150
+ # written while that model was reading attacker-authored text (specs/open-questions.md, OQ-003). This ceiling is the host's own, and pi's threshold stays pi's
151
+ # The measurement comes from the job image's runner, so it is inert until you are running an image that reports it and each key has completed one run since
152
+ # PI_SESSION_MAX_CONTEXT_PCT=
153
+ # unset = a run.resume job REFUSES to mint under GITHUB_AUTH_SOURCE=gh, pre-spend
154
+ # That source is your whole gh login: full-scope and non-expiring, and a transcript is a FILE -- any command that echoed an auth header persists it
155
+ # Prefer GITHUB_AUTH_SOURCE=app or a short-expiry fine-grained PAT. Set exactly 1 to accept the trade explicitly (SECURITY.md, docs/sessions.md)
156
+ # PI_SESSIONS_ALLOW_GH_SOURCE=
157
+ # ABSOLUTE path to the unified triggers.json, read by BOTH worker and receiver (a relative path resolves against the service's WorkingDirectory).
158
+ # Unset = cron disabled for the worker; the receiver falls back to ./triggers.json in the folder it starts from (what `pi-dispatch init` scaffolds)
159
+ # and refuses to start when neither exists (it holds the label/comment/pull_request trigger config)
160
+ # PI_TRIGGERS_FILE=
161
+ # The two keys below are the only ones where uncommenting WITHOUT filling in a path is worse than leaving
162
+ # them alone: the worker keeps an empty value and refuses to start, rather than treating it as unset.
163
+ # `pi-dispatch up` fills them in for you; by hand, write the path or leave the line commented.
164
+ # 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
165
+ # `pi-dispatch up` sets this to the pause-windows.json in the folder it runs in, which is the one `init` scaffolds and the one the panel defaults to, so all three agree
166
+ # PI_PAUSE_WINDOWS_FILE=
167
+ # ABSOLUTE path to scoped-limits.json — per repo/folder job-count caps (day/week/month, refused pre-spend as scope-cap) and max concurrent jobs per scope (excess deferred, never dropped).
168
+ # Unset = no scoped caps or concurrency; the one-job-per-folder mutex for local jobs is always on and needs no file. See docs/scoped-limits.md
169
+ # `pi-dispatch up` sets this to the scoped-limits.json in the folder it runs in, the same way and for the same reason as PI_PAUSE_WINDOWS_FILE above
170
+ # PI_SCOPED_LIMITS_FILE=
171
+ # path to subscriptions.json — operator-declared subscription plan prices (the admin defaults to ./subscriptions.json in its working directory). Read by the ADMIN EXTENSION only, never at job time.
172
+ # Subscription-backed providers bill 0 per run (their rate tables are all zeros), so this file is where the real price lives — cost analytics only; it changes no routing, auth, or job behavior
173
+ # PI_SUBSCRIPTIONS_FILE=
174
+ # ABSOLUTE path to the runtime settings overlay (default: ~/.pi-dispatch/settings.json); edited by the admin extension, read by the worker per job
175
+ # DURABLE by default, beside the run history. Losing it does not stop jobs, it WIDENS them: a missing file reads as an empty overlay and every panel-set cap falls back to this file's values
176
+ # Same per-user caveat as PI_LOGS_DIR, and the same fix: `pi-dispatch up` writes the resolved account default here explicitly, so the worker and the panel cannot drift apart silently
177
+ # The setup wizard's deployment pointer does NOT carry this key (it carries the triggers, pause-windows, scoped-limits and subscriptions paths), so an explicit line here is what keeps the two in step
178
+ # PI_SETTINGS_FILE=
179
+ # ABSOLUTE path to the deployment pointer the /dispatch panel reads to find a deployment built elsewhere
180
+ # (default: <your pi agent dir>/pi-dispatch-deployment.json). Read by the ADMIN EXTENSION only; the worker and receiver never look at it.
181
+ # Your own environment still wins key by key, so this points the panel at a deployment, it does not override one
182
+ # PI_DISPATCH_DEPLOYMENT_FILE=
183
+ # where `/dispatch insights` writes its HTML artifact (default: <OS temp dir>/pi-dispatch-<your uid>/graph). Admin extension only. See docs/insights.md
184
+ # PI_GRAPH_DIR=
93
185
 
94
186
  # --- Reuse your existing pi setup in every job (see docs/global-pi-overlay.md) ---
95
- # 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
96
- # PI_GLOBAL_ALLOW_EXTENSIONS= # the overlay's extensions LOAD by default (staging them with import-pi, which prints each one, is the vetting step). Set exactly 0 to keep them staged but dormant.
97
- # Unset, empty and the legacy 1 all mean LOAD. ANY other value refuses to boot -- a typo must never silently leave code running against adversarial input with open egress.
98
- # This knob covers the OVERLAY only. A serviced repo's own /workspace/.pi/extensions load regardless (they are default-branch, merge-gated) -- see SECURITY.md.
99
- # PI_CODING_AGENT_DIR= # where YOUR pi setup lives on this host (default: ~/.pi/agent). `pi-dispatch import-pi` reads its models.json,
100
- # skills and APPEND_SYSTEM.md from here, and the panel looks here for the deployment pointer.
101
- # It is also read AT JOB TIME: with no provider key in the environment the worker reads this directory's auth.json
102
- # for one (on by default; PI_AUTH_FROM_PI=0 turns it off), so pointing this at the wrong place makes every job
103
- # refuse pre-spend with no credential for the provider. It is the SOURCE that gets staged, never the thing mounted:
104
- # PI_GLOBAL_PI_DIR above is what a job actually sees. Set it if your pi lives somewhere other than your home directory
105
- # PI_PACKAGES_FILE= # path to pi-packages.json (default: ./pi-packages.json; --packages-file wins). Read ONLY by `pi-dispatch import-pi --with-packages`, never at job time.
106
- # Staged packages/ rides INSIDE PI_GLOBAL_PI_DIR -- no separate mount, no separate env dir -- and loads for EVERY job once staged; decline it PER TRIGGER with "packages": false in triggers.json, NOT by an env flag.
107
- # Versions must be EXACT (no ^ ~ * or latest); staging uses --ignore-scripts, so a package needing a build step is staged INCOMPLETE and import-pi warns.
108
- # 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.
109
- # GITHUB_TOKEN/GH_TOKEN are refused here -- the worker mints per-job tokens
187
+ # 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
188
+ # PI_GLOBAL_PI_DIR=
189
+ # the overlay's extensions LOAD by default (staging them with import-pi, which prints each one, is the vetting step). Set exactly 0 to keep them staged but dormant.
190
+ # Unset, empty and the legacy 1 all mean LOAD. ANY other value refuses to boot -- a typo must never silently leave code running against adversarial input with open egress.
191
+ # This knob covers the OVERLAY only. A serviced repo's own /workspace/.pi/extensions load regardless (they are default-branch, merge-gated) -- see SECURITY.md.
192
+ # PI_GLOBAL_ALLOW_EXTENSIONS=
193
+ # where YOUR pi setup lives on this host (default: ~/.pi/agent). `pi-dispatch import-pi` reads its models.json,
194
+ # skills and APPEND_SYSTEM.md from here, and the panel looks here for the deployment pointer.
195
+ # It is also read AT JOB TIME: with no provider key in the environment the worker reads this directory's auth.json
196
+ # for one (on by default; PI_AUTH_FROM_PI=0 turns it off), so pointing this at the wrong place makes every job
197
+ # refuse pre-spend with no credential for the provider. It is the SOURCE that gets staged, never the thing mounted:
198
+ # PI_GLOBAL_PI_DIR above is what a job actually sees. Set it if your pi lives somewhere other than your home directory
199
+ # PI_CODING_AGENT_DIR=
200
+ # path to pi-packages.json (default: ./pi-packages.json; --packages-file wins). Read ONLY by `pi-dispatch import-pi --with-packages`, never at job time.
201
+ # Staged packages/ rides INSIDE PI_GLOBAL_PI_DIR -- no separate mount, no separate env dir -- and loads for EVERY job once staged; decline it PER TRIGGER with "packages": false in triggers.json, NOT by an env flag.
202
+ # Versions must be EXACT (no ^ ~ * or latest); staging uses --ignore-scripts, so a package needing a build step is staged INCOMPLETE and import-pi warns.
203
+ # PI_PACKAGES_FILE=
204
+ # comma-separated extra env var NAMES to forward into the container (e.g. a CUSTOM provider's key). Explicit allowlist, not a pass-through.
205
+ # GITHUB_TOKEN/GH_TOKEN are refused here -- the worker mints per-job tokens
206
+ # PI_FORWARD_ENV=
110
207
 
111
208
  # --- Per-trigger vault secrets (docs/secrets.md, issue #225) ---
112
209
  # A trigger may name references ("secrets": { "STRIPE_KEY": "op://ci/stripe/api-key" }) and the profile that
113
210
  # resolves them. The worker runs YOUR script once per reference, on the HOST, before the container starts.
114
211
  # The job receives values; it never holds your manager's credential and cannot enumerate your vault.
115
- # PI_SECRET_PROFILES= # name:/absolute/path pairs, comma separated (each entry splits on its FIRST colon, so a Windows C:\ path parses).
116
- # A resolver is one line: `exec op read --no-newline "$1"`, `exec pass show "$1"`, `exec vault kv get -field=... "$1"`.
117
- # Exit 2 if the reference is wrong, exit 1 if you could not reach your manager (that one retries). Unset = feature off.
118
- # PI_SECRET_RESOLVER_ROOTS= # OS-path-delimited (; on Windows, : elsewhere) directories a PANEL-declared resolver may live in.
119
- # Default empty = fail-closed: `/dispatch secrets add` can declare nothing, and only PI_SECRET_PROFILES above is honoured.
120
- # PI_SECRET_RESOLVE_TIMEOUT_MS= # default 10000, per reference. Sits before a paid container and is multiplied by the reference count, so tighter than doctor's 30s.
212
+ # name:/absolute/path pairs, comma separated (each entry splits on its FIRST colon, so a Windows C:\ path parses).
213
+ # A resolver is one line: `exec op read --no-newline "$1"`, `exec pass show "$1"`, `exec vault kv get -field=... "$1"`.
214
+ # Exit 2 if the reference is wrong, exit 1 if you could not reach your manager (that one retries). Unset = feature off.
215
+ # PI_SECRET_PROFILES=
216
+ # OS-path-delimited (; on Windows, : elsewhere) directories a PANEL-declared resolver may live in.
217
+ # Default empty = fail-closed: `/dispatch secrets add` can declare nothing, and only PI_SECRET_PROFILES above is honoured.
218
+ # PI_SECRET_RESOLVER_ROOTS=
219
+ # default 10000, per reference. Sits before a paid container and is multiplied by the reference count, so tighter than doctor's 30s.
220
+ # PI_SECRET_RESOLVE_TIMEOUT_MS=
121
221
 
122
222
  # --- Holding a job until something else happens (docs/wait-for.md, issue #230) ---
123
223
  # A trigger may carry "waitFor": [{ "after": "2026-09-01T09:00:00Z" }, { "profile": "jira" }]. The job is
@@ -125,45 +225,82 @@ PI_JOB_IMAGE=pi-job:latest # the DEFAULT job image. Any trigger may nam
125
225
  # survives a restart, and runs exactly once when every condition clears. An `after` is answered from the
126
226
  # clock and costs nothing. A `profile` names one of YOUR scripts, which the worker runs on the HOST with the
127
227
  # job's id-only target as its first argument.
128
- # PI_WAIT_PROFILES= # name:/absolute/path pairs, comma separated (each entry splits on its FIRST colon, so a Windows C:\ path parses). Unset = feature off.
129
- # Map the codes yourself, because a bare pipeline cannot: `s=$(jira issue view "$1" --plain) || exit 1` then
130
- # `case "$s" in *"Status: Done"*) exit 0;; *) exit 3;; esac`. `grep -q` alone exits 1 for "no match", which is a counted FAULT, not "not yet".
131
- # Exit 0 to go, 3 for not yet, 2 if it will NEVER clear (terminal), 1 if you could not tell (held, and counted).
132
- # PI_WAIT_CHECK_TIMEOUT_MS= # default 10000, per check. Sits before a paid container and holds a concurrency slot while it runs.
133
- # PI_WAIT_INTERVAL_MS= # default 60000, floored at 30000: a positive value BELOW the floor is raised to it, while 0, a negative, a fraction or junk still refuses at boot.
134
- # The base cadence; it backs off toward 15 minutes, or toward YOUR value if you set a longer one.
135
- # PI_WAIT_CHECK_SLOTS= # default 1. How many checks may run at once. Will be held below PI_CONCURRENCY at the gate, so a check can never take the last slot from a paid job.
136
- # PI_WAIT_MAX_MS= # default 86400000 (24h). A profile hold terminates here with a named reason: a dependency, unlike a pause window, does not end on its own.
137
- # PI_WAIT_AFTER_MAX_MS= # default 2592000000 (30d). The separate, larger ceiling on an `after` instant, which polls nothing while it waits.
138
- # PI_WAIT_MAX_CHECKS= # default 96, per job. Nothing in the spend caps sees a check, so this is the bound that does.
139
- # PI_WAIT_MAX_FAULTS= # default 5 consecutive "could not tell" answers. What makes a broken check loud in minutes instead of silent for a day.
228
+ # name:/absolute/path pairs, comma separated (each entry splits on its FIRST colon, so a Windows C:\ path parses). Unset = feature off.
229
+ # Map the codes yourself, because a bare pipeline cannot: `s=$(jira issue view "$1" --plain) || exit 1` then
230
+ # `case "$s" in *"Status: Done"*) exit 0;; *) exit 3;; esac`. `grep -q` alone exits 1 for "no match", which is a counted FAULT, not "not yet".
231
+ # Exit 0 to go, 3 for not yet, 2 if it will NEVER clear (terminal), 1 if you could not tell (held, and counted).
232
+ # PI_WAIT_PROFILES=
233
+ # default 10000, per check. Sits before a paid container and holds a concurrency slot while it runs.
234
+ # PI_WAIT_CHECK_TIMEOUT_MS=
235
+ # default 60000, floored at 30000: a positive value BELOW the floor is raised to it, while 0, a negative, a fraction or junk still refuses at boot.
236
+ # The base cadence; it backs off toward 15 minutes, or toward YOUR value if you set a longer one.
237
+ # PI_WAIT_INTERVAL_MS=
238
+ # default 1. How many checks may run at once. Will be held below PI_CONCURRENCY at the gate, so a check can never take the last slot from a paid job.
239
+ # PI_WAIT_CHECK_SLOTS=
240
+ # default 86400000 (24h). A profile hold terminates here with a named reason: a dependency, unlike a pause window, does not end on its own.
241
+ # PI_WAIT_MAX_MS=
242
+ # default 2592000000 (30d). The separate, larger ceiling on an `after` instant, which polls nothing while it waits.
243
+ # PI_WAIT_AFTER_MAX_MS=
244
+ # default 96, per job. Nothing in the spend caps sees a check, so this is the bound that does.
245
+ # PI_WAIT_MAX_CHECKS=
246
+ # default 5 consecutive "could not tell" answers. What makes a broken check loud in minutes instead of silent for a day.
247
+ # PI_WAIT_MAX_FAULTS=
248
+
249
+ # --- Telling you when a paid job dies (docs/notifications.md, issue #288) ---
250
+ # Every free refusal already comments on its issue. This is the other half: when a job that SPENT money
251
+ # ends wrong (the 30-minute kill, an in-container budget stop, a final infrastructure failure), the worker
252
+ # runs YOUR one command with id-only arguments: <jobId> <outcome> <reason> <host>. Never a title, a body,
253
+ # or any payload text. You wire ntfy, Slack or mail yourself in one line; this project ships no transport.
254
+ # absolute path to ONE executable. Exec'd directly (an argv array, never a shell), fire and forget,
255
+ # at most once per job, and a hook fault can never change a job's outcome. Its exit code is logged
256
+ # (`on_failure`) and otherwise unread. Unset = off, byte-identically.
257
+ # PI_ON_FAILURE=
258
+ # default 10000. SIGTERM at the deadline, SIGKILL 2s later. Bounds a leaked child, not a job: the
259
+ # hook runs after the outcome is decided and holds nothing.
260
+ # PI_ON_FAILURE_TIMEOUT_MS=
140
261
 
141
- PI_SCHEDULER_STALL_MAX=2 # tear down a scheduler after N consecutive stalls (money backstop)
262
+ # tear down a scheduler after N consecutive stalls (money backstop)
263
+ PI_SCHEDULER_STALL_MAX=2
142
264
 
143
265
  # --- Container backend: where a job's container is built, and what that place guarantees (docs/backends.md) ---
144
- # PI_BACKENDS= # which backends this deployment blesses, comma separated (default: local, the docker daemon on this host).
145
- # # A trigger's run.backend selects among these; one that names none runs on the first, so the list must include local. An unknown name refuses to boot.
146
- # PI_BACKEND_FLOOR= # the minimum every backend above must declare, as property=word pairs: e.g. egress=enforced,nonRoot=asserted
147
- # # Words are enforced (this worker builds it and a test reads it back), asserted (something outside the worker provides it, unverifiable from here) or absent.
148
- # # A floor naming a switched-off control refuses to boot: asking for egress=enforced while PI_EGRESS=0 is a bound you would believe in and not have.
149
- # # `pi-dispatch doctor` prints enforced quietly, asserted as a warning naming who asserts it, and absent as a failure.
150
- # # Env-only, deliberately: a bound that can be widened from the surface it bounds is not a bound.
266
+ # which backends this deployment blesses, comma separated (default: local, the docker daemon on this host).
267
+ # podman is this worker account's own rootless Podman: jobs run as the worker's uid with --userns=keep-id, and a rootful or remote Podman is refused there (docs/podman.md).
268
+ # A trigger's run.backend selects among these; one that names none runs on the first. local is not required: a list without it never asks this host's docker CLI anything at boot. An unknown name refuses to boot.
269
+ # With podman listed, `pi-dispatch up` and `pi-dispatch service install` start Valkey and the egress proxy as Quadlet units in this account's user manager (docs/podman.md). service install reads this key from THIS file, never from your shell.
270
+ # PI_BACKENDS=
271
+ # the minimum every backend above must declare, as property=word pairs: e.g. egress=enforced,nonRoot=asserted
272
+ # Words are enforced (this worker builds it and a test reads it back), asserted (something outside the worker provides it, unverifiable from here) or absent.
273
+ # A floor naming a switched-off control refuses to boot: asking for egress=enforced while PI_EGRESS=0 is a bound you would believe in and not have.
274
+ # credentialTransit=enforced also refuses to boot, and refuses each job, while the docker CLI resolves an endpoint off this host (DOCKER_HOST, a docker context) or determinately fails to say which (a CLI that timed out is retried instead).
275
+ # isolation=enforced and mountSet=enforced refuse the same way while the daemon is not observed applying a container's bounds or the runtime adds mounts of its own: always for isolation on Podman through its Docker API, and for mountSet there without an empty /etc/containers/mounts.conf (docs/podman.md).
276
+ # On the podman backend the same three words are observed from `podman info` and the account's own files instead: the cgroup controllers delegated, an empty mounts.conf (the user's ~/.config/containers/mounts.conf wins over /etc's), and a service that is not remote.
277
+ # `pi-dispatch doctor` prints enforced quietly, asserted as a warning naming who asserts it, and absent as a failure.
278
+ # Env-only, deliberately: a bound that can be widened from the surface it bounds is not a bound.
279
+ # PI_BACKEND_FLOOR=
151
280
 
152
281
  # --- Egress policy: what a job container may reach on the network (docs/egress.md) ---
153
- # ON by default. Every job runs on its own --internal Docker network with no route anywhere except an
154
- # allowlist proxy, and a job whose policy cannot serve it is refused BEFORE it spends a budget slot.
155
- # START THE PROXY: docker compose -f deploy/docker-compose.yml --profile egress up -d
282
+ # ON by default. Every job runs on its own --internal Docker network whose only other member is an allowlist
283
+ # proxy, with no route off this host, and a job whose policy cannot serve it is refused BEFORE it spends a slot.
284
+ # START THE PROXY: docker compose --env-file .env -f deploy/docker-compose.yml --profile egress up -d
285
+ # (on the rootless podman backend, `pi-dispatch up` or `pi-dispatch service install` starts it as a Quadlet unit instead)
156
286
  # Until you do, every job is refused pre-spend (loud, free, and naming that command). The hosts live in
157
287
  # egress-allowlist.conf next to this file (`pi-dispatch init` writes it, and never overwrites it).
158
- # PI_EGRESS=0 # exactly 0 (off) or 1/unset (on). Any other value refuses to boot -- a typo must never leave you believing you have a policy you do not.
159
- # PI_EGRESS_PROXY= # the proxy container the per-job network is built around (default: pi-dispatch-egress-proxy)
160
- # # While the policy is armed, PI_FORWARD_ENV must not carry HTTPS_PROXY/HTTP_PROXY/NO_PROXY/NODE_USE_ENV_PROXY: the policy sets them itself, and a forwarded value would redirect every job while looking like the control working.
288
+ # exactly 0 (off) or 1/unset (on). Any other value refuses to boot -- a typo must never leave you believing you have a policy you do not.
289
+ # PI_EGRESS=0
290
+ # the proxy container the per-job network is built around (default: pi-dispatch-egress-proxy)
291
+ # While the policy is armed, PI_FORWARD_ENV must not carry HTTPS_PROXY/HTTP_PROXY/NO_PROXY/NODE_USE_ENV_PROXY: the policy sets them itself, and a forwarded value would redirect every job while looking like the control working.
292
+ # PI_EGRESS_PROXY=
161
293
 
162
- # 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)
163
- # PI_DISPATCH_RUN_PER_HOUR=3 # per-hour cap on model-invoked dispatch_run enqueues; 0 disables the tool
164
- # PI_DISPATCH_ASCII= # set 1 to render the admin extension's views with plain ASCII instead of box-drawing/sparkline-ramp glyphs (for glyph-width-hostile terminals); read at extension load
165
- # PI_CHAIN_DEPTH_MAX=1 # max follow-up chain depth from a job's /outbox; 0 = chaining kill-switch
166
- # PI_CHAIN_MAX_PER_JOB=2 # max request-<n>.json collected per completed job
294
+ # 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)
295
+ # PI_DISPATCH_RUN_ROOTS=
296
+ # per-hour cap on model-invoked dispatch_run enqueues; 0 disables the tool
297
+ # PI_DISPATCH_RUN_PER_HOUR=3
298
+ # set 1 to render the admin extension's views with plain ASCII instead of box-drawing/sparkline-ramp glyphs (for glyph-width-hostile terminals); read at extension load
299
+ # PI_DISPATCH_ASCII=
300
+ # max follow-up chain depth from a job's /outbox; 0 = chaining kill-switch
301
+ # PI_CHAIN_DEPTH_MAX=1
302
+ # max request-<n>.json collected per completed job
303
+ # PI_CHAIN_MAX_PER_JOB=2
167
304
 
168
305
  # --- GitHub trigger (receiver + worker auth) ---
169
306
  # Webhook receiver. Required only when your triggers name github (or GITHUB_AUTH_SOURCE is set): every forge arm is
@@ -171,17 +308,27 @@ PI_SCHEDULER_STALL_MAX=2 # tear down a scheduler after N consecutive
171
308
  WEBHOOK_SECRET=
172
309
  RECEIVER_PORT=3000
173
310
  RECEIVER_BIND=0.0.0.0
311
+ # how long a BOOTING receiver (serve and poll alike, every configured forge) keeps retrying an
312
+ # identity lookup that failed TRANSIENTLY -- a 502, a refused connection, the forge restarting --
313
+ # before exiting 1 for the supervisor to start another window. Default 600, floored at 60: a
314
+ # positive value below the floor is raised to it, while 0, a negative, a fraction or junk refuses
315
+ # at boot. Retries run IN-PROCESS (5s between attempts, backing off to 30s), so the bound is the
316
+ # same under systemd, launchd and nssm -- and the floor keeps systemd's StartLimitBurst crash-loop
317
+ # bound meaningful: a retrying boot exits at most once per window, never five times in a minute.
318
+ # A determinate refusal (a bad token, an untrusted CA) still exits 2 immediately, never retried.
319
+ # RECEIVER_IDENTITY_RETRY_SECONDS=
174
320
  # Worker GitHub auth: source is gh | pat | app (default gh)
175
321
  # gh = your full login scopes reach token-carrying jobs (doctor warns and names them); pat/app = narrower
176
322
  GITHUB_AUTH_SOURCE=gh
177
323
  # For GITHUB_AUTH_SOURCE=pat: a repo-scoped, short-expiry fine-grained PAT
178
324
  GITHUB_PAT=
179
- # GITHUB_PAT_VAR= # which variable above actually holds the PAT. Default GITHUB_PAT; set this only if your
180
- # secrets manager insists on its own name and you would rather not copy the value to a second key.
181
- # The NAME is not checked against anything: whatever you put here is read verbatim, so a typo
182
- # reads an empty variable and the worker refuses at boot naming the name you chose. Pointing it
183
- # at a variable that holds something else (GITHUB_APP_PRIVATE_KEY, say) is the mistake worth
184
- # knowing about, because nothing stops it and the PAT path would then send that value to GitHub.
325
+ # which variable above actually holds the PAT. Default GITHUB_PAT; set this only if your
326
+ # secrets manager insists on its own name and you would rather not copy the value to a second key.
327
+ # The NAME is not checked against anything: whatever you put here is read verbatim, so a typo
328
+ # reads an empty variable and the worker refuses at boot naming the name you chose. Pointing it
329
+ # at a variable that holds something else (GITHUB_APP_PRIVATE_KEY, say) is the mistake worth
330
+ # knowing about, because nothing stops it and the PAT path would then send that value to GitHub.
331
+ # GITHUB_PAT_VAR=
185
332
  # For GITHUB_AUTH_SOURCE=app (optional; required for multi-tenant)
186
333
  # `pi-dispatch setup github` fills all three in one browser click (App Manifest flow) and writes the PEM 0600
187
334
  GITHUB_APP_ID=
@@ -198,16 +345,18 @@ GITHUB_APP_PRIVATE_KEY=
198
345
  # same queue as the webhook path; about a minute of latency instead of a second. Nothing here is read by
199
346
  # `pi-dispatch-receiver serve`, and no WEBHOOK_SECRET is needed to poll: there is no inbound delivery to
200
347
  # verify, because the poller originates every request itself. See docs/polling.md.
201
- # POLL_REPOS= # WHICH repos to watch: comma-separated owner/name (e.g. acme/web,acme/api). Duplicates are dropped.
202
- # Each entry must be exactly owner/name -- one slash, no spaces -- or the receiver refuses at boot naming the bad entry.
203
- # Leave it UNSET only with GITHUB_AUTH_SOURCE=app: the poller then lists the App installation's own repos and
204
- # re-lists every tenth cycle, so installing the App on a new repo starts polling it without an edit here.
205
- # Unset under any other auth source is a boot refusal, deliberately: a PAT names no repo set, and a poller
206
- # watching nothing looks exactly like a poller that is working.
207
- # POLL_INTERVAL_SECONDS= # seconds between cycles. Default 60, floored at 30: a positive value BELOW the floor is raised to it,
208
- # while 0, a negative, a fraction or junk still refuses at boot.
209
- # GitHub asks pollers to respect its own x-poll-interval hint, which is honored as a MINIMUM when it arrives,
210
- # so a busy hour slows the loop down rather than the loop hammering the API. A typo'd 1 must not turn this into a hammer.
348
+ # WHICH repos to watch: comma-separated owner/name (e.g. acme/web,acme/api). Duplicates are dropped.
349
+ # Each entry must be exactly owner/name -- one slash, no spaces -- or the receiver refuses at boot naming the bad entry.
350
+ # Leave it UNSET only with GITHUB_AUTH_SOURCE=app: the poller then lists the App installation's own repos and
351
+ # re-lists every tenth cycle, so installing the App on a new repo starts polling it without an edit here.
352
+ # Unset under any other auth source is a boot refusal, deliberately: a PAT names no repo set, and a poller
353
+ # watching nothing looks exactly like a poller that is working.
354
+ # POLL_REPOS=
355
+ # seconds between cycles. Default 60, floored at 30: a positive value BELOW the floor is raised to it,
356
+ # while 0, a negative, a fraction or junk still refuses at boot.
357
+ # GitHub asks pollers to respect its own x-poll-interval hint, which is honored as a MINIMUM when it arrives,
358
+ # so a busy hour slows the loop down rather than the loop hammering the API. A typo'd 1 must not turn this into a hammer.
359
+ # POLL_INTERVAL_SECONDS=
211
360
 
212
361
  # --- GitLab trigger (receiver + worker auth) ---
213
362
  # Optional. Set these only to service GitLab projects; leaving GITLAB_TOKEN unset means no /gitlab
@@ -217,13 +366,14 @@ GITHUB_APP_PRIVATE_KEY=
217
366
  # scope that can post a note -- GitLab offers no contents-vs-issues split -- so scope it to one project
218
367
  # and rotate it (CONST-TOKEN-SCOPED-PER-JOB). A GROUP token reaches every project in the group.
219
368
  GITLAB_TOKEN=
220
- # GITLAB_AUTH_SOURCE= # accepts exactly one value, "pat", which is also the default, so there is nothing to set here.
221
- # It exists to REFUSE the wrong assumption rather than to offer a choice: GITHUB_AUTH_SOURCE has
222
- # three sources, and an operator who reasons by symmetry and writes app here gets a sentence at
223
- # boot saying GitLab has no App equivalent, instead of a knob that is silently ignored.
224
- # The refusal needs GITLAB_TOKEN to be set: with no token there is no GitLab to configure, the
225
- # whole block is skipped, and a stray app here really is ignored. Same for the two below.
226
- # FORGEJO_AUTH_SOURCE and AZURE_AUTH_SOURCE are the same variable for the same reason.
369
+ # accepts exactly one value, "pat", which is also the default, so there is nothing to set here.
370
+ # It exists to REFUSE the wrong assumption rather than to offer a choice: GITHUB_AUTH_SOURCE has
371
+ # three sources, and an operator who reasons by symmetry and writes app here gets a sentence at
372
+ # boot saying GitLab has no App equivalent, instead of a knob that is silently ignored.
373
+ # The refusal needs GITLAB_TOKEN to be set: with no token there is no GitLab to configure, the
374
+ # whole block is skipped, and a stray app here really is ignored. Same for the two below.
375
+ # FORGEJO_AUTH_SOURCE and AZURE_AUTH_SOURCE are the same variable for the same reason.
376
+ # GITLAB_AUTH_SOURCE=
227
377
  # Your instance root. Only for self-hosted GitLab.
228
378
  GITLAB_URL=https://gitlab.com
229
379
  # How the receiver verifies a delivery. REQUIRED once any GITLAB_* variable is set, and deliberately not
@@ -245,7 +395,8 @@ FORGEJO_URL=
245
395
  # expire: there is no App or installation token, so rotation is the whole mitigation
246
396
  # (CONST-TOKEN-SCOPED-PER-JOB).
247
397
  FORGEJO_TOKEN=
248
- # FORGEJO_AUTH_SOURCE= # only "pat", the default. See GITLAB_AUTH_SOURCE above for why it exists at all.
398
+ # only "pat", the default. See GITLAB_AUTH_SOURCE above for why it exists at all.
399
+ # FORGEJO_AUTH_SOURCE=
249
400
  # The harness account's NUMERIC id. Required when the token above is repository-scoped, because such a
250
401
  # token may not carry read:user and therefore cannot call GET /user. The receiver refuses to boot without an
251
402
  # identity from one source or the other: the bot-loop guard compares against it, and an unresolved identity
@@ -264,7 +415,8 @@ AZURE_ORG_URL=
264
415
  # permissions in Project Settings -- not from the token's scopes. It also needs vso.graph, to resolve the
265
416
  # actor's project membership before a job may be enqueued.
266
417
  AZURE_TOKEN=
267
- # AZURE_AUTH_SOURCE= # only "pat", the default. See GITLAB_AUTH_SOURCE above for why it exists at all.
418
+ # only "pat", the default. See GITLAB_AUTH_SOURCE above for why it exists at all.
419
+ # AZURE_AUTH_SOURCE=
268
420
  # REQUIRED once any AZURE_* variable is set, and deliberately not defaulted: both modes are shared-secret
269
421
  # compares that cover no bytes, so which header carries the secret must be a choice somebody made.
270
422
  # basic -- Authorization: Basic <base64>, the credential you set on the subscription