@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,204 @@
1
+ /**
2
+ * Find a duplicate key in JSON text (issue #313).
3
+ *
4
+ * `JSON.parse` accepts a duplicate key and silently keeps the LAST value, so
5
+ * `{"run":{"flow":"safe","flow":"evil"}}` parses to `evil` while a reviewer reading the file top to bottom
6
+ * sees `safe`. For `triggers.json` that is not a curiosity: this project's whole authorisation story rests
7
+ * on that file being the reviewed artifact. `DES-PER-TRIGGER-SECRET-PROFILE` says the overlay is not the
8
+ * reviewed artifact and the file is, the secrets refusals name it, and `CONST-TRIGGER-AUTHOR-GATE` treats
9
+ * a merged change to it as the gate. A field whose reviewed value and effective value differ defeats the
10
+ * review rather than the runtime, and the reach is the whole schema: `run.flow`, `run.command`, `on.type`,
11
+ * a scoped limit, a `run.secrets` reference. Any of them can be written twice.
12
+ *
13
+ * WHY A TEXT SCAN AND NOT A REVIVER. `JSON.parse`'s reviver sees each key with the value already resolved,
14
+ * so it is called ONCE for a duplicated key, with the winning value: it cannot tell a duplicate from a
15
+ * single occurrence. Node 22's third `context` argument gives the source text of that one value and no
16
+ * more. Measured both ways. Catching this needs the raw text, which every caller already holds.
17
+ *
18
+ * WHY THIS IS SAFE TO HAND-WRITE, which is the real question, because a hand-written source-text parser is
19
+ * a trap this project has already fallen into once (issue #282, four rounds of latent defects in a comment
20
+ * stripper). Two things make this one different.
21
+ *
22
+ * FIRST, IT RUNS ONLY ON TEXT `JSON.parse` HAS ALREADY ACCEPTED. Every caller parses first and scans
23
+ * second. So the scanner never has to decide whether malformed input is malformed, never has to recover
24
+ * from anything, and every branch it does not have is a branch that cannot be wrong. Feeding it text that
25
+ * did not parse is a caller error, not an input case: the result is unspecified rather than a refusal.
26
+ *
27
+ * SECOND, IT IS CHECKED AGAINST A GENERATOR THAT KNOWS THE ANSWER. `worker/test/json-duplicates.test.mjs`
28
+ * builds thousands of documents with adversarial key characters -- quotes, backslashes, newlines, braces,
29
+ * colons, non-ASCII -- injects a duplicate at a known path in half of them, and requires the scanner to
30
+ * agree exactly. That is an oracle rather than a restatement, which is what the #282 lesson asks for.
31
+ *
32
+ * KEYS ARE COMPARED DECODED, not byte by byte, and that is the case an attacker would actually use:
33
+ * a key written plainly and the same key written with a `u00`-style escape are ONE key to `JSON.parse`,
34
+ * and a byte comparison sees two.
35
+ *
36
+ * IMPORT-FREE, like `reserved-env.mjs` and `provider-key.mjs` beside it: `triggers.mjs` is the shared
37
+ * validator, the receiver loads it and `admin/build.mjs` inlines it into the published console, so
38
+ * anything it reaches has to be as cheap as a list of strings.
39
+ */
40
+
41
+ /** The escape characters JSON defines, other than `\u`. */
42
+ const SIMPLE_ESCAPES = { '"': '"', "\\": "\\", "/": "/", b: "\b", f: "\f", n: "\n", r: "\r", t: "\t" };
43
+
44
+ /**
45
+ * The first duplicate key in `text`, as `{ key, at }`, or `null` when there is none.
46
+ *
47
+ * `at` is a dotted path through the enclosing keys and array indices (`triggers.2.run.flow`), because the
48
+ * key alone is not enough to find it: `flow` appears in every entry, and the whole point of the refusal is
49
+ * that the operator can go and look at the one that lies.
50
+ *
51
+ * The separator is NOT escaped, so a key containing a dot, or an object key that is a number, produces a
52
+ * path that reads like a different shape. That is a signpost rather than an address, and it is left
53
+ * unescaped deliberately: for this schema it is unreachable (every schema key is a fixed identifier, and
54
+ * `run.secrets` keys are gated by `ENV_NAME`), and quoting the segments would make the common case harder
55
+ * to read to fix a case the file cannot contain.
56
+ */
57
+ export function findDuplicateKey(text) {
58
+ const s = String(text);
59
+ const n = s.length;
60
+ let i = 0;
61
+ // One frame per open container. `keys` is a Set for an object and null for an array, which is also how
62
+ // the reader knows whether a string in value position is a key.
63
+ const stack = [];
64
+ // The path to the value currently being read, as keys and array indices.
65
+ const path = [];
66
+
67
+ const skipWhitespace = () => {
68
+ while (i < n) {
69
+ const c = s.charCodeAt(i);
70
+ if (c === 0x20 || c === 0x09 || c === 0x0a || c === 0x0d) i++;
71
+ else break;
72
+ }
73
+ };
74
+
75
+ /**
76
+ * Walk past the string starting at `s[i] === '"'` WITHOUT building it.
77
+ *
78
+ * Value position only, and it is not a micro-optimisation: `readString` accumulates about 32 bytes of
79
+ * heap per source character, so a single 64MB string value cost 2GB of garbage to decode a value this
80
+ * function then discards. A trigger's `run.task` and `run.instructions` are free text, and a worker
81
+ * that dies at boot reading its own configuration is a worse failure than anything this file refuses.
82
+ */
83
+ const skipString = () => {
84
+ i++; // the opening quote
85
+ while (i < n) {
86
+ const ch = s[i];
87
+ if (ch === '"') {
88
+ i++;
89
+ return;
90
+ }
91
+ // An escape consumes its own next character, which is what stops `\"` ending the string. The
92
+ // `\uXXXX` digits need no special case here: none of them is a quote or a backslash.
93
+ i += ch === "\\" ? 2 : 1;
94
+ }
95
+ };
96
+
97
+ /** Read the string starting at `s[i] === '"'` and return its DECODED value. Keys only. */
98
+ const readString = () => {
99
+ i++; // the opening quote
100
+ let out = "";
101
+ while (i < n) {
102
+ const ch = s[i];
103
+ if (ch === '"') {
104
+ i++;
105
+ return out;
106
+ }
107
+ if (ch === "\\") {
108
+ const esc = s[i + 1];
109
+ i += 2;
110
+ if (esc === "u") {
111
+ // A lone surrogate is preserved as itself, which is what JSON.parse does too, so a pair
112
+ // written as two escapes still compares equal to the same pair written literally.
113
+ out += String.fromCharCode(Number.parseInt(s.slice(i, i + 4), 16));
114
+ i += 4;
115
+ } else {
116
+ out += SIMPLE_ESCAPES[esc] ?? esc;
117
+ }
118
+ continue;
119
+ }
120
+ out += ch;
121
+ i++;
122
+ }
123
+ return out; // unreachable on text that parsed
124
+ };
125
+
126
+ // True when the next string is a KEY rather than a value: just after `{`, or after a `,` inside one.
127
+ let expectKey = false;
128
+
129
+ while (i < n) {
130
+ skipWhitespace();
131
+ if (i >= n) break;
132
+ const ch = s[i];
133
+
134
+ if (ch === "{") {
135
+ stack.push({ keys: new Set(), named: false });
136
+ expectKey = true;
137
+ i++;
138
+ continue;
139
+ }
140
+ if (ch === "[") {
141
+ stack.push({ keys: null, named: false });
142
+ path.push(0);
143
+ expectKey = false;
144
+ i++;
145
+ continue;
146
+ }
147
+ if (ch === "}" || ch === "]") {
148
+ const frame = stack.pop();
149
+ // An array frame owns its index; an object frame owns the key of the member it is inside.
150
+ if (frame && (frame.keys === null || frame.named)) path.pop();
151
+ expectKey = false;
152
+ i++;
153
+ continue;
154
+ }
155
+ if (ch === ",") {
156
+ const top = stack[stack.length - 1];
157
+ if (top && top.keys === null) {
158
+ path[path.length - 1] = (path[path.length - 1] ?? 0) + 1;
159
+ expectKey = false;
160
+ } else if (top) {
161
+ if (top.named) {
162
+ path.pop();
163
+ top.named = false;
164
+ }
165
+ expectKey = true;
166
+ }
167
+ i++;
168
+ continue;
169
+ }
170
+ if (ch === ":") {
171
+ // Nothing to do but step over it. `expectKey` was already cleared where the key was read, so an
172
+ // assignment here would be unreachable state: on text that parsed, a `:` can only follow a key.
173
+ // (It was written that way first, and a mutation check found it pinned nothing.)
174
+ i++;
175
+ continue;
176
+ }
177
+ if (ch === '"') {
178
+ const top = stack[stack.length - 1];
179
+ if (expectKey && top && top.keys !== null) {
180
+ const key = readString();
181
+ if (top.keys.has(key)) return { key, at: [...path, key].join(".") };
182
+ top.keys.add(key);
183
+ path.push(key);
184
+ top.named = true;
185
+ expectKey = false;
186
+ continue;
187
+ }
188
+ skipString();
189
+ continue;
190
+ }
191
+ // A bare literal: a number, `true`, `false` or `null`. Nothing inside one can be a key or a quote,
192
+ // so it is enough to run to the next structural character.
193
+ //
194
+ // This loop is an OPTIMISATION, not a rule: stepping one character at a time would re-enter the
195
+ // switch and land back here until the same structural character, so the two are behaviourally
196
+ // identical (checked over 15000 documents). No test can distinguish them, which is worth saying
197
+ // here so the next mutation check reads it as equivalent rather than as a hole.
198
+ while (i < n && !STRUCTURAL.has(s[i])) i++;
199
+ }
200
+ return null;
201
+ }
202
+
203
+ /** Characters that end a bare literal. */
204
+ const STRUCTURAL = new Set([",", ":", "{", "}", "[", "]", '"', " ", "\t", "\n", "\r"]);