@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.
Files changed (97) hide show
  1. package/.env.example +300 -148
  2. package/README.md +50 -0
  3. package/deploy/com.pi-dispatch.worker.plist +9 -3
  4. package/deploy/docker-compose.yml +49 -16
  5. package/deploy/egress-proxy.conf +32 -2
  6. package/deploy/nssm-install.cmd +12 -6
  7. package/deploy/pi-dispatch-egress-out.network +10 -0
  8. package/deploy/pi-dispatch-egress-proxy.container +50 -0
  9. package/deploy/pi-dispatch-netns-keeper.container +80 -0
  10. package/deploy/pi-dispatch-netns-keeper.network +18 -0
  11. package/deploy/pi-dispatch-valkey.container +51 -0
  12. package/deploy/pi-dispatch-valkey.network +16 -0
  13. package/deploy/receiver.service +6 -0
  14. package/deploy/worker-env-wrapper.cmd +11 -0
  15. package/deploy/worker-env-wrapper.sh +60 -34
  16. package/deploy/worker.service +18 -8
  17. package/package.json +14 -4
  18. package/src/azure-host.mjs +19 -0
  19. package/src/azure-identity.mjs +18 -2
  20. package/src/backend-conformance.mjs +71 -18
  21. package/src/backend-local.mjs +637 -21
  22. package/src/backend-podman.mjs +1168 -0
  23. package/src/backend-registry.mjs +86 -3
  24. package/src/backends.mjs +489 -37
  25. package/src/branch.mjs +7 -2
  26. package/src/cancel-cli.mjs +174 -0
  27. package/src/cancel-state.mjs +125 -0
  28. package/src/cli.mjs +188 -90
  29. package/src/config.mjs +503 -43
  30. package/src/connection.mjs +374 -8
  31. package/src/container-spec.mjs +102 -7
  32. package/src/daemon-facts.mjs +167 -0
  33. package/src/deployment-venue.mjs +158 -0
  34. package/src/docker-run.mjs +146 -15
  35. package/src/doctor.mjs +4701 -414
  36. package/src/egress-conf-copy.mjs +166 -0
  37. package/src/egress-proxy-state.mjs +151 -0
  38. package/src/egress.mjs +455 -25
  39. package/src/entry.mjs +27 -0
  40. package/src/env-allowlist.mjs +222 -40
  41. package/src/env-file.mjs +1869 -33
  42. package/src/exit-code.mjs +15 -0
  43. package/src/flow-gate.mjs +5 -3
  44. package/src/forgejo-host.mjs +19 -0
  45. package/src/forgejo-identity.mjs +21 -2
  46. package/src/get-token.mjs +67 -18
  47. package/src/git-dirty.mjs +9 -1
  48. package/src/git-hardening.mjs +33 -0
  49. package/src/github-app-setup.mjs +29 -12
  50. package/src/github-prompt.mjs +4 -1
  51. package/src/gitlab-host.mjs +19 -0
  52. package/src/gitlab-identity.mjs +19 -2
  53. package/src/host-registry.mjs +32 -5
  54. package/src/identity.mjs +29 -4
  55. package/src/image-preflight.mjs +46 -11
  56. package/src/image-ref.mjs +21 -0
  57. package/src/index.mjs +387 -17
  58. package/src/init.mjs +197 -38
  59. package/src/job-user.mjs +252 -0
  60. package/src/json-duplicates.mjs +204 -0
  61. package/src/live-probes.mjs +1020 -0
  62. package/src/materialize.mjs +4 -11
  63. package/src/netns-keeper.mjs +264 -0
  64. package/src/on-failure.mjs +119 -0
  65. package/src/outbox.mjs +7 -0
  66. package/src/podman-stack.mjs +1304 -0
  67. package/src/prepare-github.mjs +6 -6
  68. package/src/prepare-local.mjs +51 -17
  69. package/src/prepare.mjs +27 -6
  70. package/src/processor.mjs +505 -26
  71. package/src/provider-key.mjs +41 -0
  72. package/src/provider-steering.mjs +144 -0
  73. package/src/queue.mjs +35 -8
  74. package/src/redact.mjs +84 -0
  75. package/src/reserved-env.mjs +7 -3
  76. package/src/retention-sweep.mjs +178 -0
  77. package/src/run-container.mjs +181 -14
  78. package/src/run-history.mjs +105 -16
  79. package/src/runtime-observations.mjs +1152 -0
  80. package/src/runtime-settings.mjs +13 -8
  81. package/src/sandbox-cli.mjs +100 -95
  82. package/src/sandbox-store.mjs +612 -45
  83. package/src/sandbox.mjs +1459 -37
  84. package/src/schedules.mjs +16 -3
  85. package/src/secret-profiles.mjs +2 -1
  86. package/src/secrets.mjs +23 -6
  87. package/src/service-env.mjs +247 -0
  88. package/src/service.mjs +618 -28
  89. package/src/session-store.mjs +678 -53
  90. package/src/start.mjs +1395 -268
  91. package/src/transient.mjs +240 -0
  92. package/src/triggers-file.mjs +71 -15
  93. package/src/triggers.mjs +176 -19
  94. package/src/up.mjs +1399 -85
  95. package/src/valkey-auth.mjs +529 -0
  96. package/src/valkey-endpoint.mjs +367 -0
  97. package/src/watch-closer.mjs +158 -0
@@ -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. Matches materialize.mjs's bar
56
- // (hooksPath, fsmonitor, --no-pager) plus the clone-specific transport/credential locks.
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
- "-c",
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",
@@ -1,8 +1,11 @@
1
+ import { GIT_READ_FLAGS } from "./git-hardening.mjs";
1
2
  import { execFile } from "node:child_process";
2
- import { existsSync, mkdirSync, writeFileSync } from "node:fs";
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
- if (!existsSync(folder)) {
29
- const error = new Error(`local folder does not exist: ${folder}`);
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
- const { stdout } = await exec("git", ["-c", "core.hooksPath=/dev/null", "--no-pager", "-C", gitDir, ...args], {
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 { mkdirSync, mkdtempSync, rmSync } from "node:fs";
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
- mkdirSync(jobsDir, { recursive: true });
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 three at once. Applied to the RESULT rather than mutated in, so a preparer's
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 = { jobId: queueJobId ?? null, kind: job.kind ?? null, image: resolveJobImage(job, jobImage) };
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