@edgehero/pi-dispatch 1.0.0 → 1.2.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 +26 -1
- package/deploy/docker-compose.yml +59 -0
- package/deploy/egress-proxy.conf +36 -0
- package/deploy/receiver.service +11 -0
- package/deploy/worker-env-wrapper.cmd +41 -4
- package/deploy/worker-env-wrapper.sh +96 -7
- package/package.json +1 -1
- package/src/azure-prompt.mjs +57 -9
- package/src/config.mjs +133 -6
- package/src/docker-run.mjs +16 -1
- package/src/doctor.mjs +398 -13
- package/src/egress.mjs +221 -0
- package/src/env-allowlist.mjs +16 -1
- package/src/forgejo-prompt.mjs +65 -11
- package/src/get-token.mjs +5 -3
- package/src/github-prompt.mjs +11 -2
- package/src/gitlab-prompt.mjs +65 -11
- package/src/init.mjs +30 -0
- package/src/packages.mjs +4 -1
- package/src/prepare-github.mjs +6 -4
- package/src/processor.mjs +67 -5
- package/src/run-container.mjs +42 -6
- package/src/run-history.mjs +41 -3
- package/src/sandbox-cli.mjs +24 -3
- package/src/sandbox.mjs +14 -1
- package/src/service.mjs +289 -25
- package/src/session-store.mjs +294 -15
- package/src/start.mjs +29 -0
- package/src/triggers.mjs +11 -12
- package/src/up.mjs +58 -0
package/src/config.mjs
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import { existsSync } from "node:fs";
|
|
9
9
|
import { delimiter } from "node:path";
|
|
10
|
+
import { DEFAULT_EGRESS_PROXY, egressArmed } from "./egress.mjs";
|
|
10
11
|
import { MINTED_TOKEN_VARS } from "./forges.mjs";
|
|
11
12
|
|
|
12
13
|
export function configError(message) {
|
|
@@ -89,7 +90,35 @@ function commaList(raw) {
|
|
|
89
90
|
// token under that name into every container of every forge, with nothing failing and nothing logged.
|
|
90
91
|
// `forges.mjs` derives both from one row, and `env-allowlist.test.mjs` binds them.
|
|
91
92
|
|
|
92
|
-
|
|
93
|
+
/**
|
|
94
|
+
* Names the WORKER holds that must never reach a job container, for a different reason than the minted
|
|
95
|
+
* ones above: nothing overrides them, they simply do not belong in there.
|
|
96
|
+
*
|
|
97
|
+
* `GITHUB_APP_PRIVATE_KEY` is the App's signing key. It mints installation tokens for every repository
|
|
98
|
+
* the App is installed on, with no expiry of its own -- so forwarding it hands an agent that is reading
|
|
99
|
+
* adversarial issue text something strictly worse than the per-job token the whole of
|
|
100
|
+
* `CONST-TOKEN-SCOPED-PER-JOB` exists to bound. This became reachable the moment the key could be an
|
|
101
|
+
* environment value at all (issue #208); before that, `PI_FORWARD_ENV` could only have carried the PATH.
|
|
102
|
+
*
|
|
103
|
+
* `GITHUB_APP_PRIVATE_KEY_PATH` is deliberately NOT here: a path string with no mount behind it is inert
|
|
104
|
+
* inside a container, and refusing harmless things is how a refusal stops being read.
|
|
105
|
+
*
|
|
106
|
+
* Kept separate from `MINTED_TOKEN_VARS`, which is defined as "every name any forge's mint can write"
|
|
107
|
+
* and derived from the forge table. This is not that, and folding it in would make that definition a lie.
|
|
108
|
+
*/
|
|
109
|
+
export const WORKER_ONLY_SECRET_VARS = new Set(["GITHUB_APP_PRIVATE_KEY"]);
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The proxy variables the egress policy writes into the closed container env (REQ-EGRESS-ALLOWLIST).
|
|
113
|
+
* Refused in `PI_FORWARD_ENV` only WHILE THE POLICY IS ARMED, and the conditionality is the point: with
|
|
114
|
+
* no policy these are an ordinary operator escape hatch, and `docs/egress.md` still documents the manual
|
|
115
|
+
* form that uses them. With a policy, a forwarded value would point every job at an operator's own proxy
|
|
116
|
+
* instead of the one the worker attached to the network -- and it would read exactly like the control
|
|
117
|
+
* working, which is the failure class this file already refuses for the minted token.
|
|
118
|
+
*/
|
|
119
|
+
export const EGRESS_ENV_VARS = new Set(["HTTPS_PROXY", "HTTP_PROXY", "NO_PROXY", "NODE_USE_ENV_PROXY"]);
|
|
120
|
+
|
|
121
|
+
function forwardEnvList(raw, egressArmed = false) {
|
|
93
122
|
const names = commaList(raw);
|
|
94
123
|
const minted = names.filter((n) => MINTED_TOKEN_VARS.has(n));
|
|
95
124
|
if (minted.length > 0) {
|
|
@@ -97,9 +126,35 @@ function forwardEnvList(raw) {
|
|
|
97
126
|
`PI_FORWARD_ENV must not forward ${minted.join(", ")} -- the worker mints a per-job token (CONST-TOKEN-SCOPED-PER-JOB) and a forwarded operator token would silently override it`,
|
|
98
127
|
);
|
|
99
128
|
}
|
|
129
|
+
const workerOnly = names.filter((n) => WORKER_ONLY_SECRET_VARS.has(n));
|
|
130
|
+
if (workerOnly.length > 0) {
|
|
131
|
+
throw configError(
|
|
132
|
+
`PI_FORWARD_ENV must not forward ${workerOnly.join(", ")} -- the App's signing key mints tokens for every repository the App is installed on, and a job container is the last place it belongs (CONST-TOKEN-SCOPED-PER-JOB)`,
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
const egress = egressArmed ? names.filter((n) => EGRESS_ENV_VARS.has(n)) : [];
|
|
136
|
+
if (egress.length > 0) {
|
|
137
|
+
throw configError(
|
|
138
|
+
`PI_FORWARD_ENV must not forward ${egress.join(", ")} while the egress policy is armed -- it sets them itself, pointing at the proxy on this job's network, and a forwarded value would silently redirect every job while looking like the control working (REQ-EGRESS-ALLOWLIST). Set PI_EGRESS=0 to use your own proxy: docs/egress.md`,
|
|
139
|
+
);
|
|
140
|
+
}
|
|
100
141
|
return names;
|
|
101
142
|
}
|
|
102
143
|
|
|
144
|
+
/**
|
|
145
|
+
* PI_EGRESS (REQ-EGRESS-ALLOWLIST). The parse itself lives in egress.mjs, because `doctor` and `up` read
|
|
146
|
+
* the environment directly and three copies of one default is two chances to flip it in the wrong number
|
|
147
|
+
* of places. Here it only gains the `piDispatchConfig` tag, so a bad value prints as a config error and
|
|
148
|
+
* the worker refuses to boot rather than guessing a security posture.
|
|
149
|
+
*/
|
|
150
|
+
function egressEnabled(env) {
|
|
151
|
+
try {
|
|
152
|
+
return egressArmed(env);
|
|
153
|
+
} catch (error) {
|
|
154
|
+
throw configError(error.message);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
103
158
|
// The operator's global pi overlay dir (REQ-GLOBAL-PI-OVERLAY). Unset/empty = feature off. When set it
|
|
104
159
|
// must EXIST at boot -- a typo pointing at nothing would silently drop the operator's whole setup on
|
|
105
160
|
// every job, so fail loud like every other config error rather than degrade to nothing.
|
|
@@ -168,7 +223,12 @@ export function loadConfig(env = process.env, { fileExists = existsSync } = {})
|
|
|
168
223
|
jobImage: env.PI_JOB_IMAGE || "pi-job:latest", // || (not ??) so an empty string falls back; "" is falsy and would throw inside buildDockerRunArgs AFTER a budget slot was reserved
|
|
169
224
|
globalPiDir: resolveGlobalPiDir(env, fileExists), // REQ-GLOBAL-PI-OVERLAY: operator's ~/.pi/agent subset, :ro-mounted; null = off
|
|
170
225
|
allowGlobalExtensions: globalExtensionsEnabled(env), // REQ-GLOBAL-PI-OVERLAY: ON unless PI_GLOBAL_ALLOW_EXTENSIONS=0
|
|
171
|
-
|
|
226
|
+
// REQ-EGRESS-ALLOWLIST. `egress` gates the whole feature; `egressProxy` names the component the
|
|
227
|
+
// per-job network is built around. Read BEFORE forwardEnv below, because the forward list's refusal
|
|
228
|
+
// of the proxy variables is conditional on it.
|
|
229
|
+
egress: egressEnabled(env),
|
|
230
|
+
egressProxy: env.PI_EGRESS_PROXY || DEFAULT_EGRESS_PROXY, // || (not ??) so an empty string falls back
|
|
231
|
+
forwardEnv: forwardEnvList(env.PI_FORWARD_ENV, egressEnabled(env)), // extra host var NAMES to forward (e.g. a custom provider's key); explicit allowlist, GitHub token names refused
|
|
172
232
|
authFromPi: env.PI_AUTH_FROM_PI !== "0", // ON by default: use the key in ~/.pi/agent/auth.json when the env has none (api-key only). PI_AUTH_FROM_PI=0 forces env-only.
|
|
173
233
|
jobsDir: env.PI_JOBS_DIR ?? defaultJobsDir(),
|
|
174
234
|
// REQ-RESURRECTABLE-SANDBOX. `||` (not `??`) so an empty string falls back, matching logsDir.
|
|
@@ -198,6 +258,22 @@ export function loadConfig(env = process.env, { fileExists = existsSync } = {})
|
|
|
198
258
|
// A bound on how large a transcript may be before it stops being resumed. Not disk hygiene: an
|
|
199
259
|
// oversized transcript is a prefill an operator never sized PI_MAX_TOKENS for.
|
|
200
260
|
sessionMaxBytes: nonNegativeInt(env, "PI_SESSION_MAX_BYTES", 8 * 1024 * 1024), // 0 = no cap
|
|
261
|
+
// How old the CONVERSATION may be, which is a different clock from sessionsTtlDays above and not a
|
|
262
|
+
// finer setting of it: the TTL reads the transcript's mtime, which the PROMOTE rename refreshes (the
|
|
263
|
+
// resolve copy does not, measured -- copyFileSync stamps the destination), so it measures time since
|
|
264
|
+
// the last COMPLETED run on this key. A key whose runs keep completing never expires however old its
|
|
265
|
+
// first turn is. This one reads the session header's own timestamp.
|
|
266
|
+
// OFF by default (0) rather than defaulted to a number: an age an operator did not choose is an
|
|
267
|
+
// opinion about their lineages that this project has no basis for.
|
|
268
|
+
sessionMaxAgeDays: nonNegativeInt(env, "PI_SESSION_MAX_AGE_DAYS", 0), // 0 = no age bound
|
|
269
|
+
// How many times in a row one key may be resumed before the next job starts fresh. The bound a long
|
|
270
|
+
// lineage actually needs: age and size both grow slowly while a chain grows once per run.
|
|
271
|
+
sessionMaxResumeChain: nonNegativeInt(env, "PI_SESSION_MAX_RESUME_CHAIN", 0), // 0 = no chain bound
|
|
272
|
+
// How full the saved context may be before a resume is refused, as a percentage of the model's own
|
|
273
|
+
// window. A PERCENTAGE, so `optionalBoundedInt` on softHoldPct's precedent rather than the 0 = off
|
|
274
|
+
// sentinel its two neighbours use: 0% would mean "never resume anything", which is a different
|
|
275
|
+
// request from "no bound", and 101 is a typo rather than a ceiling.
|
|
276
|
+
sessionMaxContextPct: optionalBoundedInt(env, "PI_SESSION_MAX_CONTEXT_PCT", 1, 100), // null = no context bound
|
|
201
277
|
chainDepthMax: nonNegativeInt(env, "PI_CHAIN_DEPTH_MAX", CHAIN_DEPTH_MAX_DEFAULT), // DES-JOB-OUTBOX-CHAINING; 0 = chaining kill-switch (fail-closed)
|
|
202
278
|
chainMaxPerJob: nonNegativeInt(env, "PI_CHAIN_MAX_PER_JOB", CHAIN_MAX_PER_JOB_DEFAULT), // INT-OUTBOX-CONTRACT: max request-<n>.json collected per parent
|
|
203
279
|
dispatchRunPerHour: nonNegativeInt(env, "PI_DISPATCH_RUN_PER_HOUR", 3), // DES-ADMIN-VIA-PI-EXTENSION; 0 = disable dispatch_run
|
|
@@ -209,10 +285,43 @@ export function loadConfig(env = process.env, { fileExists = existsSync } = {})
|
|
|
209
285
|
};
|
|
210
286
|
}
|
|
211
287
|
|
|
288
|
+
/**
|
|
289
|
+
* Normalise an inline App private key, or refuse it. Returns `null` for absent/blank (an empty
|
|
290
|
+
* `GITHUB_APP_PRIVATE_KEY=` line in a scaffolded .env means "unset", never "a key that is empty").
|
|
291
|
+
*
|
|
292
|
+
* ONE normalisation rule, and it is unambiguous rather than lenient: a PEM contains no backslash, so a
|
|
293
|
+
* value carrying literal `\n` escapes and no real newline can only be a flattened key -- which is what a
|
|
294
|
+
* .env line and most secrets-manager UIs produce, since neither can hold a multi-line value. Anything
|
|
295
|
+
* else is passed through untouched.
|
|
296
|
+
*
|
|
297
|
+
* Then the shape is CHECKED, because the alternative is a deployment that boots clean and dies at its
|
|
298
|
+
* first mint with a crypto error naming nothing. A truncated paste fails here instead.
|
|
299
|
+
*
|
|
300
|
+
* The value NEVER appears in a refusal message. That is the whole reason this is a function rather than
|
|
301
|
+
* three lines inline: one place to get that right, and one place to test it.
|
|
302
|
+
*/
|
|
303
|
+
export function normalizeAppPrivateKey(raw) {
|
|
304
|
+
const value = typeof raw === "string" ? raw.trim() : "";
|
|
305
|
+
if (value === "") return null;
|
|
306
|
+
const pem = value.includes("\\n") && !value.includes("\n") ? value.replace(/\\n/g, "\n") : value;
|
|
307
|
+
const begins = /^-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----/.test(pem);
|
|
308
|
+
const ends = /-----END [A-Z0-9 ]*PRIVATE KEY-----$/.test(pem.trimEnd());
|
|
309
|
+
if (!begins || !ends) {
|
|
310
|
+
throw configError(
|
|
311
|
+
"GITHUB_APP_PRIVATE_KEY is not a PEM private key -- expected it to begin `-----BEGIN ... PRIVATE KEY-----` and end `-----END ... PRIVATE KEY-----` (the value itself is deliberately not shown; check for a truncated paste, or use GITHUB_APP_PRIVATE_KEY_PATH)",
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
return pem;
|
|
315
|
+
}
|
|
316
|
+
|
|
212
317
|
/**
|
|
213
318
|
* Parse and validate the GitHub auth block consumed verbatim by `makeGitHubAuth(cfg)` in
|
|
214
|
-
* get-token.mjs. Shape is fixed: `{ source, patVar, appId, installationId, privateKeyPath }`.
|
|
319
|
+
* get-token.mjs. Shape is fixed: `{ source, patVar, appId, installationId, privateKeyPath, privateKey }`.
|
|
215
320
|
* Fails loud at load time so a misconfigured worker refuses to boot rather than failing per-job.
|
|
321
|
+
*
|
|
322
|
+
* `privateKey` carries KEY MATERIAL when the operator supplied it inline, so nothing may serialise this
|
|
323
|
+
* block: its three consumers (start.mjs, cli.mjs, sandbox-cli.mjs) pass it along and never print it, and
|
|
324
|
+
* that is a property to keep rather than a coincidence to rely on.
|
|
216
325
|
*/
|
|
217
326
|
export function loadGitHubAuth(env, fileExists) {
|
|
218
327
|
const source = env.GITHUB_AUTH_SOURCE ?? "gh";
|
|
@@ -224,6 +333,11 @@ export function loadGitHubAuth(env, fileExists) {
|
|
|
224
333
|
const appId = env.GITHUB_APP_ID;
|
|
225
334
|
const installationId = env.GITHUB_APP_INSTALLATION_ID;
|
|
226
335
|
const privateKeyPath = env.GITHUB_APP_PRIVATE_KEY_PATH;
|
|
336
|
+
// Blank counts as unset on BOTH, so a scaffolded `.env` full of empty keys never shadows the one an
|
|
337
|
+
// operator actually set.
|
|
338
|
+
const inlineKey = (env.GITHUB_APP_PRIVATE_KEY ?? "").trim();
|
|
339
|
+
const keyPathSet = (privateKeyPath ?? "").trim() !== "";
|
|
340
|
+
let privateKey = null;
|
|
227
341
|
|
|
228
342
|
if (source === "pat") {
|
|
229
343
|
const pat = (env[patVar] ?? "").trim();
|
|
@@ -233,19 +347,32 @@ export function loadGitHubAuth(env, fileExists) {
|
|
|
233
347
|
}
|
|
234
348
|
|
|
235
349
|
if (source === "app") {
|
|
350
|
+
// Both set is a REFUSAL, not a precedence rule. A precedence rule means two places hold the App's
|
|
351
|
+
// signing key, they disagree eventually, and the deployment keeps working with whichever one this
|
|
352
|
+
// function happened to prefer -- which is exactly the class of surprise every other credential
|
|
353
|
+
// decision in this file forecloses.
|
|
354
|
+
if (inlineKey !== "" && keyPathSet) {
|
|
355
|
+
throw configError(
|
|
356
|
+
"GITHUB_APP_PRIVATE_KEY and GITHUB_APP_PRIVATE_KEY_PATH are both set -- supply the App key exactly once (the inline value for a secrets manager, the path for a key on disk)",
|
|
357
|
+
);
|
|
358
|
+
}
|
|
236
359
|
const missing = [];
|
|
237
360
|
if (!appId) missing.push("GITHUB_APP_ID");
|
|
238
361
|
if (!installationId) missing.push("GITHUB_APP_INSTALLATION_ID");
|
|
239
|
-
if (!
|
|
362
|
+
if (inlineKey === "" && !keyPathSet) missing.push("GITHUB_APP_PRIVATE_KEY_PATH (or GITHUB_APP_PRIVATE_KEY)");
|
|
240
363
|
if (missing.length > 0) {
|
|
241
364
|
throw configError(`GITHUB_AUTH_SOURCE=app requires ${missing.join(", ")}`);
|
|
242
365
|
}
|
|
243
|
-
if (
|
|
366
|
+
if (inlineKey !== "") {
|
|
367
|
+
privateKey = normalizeAppPrivateKey(inlineKey);
|
|
368
|
+
} else if (!fileExists(privateKeyPath)) {
|
|
369
|
+
// Only when the PATH is the chosen source: an inline deployment has no key file to check, which
|
|
370
|
+
// is the entire point of the variable (docs/secrets.md).
|
|
244
371
|
throw configError(`GITHUB_APP_PRIVATE_KEY_PATH does not exist: ${privateKeyPath}`);
|
|
245
372
|
}
|
|
246
373
|
}
|
|
247
374
|
|
|
248
|
-
return { source, patVar, appId, installationId, privateKeyPath };
|
|
375
|
+
return { source, patVar, appId, installationId, privateKeyPath, privateKey };
|
|
249
376
|
}
|
|
250
377
|
|
|
251
378
|
function defaultJobsDir() {
|
package/src/docker-run.mjs
CHANGED
|
@@ -62,6 +62,8 @@ export const ISOLATION_FLAGS = [
|
|
|
62
62
|
* @param globalPiDir host path to the operator's global pi overlay (REQ-GLOBAL-PI-OVERLAY); mounted /opt/pi-global:ro
|
|
63
63
|
* @param name container name (for `docker stop` at the timeout)
|
|
64
64
|
* @param memory e.g. "4g"; cpus e.g. "2"
|
|
65
|
+
* @param network the per-job egress network this container joins (REQ-EGRESS-ALLOWLIST); null = the
|
|
66
|
+
* docker default bridge, which is what every job did before that requirement existed
|
|
65
67
|
* @param extraFlags escape hatch for a Linux-only --user uid:gid on a bind-mounted local folder
|
|
66
68
|
*/
|
|
67
69
|
export function buildDockerRunArgs({
|
|
@@ -75,13 +77,26 @@ export function buildDockerRunArgs({
|
|
|
75
77
|
name,
|
|
76
78
|
memory = "4g",
|
|
77
79
|
cpus = "2",
|
|
80
|
+
network = null,
|
|
78
81
|
extraFlags = [],
|
|
79
82
|
}) {
|
|
80
83
|
if (!image) throw new Error("docker run: image is required");
|
|
81
84
|
if (!name) throw new Error("docker run: container name is required");
|
|
82
85
|
if (!workspace) throw new Error("docker run: workspace mount is required");
|
|
83
86
|
|
|
84
|
-
|
|
87
|
+
// `--network` sits HERE, beside --memory and --cpus, and deliberately NOT inside ISOLATION_FLAGS.
|
|
88
|
+
// That array is the LITERAL, value-free, unconditional set, and two separate places assert every member
|
|
89
|
+
// of it reaches the sandbox argv *against the imported array, not a copy* (CONST-ISOLATION-CONTAINER-PER-JOB
|
|
90
|
+
// and INT-SANDBOX-CONTRACT). A conditional member makes "every member" false on any deployment running
|
|
91
|
+
// without an egress policy, so the assertion would have to be weakened to "every member except this one"
|
|
92
|
+
// -- which does not weaken a constraint so much as retire the assertion that was enforcing it. There is
|
|
93
|
+
// no literal to put there anyway: the name carries a job id.
|
|
94
|
+
//
|
|
95
|
+
// null => the flag is ABSENT, so a job argv without an egress policy is byte-identical to one built
|
|
96
|
+
// before this feature existed. Same shape as the sessionDir/outboxDir/globalPiDir mounts below.
|
|
97
|
+
const args = ["run", `--name=${name}`, ...ISOLATION_FLAGS, `--memory=${memory}`, `--cpus=${cpus}`];
|
|
98
|
+
if (network) args.push(`--network=${network}`);
|
|
99
|
+
args.push(...extraFlags);
|
|
85
100
|
|
|
86
101
|
// Explicit env allowlist. Each entry is `-e NAME=VALUE`, built from the closed map -- so a
|
|
87
102
|
// stray host variable cannot ride along (no bare `-e NAME` inheriting from the host, no
|