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