@edgehero/pi-dispatch 4.1.0 → 4.2.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
@@ -32,6 +32,8 @@ import { imageRefProblem } from "./image-ref.mjs";
32
32
  import { SKILL_NAME_RE } from "./flow-gate.mjs";
33
33
  import { FORGE_HOST_VARS, FORGE_KINDS, MINTED_TOKEN_VARS, RUN_KINDS, forgeSpec, isForgeKind } from "./forges.mjs";
34
34
  import { findDuplicateKey } from "./json-duplicates.mjs";
35
+ // The server-name rule, from the one module that parses the overlay's list (its body is import-inert).
36
+ import { FORGE_MCP_KINDS, FORGE_MCP_SERVER_NAME, MCP_SERVER_NAME_RE } from "./mcp-config.mjs";
35
37
  import { validateModelRef } from "./model-ref.mjs";
36
38
  import { parseUsdMicros } from "./money.mjs";
37
39
  import { PROVIDER_STEERING_VARS } from "./provider-steering.mjs";
@@ -189,6 +191,29 @@ const REVIEW_ACTION = "review_submitted";
189
191
  */
190
192
  // EXPORTED for the two pinned-set bolts and for the admin, which states the vocabulary to an operator.
191
193
  export const EXCLUDABLE_TOOL_NAMES = new Set(["read", "bash", "powershell", "edit", "write", "grep", "find", "ls"]);
194
+ /**
195
+ * The tools a job's MCP brings (REQ-MCP-OPERATOR-SERVERS, image/runner/src/mcp.mjs), excludable only by a trigger that
196
+ * names servers: codemode, tool_search and pi's three resource tools. One server's tools are excluded as a whole by
197
+ * `mcpServerToolPattern(name)`; a single tool of a server is not, since its name (pi's sanitized `mcp__<server>__<tool>`)
198
+ * is unknown until the server lists it, and a misspelled one would exclude nothing. That is the overlay's
199
+ * `toolExposure: "hidden"`, the operator's.
200
+ */
201
+ export const MCP_EXCLUDABLE_TOOL_NAMES = new Set(["codemode", "tool_search", "list_mcp_resources", "list_mcp_resource_templates", "read_mcp_resource"]);
202
+ /**
203
+ * `excludeTools` without the entries that name the job's MCP tools, or undefined when nothing is left: what a job that
204
+ * connects no MCP server carries (a chained child, which never inherits `run.mcp`). Its runner knows none of those
205
+ * names, and with no MCP there is nothing for them to remove, so dropping them narrows nothing.
206
+ */
207
+ export function withoutMcpExclusions(excludeTools) {
208
+ if (!Array.isArray(excludeTools)) return excludeTools;
209
+ const kept = excludeTools.filter((name) => !MCP_EXCLUDABLE_TOOL_NAMES.has(name) && !String(name).startsWith("mcp__"));
210
+ return kept.length > 0 ? kept : undefined;
211
+ }
212
+
213
+ /** The excludeTools entry that removes every tool of one named server: pi's namespace, `-` written as `_`. */
214
+ export function mcpServerToolPattern(name) {
215
+ return `mcp__${String(name).replace(/-/g, "_")}__*`;
216
+ }
192
217
 
193
218
  // A cron id flows into BullMQ's deterministic `repeat:<id>:<nextMillis>` jobId, so a `:` corrupts that
194
219
  // parse; the charset also excludes `:` and the dedicated check names the reason.
@@ -416,6 +441,7 @@ function normalizeCron(on, run, index, path, state) {
416
441
  // A cron job's session narrows like any other: the field selects what the container's agent can do,
417
442
  // which is orthogonal to what triggered it (issue #291).
418
443
  const excludeTools = validateExcludeTools(on, run, `cron trigger "${id}"`, path);
444
+ const mcp = validateMcp(on, run, `cron trigger "${id}"`, path, { kind: "local" });
419
445
  // Validated since #502; before it these three were copied untouched, so `run.provider: 7` loaded.
420
446
  const ref = validateRunModel(on, run, `cron trigger "${id}"`, path);
421
447
  // Issue #505: the portfolio flag. Strictly boolean, the house rule (`run.github`'s reason: a truthy string must not
@@ -430,7 +456,7 @@ function normalizeCron(on, run, index, path, state) {
430
456
  // freeze today's default into every stored repeatable.
431
457
  return {
432
458
  on: { type: "cron", id, pattern },
433
- run: { kind: "local", folder: run.folder, flow: run.flow, task: run.task, provider: ref.provider, model: ref.model, maxTurns: ref.maxTurns, github: run.github, packages, image, resume, ...(command !== undefined && { command }), ...(skillsDir !== undefined && { skillsDir }), ...(secrets !== undefined && { secrets }), ...(secretsProfile !== undefined && { secretsProfile }), ...(backend !== undefined && { backend }), ...(excludeTools !== undefined && { excludeTools }), ...(ref.models !== undefined && { models: ref.models }), ...(ref.maxCostUsd !== undefined && { maxCostUsd: ref.maxCostUsd }), ...(portfolio !== undefined && { portfolio }) },
459
+ run: { kind: "local", folder: run.folder, flow: run.flow, task: run.task, provider: ref.provider, model: ref.model, maxTurns: ref.maxTurns, github: run.github, packages, image, resume, ...(command !== undefined && { command }), ...(skillsDir !== undefined && { skillsDir }), ...(secrets !== undefined && { secrets }), ...(secretsProfile !== undefined && { secretsProfile }), ...(backend !== undefined && { backend }), ...(excludeTools !== undefined && { excludeTools }), ...(mcp !== undefined && { mcp }), ...(ref.models !== undefined && { models: ref.models }), ...(ref.maxCostUsd !== undefined && { maxCostUsd: ref.maxCostUsd }), ...(portfolio !== undefined && { portfolio }) },
434
460
  };
435
461
  }
436
462
 
@@ -1368,14 +1394,22 @@ function validateExcludeTools(on, run, at, path) {
1368
1394
  if (!Array.isArray(excludeTools) || excludeTools.length === 0) {
1369
1395
  throw configError(`${at}: run.excludeTools must be a non-empty array of tool names when present (got ${JSON.stringify(excludeTools)}) -- an empty exclusion is a field that does nothing, and accepted-and-ignored is how an operator comes to trust one that does: ${path}`);
1370
1396
  }
1397
+ // With run.mcp, the tools the job's MCP brings are known too (its shape is the next validator's to judge; here a
1398
+ // name that is not a valid server name simply adds no pattern).
1399
+ const known = new Set(EXCLUDABLE_TOOL_NAMES);
1400
+ if (Array.isArray(run?.mcp) && run.mcp.length > 0) {
1401
+ for (const name of MCP_EXCLUDABLE_TOOL_NAMES) known.add(name);
1402
+ for (const name of run.mcp) if (typeof name === "string" && MCP_SERVER_NAME_RE.test(name)) known.add(mcpServerToolPattern(name));
1403
+ }
1371
1404
  const seen = new Set();
1372
1405
  for (let i = 0; i < excludeTools.length; i++) {
1373
1406
  const name = excludeTools[i];
1374
1407
  if (!isNonEmptyString(name)) {
1375
1408
  throw configError(`${at}: run.excludeTools[${i}] must be a non-empty string naming a pi tool (got ${JSON.stringify(name)}): ${path}`);
1376
1409
  }
1377
- if (!EXCLUDABLE_TOOL_NAMES.has(name)) {
1378
- throw configError(`${at}: run.excludeTools[${i}] ${JSON.stringify(name)} is not a tool the pinned pi knows (known: ${[...EXCLUDABLE_TOOL_NAMES].join(", ")}) -- pi ignores unknown names silently, so a misspelled exclusion would exclude nothing while the file reads as though it did: ${path}`);
1410
+ if (!known.has(name)) {
1411
+ const mcpHint = MCP_EXCLUDABLE_TOOL_NAMES.has(name) || name.startsWith("mcp__") ? " (an MCP tool is excludable only by a trigger whose run.mcp names its server, and a server's tools only all at once, as mcp__<server>__*)" : "";
1412
+ throw configError(`${at}: run.excludeTools[${i}] ${JSON.stringify(name)} is not a tool the pinned pi knows (known: ${[...known].join(", ")})${mcpHint} -- pi ignores unknown names silently, so a misspelled exclusion would exclude nothing while the file reads as though it did: ${path}`);
1379
1413
  }
1380
1414
  if (seen.has(name)) {
1381
1415
  throw configError(`${at}: run.excludeTools names ${JSON.stringify(name)} twice -- the entries are a removal set, so the second can never change the answer: ${path}`);
@@ -1386,6 +1420,84 @@ function validateExcludeTools(on, run, at, path) {
1386
1420
  return [...excludeTools];
1387
1421
  }
1388
1422
 
1423
+ /** The most MCP servers one trigger may name. A bound, not a budget: each one is a connection the job opens at start. */
1424
+ export const MCP_SERVERS_MAX = 8;
1425
+
1426
+ /** The spellings of `run.mcp` a key is refused for, compared after `mcpKeySkeleton`. */
1427
+ const MCP_NEAR_MISSES = Object.freeze(["mcp", "mcps", "mcpserver", "mcpservers"]);
1428
+
1429
+ /** An ASCII key reduced to its letters and digits, lowercased: `MCP_Servers` and `mcp-servers` both read `mcpservers`. */
1430
+ export function mcpKeySkeleton(key) {
1431
+ return String(key).toLowerCase().replace(/[^a-z0-9]/g, "");
1432
+ }
1433
+
1434
+ /**
1435
+ * Whether `key` (not the field itself) is refused as a spelling of `run.mcp` rather than dropped: an ASCII key whose
1436
+ * skeleton is one of the field's spellings, or a key that is not plain ASCII and whose plain letters are all letters
1437
+ * of `mcpservers`. A key in another script can be drawn exactly like this one (Greek, Cyrillic, Cherokee, small
1438
+ * capitals, fullwidth forms), which no table of lookalikes keeps up with, but any such key keeps at most the field's
1439
+ * own letters in ASCII. An unrelated non-ASCII key (`fl\u00f6w`) keeps others and still loads, and a lookalike of
1440
+ * another field (`m\u043edels`) is left to that field's sweep.
1441
+ */
1442
+ export function isMcpNearMiss(key) {
1443
+ if (key === "mcp") return false;
1444
+ if (/^[\x20-\x7E]*$/.test(key)) return MCP_NEAR_MISSES.includes(mcpKeySkeleton(key));
1445
+ return [...key.matchAll(/[a-z]/gi)].every(([letter]) => "mcpservers".includes(letter.toLowerCase()));
1446
+ }
1447
+
1448
+ /**
1449
+ * `run.mcp` -- the overlay MCP servers this trigger's jobs connect (REQ-MCP-OPERATOR-SERVERS), by name.
1450
+ *
1451
+ * NAMES ONLY. A server's definition is the operator's, in the overlay's `mcp.json` (INT-OVERLAY-MCP-CONFIG), so a
1452
+ * trigger edit can choose among the servers the deployment offers and never point a credential at a new host. The
1453
+ * loader is fs-free, so whether a name is in the overlay is checked where the file is read: at the job's pickup,
1454
+ * before anything is spent, and by doctor. Absent means none: a job connects no MCP server unless its trigger says
1455
+ * so. The near-miss sweep is excludeTools': a dropped `run.mcp` runs the job without the servers its flow was written
1456
+ * against, while the file reads as though it named them.
1457
+ *
1458
+ * One name is not the overlay's: `forge` (DES-FORGE-MCP) is the job's own forge, through that forge's MCP server and
1459
+ * the job's own token, and the worker writes its entry. Accepted on a github or gitlab trigger only, and refused here
1460
+ * on the rest, since whether it can ever work is a fact about the kind: a cron job holds no forge token of its own, and
1461
+ * forgejo and azure have no forge MCP server this repository has verified.
1462
+ */
1463
+ function validateMcp(on, run, at, path, { kind }) {
1464
+ for (const [label, source, exactIsLegal] of [
1465
+ ["run", run, true],
1466
+ ["on", on, false],
1467
+ ]) {
1468
+ for (const key of Object.keys(source ?? {})) {
1469
+ if (exactIsLegal ? isMcpNearMiss(key) : key === "mcp" || isMcpNearMiss(key)) {
1470
+ if (!/^[\x20-\x7E]*$/.test(key)) {
1471
+ throw configError(`${at}: ${label}.${JSON.stringify(key)} is not a field: a key outside plain ASCII with no plain letter other than run.mcp's can be drawn exactly like run.mcp, which the loader would drop while the file reads as though it named servers. Write field names in plain ASCII: ${path}`);
1472
+ }
1473
+ throw configError(`${at}: ${label}.${key} is not a field -- did you mean run.mcp? A list of MCP servers the loader drops runs the job without them while the file reads as though it named them, so a near miss is refused rather than dropped: ${path}`);
1474
+ }
1475
+ }
1476
+ }
1477
+ const mcp = run?.mcp;
1478
+ if (mcp === undefined) return undefined;
1479
+ if (!Array.isArray(mcp) || mcp.length === 0) {
1480
+ throw configError(`${at}: run.mcp must be a non-empty array of server names from the overlay's mcp.json when present (got ${JSON.stringify(mcp)}); leave it out for none: ${path}`);
1481
+ }
1482
+ if (mcp.length > MCP_SERVERS_MAX) {
1483
+ throw configError(`${at}: run.mcp names ${mcp.length} servers, more than ${MCP_SERVERS_MAX}: ${path}`);
1484
+ }
1485
+ const seen = new Set();
1486
+ for (let i = 0; i < mcp.length; i++) {
1487
+ const name = mcp[i];
1488
+ if (typeof name !== "string" || !MCP_SERVER_NAME_RE.test(name)) {
1489
+ throw configError(`${at}: run.mcp[${i}] must name a server of the overlay's mcp.json: lowercase letters and digits, words joined by single -, up to 32 (got ${JSON.stringify(name)}): ${path}`);
1490
+ }
1491
+ if (seen.has(name)) throw configError(`${at}: run.mcp names ${JSON.stringify(name)} twice: ${path}`);
1492
+ seen.add(name);
1493
+ if (name === FORGE_MCP_SERVER_NAME && !FORGE_MCP_KINDS.includes(kind)) {
1494
+ const why = kind === "local" ? "a cron job holds no forge token of its own for the server to use" : `${kind} has no forge MCP server this deployment has verified yet`;
1495
+ throw configError(`${at}: run.mcp names "forge", the job's own forge server, which a ${FORGE_MCP_KINDS.join(" or ")} trigger may name and this one may not: ${why}: ${path}`);
1496
+ }
1497
+ }
1498
+ return [...mcp];
1499
+ }
1500
+
1389
1501
  /**
1390
1502
  * `run.waitFor` -- the conditions that must all clear before this trigger's job starts (issue #230).
1391
1503
  *
@@ -1544,10 +1656,11 @@ function normalizeLabel(on, run, index, path) {
1544
1656
  const backend = validateBackend(on, run, at, path, { localWorkspace: false });
1545
1657
  const waitFor = validateWaitFor(on, run, at, path, { onType: on.type });
1546
1658
  const excludeTools = validateExcludeTools(on, run, at, path);
1659
+ const mcp = validateMcp(on, run, at, path, { kind: run.kind });
1547
1660
  const ref = validateRunModel(on, run, at, path);
1548
1661
  return {
1549
1662
  on: { type: "label", any: predicate.any, all: predicate.all, none: predicate.none },
1550
- run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...(command !== undefined && { command }), ...(skillsDir !== undefined && { skillsDir }), ...(instructions !== undefined && { instructions }), ...(repository !== undefined && { repository }), ...(secrets !== undefined && { secrets }), ...(secretsProfile !== undefined && { secretsProfile }), ...(waitFor !== undefined && { waitFor }), ...(backend !== undefined && { backend }), ...(excludeTools !== undefined && { excludeTools }), ...ref },
1663
+ run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...(command !== undefined && { command }), ...(skillsDir !== undefined && { skillsDir }), ...(instructions !== undefined && { instructions }), ...(repository !== undefined && { repository }), ...(secrets !== undefined && { secrets }), ...(secretsProfile !== undefined && { secretsProfile }), ...(waitFor !== undefined && { waitFor }), ...(backend !== undefined && { backend }), ...(excludeTools !== undefined && { excludeTools }), ...(mcp !== undefined && { mcp }), ...ref },
1551
1664
  };
1552
1665
  }
1553
1666
 
@@ -1586,10 +1699,11 @@ function normalizeComment(on, run, index, path, state) {
1586
1699
  const backend = validateBackend(on, run, at, path, { localWorkspace: false });
1587
1700
  const waitFor = validateWaitFor(on, run, at, path, { onType: on.type });
1588
1701
  const excludeTools = validateExcludeTools(on, run, at, path);
1702
+ const mcp = validateMcp(on, run, at, path, { kind: run.kind });
1589
1703
  const ref = validateRunModel(on, run, at, path);
1590
1704
  return {
1591
1705
  on: { type: "comment", phrase: on.phrase },
1592
- run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...(command !== undefined && { command }), ...(skillsDir !== undefined && { skillsDir }), ...(instructions !== undefined && { instructions }), ...(repository !== undefined && { repository }), ...(secrets !== undefined && { secrets }), ...(secretsProfile !== undefined && { secretsProfile }), ...(waitFor !== undefined && { waitFor }), ...(backend !== undefined && { backend }), ...(excludeTools !== undefined && { excludeTools }), ...ref },
1706
+ run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...(command !== undefined && { command }), ...(skillsDir !== undefined && { skillsDir }), ...(instructions !== undefined && { instructions }), ...(repository !== undefined && { repository }), ...(secrets !== undefined && { secrets }), ...(secretsProfile !== undefined && { secretsProfile }), ...(waitFor !== undefined && { waitFor }), ...(backend !== undefined && { backend }), ...(excludeTools !== undefined && { excludeTools }), ...(mcp !== undefined && { mcp }), ...ref },
1593
1707
  };
1594
1708
  }
1595
1709
 
@@ -1655,6 +1769,7 @@ function normalizeIssue(on, run, index, path) {
1655
1769
  const backend = validateBackend(on, run, at, path, { localWorkspace: false });
1656
1770
  const waitFor = validateWaitFor(on, run, at, path, { onType: on.type });
1657
1771
  const excludeTools = validateExcludeTools(on, run, at, path);
1772
+ const mcp = validateMcp(on, run, at, path, { kind: run.kind });
1658
1773
  const ref = validateRunModel(on, run, at, path);
1659
1774
  return {
1660
1775
  on: {
@@ -1665,7 +1780,7 @@ function normalizeIssue(on, run, index, path) {
1665
1780
  ...(number !== undefined && { number }),
1666
1781
  ...(once !== undefined && { once }),
1667
1782
  },
1668
- run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...(command !== undefined && { command }), ...(skillsDir !== undefined && { skillsDir }), ...(instructions !== undefined && { instructions }), ...(secrets !== undefined && { secrets }), ...(secretsProfile !== undefined && { secretsProfile }), ...(waitFor !== undefined && { waitFor }), ...(backend !== undefined && { backend }), ...(excludeTools !== undefined && { excludeTools }), ...ref },
1783
+ run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...(command !== undefined && { command }), ...(skillsDir !== undefined && { skillsDir }), ...(instructions !== undefined && { instructions }), ...(secrets !== undefined && { secrets }), ...(secretsProfile !== undefined && { secretsProfile }), ...(waitFor !== undefined && { waitFor }), ...(backend !== undefined && { backend }), ...(excludeTools !== undefined && { excludeTools }), ...(mcp !== undefined && { mcp }), ...ref },
1669
1784
  };
1670
1785
  }
1671
1786
 
@@ -1742,6 +1857,7 @@ function normalizePullRequest(on, run, index, path) {
1742
1857
  const backend = validateBackend(on, run, at, path, { localWorkspace: false });
1743
1858
  const waitFor = validateWaitFor(on, run, at, path, { onType: on.type });
1744
1859
  const excludeTools = validateExcludeTools(on, run, at, path);
1860
+ const mcp = validateMcp(on, run, at, path, { kind: run.kind });
1745
1861
  const ref = validateRunModel(on, run, at, path);
1746
1862
  return {
1747
1863
  on: {
@@ -1757,7 +1873,7 @@ function normalizePullRequest(on, run, index, path) {
1757
1873
  ...(number !== undefined && { number }),
1758
1874
  ...(once !== undefined && { once }),
1759
1875
  },
1760
- run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...(command !== undefined && { command }), ...(skillsDir !== undefined && { skillsDir }), ...(instructions !== undefined && { instructions }), ...(secrets !== undefined && { secrets }), ...(secretsProfile !== undefined && { secretsProfile }), ...(waitFor !== undefined && { waitFor }), ...(backend !== undefined && { backend }), ...(excludeTools !== undefined && { excludeTools }), ...ref },
1876
+ run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...(command !== undefined && { command }), ...(skillsDir !== undefined && { skillsDir }), ...(instructions !== undefined && { instructions }), ...(secrets !== undefined && { secrets }), ...(secretsProfile !== undefined && { secretsProfile }), ...(waitFor !== undefined && { waitFor }), ...(backend !== undefined && { backend }), ...(excludeTools !== undefined && { excludeTools }), ...(mcp !== undefined && { mcp }), ...ref },
1761
1877
  };
1762
1878
  }
1763
1879