@edgehero/pi-dispatch 2.1.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/.env.example +41 -5
  2. package/README.md +11 -5
  3. package/deploy/docker-compose.yml +12 -0
  4. package/deploy/egress-proxy.conf +28 -3
  5. package/deploy/pi-dispatch-egress-proxy.container +8 -2
  6. package/package.json +8 -1
  7. package/src/allocation.mjs +731 -0
  8. package/src/backends.mjs +243 -0
  9. package/src/budget.mjs +40 -4
  10. package/src/cli.mjs +222 -11
  11. package/src/config.mjs +126 -5
  12. package/src/daemon-facts.mjs +3 -0
  13. package/src/deployment-venue.mjs +1 -0
  14. package/src/doctor.mjs +2261 -203
  15. package/src/dollar-budget.mjs +373 -0
  16. package/src/dollar-fingerprint.mjs +83 -0
  17. package/src/egress-cli.mjs +316 -0
  18. package/src/egress-proxy-state.mjs +35 -5
  19. package/src/egress.mjs +12 -0
  20. package/src/env-allowlist.mjs +107 -6
  21. package/src/env-file.mjs +194 -25
  22. package/src/envelope.mjs +413 -0
  23. package/src/exit-code.mjs +22 -0
  24. package/src/fleet-lease.mjs +85 -25
  25. package/src/get-token.mjs +16 -5
  26. package/src/git-dirty.mjs +67 -0
  27. package/src/github-app-setup.mjs +6 -3
  28. package/src/github-host.mjs +5 -3
  29. package/src/identity.mjs +2 -1
  30. package/src/image-preflight.mjs +98 -24
  31. package/src/image-ref.mjs +37 -0
  32. package/src/import-pi.mjs +4 -2
  33. package/src/index.mjs +407 -62
  34. package/src/init.mjs +18 -0
  35. package/src/job-id.mjs +26 -3
  36. package/src/live-probes.mjs +24 -9
  37. package/src/model-catalog.mjs +297 -0
  38. package/src/model-endpoints.mjs +649 -0
  39. package/src/model-ref.mjs +151 -0
  40. package/src/models-json.mjs +262 -0
  41. package/src/money.mjs +144 -0
  42. package/src/octokit-log.mjs +65 -0
  43. package/src/outbox-plan.mjs +218 -0
  44. package/src/outbox.mjs +29 -9
  45. package/src/output-cap.mjs +157 -0
  46. package/src/pause-windows.mjs +81 -2
  47. package/src/pi-model-loader.mjs +77 -0
  48. package/src/podman-stack.mjs +16 -3
  49. package/src/portfolio-snapshot.mjs +304 -0
  50. package/src/prepare-local.mjs +247 -12
  51. package/src/prepare.mjs +35 -3
  52. package/src/priorities.mjs +569 -0
  53. package/src/processor.mjs +599 -170
  54. package/src/project-id.mjs +17 -0
  55. package/src/projects.mjs +238 -0
  56. package/src/provider-steering.mjs +179 -65
  57. package/src/queue.mjs +111 -6
  58. package/src/reserved-env.mjs +30 -0
  59. package/src/run-container.mjs +59 -5
  60. package/src/run-history.mjs +379 -24
  61. package/src/run-mirror.mjs +30 -0
  62. package/src/runtime-settings.mjs +104 -9
  63. package/src/schedules.mjs +33 -1
  64. package/src/scoped-limits.mjs +447 -27
  65. package/src/service.mjs +15 -4
  66. package/src/session-store.mjs +131 -6
  67. package/src/start.mjs +528 -40
  68. package/src/triggers-file.mjs +65 -4
  69. package/src/triggers.mjs +135 -7
  70. package/src/up.mjs +308 -34
  71. package/src/valkey-endpoint.mjs +3 -2
@@ -0,0 +1,316 @@
1
+ /**
2
+ * `pi-dispatch egress render` (issue #503, INT-MODEL-ENDPOINTS-FILE-CONTRACT, INT-EGRESS-POLICY-CONTRACT): writes the
3
+ * egress proxy's include, `model-endpoints.conf` in the deployment folder, from the declared model endpoints, then
4
+ * prints the command that reloads the proxy. It never reloads the proxy itself: the worker does not manage the proxy's
5
+ * lifecycle, and a reload is the operator's step.
6
+ *
7
+ * IN PLACE, and that is the measured part (issue #503, Docker 29.1.3 and rootless and rootful Podman 4.9.3 and 5.8.1 on
8
+ * Linux, 2026-09-30). The include is a single-file bind mount, and such a mount holds the file's inode. A temp file
9
+ * renamed over the path is a new inode the running container never sees, and `squid -k reconfigure` then reloads the
10
+ * OLD rules and says nothing. So the existing file is opened without O_CREAT, truncated and written, and the inode the
11
+ * proxy holds is the one that changes. The render is built and checked in memory first, so a refused declaration never
12
+ * truncates anything.
13
+ *
14
+ * Refused at the path: a symlink (a write through it would land wherever it points, and the mount holds the target,
15
+ * not the link), a directory (Docker creates one for a missing bind source, and squid then reads it as empty with no
16
+ * warning, measured on Docker Desktop), anything else that is not a regular file, and nothing at all. A missing file
17
+ * is not created: `pi-dispatch init` scaffolds it, and a render run from the wrong folder must say so rather than
18
+ * write a file no proxy mounts and then print a reload that changes nothing.
19
+ *
20
+ * The reload is `squid -k reconfigure` through `docker exec` or `podman exec`, on every venue (measured the same day):
21
+ * it re-reads the include and the allowlist in well under a second, keeps the same squid, its networks and their
22
+ * addresses, and keeps every open tunnel, so a running job is not cut off. A restart kills every tunnel, takes about
23
+ * 11 s with this image, and changes the proxy's addresses on Podman.
24
+ */
25
+ import { closeSync, constants as fsConstants, fstatSync, ftruncateSync, lstatSync, openSync, readFileSync, fsyncSync, writeSync, existsSync } from "node:fs";
26
+ import { homedir } from "node:os";
27
+ import { join, resolve } from "node:path";
28
+ import { venuesOf } from "./backends.mjs";
29
+ import { DEFAULT_VALKEY_URL } from "./config.mjs";
30
+ import { deploymentVenueEnv } from "./deployment-venue.mjs";
31
+ import { egressArmed, egressProxyName } from "./egress.mjs";
32
+ import { rulesIncludeEndpoints } from "./egress-proxy-state.mjs";
33
+ import { proxyConfCopyPath } from "./podman-stack.mjs";
34
+ import { readEnvAssignments } from "./env-file.mjs";
35
+ import { MODEL_ENDPOINTS_INCLUDE_NAME, loadModelEndpoints, renderEndpointsInclude } from "./model-endpoints.mjs";
36
+ import { valkeyClientContext } from "./valkey-endpoint.mjs";
37
+
38
+ const USAGE = `pi-dispatch egress render
39
+ write model-endpoints.conf in this deployment folder from model-endpoints.json (PI_MODEL_ENDPOINTS_FILE
40
+ overrides), in place, then print the command that reloads the egress proxy. It never reloads the proxy itself.
41
+ `;
42
+
43
+ /** The reload, for one runtime and one proxy name: a reconfigure, never a restart (see the header). */
44
+ export function reloadCommand(bin, proxy) {
45
+ return `${bin} exec ${proxy} squid -k reconfigure`;
46
+ }
47
+
48
+ /**
49
+ * PI_MODEL_ENDPOINTS_FILE as the service reads it: this shell's value where it sets one, else the deployment `.env`'s.
50
+ * Both set differently is refused, since the service runs the file's and a render of the other file would write rules
51
+ * the worker does not believe in. A `.env` line this reader cannot vouch for is refused too. Returns `{ value }`
52
+ * (null for unset) or `{ error }`.
53
+ */
54
+ export function endpointsFileSetting({ env, cwd, platform = process.platform, readEnv = (p) => readFileSync(p), exists = existsSync }) {
55
+ const envPath = join(cwd, ".env");
56
+ const shell = typeof env.PI_MODEL_ENDPOINTS_FILE === "string" ? env.PI_MODEL_ENDPOINTS_FILE : null;
57
+ let file = null;
58
+ if (exists(envPath)) {
59
+ let text;
60
+ try {
61
+ text = String(readEnv(envPath));
62
+ } catch (err) {
63
+ return { error: `${envPath} could not be read (${err?.code ?? err?.message}), so where the declared model endpoints are is not known` };
64
+ }
65
+ const loader = platform === "linux" ? "systemd" : platform === "win32" ? "cmd" : "shell";
66
+ const found = readEnvAssignments(text, ["PI_MODEL_ENDPOINTS_FILE"], { loader }).PI_MODEL_ENDPOINTS_FILE;
67
+ if (found) {
68
+ if (!found.plain) return { error: `${envPath} line ${found.line} sets PI_MODEL_ENDPOINTS_FILE in a way this command cannot read the same as the service does: write it as a plain KEY=value line` };
69
+ file = found.value;
70
+ }
71
+ }
72
+ // Compared as the paths they name, resolved against the deployment folder as the loader resolves them, so
73
+ // `./x.json` and `x.json` agree.
74
+ if (shell !== null && file !== null && (shell === "" || file === "" ? shell !== file : resolve(cwd, shell) !== resolve(cwd, file))) {
75
+ return { error: `PI_MODEL_ENDPOINTS_FILE is ${JSON.stringify(shell)} in this shell and ${JSON.stringify(file)} in ${envPath}, and the service reads the file's. Make them agree, or unset it in this shell` };
76
+ }
77
+ // Set in this shell alone: the service does not see it, so its worker reads another file than this render did.
78
+ const note = shell !== null && file === null ? `PI_MODEL_ENDPOINTS_FILE comes from this shell only, and the service reads ${envPath}, which does not set it: put it in ${envPath}, or the worker reads another file than this render did` : null;
79
+ return { value: shell ?? file, note };
80
+ }
81
+
82
+ /** VALKEY_URL as the service reads it (the `.env`'s, else this shell's, else the default), for the port refusal. */
83
+ function serviceValkeyUrl({ env, cwd }) {
84
+ const context = valkeyClientContext({ env, cwd });
85
+ return context?.url?.file ?? context?.url?.environment ?? DEFAULT_VALKEY_URL;
86
+ }
87
+
88
+ /**
89
+ * What is at the include's path, by lstat (a symlink is judged as itself): `{ ok: true }` for a regular file, else
90
+ * `{ error }` naming what is there and what to do.
91
+ */
92
+ export function includePathProblem(path, { lstat = lstatSync } = {}) {
93
+ let st;
94
+ try {
95
+ st = lstat(path);
96
+ } catch (err) {
97
+ if (err?.code === "ENOENT") return `${path} does not exist: \`pi-dispatch init\` in the deployment folder writes it (create-only), and the egress proxy mounts it from there. Run this command from that folder`;
98
+ return `${path} could not be read (${err?.code ?? err?.message})`;
99
+ }
100
+ if (st.isSymbolicLink()) return `${path} is a symlink: the proxy mounts the file itself, so a render through a link could write a file the proxy does not read. Replace the link with a regular file (\`pi-dispatch init\` writes one when nothing is there)`;
101
+ if (st.isDirectory()) return `${path} is a directory, not a file (a runtime creates one when it mounts a path that does not exist), and squid reads it as no rules at all. Remove the directory, then \`pi-dispatch init\` writes the file`;
102
+ if (!st.isFile()) return `${path} is not a regular file`;
103
+ return null;
104
+ }
105
+
106
+ /**
107
+ * Write `text` over the regular file at `path` IN PLACE: no O_CREAT, so nothing new is ever made here; O_NOFOLLOW where
108
+ * the platform has it, so a link swapped in after the check is refused by the kernel; O_TRUNC, so the inode the
109
+ * proxy's mount holds is the one that changes. Then fsync. Throws on any failure.
110
+ */
111
+ export function writeInPlace(path, text, fs = { openSync, fstatSync, ftruncateSync, writeSync, fsyncSync, closeSync }) {
112
+ // Opened WITHOUT O_TRUNC, so a refusal below leaves the file as it was; truncated only once it is judged.
113
+ const flags = fsConstants.O_WRONLY | (fsConstants.O_NOFOLLOW ?? 0);
114
+ const fd = fs.openSync(path, flags);
115
+ try {
116
+ const st = fs.fstatSync(fd);
117
+ if (!st.isFile()) throw Object.assign(new Error(`${path} is not a regular file`), { untouched: true });
118
+ // A second name for this inode (a hard link) is a file the render would also rewrite, wherever it is.
119
+ if (st.nlink > 1) throw Object.assign(new Error(`${path} has ${st.nlink} hard links: a write in place would change every one of them. Replace it with a file of its own`), { untouched: true });
120
+ fs.ftruncateSync(fd, 0);
121
+ try {
122
+ const bytes = Buffer.from(text, "utf8");
123
+ let off = 0;
124
+ while (off < bytes.length) off += fs.writeSync(fd, bytes, off, bytes.length - off);
125
+ fs.fsyncSync(fd);
126
+ } catch (e) {
127
+ // A PARTIAL include is not safe: a prefix ending in the middle of an endpoint's lines can be an allow without
128
+ // its port ACL, which opens every port of that host (measured). An EMPTY include is valid and closes every
129
+ // endpoint, so a failed write leaves it empty, never half written.
130
+ try {
131
+ fs.ftruncateSync(fd, 0);
132
+ fs.fsyncSync(fd);
133
+ e.emptied = true;
134
+ } catch {
135
+ e.emptied = false;
136
+ }
137
+ throw e;
138
+ }
139
+ } finally {
140
+ fs.closeSync(fd);
141
+ }
142
+ }
143
+
144
+ /**
145
+ * The reload lines for this deployment's venues: docker's for the docker venue (Docker Engine, Docker Desktop, and
146
+ * rootful Podman through its Docker API, all reached as `docker`), podman's for the native rootless one. Both when
147
+ * the deployment runs both. The proxy's name is PI_EGRESS_PROXY's when it names the operator's own proxy, which then
148
+ * has to mount the include itself.
149
+ */
150
+ export function reloadLines(env) {
151
+ const venues = venuesOf(env);
152
+ const proxy = egressProxyName(env);
153
+ const lines = [];
154
+ if (venues.localUsed) lines.push(reloadCommand("docker", proxy));
155
+ if (venues.podmanUsed) lines.push(reloadCommand("podman", proxy));
156
+ return { lines, proxy };
157
+ }
158
+
159
+ /**
160
+ * The verb. `deps` are seams for tests: `cwd`, `env`, `platform`, `out`, `err`, `fsRead` (the include's current text),
161
+ * `lstat`, `write` (the in-place writer), `load` (the endpoints loader), `venueEnv` (the deployment venue resolver).
162
+ */
163
+ export async function runEgress(argv = [], deps = {}) {
164
+ const {
165
+ env = process.env,
166
+ cwd = process.cwd(),
167
+ platform = process.platform,
168
+ out = (s) => process.stdout.write(s),
169
+ err = (s) => process.stderr.write(s),
170
+ lstat = lstatSync,
171
+ readInclude = (p) => readFileSync(p, "utf8"),
172
+ write = writeInPlace,
173
+ load = loadModelEndpoints,
174
+ readEnv = (p) => readFileSync(p),
175
+ exists = existsSync,
176
+ venueEnv = (args) => deploymentVenueEnv(args),
177
+ valkeyUrl = serviceValkeyUrl,
178
+ home = homedir(),
179
+ readRules = (p, enc) => readFileSync(p, enc),
180
+ } = deps;
181
+ const sub = argv[0];
182
+ if (sub === undefined || sub === "--help" || sub === "-h" || sub === "help") {
183
+ out(USAGE);
184
+ return sub === undefined ? 1 : 0;
185
+ }
186
+ if (sub !== "render") {
187
+ err(`pi-dispatch egress: unknown subcommand ${JSON.stringify(sub)}\n${USAGE}`);
188
+ return 1;
189
+ }
190
+ if (argv.length > 1) {
191
+ err(`pi-dispatch egress render takes no argument (got ${argv.slice(1).map((a) => JSON.stringify(a)).join(" ")})\n`);
192
+ return 1;
193
+ }
194
+ const setting = endpointsFileSetting({ env, cwd, platform, readEnv, exists });
195
+ if (setting.error) {
196
+ err(`✗ ${setting.error}\n`);
197
+ return 1;
198
+ }
199
+ if (setting.note) out(`⚠ ${setting.note}\n`);
200
+ const path = join(cwd, MODEL_ENDPOINTS_INCLUDE_NAME);
201
+ // Validated and rendered in memory BEFORE the include is touched: a refused declaration leaves the rules in force.
202
+ let text;
203
+ let endpoints;
204
+ try {
205
+ endpoints = load({ modelEndpointsFile: setting.value, valkeyUrl: valkeyUrl({ env, cwd }) }, { cwd });
206
+ text = renderEndpointsInclude(endpoints);
207
+ } catch (e) {
208
+ err(`✗ ${e?.message ?? e}\n${exists(path) ? `${MODEL_ENDPOINTS_INCLUDE_NAME} was not changed.` : `Nothing was written.`}\n`);
209
+ return 1;
210
+ }
211
+ const problem = includePathProblem(path, { lstat });
212
+ if (problem) {
213
+ err(`✗ ${problem}\n`);
214
+ return 1;
215
+ }
216
+ let current = null;
217
+ try {
218
+ current = String(readInclude(path));
219
+ } catch {
220
+ // Unreadable reads as changed: the write below then says what is wrong.
221
+ }
222
+ const venue = venueEnv({ env, fs: { existsSync: exists, readFileSync: readEnv }, envPath: join(cwd, ".env"), platform, command: "egress" });
223
+ const unchanged = current === text;
224
+ if (unchanged) {
225
+ out(`✓ ${path} already matches the declared model endpoints: nothing written.\n`);
226
+ } else {
227
+ try {
228
+ write(path, text);
229
+ } catch (e) {
230
+ const state = e?.untouched ? "It was not changed" : e?.emptied ? "It was EMPTIED rather than left half written, which closes every declared endpoint until a render succeeds" : "It may be partly written";
231
+ err(`✗ could not write ${path} in place (${e?.message ?? e?.code ?? e}). ${state}: fix the cause and run this command again\n`);
232
+ return 1;
233
+ }
234
+ out(`✓ wrote ${path} (in place)\n`);
235
+ }
236
+ if (venue.error) {
237
+ out(`The reload depends on the venue, which could not be read: ${venue.error}\nOn Docker: ${reloadCommand("docker", "pi-dispatch-egress-proxy")}\nOn rootless Podman: ${reloadCommand("podman", "pi-dispatch-egress-proxy")}\n`);
238
+ return 0;
239
+ }
240
+ let armed = true;
241
+ try {
242
+ armed = egressArmed(venue.env);
243
+ } catch {
244
+ // A malformed PI_EGRESS is the worker's boot failure, and doctor's to report; it reads as armed here.
245
+ }
246
+ if (!armed) {
247
+ out("The egress policy is off (PI_EGRESS=0), so no proxy reads this file now. It takes effect once the policy is on and the proxy starts.\n");
248
+ return 0;
249
+ }
250
+ const { lines, proxy } = reloadLines(venue.env);
251
+ out(`${unchanged ? "If the proxy started before it last changed, reload it" : "Reload the egress proxy so it reads the new rules"} (no restart; open tunnels and running jobs are kept):\n${lines.map((l) => ` ${l}`).join("\n")}\n`);
252
+ if (proxy !== "pi-dispatch-egress-proxy") out(`${proxy} is your own proxy (PI_EGRESS_PROXY): it must mount ${path} at /etc/pi-dispatch/model-endpoints.conf and include it, as the shipped rules do.\n`);
253
+ // The one state the reload cannot fix (issue #503): endpoints declared, and the rules the proxy runs predate the include.
254
+ if (proxy === "pi-dispatch-egress-proxy" && endpoints.length > 0) {
255
+ const venues = venuesOf(venue.env);
256
+ for (const v of [...(venues.localUsed ? ["docker"] : []), ...(venues.podmanUsed ? ["podman"] : [])]) {
257
+ const rules = runningRulesPath(v, { cwd, home });
258
+ // No rules file at all is not that state: init or `service install` writes the current rules, include and all.
259
+ if (exists(rules) && !rulesFileIncludes(rules, { readFileSync: readRules })) out(`⚠ ${rulesPredateEndpointsLine(v)}\n`);
260
+ }
261
+ }
262
+ return 0;
263
+ }
264
+
265
+ /**
266
+ * The rules file the shipped proxy runs, per venue (issue #503): docker's mounts the deployment folder's
267
+ * `deploy/egress-proxy.conf`; the rootless podman venue's mounts the account-owned copy `service install` writes.
268
+ */
269
+ export function runningRulesPath(venue, { cwd, home }) {
270
+ return venue === "podman" ? proxyConfCopyPath(home) : join(cwd, "deploy/egress-proxy.conf");
271
+ }
272
+
273
+ /** Whether the rules file at `path` includes the model endpoints' file. Unreadable or absent reads as no. */
274
+ export function rulesFileIncludes(path, fs) {
275
+ try {
276
+ return rulesIncludeEndpoints(String(fs.readFileSync(path, "utf8")));
277
+ } catch {
278
+ return false;
279
+ }
280
+ }
281
+
282
+ /**
283
+ * THE ONE LINE for endpoints declared under rules that predate #503 (issue #503's governing rule): `up`, `doctor` and
284
+ * `egress render` print it alike. Replacing or reloading the proxy fixes nothing here, since its rules do not read the
285
+ * include; the rules refresh does, and it brings the proxy's third mount with it.
286
+ */
287
+ export function rulesPredateEndpointsLine(venue) {
288
+ return venue === "podman"
289
+ ? "model endpoints are declared, but the podman proxy's rules (~/.config/pi-dispatch/egress-proxy.conf) predate #503 and do not include model-endpoints.conf, so the endpoints stay unreachable until the rules are refreshed: `pi-dispatch service install --force`"
290
+ : "model endpoints are declared, but deploy/egress-proxy.conf predates #503 and does not include model-endpoints.conf, so the endpoints stay unreachable until the rules are refreshed: `pi-dispatch up`, and accept the refresh";
291
+ }
292
+
293
+ /**
294
+ * Whether model endpoints are declared for this deployment, read as the service reads them. A declaration that does
295
+ * not load counts as none here: doctor names why it does not load, and the worker refuses it.
296
+ */
297
+ export function endpointsDeclaredIn({ env, cwd, fs, platform = process.platform }) {
298
+ return declaredEndpointsIn({ env, cwd, fs, platform }).length > 0;
299
+ }
300
+
301
+ /**
302
+ * The declared model endpoints for this deployment, read as the service reads them (the same rule as
303
+ * `endpointsDeclaredIn`, which counts them): `[]` for none, and for a declaration that does not load, which doctor's
304
+ * boot-file line names. Doctor's endpoint rows (issue #503) are built from this list and from nothing else.
305
+ */
306
+ export function declaredEndpointsIn({ env, cwd, fs, platform = process.platform, valkeyUrl = null }) {
307
+ try {
308
+ const setting = endpointsFileSetting({ env, cwd, platform, readEnv: (p) => fs.readFileSync(p), exists: (p) => fs.existsSync(p) });
309
+ if (setting.error) return [];
310
+ // `valkeyUrl` null skips the queue-port refusal; doctor's keyless line passes the service's (issue #503), so an
311
+ // endpoint on the queue's port, which refuses the worker's boot, is not called keyless.
312
+ return loadModelEndpoints({ modelEndpointsFile: setting.value, valkeyUrl }, { cwd, readFileSync: (p, enc) => fs.readFileSync(p, enc), existsSync: (p) => fs.existsSync(p) });
313
+ } catch {
314
+ return [];
315
+ }
316
+ }
@@ -12,7 +12,14 @@
12
12
  * of it, or a "present" that leaves it running, keeps that policy. Compared: the image's digest, the entrypoint and
13
13
  * command against the pinned image's own (read from its registry config, identical on every platform in the
14
14
  * manifest list), and the mounts: the two bind sources against this folder's `deploy/egress-proxy.conf` and
15
- * `egress-allowlist.conf`, the image's two anonymous volumes allowed, anything else stale.
15
+ * `egress-allowlist.conf`, the image's two anonymous volumes allowed, anything else stale. A third bind, of
16
+ * `model-endpoints.conf` (issue #503), must be this folder's file when it is there. When it is not there, the proxy
17
+ * is still current (one made before #503 mounts two files) unless this folder's rules `include` the file: squid
18
+ * refuses to start without it, so that proxy's next restart would crash-loop. That is the one rule, and it is the
19
+ * only thing that makes a missing third mount drift. Endpoints declared under rules without the include are NOT
20
+ * drift: replacing the proxy would cut every tunnel and change nothing, since its rules still lack the include.
21
+ * That state is the rules refresh's to fix, and `up`, `doctor` and `egress render` name it alike
22
+ * (`rulesPredateEndpointsLine` in egress-cli.mjs).
16
23
  *
17
24
  * MEASURED (Docker Engine 29.8.1, compose 5.5.1, rootful, gate round 2; pinned in egress-proxy-state.test.mjs): a proxy
18
25
  * made by `docker compose --profile egress up -d` and one made by `up`'s argv carry the pinned reference exactly as
@@ -52,9 +59,19 @@ const VM_PREFIXES = Object.freeze(["/host_mnt/", "/run/desktop/mnt/host/"]);
52
59
  export const PROXY_STATE_FORMAT =
53
60
  '--format={"status":{{json .State.Status}},"health":{{if .State.Health}}{{json .State.Health.Status}}{{else}}"none"{{end}},"image":{{json .Config.Image}},"entrypoint":{{json .Config.Entrypoint}},"cmd":{{json .Config.Cmd}},"mounts":{{json .Mounts}},"networks":{{json .NetworkSettings.Networks}}}';
54
61
 
55
- /** Where the proxy's two files are mounted inside it (compose's and `up`'s mounts alike). */
62
+ /** Where the proxy's files are mounted inside it (compose's and `up`'s mounts alike). */
56
63
  const SQUID_CONF = "/etc/squid/squid.conf";
57
64
  const ALLOWLIST = "/etc/pi-dispatch/allowlist.conf";
65
+ /** The declared model endpoints' rules (issue #503), the path the shipped rules `include`. */
66
+ export const MODEL_ENDPOINTS_TARGET = "/etc/pi-dispatch/model-endpoints.conf";
67
+
68
+ /**
69
+ * Whether a copy of the proxy's rules `include`s the model endpoints' file (issue #503): an uncommented include line
70
+ * naming exactly that path. A copy from before #503 does not, and a proxy started on it needs no third mount.
71
+ */
72
+ export function rulesIncludeEndpoints(text) {
73
+ return new RegExp(`^[ \\t]*include[ \\t]+${MODEL_ENDPOINTS_TARGET.replace(/[.]/g, "\\.")}[ \\t]*$`, "m").test(String(text ?? ""));
74
+ }
58
75
 
59
76
  /**
60
77
  * `{ status, health, image, entrypoint, cmd, mounts, networks }` from `PROXY_STATE_FORMAT`'s stdout, or null when it
@@ -97,9 +114,12 @@ const sameList = (a, b) => Array.isArray(a) && a.length === b.length && a.every(
97
114
  * sentences that make it stale, `unknown` why the mounts could not be compared here (null when they were).
98
115
  * `realpath` throws for a path that does not resolve on this host. `compareMounts: false` judges the image, entrypoint and
99
116
  * command alone, for a caller that does not know which folder the service uses. `platform` decides whether a source
100
- * that does not resolve can be a path of the runtime's own VM (below).
117
+ * that does not resolve can be a path of the runtime's own VM (below). `rulesInclude` (this folder's rules include the
118
+ * model endpoints' file, `rulesIncludeEndpoints`) makes an absent third mount drift (issue #503); without it, a proxy
119
+ * mounting the two files is current, as every proxy made before #503 does. `outOfDate` is true when the missing third
120
+ * mount is the ONLY drift: the proxy is this folder's, made before #503.
101
121
  */
102
- export function shippedProxyDrift(state, { cwd, realpath = (p) => p, compareMounts = true, platform = "linux" }) {
122
+ export function shippedProxyDrift(state, { cwd, realpath = (p) => p, compareMounts = true, platform = "linux", rulesInclude = false }) {
103
123
  const drift = [];
104
124
  if (digestOf(state.image) !== digestOf(EGRESS_PROXY_IMAGE)) drift.push(`it was created from ${state.image || "an unnamed image"}, not the pinned ${EGRESS_PROXY_IMAGE}`);
105
125
  if (!sameList(state.entrypoint, EGRESS_PROXY_ENTRYPOINT)) drift.push(`its entrypoint is ${JSON.stringify(state.entrypoint)}, not the image's ${JSON.stringify(EGRESS_PROXY_ENTRYPOINT)}`);
@@ -128,6 +148,15 @@ export function shippedProxyDrift(state, { cwd, realpath = (p) => p, compareMoun
128
148
  return false;
129
149
  }
130
150
  };
151
+ // The third bind (issue #503): compared like the two when it is there; its absence is drift only when it is needed.
152
+ const endpointsWant = resolve(cwd, "model-endpoints.conf");
153
+ const endpointsBind = binds.find((m) => m.destination === MODEL_ENDPOINTS_TARGET);
154
+ if (endpointsBind) expected[MODEL_ENDPOINTS_TARGET] = endpointsWant;
155
+ let includeMissing = false;
156
+ if (!endpointsBind && rulesInclude) {
157
+ includeMissing = true;
158
+ drift.push(`nothing is mounted at ${MODEL_ENDPOINTS_TARGET}, where ${endpointsWant} belongs: this folder's rules include it, and squid will not start again without it`);
159
+ }
131
160
  const unknownSources = [];
132
161
  for (const [destination, want] of Object.entries(expected)) {
133
162
  const bind = binds.find((m) => m.destination === destination);
@@ -142,7 +171,8 @@ export function shippedProxyDrift(state, { cwd, realpath = (p) => p, compareMoun
142
171
  drift.push(`it has a ${m.type || "mount"} at ${m.destination} (from ${m.source || "nowhere named"}) that the shipped proxy does not`);
143
172
  }
144
173
  const unknown = unknownSources.length > 0 ? `${unknownSources.join(", ")} ${unknownSources.length === 1 ? "is a path" : "are paths"} this host cannot resolve (the runtime's own VM's, as Docker Desktop reports its sources), so ${unknownSources.length === 1 ? "that mount cannot" : "those mounts cannot"} be compared from here` : null;
145
- return { drift, unknown };
174
+ // `outOfDate` only when true, so a current proxy's judgement keeps its two-key shape.
175
+ return includeMissing && drift.length === 1 && unknown === null ? { drift, unknown, outOfDate: true } : { drift, unknown };
146
176
  }
147
177
 
148
178
  /** The job and sandbox networks attached to the proxy, which a removal would cut off (issue #453, gate round 2). */
package/src/egress.mjs CHANGED
@@ -137,6 +137,18 @@ export function egressCanaryProbe(slug, pid) {
137
137
  return `${EGRESS_CANARY_PROBE_PREFIX}${slug}-${pid}`;
138
138
  }
139
139
 
140
+ /**
141
+ * The probe containers doctor runs per declared model endpoint (issue #503), on the same canary network. UNDER the
142
+ * canary's probe prefix on purpose: every line and page that tells an operator what a leftover probe looks like
143
+ * (`pi-dispatch-egress-probe-...`) stays true, and the dead-pid sweep matches these by an anchored pattern of its own.
144
+ */
145
+ export const EGRESS_ENDPOINT_PROBE_PREFIX = `${EGRESS_CANARY_PROBE_PREFIX}endpoint-`;
146
+
147
+ /** One endpoint probe container: per probe, per endpoint id (`[a-z0-9-]{1,32}`, the parser's rule) and per doctor process. */
148
+ export function egressEndpointProbe(slug, id, pid) {
149
+ return `${EGRESS_ENDPOINT_PROBE_PREFIX}${slug}-${id}-${pid}`;
150
+ }
151
+
140
152
  /**
141
153
  * How a container reaches the proxy: by NAME, resolved by docker's embedded DNS on the user-defined
142
154
  * network. `docs/sandbox.md`'s recipe had to write a bare gateway IP because the DEFAULT bridge has no
@@ -33,6 +33,8 @@ import { egressEnv } from "./egress.mjs";
33
33
  import { forgeSpec } from "./forges.mjs";
34
34
  import { apiKeyVariable } from "./provider-key.mjs";
35
35
  import { isDeterminateFsCode } from "./transient.mjs";
36
+ import { KEYLESS_HOW, keylessVerdict } from "./model-endpoints.mjs";
37
+ import { KEYLESS_ENV_NAME } from "./reserved-env.mjs";
36
38
 
37
39
  function configError(message) {
38
40
  const error = new Error(message);
@@ -111,8 +113,15 @@ export function piProviders() {
111
113
  * API-key credentials only; an OAuth/subscription login is refused (it expires, the container cannot refresh
112
114
  * it, and it is not the credential for an unattended service). Throws a config-tagged error (pre-spend
113
115
  * refusal) when neither source yields a credential.
116
+ *
117
+ * Third way in (issue #503), only for a provider pi does not know: a custom provider in the overlay `models.json`
118
+ * whose every model is served by a declared `keyless` model endpoint passes with no credential, and the answer is
119
+ * `{ PI_DISPATCH_KEYLESS: "keyless" }`, the fixed value its `"apiKey": "$PI_DISPATCH_KEYLESS"` resolves to in the job.
120
+ * `modelEndpoints` is the pickup's snapshot `{ endpoints, models }` (never a read of its own), so the free gate and
121
+ * the container env decide on the same declaration. A builtin provider whose baseUrl an operator pointed at a local
122
+ * server is NOT keyless (a named residual): pi knows it, so it still needs its key.
114
123
  */
115
- export function resolveProviderCredential({ provider, hostEnv, authFromPi = false, agentDir, readFile = readFileSync, forwardEnv = [] }) {
124
+ export function resolveProviderCredential({ provider, hostEnv, authFromPi = false, agentDir, readFile = readFileSync, forwardEnv = [], modelEndpoints = null }) {
116
125
  // The NAMES come from pi, the VALUES from `hostEnv`, and this asks each question of the thing that can
117
126
  // answer it (issue #311). It used to be one `providerKeyVars(provider, hostEnv)` call, which conflates
118
127
  // them: that is `findEnvKeys`, whose presence test falls back to the REAL `process.env` for any name the
@@ -129,7 +138,32 @@ export function resolveProviderCredential({ provider, hostEnv, authFromPi = fals
129
138
  // is the DI seam that was dishonest, which is worth fixing where the seam is the whole test surface.
130
139
  // ONE read per name, checked and returned, for the same reason: two reads could decide on one value and
131
140
  // ship another.
132
- const held = providerKeyCandidates(provider)
141
+ const candidates = providerKeyCandidates(provider);
142
+ // Issue #503: a provider pi does not know at all. The same two questions, in the same order, as the auth.json
143
+ // refusal below and doctor's `noKeyVariableCheck`: no key variable, and not in pi's catalog. Such a provider can
144
+ // never take a key through this gate (there is no variable to write it under), so it is answered HERE, before
145
+ // auth.json is read: either it is keyless, or it is refused with both ways in named. That used to be decided
146
+ // after an auth.json read, which could only end in a refusal too, and whose wording ("set the key in .env, or run
147
+ // `pi login`") was advice no custom provider can follow.
148
+ if (candidates.length === 0 && !piProviders().includes(provider)) {
149
+ const ids = keylessEndpointsFor(provider, modelEndpoints);
150
+ // The one variable this branch writes, fixed and non-secret. It is the credential's slot in the closed map,
151
+ // so buildContainerEnv carries it exactly when this gate passes keyless, and on no other job.
152
+ if (ids !== null) return { [KEYLESS_ENV_NAME]: KEYLESS_VALUE };
153
+ // The overlay could not be read at this pickup for a TRANSIENT reason (index.mjs, `modelsUnreadable`): the provider
154
+ // may well be keyless, so this is no verdict at all. UNTAGGED and marked transient, never `piDispatchConfig`: a
155
+ // config refusal is permanent, refunded and commented publicly, and the same job may pass on the next attempt. The
156
+ // processor turns this into an infra retry (CONST-RETRY-INFRA-ONLY); absent or invalid JSON stays determinate.
157
+ const unreadable = modelEndpoints?.modelsUnreadable;
158
+ if (unreadable && Array.isArray(modelEndpoints?.endpoints) && modelEndpoints.endpoints.length > 0) {
159
+ const error = new Error(`the overlay models.json could not be read at this pickup (${unreadable.code}), so whether provider "${provider}" is keyless is not known yet`);
160
+ error.piDispatchTransient = true;
161
+ error.code = unreadable.code;
162
+ throw error;
163
+ }
164
+ throw configError(unknownProviderMessage(provider));
165
+ }
166
+ const held = candidates
133
167
  .map((name) => [name, hostEnv[name]])
134
168
  .filter(([, value]) => value);
135
169
  // EVERY held candidate is forwarded, in pi's order, including the two that are not API keys: a host
@@ -157,7 +191,26 @@ export function resolveProviderCredential({ provider, hostEnv, authFromPi = fals
157
191
  const { name, value } = credentialFromPiAuth(provider, agentDir ?? defaultAgentDir(hostEnv), readFile, { hostEnv, forwardEnv });
158
192
  return { [name]: value };
159
193
  }
160
- throw configError(`provider ${provider} has no configured credential in the worker environment`);
194
+ throw configError(`provider ${provider} has no configured credential in the worker environment. Set its key there, or, ${KEYLESS_HOW}.`);
195
+ }
196
+
197
+ /** The value of `PI_DISPATCH_KEYLESS`: fixed and non-secret, since the model server takes no key. */
198
+ export const KEYLESS_VALUE = "keyless";
199
+
200
+ /**
201
+ * The keyless endpoint ids serving this provider, or null (issue #503). Asked only for a provider pi does not know (the
202
+ * caller's predicate), against the pickup's snapshot `{ endpoints, models }` (index.mjs, one read per pickup). No
203
+ * snapshot, or no endpoint declared, is null: with nothing declared the overlay is never read, and nothing is keyless.
204
+ */
205
+ export function keylessEndpointsFor(provider, modelEndpoints) {
206
+ const endpoints = modelEndpoints?.endpoints;
207
+ if (!Array.isArray(endpoints) || endpoints.length === 0) return null;
208
+ const verdict = keylessVerdict({ models: modelEndpoints.models, provider, endpoints });
209
+ return verdict.keyless ? verdict.endpoints : null;
210
+ }
211
+
212
+ function unknownProviderMessage(provider) {
213
+ return `pi has no provider "${provider}", so there is no key variable to give it a key. Use one of pi's provider ids with its key in the worker environment or in pi's auth.json, or, ${KEYLESS_HOW}.`;
161
214
  }
162
215
 
163
216
  function defaultAgentDir(hostEnv) {
@@ -245,7 +298,9 @@ function credentialFromPiAuth(provider, agentDir, readFile, { hostEnv = {}, forw
245
298
  `pi authenticates "${provider}" without an API-key environment variable (an AWS profile or an OAuth login), and the container env is a closed set of variables, so it has no way in. Configure a provider whose credential is a single environment variable.`,
246
299
  );
247
300
  }
248
- throw configError(`could not determine the environment variable pi expects for provider "${provider}" — pi has no such provider, so check PI_PROVIDER against pi's own ids`);
301
+ // Unreachable through resolveProviderCredential, which answers an unknown provider before auth.json is read. Kept
302
+ // as the backstop with the same words, so a later caller cannot reopen the old advice.
303
+ throw configError(unknownProviderMessage(provider));
249
304
  }
250
305
  return { name, value: cred.key };
251
306
  }
@@ -296,12 +351,14 @@ function resolveEnvName(provider) {
296
351
  * `allowGlobalExtensions` defaults to TRUE here, matching loadConfig's default (REQ-GLOBAL-PI-OVERLAY): a
297
352
  * caller that says nothing gets the operator's staged setup, and only an explicit `false` withholds it.
298
353
  */
299
- export function buildContainerEnv({ provider, model, maxTurns, maxTokens, jobId, githubToken, forgeKind, forgeHosts = {}, hostEnv, allowGlobalExtensions = true, packagePaths = [], forwardEnv = [], secrets = {}, sessionFile = null, flow = null, command = null, excludeTools = [], authFromPi = false, egress = false, egressProxy, agentDir, home = null, readFile = readFileSync }) {
354
+ export function buildContainerEnv({ provider, model, maxTurns, maxTokens, maxCostMicros = null, jobId, githubToken, forgeKind, forgeHosts = {}, hostEnv, allowGlobalExtensions = true, packagePaths = [], forwardEnv = [], secrets = {}, sessionFile = null, flow = null, command = null, excludeTools = [], allowedModels = null, authFromPi = false, egress = false, egressProxy, agentDir, home = null, readFile = readFileSync, modelEndpoints = null, exitAuth = false }) {
300
355
  // The provider credential(s), by pi's expected variable name(s) -- from the worker env, or (when
301
356
  // PI_AUTH_FROM_PI is set and the env has none) host-side from pi's auth.json. Throws (config) if
302
357
  // neither source yields one, which the processor turns into a policy refusal that refunds any reserve
303
358
  // (issue #310); the same call is made by its free credential gate, before anything is reserved at all.
304
- const credEnv = resolveProviderCredential({ provider, hostEnv, authFromPi, agentDir, readFile, forwardEnv });
359
+ // `modelEndpoints` is the pickup's snapshot (issue #503): the keyless branch answers `PI_DISPATCH_KEYLESS` in this
360
+ // map, so the variable is set exactly when the gate passed keyless and is absent on every other job.
361
+ const credEnv = resolveProviderCredential({ provider, hostEnv, authFromPi, agentDir, readFile, forwardEnv, modelEndpoints });
305
362
 
306
363
  const env = {
307
364
  PI_PROVIDER: provider,
@@ -356,6 +413,13 @@ export function buildContainerEnv({ provider, model, maxTurns, maxTokens, jobId,
356
413
  // second-validator rule directly above). Absent means the full pinned default set, never an empty
357
414
  // string, for PI_PACKAGES' reason.
358
415
  PI_EXCLUDE_TOOLS: excludeTools.length > 0 ? excludeTools.join(",") : undefined,
416
+ // The job's EFFECTIVE allowed-model list (issue #502): its trigger's `run.models`, else the deployment's
417
+ // PI_ALLOWED_MODELS. Comma-joined `provider/model` entries, which the runner splits at each entry's first `/`
418
+ // (image/runner/src/config.mjs). Comma is safe because both list parsers refuse an entry carrying one. NULL is
419
+ // unrestricted and emits NO variable, never an empty string: the runner refuses an empty value as a config
420
+ // error, because an empty allow list read as "unset" would fail open. An image too old to enforce a list is
421
+ // refused before this is ever built (`modelPolicy`, CAPABILITY_GATES).
422
+ PI_ALLOWED_MODELS: Array.isArray(allowedModels) && allowedModels.length > 0 ? allowedModels.join(",") : undefined,
359
423
  // Kill switch for job-time package installation, UNCONDITIONAL for every job. pi's resolver shells out
360
424
  // to a REAL `npm install` for any npm:/git: source unless offline mode is on, and `~/.pi/agent` IS
361
425
  // writable in the container. We emit only local paths, so nothing should reach that branch -- this
@@ -431,6 +495,43 @@ export function buildContainerEnv({ provider, model, maxTurns, maxTokens, jobId,
431
495
  // env-internal HOME: written into the job's closed env map here, never read from the worker's environment.
432
496
  if (typeof home === "string" && home !== "") env.HOME = home;
433
497
 
498
+ // Issue #503: PI_DISPATCH_KEYLESS is the credential gate's answer and nothing else's, so it is settled AFTER the
499
+ // PI_FORWARD_ENV and secrets loops, as HOME and the egress variables are: both lists refuse the name upstream, and
500
+ // this is the backstop that keeps a value from either one from replacing it, or from appearing on a keyed job.
501
+ if (credEnv[KEYLESS_ENV_NAME] === KEYLESS_VALUE) env[KEYLESS_ENV_NAME] = KEYLESS_VALUE;
502
+ else delete env[KEYLESS_ENV_NAME];
503
+
504
+ // Issue #501: the per-job dollar cap is the worker's answer and nothing else's, so it is settled again AFTER the
505
+ // PI_FORWARD_ENV and secrets loops, PI_DISPATCH_KEYLESS's backstop. Both lists refuse the name upstream (config.mjs
506
+ // refuses every CONTAINER_ENV_NAMES member in PI_FORWARD_ENV at boot, #502; the loader refuses it in run.secrets);
507
+ // this is the line that holds if either refusal is ever bypassed. A forwarded or secret PI_MAX_COST_MICROS would
508
+ // otherwise replace the computed cap (measured: a host value of 1e12 beat a computed 500000), and on a job with
509
+ // no cap it would send one past the `costCap` gate, which only runs for capped jobs, to an image that may ignore it.
510
+ // It is set ONLY here, once, after both loops. `!== null`, never truthiness: 0 is a real cap (no priced call at
511
+ // all, which a fail-closed job value reads as, `effectiveCostCapMicros`), and a truthy test would send NO cap for
512
+ // it, the widest one there is. Absent (null or undefined) leaves no variable, so a job with no cap carries the env
513
+ // it always did, and never an empty string (the runner refuses one).
514
+ // env-internal PI_MAX_COST_MICROS: written into the job's closed env map here, never read from the worker's environment.
515
+ if (maxCostMicros === null || maxCostMicros === undefined) delete env.PI_MAX_COST_MICROS;
516
+ else env.PI_MAX_COST_MICROS = String(maxCostMicros);
517
+
518
+ // Issue #545: the exit line's key waits on the container's stdin, and this variable tells the runner to read it.
519
+ // The value names the channel and is never the key: the environment is readable from /proc/1/environ by every
520
+ // process in the container (measured), which is why the key does not travel here. Settled after both loops like
521
+ // the cap above, and ONLY for a run the worker actually hands a key, so a runner never blocks on a stdin no one
522
+ // writes. `=== true`, so only run-container's own boolean asks for it.
523
+ // env-internal PI_EXIT_AUTH: written into the job's closed env map here, never read from the worker's environment.
524
+ if (exitAuth === true) env.PI_EXIT_AUTH = "stdin";
525
+ else delete env.PI_EXIT_AUTH;
526
+
527
+ // Issue #500: the runner sets these two in its own environment, for its child processes, and no value from outside
528
+ // may arrive first. Both lists refuse them upstream (config.mjs for PI_FORWARD_ENV, the triggers loader for
529
+ // run.secrets, through RUNNER_ENV_NAMES); this is the line that holds if either refusal is ever bypassed.
530
+ // env-internal PI_DISPATCH_CHILD_LEDGER: set by the runner inside the container, never by the worker, so removed here.
531
+ delete env.PI_DISPATCH_CHILD_LEDGER;
532
+ // env-internal PI_DISPATCH_RUNNER_PID: set by the runner inside the container, never by the worker, so removed here.
533
+ delete env.PI_DISPATCH_RUNNER_PID;
534
+
434
535
  // Forge-backed jobs, and local cron jobs that opted in via run.github. Other local-folder jobs have
435
536
  // no token (CONST-TOKEN-SCOPED-PER-JOB). The mint goes into BOTH of its forge's variables because
436
537
  // each CLI has its own preference -- gh prefers GH_TOKEN over GITHUB_TOKEN, glab prefers GITLAB_TOKEN