@edgehero/pi-dispatch 0.3.0 → 1.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/src/service.mjs CHANGED
@@ -94,6 +94,13 @@ export const TEMPLATE_PINS = {
94
94
  "EnvironmentFile=/opt/pi-dispatch/.env",
95
95
  "\nUser=pi\n",
96
96
  "WantedBy=multi-user.target",
97
+ // Byte-for-byte survivors — semantics the render must not lose, matching worker.service's list
98
+ // above. Added with #187: the receiver had none of the three, so any config it could not parse
99
+ // produced an unbounded five-second restart loop, and `service install` rendering them away would
100
+ // put that back on a deployment that had just been fixed.
101
+ "RestartPreventExitStatus=2", // EXIT_POLICY is never restarted (the next start reads the same bad file)
102
+ "StartLimitIntervalSec=60",
103
+ "StartLimitBurst=5",
97
104
  "KillSignal=SIGTERM",
98
105
  "TimeoutStopSec=30",
99
106
  ],
@@ -118,8 +125,112 @@ export const TEMPLATE_PINS = {
118
125
  "AppExit Default Restart",
119
126
  "AppExit 2 Exit",
120
127
  ],
128
+ // The wrappers are COPIED, never substituted, so they have no substitution surface of their own.
129
+ // They are pinned anyway for the same reason nssm-install.cmd is: the render EMITS the variable name
130
+ // they read back (`--env-setup`, issue #209 — the plist's EnvironmentVariables dict on macOS, nssm's
131
+ // AppEnvironmentExtra on Windows), and a rename on one side with silence on the other would produce
132
+ // a unit that starts fine and ignores the operator's secrets manager.
133
+ "worker-env-wrapper.sh": ["PI_ENV_SETUP"],
134
+ "worker-env-wrapper.cmd": ["PI_ENV_SETUP"],
135
+ };
136
+
137
+ /**
138
+ * The env-setup seam (issue #209): an operator-typed path to their OWN script, sourced before the
139
+ * worker starts, so a secrets manager can put the provider key and the forge token into the process
140
+ * environment without anyone hand-editing a rendered unit. The renderer owns the `exec` that follows
141
+ * it, which is the whole point — the hand-edit an operator naturally reaches for (`<manager> run -- …`)
142
+ * reports the CHILD's exit 2 as 1, and exit 2 is the determinate policy refusal that
143
+ * RestartPreventExitStatus=2, nssm's `AppExit 2 Exit` and the wrapper's exit-2 conversion all key on.
144
+ *
145
+ * A PATH and not a command, deliberately. systemd expands `$…`/`${…}` inside Exec lines whatever the
146
+ * quoting, a plist is XML, and nssm's argv is neither, so an inline command would need three separate
147
+ * escaping stories — and it is the shape docs/secrets.md already tells operators not to write.
148
+ *
149
+ * Absolute, and never resolved against anything: `.` in POSIX sh SEARCHES $PATH for an operand with no
150
+ * slash in it, so a relative path here would be a different file depending on the service manager's
151
+ * environment. Refuse rather than guess.
152
+ *
153
+ * The refused characters are per platform because the constraint is: on Linux the path lands inside a
154
+ * systemd Exec word (which owns `$`, `"` and `\`), on macOS inside a plist <string> (XML), and on
155
+ * Windows inside an nssm NAME=VALUE that cmd expands with `%`. Spaces are fine everywhere: every
156
+ * composed form quotes.
157
+ */
158
+ const ENV_SETUP_REFUSALS = {
159
+ linux: { re: /['"$\\\x00-\x1f\x7f]/, why: "the path lands inside a single-quoted systemd Exec word, and systemd expands $VAR there whatever the quoting" },
160
+ darwin: { re: /[<>&"\x00-\x1f\x7f]/, why: "the launchd unit is a plist, and the path lands in an XML <string>" },
161
+ win32: { re: /[%"\x00-\x1f\x7f]/, why: "cmd expands %VAR% when the wrapper runs the script" },
121
162
  };
122
163
 
164
+ function resolveEnvSetup(ctx, raw) {
165
+ const path = String(raw);
166
+ const absolute = ctx.platform === "win32" ? /^([A-Za-z]:[\\/]|\\\\)/.test(path) : path.startsWith("/");
167
+ if (!absolute) {
168
+ return { error: `--env-setup needs an ABSOLUTE path, got: ${path}\nPOSIX \`.\` searches $PATH for an operand with no slash in it, and a service manager's environment is not your shell's, so a relative path here is a different file on every host.` };
169
+ }
170
+ const { re, why } = ENV_SETUP_REFUSALS[ctx.platform];
171
+ const hit = re.exec(path);
172
+ if (hit) {
173
+ const shown = hit[0].charCodeAt(0) < 0x20 || hit[0].charCodeAt(0) === 0x7f ? `\\x${hit[0].charCodeAt(0).toString(16).padStart(2, "0")}` : hit[0];
174
+ return { error: `--env-setup path contains ${JSON.stringify(shown)}, which cannot be rendered safely on ${ctx.platform}: ${why}. Move the script somewhere without it: ${path}` };
175
+ }
176
+ if (!ctx.fs.existsSync(path)) {
177
+ return { error: `--env-setup script not found: ${path}\nRefusing rather than rendering: a unit pointing at a script that is not there installs cleanly and then fails at every boot.` };
178
+ }
179
+ return { path };
180
+ }
181
+
182
+ /**
183
+ * The read side of the same seam (issue #216). `--env-setup` is a RENDER-TIME flag: once the unit is
184
+ * written, the unit is the only record of the path, so a preflight that wants to check the script has
185
+ * to read it back out of the file that actually boots. These patterns match exactly what
186
+ * composeEnvSetupExec and renderPlist emit, and they live here beside ENV_SETUP_REFUSALS so the writer
187
+ * and the reader cannot drift apart unnoticed — worker/test/service.test.mjs round-trips every render
188
+ * through readUnitSeam, so changing one half without the other turns the suite red.
189
+ *
190
+ * The capture classes are exact rather than lazy because ENV_SETUP_REFUSALS already guarantees what
191
+ * cannot be in the path: no `"` on any platform, so `[^"]*` ends where the renderer's quote does, and
192
+ * no `<`/`>`/`&` on macOS, so a plist <string> can never hold an XML entity to decode.
193
+ *
194
+ * win32 is not a file at all — the value comes back from `nssm get <service> AppEnvironmentExtra`,
195
+ * which some nssm builds write as UTF-16, hence the NUL strip in readUnitSeam.
196
+ */
197
+ const ENV_SETUP_READERS = {
198
+ linux: {
199
+ setup: /^ExecStart=\/bin\/sh -c 'set -a; \. "([^"]*)" \|\| exit 1;/m,
200
+ deployDir: /^WorkingDirectory=(.*)$/m,
201
+ },
202
+ darwin: {
203
+ setup: /<key>PI_ENV_SETUP<\/key>\s*<string>([^<]*)<\/string>/,
204
+ deployDir: /<key>WorkingDirectory<\/key>\s*<string>([^<]*)<\/string>/,
205
+ },
206
+ win32: {
207
+ setup: /^PI_ENV_SETUP=(.*)$/m,
208
+ // nssm holds the deployment folder in a SEPARATE property (AppDirectory), and there is only one
209
+ // machine-scoped service per name to confuse it with — so the caller matches on nothing here
210
+ // rather than spending a second `nssm get` to answer a question Windows cannot ask twice.
211
+ deployDir: null,
212
+ },
213
+ };
214
+
215
+ /**
216
+ * What an installed unit records: the configured `--env-setup` path and the deployment folder it
217
+ * serves. Both null for a unit rendered without the flag, which is every unit that predates issue #209
218
+ * and every default render since. Never throws: an unreadable, truncated or hand-rewritten unit simply
219
+ * reads as "no seam configured", because a preflight guessing at a shape it does not recognise is
220
+ * worse than one that says nothing.
221
+ */
222
+ export function readUnitSeam(text, platform) {
223
+ const readers = ENV_SETUP_READERS[platform];
224
+ if (!readers || typeof text !== "string") return { setup: null, deployDir: null };
225
+ const clean = text.replace(/\0/g, "");
226
+ const grab = (re) => (re ? (re.exec(clean)?.[1] ?? null) : null);
227
+ // Only a trailing CR is stripped, never surrounding whitespace: on the quoted and XML forms the
228
+ // capture already ends exactly where the renderer's delimiter does, and a path is allowed to hold a
229
+ // space anywhere in it. An empty value means the same as no value at all.
230
+ const clip = (v) => (v === null ? null : v.replace(/\r$/, "") || null);
231
+ return { setup: clip(grab(readers.setup)), deployDir: clip(grab(readers.deployDir)) };
232
+ }
233
+
123
234
  const SUBCOMMANDS = new Set(["render", "install", "uninstall", "status", "start", "stop", "restart"]);
124
235
 
125
236
  const SERVICE_USAGE = `pi-dispatch service — run the worker (or --receiver) as an OS service, rendered for THIS host
@@ -137,6 +248,9 @@ const SERVICE_USAGE = `pi-dispatch service — run the worker (or --receiver) as
137
248
  --user | --system linux scope (default --user; --system never executes root commands)
138
249
  --force replace an existing unit in the same scope
139
250
  --print also print the rendered unit before installing
251
+ --env-setup <path> render|install: source YOUR script before the worker starts, so a secrets
252
+ manager fills the environment. Absolute path, env only (no exec, no paths):
253
+ the render owns the exec, so a policy refusal still exits 2. docs/secrets.md
140
254
  `;
141
255
 
142
256
  export async function runService(argv = [], deps = {}) {
@@ -175,6 +289,7 @@ export async function runService(argv = [], deps = {}) {
175
289
  print: { type: "boolean", default: false },
176
290
  drain: { type: "boolean", default: false },
177
291
  "drain-timeout": { type: "string" },
292
+ "env-setup": { type: "string" },
178
293
  },
179
294
  }));
180
295
  } catch (error) {
@@ -224,8 +339,23 @@ export async function runService(argv = [], deps = {}) {
224
339
  which: values.receiver ? "receiver" : "worker",
225
340
  scope: platform === "linux" && values.system ? "system" : "user",
226
341
  force: values.force,
342
+ // The operator's env-setup script, or null for the shipped default. Null is load-bearing: with no
343
+ // seam configured every render below is byte-identical to what it produced before issue #209.
344
+ envSetup: null,
227
345
  };
228
346
 
347
+ if (values["env-setup"] !== undefined) {
348
+ // Only the two subcommands that COMPOSE a command line can honour it. Ignoring the flag on the
349
+ // others would be the silent no-op this project refuses on principle: an operator who typed it on
350
+ // `restart` would believe the seam was in place.
351
+ if (cmd !== "render" && cmd !== "install") {
352
+ return fail(err, `--env-setup applies to \`service render\` and \`service install\`, which compose the command the unit runs; \`service ${cmd}\` renders nothing. Re-render (or re-install --force) to change it.`);
353
+ }
354
+ const resolved = resolveEnvSetup(ctx, values["env-setup"]);
355
+ if (resolved.error) return fail(err, resolved.error);
356
+ ctx.envSetup = resolved.path;
357
+ }
358
+
229
359
  switch (cmd) {
230
360
  case "render":
231
361
  return doRender(ctx);
@@ -282,10 +412,59 @@ function unitPaths(ctx) {
282
412
  return { name: `pi-dispatch-${ctx.which}` };
283
413
  }
284
414
 
415
+ /**
416
+ * Every unit FILE this tool could have installed on this host: both daemons, both scopes, plus the
417
+ * `worker.service` name the README's manual `sudo cp` instructions produce (doStatus already counts
418
+ * that one as a real worker unit, so a preflight reading units must too). Derived from unitPaths
419
+ * rather than restated, so the two cannot disagree about where a unit lives.
420
+ *
421
+ * Empty on win32 by construction: there is no file to read there, only `nssm get`.
422
+ */
423
+ export function installedUnitPaths(platform, home) {
424
+ if (platform !== "linux" && platform !== "darwin") return [];
425
+ const found = [];
426
+ for (const which of ["worker", "receiver"]) {
427
+ const paths = unitPaths({ platform, home, which, scope: "user" });
428
+ found.push({ which, scope: "user", path: platform === "linux" ? paths.userPath : paths.installPath });
429
+ found.push({ which, scope: "system", path: paths.systemPath });
430
+ if (platform === "linux" && which === "worker") found.push({ which, scope: "system", path: "/etc/systemd/system/worker.service" });
431
+ }
432
+ return found;
433
+ }
434
+
285
435
  function readTemplate(ctx, name) {
286
436
  return ctx.fs.readFileSync(join(ctx.templatesDir, name), "utf8");
287
437
  }
288
438
 
439
+ /**
440
+ * The per-host path substitutions, applied to a TEMPLATE and never to anything a render composed.
441
+ * /opt/pi-dispatch becomes the deployment folder (the cwd this render ran from): WorkingDirectory and
442
+ * EnvironmentFile stay operator territory, never the package dir npm may wipe on update.
443
+ *
444
+ * The ordering matters and used to be wrong. A composed value is already absolute for THIS host, so
445
+ * putting it through these rewrites a path the operator typed rather than a placeholder the template
446
+ * shipped — and an `--env-setup /opt/pi-dispatch/setup-env.sh` on a deployment at /srv/pi-deploy became
447
+ * `. "/srv/pi-deploy/setup-env.sh"`, a file resolveEnvSetup never checked, while the banner above it
448
+ * still named the one that was typed. Substitute first, compose second, and anchor on the substituted
449
+ * form.
450
+ */
451
+ function subDeployDir(ctx, text) {
452
+ // Function replacement, here and everywhere below a computed path is the REPLACEMENT: String.replace
453
+ // and replaceAll both read `$&` and its relatives out of a replacement STRING, so a deployment folder
454
+ // holding one would splice itself into the unit. A function replacement is taken literally.
455
+ return text.replaceAll("/opt/pi-dispatch", () => ctx.deployDir);
456
+ }
457
+
458
+ /**
459
+ * subDeployDir plus the systemd unit's node: the shipped `/usr/bin/node` literal does not exist on an
460
+ * nvm host. The PLIST deliberately gets only subDeployDir — its header comment SHOWS
461
+ * `<string>/usr/bin/node</string>` as the argv an operator would append by hand, and that prose is
462
+ * meant to keep reading as an example rather than silently becoming this host's node.
463
+ */
464
+ function subUnitPaths(ctx, text) {
465
+ return subDeployDir(ctx, text).replaceAll("/usr/bin/node", () => ctx.execPath);
466
+ }
467
+
289
468
  /**
290
469
  * The receiver refusal, shared by render and install: a null receiverStart means the receiver package
291
470
  * is not resolvable from this worker install. Refusing beats rendering — a unit pointing at a
@@ -299,6 +478,32 @@ function refuseMissingReceiver(ctx) {
299
478
  );
300
479
  }
301
480
 
481
+ /**
482
+ * The systemd form of the env-setup seam. Three pieces, each load-bearing:
483
+ *
484
+ * - `exec` keeps systemd watching the WORKER and not a shell in front of it, so an exit 2 arrives as
485
+ * an exit 2 and RestartPreventExitStatus=2 still sees a real policy refusal. This is the whole
486
+ * reason the renderer owns this line instead of leaving it to a hand-edit.
487
+ * - `|| exit 1` maps a failed setup (an expired token, an unreachable manager) onto the
488
+ * infrastructure code, so systemd retries it under Restart=on-failure inside the StartLimit bound,
489
+ * and it can never be mistaken for the determinate refusal that must stay stopped. A setup script
490
+ * that calls `exit 2` ITSELF still exits 2 — sourcing cannot intercept that — which is why
491
+ * docs/secrets.md tells operators not to.
492
+ * - `set -a` exports a bare KEY=value, exactly as EnvironmentFile= does, so the seam accepts both an
493
+ * `export`-style script and raw dotenv output. EnvironmentFile= is untouched and still read first;
494
+ * this runs after it, so the manager wins over a stale value left in the file.
495
+ *
496
+ * The whole sh script is ONE single-quoted systemd word, which is why resolveEnvSetup refuses a single
497
+ * quote in the path (it would end the word) and a `$` (systemd expands those inside Exec lines whatever
498
+ * the quoting). The inner double quotes are sh's, and they are what makes a path with spaces work.
499
+ * Verified against systemd 252: `systemctl show -p ExecStart` reports argv[] as /bin/sh, -c, and the
500
+ * whole script as one word, and a worker exiting 2 under it reports ExecMainStatus=2 with NRestarts=0.
501
+ */
502
+ function composeEnvSetupExec(setup, argv) {
503
+ const command = argv.map((a) => `"${a}"`).join(" ");
504
+ return `/bin/sh -c 'set -a; . "${setup}" || exit 1; set +a; exec ${command}'`;
505
+ }
506
+
302
507
  /**
303
508
  * Render worker.service / receiver.service for this host. Targeted substitution of the templates'
304
509
  * known literals (see TEMPLATE_PINS); everything else — RestartPreventExitStatus=2, the StartLimit
@@ -310,20 +515,24 @@ function renderLinuxUnit(ctx) {
310
515
  // to a repo-root WorkingDirectory that only a checkout has. The rendered unit points at absolute
311
516
  // entries that exist in both layouts — cliPath beside this module; the receiver package's ./start
312
517
  // export — so ExecStart works no matter what WorkingDirectory is.
313
- const execStart =
314
- ctx.which === "receiver"
315
- ? ["ExecStart=/usr/bin/node receiver/src/start.mjs", `ExecStart=${ctx.execPath} ${ctx.receiverStart}`]
316
- : ["ExecStart=/usr/bin/node worker/src/cli.mjs worker", `ExecStart=${ctx.execPath} ${ctx.cliPath} worker`];
518
+ const argv = ctx.which === "receiver" ? [ctx.execPath, ctx.receiverStart] : [ctx.execPath, ctx.cliPath, "worker"];
519
+ const templateLine =
520
+ ctx.which === "receiver" ? "ExecStart=/usr/bin/node receiver/src/start.mjs" : "ExecStart=/usr/bin/node worker/src/cli.mjs worker";
521
+ const execStart = `ExecStart=${ctx.envSetup ? composeEnvSetupExec(ctx.envSetup, argv) : argv.join(" ")}`;
317
522
  // The banner outranks the template's own "TEMPLATE/UNTESTED EXAMPLE — set the PLACEHOLDERs" header,
318
523
  // which renders through below (the no-markers design keeps templates byte-usable, so their prose
319
524
  // survives): a reader of the rendered unit should know the placeholders are already substituted.
320
- let unit = `# rendered by \`pi-dispatch service\` — paths computed for this host from deploy/${template};\n# the template's PLACEHOLDER prose below is already substituted.\n` +
321
- readTemplate(ctx, template)
322
- .replace(execStart[0], execStart[1])
323
- // /opt/pi-dispatch → the deployment folder (the cwd this render ran from): WorkingDirectory and
324
- // EnvironmentFile stay operator territory, never the package dir npm may wipe on update.
325
- .replaceAll("/opt/pi-dispatch", ctx.deployDir)
326
- .replaceAll("/usr/bin/node", ctx.execPath);
525
+ const seamNote = ctx.envSetup
526
+ ? `# ExecStart also runs \`--env-setup ${ctx.envSetup}\` first: sourced with \`set -a\` (a bare\n# KEY=value exports, exactly as EnvironmentFile= does, and it runs AFTER EnvironmentFile so a secrets\n# manager wins over a stale value in .env). A failed setup exits 1, never the exit 2 below, and \`exec\`\n# keeps systemd watching the worker itself. Re-render to change it; do not hand-edit this line.\n`
527
+ : "";
528
+ // Substituted first, then the composed line goes in — see subUnitPaths for why that order is
529
+ // load-bearing. The anchor is the template's own ExecStart put through the same substitutions, which
530
+ // is what it looks like by the time we search for it. Function replacements throughout: a deployment
531
+ // path holding `$&` would otherwise be spliced by String.replace's own syntax.
532
+ const substituted = subUnitPaths(ctx, readTemplate(ctx, template));
533
+ let unit =
534
+ `# rendered by \`pi-dispatch service\` — paths computed for this host from deploy/${template};\n# the template's PLACEHOLDER prose below is already substituted.\n${seamNote}` +
535
+ substituted.replace(subUnitPaths(ctx, templateLine), () => execStart);
327
536
  if (ctx.scope === "user") {
328
537
  // A systemd --user unit always runs as the invoking user, and systemd REJECTS a User= line in
329
538
  // user scope ("Unknown lvalue"); the line must not survive the render. The replacement is a
@@ -339,7 +548,7 @@ function renderLinuxUnit(ctx) {
339
548
  // started. default.target is the user manager's boot target.
340
549
  unit = unit.replace("WantedBy=multi-user.target", "WantedBy=default.target");
341
550
  } else {
342
- unit = unit.replace(/^User=pi$/m, `User=${ctx.user}`);
551
+ unit = unit.replace(/^User=pi$/m, () => `User=${ctx.user}`);
343
552
  }
344
553
  return unit;
345
554
  }
@@ -351,7 +560,10 @@ function renderLinuxUnit(ctx) {
351
560
  * receiver — it has no EXIT_POLICY — and harmless.
352
561
  */
353
562
  function renderPlist(ctx) {
354
- let plist = readTemplate(ctx, "com.pi-dispatch.worker.plist");
563
+ // Substituted before anything composed goes in (subDeployDir): the two anchors below are therefore
564
+ // written in their post-substitution form, and PI_ENV_SETUP can no longer be rewritten into a path
565
+ // the operator never typed. Only subDeployDir here — the template's /usr/bin/node is example prose.
566
+ let plist = subDeployDir(ctx, readTemplate(ctx, "com.pi-dispatch.worker.plist"));
355
567
  // One wrapper, two daemons: the exec argv IS the difference now. The wrapper sources ./.env in the
356
568
  // unit's WorkingDirectory and runs exactly these arguments — no `receiver` selector flag, no paths
357
569
  // guessed inside the wrapper (issue #96: the wrapper's self-relative guess broke under npm install).
@@ -366,26 +578,41 @@ function renderPlist(ctx) {
366
578
  // the command: absolute node, absolute script. The wrapper path is module-relative (templatesDir),
367
579
  // so it exists in a checkout AND under node_modules — unlike the old repo-root guess.
368
580
  plist = plist.replace(
369
- "<string>/opt/pi-dispatch/deploy/worker-env-wrapper.sh</string>",
370
- [
371
- `<string>${ctx.wrapperSh}</string>`,
372
- "<!-- the command the wrapper execs after sourcing ./.env in WorkingDirectory - absolute paths, nothing guessed -->",
373
- ...execArgv.map((a) => `<string>${a}</string>`),
374
- ].join("\n\t\t"),
581
+ `<string>${ctx.deployDir}/deploy/worker-env-wrapper.sh</string>`,
582
+ () =>
583
+ [
584
+ `<string>${ctx.wrapperSh}</string>`,
585
+ "<!-- the command the wrapper execs after sourcing ./.env in WorkingDirectory - absolute paths, nothing guessed -->",
586
+ ...execArgv.map((a) => `<string>${a}</string>`),
587
+ ].join("\n\t\t"),
375
588
  );
376
589
  // launchd's default PATH is /usr/bin:/bin — an nvm or Homebrew node is invisible to it. The exec
377
590
  // argv above pins THIS node absolutely, but the worker's own children (npx-style hooks, tooling
378
591
  // that spawns bare `node`) still resolve via PATH; prepending the render node's directory keeps
379
592
  // them on the SAME binary. PATH is configuration, not a secret: the template's deliberate
380
593
  // no-EnvironmentVariables stance is about credentials, which still live only in .env.
594
+ // The dict is built as a list so the seam below can extend it without a second anchor. Everything
595
+ // in it is a PATH; nothing in it is a credential, which is what keeps this file committable.
596
+ const envEntries = ["\t\t<key>PATH</key>", `\t\t<string>${dirname(ctx.execPath)}:/usr/bin:/bin:/usr/sbin:/sbin</string>`];
597
+ if (ctx.envSetup) {
598
+ // launchd has no EnvironmentFile and no shell in front of ProgramArguments, so the seam is a
599
+ // VARIABLE the wrapper reads rather than a composed command line: nothing here has to be quoted
600
+ // for a shell, and ProgramArguments stays byte-identical to the default render. The wrapper
601
+ // sources it AFTER ./.env, so a secrets manager wins over a stale key left in the file.
602
+ envEntries.push(
603
+ "\t\t<!-- `pi-dispatch service --env-setup`: worker-env-wrapper.sh sources this file after ./.env.\n\t\t A path, not a credential. Re-render to change it. -->",
604
+ "\t\t<key>PI_ENV_SETUP</key>",
605
+ `\t\t<string>${ctx.envSetup}</string>`,
606
+ );
607
+ }
608
+ const workingDir = `<key>WorkingDirectory</key>\n\t<string>${ctx.deployDir}</string>`;
381
609
  plist = plist.replace(
382
- "<key>WorkingDirectory</key>\n\t<string>/opt/pi-dispatch</string>",
383
- "<key>WorkingDirectory</key>\n\t<string>/opt/pi-dispatch</string>\n\n\t<!-- Injected by `pi-dispatch service`: launchd's default PATH cannot see an nvm/Homebrew node,\n\t and child processes may call bare `node`. Not a secrets dict - credentials still live only\n\t in .env (see the header comment). -->\n\t<key>EnvironmentVariables</key>\n\t<dict>\n\t\t<key>PATH</key>\n\t\t<string>" +
384
- `${dirname(ctx.execPath)}:/usr/bin:/bin:/usr/sbin:/sbin</string>\n\t</dict>`,
610
+ workingDir,
611
+ () =>
612
+ `${workingDir}\n\n\t<!-- Injected by \`pi-dispatch service\`: launchd's default PATH cannot see an nvm/Homebrew node,\n\t and child processes may call bare \`node\`. Not a secrets dict - credentials still live only\n\t in .env (see the header comment). -->\n\t<key>EnvironmentVariables</key>\n\t<dict>\n` +
613
+ `${envEntries.join("\n")}\n\t</dict>`,
385
614
  );
386
- // Everything left standing on /opt/pi-dispatch — WorkingDirectory, the log paths, comment prose —
387
- // belongs to the deployment folder.
388
- return plist.replaceAll("/opt/pi-dispatch", ctx.deployDir);
615
+ return plist;
389
616
  }
390
617
 
391
618
  /**
@@ -407,6 +634,10 @@ function nssmSequence(ctx) {
407
634
  commands: [
408
635
  ["install", service, ctx.wrapperCmd, ...execArgv],
409
636
  ["set", service, "AppDirectory", ctx.deployDir],
637
+ // Same seam as the plist's EnvironmentVariables dict, for the same reason: nssm takes a
638
+ // NAME=VALUE with no shell between it and the wrapper, so the path needs no quoting and
639
+ // AppParameters stays byte-identical to the default render.
640
+ ...(ctx.envSetup ? [["set", service, "AppEnvironmentExtra", `PI_ENV_SETUP=${ctx.envSetup}`]] : []),
410
641
  ["set", service, "AppStdout", `${logDir}\\${ctx.which}.out.log`],
411
642
  ["set", service, "AppStderr", `${logDir}\\${ctx.which}.err.log`],
412
643
  ["set", service, "AppStopMethodConsole", "15000"],
@@ -426,6 +657,11 @@ function doRender(ctx) {
426
657
  ctx.out(
427
658
  "\n# note: ProgramArguments runs the package's worker-env-wrapper.sh, which sources ./.env in the\n# WorkingDirectory above and then runs the argv that follows it — launchd has no EnvironmentFile.\n# The wrapper also converts a policy refusal (exit 2, EXIT_POLICY) into a clean exit, so KeepAlive\n# never relaunch-loops a refusal into a provider bill.\n",
428
659
  );
660
+ if (ctx.envSetup) {
661
+ ctx.out(
662
+ `# note: --env-setup is delivered as PI_ENV_SETUP above. The wrapper sources ${ctx.envSetup} AFTER\n# ./.env, so your manager wins over a stale value in the file, and a missing or failing script exits 1\n# rather than the exit 2 that means a policy refusal. With it set, ./.env becomes optional.\n`,
663
+ );
664
+ }
429
665
  return 0;
430
666
  }
431
667
  if (ctx.platform === "linux") {
@@ -610,6 +846,11 @@ async function doStatus(ctx) {
610
846
  if (status.code === null) ctx.out(`${paths.name}: cannot query — nssm.exe not on PATH (https://nssm.cc)\n`);
611
847
  else if (status.code !== 0) ctx.out(`${paths.name}: not installed\n`);
612
848
  else ctx.out(`${paths.name}: ${status.output.trim()}\n`);
849
+ if (status.code === 0) {
850
+ const extra = await runCapture(ctx, "nssm", ["get", paths.name, "AppEnvironmentExtra"]);
851
+ const setup = extra.code === 0 ? readUnitSeam(extra.output, "win32").setup : null;
852
+ if (setup) ctx.out(`env-setup: ${setup} (named by ${paths.name}'s AppEnvironmentExtra)\n`);
853
+ }
613
854
  return 0;
614
855
  }
615
856
  if (ctx.platform === "darwin") {
@@ -620,6 +861,7 @@ async function doStatus(ctx) {
620
861
  ctx.out(`user scope: not installed (${paths.installPath})\n`);
621
862
  }
622
863
  ctx.out(ctx.fs.existsSync(paths.systemPath) ? `system scope: ${paths.systemPath} EXISTS — not managed by this tool\n` : "system scope: none\n");
864
+ reportEnvSetup(ctx, [paths.installPath, paths.systemPath]);
623
865
  return 0;
624
866
  }
625
867
  if (ctx.fs.existsSync(paths.userPath)) {
@@ -630,9 +872,31 @@ async function doStatus(ctx) {
630
872
  }
631
873
  const systemHits = [paths.systemPath, ...(ctx.which === "worker" ? ["/etc/systemd/system/worker.service"] : [])].filter((p) => ctx.fs.existsSync(p));
632
874
  ctx.out(systemHits.length ? `system scope: ${systemHits.join(", ")} EXISTS — not managed by this tool\n` : "system scope: none\n");
875
+ reportEnvSetup(ctx, [paths.userPath, ...systemHits]);
633
876
  return 0;
634
877
  }
635
878
 
879
+ /**
880
+ * One informational line per unit that names an `--env-setup` script (issue #216). status REPORTS: it
881
+ * says where the seam is configured, and nothing about whether the script is still there, still
882
+ * private, or still uncommitted. Those are `doctor`'s — it is the preflight, it owns the warn tiers,
883
+ * and it already has the git check-ignore seam. Prints nothing when no seam is configured, so the
884
+ * output of every deployment that does not use one is byte-identical to before.
885
+ */
886
+ function reportEnvSetup(ctx, paths) {
887
+ for (const path of paths) {
888
+ if (!ctx.fs.existsSync(path)) continue;
889
+ let setup = null;
890
+ try {
891
+ setup = readUnitSeam(ctx.fs.readFileSync(path, "utf8"), ctx.platform).setup;
892
+ } catch {
893
+ // Unreadable — a system-scope unit this user may not read is the ordinary case. The scope line
894
+ // above already reported that it exists; status never fails on what it could not look at.
895
+ }
896
+ if (setup) ctx.out(`env-setup: ${setup} (named by ${path})\n`);
897
+ }
898
+ }
899
+
636
900
  async function doStart(ctx) {
637
901
  return startStop(ctx, "start");
638
902
  }
package/src/start.mjs CHANGED
@@ -13,6 +13,7 @@ import { makeForgejoAuth } from "./forgejo-auth.mjs";
13
13
  import { makeForgejoHost } from "./forgejo-host.mjs";
14
14
  import { makeAzureAuth } from "./azure-auth.mjs";
15
15
  import { makeAzureHost } from "./azure-host.mjs";
16
+ import { makeEgressPreflight } from "./egress.mjs";
16
17
  import { makeImagePreflight } from "./image-preflight.mjs";
17
18
  import { createWorker } from "./index.mjs";
18
19
  import { makeCollectChain } from "./outbox.mjs";
@@ -112,6 +113,22 @@ export function makeReaper({ log }) {
112
113
  await exec("docker", ["rm", "-f", name]);
113
114
  log("reaped_container", { name });
114
115
  }
116
+ // REQ-EGRESS-ALLOWLIST: the per-job networks those containers were on. Swept AFTER the containers,
117
+ // because a network with a member still attached cannot be removed -- and swept by the SAME
118
+ // `pi-job-` filter, so the namespace rule that keeps an operator's live sandbox safe from the
119
+ // container reaper keeps their sandbox NETWORK safe too, with no second rule to remember.
120
+ //
121
+ // A crashed worker is the case this exists for: `runContainer`'s own finally removes the network
122
+ // on every ordinary path, so anything still here outlived a process that did not get to run it.
123
+ // A network still in use by something else fails to remove and is skipped, which is correct: this
124
+ // is a best-effort sweep and never a reason not to boot.
125
+ const { stdout: nets } = await exec("docker", ["network", "ls", "--filter", "name=pi-job-", "--format", "{{.Name}}"]);
126
+ for (const net of nets.split("\n").map((n) => n.trim()).filter(Boolean)) {
127
+ try {
128
+ await exec("docker", ["network", "rm", net]);
129
+ log("reaped_network", { network: net });
130
+ } catch {} // still in use, or already gone -- either way not this boot's problem
131
+ }
115
132
  } catch (err) {
116
133
  log("reaper_skipped", { reason: err?.message });
117
134
  }
@@ -146,6 +163,7 @@ export async function startWorker(
146
163
  makeSandboxReaper: makeSandboxReaperFn = makeSandboxReaper,
147
164
  makeRunContainer: makeRunContainerFn = makeRunContainer,
148
165
  makeImagePreflight: makeImagePreflightFn = makeImagePreflight,
166
+ makeEgressPreflight: makeEgressPreflightFn = makeEgressPreflight,
149
167
  makeGitLabAuth: makeGitLabAuthFn = makeGitLabAuth,
150
168
  makeGitLabHost: makeGitLabHostFn = makeGitLabHost,
151
169
  makeForgejoAuth: makeForgejoAuthFn = makeForgejoAuth,
@@ -386,6 +404,12 @@ export async function startWorker(
386
404
  // one who removes it would stay admitted. Contrast the staged-package manifest, correctly read once at
387
405
  // boot because it is deploy-time state under a :ro mount; the host's image set is not.
388
406
  imagePreflight: makeImagePreflightFn({ image: config.jobImage }),
407
+ // REQ-EGRESS-ALLOWLIST, and built here for the same reason the image preflight is: one deployment
408
+ // value, one place, so the gate that checks the proxy and the runner that attaches to its network
409
+ // cannot disagree about which proxy is meant. Nothing is memoised here either -- an operator who
410
+ // starts the proxy mid-day must not stay refused, and one who stops it must not stay admitted.
411
+ // Unarmed it spawns nothing at all, so a deployment without a policy pays for none of this.
412
+ egressPreflight: makeEgressPreflightFn({ proxy: config.egressProxy, armed: config.egress }),
389
413
  // Completed-only, so a policy or infra exit leaves the canonical transcript byte-identical and a
390
414
  // retry starts from what the first attempt did (CONST-RETRY-INFRA-ONLY).
391
415
  promoteSession: sessionStore.promoteSession,
@@ -397,6 +421,8 @@ export async function startWorker(
397
421
  runContainer: makeRunContainerFn({
398
422
  image: config.jobImage,
399
423
  hostEnv: env,
424
+ egress: config.egress, // REQ-EGRESS-ALLOWLIST: the per-job network and the proxy variables
425
+ egressProxy: config.egressProxy,
400
426
  openJobLog,
401
427
  globalPiDir: config.globalPiDir, // REQ-GLOBAL-PI-OVERLAY: :ro overlay mount when configured
402
428
  allowGlobalExtensions: config.allowGlobalExtensions,