@edgehero/pi-dispatch 1.10.2 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +300 -148
- package/README.md +50 -0
- package/deploy/com.pi-dispatch.worker.plist +9 -3
- 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 +11 -0
- package/deploy/worker-env-wrapper.sh +60 -34
- package/deploy/worker.service +18 -8
- package/package.json +14 -4
- 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 +4701 -414
- package/src/egress-conf-copy.mjs +166 -0
- package/src/egress-proxy-state.mjs +151 -0
- package/src/egress.mjs +455 -25
- package/src/entry.mjs +27 -0
- package/src/env-allowlist.mjs +222 -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-registry.mjs +32 -5
- package/src/identity.mjs +29 -4
- package/src/image-preflight.mjs +46 -11
- package/src/image-ref.mjs +21 -0
- package/src/index.mjs +387 -17
- 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/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/processor.mjs +505 -26
- package/src/provider-key.mjs +41 -0
- package/src/provider-steering.mjs +144 -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 +23 -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 +1395 -268
- package/src/transient.mjs +240 -0
- package/src/triggers-file.mjs +71 -15
- package/src/triggers.mjs +176 -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/prepare-github.mjs
CHANGED
|
@@ -40,6 +40,7 @@
|
|
|
40
40
|
* github-host.mjs's 404-vs-other split: only the determinate class becomes policy.
|
|
41
41
|
*/
|
|
42
42
|
|
|
43
|
+
import { GIT_SAFE_CONFIG } from "./git-hardening.mjs";
|
|
43
44
|
import { execFile } from "node:child_process";
|
|
44
45
|
import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
45
46
|
import { tmpdir } from "node:os";
|
|
@@ -52,13 +53,12 @@ import { InfraRetry } from "./processor.mjs";
|
|
|
52
53
|
const exec = promisify(execFile);
|
|
53
54
|
|
|
54
55
|
// The -c flags that make a clone/checkout run no repo-supplied code, applied to EVERY git invocation
|
|
55
|
-
// through `hardened()` below so they cannot be omitted at a call site.
|
|
56
|
-
//
|
|
56
|
+
// through `hardened()` below so they cannot be omitted at a call site. The shared bar comes from
|
|
57
|
+
// git-hardening.mjs; the clone-specific transport/credential locks are this file's own, and they sit
|
|
58
|
+
// BETWEEN the shared pairs and `--no-pager`, which is why that module exports the pairs separately from
|
|
59
|
+
// the finished read prefix -- composing here must not reorder this argv.
|
|
57
60
|
const HARDEN_FLAGS = [
|
|
58
|
-
|
|
59
|
-
"core.hooksPath=/dev/null",
|
|
60
|
-
"-c",
|
|
61
|
-
"core.fsmonitor=false",
|
|
61
|
+
...GIT_SAFE_CONFIG,
|
|
62
62
|
"-c",
|
|
63
63
|
"protocol.ext.allow=never",
|
|
64
64
|
"-c",
|
package/src/prepare-local.mjs
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
|
+
import { GIT_READ_FLAGS } from "./git-hardening.mjs";
|
|
1
2
|
import { execFile } from "node:child_process";
|
|
2
|
-
import
|
|
3
|
+
import * as realFs from "node:fs";
|
|
3
4
|
import { basename, join } from "node:path";
|
|
4
5
|
import { promisify } from "node:util";
|
|
5
6
|
import { materializePiDir } from "./materialize.mjs";
|
|
7
|
+
import { InfraRetry } from "./processor.mjs";
|
|
8
|
+
import { isDeterminateFsCode } from "./transient.mjs";
|
|
6
9
|
|
|
7
10
|
const exec = promisify(execFile);
|
|
8
11
|
|
|
@@ -24,25 +27,17 @@ const exec = promisify(execFile);
|
|
|
24
27
|
* default keeps a directly-constructed call (tests, older wiring) honest: a job with no derived
|
|
25
28
|
* context is a manual run.
|
|
26
29
|
*/
|
|
27
|
-
export async function prepareLocalWorkspace({ folder, task, jobDir, git = defaultGit, event = { source: "manual" } }) {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
error.piDispatchConfig = true;
|
|
31
|
-
throw error;
|
|
32
|
-
}
|
|
33
|
-
if (!existsSync(join(folder, ".git"))) {
|
|
34
|
-
const error = new Error(`local folder is not a git repository (v1 requires one): ${folder}`);
|
|
35
|
-
error.piDispatchConfig = true;
|
|
36
|
-
throw error;
|
|
37
|
-
}
|
|
30
|
+
export async function prepareLocalWorkspace({ folder, task, jobDir, git = defaultGit, event = { source: "manual" }, fs = realFs }) {
|
|
31
|
+
requirePath(fs, folder, `local folder does not exist: ${folder}`, "the local folder");
|
|
32
|
+
requirePath(fs, join(folder, ".git"), `local folder is not a git repository (v1 requires one): ${folder}`, "the local folder's .git");
|
|
38
33
|
|
|
39
34
|
const sha = (await git(folder, ["rev-parse", "HEAD"])).trim();
|
|
40
35
|
|
|
41
|
-
mkdirSync(jobDir, { recursive: true });
|
|
36
|
+
fs.mkdirSync(jobDir, { recursive: true });
|
|
42
37
|
// The outbox is the container's only signal channel back to the worker (INT-OUTBOX-CONTRACT). It is
|
|
43
38
|
// mounted /outbox:rw for local jobs only; the host reads it after the run to enqueue chained children.
|
|
44
39
|
const outboxDir = join(jobDir, "outbox");
|
|
45
|
-
mkdirSync(outboxDir, { recursive: true });
|
|
40
|
+
fs.mkdirSync(outboxDir, { recursive: true });
|
|
46
41
|
// Instructions from HEAD, via the symlink-safe git materialiser, into /job/pi (mounted :ro).
|
|
47
42
|
const pi = await materializePiDir({ gitDir: folder, sha, destDir: jobDir });
|
|
48
43
|
// A .pi/ over a materialiser cap (issue #60) refuses the job determinately, before prompt.md and
|
|
@@ -52,7 +47,7 @@ export async function prepareLocalWorkspace({ folder, task, jobDir, git = defaul
|
|
|
52
47
|
const written = pi.written;
|
|
53
48
|
|
|
54
49
|
// The task the operator asked for. Plain data below the instructions.
|
|
55
|
-
writeFileSync(join(jobDir, "prompt.md"), String(task ?? ""), { mode: 0o444 });
|
|
50
|
+
fs.writeFileSync(join(jobDir, "prompt.md"), String(task ?? ""), { mode: 0o444 });
|
|
56
51
|
|
|
57
52
|
// The trigger context, /job/event.json (INT-CONTAINER-JOB-INPUTS): one file per concern, 0o444 like
|
|
58
53
|
// the prompt, written unconditionally so every local run carries its origin. `folder` is the BASENAME
|
|
@@ -66,14 +61,53 @@ export async function prepareLocalWorkspace({ folder, task, jobDir, git = defaul
|
|
|
66
61
|
sha,
|
|
67
62
|
...(event.source === "cron" ? { scheduledFor: event.scheduledFor ?? null, previousRunAt: event.previousRunAt ?? null } : {}),
|
|
68
63
|
};
|
|
69
|
-
writeFileSync(join(jobDir, "event.json"), JSON.stringify(eventBody, null, 2), { mode: 0o444 });
|
|
64
|
+
fs.writeFileSync(join(jobDir, "event.json"), JSON.stringify(eventBody, null, 2), { mode: 0o444 });
|
|
70
65
|
|
|
71
66
|
// The folder itself is /workspace (rw). No clone: local jobs edit in place.
|
|
72
67
|
return { workspace: folder, jobDir, outboxDir, sha, materialised: written };
|
|
73
68
|
}
|
|
74
69
|
|
|
70
|
+
/**
|
|
71
|
+
* Assert that something exists at `path`, distinguishing absence from a filesystem that is momentarily
|
|
72
|
+
* unable to answer (issue #316).
|
|
73
|
+
*
|
|
74
|
+
* `existsSync` was the wrong instrument and it was the only one here for a year: it returns FALSE for
|
|
75
|
+
* every stat error, not only `ENOENT`. So `EACCES` on a parent directory, `EIO` on a failing disk, and a
|
|
76
|
+
* hung or not-yet-mounted autofs/NFS path after a host reboot all reported "your folder does not exist".
|
|
77
|
+
* #310 turned that into a refund, a never-retried delivery and a public comment telling the issue author
|
|
78
|
+
* the operator's deployment is misconfigured, on a deployment that was correct a second earlier and is
|
|
79
|
+
* correct again a second later.
|
|
80
|
+
*
|
|
81
|
+
* `statSync` FOLLOWS symlinks and accepts a file as readily as a directory, both of which this needs:
|
|
82
|
+
* `.git` is a FILE in a worktree and in a submodule, and a symlinked project folder is ordinary.
|
|
83
|
+
*/
|
|
84
|
+
function requirePath(fs, path, absentMessage, what) {
|
|
85
|
+
try {
|
|
86
|
+
fs.statSync(path);
|
|
87
|
+
} catch (error) {
|
|
88
|
+
if (isDeterminateFsCode(error?.code)) {
|
|
89
|
+
const refusal = new Error(absentMessage);
|
|
90
|
+
refusal.piDispatchConfig = true;
|
|
91
|
+
throw refusal;
|
|
92
|
+
}
|
|
93
|
+
// Not absent, just unreachable right now. Throw the retryable class so the queue tries again
|
|
94
|
+
// instead of spending the delivery on a verdict that is wrong by the time it is posted.
|
|
95
|
+
// BASENAME, not the path (issue #289): an InfraRetry survives retries and its message becomes the
|
|
96
|
+
// queue's failedReason and the job_failed line -- a full host path there carries an OS account
|
|
97
|
+
// name, which is buildRecord's own reason for reducing folders to basenames. The config refusals
|
|
98
|
+
// above keep their full paths: they surface on CLI stderr and through the #310 classifier's fixed
|
|
99
|
+
// sentence, where the path is the repair.
|
|
100
|
+
throw new InfraRetry(`could not read ${what} (${error?.code ?? error?.message ?? "unknown"}): ${basename(path)}`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
75
104
|
async function defaultGit(gitDir, args) {
|
|
76
|
-
|
|
105
|
+
// This was the one copy of seven missing `core.fsmonitor=false`, which is why the flags are imported
|
|
106
|
+
// now rather than restated (issue #286's sweep). Nothing was exploitable -- the only command below is
|
|
107
|
+
// `rev-parse HEAD`, which does not refresh the index -- but a local-folder job's agent can write
|
|
108
|
+
// `.git/config` inside /workspace, so the moment this grows a second command that touches the index,
|
|
109
|
+
// an attacker-controlled fsmonitor hook runs on the worker HOST, outside any container.
|
|
110
|
+
const { stdout } = await exec("git", [...GIT_READ_FLAGS, "-C", gitDir, ...args], {
|
|
77
111
|
encoding: "utf8",
|
|
78
112
|
maxBuffer: 16 * 1024 * 1024,
|
|
79
113
|
});
|
package/src/prepare.mjs
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
2
2
|
import { rm } from "node:fs/promises";
|
|
3
3
|
import { join } from "node:path";
|
|
4
|
+
import { resolveBackendName } from "./backend-registry.mjs";
|
|
5
|
+
import { ensureJobsDir } from "./config.mjs";
|
|
4
6
|
import { resolveJobImage } from "./image-preflight.mjs";
|
|
5
7
|
import { retainJobDir } from "./sandbox-store.mjs";
|
|
6
8
|
import { prepareGithubWorkspace } from "./prepare-github.mjs";
|
|
@@ -42,6 +44,10 @@ export const TRIGGER_SKILLS_SUBDIR = "trigger-skills";
|
|
|
42
44
|
export function makePrepareWorkspace({
|
|
43
45
|
jobsDir,
|
|
44
46
|
forgeFor,
|
|
47
|
+
// #277. The deployment's default venue, for the sandbox stamp: a retained directory records the venue the
|
|
48
|
+
// job resolved to through the SAME function the registry dispatches on, so the venue that ran and the
|
|
49
|
+
// venue a sandbox later checks are one answer. Null (a DI seam) stamps no venue, and the sandbox refuses.
|
|
50
|
+
defaultBackend = null,
|
|
45
51
|
// REQ-RESURRECTABLE-SANDBOX. The DEPLOYMENT default image, resolved per job against the trigger's own
|
|
46
52
|
// `run.image` through the SAME function the pre-spend preflight and run-container use -- so the tag that
|
|
47
53
|
// was checked, the tag that ran and the tag a sandbox later re-opens are one answer by construction
|
|
@@ -60,9 +66,14 @@ export function makePrepareWorkspace({
|
|
|
60
66
|
// an unwired dispatcher behaves exactly as it did: a job with no `run.skillsDir` never calls it.
|
|
61
67
|
injectSkills = copySkillTree,
|
|
62
68
|
log = () => {},
|
|
69
|
+
// Issue #464: creates the jobs dir and refuses one another account owns (config.mjs). Asked again before every job,
|
|
70
|
+
// not only here: a temp cleaner (systemd-tmpfiles ages /tmp) can remove an idle jobs dir, and a name under the temp
|
|
71
|
+
// dir that is gone is one any account can create next.
|
|
72
|
+
ensureDir = (dir) => ensureJobsDir(dir),
|
|
63
73
|
}) {
|
|
64
|
-
|
|
65
|
-
return async function prepareWorkspace(job, token, { queueJobId, piVersion = null } = {}) {
|
|
74
|
+
ensureDir(jobsDir);
|
|
75
|
+
return async function prepareWorkspace(job, token, { queueJobId, piVersion = null, jobUser = null, podmanStore = null } = {}) {
|
|
76
|
+
ensureDir(jobsDir);
|
|
66
77
|
const jobDir = mkdtempSync(join(jobsDir, "job-"));
|
|
67
78
|
// The trigger's injected skills (REQ-PER-TRIGGER-SKILLS, issue #60), COPIED here rather than
|
|
68
79
|
// mounted, and copied ONCE for every job kind because this is where local and forge converge.
|
|
@@ -85,9 +96,19 @@ export function makePrepareWorkspace({
|
|
|
85
96
|
log("trigger_skills_injected", { dirs: injected.dirs, files: injected.files, bytes: injected.bytes });
|
|
86
97
|
}
|
|
87
98
|
// What `cleanup` needs to retain this run's directory, stamped here because this is the only place
|
|
88
|
-
// that holds all
|
|
99
|
+
// that holds all of it at once. Applied to the RESULT rather than mutated in, so a preparer's
|
|
89
100
|
// `{ outcome: "policy" }` refusal -- which carries no jobDir -- is passed through untouched.
|
|
90
|
-
const sandbox = {
|
|
101
|
+
const sandbox = {
|
|
102
|
+
jobId: queueJobId ?? null,
|
|
103
|
+
kind: job.kind ?? null,
|
|
104
|
+
image: resolveJobImage(job, jobImage),
|
|
105
|
+
backend: resolveBackendName(job, defaultBackend),
|
|
106
|
+
// Issue #341: WHO the job ran as, so a re-opened sandbox runs as the uid that owns these files. Only when the
|
|
107
|
+
// processor decided it; a direct call stamps nothing, and the sandbox then decides from its own facts.
|
|
108
|
+
...(jobUser ? { jobUser: { user: jobUser.user ?? null, home: jobUser.home ?? null } } : {}),
|
|
109
|
+
// Issue #429: the podman store the run's container lived in, for a podman run only.
|
|
110
|
+
...(typeof podmanStore === "string" && podmanStore !== "" ? { podmanStore } : {}),
|
|
111
|
+
};
|
|
91
112
|
if (job.kind === "local") {
|
|
92
113
|
// Harness text above, operator DATA below: the fixed pointer line names /job/event.json so a
|
|
93
114
|
// flow can discover the trigger context (mirroring the github prompt, which names the same
|
|
@@ -223,7 +244,7 @@ export function makeCleanup({ sandboxDir, retentionHours = 0, log = () => {} } =
|
|
|
223
244
|
await cleanup(prepared);
|
|
224
245
|
return;
|
|
225
246
|
}
|
|
226
|
-
retainJobDir(prepared, { sandboxDir, log });
|
|
247
|
+
retainJobDir(prepared, { sandboxDir, retentionHours, log });
|
|
227
248
|
};
|
|
228
249
|
}
|
|
229
250
|
|