@edgehero/pi-dispatch 1.10.3 → 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 +29 -2
  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 +363 -13
  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 +1348 -326
  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
@@ -0,0 +1,166 @@
1
+ /**
2
+ * A copy of the egress proxy's RULES that has gone stale (issue #484). Two copies of the package's
3
+ * `deploy/egress-proxy.conf` live outside the package: the deployment folder's `deploy/egress-proxy.conf`, which `init`
4
+ * writes create-only (issue #480) and the docker proxy mounts, and the podman venue's account-owned copy,
5
+ * `~/.config/pi-dispatch/egress-proxy.conf`, which `service install` writes (podman-stack.mjs, `proxyConfCopyPath`).
6
+ * Neither is rewritten by an upgrade, so a release that changes the shipped rules leaves the proxy on the old ones, and
7
+ * the mount-path comparison `up` and `doctor` make (egress-proxy-state.mjs) cannot see it: the path is right, only the
8
+ * bytes are old.
9
+ *
10
+ * So the bytes are compared with the INSTALLED package's copy, the one `init` copies from, and a difference is said.
11
+ * Said, never repaired on its own: a difference reads the same whether an upgrade left the file behind or the operator
12
+ * edited it on purpose, and nothing here can tell the two apart. `doctor` warns (⚠: a differing copy still runs a
13
+ * proxy, and may be exactly what the operator meant); `up` offers the refresh, shown and asked, and keeps the old file.
14
+ */
15
+ import { randomBytes } from "node:crypto";
16
+ import { readFileSync } from "node:fs";
17
+ import { basename, dirname, join } from "node:path";
18
+ import { fileURLToPath } from "node:url";
19
+
20
+ /**
21
+ * The proxy's RULES (INT-EGRESS-POLICY-CONTRACT), the package's own copy: `../deploy/` from this module, the layout
22
+ * service.mjs resolves its templates by, which is worker/deploy in a checkout (byte-identical to the root deploy/,
23
+ * pinned by worker/test/publish.test.mjs) and the shipped deploy/ under npm. Issue #480: the docker proxy mounts
24
+ * `./deploy/egress-proxy.conf` from the deployment folder, and a folder made by `npx @edgehero/pi-dispatch up` had
25
+ * none, so `up` declined to start the proxy the policy is on by default for and every job was refused before it spent.
26
+ */
27
+ export const PACKAGED_EGRESS_PROXY_CONF = fileURLToPath(new URL("../deploy/egress-proxy.conf", import.meta.url));
28
+
29
+ /** The installed package's version, for the words "the package's copy (<version>)". Null when it cannot be read. */
30
+ export const PACKAGE_VERSION = (() => {
31
+ try {
32
+ const version = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
33
+ return typeof version === "string" ? version : null;
34
+ } catch {
35
+ return null;
36
+ }
37
+ })();
38
+
39
+ /** The installed package's rules, as text: the file `init` copies. */
40
+ export function readPackagedProxyConf() {
41
+ return String(readFileSync(PACKAGED_EGRESS_PROXY_CONF, "utf8"));
42
+ }
43
+
44
+ /** "the package's copy (2.0.0)", or without the version where it could not be read. */
45
+ export function packageCopyName(version = PACKAGE_VERSION) {
46
+ return version ? `the package's copy (${version})` : "the package's copy";
47
+ }
48
+
49
+ /**
50
+ * A short account of how `current` differs from `packaged`, by lines counted as a multiset (a line's order is not
51
+ * compared, only whether it is there, which is what an operator reading the summary asks first). `diff` is named for
52
+ * the rest. Never the lines themselves: a rules file is short, but a summary that quoted it would print an operator's
53
+ * own edit into every doctor run.
54
+ */
55
+ export function describeConfDifference(current, packaged) {
56
+ // Line endings alone (PR #491's review): an editor or a checkout that wrote CRLF changes every line, and "66 lines of
57
+ // it not in the package's copy" then reads as a rewrite. Said as what it is, naming which side has which.
58
+ const lf = (text) => String(text).replace(/\r\n/g, "\n");
59
+ if (lf(current) === lf(packaged)) {
60
+ const crlf = (text) => String(text).includes("\r\n");
61
+ return `only in its line endings, ${crlf(current) ? "CRLF" : "LF"} here and ${crlf(packaged) ? "CRLF" : "LF"} in the package's`;
62
+ }
63
+ const count = (text) => {
64
+ const m = new Map();
65
+ for (const line of String(text).split("\n")) m.set(line, (m.get(line) ?? 0) + 1);
66
+ return m;
67
+ };
68
+ const a = count(current);
69
+ const b = count(packaged);
70
+ let onlyHere = 0;
71
+ let onlyThere = 0;
72
+ for (const [line, n] of a) onlyHere += Math.max(0, n - (b.get(line) ?? 0));
73
+ for (const [line, n] of b) onlyThere += Math.max(0, n - (a.get(line) ?? 0));
74
+ if (onlyHere === 0 && onlyThere === 0) return "the same lines, in another order";
75
+ const lines = (n) => `${n} line${n === 1 ? "" : "s"}`;
76
+ return `${lines(onlyHere)} of it not in the package's copy, ${lines(onlyThere)} of the package's not in it`;
77
+ }
78
+
79
+ /**
80
+ * Judges one copy against the package's: `{ state }` is "absent" (nothing to compare; whoever mounts it says so),
81
+ * "same", "differs" (with `summary`), "unreadable" (with `error`, the copy could not be read), or "no-package" (with
82
+ * `error`, the package's own copy could not be read, which is a broken install and said as one). `read(path)` reads the
83
+ * copy; `readPackaged()` the package's.
84
+ */
85
+ export function judgeProxyConfCopy({ path, read, readPackaged = readPackagedProxyConf }) {
86
+ let current;
87
+ try {
88
+ current = String(read(path));
89
+ } catch (err) {
90
+ if (err?.code === "ENOENT" || err?.code === "ENOTDIR") return { state: "absent" };
91
+ return { state: "unreadable", error: err?.code ?? err?.message ?? String(err) };
92
+ }
93
+ let packaged;
94
+ try {
95
+ packaged = String(readPackaged());
96
+ } catch (err) {
97
+ return { state: "no-package", error: err?.code ?? err?.message ?? String(err) };
98
+ }
99
+ if (current === packaged) return { state: "same" };
100
+ return { state: "differs", summary: describeConfDifference(current, packaged), packaged };
101
+ }
102
+
103
+ /** A timestamp for a backup's name, from the injected clock: 20260929T101500Z. No colon, which Windows refuses. */
104
+ export function backupStamp(now) {
105
+ return new Date(now).toISOString().replace(/\.\d{3}Z$/, "Z").replace(/[-:]/g, "");
106
+ }
107
+
108
+ /**
109
+ * Replaces the copy at `path` with `text`, keeping the old bytes as `<path>.bak-<stamp>` (issue #484). Returns
110
+ * `{ ok: true, backup }` or `{ ok: false, reason }`, where `reason` finishes the sentence "not replaced: ...".
111
+ *
112
+ * NO SYMLINK IS FOLLOWED, and both ends are asked with `lstat`: a symlinked parent (`deploy/` a link) would put the new
113
+ * file and the backup wherever it points, and a symlinked file would have the backup read through it; both are refused
114
+ * with nothing written, as `init` refuses a symlinked `deploy/` (PR #488's review). Anything that is not a regular file
115
+ * is refused too.
116
+ *
117
+ * ATOMIC: the new text is written to a temp file in the SAME directory (`wx`, so nothing that exists is written through)
118
+ * and renamed over the copy, so a reader (a proxy starting this second) sees the old file or the new one, never half of
119
+ * either, and a failed write leaves the old file exactly as it was. `rename` replaces the directory ENTRY, so a link
120
+ * that appeared at `path` after the check is replaced, never written through. The backup is written `wx` before the
121
+ * rename, so a backup that could not be written replaces nothing. A second refresh in the same second finds the backup
122
+ * name taken and refuses rather than overwrite the first backup.
123
+ */
124
+ export function replaceProxyConfCopy({ path, text, fs, now = Date.now, random = () => randomBytes(6).toString("hex") }) {
125
+ if (typeof fs.lstatSync !== "function") return { ok: false, reason: "this build cannot check the path for a symlink, so it is left as it is" };
126
+ const dir = dirname(path);
127
+ const lst = (p) => {
128
+ try {
129
+ return fs.lstatSync(p);
130
+ } catch {
131
+ return null;
132
+ }
133
+ };
134
+ const parent = lst(dir);
135
+ if (!parent || parent.isSymbolicLink() || !parent.isDirectory()) return { ok: false, reason: `${dir} is ${!parent ? "not there" : parent.isSymbolicLink() ? "a symlink" : "not a directory"}, and the new file would be written wherever it leads` };
136
+ const own = lst(path);
137
+ if (!own) return { ok: false, reason: `${path} is not there` };
138
+ if (own.isSymbolicLink()) return { ok: false, reason: `${path} is a symlink, and the file it points to is not this folder's to replace` };
139
+ if (!own.isFile()) return { ok: false, reason: `${path} is not a regular file` };
140
+ const mode = typeof own.mode === "number" ? own.mode & 0o777 : 0o644;
141
+ let previous;
142
+ try {
143
+ previous = fs.readFileSync(path);
144
+ } catch (err) {
145
+ return { ok: false, reason: `${path} could not be read (${err?.code ?? err?.message})` };
146
+ }
147
+ const backup = `${path}.bak-${backupStamp(now())}`;
148
+ try {
149
+ fs.writeFileSync(backup, previous, { flag: "wx", mode });
150
+ } catch (err) {
151
+ return { ok: false, reason: `the backup ${backup} could not be written (${err?.code ?? err?.message}), so the copy was not touched` };
152
+ }
153
+ const tmp = join(dir, `.${basename(path)}.tmp-${random()}`);
154
+ try {
155
+ fs.writeFileSync(tmp, text, { flag: "wx", mode });
156
+ fs.renameSync(tmp, path);
157
+ } catch (err) {
158
+ try {
159
+ fs.unlinkSync(tmp);
160
+ } catch {
161
+ // Never written, or already renamed: nothing to take back.
162
+ }
163
+ return { ok: false, reason: `the new file could not be written beside it (${err?.code ?? err?.message}); the copy is as it was, and ${backup} holds the same bytes` };
164
+ }
165
+ return { ok: true, backup };
166
+ }
@@ -0,0 +1,151 @@
1
+ /**
2
+ * THE SHIPPED EGRESS PROXY'S STATE, read one way by `up` and `doctor` on the docker venue (issue #453, gate rounds 1
3
+ * and 2).
4
+ *
5
+ * Two questions, both answered from one `docker inspect`:
6
+ * - RUNNING is `{{.State.Status}}` === "running". Measured on Docker Engine 29.8.1: a paused container reads
7
+ * `paused` with `.State.Running` TRUE, and one a restart policy is cycling reads `restarting` with `.State.Running`
8
+ * TRUE (39 of 40 samples); Podman reports a paused one `paused` with Running false and one between restarts
9
+ * `stopped` (4.9.3 and 5.8.1). Only the status word tells them from a proxy that carries traffic.
10
+ * - CURRENT: a container of the shipped name made from another image (an older squid digest), with another
11
+ * entrypoint or command, or with other files mounted, is not the proxy this deployment ships, and `docker start`
12
+ * of it, or a "present" that leaves it running, keeps that policy. Compared: the image's digest, the entrypoint and
13
+ * command against the pinned image's own (read from its registry config, identical on every platform in the
14
+ * manifest list), and the mounts: the two bind sources against this folder's `deploy/egress-proxy.conf` and
15
+ * `egress-allowlist.conf`, the image's two anonymous volumes allowed, anything else stale.
16
+ *
17
+ * MEASURED (Docker Engine 29.8.1, compose 5.5.1, rootful, gate round 2; pinned in egress-proxy-state.test.mjs): a proxy
18
+ * made by `docker compose --profile egress up -d` and one made by `up`'s argv carry the pinned reference exactly as
19
+ * written, the image's own entrypoint and command, the two binds and the image's two anonymous volumes; a folder
20
+ * reached through a symlink is reported by the symlink's path, which the realpath below makes the same folder.
21
+ *
22
+ * THE MOUNTS ARE COMPARED WHERE THEY CAN BE. A runtime that reports a source as a path of its own VM (Docker Desktop's
23
+ * `/host_mnt/...` and `/run/desktop/mnt/host/...`, not measured here) gives sources nothing here can resolve, and
24
+ * calling that proxy stale would have doctor fail forever and `up --yes` remove a healthy proxy on every run. So a
25
+ * source under one of those prefixes, or any unresolvable source on a macOS or Windows host, makes the mount
26
+ * comparison UNKNOWN, said as such and never the ground for a removal. On a Linux host an unresolvable source is gone,
27
+ * and that proxy is stale. The image, entrypoint and command are compared either way.
28
+ *
29
+ * Only the SHIPPED name is judged current or stale: a container `PI_EGRESS_PROXY` names is the operator's own.
30
+ */
31
+ import { resolve } from "node:path";
32
+
33
+ /** The pinned squid, as `deploy/docker-compose.yml` and `up`'s run argv name it (a test binds the three). */
34
+ export const EGRESS_PROXY_IMAGE = "ubuntu/squid@sha256:6a097f68bae708cedbabd6188d68c7e2e7a38cedd05a176e1cc0ba29e3bbe029";
35
+
36
+ /**
37
+ * The pinned image's own entrypoint and command, and its anonymous volumes, from its registry config (read on
38
+ * 2026-09-27 for every linux platform in the manifest list: amd64, arm64, ppc64le, s390x, all identical). Neither
39
+ * compose nor `up` sets either, so a container made by them carries these.
40
+ */
41
+ export const EGRESS_PROXY_ENTRYPOINT = Object.freeze(["entrypoint.sh"]);
42
+ export const EGRESS_PROXY_CMD = Object.freeze(["-f", "/etc/squid/squid.conf", "-NYC"]);
43
+ const IMAGE_VOLUMES = Object.freeze(["/var/log/squid", "/var/spool/squid"]);
44
+
45
+ /** Where Docker Desktop reports a bind source from its own VM (not measured here; the documented shapes). */
46
+ const VM_PREFIXES = Object.freeze(["/host_mnt/", "/run/desktop/mnt/host/"]);
47
+
48
+ /**
49
+ * The inspect format both callers use: one JSON object, so no field can be split on a separator a path might hold.
50
+ * `health` is doctor's advisory line; the rest is this module's.
51
+ */
52
+ export const PROXY_STATE_FORMAT =
53
+ '--format={"status":{{json .State.Status}},"health":{{if .State.Health}}{{json .State.Health.Status}}{{else}}"none"{{end}},"image":{{json .Config.Image}},"entrypoint":{{json .Config.Entrypoint}},"cmd":{{json .Config.Cmd}},"mounts":{{json .Mounts}},"networks":{{json .NetworkSettings.Networks}}}';
54
+
55
+ /** Where the proxy's two files are mounted inside it (compose's and `up`'s mounts alike). */
56
+ const SQUID_CONF = "/etc/squid/squid.conf";
57
+ const ALLOWLIST = "/etc/pi-dispatch/allowlist.conf";
58
+
59
+ /**
60
+ * `{ status, health, image, entrypoint, cmd, mounts, networks }` from `PROXY_STATE_FORMAT`'s stdout, or null when it
61
+ * is not that shape. `mounts` is the list as `[{ type, source, destination }]`, `networks` the attached names.
62
+ */
63
+ export function parseProxyState(stdout) {
64
+ let body;
65
+ try {
66
+ body = JSON.parse(String(stdout ?? "").trim());
67
+ } catch {
68
+ return null;
69
+ }
70
+ if (!body || typeof body !== "object" || typeof body.status !== "string" || !/^[a-z]{1,20}$/.test(body.status)) return null;
71
+ // An argv as a list. Podman 4.9.3's native inspect renders `.Config.Entrypoint` as a STRING (`"entrypoint.sh"`, and
72
+ // `""` for none; measured, gate round 3), where Docker Engine 29.8.1 and Podman 5.8.1 (native and through its Docker
73
+ // API) render a list; docker renders none as `null`. Each is normalised, so a correctly shipped proxy never reads
74
+ // stale on the runtime's spelling.
75
+ const strings = (v) => (Array.isArray(v) && v.every((x) => typeof x === "string") ? v : typeof v === "string" ? (v === "" ? [] : [v]) : v === null || v === undefined ? [] : null);
76
+ return {
77
+ status: body.status,
78
+ // Podman's Docker API answers "" where docker's template says "none" (measured, 5.8.1): both are "no healthcheck".
79
+ health: typeof body.health === "string" && body.health !== "" ? body.health : "none",
80
+ image: typeof body.image === "string" ? body.image : "",
81
+ entrypoint: strings(body.entrypoint),
82
+ cmd: strings(body.cmd),
83
+ mounts: (Array.isArray(body.mounts) ? body.mounts : []).filter((m) => m && typeof m === "object").map((m) => ({ type: String(m.Type ?? ""), source: String(m.Source ?? ""), destination: String(m.Destination ?? "") })),
84
+ networks: body.networks && typeof body.networks === "object" ? Object.keys(body.networks) : [],
85
+ };
86
+ }
87
+
88
+ /** The digest an image reference pins, or null. */
89
+ function digestOf(image) {
90
+ return /@(sha256:[0-9a-f]{64})$/.exec(String(image ?? ""))?.[1] ?? null;
91
+ }
92
+
93
+ const sameList = (a, b) => Array.isArray(a) && a.length === b.length && a.every((x, i) => x === b[i]);
94
+
95
+ /**
96
+ * What makes a container of the shipped name not this deployment's proxy, as `{ drift, unknown }`: `drift` the
97
+ * sentences that make it stale, `unknown` why the mounts could not be compared here (null when they were).
98
+ * `realpath` throws for a path that does not resolve on this host. `compareMounts: false` judges the image, entrypoint and
99
+ * command alone, for a caller that does not know which folder the service uses. `platform` decides whether a source
100
+ * that does not resolve can be a path of the runtime's own VM (below).
101
+ */
102
+ export function shippedProxyDrift(state, { cwd, realpath = (p) => p, compareMounts = true, platform = "linux" }) {
103
+ const drift = [];
104
+ if (digestOf(state.image) !== digestOf(EGRESS_PROXY_IMAGE)) drift.push(`it was created from ${state.image || "an unnamed image"}, not the pinned ${EGRESS_PROXY_IMAGE}`);
105
+ if (!sameList(state.entrypoint, EGRESS_PROXY_ENTRYPOINT)) drift.push(`its entrypoint is ${JSON.stringify(state.entrypoint)}, not the image's ${JSON.stringify(EGRESS_PROXY_ENTRYPOINT)}`);
106
+ if (!sameList(state.cmd, EGRESS_PROXY_CMD)) drift.push(`its command is ${JSON.stringify(state.cmd)}, not the image's ${JSON.stringify(EGRESS_PROXY_CMD)}`);
107
+ if (!compareMounts) return { drift, unknown: null };
108
+ const binds = state.mounts.filter((m) => m.type === "bind");
109
+ const resolves = (source) => {
110
+ try {
111
+ realpath(source);
112
+ return true;
113
+ } catch {
114
+ return false;
115
+ }
116
+ };
117
+ // UNKNOWN only where a source can be a path of the runtime's own VM: under a VM prefix Docker Desktop is known to use,
118
+ // or on a macOS or Windows host, where every Linux daemon runs in a VM (gate round 3's simple rule). On a Linux host a
119
+ // source that does not resolve is simply gone (its folder deleted, say), and a proxy mounting nothing real is stale.
120
+ // EACH BIND ON ITS OWN (round-cap re-review): an unknown one is unknown for itself, and the other expected bind and
121
+ // every extra mount are still compared; a caller refuses a removal only while an EXPECTED bind is unknown.
122
+ const vmPath = (source) => VM_PREFIXES.some((prefix) => source.startsWith(prefix)) || platform === "darwin" || platform === "win32";
123
+ const expected = { [SQUID_CONF]: resolve(cwd, "deploy/egress-proxy.conf"), [ALLOWLIST]: resolve(cwd, "egress-allowlist.conf") };
124
+ const same = (source, want) => {
125
+ try {
126
+ return source === want || realpath(source) === realpath(want);
127
+ } catch {
128
+ return false;
129
+ }
130
+ };
131
+ const unknownSources = [];
132
+ for (const [destination, want] of Object.entries(expected)) {
133
+ const bind = binds.find((m) => m.destination === destination);
134
+ if (!bind) drift.push(`nothing is mounted at ${destination}, where ${want} belongs`);
135
+ else if (!resolves(bind.source) && vmPath(bind.source)) unknownSources.push(bind.source);
136
+ else if (!resolves(bind.source)) drift.push(`its ${destination} is ${bind.source}, which does not exist on this host`);
137
+ else if (!same(bind.source, want)) drift.push(`its ${destination} is ${bind.source}, not ${want}`);
138
+ }
139
+ for (const m of state.mounts) {
140
+ if (m.type === "bind" && Object.hasOwn(expected, m.destination)) continue;
141
+ if (m.type === "volume" && IMAGE_VOLUMES.includes(m.destination)) continue;
142
+ drift.push(`it has a ${m.type || "mount"} at ${m.destination} (from ${m.source || "nowhere named"}) that the shipped proxy does not`);
143
+ }
144
+ const unknown = unknownSources.length > 0 ? `${unknownSources.join(", ")} ${unknownSources.length === 1 ? "is a path" : "are paths"} this host cannot resolve (the runtime's own VM's, as Docker Desktop reports its sources), so ${unknownSources.length === 1 ? "that mount cannot" : "those mounts cannot"} be compared from here` : null;
145
+ return { drift, unknown };
146
+ }
147
+
148
+ /** The job and sandbox networks attached to the proxy, which a removal would cut off (issue #453, gate round 2). */
149
+ export function jobNetworksOf(state) {
150
+ return state.networks.filter((n) => n.startsWith("pi-job-") || n.startsWith("pi-sandbox-"));
151
+ }