-
Notifications
You must be signed in to change notification settings - Fork 10
Expand file tree
/
Copy path.env.example
More file actions
442 lines (424 loc) · 36.9 KB
/
Copy path.env.example
File metadata and controls
442 lines (424 loc) · 36.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
# Copy to .env and fill in. Never commit a real .env (it is gitignored).
#
# A value in 'single quotes' is quoted because it NEEDS to be: `pi-dispatch up` writes bare values and quotes
# only what it must. Do not tidy the quotes away. On the POSIX wrapper deployments this file is sourced by a
# shell (`set -a; . ./.env`), where an unquoted value with a space in it is a command-prefix assignment: the
# key ends up UNSET and the rest of the line is EXECUTED (measured in sh, bash and zsh). systemd's
# EnvironmentFile= honours single quotes too.
# The exception is Windows: deploy/worker-env-wrapper.cmd keeps surrounding quotes as PART of the value,
# which is why `up` never writes a quoted value there and why those paths are written bare.
#
# VALUES ARE LITERAL UNDER systemd, and `$` is where this file's four consumers stop agreeing.
# `PI_JOBS_DIR=$HOME/jobs` is read as the literal characters `$HOME/jobs` by systemd's EnvironmentFile= and
# by the Windows cmd wrapper, and as your home directory by a sourcing shell and by compose's env_file
# (measured: systemd 252, compose v2.31.0, sh/bash/zsh/dash). Nothing refuses it, so one file puts the jobs
# in two different places depending on which deployment reads it. Write the path out in full, not $HOME.
# --- Provider credential ---
# pi supports ~30 providers; set the key for the one you use, under the variable name pi expects.
# The worker forwards ONLY the configured provider's key into the job container -- nothing else.
# Anthropic: ANTHROPIC_API_KEY (or ANTHROPIC_OAUTH_TOKEN, which takes precedence)
# OpenAI: OPENAI_API_KEY Google: GEMINI_API_KEY Groq: GROQ_API_KEY ... etc.
# You can LEAVE THIS BLANK if you are already logged into pi: when the env has no key, the worker reads the
# API key from ~/.pi/agent/auth.json (host-side) and env-injects it -- on by default, nothing to set.
# API-key logins only; an OAuth/subscription login is refused (it expires; use an API key for a service).
ANTHROPIC_API_KEY=
# uncomment to force env-only (fail loudly on a missing env key instead of using your pi login)
# PI_AUTH_FROM_PI=0
# --- Which provider/model to run by default (override per job with --provider / --model) ---
PI_PROVIDER=anthropic
# A DATED model id is deterministic; a floating alias (e.g. claude-sonnet-4-5) can change cost.
PI_MODEL=claude-sonnet-4-5-20250929
# --- Spend + concurrency guards (money bounds; all have conservative defaults) ---
# per-job turn cap -- pi has none of its own, so the harness imposes one
PI_MAX_TURNS=30
# max job containers started per day (mandatory window)
PI_DAILY_CAP=25
# optional weekly ceiling on container starts; unset = weekly window disabled
# PI_WEEKLY_CAP=100
# optional monthly ceiling on container starts; unset = monthly window disabled
# PI_MONTHLY_CAP=400
# optional soft-hold band (1-99): once any window hits this % of its cap, new starts
# pause (in-flight jobs finish) and the panel meter turns amber; unset = disabled
# PI_SOFT_HOLD_PCT=80
# optional per-job token budget: the runner aborts the agent once cumulative usage exceeds it.
# LAGGING (spends before it can see the total) -- a single-job runaway backstop, not before-the-spend;
# PI_MAX_TURNS stays the proactive lever. Unset = per-job token budget disabled (usage is still recorded).
# PI_MAX_TOKENS=
# optional daily token cap: once a day's recorded spend reaches it, the NEXT job is refused pre-container.
# Check-AFTER by nature (token cost is only known post-run), unlike PI_DAILY_CAP; unset = disabled
# PI_DAILY_TOKEN_CAP=
# how many jobs run in parallel
PI_CONCURRENCY=3
# --- Infrastructure ---
VALKEY_URL=redis://127.0.0.1:6379
# what this machine calls itself. Default: your hostname, lowercased and reduced to [A-Za-z0-9._-]
# Lands on every worker log line and in every run record, and identifies this host to the others when you run more than one
# 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)
# 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
# SETTING IT TURNS ON MULTI-HOST ROUTING (docs/multi-host.md): work only this machine can do (a cron folder, a chained child,
# a manual run) is enqueued to this host's own queue instead of the shared one, and the wait-check and scoped-concurrency
# ceilings become fleet-wide instead of per process. Leave it unset on a single-machine deployment and nothing changes
# PI_WORKER_NAME=
# the DEFAULT job image. Any trigger may name its own with "image" in triggers.json (docs/job-image.md)
# Jobs run with --pull=never: pull or BUILD every image you name -- the worker never fetches one at job time, and doctor checks presence
# docker pull ghcr.io/edgehero/pi-job:latest && docker tag ghcr.io/edgehero/pi-job:latest pi-job:latest (or build image/Dockerfile)
# 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)
PI_JOB_IMAGE=pi-job:latest
# where per-job /job inputs live (default: your OS temp dir)
# PI_JOBS_DIR=
# where per-job status records (and optional raw logs) land (default: ~/.pi-dispatch/logs)
# 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
# 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
# It pins whatever the account default RESOLVES to, and refuses only a value YOUR shell exported that is relative or inside this folder
# That refusal exists because the retention sweep below deletes every .log and .json past its window, and a deployment folder holds triggers.json
# 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
# PI_LOGS_DIR=
# 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)
# PI_CAPTURE_JOB_LOGS=
# default 30; prunes logs older than N days at boot AND on the retention timer below; 0 = keep forever
# PI_LOG_RETENTION_DAYS=
# default 24; how often the three retention sweeps (logs, sandboxes, sessions) re-run while the worker is up
# 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
# 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
# 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
# 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)
# Refused above 168 (one week): setInterval clamps a longer delay to 1ms, and a sweep slower than a week is 0 with extra steps
# PI_SWEEP_INTERVAL_HOURS=
# where a finished run's directory is kept so you can re-open it (default: <PI_JOBS_DIR>/sandboxes, mode 0700)
# `pi-dispatch sandbox <jobId>` starts a fresh container from the same image with the same workspace and NO credentials (docs/sandbox.md)
# A retained directory holds the run's clone plus its prompt.md/event.json -- so issue text. Host-only; never mounted into a job
# PI_SANDBOX_DIR=
# default 24. NOTE: 0 means OFF here -- nothing is retained and cleanup deletes as it always did
# This is the OPPOSITE of PI_LOG_RETENTION_DAYS/PI_SESSIONS_TTL_DAYS, where 0 means keep forever
# 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
# PI_SANDBOX_RETENTION_HOURS=
# default 7; `pi-dispatch sandbox <jobId> --pin` extends THAT run to now + this many days. Still swept afterwards
# PI_SANDBOX_PIN_DAYS=
# default 30; bash's own TMOUT inside a sandbox, so a forgotten shell closes itself; 0 = no idle logout
# 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)
# PI_SANDBOX_IDLE_MINUTES=
# 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)
# Unset = the feature is unavailable and an armed trigger refuses PRE-SPEND rather than running unpersisted and looking like it worked
# 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
# 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
# PI_SESSIONS_DIR=
# default 14; a transcript older than this is not resumed AND is swept at boot and on the retention timer; 0 = keep forever
# Enforced at OPEN as well as at boot: a stale transcript is a live input to a future job, not debris
# PI_SESSIONS_TTL_DAYS=
# default 8388608 (8 MiB); a transcript larger than this is not resumed; 0 = no cap
# Not disk hygiene -- an oversized transcript is a prefill nobody sized PI_MAX_TOKENS for
# PI_SESSION_MAX_BYTES=
# unset/0 = no bound. How old the CONVERSATION may be, read from the session header's own timestamp
# A DIFFERENT CLOCK from PI_SESSIONS_TTL_DAYS, not a finer setting of it: that one reads mtime, which every COMPLETED run refreshes,
# so a lineage that keeps finishing work never expires however old its first turn is. This one measures from the first turn
# A header with no readable timestamp is refused rather than assumed young (reason: conversation-too-old)
# PI_SESSION_MAX_AGE_DAYS=
# unset/0 = no bound. How many times in a row one key may be resumed before the next job starts fresh
# The bound a long lineage actually needs: age and size grow slowly, a chain grows once per run
# 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
# PI_SESSION_MAX_RESUME_CHAIN=
# unset = no bound; 1-100. Refuse a resume when the saved session's context is already this full, e.g. 80
# A SAFETY bound before an economic one: past pi's compaction threshold a resumed job replays a model-written summary of the transcript,
# 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
# 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
# PI_SESSION_MAX_CONTEXT_PCT=
# unset = a run.resume job REFUSES to mint under GITHUB_AUTH_SOURCE=gh, pre-spend
# 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
# 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)
# PI_SESSIONS_ALLOW_GH_SOURCE=
# ABSOLUTE path to the unified triggers.json, read by BOTH worker and receiver (a relative path resolves against the service's WorkingDirectory).
# Unset = cron disabled for the worker; the receiver falls back to ./triggers.json in the folder it starts from (what `pi-dispatch init` scaffolds)
# and refuses to start when neither exists (it holds the label/comment/pull_request trigger config)
# PI_TRIGGERS_FILE=
# The two keys below are the only ones where uncommenting WITHOUT filling in a path is worse than leaving
# them alone: the worker keeps an empty value and refuses to start, rather than treating it as unset.
# `pi-dispatch up` fills them in for you; by hand, write the path or leave the line commented.
# 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
# `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
# PI_PAUSE_WINDOWS_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).
# 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
# `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
# PI_SCOPED_LIMITS_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.
# 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
# PI_SUBSCRIPTIONS_FILE=
# ABSOLUTE path to the runtime settings overlay (default: ~/.pi-dispatch/settings.json); edited by the admin extension, read by the worker per job
# 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
# 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
# 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
# PI_SETTINGS_FILE=
# ABSOLUTE path to the deployment pointer the /dispatch panel reads to find a deployment built elsewhere
# (default: <your pi agent dir>/pi-dispatch-deployment.json). Read by the ADMIN EXTENSION only; the worker and receiver never look at it.
# Your own environment still wins key by key, so this points the panel at a deployment, it does not override one
# PI_DISPATCH_DEPLOYMENT_FILE=
# where `/dispatch insights` writes its HTML artifact (default: under the OS temp dir). Admin extension only. See docs/insights.md
# PI_GRAPH_DIR=
# --- Reuse your existing pi setup in every job (see docs/global-pi-overlay.md) ---
# 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
# PI_GLOBAL_PI_DIR=
# 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.
# 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.
# 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.
# PI_GLOBAL_ALLOW_EXTENSIONS=
# where YOUR pi setup lives on this host (default: ~/.pi/agent). `pi-dispatch import-pi` reads its models.json,
# skills and APPEND_SYSTEM.md from here, and the panel looks here for the deployment pointer.
# It is also read AT JOB TIME: with no provider key in the environment the worker reads this directory's auth.json
# for one (on by default; PI_AUTH_FROM_PI=0 turns it off), so pointing this at the wrong place makes every job
# refuse pre-spend with no credential for the provider. It is the SOURCE that gets staged, never the thing mounted:
# PI_GLOBAL_PI_DIR above is what a job actually sees. Set it if your pi lives somewhere other than your home directory
# PI_CODING_AGENT_DIR=
# 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.
# 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.
# 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.
# PI_PACKAGES_FILE=
# comma-separated extra env var NAMES to forward into the container (e.g. a CUSTOM provider's key). Explicit allowlist, not a pass-through.
# GITHUB_TOKEN/GH_TOKEN are refused here -- the worker mints per-job tokens
# PI_FORWARD_ENV=
# --- Per-trigger vault secrets (docs/secrets.md, issue #225) ---
# A trigger may name references ("secrets": { "STRIPE_KEY": "op://ci/stripe/api-key" }) and the profile that
# resolves them. The worker runs YOUR script once per reference, on the HOST, before the container starts.
# The job receives values; it never holds your manager's credential and cannot enumerate your vault.
# name:/absolute/path pairs, comma separated (each entry splits on its FIRST colon, so a Windows C:\ path parses).
# A resolver is one line: `exec op read --no-newline "$1"`, `exec pass show "$1"`, `exec vault kv get -field=... "$1"`.
# Exit 2 if the reference is wrong, exit 1 if you could not reach your manager (that one retries). Unset = feature off.
# PI_SECRET_PROFILES=
# OS-path-delimited (; on Windows, : elsewhere) directories a PANEL-declared resolver may live in.
# Default empty = fail-closed: `/dispatch secrets add` can declare nothing, and only PI_SECRET_PROFILES above is honoured.
# PI_SECRET_RESOLVER_ROOTS=
# default 10000, per reference. Sits before a paid container and is multiplied by the reference count, so tighter than doctor's 30s.
# PI_SECRET_RESOLVE_TIMEOUT_MS=
# --- Holding a job until something else happens (docs/wait-for.md, issue #230) ---
# A trigger may carry "waitFor": [{ "after": "2026-09-01T09:00:00Z" }, { "profile": "jira" }]. The job is
# enqueued as usual and then HELD: it reserves no budget slot, arms no kill timer, consumes no retry attempt,
# survives a restart, and runs exactly once when every condition clears. An `after` is answered from the
# clock and costs nothing. A `profile` names one of YOUR scripts, which the worker runs on the HOST with the
# job's id-only target as its first argument.
# name:/absolute/path pairs, comma separated (each entry splits on its FIRST colon, so a Windows C:\ path parses). Unset = feature off.
# Map the codes yourself, because a bare pipeline cannot: `s=$(jira issue view "$1" --plain) || exit 1` then
# `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".
# Exit 0 to go, 3 for not yet, 2 if it will NEVER clear (terminal), 1 if you could not tell (held, and counted).
# PI_WAIT_PROFILES=
# default 10000, per check. Sits before a paid container and holds a concurrency slot while it runs.
# PI_WAIT_CHECK_TIMEOUT_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.
# The base cadence; it backs off toward 15 minutes, or toward YOUR value if you set a longer one.
# PI_WAIT_INTERVAL_MS=
# 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.
# PI_WAIT_CHECK_SLOTS=
# default 86400000 (24h). A profile hold terminates here with a named reason: a dependency, unlike a pause window, does not end on its own.
# PI_WAIT_MAX_MS=
# default 2592000000 (30d). The separate, larger ceiling on an `after` instant, which polls nothing while it waits.
# PI_WAIT_AFTER_MAX_MS=
# default 96, per job. Nothing in the spend caps sees a check, so this is the bound that does.
# PI_WAIT_MAX_CHECKS=
# default 5 consecutive "could not tell" answers. What makes a broken check loud in minutes instead of silent for a day.
# PI_WAIT_MAX_FAULTS=
# --- Telling you when a paid job dies (docs/notifications.md, issue #288) ---
# Every free refusal already comments on its issue. This is the other half: when a job that SPENT money
# ends wrong (the 30-minute kill, an in-container budget stop, a final infrastructure failure), the worker
# runs YOUR one command with id-only arguments: <jobId> <outcome> <reason> <host>. Never a title, a body,
# or any payload text. You wire ntfy, Slack or mail yourself in one line; this project ships no transport.
# absolute path to ONE executable. Exec'd directly (an argv array, never a shell), fire and forget,
# at most once per job, and a hook fault can never change a job's outcome. Its exit code is logged
# (`on_failure`) and otherwise unread. Unset = off, byte-identically.
# PI_ON_FAILURE=
# default 10000. SIGTERM at the deadline, SIGKILL 2s later. Bounds a leaked child, not a job: the
# hook runs after the outcome is decided and holds nothing.
# PI_ON_FAILURE_TIMEOUT_MS=
# tear down a scheduler after N consecutive stalls (money backstop)
PI_SCHEDULER_STALL_MAX=2
# --- Container backend: where a job's container is built, and what that place guarantees (docs/backends.md) ---
# which backends this deployment blesses, comma separated (default: local, the docker daemon on this host).
# 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).
# 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.
# 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.
# PI_BACKENDS=
# the minimum every backend above must declare, as property=word pairs: e.g. egress=enforced,nonRoot=asserted
# 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.
# 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.
# 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).
# 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).
# 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.
# `pi-dispatch doctor` prints enforced quietly, asserted as a warning naming who asserts it, and absent as a failure.
# Env-only, deliberately: a bound that can be widened from the surface it bounds is not a bound.
# PI_BACKEND_FLOOR=
# --- Egress policy: what a job container may reach on the network (docs/egress.md) ---
# ON by default. Every job runs on its own --internal Docker network whose only other member is an allowlist
# proxy, with no route off this host, and a job whose policy cannot serve it is refused BEFORE it spends a slot.
# START THE PROXY: docker compose -f deploy/docker-compose.yml --profile egress up -d
# (on the rootless podman backend, `pi-dispatch up` or `pi-dispatch service install` starts it as a Quadlet unit instead)
# Until you do, every job is refused pre-spend (loud, free, and naming that command). The hosts live in
# egress-allowlist.conf next to this file (`pi-dispatch init` writes it, and never overwrites it).
# 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.
# PI_EGRESS=0
# the proxy container the per-job network is built around (default: pi-dispatch-egress-proxy)
# 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.
# PI_EGRESS_PROXY=
# 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)
# PI_DISPATCH_RUN_ROOTS=
# per-hour cap on model-invoked dispatch_run enqueues; 0 disables the tool
# PI_DISPATCH_RUN_PER_HOUR=3
# 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
# PI_DISPATCH_ASCII=
# max follow-up chain depth from a job's /outbox; 0 = chaining kill-switch
# PI_CHAIN_DEPTH_MAX=1
# max request-<n>.json collected per completed job
# PI_CHAIN_MAX_PER_JOB=2
# --- GitHub trigger (receiver + worker auth) ---
# Webhook receiver. Required only when your triggers name github (or GITHUB_AUTH_SOURCE is set): every forge arm is
# conditional, so a gitlab/forgejo/azure-only deployment needs no value here and answers 404 on the github endpoint
WEBHOOK_SECRET=
RECEIVER_PORT=3000
RECEIVER_BIND=0.0.0.0
# how long a BOOTING receiver (serve and poll alike, every configured forge) keeps retrying an
# identity lookup that failed TRANSIENTLY -- a 502, a refused connection, the forge restarting --
# before exiting 1 for the supervisor to start another window. Default 600, floored at 60: a
# positive value below the floor is raised to it, while 0, a negative, a fraction or junk refuses
# at boot. Retries run IN-PROCESS (5s between attempts, backing off to 30s), so the bound is the
# same under systemd, launchd and nssm -- and the floor keeps systemd's StartLimitBurst crash-loop
# bound meaningful: a retrying boot exits at most once per window, never five times in a minute.
# A determinate refusal (a bad token, an untrusted CA) still exits 2 immediately, never retried.
# RECEIVER_IDENTITY_RETRY_SECONDS=
# Worker GitHub auth: source is gh | pat | app (default gh)
# gh = your full login scopes reach token-carrying jobs (doctor warns and names them); pat/app = narrower
GITHUB_AUTH_SOURCE=gh
# For GITHUB_AUTH_SOURCE=pat: a repo-scoped, short-expiry fine-grained PAT
GITHUB_PAT=
# which variable above actually holds the PAT. Default GITHUB_PAT; set this only if your
# secrets manager insists on its own name and you would rather not copy the value to a second key.
# The NAME is not checked against anything: whatever you put here is read verbatim, so a typo
# reads an empty variable and the worker refuses at boot naming the name you chose. Pointing it
# at a variable that holds something else (GITHUB_APP_PRIVATE_KEY, say) is the mistake worth
# knowing about, because nothing stops it and the PAT path would then send that value to GitHub.
# GITHUB_PAT_VAR=
# For GITHUB_AUTH_SOURCE=app (optional; required for multi-tenant)
# `pi-dispatch setup github` fills all three in one browser click (App Manifest flow) and writes the PEM 0600
GITHUB_APP_ID=
GITHUB_APP_INSTALLATION_ID=
GITHUB_APP_PRIVATE_KEY_PATH=
# Or supply the key itself instead of a path, for a deployment whose env comes from a secrets manager
# (docs/secrets.md). Set exactly ONE of these two; both set refuses at boot. Real newlines or `\n`
# escapes both work. Never list it in PI_FORWARD_ENV: it mints tokens for every repo the App is on.
GITHUB_APP_PRIVATE_KEY=
# --- Polling ingest, instead of a webhook (GitHub only) --- issue #282
# `pi-dispatch-receiver poll` fetches issue events, comments and pull requests over TLS with your own
# credential, so a deployment with no public URL, no DNS and no tunnel still fires triggers. Same gates and
# same queue as the webhook path; about a minute of latency instead of a second. Nothing here is read by
# `pi-dispatch-receiver serve`, and no WEBHOOK_SECRET is needed to poll: there is no inbound delivery to
# verify, because the poller originates every request itself. See docs/polling.md.
# WHICH repos to watch: comma-separated owner/name (e.g. acme/web,acme/api). Duplicates are dropped.
# Each entry must be exactly owner/name -- one slash, no spaces -- or the receiver refuses at boot naming the bad entry.
# Leave it UNSET only with GITHUB_AUTH_SOURCE=app: the poller then lists the App installation's own repos and
# re-lists every tenth cycle, so installing the App on a new repo starts polling it without an edit here.
# Unset under any other auth source is a boot refusal, deliberately: a PAT names no repo set, and a poller
# watching nothing looks exactly like a poller that is working.
# POLL_REPOS=
# seconds between cycles. Default 60, floored at 30: a positive value BELOW the floor is raised to it,
# while 0, a negative, a fraction or junk still refuses at boot.
# GitHub asks pollers to respect its own x-poll-interval hint, which is honored as a MINIMUM when it arrives,
# 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.
# POLL_INTERVAL_SECONDS=
# --- GitLab trigger (receiver + worker auth) ---
# Optional. Set these only to service GitLab projects; leaving GITLAB_TOKEN unset means no /gitlab
# endpoint exists at all, rather than one that answers 401. See docs/gitlab.md.
#
# A PROJECT access token with the `api` scope and the Developer role or above. `api` is the narrowest
# scope that can post a note -- GitLab offers no contents-vs-issues split -- so scope it to one project
# and rotate it (CONST-TOKEN-SCOPED-PER-JOB). A GROUP token reaches every project in the group.
GITLAB_TOKEN=
# accepts exactly one value, "pat", which is also the default, so there is nothing to set here.
# It exists to REFUSE the wrong assumption rather than to offer a choice: GITHUB_AUTH_SOURCE has
# three sources, and an operator who reasons by symmetry and writes app here gets a sentence at
# boot saying GitLab has no App equivalent, instead of a knob that is silently ignored.
# The refusal needs GITLAB_TOKEN to be set: with no token there is no GitLab to configure, the
# whole block is skipped, and a stray app here really is ignored. Same for the two below.
# FORGEJO_AUTH_SOURCE and AZURE_AUTH_SOURCE are the same variable for the same reason.
# GITLAB_AUTH_SOURCE=
# Your instance root. Only for self-hosted GitLab.
GITLAB_URL=https://gitlab.com
# How the receiver verifies a delivery. REQUIRED once any GITLAB_* variable is set, and deliberately not
# defaulted -- the two are not equally strong, so it must be a choice somebody made:
# signature HMAC-SHA256 over the body. Needs GitLab 19.0+. Use this if you can.
# token a shared-secret compare. Works on any version, and proves nothing about the body.
GITLAB_WEBHOOK_MODE=
# The signing token (signature mode) or the secret token (token mode) from the webhook's settings.
GITLAB_WEBHOOK_SECRET=
# --- Forgejo / Gitea trigger (receiver + worker auth) --- issue #61
# Forgejo's webhook transport is byte-compatible with GitHub's (HMAC-SHA256 over the raw body,
# X-Hub-Signature-256), so there is no mode to choose here: there is one mechanism and it is the strong one.
# Point the webhook at /forgejo -- NOT at / -- because Forgejo also sends X-GitHub-* headers, so the path is
# the only thing that can tell the two apart, and a sender must never choose which gate it faces.
FORGEJO_URL=
# A REPOSITORY-scoped token ("Specific repositories") carrying only write:repository and write:issue.
# Narrower than GitLab's equivalent -- Forgejo has no all-or-nothing `api` scope. What it cannot do is
# expire: there is no App or installation token, so rotation is the whole mitigation
# (CONST-TOKEN-SCOPED-PER-JOB).
FORGEJO_TOKEN=
# only "pat", the default. See GITLAB_AUTH_SOURCE above for why it exists at all.
# FORGEJO_AUTH_SOURCE=
# The harness account's NUMERIC id. Required when the token above is repository-scoped, because such a
# token may not carry read:user and therefore cannot call GET /user. The receiver refuses to boot without an
# identity from one source or the other: the bot-loop guard compares against it, and an unresolved identity
# never matches -- so it would fail open silently and the harness's own comments would start more jobs.
FORGEJO_BOT_ID=
FORGEJO_WEBHOOK_SECRET=
# --- Azure DevOps trigger (receiver + worker auth) --- issue #43
# READ docs/azure-devops.md BEFORE ENABLING. Azure Service Hooks offer no HMAC of any kind: the credential
# proves the sender knew a secret and covers no bytes, there is no delivery-id header (the dedup key comes
# from the body), and there is no signed timestamp and so no replay window. OQ-015 records the residual.
# HTTPS is not optional here -- over plain HTTP the credential is on the wire in base64.
AZURE_ORG_URL=
# A PAT for a DEDICATED identity. Azure gives you a real expiry and cannot scope below the organization
# (vso.code_write reaches every repo in the org), so the bound comes from that identity's per-repository
# permissions in Project Settings -- not from the token's scopes. It also needs vso.graph, to resolve the
# actor's project membership before a job may be enqueued.
AZURE_TOKEN=
# only "pat", the default. See GITLAB_AUTH_SOURCE above for why it exists at all.
# AZURE_AUTH_SOURCE=
# REQUIRED once any AZURE_* variable is set, and deliberately not defaulted: both modes are shared-secret
# compares that cover no bytes, so which header carries the secret must be a choice somebody made.
# basic -- Authorization: Basic <base64>, the credential you set on the subscription
# header -- one custom header, named below
AZURE_WEBHOOK_MODE=
# For basic: the base64 of "user:password" exactly as the subscription sends it.
AZURE_WEBHOOK_SECRET=
# Required only when AZURE_WEBHOOK_MODE=header.
AZURE_WEBHOOK_HEADER=
# --- What is deliberately NOT a key in this file --- issue #282
# The rule, and it is checked by a test (worker/test/env-docs.test.mjs): every environment variable this
# project's loaders read is either a key above, or named here with the reason it cannot be one. A variable
# that is read and appears in neither is the failure this section exists to prevent, because an operator has
# no way to discover it and no way to find out that setting it did nothing.
#
# The worker's own inputs to a job container: PI_JOB_ID, PI_FLOW, PI_COMMAND, PI_PACKAGES, PI_SESSION_FILE,
# PI_OFFLINE, and the three PLAYWRIGHT_ names. The container's environment is BUILT, not inherited: the worker
# passes exactly the map it composed, so a value set here never reaches a job to be overridden in the first
# place. Several of them are also conditional, present only when the job has a flow, a command, staged
# packages or a session to resume. These are the container's side of INT-CONTAINER-RUNTIME-CONTRACT
# (specs/interfaces.md, docs/job-image.md), and run.secrets refuses the names at load so a trigger cannot
# bind one either. PI_FORWARD_ENV is NOT checked against these names, and it is applied after the worker's
# own map, so naming one there does override it. Not everything is: run.secrets, the egress variables and
# the minted forge token are all written later still and win over a forwarded value. A real edge, not a
# recommendation.
#
# PI_ENV_SETUP is an argument to `pi-dispatch service render|install --env-setup <absolute path>`, never a
# key. The service wrappers capture it BEFORE they source ./.env, precisely so that anything able to write
# this file cannot name a script the wrapper will run as the worker. A line here is honored by nothing, and
# that is the point rather than an oversight (docs/secrets.md, REQ-DEPLOYMENT-BOOTSTRAP).
#
# PI_RETRY_MAX (default 2) and PI_RETRY_BASE_MS (default 2000) are read by the runner INSIDE the container,
# and nothing on the host writes them, so a line here sets them on this machine and never reaches a job.
# PI_FORWARD_ENV is what carries a host value into a container, and is how you would actually change them.
#
# Provider key names are pi's, not ours. The worker asks pi which variable your PI_PROVIDER expects, so the
# provider block at the top of this file lists the common ones as examples and is not the whole set: pi
# supports around thirty providers and the answer travels with pi rather than with this file.
#
# Read from the surrounding system, not from a deployment: TMPDIR and TEMP (where the default job, log,
# graph and settings paths go), USER (the account a rendered service unit runs as), and TERM, SSH_CONNECTION,
# SSH_TTY, DISPLAY, WAYLAND_DISPLAY (how the panel decides whether it can open a browser for you).
#
# PI_DISPATCH_REQUIRE_LOADER_TESTS, PI_DISPATCH_REQUIRE_WORKER_TESTS, PI_DISPATCH_REQUIRE_RECEIVER_TESTS and
# VALKEY_TEST_URL turn locally skipped integration tests into required ones. They are read by the test files
# themselves and by CI, never by the worker, the receiver or the panel.