@edgehero/pi-dispatch 0.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 +160 -0
- package/deploy/com.pi-dispatch.worker.plist +66 -0
- package/deploy/nssm-install.cmd +59 -0
- package/deploy/receiver.service +36 -0
- package/deploy/worker-env-wrapper.cmd +50 -0
- package/deploy/worker-env-wrapper.sh +63 -0
- package/deploy/worker.service +55 -0
- package/package.json +83 -0
- package/src/azure-auth.mjs +61 -0
- package/src/azure-host.mjs +236 -0
- package/src/azure-identity.mjs +63 -0
- package/src/azure-prompt.mjs +118 -0
- package/src/branch.mjs +80 -0
- package/src/budget.mjs +179 -0
- package/src/cli.mjs +208 -0
- package/src/config.mjs +329 -0
- package/src/connection.mjs +40 -0
- package/src/cron.mjs +94 -0
- package/src/docker-run.mjs +119 -0
- package/src/doctor.mjs +1127 -0
- package/src/env-allowlist.mjs +198 -0
- package/src/env-file.mjs +153 -0
- package/src/exit-code.mjs +32 -0
- package/src/flow-gate.mjs +82 -0
- package/src/forgejo-auth.mjs +77 -0
- package/src/forgejo-host.mjs +172 -0
- package/src/forgejo-identity.mjs +74 -0
- package/src/forgejo-prompt.mjs +123 -0
- package/src/forges.mjs +148 -0
- package/src/get-token.mjs +226 -0
- package/src/git-dirty.mjs +16 -0
- package/src/github-app-setup.mjs +517 -0
- package/src/github-host.mjs +159 -0
- package/src/github-prompt.mjs +286 -0
- package/src/gitlab-auth.mjs +72 -0
- package/src/gitlab-host.mjs +200 -0
- package/src/gitlab-identity.mjs +61 -0
- package/src/gitlab-prompt.mjs +123 -0
- package/src/identity.mjs +57 -0
- package/src/image-preflight.mjs +180 -0
- package/src/import-pi.mjs +451 -0
- package/src/index.mjs +177 -0
- package/src/init.mjs +77 -0
- package/src/job-id.mjs +100 -0
- package/src/materialize.mjs +138 -0
- package/src/outbox.mjs +179 -0
- package/src/packages.mjs +188 -0
- package/src/pause-windows.mjs +218 -0
- package/src/prepare-github.mjs +260 -0
- package/src/prepare-local.mjs +76 -0
- package/src/prepare.mjs +199 -0
- package/src/pricing.mjs +168 -0
- package/src/processor.mjs +360 -0
- package/src/queue.mjs +152 -0
- package/src/run-container.mjs +133 -0
- package/src/run-history.mjs +534 -0
- package/src/runtime-settings.mjs +188 -0
- package/src/sandbox-cli.mjs +156 -0
- package/src/sandbox-store.mjs +269 -0
- package/src/sandbox.mjs +171 -0
- package/src/scheduler-stall-guard.mjs +67 -0
- package/src/schedules.mjs +62 -0
- package/src/service.mjs +677 -0
- package/src/session-key.mjs +108 -0
- package/src/session-store.mjs +249 -0
- package/src/start.mjs +502 -0
- package/src/subscriptions.mjs +208 -0
- package/src/triggers.mjs +491 -0
- package/src/up.mjs +315 -0
package/src/config.mjs
ADDED
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Worker configuration, from the environment. Validated and fail-loud: a misconfigured worker
|
|
3
|
+
* should refuse to start with a clear message, not launch and fail per-job.
|
|
4
|
+
*
|
|
5
|
+
* Errors are tagged `piDispatchConfig` so the CLI/entry can print them cleanly and exit non-zero.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { existsSync } from "node:fs";
|
|
9
|
+
import { delimiter } from "node:path";
|
|
10
|
+
import { MINTED_TOKEN_VARS } from "./forges.mjs";
|
|
11
|
+
|
|
12
|
+
export function configError(message) {
|
|
13
|
+
const error = new Error(message);
|
|
14
|
+
error.piDispatchConfig = true;
|
|
15
|
+
return error;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function boundedInt(env, name, fallback, min, want) {
|
|
19
|
+
const raw = env[name];
|
|
20
|
+
if (raw === undefined || raw === "") {
|
|
21
|
+
if (fallback !== undefined) return fallback;
|
|
22
|
+
throw configError(`missing required env: ${name}`);
|
|
23
|
+
}
|
|
24
|
+
const n = Number.parseInt(raw, 10);
|
|
25
|
+
if (!Number.isInteger(n) || n < min || String(n) !== String(raw).trim()) {
|
|
26
|
+
throw configError(`invalid ${name}: ${JSON.stringify(raw)} (want ${want})`);
|
|
27
|
+
}
|
|
28
|
+
return n;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function positiveInt(env, name, fallback) {
|
|
32
|
+
return boundedInt(env, name, fallback, 1, "a positive integer");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// min=0: accepts 0 (a sentinel, e.g. "keep forever" for log retention), still rejects negatives and non-integers.
|
|
36
|
+
function nonNegativeInt(env, name, fallback) {
|
|
37
|
+
return boundedInt(env, name, fallback, 0, "a non-negative integer");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// An OPTIONAL bounded int: an unset or empty var is `null` (the feature it gates is disabled), a present
|
|
41
|
+
// one is validated in `[min, max]` (max undefined = no upper bound) and otherwise a config error. Unlike
|
|
42
|
+
// `boundedInt`, absence is a first-class "off", not a fallback default -- used by the optional week/month
|
|
43
|
+
// spend ceilings and the soft-hold band, which default to disabled rather than to a number.
|
|
44
|
+
function optionalBoundedInt(env, name, min, max) {
|
|
45
|
+
const raw = env[name];
|
|
46
|
+
if (raw === undefined || raw === "") return null;
|
|
47
|
+
const n = Number.parseInt(raw, 10);
|
|
48
|
+
const inRange = Number.isInteger(n) && n >= min && (max === undefined || n <= max) && String(n) === String(raw).trim();
|
|
49
|
+
if (!inRange) {
|
|
50
|
+
const want = max === undefined ? `an integer >= ${min}` : `an integer ${min}-${max}`;
|
|
51
|
+
throw configError(`invalid ${name}: ${JSON.stringify(raw)} (want ${want})`);
|
|
52
|
+
}
|
|
53
|
+
return n;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Split a PATH-style list on the OS path delimiter (`;` on Windows, `:` elsewhere) so a Windows
|
|
57
|
+
// drive-letter colon is not mistaken for a separator. Trims, drops empties. Entries are stored
|
|
58
|
+
// verbatim; downstream (task 3.1) realpaths them, so no posix normalisation happens here.
|
|
59
|
+
function delimitedList(raw) {
|
|
60
|
+
return (raw ?? "")
|
|
61
|
+
.split(delimiter)
|
|
62
|
+
.map((s) => s.trim())
|
|
63
|
+
.filter((s) => s.length > 0);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// A comma-separated list of NAMES (env var names cannot contain commas, so unlike a path list this
|
|
67
|
+
// splits on ",", not the OS path delimiter). Used by PI_FORWARD_ENV.
|
|
68
|
+
function commaList(raw) {
|
|
69
|
+
return (raw ?? "")
|
|
70
|
+
.split(",")
|
|
71
|
+
.map((s) => s.trim())
|
|
72
|
+
.filter((s) => s.length > 0);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// PI_FORWARD_ENV, with the token names the worker itself owns refused at boot. env-allowlist.mjs sets
|
|
76
|
+
// them from the per-job mint; forwarding one from the host would silently swap which credential every job
|
|
77
|
+
// spends, so this fails loud here rather than per-job.
|
|
78
|
+
//
|
|
79
|
+
// Derived from the forge table rather than written out, because this list and the mint that writes those
|
|
80
|
+
// names have to agree and used to be two hand-maintained lists twenty lines apart in different files. A
|
|
81
|
+
// forge added to the mint but missed here is not refused -- so an operator could forward a long-lived host
|
|
82
|
+
// token under that name into every container of every forge, with nothing failing and nothing logged.
|
|
83
|
+
// `forges.mjs` derives both from one row, and `env-allowlist.test.mjs` binds them.
|
|
84
|
+
|
|
85
|
+
function forwardEnvList(raw) {
|
|
86
|
+
const names = commaList(raw);
|
|
87
|
+
const minted = names.filter((n) => MINTED_TOKEN_VARS.has(n));
|
|
88
|
+
if (minted.length > 0) {
|
|
89
|
+
throw configError(
|
|
90
|
+
`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`,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
return names;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// The operator's global pi overlay dir (REQ-GLOBAL-PI-OVERLAY). Unset/empty = feature off. When set it
|
|
97
|
+
// must EXIST at boot -- a typo pointing at nothing would silently drop the operator's whole setup on
|
|
98
|
+
// every job, so fail loud like every other config error rather than degrade to nothing.
|
|
99
|
+
function resolveGlobalPiDir(env, fileExists) {
|
|
100
|
+
const dir = env.PI_GLOBAL_PI_DIR;
|
|
101
|
+
if (dir === undefined || dir === "") return null;
|
|
102
|
+
if (!fileExists(dir)) throw configError(`PI_GLOBAL_PI_DIR does not exist: ${dir}`);
|
|
103
|
+
return dir;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Does the overlay's `extensions/` dir load in job containers (REQ-GLOBAL-PI-OVERLAY)?
|
|
108
|
+
*
|
|
109
|
+
* ON by default. The operator vetted this code twice already -- once by having it in their own `~/.pi/agent`,
|
|
110
|
+
* once by staging it into the overlay with `import-pi` -- so a third gate is friction, not safety, and the
|
|
111
|
+
* setup they staged is the setup a job should get. `PI_GLOBAL_ALLOW_EXTENSIONS` survives only as the
|
|
112
|
+
* opt-OUT: the exact string "0" disables them. Unset, empty, and the legacy "1" all mean LOAD, so an .env
|
|
113
|
+
* that still carries the old arming flag keeps working and says the same thing it always did.
|
|
114
|
+
*
|
|
115
|
+
* Any OTHER value is a config error, not a default in either direction. The strict-parse discipline is
|
|
116
|
+
* unchanged, but the thing it now defends against has flipped: under the old fail-closed reading a typo
|
|
117
|
+
* degraded to "dormant", which was merely disappointing; now the damaging misreading is "the operator
|
|
118
|
+
* believes they turned extensions off and they are still loading into every adversarial-input container".
|
|
119
|
+
* `PI_GLOBAL_ALLOW_EXTENSIONS=false` must therefore refuse to boot rather than be quietly ignored. "0" is
|
|
120
|
+
* the canonical opt-out because it is the exact inverse of the "1" already written in existing .env files
|
|
121
|
+
* and matches the single-character discipline of PI_AUTH_FROM_PI=0 / PI_CAPTURE_JOB_LOGS=1.
|
|
122
|
+
*
|
|
123
|
+
* Exported so `doctor` reports the same reading the worker will boot with (it deliberately re-reads env
|
|
124
|
+
* defaults rather than calling loadConfig, which throws on unrelated GitHub-auth problems).
|
|
125
|
+
*/
|
|
126
|
+
export function globalExtensionsEnabled(env) {
|
|
127
|
+
const raw = env.PI_GLOBAL_ALLOW_EXTENSIONS;
|
|
128
|
+
if (raw === undefined || raw === "" || raw === "1") return true;
|
|
129
|
+
if (raw === "0") return false;
|
|
130
|
+
throw configError(
|
|
131
|
+
`invalid PI_GLOBAL_ALLOW_EXTENSIONS: ${JSON.stringify(raw)} (want "0" to disable the overlay's extensions, or leave it unset to load them)`,
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Parse the worker's config from `env` (default process.env). Every MONEY default is conservative:
|
|
137
|
+
* spend controls (`PI_DAILY_CAP`, `PI_MAX_TURNS`) exist to bound money, so they default low, and a
|
|
138
|
+
* cap of 0 would fail closed (budget.mjs refuses every job) rather than mean "unlimited". The optional
|
|
139
|
+
* week/month ceilings and the soft-hold band default to disabled (`null`) -- the mandatory daily cap is
|
|
140
|
+
* always the primary money bound; the others are additive ceilings an operator opts into.
|
|
141
|
+
*
|
|
142
|
+
* The operator's OWN staged setup is the deliberate exception: `allowGlobalExtensions` defaults to ON
|
|
143
|
+
* (REQ-GLOBAL-PI-OVERLAY). Staging is itself the vetting step, so the overlay an operator built is the
|
|
144
|
+
* overlay their jobs get, and the knob is an opt-out. That relaxation stops there -- it never touches the
|
|
145
|
+
* spend caps above, the per-job token scoping, or the admin-extension recursion block.
|
|
146
|
+
*/
|
|
147
|
+
export function loadConfig(env = process.env, { fileExists = existsSync } = {}) {
|
|
148
|
+
const model = env.PI_MODEL ?? "claude-sonnet-4-5-20250929"; // dated snapshot; deterministic per CONST-PI-VERSION-PINNED
|
|
149
|
+
return {
|
|
150
|
+
valkeyUrl: env.VALKEY_URL ?? "redis://127.0.0.1:6379",
|
|
151
|
+
concurrency: positiveInt(env, "PI_CONCURRENCY", 3), // DES-CONCURRENCY-3
|
|
152
|
+
dailyCap: positiveInt(env, "PI_DAILY_CAP", 25), // bounds container STARTS per day (money)
|
|
153
|
+
weeklyCap: optionalBoundedInt(env, "PI_WEEKLY_CAP", 1), // REQ-SPEND-CAPS-MULTI-WINDOW; null = weekly window disabled
|
|
154
|
+
monthlyCap: optionalBoundedInt(env, "PI_MONTHLY_CAP", 1), // null = monthly window disabled
|
|
155
|
+
softHoldPct: optionalBoundedInt(env, "PI_SOFT_HOLD_PCT", 1, 99), // null = soft-hold band disabled
|
|
156
|
+
provider: env.PI_PROVIDER ?? "anthropic",
|
|
157
|
+
model,
|
|
158
|
+
maxTurns: positiveInt(env, "PI_MAX_TURNS", 30), // pi has no turn limit; we impose one
|
|
159
|
+
maxTokens: optionalBoundedInt(env, "PI_MAX_TOKENS", 1), // issue #25; null = per-job token budget disabled (lagging in-run backstop)
|
|
160
|
+
dailyTokenCap: optionalBoundedInt(env, "PI_DAILY_TOKEN_CAP", 1), // issue #25; null = daily token counter disabled (check-AFTER, host-side)
|
|
161
|
+
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
|
|
162
|
+
globalPiDir: resolveGlobalPiDir(env, fileExists), // REQ-GLOBAL-PI-OVERLAY: operator's ~/.pi/agent subset, :ro-mounted; null = off
|
|
163
|
+
allowGlobalExtensions: globalExtensionsEnabled(env), // REQ-GLOBAL-PI-OVERLAY: ON unless PI_GLOBAL_ALLOW_EXTENSIONS=0
|
|
164
|
+
forwardEnv: forwardEnvList(env.PI_FORWARD_ENV), // extra host var NAMES to forward (e.g. a custom provider's key); explicit allowlist, GitHub token names refused
|
|
165
|
+
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.
|
|
166
|
+
jobsDir: env.PI_JOBS_DIR ?? defaultJobsDir(),
|
|
167
|
+
// REQ-RESURRECTABLE-SANDBOX. `||` (not `??`) so an empty string falls back, matching logsDir.
|
|
168
|
+
sandboxDir: env.PI_SANDBOX_DIR || defaultSandboxDir(env),
|
|
169
|
+
// Hours a finished run's directory stays re-openable. NOTE THE SENTINEL, which is the OPPOSITE of
|
|
170
|
+
// logRetentionDays' and sessionsTtlDays': 0 means the feature is OFF (nothing is retained, cleanup
|
|
171
|
+
// is the `rm` it always was), NOT keep-forever. There is deliberately no keep-forever value -- a
|
|
172
|
+
// full repository clone per run with no ceiling is a disk bomb, and `--pin` exists for the one run
|
|
173
|
+
// worth keeping longer, bounded by sandboxPinDays.
|
|
174
|
+
sandboxRetentionHours: nonNegativeInt(env, "PI_SANDBOX_RETENTION_HOURS", 24),
|
|
175
|
+
sandboxPinDays: nonNegativeInt(env, "PI_SANDBOX_PIN_DAYS", 7), // `--pin` extends to now + this, never to forever
|
|
176
|
+
sandboxIdleMinutes: nonNegativeInt(env, "PI_SANDBOX_IDLE_MINUTES", 30), // bash's own TMOUT inside a sandbox; 0 = no idle logout
|
|
177
|
+
triggersFile: env.PI_TRIGGERS_FILE ?? null, // DES-CRON-VIA-BULLMQ-SCHEDULER: unified triggers file; null = cron disabled for the worker (it selects on.type:"cron")
|
|
178
|
+
pauseWindowsFile: env.PI_PAUSE_WINDOWS_FILE ?? null, // REQ-SCOPED-PAUSE-WINDOWS: per-folder/repo timed pause; null = no scoped pauses
|
|
179
|
+
schedulerStallMax: positiveInt(env, "PI_SCHEDULER_STALL_MAX", 2), // CONST-RETRY-INFRA-ONLY: per-scheduler stall backstop; positiveInt rejects <1 so a 0 threshold fails closed
|
|
180
|
+
logsDir: env.PI_LOGS_DIR || defaultLogsDir(), // || (not ??) so an empty string falls back to the default
|
|
181
|
+
settingsFile: env.PI_SETTINGS_FILE || defaultSettingsFile(), // || (not ??) so an empty string falls back; INT-CONFIG-OVERLAY-CONTRACT
|
|
182
|
+
captureJobLogs: env.PI_CAPTURE_JOB_LOGS === "1", // no-pii-in-logs: raw job-log capture is opt-in; anything but "1" is off
|
|
183
|
+
logRetentionDays: nonNegativeInt(env, "PI_LOG_RETENTION_DAYS", 30), // 0 = keep forever
|
|
184
|
+
// REQ-RESUMABLE-SESSION. NO DEFAULT, deliberately unlike logsDir/jobsDir: unset means the feature
|
|
185
|
+
// is unavailable, and a trigger that armed run.resume then refuses PRE-SPEND rather than running
|
|
186
|
+
// silently without persistence. A transcript is the most PII-bearing artifact this system holds --
|
|
187
|
+
// tool output, file contents, the agent's own reasoning -- and defaulting it into <OS temp>, which
|
|
188
|
+
// is mode 1777 on POSIX, is not a place to put that by accident.
|
|
189
|
+
sessionsDir: env.PI_SESSIONS_DIR || null,
|
|
190
|
+
sessionsTtlDays: nonNegativeInt(env, "PI_SESSIONS_TTL_DAYS", 14), // 0 = keep forever
|
|
191
|
+
// A bound on how large a transcript may be before it stops being resumed. Not disk hygiene: an
|
|
192
|
+
// oversized transcript is a prefill an operator never sized PI_MAX_TOKENS for.
|
|
193
|
+
sessionMaxBytes: nonNegativeInt(env, "PI_SESSION_MAX_BYTES", 8 * 1024 * 1024), // 0 = no cap
|
|
194
|
+
chainDepthMax: nonNegativeInt(env, "PI_CHAIN_DEPTH_MAX", 1), // DES-JOB-OUTBOX-CHAINING; 0 = chaining kill-switch (fail-closed)
|
|
195
|
+
chainMaxPerJob: nonNegativeInt(env, "PI_CHAIN_MAX_PER_JOB", 2), // INT-OUTBOX-CONTRACT: max request-<n>.json collected per parent
|
|
196
|
+
dispatchRunPerHour: nonNegativeInt(env, "PI_DISPATCH_RUN_PER_HOUR", 3), // DES-ADMIN-VIA-PI-EXTENSION; 0 = disable dispatch_run
|
|
197
|
+
dispatchRunRoots: delimitedList(env.PI_DISPATCH_RUN_ROOTS), // DES-AI-TRIGGER-FLOW-GATE: default [] fails closed — no folder passes, dispatch_run refuses everything
|
|
198
|
+
github: { ...loadGitHubAuth(env, fileExists), allowGhResume: env.PI_SESSIONS_ALLOW_GH_SOURCE === "1" },
|
|
199
|
+
gitlab: loadGitLabAuth(env),
|
|
200
|
+
forgejo: loadForgejoAuth(env),
|
|
201
|
+
azure: loadAzureAuth(env),
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Parse and validate the GitHub auth block consumed verbatim by `makeGitHubAuth(cfg)` in
|
|
207
|
+
* get-token.mjs. Shape is fixed: `{ source, patVar, appId, installationId, privateKeyPath }`.
|
|
208
|
+
* Fails loud at load time so a misconfigured worker refuses to boot rather than failing per-job.
|
|
209
|
+
*/
|
|
210
|
+
export function loadGitHubAuth(env, fileExists) {
|
|
211
|
+
const source = env.GITHUB_AUTH_SOURCE ?? "gh";
|
|
212
|
+
if (source !== "pat" && source !== "gh" && source !== "app") {
|
|
213
|
+
throw configError(`invalid GITHUB_AUTH_SOURCE: ${source} (expected pat|gh|app)`);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const patVar = env.GITHUB_PAT_VAR ?? "GITHUB_PAT";
|
|
217
|
+
const appId = env.GITHUB_APP_ID;
|
|
218
|
+
const installationId = env.GITHUB_APP_INSTALLATION_ID;
|
|
219
|
+
const privateKeyPath = env.GITHUB_APP_PRIVATE_KEY_PATH;
|
|
220
|
+
|
|
221
|
+
if (source === "pat") {
|
|
222
|
+
const pat = (env[patVar] ?? "").trim();
|
|
223
|
+
if (!pat) {
|
|
224
|
+
throw configError(`GITHUB_AUTH_SOURCE=pat requires a non-empty ${patVar}`);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (source === "app") {
|
|
229
|
+
const missing = [];
|
|
230
|
+
if (!appId) missing.push("GITHUB_APP_ID");
|
|
231
|
+
if (!installationId) missing.push("GITHUB_APP_INSTALLATION_ID");
|
|
232
|
+
if (!privateKeyPath) missing.push("GITHUB_APP_PRIVATE_KEY_PATH");
|
|
233
|
+
if (missing.length > 0) {
|
|
234
|
+
throw configError(`GITHUB_AUTH_SOURCE=app requires ${missing.join(", ")}`);
|
|
235
|
+
}
|
|
236
|
+
if (!fileExists(privateKeyPath)) {
|
|
237
|
+
throw configError(`GITHUB_APP_PRIVATE_KEY_PATH does not exist: ${privateKeyPath}`);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return { source, patVar, appId, installationId, privateKeyPath };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function defaultJobsDir() {
|
|
245
|
+
// Under the OS temp dir by default. Holds only the read-only /job inputs (prompt + .pi/); the
|
|
246
|
+
// workspace for a local job is the operator's own folder, not here.
|
|
247
|
+
return `${process.env.TMPDIR ?? process.env.TEMP ?? "/tmp"}/pi-dispatch/jobs`.replace(/\\/g, "/");
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export function defaultSandboxDir(env = process.env) {
|
|
251
|
+
// Beside the per-job dirs, because a retained directory IS a per-job dir -- `cleanup` renames it here
|
|
252
|
+
// rather than copying, which only stays atomic while both live on one filesystem. Created mode 0700 by
|
|
253
|
+
// the retention step, since the OS temp dir is 1777 on POSIX and a retained tree holds a repository
|
|
254
|
+
// clone plus the run's prompt.md/event.json. Exported so the admin extension resolves the same default
|
|
255
|
+
// without calling loadConfig, which throws on unrelated env problems.
|
|
256
|
+
return `${env.PI_JOBS_DIR ?? defaultJobsDir()}/sandboxes`.replace(/\\/g, "/");
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export function defaultLogsDir() {
|
|
260
|
+
// Under the OS temp dir by default. Holds durable per-run history/log artifacts written host-side;
|
|
261
|
+
// a worker-owned path that never enters the container env allowlist (no-broad-env-into-container).
|
|
262
|
+
return `${process.env.TMPDIR ?? process.env.TEMP ?? "/tmp"}/pi-dispatch/logs`.replace(/\\/g, "/");
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export function defaultSettingsFile() {
|
|
266
|
+
// Under the OS temp dir by default. Holds the runtime-tunable settings overlay shared with the admin
|
|
267
|
+
// extension (INT-CONFIG-OVERLAY-CONTRACT); a worker-owned path that never enters the container env
|
|
268
|
+
// allowlist (no-broad-env-into-container). Exported so the admin extension resolves the same default
|
|
269
|
+
// without calling loadConfig, which throws on unrelated env problems.
|
|
270
|
+
return `${process.env.TMPDIR ?? process.env.TEMP ?? "/tmp"}/pi-dispatch/settings.json`.replace(/\\/g, "/");
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* The worker's Azure DevOps auth config, or `null` when none is configured -- same presence rule as the
|
|
275
|
+
* other two optional forges.
|
|
276
|
+
*/
|
|
277
|
+
export function loadAzureAuth(env) {
|
|
278
|
+
const token = env.AZURE_TOKEN;
|
|
279
|
+
if (typeof token !== "string" || token.trim() === "") return null;
|
|
280
|
+
const source = env.AZURE_AUTH_SOURCE ?? "pat";
|
|
281
|
+
if (source !== "pat") {
|
|
282
|
+
throw configError(`AZURE_AUTH_SOURCE must be "pat" (got ${JSON.stringify(source)}) -- Azure DevOps has no App or installation-token equivalent, so there is no other source`);
|
|
283
|
+
}
|
|
284
|
+
const orgUrl = env.AZURE_ORG_URL;
|
|
285
|
+
if (typeof orgUrl !== "string" || orgUrl.trim() === "") {
|
|
286
|
+
throw configError("AZURE_ORG_URL is required when AZURE_TOKEN is set (e.g. https://dev.azure.com/your-org)");
|
|
287
|
+
}
|
|
288
|
+
return { source, orgUrl: orgUrl.trim().replace(/\/+$/, ""), tokenVar: "AZURE_TOKEN" };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* The worker's Forgejo auth config, or `null` when none is configured -- same presence rule as GitLab's.
|
|
293
|
+
*
|
|
294
|
+
* `FORGEJO_BOT_ID` rides here because a repository-scoped Forgejo token cannot call `GET /user`, so the
|
|
295
|
+
* identity the bot-loop guard needs may have to be supplied rather than asked for (forgejo-identity.mjs).
|
|
296
|
+
*/
|
|
297
|
+
export function loadForgejoAuth(env) {
|
|
298
|
+
const token = env.FORGEJO_TOKEN;
|
|
299
|
+
if (typeof token !== "string" || token.trim() === "") return null;
|
|
300
|
+
const source = env.FORGEJO_AUTH_SOURCE ?? "pat";
|
|
301
|
+
if (source !== "pat") {
|
|
302
|
+
throw configError(`FORGEJO_AUTH_SOURCE must be "pat" (got ${JSON.stringify(source)}) -- Forgejo has no App or installation-token equivalent, so there is no other source`);
|
|
303
|
+
}
|
|
304
|
+
const apiUrl = env.FORGEJO_URL;
|
|
305
|
+
// No default instance, deliberately: Forgejo is self-hosted by nature and there is no forgejo.com to
|
|
306
|
+
// fall back to. Guessing one would send an operator's token to a host they never named.
|
|
307
|
+
if (typeof apiUrl !== "string" || apiUrl.trim() === "") {
|
|
308
|
+
throw configError("FORGEJO_URL is required when FORGEJO_TOKEN is set -- there is no default Forgejo instance to fall back to");
|
|
309
|
+
}
|
|
310
|
+
return { source, apiUrl: apiUrl.trim(), tokenVar: "FORGEJO_TOKEN", botId: env.FORGEJO_BOT_ID ?? null };
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* The worker's GitLab auth config, or `null` when no GitLab is configured -- in which case the forge is
|
|
315
|
+
* simply absent from the map and a gitlab job refuses at mint time with a message naming what is missing.
|
|
316
|
+
*
|
|
317
|
+
* Only `pat` exists, and deliberately so: GitLab has no App equivalent, so there is no stronger source to
|
|
318
|
+
* offer and no choice to make. The variable is still named `GITLAB_AUTH_SOURCE` for symmetry with
|
|
319
|
+
* `GITHUB_AUTH_SOURCE`, so an operator reading .env.example finds the same shape on both sides.
|
|
320
|
+
*/
|
|
321
|
+
export function loadGitLabAuth(env) {
|
|
322
|
+
const token = env.GITLAB_TOKEN;
|
|
323
|
+
if (typeof token !== "string" || token.trim() === "") return null;
|
|
324
|
+
const source = env.GITLAB_AUTH_SOURCE ?? "pat";
|
|
325
|
+
if (source !== "pat") {
|
|
326
|
+
throw configError(`GITLAB_AUTH_SOURCE must be "pat" (got ${JSON.stringify(source)}) -- GitLab has no App equivalent, so there is no other source`);
|
|
327
|
+
}
|
|
328
|
+
return { source, apiUrl: env.GITLAB_URL ?? "https://gitlab.com", tokenVar: "GITLAB_TOKEN" };
|
|
329
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { Redis } from "ioredis";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Connection helpers for BullMQ and the budget's raw Redis client, both from one VALKEY_URL.
|
|
5
|
+
*
|
|
6
|
+
* `maxRetriesPerRequest: null` is REQUIRED by BullMQ for its blocking connections (the Worker
|
|
7
|
+
* uses BRPOPLPUSH); without it BullMQ throws at construction. It is harmless on the Queue and the
|
|
8
|
+
* budget client, so it is set consistently.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* BullMQ connection options parsed from a redis:// URL.
|
|
13
|
+
*
|
|
14
|
+
* `failFast` is for the CLI producer (a one-shot enqueue): if Valkey is unreachable it should
|
|
15
|
+
* error in a couple of seconds with a clear message, not hang forever. The long-running WORKER
|
|
16
|
+
* uses the default (persistent) options -- it should ride out a Valkey restart, not give up.
|
|
17
|
+
*/
|
|
18
|
+
export function parseConnection(url, { failFast = false } = {}) {
|
|
19
|
+
const u = new URL(url);
|
|
20
|
+
return {
|
|
21
|
+
host: u.hostname || "127.0.0.1",
|
|
22
|
+
port: Number(u.port || 6379),
|
|
23
|
+
...(u.password ? { password: u.password } : {}),
|
|
24
|
+
...(u.username ? { username: u.username } : {}),
|
|
25
|
+
...(u.pathname && u.pathname !== "/" ? { db: Number(u.pathname.slice(1)) } : {}),
|
|
26
|
+
maxRetriesPerRequest: null, // required for BullMQ blocking connections
|
|
27
|
+
...(failFast
|
|
28
|
+
? {
|
|
29
|
+
connectTimeout: 2000,
|
|
30
|
+
enableOfflineQueue: false, // don't buffer commands while disconnected -- error now
|
|
31
|
+
retryStrategy: (attempts) => (attempts > 2 ? null : 200), // give up after ~2 tries
|
|
32
|
+
}
|
|
33
|
+
: {}),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** A raw ioredis client for the budget's INCR/EXPIRE. */
|
|
38
|
+
export function makeRedisClient(url) {
|
|
39
|
+
return new Redis(url, { maxRetriesPerRequest: null });
|
|
40
|
+
}
|
package/src/cron.mjs
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reconcile the Redis-resident BullMQ job schedulers with the host-side schedule config
|
|
3
|
+
* (DES-CRON-VIA-BULLMQ-SCHEDULER). Given the normalized schedules from schedules.mjs, upsert each one
|
|
4
|
+
* and prune any resident scheduler the config no longer names. Idempotency is BullMQ's: upsert is keyed
|
|
5
|
+
* by schedulerId, so re-running with unchanged config installs the same set and removes nothing.
|
|
6
|
+
*
|
|
7
|
+
* The queue is injected, so this module carries no bullmq import and runs anywhere -- the whole
|
|
8
|
+
* reconcile is exercised in tier-1 tests against a fake queue, with no real Valkey.
|
|
9
|
+
*
|
|
10
|
+
* A `-10` (SchedulerJobIdCollision) or `-11` (SchedulerJobSlotsBusy) from upsertJobScheduler is a
|
|
11
|
+
* sentinel, not a success: swallowing it makes a schedule edit a silent no-op that looks identical to a
|
|
12
|
+
* clean install (design.md:231-232). Whether the SDK throws or returns the sentinel is unconfirmed until
|
|
13
|
+
* it runs against live Valkey, so BOTH the thrown path and the negative-return path are loud failures.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { configError } from "./config.mjs";
|
|
17
|
+
import { loadSchedules } from "./schedules.mjs";
|
|
18
|
+
|
|
19
|
+
function sentinelName(code) {
|
|
20
|
+
if (code === -10) return "SchedulerJobIdCollision";
|
|
21
|
+
if (code === -11) return "SchedulerJobSlotsBusy";
|
|
22
|
+
return "unknown scheduler sentinel";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Install `schedules` (the normalized `{ schedulerId, name, pattern, data, opts }` shape from
|
|
27
|
+
* schedules.mjs) and prune orphans. `queue` supplies `upsertJobScheduler` / `getJobSchedulers` /
|
|
28
|
+
* `removeJobScheduler`; `log(event, fields)` records stable scheduler ids only. Returns
|
|
29
|
+
* `{ installed, removed }`.
|
|
30
|
+
*/
|
|
31
|
+
export async function reconcile(queue, schedules, { log = () => {} } = {}) {
|
|
32
|
+
for (const { schedulerId, name, pattern, data, opts } of schedules) {
|
|
33
|
+
let res;
|
|
34
|
+
try {
|
|
35
|
+
// No custom jobId: BullMQ mints the deterministic repeat:<schedulerId>:<nextMillis> id itself.
|
|
36
|
+
res = await queue.upsertJobScheduler(schedulerId, { pattern }, { name, data, opts });
|
|
37
|
+
} catch (error) {
|
|
38
|
+
const loud = configError(`scheduler "${schedulerId}": upsertJobScheduler threw: ${error?.message ?? error}`);
|
|
39
|
+
loud.cause = error;
|
|
40
|
+
throw loud;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (typeof res === "number" && res < 0) {
|
|
44
|
+
throw configError(
|
|
45
|
+
`scheduler "${schedulerId}": upsertJobScheduler returned ${res} (${sentinelName(res)}); a schedule edit would be a silent no-op`,
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const resident = await queue.getJobSchedulers(0, -1, true);
|
|
51
|
+
const configIds = new Set(schedules.map((s) => s.schedulerId));
|
|
52
|
+
|
|
53
|
+
// Custom: trivial set-diff over getJobSchedulers; no package warranted
|
|
54
|
+
const orphanIds = resident
|
|
55
|
+
.map((d) => (typeof d === "string" ? d : (d.key ?? d.id ?? d.name)))
|
|
56
|
+
.filter((rid) => !configIds.has(rid));
|
|
57
|
+
|
|
58
|
+
for (const rid of orphanIds) {
|
|
59
|
+
try {
|
|
60
|
+
await queue.removeJobScheduler(rid);
|
|
61
|
+
} catch {
|
|
62
|
+
// A scheduler already gone (a concurrent reconcile pruned it) is the goal state, not an error.
|
|
63
|
+
}
|
|
64
|
+
log("scheduler_removed_orphan", { schedulerId: rid });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return { installed: schedules.length, removed: orphanIds.length };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Live-reload the cron schedulers from the (changed) triggers file: re-select the cron subset and reconcile
|
|
72
|
+
* it against the resident schedulers -- an add installs, a delete prunes (reconcile already removes orphans),
|
|
73
|
+
* an edit re-upserts. Idempotent, so a spurious watch event costs one no-op reconcile. A bad edit
|
|
74
|
+
* (`loadSchedules` throws a `configError`) is logged and the RUNNING schedulers are KEPT -- a live worker is
|
|
75
|
+
* never taken down by a malformed trigger file (the OQ-008 live-edit safety). Returns `{ ok }` /
|
|
76
|
+
* `{ invalid }` / `{ failed }`. `loadFn`/`reconcileFn` are injectable so the reload is unit-tested with no fs.
|
|
77
|
+
*/
|
|
78
|
+
export async function reloadSchedules(config, queue, { log = () => {}, loadFn = loadSchedules, reconcileFn = reconcile } = {}) {
|
|
79
|
+
let schedules;
|
|
80
|
+
try {
|
|
81
|
+
schedules = loadFn(config);
|
|
82
|
+
} catch (error) {
|
|
83
|
+
log("schedules_reload_invalid", { reason: error?.message ?? String(error), kept: true });
|
|
84
|
+
return { invalid: error?.message ?? String(error) };
|
|
85
|
+
}
|
|
86
|
+
try {
|
|
87
|
+
const r = await reconcileFn(queue, schedules, { log });
|
|
88
|
+
log("schedules_reloaded", { installed: r.installed, removed: r.removed });
|
|
89
|
+
return { ok: true, ...r };
|
|
90
|
+
} catch (error) {
|
|
91
|
+
log("schedules_reload_failed", { reason: error?.message ?? String(error) });
|
|
92
|
+
return { failed: error?.message ?? String(error) };
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* INT-CONTAINER-RUNTIME-CONTRACT. Construct the `docker run` argv for one job container.
|
|
3
|
+
*
|
|
4
|
+
* Every flag here is the enforcement surface of CONST-ISOLATION-CONTAINER-PER-JOB -- pi has no
|
|
5
|
+
* permission system, so the container is the only real control. The argv is built as an explicit
|
|
6
|
+
* array (never a shell string): no interpolation, no injection, and the env allowlist is passed
|
|
7
|
+
* with explicit `-e NAME` where the value is read from the argv env map, never `--env-file` and
|
|
8
|
+
* never a host pass-through.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Where the operator's global pi overlay lands INSIDE the container (REQ-GLOBAL-PI-OVERLAY). Exported
|
|
13
|
+
* because packages.mjs derives the staged-packages root from it: the mount and that root are ONE fact on
|
|
14
|
+
* one side of the boundary, and two literals in two modules could drift apart with both test suites still
|
|
15
|
+
* green. A Linux container path, so it is always built with "/" -- never `path.join`, which yields
|
|
16
|
+
* backslashes when the worker itself runs on Windows.
|
|
17
|
+
*/
|
|
18
|
+
export const CONTAINER_GLOBAL_PI_DIR = "/opt/pi-global";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The session mount and the file inside it, exported together and used by both the argv builder here and
|
|
22
|
+
* the env builder in env-allowlist.mjs. Two literals in two modules is how a mount and the variable
|
|
23
|
+
* naming a path inside it drift apart with both suites green -- the runner would then look for a
|
|
24
|
+
* transcript at a path nothing mounted, find none, and cold-start every job without saying so.
|
|
25
|
+
*
|
|
26
|
+
* Nothing key-derived crosses the boundary: the container always sees the same constant path, so no
|
|
27
|
+
* repository name, no branch name and no host layout is legible from inside a job.
|
|
28
|
+
*/
|
|
29
|
+
export const CONTAINER_SESSION_DIR = "/session";
|
|
30
|
+
export const CONTAINER_SESSION_FILE = `${CONTAINER_SESSION_DIR}/current.jsonl`;
|
|
31
|
+
|
|
32
|
+
/** The fixed isolation flags. Not configurable -- these ARE the boundary. */
|
|
33
|
+
export const ISOLATION_FLAGS = [
|
|
34
|
+
// The image must ALREADY be on this host. `docker run` defaults to --pull=missing, so an unknown name is
|
|
35
|
+
// a registry FETCH: a typo in the operator's image config would otherwise pull and execute a stranger's
|
|
36
|
+
// image under a name that looks like theirs. Every other flag here bounds what a chosen image may DO;
|
|
37
|
+
// this one bounds which image is chosen at all, which is why it leads. The same make-it-unreachable move
|
|
38
|
+
// PI_OFFLINE=1 makes one layer up, and it costs nothing the documented flow was using: the README's
|
|
39
|
+
// install step is an explicit `docker pull && docker tag`, and `pi-job:latest` is a local-only tag with
|
|
40
|
+
// no registry behind it. A readable diagnosis is the preflight's job (image-preflight.mjs); this is the
|
|
41
|
+
// part that cannot be raced.
|
|
42
|
+
"--pull=never",
|
|
43
|
+
"--rm", // ephemeral: gone after the run
|
|
44
|
+
"--init", // reap zombies (Chromium spawns many); node is PID 1 and does not reap
|
|
45
|
+
"--cap-drop=ALL", // pi would otherwise inherit the launching user's capabilities
|
|
46
|
+
"--security-opt",
|
|
47
|
+
"no-new-privileges",
|
|
48
|
+
"--pids-limit=512", // bound a fork bomb (UNVERIFIED figure; measured headroom ~4.5x, see spec)
|
|
49
|
+
"--shm-size=1g", // Chromium OOMs on the default 64MB /dev/shm; NOT --ipc=host (shares host ns)
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Build the full `docker run` argv (excluding the leading "docker").
|
|
54
|
+
*
|
|
55
|
+
* @param image pinned job image tag/digest
|
|
56
|
+
* @param env the closed env map from buildContainerEnv -- passed as explicit -e NAME=VALUE
|
|
57
|
+
* @param jobDir host path to the /job inputs dir (contains prompt.md and pi/); mounted /job:ro
|
|
58
|
+
* @param workspace host path to the fresh clone / local folder (mounted /workspace:rw)
|
|
59
|
+
* @param outboxDir host path to the /outbox chain-request dir (local jobs only); mounted /outbox:rw
|
|
60
|
+
* @param sessionDir host path to this job's OWN copy of its session transcript (REQ-RESUMABLE-SESSION);
|
|
61
|
+
* mounted /session:rw. Per-job, like jobDir -- never the shared store.
|
|
62
|
+
* @param globalPiDir host path to the operator's global pi overlay (REQ-GLOBAL-PI-OVERLAY); mounted /opt/pi-global:ro
|
|
63
|
+
* @param name container name (for `docker stop` at the timeout)
|
|
64
|
+
* @param memory e.g. "4g"; cpus e.g. "2"
|
|
65
|
+
* @param extraFlags escape hatch for a Linux-only --user uid:gid on a bind-mounted local folder
|
|
66
|
+
*/
|
|
67
|
+
export function buildDockerRunArgs({
|
|
68
|
+
image,
|
|
69
|
+
env,
|
|
70
|
+
jobDir,
|
|
71
|
+
workspace,
|
|
72
|
+
outboxDir,
|
|
73
|
+
sessionDir,
|
|
74
|
+
globalPiDir,
|
|
75
|
+
name,
|
|
76
|
+
memory = "4g",
|
|
77
|
+
cpus = "2",
|
|
78
|
+
extraFlags = [],
|
|
79
|
+
}) {
|
|
80
|
+
if (!image) throw new Error("docker run: image is required");
|
|
81
|
+
if (!name) throw new Error("docker run: container name is required");
|
|
82
|
+
if (!workspace) throw new Error("docker run: workspace mount is required");
|
|
83
|
+
|
|
84
|
+
const args = ["run", `--name=${name}`, ...ISOLATION_FLAGS, `--memory=${memory}`, `--cpus=${cpus}`, ...extraFlags];
|
|
85
|
+
|
|
86
|
+
// Explicit env allowlist. Each entry is `-e NAME=VALUE`, built from the closed map -- so a
|
|
87
|
+
// stray host variable cannot ride along (no bare `-e NAME` inheriting from the host, no
|
|
88
|
+
// --env-file). Undefined values are skipped, never passed as an empty string.
|
|
89
|
+
for (const [k, v] of Object.entries(env ?? {})) {
|
|
90
|
+
if (v === undefined || v === null) continue;
|
|
91
|
+
args.push("-e", `${k}=${v}`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// The WHOLE /job dir is read-only (INT-CONTAINER-JOB-INPUTS): it holds prompt.md and pi/, and
|
|
95
|
+
// the agent cannot rewrite any of it. /workspace is the only writable mount.
|
|
96
|
+
if (jobDir) args.push("-v", `${jobDir}:/job:ro`);
|
|
97
|
+
args.push("-v", `${workspace}:/workspace`);
|
|
98
|
+
// Local jobs get a writable /outbox host bind, the same host-bind mechanism as /workspace
|
|
99
|
+
// (DES-WORKER-ON-HOST). github jobs pass no outboxDir, so the request channel does not exist for
|
|
100
|
+
// them -- an untrusted issue author cannot chain (INT-OUTBOX-CONTRACT).
|
|
101
|
+
if (outboxDir) args.push("-v", `${outboxDir}:/outbox`);
|
|
102
|
+
|
|
103
|
+
// This job's OWN copy of its session transcript (REQ-RESUMABLE-SESSION, INT-SESSION-STORE-CONTRACT).
|
|
104
|
+
// Writable, because pi appends to it as the agent works -- and per-job, exactly like jobDir, which is
|
|
105
|
+
// the whole reason CONST-ISOLATION-CONTAINER-PER-JOB's "none host-wide" clause still reads true. The
|
|
106
|
+
// shared store under PI_SESSIONS_DIR is NEVER bind-mounted: one job here would otherwise be able to
|
|
107
|
+
// read and rewrite every other branch's and every other repository's transcripts, which is not a
|
|
108
|
+
// weakening of that constraint but its inversion. Absent unless the trigger armed run.resume AND a key
|
|
109
|
+
// resolved, so an unarmed job's argv is byte-identical to one built before this feature existed.
|
|
110
|
+
if (sessionDir) args.push("-v", `${sessionDir}:${CONTAINER_SESSION_DIR}`);
|
|
111
|
+
|
|
112
|
+
// The operator's global pi overlay (REQ-GLOBAL-PI-OVERLAY): custom models, global skills, a global
|
|
113
|
+
// persona, layered UNDER each repo's own .pi/. Read-only -- it is operator-authored deploy-time config,
|
|
114
|
+
// the same trust class as the baked floor, but the agent still must not rewrite it. Both job kinds.
|
|
115
|
+
if (globalPiDir) args.push("-v", `${globalPiDir}:${CONTAINER_GLOBAL_PI_DIR}:ro`);
|
|
116
|
+
|
|
117
|
+
args.push(image);
|
|
118
|
+
return args;
|
|
119
|
+
}
|