@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/triggers.mjs CHANGED
@@ -191,13 +191,20 @@ function normalizeCron(on, run, index, path, state) {
191
191
  throw configError(`cron trigger "${id}": on.pattern must have 5 or 6 space-separated fields, got ${fieldCount}: ${path}`);
192
192
  }
193
193
 
194
+ // FIRST among the run checks (all four normalizers do this), so a command-only entry is never told to
195
+ // add the flow it deliberately does not have, and a flow+command entry gets the exclusion message
196
+ // rather than whichever single-field check happens to run first.
197
+ const command = validateCommand(run, `cron trigger "${id}"`, path, { onType: "cron" });
198
+
194
199
  if (!isNonEmptyString(run.folder)) {
195
200
  throw configError(`cron trigger "${id}": run.folder must be a non-empty string: ${path}`);
196
201
  }
197
- if (!isNonEmptyString(run.flow)) {
198
- throw configError(`cron trigger "${id}": run.flow must be a non-empty string: ${path}`);
202
+ if (command === undefined && !isNonEmptyString(run.flow)) {
203
+ throw configError(`cron trigger "${id}": run.flow must be a non-empty string (or use run.command): ${path}`);
199
204
  }
200
- if (!isNonEmptyString(run.task)) {
205
+ // Gated on the flow path only: a command job's prompt IS the command line, and validateCommand has
206
+ // already refused any run.task written beside one.
207
+ if (command === undefined && !isNonEmptyString(run.task)) {
201
208
  throw configError(`cron trigger "${id}": run.task must be a non-empty string: ${path}`);
202
209
  }
203
210
 
@@ -229,7 +236,7 @@ function normalizeCron(on, run, index, path, state) {
229
236
  // freeze today's default into every stored repeatable.
230
237
  return {
231
238
  on: { type: "cron", id, pattern },
232
- run: { kind: "local", folder: run.folder, flow: run.flow, task: run.task, provider: run.provider, model: run.model, maxTurns: run.maxTurns, github: run.github, packages, image, resume, ...(skillsDir !== undefined && { skillsDir }) },
239
+ run: { kind: "local", folder: run.folder, flow: run.flow, task: run.task, provider: run.provider, model: run.model, maxTurns: run.maxTurns, github: run.github, packages, image, resume, ...(command !== undefined && { command }), ...(skillsDir !== undefined && { skillsDir }) },
233
240
  };
234
241
  }
235
242
 
@@ -271,8 +278,9 @@ function validatePackagesFlag(run, at, path) {
271
278
  * had. `validateReplicas` states the argument in one line and it applies verbatim here: a field accepted
272
279
  * where it does nothing is how an operator comes to trust one that does nothing.
273
280
  *
274
- * "NOT YET COVERED", not impossible -- validateReplicas' own distinction, kept because the two are
275
- * different facts and an operator planning work needs the right one. The local key already exists and is
281
+ * "NOT YET COVERED", not impossible -- a distinction this file keeps because the two are different facts
282
+ * and an operator planning work needs the right one. `run.replicas` carried the same wording for the same
283
+ * reason until #187 closed its gap; this one is still open, which is why the phrasing outlived it. The local key already exists and is
276
284
  * the strongest key in this feature: session-key.mjs keys a cron job on its scheduler id, which is
277
285
  * operator-authored, unique across the file, stable across fires, and chosen by nobody untrusted. Nothing
278
286
  * reaches it. Wiring `resolveSession` into the local path is a feature, and this line is what stops the
@@ -420,8 +428,9 @@ const INSTRUCTIONS_MAX = 2000;
420
428
  * `run.instructions` (issue #60): one line of operator standing text, rendered into the USER prompt's
421
429
  * envelope above the fenced data region.
422
430
  *
423
- * REFUSED on cron, and it is a DIFFERENT refusal from run.replicas' "not yet covered": cron already has
424
- * an operator-authored free-text field landing in the same region of the same file. A local job's prompt
431
+ * REFUSED on cron, and it is a DIFFERENT refusal from the "not yet covered" shape (run.resume's, since
432
+ * #187 retired run.replicas'): cron already has an operator-authored free-text field landing in the same
433
+ * region of the same file. A local job's prompt
425
434
  * is `flow hint + pointer + run.task` with no envelope, no data heading and no fence (prepare.mjs), so
426
435
  * there is no "standing" region distinct from the task for a second field to occupy. Two fields writing
427
436
  * one region with an undefined combination order is worse than a field that does nothing, because both
@@ -450,6 +459,85 @@ function validateInstructions(run, at, path, { cron = false } = {}) {
450
459
  return text;
451
460
  }
452
461
 
462
+ /**
463
+ * `run.command` (issue #189): dispatch a REGISTERED pi extension command headlessly, instead of a flow.
464
+ * The runner half is already merged: it exports the name via PI_COMMAND, refuses pre-spend when
465
+ * `getCommand` does not know it, and prompts with the exact `/name args` line and nothing else, so the
466
+ * arguments reach the command handler precisely as written here. Shared by all four normalizers so both
467
+ * services refuse the same file identically; the runner re-validates at the paid boundary regardless.
468
+ *
469
+ * EXACTLY ONE of run.flow / run.command, and the exclusion is checked BEFORE every normalizer's
470
+ * flow-required check on purpose: the two mistakes need their own messages. A flow+command entry no
471
+ * longer says which one runs and must hear that, not whichever single-field complaint fires first; a
472
+ * command-only entry must never be told to add the flow it deliberately does not have.
473
+ *
474
+ * The value rules each refuse something specific:
475
+ * - no leading "/": the runner PREPENDS the slash when it builds the prompt, so a written one would
476
+ * dispatch "//name" -- a command no registry holds, refused only after review already passed it.
477
+ * - surrounding whitespace is REFUSED, never trimmed (validateImageRef's rule): the arguments pass to
478
+ * the handler verbatim, so a silent trim is the reviewed file disagreeing with what runs.
479
+ * - no control characters. A newline would smuggle a SECOND line into what the operator reviewed as
480
+ * one command line, and the whole class is refused rather than the newline alone because every
481
+ * member is invisible in review, which is the hazard. The regex is spelled in \u escapes for the
482
+ * same reason: a literal ESC in this source would be exactly the unreviewable byte it refuses.
483
+ *
484
+ * Cross-field refusals, validateReplicas' posture (a field accepted where it does nothing is one an
485
+ * operator sets and then trusts):
486
+ * - run.task on cron: a command job's prompt IS the `/name args` line, so there is no task text for
487
+ * the runner to render -- two prompts written for one job, with only one ever sent.
488
+ * - run.instructions on the webhook kinds: instructions land in the prompt ENVELOPE, which a command
489
+ * job bypasses entirely, so nothing would render them. (Cron refuses instructions already, with its
490
+ * own run.task message, and that refusal stays the one a cron entry gets.)
491
+ * - run.resume: true on any kind: what a resumed session should do with a re-dispatched command is
492
+ * UNDESIGNED -- "not yet covered", validateResumeFlag's own vocabulary, because it is a gap to
493
+ * close and not a limit. Only `true` is refused; `false` is the documented default and refusing it
494
+ * would refuse an operator for writing down the behaviour they already have.
495
+ *
496
+ * Everything else stays orthogonal on purpose -- replicas, image, packages, skillsDir, repository,
497
+ * github. Those gate the CONTAINER a job runs in, and a command job runs in the same container a flow
498
+ * job does.
499
+ *
500
+ * A COMMENT command trigger has no default flow, which leaves the receiver's `<phrase> <flow>` comment
501
+ * override with nothing to override. That token is made inert by the receiver's FILTER, not refused
502
+ * here: the override lives in adversarial comment text, which this file-shape validator never sees.
503
+ *
504
+ * `at` is the caller's message prefix, `onType` selects the cross-field set. Returns the command,
505
+ * undefined when absent, and the callers spread it conditionally: a flow trigger must not grow the key
506
+ * at all, so an unflagged file normalizes byte-identically to today's (deepEqual pins depend on it).
507
+ */
508
+ function validateCommand(run, at, path, { onType }) {
509
+ const raw = run.command;
510
+ if (run.flow !== undefined && raw !== undefined) {
511
+ throw configError(`${at}: exactly one of run.flow or run.command must be set -- a trigger dispatches either a flow or a registered command, and with both present the file does not say which one runs: ${path}`);
512
+ }
513
+ if (raw === undefined) return undefined;
514
+ if (!isNonEmptyString(raw)) {
515
+ throw configError(`${at}: run.command must be a non-empty string -- the registered command name, optionally followed by its arguments: ${path}`);
516
+ }
517
+ if (raw !== raw.trim()) {
518
+ throw configError(`${at}: run.command must not have leading or trailing whitespace -- the arguments reach the command handler verbatim, so trimming here would make the reviewed file disagree with what runs (got ${JSON.stringify(raw)}): ${path}`);
519
+ }
520
+ if (raw.startsWith("/")) {
521
+ throw configError(`${at}: run.command must not start with "/" -- the runner prepends the slash when it builds the /name args prompt, so a written one would dispatch "//name" (got ${JSON.stringify(raw)}): ${path}`);
522
+ }
523
+ // The class is the RUNNER's (parseCommand, image/runner/src/config.mjs), DEL included: the two
524
+ // must refuse identically, or a value that loads here refuses in-container with the budget
525
+ // slot already burned -- the drift INT-TRIGGERS-FILE-CONTRACT promises cannot happen.
526
+ if (/[\u0000-\u001F\u007F]/.test(raw)) {
527
+ throw configError(`${at}: run.command must not contain control characters -- a newline would smuggle a second line into what the operator reviewed as one command line (got ${JSON.stringify(raw)}): ${path}`);
528
+ }
529
+ if (onType === "cron" && run.task !== undefined) {
530
+ throw configError(`${at}: run.command and run.task cannot be combined -- a command job's prompt IS the /name args line, so there is no task text for the runner to render; put the arguments in run.command: ${path}`);
531
+ }
532
+ if (onType !== "cron" && run.instructions !== undefined) {
533
+ throw configError(`${at}: run.command and run.instructions cannot be combined -- instructions render into the prompt envelope, and a command job's prompt is the exact /name args line with no envelope, so nothing would render them: ${path}`);
534
+ }
535
+ if (run.resume === true) {
536
+ throw configError(`${at}: combining run.command and run.resume is not yet covered -- what a resumed session should do with a re-dispatched command is undesigned, so this is a gap to close, not a limit: ${path}`);
537
+ }
538
+ return raw;
539
+ }
540
+
453
541
  /**
454
542
  * Validate an `{any, all, none}` label predicate. Selectors are validated as arrays of non-empty strings
455
543
  * BEFORE the positive-selector count, because `.length` is truthy on a string too -- a string selector
@@ -511,12 +599,12 @@ function validateRepository(run, onType, at, path) {
511
599
  *
512
600
  * WHY EACH REFUSAL:
513
601
  * - a LOCAL (cron) trigger: its `/workspace` IS the operator's folder, bind-mounted read-write and edited
514
- * in place, so two replicas would stomp each other's working tree with no gate and no undo. A github
602
+ * in place, so two replicas would stomp each other's working tree with no gate and no undo. A forge
515
603
  * job gets its own `mkdtemp`'d clone, which is the entire reason this is safe there and not here.
516
- * Checked FIRST so a cron trigger gets that reason rather than the forge-coverage one below.
517
- * - a non-github forge: every forge mints its branch through the same `issueBranch`, so extending this is
518
- * mechanical -- but it is not done, and the message says "not yet covered" rather than "impossible"
519
- * because those are different facts and an operator planning work needs the right one.
604
+ * Checked FIRST, and since #187 that ordering carries the whole kind gate rather than merely picking
605
+ * which reason a cron trigger hears. Every forge mints its branch through the same `issueBranch`, so
606
+ * anything that is not `local` is now allowed; move this below the range check and a cron entry
607
+ * carrying `replicas: 2` would be ACCEPTED, not refused with a different message.
520
608
  * - a non-integer, `< 2`, or `> REPLICAS_MAX`. `1` is REFUSED rather than accepted: a one-member replica
521
609
  * set is a field that does nothing, and this validator's whole job is to make sure nothing does nothing.
522
610
  * - `run.resume: true`. A resumed run continues ONE lineage; replicas exist to fork it. This is the
@@ -538,9 +626,6 @@ function validateReplicas(run, at, path) {
538
626
  if (run.kind === "local") {
539
627
  throw configError(`${at}: run.replicas is not available on a cron trigger -- a local job's /workspace IS the operator's folder, bind-mounted read-write, so two replicas would edit one working tree with no gate and no undo: ${path}`);
540
628
  }
541
- if (run.kind !== "github") {
542
- throw configError(`${at}: run.replicas is not yet covered for ${run.kind} triggers (github only in this version); every forge mints its branch the same way, so this is a gap to close, not a limit: ${path}`);
543
- }
544
629
  if (!Number.isInteger(replicas) || replicas < 2 || replicas > REPLICAS_MAX) {
545
630
  throw configError(`${at}: run.replicas must be an integer between 2 and ${REPLICAS_MAX} when present -- ${REPLICAS_MAX} is the ceiling because PI_CONCURRENCY defaults to 3, so a further replica would queue instead of racing, and 1 is refused because a one-member replica set is a flag that does nothing (got ${JSON.stringify(replicas)}): ${path}`);
546
631
  }
@@ -553,8 +638,10 @@ function validateReplicas(run, at, path) {
553
638
  function normalizeLabel(on, run, index, path) {
554
639
  const at = `trigger at index ${index}`;
555
640
  const predicate = validatePredicate(on, index, path, true);
556
- if (!isNonEmptyString(run.flow)) {
557
- throw configError(`${at}: label trigger run.flow must be a non-empty string: ${path}`);
641
+ // First among the run checks, before the flow-required check -- validateCommand says why.
642
+ const command = validateCommand(run, at, path, { onType: "label" });
643
+ if (command === undefined && !isNonEmptyString(run.flow)) {
644
+ throw configError(`${at}: label trigger run.flow must be a non-empty string (or use run.command): ${path}`);
558
645
  }
559
646
  const packages = validatePackagesFlag(run, at, path);
560
647
  const image = validateImageRef(run, at, path);
@@ -565,7 +652,7 @@ function normalizeLabel(on, run, index, path) {
565
652
  const replicas = validateReplicas(run, at, path);
566
653
  return {
567
654
  on: { type: "label", any: predicate.any, all: predicate.all, none: predicate.none },
568
- run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...(skillsDir !== undefined && { skillsDir }), ...(instructions !== undefined && { instructions }), ...(repository !== undefined && { repository }) },
655
+ run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...(command !== undefined && { command }), ...(skillsDir !== undefined && { skillsDir }), ...(instructions !== undefined && { instructions }), ...(repository !== undefined && { repository }) },
569
656
  };
570
657
  }
571
658
 
@@ -574,8 +661,12 @@ function normalizeComment(on, run, index, path, state) {
574
661
  if (!isNonEmptyString(on.phrase)) {
575
662
  throw configError(`${at}: comment trigger on.phrase must be a non-empty string: ${path}`);
576
663
  }
577
- if (!isNonEmptyString(run.flow)) {
578
- throw configError(`${at}: comment trigger run.flow (the default flow) must be a non-empty string: ${path}`);
664
+ // First among the run checks, before the flow-required check -- validateCommand says why. A command
665
+ // trigger has NO default flow for the `<phrase> <flow>` comment override to replace; making that
666
+ // token inert is the receiver filter's job, not a shape this validator can see.
667
+ const command = validateCommand(run, at, path, { onType: "comment" });
668
+ if (command === undefined && !isNonEmptyString(run.flow)) {
669
+ throw configError(`${at}: comment trigger run.flow (the default flow) must be a non-empty string (or use run.command): ${path}`);
579
670
  }
580
671
  // At most one comment trigger PER FORGE. The cap exists because the receiver holds one comment rule
581
672
  // per forge and a second would be silently unreachable -- so it is a cap on ambiguity, not on count,
@@ -593,7 +684,7 @@ function normalizeComment(on, run, index, path, state) {
593
684
  const replicas = validateReplicas(run, at, path);
594
685
  return {
595
686
  on: { type: "comment", phrase: on.phrase },
596
- run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...(skillsDir !== undefined && { skillsDir }), ...(instructions !== undefined && { instructions }), ...(repository !== undefined && { repository }) },
687
+ run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...(command !== undefined && { command }), ...(skillsDir !== undefined && { skillsDir }), ...(instructions !== undefined && { instructions }), ...(repository !== undefined && { repository }) },
597
688
  };
598
689
  }
599
690
 
@@ -634,8 +725,10 @@ function normalizePullRequest(on, run, index, path) {
634
725
  throw configError(`${at}: an azure pull_request trigger cannot carry a label predicate -- Azure DevOps attaches tags to work items, never to pull requests, so any/all/none could never match: ${path}`);
635
726
  }
636
727
  const predicate = validatePredicate(on, index, path, requirePositive);
637
- if (!isNonEmptyString(run.flow)) {
638
- throw configError(`${at}: pull_request trigger run.flow must be a non-empty string: ${path}`);
728
+ // First among the run checks, before the flow-required check -- validateCommand says why.
729
+ const command = validateCommand(run, at, path, { onType: "pull_request" });
730
+ if (command === undefined && !isNonEmptyString(run.flow)) {
731
+ throw configError(`${at}: pull_request trigger run.flow must be a non-empty string (or use run.command): ${path}`);
639
732
  }
640
733
  const packages = validatePackagesFlag(run, at, path);
641
734
  const image = validateImageRef(run, at, path);
@@ -655,7 +748,7 @@ function normalizePullRequest(on, run, index, path) {
655
748
  all: predicate.all,
656
749
  none: predicate.none,
657
750
  },
658
- run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...(skillsDir !== undefined && { skillsDir }), ...(instructions !== undefined && { instructions }) },
751
+ run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...(command !== undefined && { command }), ...(skillsDir !== undefined && { skillsDir }), ...(instructions !== undefined && { instructions }) },
659
752
  };
660
753
  }
661
754
 
package/src/up.mjs CHANGED
@@ -24,6 +24,7 @@ import { spawn as nodeSpawn } from "node:child_process";
24
24
  import { chmodSync, existsSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
25
25
  import { connect as netConnect } from "node:net";
26
26
  import { join } from "node:path";
27
+ import { egressArmed as egressArmedFn } from "./egress.mjs";
27
28
  import { updateEnvFile } from "./env-file.mjs";
28
29
 
29
30
  // The one image up may ever fetch, and the local name jobs run under. Literal on purpose (not
@@ -63,6 +64,31 @@ const VALKEY_RUN_ARGS = [
63
64
  "yes",
64
65
  ];
65
66
 
67
+ // deploy/docker-compose.yml's `egress` profile, reproduced as one docker run (REQ-EGRESS-ALLOWLIST):
68
+ // same digest-pinned image, same two mounts, same explicit container name, same restart policy, on the
69
+ // same upstream network. Written out here for the same reason VALKEY_RUN_ARGS is -- an operator who runs
70
+ // `up` and one who runs compose must end up with the same component, and two ways of starting one thing
71
+ // is two places for it to drift.
72
+ //
73
+ // The per-job networks are NOT here: the worker creates one per job and attaches this container to it for
74
+ // the life of that run. This is only the proxy and its way out.
75
+ const EGRESS_NETWORK_ARGS = ["network", "create", "pi-dispatch-egress-out"];
76
+ const EGRESS_RUN_ARGS = [
77
+ "run",
78
+ "-d",
79
+ "--name",
80
+ "pi-dispatch-egress-proxy",
81
+ "--restart",
82
+ "unless-stopped",
83
+ "--network",
84
+ "pi-dispatch-egress-out",
85
+ "-v",
86
+ "./deploy/egress-proxy.conf:/etc/squid/squid.conf:ro",
87
+ "-v",
88
+ "./egress-allowlist.conf:/etc/pi-dispatch/allowlist.conf:ro",
89
+ "ubuntu/squid@sha256:6a097f68bae708cedbabd6188d68c7e2e7a38cedd05a176e1cc0ba29e3bbe029",
90
+ ];
91
+
66
92
  export async function runUp(argv = [], deps = {}) {
67
93
  const {
68
94
  env = process.env,
@@ -173,6 +199,38 @@ export async function runUp(argv = [], deps = {}) {
173
199
  summary.push(["WEBHOOK_SECRET", "no .env here — skipped (set it wherever your env lives)"]);
174
200
  }
175
201
 
202
+ // (e2) the egress policy's proxy, and ONLY when the operator has already armed it. up never invents
203
+ // operator policy -- the same doctrine that keeps it pulling this repo's own image and no other -- so a
204
+ // deployment that has not set PI_EGRESS hears nothing about this at all.
205
+ //
206
+ // AFTER init, deliberately: init has just scaffolded egress-allowlist.conf, and starting a proxy whose
207
+ // allowlist file does not exist gets a directory created by docker where a file belonged and a squid
208
+ // that fails confusingly. If the file is still missing, this step declines itself and says which file.
209
+ if (egressArmedFn(env)) {
210
+ if ((await runCmd(spawn, "docker", ["inspect", "--format={{.State.Running}}", "pi-dispatch-egress-proxy"])) === 0) {
211
+ out("\n✓ Egress proxy already present (pi-dispatch-egress-proxy)\n");
212
+ summary.push(["egress", "proxy already present — left untouched"]);
213
+ } else if (!fs.existsSync(join(cwd, "egress-allowlist.conf"))) {
214
+ out("\n✗ the egress policy is on but egress-allowlist.conf is not here — not starting a proxy with no allowlist\n");
215
+ summary.push(["egress", "skipped — no egress-allowlist.conf in this folder; run `pi-dispatch init` here, then `up` again"]);
216
+ } else if (
217
+ await consent("The egress policy is on (PI_EGRESS=0 opts out) but the allowlist proxy is not running. up would start it (same semantics as deploy/docker-compose.yml --profile egress):", [`docker ${EGRESS_NETWORK_ARGS.join(" ")}`, `docker ${quoteArgs(EGRESS_RUN_ARGS)}`], { yes, out, prompt })
218
+ ) {
219
+ // The network may already exist from a previous run; that is not a failure, so its code is not
220
+ // checked. The proxy is what matters and it is checked.
221
+ await runStreamed(spawn, "docker", EGRESS_NETWORK_ARGS, out);
222
+ if ((await runStreamed(spawn, "docker", EGRESS_RUN_ARGS, out)) !== 0) {
223
+ out("✗ could not start the egress proxy — continuing; doctor below will re-check it\n");
224
+ summary.push(["egress", "start failed — every job refuses pre-spend until it is up (costs no budget, runs nothing)"]);
225
+ } else {
226
+ summary.push(["egress", "started pi-dispatch-egress-proxy on pi-dispatch-egress-out"]);
227
+ }
228
+ } else {
229
+ out("skipped — start it later with `docker compose -f deploy/docker-compose.yml --profile egress up -d`\n");
230
+ summary.push(["egress", "skipped (declined) — every job is refused pre-spend until the proxy is up (PI_EGRESS=0 opts out)"]);
231
+ }
232
+ }
233
+
176
234
  // (f) doctor — always, verbatim: up converges what it can, doctor is the judge of what remains
177
235
  // (provider key, forge env, overlay …), and its verdict is up's exit code.
178
236
  out("\ndoctor:\n");