@edgehero/pi-dispatch 1.10.3 → 2.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 +303 -150
- package/README.md +52 -0
- package/deploy/com.pi-dispatch.worker.plist +10 -4
- package/deploy/docker-compose.yml +49 -16
- package/deploy/egress-proxy.conf +32 -2
- package/deploy/nssm-install.cmd +12 -6
- package/deploy/pi-dispatch-egress-out.network +10 -0
- package/deploy/pi-dispatch-egress-proxy.container +50 -0
- package/deploy/pi-dispatch-netns-keeper.container +80 -0
- package/deploy/pi-dispatch-netns-keeper.network +18 -0
- package/deploy/pi-dispatch-valkey.container +51 -0
- package/deploy/pi-dispatch-valkey.network +16 -0
- package/deploy/receiver.service +6 -0
- package/deploy/worker-env-wrapper.cmd +12 -1
- package/deploy/worker-env-wrapper.sh +63 -37
- package/deploy/worker.service +18 -8
- package/package.json +15 -5
- package/src/azure-host.mjs +19 -0
- package/src/azure-identity.mjs +18 -2
- package/src/backend-conformance.mjs +71 -18
- package/src/backend-local.mjs +637 -21
- package/src/backend-podman.mjs +1168 -0
- package/src/backend-registry.mjs +86 -3
- package/src/backends.mjs +489 -37
- package/src/branch.mjs +7 -2
- package/src/cancel-cli.mjs +174 -0
- package/src/cancel-state.mjs +125 -0
- package/src/cli.mjs +188 -90
- package/src/config.mjs +503 -43
- package/src/connection.mjs +374 -8
- package/src/container-spec.mjs +102 -7
- package/src/daemon-facts.mjs +167 -0
- package/src/deployment-venue.mjs +158 -0
- package/src/docker-run.mjs +146 -15
- package/src/doctor.mjs +4756 -394
- package/src/egress-conf-copy.mjs +166 -0
- package/src/egress-proxy-state.mjs +151 -0
- package/src/egress.mjs +456 -25
- package/src/entry.mjs +27 -0
- package/src/env-allowlist.mjs +245 -40
- package/src/env-file.mjs +1869 -33
- package/src/exit-code.mjs +15 -0
- package/src/flow-gate.mjs +5 -3
- package/src/forgejo-host.mjs +19 -0
- package/src/forgejo-identity.mjs +21 -2
- package/src/get-token.mjs +67 -18
- package/src/git-dirty.mjs +9 -1
- package/src/git-hardening.mjs +33 -0
- package/src/github-app-setup.mjs +29 -12
- package/src/github-prompt.mjs +4 -1
- package/src/gitlab-host.mjs +19 -0
- package/src/gitlab-identity.mjs +19 -2
- package/src/host-pi.mjs +19 -3
- package/src/host-registry.mjs +29 -2
- package/src/identity.mjs +29 -4
- package/src/image-preflight.mjs +46 -11
- package/src/image-ref.mjs +21 -0
- package/src/index.mjs +363 -13
- package/src/init.mjs +197 -38
- package/src/job-user.mjs +252 -0
- package/src/json-duplicates.mjs +204 -0
- package/src/live-probes.mjs +1020 -0
- package/src/materialize.mjs +4 -11
- package/src/netns-keeper.mjs +264 -0
- package/src/on-failure.mjs +119 -0
- package/src/outbox.mjs +7 -0
- package/src/packages.mjs +2 -2
- package/src/podman-stack.mjs +1304 -0
- package/src/prepare-github.mjs +6 -6
- package/src/prepare-local.mjs +51 -17
- package/src/prepare.mjs +27 -6
- package/src/pricing.mjs +9 -5
- package/src/processor.mjs +506 -26
- package/src/provider-key.mjs +66 -0
- package/src/provider-steering.mjs +185 -0
- package/src/queue.mjs +35 -8
- package/src/redact.mjs +84 -0
- package/src/reserved-env.mjs +7 -3
- package/src/retention-sweep.mjs +178 -0
- package/src/run-container.mjs +181 -14
- package/src/run-history.mjs +105 -16
- package/src/runtime-observations.mjs +1152 -0
- package/src/runtime-settings.mjs +13 -8
- package/src/sandbox-cli.mjs +100 -95
- package/src/sandbox-store.mjs +612 -45
- package/src/sandbox.mjs +1459 -37
- package/src/schedules.mjs +16 -3
- package/src/secret-profiles.mjs +2 -1
- package/src/secrets.mjs +24 -6
- package/src/service-env.mjs +247 -0
- package/src/service.mjs +618 -28
- package/src/session-store.mjs +678 -53
- package/src/start.mjs +1348 -326
- package/src/subscriptions.mjs +7 -3
- package/src/transient.mjs +240 -0
- package/src/triggers-file.mjs +71 -15
- package/src/triggers.mjs +179 -19
- package/src/up.mjs +1399 -85
- package/src/valkey-auth.mjs +529 -0
- package/src/valkey-endpoint.mjs +367 -0
- package/src/watch-closer.mjs +158 -0
package/src/config.mjs
CHANGED
|
@@ -5,14 +5,16 @@
|
|
|
5
5
|
* Errors are tagged `piDispatchConfig` so the CLI/entry can print them cleanly and exit non-zero.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { existsSync } from "node:fs";
|
|
9
|
-
import { hostname } from "node:os";
|
|
10
|
-
import { delimiter } from "node:path";
|
|
8
|
+
import { chmodSync, existsSync, lstatSync, mkdirSync, realpathSync, statSync } from "node:fs";
|
|
9
|
+
import { homedir, hostname, tmpdir } from "node:os";
|
|
10
|
+
import { delimiter, isAbsolute, posix } from "node:path";
|
|
11
11
|
import { DEFAULT_BACKEND, backendRefusals, parseBackendFloor, parseBackendList } from "./backends.mjs";
|
|
12
|
-
import {
|
|
12
|
+
import { egressArmed, egressProxyName } from "./egress.mjs";
|
|
13
13
|
import { MINTED_TOKEN_VARS } from "./forges.mjs";
|
|
14
|
+
import { SWEEP_INTERVAL_HOURS, SWEEP_INTERVAL_MAX_HOURS } from "./retention-sweep.mjs";
|
|
14
15
|
import { parseSecretProfiles } from "./secret-profiles.mjs";
|
|
15
16
|
import { WAIT_AFTER_MAX_DEFAULT_MS, WAIT_INTERVAL_FLOOR_MS, parseWaitProfiles } from "./wait-for.mjs";
|
|
17
|
+
import { imageRefProblem } from "./image-ref.mjs";
|
|
16
18
|
|
|
17
19
|
export function configError(message) {
|
|
18
20
|
const error = new Error(message);
|
|
@@ -27,14 +29,30 @@ export function configError(message) {
|
|
|
27
29
|
export const CHAIN_DEPTH_MAX_DEFAULT = 1; // DES-JOB-OUTBOX-CHAINING; 0 = chaining kill-switch (fail-closed)
|
|
28
30
|
export const CHAIN_MAX_PER_JOB_DEFAULT = 2; // INT-OUTBOX-CONTRACT: max request-<n>.json collected per parent
|
|
29
31
|
|
|
30
|
-
|
|
32
|
+
// The failure hook's command (issue #288). Unset/blank -> null (the feature is off). Set -> one
|
|
33
|
+
// ABSOLUTE path, verbatim; a relative path refuses at boot, because resolving it against a service
|
|
34
|
+
// manager's working directory would make the hook fire or vanish depending on who started the worker.
|
|
35
|
+
// `isAbsolute` is the PLATFORM's: on Windows it accepts `C:\...` where the posix one would refuse it,
|
|
36
|
+
// and that half is only testable on Windows itself -- the same only-runnable-there caveat this file
|
|
37
|
+
// already records for the drive-letter split below.
|
|
38
|
+
function parseOnFailure(raw) {
|
|
39
|
+
if (raw === undefined || raw === "") return null;
|
|
40
|
+
if (typeof raw !== "string" || !isAbsolute(raw)) {
|
|
41
|
+
throw configError(`invalid PI_ON_FAILURE: ${JSON.stringify(raw)} (want an absolute path to one executable)`);
|
|
42
|
+
}
|
|
43
|
+
return raw;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// `max` is optional and defaults to no upper bound, which is the convention `optionalBoundedInt` below
|
|
47
|
+
// already documents -- so every existing caller is unchanged by its arrival.
|
|
48
|
+
function boundedInt(env, name, fallback, min, want, max = undefined) {
|
|
31
49
|
const raw = env[name];
|
|
32
50
|
if (raw === undefined || raw === "") {
|
|
33
51
|
if (fallback !== undefined) return fallback;
|
|
34
52
|
throw configError(`missing required env: ${name}`);
|
|
35
53
|
}
|
|
36
54
|
const n = Number.parseInt(raw, 10);
|
|
37
|
-
if (!Number.isInteger(n) || n < min || String(n) !== String(raw).trim()) {
|
|
55
|
+
if (!Number.isInteger(n) || n < min || (max !== undefined && n > max) || String(n) !== String(raw).trim()) {
|
|
38
56
|
throw configError(`invalid ${name}: ${JSON.stringify(raw)} (want ${want})`);
|
|
39
57
|
}
|
|
40
58
|
return n;
|
|
@@ -45,8 +63,8 @@ export function positiveInt(env, name, fallback) {
|
|
|
45
63
|
}
|
|
46
64
|
|
|
47
65
|
// min=0: accepts 0 (a sentinel, e.g. "keep forever" for log retention), still rejects negatives and non-integers.
|
|
48
|
-
function nonNegativeInt(env, name, fallback) {
|
|
49
|
-
return boundedInt(env, name, fallback, 0, "a non-negative integer");
|
|
66
|
+
function nonNegativeInt(env, name, fallback, max = undefined) {
|
|
67
|
+
return boundedInt(env, name, fallback, 0, max === undefined ? "a non-negative integer" : `an integer 0-${max}`, max);
|
|
50
68
|
}
|
|
51
69
|
|
|
52
70
|
// An OPTIONAL bounded int: an unset or empty var is `null` (the feature it gates is disabled), a present
|
|
@@ -107,10 +125,21 @@ function commaList(raw) {
|
|
|
107
125
|
* `GITHUB_APP_PRIVATE_KEY_PATH` is deliberately NOT here: a path string with no mount behind it is inert
|
|
108
126
|
* inside a container, and refusing harmless things is how a refusal stops being read.
|
|
109
127
|
*
|
|
128
|
+
* `VALKEY_PASSWORD` (issue #468) is the queue's password. Whoever holds it can read every queued job (its task text,
|
|
129
|
+
* its repository), enqueue work this deployment's worker runs with its provider key and forge credentials, and delete
|
|
130
|
+
* the queue: the very thing the password was added to keep from another account on the host, and so no more a job
|
|
131
|
+
* container's than theirs.
|
|
132
|
+
*
|
|
110
133
|
* Kept separate from `MINTED_TOKEN_VARS`, which is defined as "every name any forge's mint can write"
|
|
111
134
|
* and derived from the forge table. This is not that, and folding it in would make that definition a lie.
|
|
112
135
|
*/
|
|
113
|
-
export const WORKER_ONLY_SECRET_VARS = new Set(["GITHUB_APP_PRIVATE_KEY"]);
|
|
136
|
+
export const WORKER_ONLY_SECRET_VARS = new Set(["GITHUB_APP_PRIVATE_KEY", "VALKEY_PASSWORD"]);
|
|
137
|
+
|
|
138
|
+
/** Why each of `WORKER_ONLY_SECRET_VARS` stays in the worker, for the refusal that names it. */
|
|
139
|
+
const WORKER_ONLY_WHY = Object.freeze({
|
|
140
|
+
GITHUB_APP_PRIVATE_KEY: "the App's signing key mints tokens for every repository the App is installed on",
|
|
141
|
+
VALKEY_PASSWORD: "the queue's password lets whoever holds it read, enqueue and delete this deployment's jobs",
|
|
142
|
+
});
|
|
114
143
|
|
|
115
144
|
/**
|
|
116
145
|
* The proxy variables the egress policy writes into the closed container env (REQ-EGRESS-ALLOWLIST).
|
|
@@ -133,7 +162,7 @@ function forwardEnvList(raw, egressArmed = false) {
|
|
|
133
162
|
const workerOnly = names.filter((n) => WORKER_ONLY_SECRET_VARS.has(n));
|
|
134
163
|
if (workerOnly.length > 0) {
|
|
135
164
|
throw configError(
|
|
136
|
-
`PI_FORWARD_ENV must not forward ${workerOnly.join(", ")} --
|
|
165
|
+
`PI_FORWARD_ENV must not forward ${workerOnly.join(", ")} -- ${workerOnly.map((n) => WORKER_ONLY_WHY[n]).join("; ")}, and a job container is the last place it belongs (CONST-TOKEN-SCOPED-PER-JOB)`,
|
|
137
166
|
);
|
|
138
167
|
}
|
|
139
168
|
const egress = egressArmed ? names.filter((n) => EGRESS_ENV_VARS.has(n)) : [];
|
|
@@ -200,6 +229,19 @@ function refuseBackendShortfall(config) {
|
|
|
200
229
|
if (first) throw configError(first);
|
|
201
230
|
}
|
|
202
231
|
|
|
232
|
+
/**
|
|
233
|
+
* PI_JOB_IMAGE as the worker runs it (issue #471): unset or empty is pi-job:latest (`||`, so "" falls back), and any
|
|
234
|
+
* other value is judged by the one image rule `run.image` is (`image-ref.mjs`). A refused value is a config error at
|
|
235
|
+
* boot (exit 2), naming the key: before #471 a dash-leading value booted, and every job then handed the runtime a flag
|
|
236
|
+
* where the image belongs, after its budget slot was reserved.
|
|
237
|
+
*/
|
|
238
|
+
export function jobImageFrom(env) {
|
|
239
|
+
const image = env.PI_JOB_IMAGE || "pi-job:latest";
|
|
240
|
+
const problem = imageRefProblem(image);
|
|
241
|
+
if (problem) throw configError(`PI_JOB_IMAGE ${problem.reason} (got ${JSON.stringify(image)})`);
|
|
242
|
+
return image;
|
|
243
|
+
}
|
|
244
|
+
|
|
203
245
|
// The operator's global pi overlay dir (REQ-GLOBAL-PI-OVERLAY). Unset/empty = feature off. When set it
|
|
204
246
|
// must EXIST at boot -- a typo pointing at nothing would silently drop the operator's whole setup on
|
|
205
247
|
// every job, so fail loud like every other config error rather than degrade to nothing.
|
|
@@ -278,7 +320,7 @@ export function loadConfig(env = process.env, { fileExists = existsSync } = {})
|
|
|
278
320
|
maxTurns: positiveInt(env, "PI_MAX_TURNS", 30), // pi has no turn limit; we impose one
|
|
279
321
|
maxTokens: optionalBoundedInt(env, "PI_MAX_TOKENS", 1), // issue #25; null = per-job token budget disabled (lagging in-run backstop)
|
|
280
322
|
dailyTokenCap: optionalBoundedInt(env, "PI_DAILY_TOKEN_CAP", 1), // issue #25; null = daily token counter disabled (check-AFTER, host-side)
|
|
281
|
-
jobImage: env
|
|
323
|
+
jobImage: jobImageFrom(env), // || (not ??) so an empty string falls back; "" is falsy and would throw inside buildDockerRunArgs AFTER a budget slot was reserved
|
|
282
324
|
globalPiDir: resolveGlobalPiDir(env, fileExists), // REQ-GLOBAL-PI-OVERLAY: operator's ~/.pi/agent subset, :ro-mounted; null = off
|
|
283
325
|
allowGlobalExtensions: globalExtensionsEnabled(env), // REQ-GLOBAL-PI-OVERLAY: ON unless PI_GLOBAL_ALLOW_EXTENSIONS=0
|
|
284
326
|
// REQ-EGRESS-ALLOWLIST. `egress` gates the whole feature; `egressProxy` names the component the
|
|
@@ -294,10 +336,10 @@ export function loadConfig(env = process.env, { fileExists = existsSync } = {})
|
|
|
294
336
|
// trigger says otherwise". `parseBackendList` never returns empty, so the fallback is belt-and-braces
|
|
295
337
|
// against a future edit rather than a reachable branch today.
|
|
296
338
|
defaultBackend: backends[0] ?? DEFAULT_BACKEND,
|
|
297
|
-
egressProxy: env
|
|
339
|
+
egressProxy: egressProxyName(env), // one derivation, shared with the panel's sandbox (#277)
|
|
298
340
|
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
|
|
299
341
|
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.
|
|
300
|
-
jobsDir: env
|
|
342
|
+
jobsDir: jobsDirPath(env),
|
|
301
343
|
// REQ-RESURRECTABLE-SANDBOX. `||` (not `??`) so an empty string falls back, matching logsDir.
|
|
302
344
|
sandboxDir: env.PI_SANDBOX_DIR || defaultSandboxDir(env),
|
|
303
345
|
// Hours a finished run's directory stays re-openable. NOTE THE SENTINEL, which is the OPPOSITE of
|
|
@@ -309,18 +351,26 @@ export function loadConfig(env = process.env, { fileExists = existsSync } = {})
|
|
|
309
351
|
sandboxPinDays: nonNegativeInt(env, "PI_SANDBOX_PIN_DAYS", 7), // `--pin` extends to now + this, never to forever
|
|
310
352
|
sandboxIdleMinutes: nonNegativeInt(env, "PI_SANDBOX_IDLE_MINUTES", 30), // bash's own TMOUT inside a sandbox; 0 = no idle logout
|
|
311
353
|
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")
|
|
312
|
-
pauseWindowsFile: env
|
|
313
|
-
scopedLimitsFile: env
|
|
354
|
+
pauseWindowsFile: pauseWindowsFilePath(env), // REQ-SCOPED-PAUSE-WINDOWS: per-folder/repo timed pause; null = no scoped pauses
|
|
355
|
+
scopedLimitsFile: scopedLimitsFilePath(env), // issue #242: per-scope run caps + concurrency (INT-SCOPED-LIMITS-FILE-CONTRACT); null = none. The one-job-per-folder mutex for local jobs is code, not configuration, and holds regardless
|
|
314
356
|
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
|
|
315
|
-
logsDir: env
|
|
316
|
-
settingsFile: env
|
|
357
|
+
logsDir: logsDirPath(env), // || (not ??) inside logsDirPath, so an empty string falls back to the default
|
|
358
|
+
settingsFile: settingsFilePath(env), // || (not ??) inside settingsFilePath, so an empty string falls back; INT-CONFIG-OVERLAY-CONTRACT
|
|
317
359
|
captureJobLogs: env.PI_CAPTURE_JOB_LOGS === "1", // no-pii-in-logs: raw job-log capture is opt-in; anything but "1" is off
|
|
318
360
|
logRetentionDays: nonNegativeInt(env, "PI_LOG_RETENTION_DAYS", 30), // 0 = keep forever
|
|
319
|
-
//
|
|
320
|
-
//
|
|
321
|
-
//
|
|
322
|
-
//
|
|
323
|
-
//
|
|
361
|
+
// issue #292, OQ-007: how often the three host-side retention sweeps re-run while the worker is up.
|
|
362
|
+
// 0 = BOOT-ONLY, which is byte-identical to every version before this one -- start.mjs does not even
|
|
363
|
+
// CONSTRUCT the sweep at 0, so no timer, no closer and no second invocation of any reaper is reachable.
|
|
364
|
+
// The ceiling is enforced because setInterval clamps a delay past 2^31-1 ms to 1ms (see
|
|
365
|
+
// SWEEP_INTERVAL_MAX_HOURS); a hot loop over the filesystem is a worse failure than a slow sweep.
|
|
366
|
+
sweepIntervalHours: nonNegativeInt(env, "PI_SWEEP_INTERVAL_HOURS", SWEEP_INTERVAL_HOURS, SWEEP_INTERVAL_MAX_HOURS),
|
|
367
|
+
// REQ-RESUMABLE-SESSION. NO DEFAULT AT ALL, deliberately unlike every sibling above: unset means the
|
|
368
|
+
// feature is unavailable, and a trigger that armed run.resume then refuses PRE-SPEND rather than
|
|
369
|
+
// running silently without persistence. A transcript is the most PII-bearing artifact this system
|
|
370
|
+
// holds -- tool output, file contents, the agent's own reasoning -- and a DEFAULT would turn that
|
|
371
|
+
// refusal into a silent success on a path nobody chose. That reasoning stands on its own and does
|
|
372
|
+
// not rest on where the other stores default: issue #290 moved logs and settings to ~/.pi-dispatch
|
|
373
|
+
// and this one still has no default, for the same reason it never did.
|
|
324
374
|
sessionsDir: env.PI_SESSIONS_DIR || null,
|
|
325
375
|
sessionsTtlDays: nonNegativeInt(env, "PI_SESSIONS_TTL_DAYS", 14), // 0 = keep forever
|
|
326
376
|
// A bound on how large a transcript may be before it stops being resumed. Not disk hygiene: an
|
|
@@ -395,6 +445,16 @@ export function loadConfig(env = process.env, { fileExists = existsSync } = {})
|
|
|
395
445
|
// broken check loud in minutes instead of silent for a day (OQ-027: most CLIs exit 1 for everything).
|
|
396
446
|
waitMaxChecks: positiveInt(env, "PI_WAIT_MAX_CHECKS", 96),
|
|
397
447
|
waitMaxFaults: positiveInt(env, "PI_WAIT_MAX_FAULTS", 5),
|
|
448
|
+
// Issue #288, the operator's failure hook: ONE command, exec'd with id-only argv when a paid job
|
|
449
|
+
// reaches a terminal failure. Unset = off, byte-identically. Set = must be an ABSOLUTE path,
|
|
450
|
+
// refused at boot otherwise (parseWaitProfiles' fail-loud posture, and sharper here: a silently
|
|
451
|
+
// dropped hook is a notification the operator believes is wired, discovered at the failure it was
|
|
452
|
+
// wired for). Existence/executability are probed at FIRE time, not boot, so a script installed
|
|
453
|
+
// mid-day works and one deleted mid-day logs `unresolvable` rather than pretending.
|
|
454
|
+
onFailure: parseOnFailure(env.PI_ON_FAILURE),
|
|
455
|
+
// Bounds a LEAKED CHILD after the job, not a pre-spend wait -- which is why this differs from its
|
|
456
|
+
// two 10s twins in what it protects: nothing here holds a slot or delays a container.
|
|
457
|
+
onFailureTimeoutMs: positiveInt(env, "PI_ON_FAILURE_TIMEOUT_MS", 10000),
|
|
398
458
|
github: { ...loadGitHubAuth(env, fileExists), allowGhResume: env.PI_SESSIONS_ALLOW_GH_SOURCE === "1" },
|
|
399
459
|
gitlab: loadGitLabAuth(env),
|
|
400
460
|
forgejo: loadForgejoAuth(env),
|
|
@@ -498,28 +558,433 @@ export function loadGitHubAuth(env, fileExists) {
|
|
|
498
558
|
return { source, patVar, appId, installationId, privateKeyPath, privateKey };
|
|
499
559
|
}
|
|
500
560
|
|
|
501
|
-
// env-internal TMPDIR, TEMP: the OS temp dir, read to place the default job,
|
|
502
|
-
//
|
|
503
|
-
//
|
|
504
|
-
|
|
505
|
-
|
|
561
|
+
// env-internal TMPDIR, TEMP: the OS temp dir, read to place the default job, sandbox and graph paths
|
|
562
|
+
// below -- and, only on a host with no home directory to name, as the last-resort fallback for the
|
|
563
|
+
// durable state root. Not a variable of this project's and not a deployment knob: PI_JOBS_DIR,
|
|
564
|
+
// PI_LOGS_DIR, PI_GRAPH_DIR and PI_SETTINGS_FILE are how an operator moves any of them, and
|
|
565
|
+
// .env.example says so.
|
|
566
|
+
|
|
567
|
+
/** This process's effective uid, the one its files are created as, or null where the platform has none (Windows). */
|
|
568
|
+
function currentUid() {
|
|
569
|
+
return typeof process.geteuid === "function" ? process.geteuid() : null;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/**
|
|
573
|
+
* The per-account root under the OS temp dir that the default jobs, sandbox and graph paths live in (issue #464):
|
|
574
|
+
* `<tmp>/pi-dispatch-<uid>`, or `<tmp>/pi-dispatch` where the platform has no uid (Windows, whose TEMP is per user).
|
|
575
|
+
*
|
|
576
|
+
* Per ACCOUNT because the OS temp dir is shared by every account on a host. The old `<tmp>/pi-dispatch` was created by
|
|
577
|
+
* whichever account ran a job first (mode 775 or 755 under that account), and every other account's jobs then failed
|
|
578
|
+
* `EACCES` at `mkdtemp`, their worker service included, while doctor said ready (measured on Fedora 44 and Ubuntu
|
|
579
|
+
* 24.04). Not `$XDG_RUNTIME_DIR`: that is a small tmpfs (10% of RAM by default) that a retained sandbox, a whole repo
|
|
580
|
+
* clone, would fill, it is removed at the account's last logout without linger, and a shell reached through `sudo -iu`
|
|
581
|
+
* has none, so the worker and doctor could disagree about where the jobs are. `ensureAccountTempRoot` creates this
|
|
582
|
+
* directory 0700 and refuses one this account does not own, since anyone can create a name under a sticky /tmp first.
|
|
583
|
+
*/
|
|
584
|
+
export function accountTempRoot(env = process.env, uid = currentUid()) {
|
|
585
|
+
const suffix = Number.isInteger(uid) && uid >= 0 ? `-${uid}` : "";
|
|
586
|
+
return `${tempRoot(env)}/pi-dispatch${suffix}`;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
function defaultJobsDir(env = process.env, uid = currentUid()) {
|
|
590
|
+
// Under the OS temp dir by default, and it STAYS there (issue #290 moved only the two durable
|
|
591
|
+
// stores). Holds only the read-only /job inputs (prompt + .pi/), rebuilt from scratch every job; the
|
|
506
592
|
// workspace for a local job is the operator's own folder, not here.
|
|
507
|
-
|
|
593
|
+
//
|
|
594
|
+
// Takes `env` because its caller `defaultSandboxDir` advertises one and could not honour it while
|
|
595
|
+
// this function read `process.env` directly -- an injected TMPDIR was silently ignored one frame in.
|
|
596
|
+
return `${accountTempRoot(env, uid)}/jobs`;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/**
|
|
600
|
+
* The per-job directory root, ONE derivation (issue #278). `doctor --live` builds its fixture here, and a probe that
|
|
601
|
+
* derived the path on its own could disagree with the worker about `PI_JOBS_DIR=""` (kept as given, `??` not `||`,
|
|
602
|
+
* as `loadConfig` always has) or an injected `TMPDIR`, and read back a directory no job uses. `loadConfig` read
|
|
603
|
+
* `defaultJobsDir()` without its `env` before this, which ignored an injected TMPDIR exactly as `defaultSandboxDir`'s
|
|
604
|
+
* comment below records for itself; identical on the real path, where env is process.env.
|
|
605
|
+
*/
|
|
606
|
+
export function jobsDirPath(env = process.env, uid = currentUid()) {
|
|
607
|
+
return env.PI_JOBS_DIR ?? defaultJobsDir(env, uid);
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* The jobs dir, made ready for this account's jobs, or a thrown Error saying why it cannot be (issue #464). A jobs dir
|
|
612
|
+
* inside the per-account root (`accountTempRoot`, where the default lives) secures that root first; then the jobs dir
|
|
613
|
+
* is created (0700 where it is new) and must be a directory this account owns. A `PI_JOBS_DIR` elsewhere that another
|
|
614
|
+
* account owns is refused too: the owner of the directory holding a job's inputs can rename them and put its own in
|
|
615
|
+
* their place. `fs` is a seam; with no uid (Windows) nothing is checked and the dir is created as before. A refusal is a
|
|
616
|
+
* `configError` (determinate: the next try meets the same owner); a failed mkdir is thrown as the fs error it is.
|
|
617
|
+
*/
|
|
618
|
+
export function ensureJobsDir(jobsDir, { env = process.env, uid = currentUid(), fs = { mkdirSync, lstatSync, statSync, chmodSync } } = {}) {
|
|
619
|
+
ensureUnderAccountRoot(jobsDir, { env, uid, fs });
|
|
620
|
+
fs.mkdirSync(jobsDir, { recursive: true, mode: 0o700 });
|
|
621
|
+
if (!Number.isInteger(uid)) return;
|
|
622
|
+
const st = fs.statSync(jobsDir);
|
|
623
|
+
if (!st.isDirectory()) throw configError(`the jobs dir ${jobsDir} is not a directory`);
|
|
624
|
+
if (st.uid !== uid) throw configError(`the jobs dir ${jobsDir} is owned by uid ${st.uid}, not by this account (uid ${uid}), and that account could replace a job's inputs: ${jobsDirOwnerFix(jobsDir, false)}`);
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/**
|
|
628
|
+
* The sandbox dir (retained workspaces), checked at boot as the jobs dir is (issue #464, gate round 1): inside the
|
|
629
|
+
* per-account root that root is secured first; a sandbox dir that exists must be a directory this account owns. Not
|
|
630
|
+
* created here: the retention step makes it 0700 when it first keeps a run (`retainJobDir`), and asks the owner again
|
|
631
|
+
* then. The owner of the directory holding a retained workspace can rename it and plant a manifest of its own in its
|
|
632
|
+
* place, which `pi-dispatch sandbox` then lists and opens (measured: an explicit PI_SANDBOX_DIR another account made
|
|
633
|
+
* 0777 was used, and that account swapped the entry). A `configError`, since a restart meets the same owner.
|
|
634
|
+
*/
|
|
635
|
+
export function ensureSandboxDir(sandboxDir, { env = process.env, uid = currentUid(), fs = { mkdirSync, lstatSync, statSync, chmodSync } } = {}) {
|
|
636
|
+
ensureUnderAccountRoot(sandboxDir, { env, uid, fs });
|
|
637
|
+
if (!Number.isInteger(uid)) return;
|
|
638
|
+
let st;
|
|
639
|
+
try {
|
|
640
|
+
st = fs.statSync(sandboxDir);
|
|
641
|
+
} catch (err) {
|
|
642
|
+
if (err?.code === "ENOENT") return;
|
|
643
|
+
throw configError(`the sandbox dir ${sandboxDir} could not be read (${err?.code ?? err?.message}): point PI_SANDBOX_DIR at a directory this account owns`);
|
|
644
|
+
}
|
|
645
|
+
if (!st.isDirectory()) throw configError(`the sandbox dir ${sandboxDir} is not a directory: point PI_SANDBOX_DIR at a directory this account owns`);
|
|
646
|
+
if (st.uid !== uid) throw configError(`the sandbox dir ${sandboxDir} is owned by uid ${st.uid}, not by this account (uid ${uid}), and that account could swap a retained workspace for one of its own: ${sandboxDirOwnerFix(sandboxDir)}`);
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/** The remedy for a sandbox dir another account owns, shared with doctor. */
|
|
650
|
+
export function sandboxDirOwnerFix(dir) {
|
|
651
|
+
return `point PI_SANDBOX_DIR at a directory this account owns (or remove the line for the default), or chown ${dir} to this account`;
|
|
508
652
|
}
|
|
509
653
|
|
|
510
|
-
|
|
654
|
+
/**
|
|
655
|
+
* Secure the per-account root (`ensureAccountTempRoot`) when `path` lies in it, else do nothing (issue #464). The one
|
|
656
|
+
* rule for every default that can live there: the jobs, sandbox and graph dirs, and, on an account with no home, the
|
|
657
|
+
* run history and the settings overlay (`defaultStateDir`). Returns whether it did.
|
|
658
|
+
*/
|
|
659
|
+
export function ensureUnderAccountRoot(path, { env = process.env, uid = currentUid(), fs = { mkdirSync, lstatSync, statSync, chmodSync } } = {}) {
|
|
660
|
+
const root = accountTempRoot(env, uid);
|
|
661
|
+
const p = String(path ?? "").replace(/\\/g, "/");
|
|
662
|
+
if (p !== root && !p.startsWith(`${root}/`)) return false;
|
|
663
|
+
ensureAccountTempRoot(root, { uid, fs });
|
|
664
|
+
return true;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/** The remedy for a jobs dir (`inRoot` false) or a per-account root (`inRoot` true) another account owns, shared with doctor. */
|
|
668
|
+
export function jobsDirOwnerFix(dir, inRoot) {
|
|
669
|
+
return inRoot
|
|
670
|
+
? `another account created ${dir} before this one; remove it as its owner or as root (sudo rm -rf ${dir}), or set PI_JOBS_DIR in .env to a directory this account owns`
|
|
671
|
+
: `point PI_JOBS_DIR at a directory this account owns, or chown ${dir} to this account`;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
/**
|
|
675
|
+
* Create `root` mode 0700 when it is absent, and refuse it unless it is a real directory (never a symlink, lstat) owned
|
|
676
|
+
* by `uid`; one this account owns with group or other bits is tightened to 0700. Exported for the admin's graph dir,
|
|
677
|
+
* which lives under the same root. Throws a `configError` naming the path and the remedy.
|
|
678
|
+
*
|
|
679
|
+
* lstat FIRST, never a recursive mkdir first (gate round 1, measured on Fedora 44): with fs.protected_symlinks on (the
|
|
680
|
+
* default on Fedora and Ubuntu), following another account's symlink under the sticky /tmp fails EACCES, and the
|
|
681
|
+
* recursive mkdir's own stat of the existing name did exactly that, so a symlink squat reached the worker as a raw
|
|
682
|
+
* EACCES (exit 1, a systemd restart loop) and each job as a generic failure instead of this refusal. The root itself is
|
|
683
|
+
* made with a plain mkdir, so a name another account creates between the lstat and the mkdir is EEXIST, judged again.
|
|
684
|
+
*/
|
|
685
|
+
export function ensureAccountTempRoot(root, { uid = currentUid(), fs = { mkdirSync, lstatSync, statSync, chmodSync } } = {}) {
|
|
686
|
+
if (!Number.isInteger(uid)) {
|
|
687
|
+
fs.mkdirSync(root, { recursive: true, mode: 0o700 });
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
690
|
+
const refuse = (why) => configError(`${root} ${why}: ${jobsDirOwnerFix(root, true)}`);
|
|
691
|
+
const look = () => {
|
|
692
|
+
try {
|
|
693
|
+
return fs.lstatSync(root);
|
|
694
|
+
} catch (err) {
|
|
695
|
+
if (err?.code === "ENOENT") return null;
|
|
696
|
+
throw refuse(`could not be read (${err?.code ?? err?.message})`);
|
|
697
|
+
}
|
|
698
|
+
};
|
|
699
|
+
let st = look();
|
|
700
|
+
if (st === null) {
|
|
701
|
+
try {
|
|
702
|
+
// The temp dir itself (a TMPDIR the operator named may not exist yet), then the root on its own.
|
|
703
|
+
fs.mkdirSync(posix.dirname(root), { recursive: true });
|
|
704
|
+
fs.mkdirSync(root, { mode: 0o700 });
|
|
705
|
+
} catch (err) {
|
|
706
|
+
if (err?.code !== "EEXIST") throw refuse(`could not be created (${err?.code ?? err?.message})`);
|
|
707
|
+
}
|
|
708
|
+
st = look();
|
|
709
|
+
if (st === null) throw refuse("vanished as it was created");
|
|
710
|
+
}
|
|
711
|
+
if (st.isSymbolicLink() || !st.isDirectory()) throw refuse("is not a directory (a symlink or a file there is refused, since any account can create a name under the temp dir)");
|
|
712
|
+
if (st.uid !== uid) throw refuse(`is owned by uid ${st.uid}, not by this account (uid ${uid})`);
|
|
713
|
+
if ((st.mode & 0o077) !== 0) fs.chmodSync(root, 0o700);
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
export function defaultSandboxDir(env = process.env, uid = currentUid()) {
|
|
511
717
|
// Beside the per-job dirs, because a retained directory IS a per-job dir -- `cleanup` renames it here
|
|
512
718
|
// rather than copying, which only stays atomic while both live on one filesystem. Created mode 0700 by
|
|
513
719
|
// the retention step, since the OS temp dir is 1777 on POSIX and a retained tree holds a repository
|
|
514
720
|
// clone plus the run's prompt.md/event.json. Exported so the admin extension resolves the same default
|
|
515
721
|
// without calling loadConfig, which throws on unrelated env problems.
|
|
516
|
-
|
|
722
|
+
//
|
|
723
|
+
// Under temp deliberately, and unmoved by issue #290: a retained workspace is bounded by
|
|
724
|
+
// PI_SANDBOX_RETENTION_HOURS and is disposable by design, so a swept temp dir costs nothing that the
|
|
725
|
+
// sweep was not already going to take.
|
|
726
|
+
return `${jobsDirPath(env, uid)}/sandboxes`.replace(/\\/g, "/");
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
/**
|
|
730
|
+
* The home directory, or "" when this host cannot name one.
|
|
731
|
+
*
|
|
732
|
+
* `homedir()` throws on a host with no passwd entry (a bare uid in a container) and returns "" when
|
|
733
|
+
* HOME is set but empty -- both measured. This takes `defaultWorkerName`'s posture below: a path is
|
|
734
|
+
* never worth refusing boot for, so an unanswerable home degrades to the temp fallback rather than
|
|
735
|
+
* throwing, and `underOsTempDir` is what makes that degradation VISIBLE instead of silent.
|
|
736
|
+
*/
|
|
737
|
+
export function safeHomeDir() {
|
|
738
|
+
try {
|
|
739
|
+
return homeOrEmpty(homedir());
|
|
740
|
+
} catch {
|
|
741
|
+
return "";
|
|
742
|
+
}
|
|
517
743
|
}
|
|
518
744
|
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
745
|
+
/**
|
|
746
|
+
* Normalise anything offered as a home directory to a usable string, or "".
|
|
747
|
+
*
|
|
748
|
+
* Split out of `safeHomeDir` because `home` is also a SEAM (doctor injects one to exercise the no-home
|
|
749
|
+
* case on a host that has one), and a seam that only the guarded path validates is a guard with a hole:
|
|
750
|
+
* `defaultLogsDir({}, null)` threw a TypeError before this existed. Blank-but-present is "" for the same
|
|
751
|
+
* reason `loadConfig` spells the durable paths with `||` -- an empty value can only mask a working
|
|
752
|
+
* default, never express one.
|
|
753
|
+
*/
|
|
754
|
+
function homeOrEmpty(home) {
|
|
755
|
+
return typeof home === "string" ? home.trim() : "";
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
/**
|
|
759
|
+
* The root of the two DURABLE stores: the run history and the settings overlay (issue #290).
|
|
760
|
+
*
|
|
761
|
+
* Under the home directory, NOT the OS temp dir. Linux leaves TMPDIR unset so the old default was
|
|
762
|
+
* literally `/tmp/pi-dispatch`, and on the distros where /tmp is tmpfs that is RAM: the run history
|
|
763
|
+
* REQ-DURABLE-RUN-HISTORY promises and every cap the operator tuned from the panel were gone on the
|
|
764
|
+
* next reboot, while the queue beside them survived on Valkey's AOF volume.
|
|
765
|
+
*
|
|
766
|
+
* `~/.pi-dispatch` rather than a per-platform state dir (XDG_STATE_HOME, ~/Library/Application
|
|
767
|
+
* Support, LOCALAPPDATA), for four reasons. This project has exactly ONE home-dir pattern and it is
|
|
768
|
+
* this shape (`PI_CODING_AGENT_DIR || ~/.pi/agent`). docs/sessions.md already teaches
|
|
769
|
+
* `~/.pi-dispatch/sessions` as the place to put the session store, so these two land beside a
|
|
770
|
+
* directory the docs already tell an operator to create, and docs/backup.md can name one target.
|
|
771
|
+
* `~/Library/Application Support` contains a space, and doctor prints copy-pasteable fix lines. And a
|
|
772
|
+
* per-platform trio would be three new ENV reads needing their own env-internal markers, where
|
|
773
|
+
* `homedir()` is a node:os call that the env-docs scan does not see at all.
|
|
774
|
+
*
|
|
775
|
+
* WITH NO HOME, this falls back to a temp path, and that is load-bearing rather than a leak: the fallback
|
|
776
|
+
* is exactly what `underOsTempDir` detects, so doctor prints one line naming the path that will not
|
|
777
|
+
* survive a reboot. Since issue #464 that fallback is the PER-ACCOUNT root (`accountTempRoot`,
|
|
778
|
+
* `<tmp>/pi-dispatch-<euid>`), secured by `ensureUnderAccountRoot` as the jobs dir is: the old shared
|
|
779
|
+
* `<tmp>/pi-dispatch` was created by whichever homeless account wrote first, and every other account's
|
|
780
|
+
* records then failed to write, or landed in a directory another account controls. `legacyTempStateDir`
|
|
781
|
+
* still names that OLD shared address, for doctor's migration hint, and is spelled out on its own.
|
|
782
|
+
*
|
|
783
|
+
* The temp fallback reads a BLANK `TMPDIR`/`TEMP` as absent rather than using it.
|
|
784
|
+
* The pre-#290 code spelled this `??`, so `TMPDIR=""` composed the bare `/pi-dispatch` -- a path at the
|
|
785
|
+
* filesystem root that a non-root worker cannot create, and one `underOsTempDir` cannot recognise either,
|
|
786
|
+
* because an empty root is not a prefix of anything. The detector would then have reported "survives a
|
|
787
|
+
* reboot" over a directory that does not even exist. `||` in both places is what keeps the fallback and
|
|
788
|
+
* the detector describing the same world; it is the same rule `loadConfig` applies to PI_LOGS_DIR itself.
|
|
789
|
+
*/
|
|
790
|
+
function defaultStateDir(env = process.env, home = safeHomeDir(), uid = currentUid()) {
|
|
791
|
+
// Strip the home dir's own trailing separator BEFORE composing, not after: stripping the composed
|
|
792
|
+
// string only reaches a slash at the END, and `${"/home/u/"}/.pi-dispatch` puts one in the MIDDLE.
|
|
793
|
+
const h = homeOrEmpty(home);
|
|
794
|
+
if (h !== "") return `${h.replace(/\\/g, "/").replace(/\/+$/, "")}/.pi-dispatch`;
|
|
795
|
+
return accountTempRoot(env, uid);
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
/**
|
|
799
|
+
* The OS temp dir as this project reads it, or "/tmp".
|
|
800
|
+
*
|
|
801
|
+
* TRIMMED and stripped of a trailing separator, which is not cosmetic on either count. A whitespace-only
|
|
802
|
+
* `TMPDIR` composed a RELATIVE path (" /pi-dispatch/logs") that would resolve against whatever cwd the
|
|
803
|
+
* worker was started in, and macOS hands back a trailing slash that otherwise rides into the `//` doctor
|
|
804
|
+
* prints in its copy-pasteable fix lines. Blank reads as absent for the reason `defaultStateDir` explains.
|
|
805
|
+
*/
|
|
806
|
+
function tempRoot(env = process.env) {
|
|
807
|
+
for (const raw of [env.TMPDIR, env.TEMP]) {
|
|
808
|
+
const v = typeof raw === "string" ? raw.trim() : "";
|
|
809
|
+
if (v !== "") return v.replace(/\\/g, "/").replace(/(?!^)\/+$/, "");
|
|
810
|
+
}
|
|
811
|
+
return "/tmp";
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
/** The address the durable stores had before issue #290, so doctor's migration hint can name it. */
|
|
815
|
+
export function legacyTempStateDir(env = process.env) {
|
|
816
|
+
// The SHARED address every account used before issues #290 and #464; not a path anything writes any more.
|
|
817
|
+
return `${tempRoot(env)}/pi-dispatch`;
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
export function defaultLogsDir(env = process.env, home = safeHomeDir(), uid = currentUid()) {
|
|
821
|
+
// Durable by default (issue #290, REQ-DURABLE-RUN-HISTORY): holds the per-run history sidecars and,
|
|
822
|
+
// when PI_CAPTURE_JOB_LOGS=1, the raw logs. A worker-owned path that never enters the container env
|
|
823
|
+
// allowlist (no-broad-env-into-container). Exported so the admin extension resolves the same default
|
|
824
|
+
// without calling loadConfig, which throws on unrelated env problems -- and the admin resolving the
|
|
825
|
+
// SAME answer is the whole point: a panel reading a different directory shows an empty history and
|
|
826
|
+
// says nothing about why.
|
|
827
|
+
return `${defaultStateDir(env, home, uid)}/logs`;
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
export function defaultSettingsFile(env = process.env, home = safeHomeDir(), uid = currentUid()) {
|
|
831
|
+
// Durable by default, beside the run history (issue #290). Holds the runtime-tunable settings overlay
|
|
832
|
+
// shared with the admin extension (INT-CONFIG-OVERLAY-CONTRACT); a worker-owned path that never
|
|
833
|
+
// enters the container env allowlist (no-broad-env-into-container).
|
|
834
|
+
//
|
|
835
|
+
// Losing this file is not neutral: readOverlay treats a missing file as an EMPTY overlay, so a swept
|
|
836
|
+
// settings.json silently restores the wider env cap. That is the same fail-open
|
|
837
|
+
// DES-RUNTIME-SETTINGS-FILE-OVERLAY already refuses on a bad parse, arriving through the filesystem
|
|
838
|
+
// instead of through the parser.
|
|
839
|
+
return `${defaultStateDir(env, home, uid)}/settings.json`;
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
/**
|
|
843
|
+
* The resolved run-history directory. ONE derivation, so loadConfig, the admin and doctor cannot drift.
|
|
844
|
+
*
|
|
845
|
+
* `home` is forwarded rather than resolved here: doctor injects a home seam (it has to, to exercise the
|
|
846
|
+
* no-home case on a host that has one), and a helper that read the real homedir behind that seam's back
|
|
847
|
+
* would answer a different question than the one doctor is asking. Passing `undefined` is what keeps the
|
|
848
|
+
* ordinary caller on `safeHomeDir()`, since an undefined argument activates a default parameter.
|
|
849
|
+
*/
|
|
850
|
+
/**
|
|
851
|
+
* The two boot files' paths, `??` and not `||`, EXPORTED so doctor asks the same question the worker does.
|
|
852
|
+
*
|
|
853
|
+
* The distinction is the whole reason these exist (issue #384). `??` keeps an empty string, so a blank
|
|
854
|
+
* `PI_PAUSE_WINDOWS_FILE=` survives into the config, `start.mjs` loads it unconditionally and the boot
|
|
855
|
+
* refuses; `logsDirPath` below uses `||` and falls back instead. Doctor had a copy of the `??` rule written
|
|
856
|
+
* out by hand, which is how it came to warn about a deployment its own sibling check failed.
|
|
857
|
+
*/
|
|
858
|
+
export function pauseWindowsFilePath(env = process.env) {
|
|
859
|
+
return env.PI_PAUSE_WINDOWS_FILE ?? null;
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
export function scopedLimitsFilePath(env = process.env) {
|
|
863
|
+
return env.PI_SCOPED_LIMITS_FILE ?? null;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
export function logsDirPath(env = process.env, home) {
|
|
867
|
+
return env.PI_LOGS_DIR || defaultLogsDir(env, home);
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
/**
|
|
871
|
+
* The resolved settings-overlay path. Lives HERE, beside `logsDirPath`, so the pair of durable stores has
|
|
872
|
+
* one derivation each and `loadConfig` does not open-code either. `runtime-settings.mjs` re-exports it, so
|
|
873
|
+
* `@edgehero/pi-dispatch/runtime-settings` stays the import path the admin and the probe fixture already
|
|
874
|
+
* use; the alternative (config importing runtime-settings) would be a cycle, since that module reads
|
|
875
|
+
* `defaultSettingsFile` from here.
|
|
876
|
+
*/
|
|
877
|
+
export function settingsFilePath(env = process.env, home) {
|
|
878
|
+
return env.PI_SETTINGS_FILE || defaultSettingsFile(env, home);
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
/** One path in the slash alphabet these defaults are written in, with any trailing separators dropped. */
|
|
882
|
+
function slashy(p) {
|
|
883
|
+
const s = posix.normalize(String(p).replace(/\\/g, "/")).replace(/\/+$/, "");
|
|
884
|
+
return s === "" ? "/" : s;
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
/** A root's spellings: as written, plus its realpath when one resolves. Both count, for a root. */
|
|
888
|
+
function spellings(p, realpath) {
|
|
889
|
+
const out = new Set([slashy(p)]);
|
|
890
|
+
try {
|
|
891
|
+
out.add(slashy(realpath(p)));
|
|
892
|
+
} catch {
|
|
893
|
+
// ENOENT is the NORMAL case, not an error: doctor asks this question before these directories
|
|
894
|
+
// exist. The lexical spelling always remains, and the ROOT usually resolves even when the
|
|
895
|
+
// candidate does not, which is what still catches macOS's /var -> /private/var on a fresh host.
|
|
896
|
+
}
|
|
897
|
+
return out;
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
/**
|
|
901
|
+
* WHERE THE CANDIDATE'S BYTES ACTUALLY LAND, as one spelling.
|
|
902
|
+
*
|
|
903
|
+
* Deliberately not `spellings()`: unioning the written and resolved forms and accepting any match makes a
|
|
904
|
+
* symlink that points OUT of the temp dir a false positive, and this check's stated posture is that a
|
|
905
|
+
* false alarm on a durable path is the worse error. A resolved path is the honest answer to "will the OS
|
|
906
|
+
* sweep this", so it wins whenever it exists. The dirname retry covers the ordinary case where the leaf
|
|
907
|
+
* has not been created yet but its parent is a symlink; when neither resolves, the written form is all
|
|
908
|
+
* there is, which is doctor's situation on a fresh host.
|
|
909
|
+
*/
|
|
910
|
+
function landingSpelling(p, realpath) {
|
|
911
|
+
try {
|
|
912
|
+
return slashy(realpath(p));
|
|
913
|
+
} catch {
|
|
914
|
+
// fall through to the parent
|
|
915
|
+
}
|
|
916
|
+
const written = slashy(p);
|
|
917
|
+
const cut = written.lastIndexOf("/");
|
|
918
|
+
if (cut > 0) {
|
|
919
|
+
try {
|
|
920
|
+
return `${slashy(realpath(written.slice(0, cut)))}${written.slice(cut)}`;
|
|
921
|
+
} catch {
|
|
922
|
+
// fall through to the written form
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
return written;
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
/**
|
|
929
|
+
* Directories a POSIX host clears without being asked, beyond whatever TMPDIR names.
|
|
930
|
+
*
|
|
931
|
+
* `/tmp` is here because it is what the pre-#290 default resolved to on Linux, where TMPDIR is unset,
|
|
932
|
+
* and it is tmpfs on several distributions. The other three are the ones an operator most plausibly
|
|
933
|
+
* reaches for while looking for somewhere fast: `/dev/shm` and `/run` are tmpfs by definition, so they
|
|
934
|
+
* are RAM and do not survive a reboot at all (`/run` does not survive a service restart on some
|
|
935
|
+
* layouts), and `/var/tmp` is swept by systemd-tmpfiles on a 30 day timer, which is the same order as
|
|
936
|
+
* the run history's own retention window. Each of these would otherwise have been reported as durable.
|
|
937
|
+
*/
|
|
938
|
+
const POSIX_VOLATILE_ROOTS = Object.freeze(["/tmp", "/var/tmp", "/dev/shm", "/run"]);
|
|
939
|
+
|
|
940
|
+
/**
|
|
941
|
+
* Does this path sit under a directory the OS is entitled to sweep (issue #290)?
|
|
942
|
+
*
|
|
943
|
+
* ADVISORY, and therefore FAILS OPEN -- the opposite posture from `withinRoots` in
|
|
944
|
+
* secret-profiles.mjs and `folderUnderRoots` in the admin's read-model, which are boundaries and fail
|
|
945
|
+
* closed. Nothing is gated on this answer; it decides whether doctor prints one warning. A false
|
|
946
|
+
* alarm on a durable path is the worse error, because a warning an operator learns to skim is a
|
|
947
|
+
* warning that stops working.
|
|
948
|
+
*
|
|
949
|
+
* It reuses `withinRoots`' ALGORITHM -- normalize, strip trailing separators, then `===` or
|
|
950
|
+
* `startsWith(base + separator)` so a sibling like `<tmp>-notmine` cannot match -- but deliberately
|
|
951
|
+
* does not call it. `withinRoots` compares in the NATIVE alphabet (path.sep), while every default in
|
|
952
|
+
* this file is stored slash-normalized, so its Windows branch would be untestable anywhere but
|
|
953
|
+
* Windows. Comparing in the alphabet the values are written in makes this checkable on every platform.
|
|
954
|
+
*
|
|
955
|
+
* Four cases it has to get right, all measured on this project's own hosts:
|
|
956
|
+
* - macOS TMPDIR carries a TRAILING SLASH, so the old default composed `<tmp>//pi-dispatch/logs`;
|
|
957
|
+
* - macOS /var/folders/... realpaths to /private/var/folders/..., so an operator's /private path
|
|
958
|
+
* is a FALSE NEGATIVE unless both sides are expanded;
|
|
959
|
+
* - `<tmp>-notmine/logs` is a FALSE POSITIVE for a bare prefix test;
|
|
960
|
+
* - realpathSync THROWS on a path that does not exist yet, which is doctor's ordinary situation.
|
|
961
|
+
*
|
|
962
|
+
* os.tmpdir() is checked beside TMPDIR/TEMP because it consults TMP internally, which covers a third
|
|
963
|
+
* variable without naming one here. The literal "/tmp" is checked because it is what the pre-#290
|
|
964
|
+
* default resolved to on Linux, where TMPDIR is unset.
|
|
965
|
+
*/
|
|
966
|
+
export function underOsTempDir(candidate, env = process.env, { realpath = realpathSync, osTmpDir = tmpdir, platform = process.platform } = {}) {
|
|
967
|
+
if (typeof candidate !== "string" || candidate.trim() === "") return false;
|
|
968
|
+
let tmp = "";
|
|
969
|
+
try {
|
|
970
|
+
tmp = osTmpDir();
|
|
971
|
+
} catch {
|
|
972
|
+
// A host that cannot name its own temp dir is not one to throw over from an advisory check.
|
|
973
|
+
}
|
|
974
|
+
const target = landingSpelling(candidate, realpath);
|
|
975
|
+
// TEMP is consulted on WINDOWS ONLY. On POSIX it is not an OS temp variable, and plenty of shells
|
|
976
|
+
// export one for a ported toolchain -- honouring it there turns an ordinary durable subtree into a
|
|
977
|
+
// false "swept" verdict, which is the direction this check says it cares about most.
|
|
978
|
+
const win = platform === "win32";
|
|
979
|
+
for (const root of [env.TMPDIR, win ? env.TEMP : "", tmp, ...(win ? [] : POSIX_VOLATILE_ROOTS)]) {
|
|
980
|
+
if (typeof root !== "string" || root.trim() === "") continue;
|
|
981
|
+
for (const base of spellings(root, realpath)) {
|
|
982
|
+
// `base` is "/" only when the root IS the filesystem root, where `${base}/` would be "//" and
|
|
983
|
+
// match nothing. Everything absolute is under it, which is the honest answer for TMPDIR=/.
|
|
984
|
+
if (target === base || target.startsWith(base === "/" ? "/" : `${base}/`)) return true;
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
return false;
|
|
523
988
|
}
|
|
524
989
|
|
|
525
990
|
/**
|
|
@@ -596,23 +1061,18 @@ function workerName(env) {
|
|
|
596
1061
|
return declared;
|
|
597
1062
|
}
|
|
598
1063
|
|
|
599
|
-
export function defaultGraphDir(env = process.env) {
|
|
1064
|
+
export function defaultGraphDir(env = process.env, uid = currentUid()) {
|
|
600
1065
|
// Under the OS temp dir by default, beside logs/ and jobs/ -- the admin's graph HTML artifact
|
|
601
1066
|
// (issue #54) is host-side display output on the defaultLogsDir doctrine, and deliberately NOT
|
|
602
1067
|
// inside logsDir: INT-RUN-HISTORY-FILE-CONTRACT names that directory's filename shape, and a
|
|
603
1068
|
// stray .html beside the sidecars would widen a contract for a file that is not a record.
|
|
604
1069
|
// Overridable with PI_GRAPH_DIR; exported so the admin resolves the same default without
|
|
605
1070
|
// loadConfig, like defaultSandboxDir above.
|
|
606
|
-
|
|
1071
|
+
// Issue #464: under the per-account root, for the jobs dir's reason: another account's `<tmp>/pi-dispatch` made
|
|
1072
|
+
// this one uncreatable.
|
|
1073
|
+
return `${accountTempRoot(env, uid)}/graph`;
|
|
607
1074
|
}
|
|
608
1075
|
|
|
609
|
-
export function defaultSettingsFile() {
|
|
610
|
-
// Under the OS temp dir by default. Holds the runtime-tunable settings overlay shared with the admin
|
|
611
|
-
// extension (INT-CONFIG-OVERLAY-CONTRACT); a worker-owned path that never enters the container env
|
|
612
|
-
// allowlist (no-broad-env-into-container). Exported so the admin extension resolves the same default
|
|
613
|
-
// without calling loadConfig, which throws on unrelated env problems.
|
|
614
|
-
return `${process.env.TMPDIR ?? process.env.TEMP ?? "/tmp"}/pi-dispatch/settings.json`.replace(/\\/g, "/");
|
|
615
|
-
}
|
|
616
1076
|
|
|
617
1077
|
/**
|
|
618
1078
|
* The worker's Azure DevOps auth config, or `null` when none is configured -- same presence rule as the
|