@edgehero/pi-dispatch 2.1.0 → 3.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.
- package/.env.example +41 -5
- package/README.md +11 -5
- package/deploy/docker-compose.yml +12 -0
- package/deploy/egress-proxy.conf +28 -3
- package/deploy/pi-dispatch-egress-proxy.container +8 -2
- package/package.json +9 -2
- package/src/allocation.mjs +731 -0
- package/src/backends.mjs +243 -0
- package/src/budget.mjs +40 -4
- package/src/cli.mjs +222 -11
- package/src/config.mjs +126 -5
- package/src/daemon-facts.mjs +3 -0
- package/src/deployment-venue.mjs +1 -0
- package/src/doctor.mjs +2280 -195
- package/src/dollar-budget.mjs +373 -0
- package/src/dollar-fingerprint.mjs +83 -0
- package/src/egress-cli.mjs +316 -0
- package/src/egress-proxy-state.mjs +35 -5
- package/src/egress.mjs +12 -0
- package/src/env-allowlist.mjs +125 -6
- package/src/env-file.mjs +194 -25
- package/src/envelope.mjs +413 -0
- package/src/exit-code.mjs +22 -0
- package/src/fleet-lease.mjs +85 -25
- package/src/get-token.mjs +16 -5
- package/src/git-dirty.mjs +67 -0
- package/src/github-app-setup.mjs +6 -3
- package/src/github-host.mjs +5 -3
- package/src/host-pi.mjs +1 -1
- package/src/identity.mjs +2 -1
- package/src/image-preflight.mjs +98 -24
- package/src/image-ref.mjs +37 -0
- package/src/import-pi.mjs +4 -2
- package/src/index.mjs +407 -62
- package/src/init.mjs +18 -0
- package/src/job-id.mjs +26 -3
- package/src/live-probes.mjs +24 -9
- package/src/model-catalog.mjs +297 -0
- package/src/model-endpoints.mjs +671 -0
- package/src/model-ref.mjs +151 -0
- package/src/models-json.mjs +268 -0
- package/src/money.mjs +144 -0
- package/src/octokit-log.mjs +65 -0
- package/src/outbox-plan.mjs +218 -0
- package/src/outbox.mjs +29 -9
- package/src/output-cap.mjs +157 -0
- package/src/pause-windows.mjs +81 -2
- package/src/pi-model-loader.mjs +77 -0
- package/src/podman-stack.mjs +16 -3
- package/src/portfolio-snapshot.mjs +304 -0
- package/src/prepare-local.mjs +247 -12
- package/src/prepare.mjs +35 -3
- package/src/priorities.mjs +569 -0
- package/src/processor.mjs +599 -170
- package/src/project-id.mjs +17 -0
- package/src/projects.mjs +238 -0
- package/src/provider-steering.mjs +179 -65
- package/src/queue.mjs +111 -6
- package/src/reserved-env.mjs +31 -0
- package/src/run-container.mjs +59 -5
- package/src/run-history.mjs +379 -24
- package/src/run-mirror.mjs +30 -0
- package/src/runtime-settings.mjs +104 -9
- package/src/schedules.mjs +33 -1
- package/src/scoped-limits.mjs +447 -27
- package/src/service.mjs +15 -4
- package/src/session-store.mjs +131 -6
- package/src/start.mjs +528 -40
- package/src/triggers-file.mjs +65 -4
- package/src/triggers.mjs +141 -11
- package/src/up.mjs +308 -34
- 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
|
|
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
|
-
|
|
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
|
package/src/env-allowlist.mjs
CHANGED
|
@@ -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, providerRenameHint, readOverlayModels, RENAMED_PROVIDERS } 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
|
|
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, hintOverlay(provider, modelEndpoints, hostEnv)));
|
|
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,42 @@ 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, overlay = {}) {
|
|
213
|
+
return `pi has no provider "${provider}", so there is no key variable to give it a key.${providerRenameHint(provider, { ...overlay, piProviders: piProviders() })} Use one of pi's provider ids with its key in the worker environment or in pi's auth.json, or, ${KEYLESS_HOW}.`;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* The overlay models.json the rename hint is judged on (issue #587's review): the pickup's snapshot when it read one, else
|
|
218
|
+
* the overlay itself, read here, only for an id pi renamed. The snapshot reads the overlay only when an endpoint is
|
|
219
|
+
* declared, so a full custom provider under the old id, with no endpoint, was told "did you mean". A read that fails
|
|
220
|
+
* is `overlayUnread`: no guess.
|
|
221
|
+
*/
|
|
222
|
+
function hintOverlay(provider, modelEndpoints, hostEnv) {
|
|
223
|
+
if (modelEndpoints?.models) return { models: modelEndpoints.models };
|
|
224
|
+
if (!Object.hasOwn(RENAMED_PROVIDERS, provider)) return {};
|
|
225
|
+
try {
|
|
226
|
+
return { models: readOverlayModels(hostEnv?.PI_GLOBAL_PI_DIR) };
|
|
227
|
+
} catch {
|
|
228
|
+
return { overlayUnread: true };
|
|
229
|
+
}
|
|
161
230
|
}
|
|
162
231
|
|
|
163
232
|
function defaultAgentDir(hostEnv) {
|
|
@@ -245,7 +314,9 @@ function credentialFromPiAuth(provider, agentDir, readFile, { hostEnv = {}, forw
|
|
|
245
314
|
`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
315
|
);
|
|
247
316
|
}
|
|
248
|
-
|
|
317
|
+
// Unreachable through resolveProviderCredential, which answers an unknown provider before auth.json is read. Kept
|
|
318
|
+
// as the backstop with the same words, so a later caller cannot reopen the old advice.
|
|
319
|
+
throw configError(unknownProviderMessage(provider));
|
|
249
320
|
}
|
|
250
321
|
return { name, value: cred.key };
|
|
251
322
|
}
|
|
@@ -296,12 +367,14 @@ function resolveEnvName(provider) {
|
|
|
296
367
|
* `allowGlobalExtensions` defaults to TRUE here, matching loadConfig's default (REQ-GLOBAL-PI-OVERLAY): a
|
|
297
368
|
* caller that says nothing gets the operator's staged setup, and only an explicit `false` withholds it.
|
|
298
369
|
*/
|
|
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 }) {
|
|
370
|
+
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
371
|
// The provider credential(s), by pi's expected variable name(s) -- from the worker env, or (when
|
|
301
372
|
// PI_AUTH_FROM_PI is set and the env has none) host-side from pi's auth.json. Throws (config) if
|
|
302
373
|
// neither source yields one, which the processor turns into a policy refusal that refunds any reserve
|
|
303
374
|
// (issue #310); the same call is made by its free credential gate, before anything is reserved at all.
|
|
304
|
-
|
|
375
|
+
// `modelEndpoints` is the pickup's snapshot (issue #503): the keyless branch answers `PI_DISPATCH_KEYLESS` in this
|
|
376
|
+
// map, so the variable is set exactly when the gate passed keyless and is absent on every other job.
|
|
377
|
+
const credEnv = resolveProviderCredential({ provider, hostEnv, authFromPi, agentDir, readFile, forwardEnv, modelEndpoints });
|
|
305
378
|
|
|
306
379
|
const env = {
|
|
307
380
|
PI_PROVIDER: provider,
|
|
@@ -356,6 +429,13 @@ export function buildContainerEnv({ provider, model, maxTurns, maxTokens, jobId,
|
|
|
356
429
|
// second-validator rule directly above). Absent means the full pinned default set, never an empty
|
|
357
430
|
// string, for PI_PACKAGES' reason.
|
|
358
431
|
PI_EXCLUDE_TOOLS: excludeTools.length > 0 ? excludeTools.join(",") : undefined,
|
|
432
|
+
// The job's EFFECTIVE allowed-model list (issue #502): its trigger's `run.models`, else the deployment's
|
|
433
|
+
// PI_ALLOWED_MODELS. Comma-joined `provider/model` entries, which the runner splits at each entry's first `/`
|
|
434
|
+
// (image/runner/src/config.mjs). Comma is safe because both list parsers refuse an entry carrying one. NULL is
|
|
435
|
+
// unrestricted and emits NO variable, never an empty string: the runner refuses an empty value as a config
|
|
436
|
+
// error, because an empty allow list read as "unset" would fail open. An image too old to enforce a list is
|
|
437
|
+
// refused before this is ever built (`modelPolicy`, CAPABILITY_GATES).
|
|
438
|
+
PI_ALLOWED_MODELS: Array.isArray(allowedModels) && allowedModels.length > 0 ? allowedModels.join(",") : undefined,
|
|
359
439
|
// Kill switch for job-time package installation, UNCONDITIONAL for every job. pi's resolver shells out
|
|
360
440
|
// to a REAL `npm install` for any npm:/git: source unless offline mode is on, and `~/.pi/agent` IS
|
|
361
441
|
// writable in the container. We emit only local paths, so nothing should reach that branch -- this
|
|
@@ -431,6 +511,45 @@ export function buildContainerEnv({ provider, model, maxTurns, maxTokens, jobId,
|
|
|
431
511
|
// env-internal HOME: written into the job's closed env map here, never read from the worker's environment.
|
|
432
512
|
if (typeof home === "string" && home !== "") env.HOME = home;
|
|
433
513
|
|
|
514
|
+
// Issue #503: PI_DISPATCH_KEYLESS is the credential gate's answer and nothing else's, so it is settled AFTER the
|
|
515
|
+
// PI_FORWARD_ENV and secrets loops, as HOME and the egress variables are: both lists refuse the name upstream, and
|
|
516
|
+
// this is the backstop that keeps a value from either one from replacing it, or from appearing on a keyed job.
|
|
517
|
+
if (credEnv[KEYLESS_ENV_NAME] === KEYLESS_VALUE) env[KEYLESS_ENV_NAME] = KEYLESS_VALUE;
|
|
518
|
+
else delete env[KEYLESS_ENV_NAME];
|
|
519
|
+
|
|
520
|
+
// Issue #501: the per-job dollar cap is the worker's answer and nothing else's, so it is settled again AFTER the
|
|
521
|
+
// PI_FORWARD_ENV and secrets loops, PI_DISPATCH_KEYLESS's backstop. Both lists refuse the name upstream (config.mjs
|
|
522
|
+
// refuses every CONTAINER_ENV_NAMES member in PI_FORWARD_ENV at boot, #502; the loader refuses it in run.secrets);
|
|
523
|
+
// this is the line that holds if either refusal is ever bypassed. A forwarded or secret PI_MAX_COST_MICROS would
|
|
524
|
+
// otherwise replace the computed cap (measured: a host value of 1e12 beat a computed 500000), and on a job with
|
|
525
|
+
// no cap it would send one past the `costCap` gate, which only runs for capped jobs, to an image that may ignore it.
|
|
526
|
+
// It is set ONLY here, once, after both loops. `!== null`, never truthiness: 0 is a real cap (no priced call at
|
|
527
|
+
// all, which a fail-closed job value reads as, `effectiveCostCapMicros`), and a truthy test would send NO cap for
|
|
528
|
+
// it, the widest one there is. Absent (null or undefined) leaves no variable, so a job with no cap carries the env
|
|
529
|
+
// it always did, and never an empty string (the runner refuses one).
|
|
530
|
+
// env-internal PI_MAX_COST_MICROS: written into the job's closed env map here, never read from the worker's environment.
|
|
531
|
+
if (maxCostMicros === null || maxCostMicros === undefined) delete env.PI_MAX_COST_MICROS;
|
|
532
|
+
else env.PI_MAX_COST_MICROS = String(maxCostMicros);
|
|
533
|
+
|
|
534
|
+
// Issue #545: the exit line's key waits on the container's stdin, and this variable tells the runner to read it.
|
|
535
|
+
// The value names the channel and is never the key: the environment is readable from /proc/1/environ by every
|
|
536
|
+
// process in the container (measured), which is why the key does not travel here. Settled after both loops like
|
|
537
|
+
// the cap above, and ONLY for a run the worker actually hands a key, so a runner never blocks on a stdin no one
|
|
538
|
+
// writes. `=== true`, so only run-container's own boolean asks for it.
|
|
539
|
+
// env-internal PI_EXIT_AUTH: written into the job's closed env map here, never read from the worker's environment.
|
|
540
|
+
if (exitAuth === true) env.PI_EXIT_AUTH = "stdin";
|
|
541
|
+
else delete env.PI_EXIT_AUTH;
|
|
542
|
+
|
|
543
|
+
// Issue #500: the runner sets these in its own environment, for its child processes, and no value from outside
|
|
544
|
+
// may arrive first. Both lists refuse them upstream (config.mjs for PI_FORWARD_ENV, the triggers loader for
|
|
545
|
+
// run.secrets, through RUNNER_ENV_NAMES); this is the line that holds if either refusal is ever bypassed.
|
|
546
|
+
// env-internal PI_DISPATCH_CHILD_LEDGER: set by the runner inside the container, never by the worker, so removed here.
|
|
547
|
+
delete env.PI_DISPATCH_CHILD_LEDGER;
|
|
548
|
+
// env-internal PI_DISPATCH_RUNNER_PID: set by the runner inside the container, never by the worker, so removed here.
|
|
549
|
+
delete env.PI_DISPATCH_RUNNER_PID;
|
|
550
|
+
// env-internal PI_DISPATCH_PRICE_TABLE: set by the runner inside the container, never by the worker, so removed here.
|
|
551
|
+
delete env.PI_DISPATCH_PRICE_TABLE;
|
|
552
|
+
|
|
434
553
|
// Forge-backed jobs, and local cron jobs that opted in via run.github. Other local-folder jobs have
|
|
435
554
|
// no token (CONST-TOKEN-SCOPED-PER-JOB). The mint goes into BOTH of its forge's variables because
|
|
436
555
|
// each CLI has its own preference -- gh prefers GH_TOKEN over GITHUB_TOKEN, glab prefers GITLAB_TOKEN
|