@edgehero/pi-dispatch 4.1.0 → 4.3.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/.env.example CHANGED
@@ -33,15 +33,16 @@ ANTHROPIC_API_KEY=
33
33
 
34
34
  # --- Which provider/model to run by default (override per job with --provider / --model) ---
35
35
  PI_PROVIDER=anthropic
36
- # A DATED model id is deterministic; a floating alias (e.g. claude-sonnet-4-5) can change cost.
37
- PI_MODEL=claude-sonnet-4-5-20250929
36
+ # Name an exact model version (claude-sonnet-5-5, or a dated id such as claude-sonnet-4-5-20250929); an alias
37
+ # that moves to a newer model can change cost.
38
+ PI_MODEL=claude-sonnet-5-5
38
39
  # optional: the only models a job may call, comma-separated provider/model (at most 16). A trigger's own
39
40
  # run.models replaces this list for its jobs. The default model must be on it, or every job that uses the
40
41
  # default is refused before it spends. Env only: the settings overlay cannot set or widen it.
41
42
  # Unset = any model. Needs a job image that declares the modelPolicy capability.
42
43
  # No spaces, not even after a comma: a shell that sources this file cuts the value at the first space
43
44
  # and leaves it unset, so jobs would run with no list (the worker refuses a spaced value at boot).
44
- # PI_ALLOWED_MODELS=anthropic/claude-sonnet-4-5-20250929,anthropic/claude-haiku-4-5-20251001
45
+ # PI_ALLOWED_MODELS=anthropic/claude-sonnet-5-5,anthropic/claude-haiku-5-5
45
46
 
46
47
  # --- Spend + concurrency guards (money bounds; all have conservative defaults) ---
47
48
  # per-job turn cap -- pi has none of its own, so the harness imposes one
@@ -433,7 +434,8 @@ GITHUB_APP_PRIVATE_KEY=
433
434
  # Optional. Set these only to service GitLab projects; leaving GITLAB_TOKEN unset means no /gitlab
434
435
  # endpoint exists at all, rather than one that answers 401. See docs/gitlab.md.
435
436
  #
436
- # A PROJECT access token with the `api` scope and the Developer role or above. `api` is the narrowest
437
+ # A PROJECT access token with the `api` scope and the Developer role, not Maintainer (docs/gitlab.md:
438
+ # a Maintainer token lets the job unprotect a branch or change who may merge). `api` is the narrowest
437
439
  # scope that can post a note -- GitLab offers no contents-vs-issues split -- so scope it to one project
438
440
  # and rotate it (CONST-TOKEN-SCOPED-PER-JOB). A GROUP token reaches every project in the group.
439
441
  GITLAB_TOKEN=
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@edgehero/pi-dispatch",
3
- "version": "4.1.0",
3
+ "version": "4.3.0",
4
4
  "type": "module",
5
5
  "description": "The pi-dispatch worker and CLI: runs the pi coding agent as a self-hosted service, one locked down Docker or Podman container per job, with spend caps checked before anything is spent, plus init, up, doctor and service install.",
6
6
  "keywords": [
@@ -55,6 +55,7 @@
55
55
  "./git-dirty": "./src/git-dirty.mjs",
56
56
  "./queue": "./src/queue.mjs",
57
57
  "./capabilities": "./src/capabilities.mjs",
58
+ "./parked": "./src/parked.mjs",
58
59
  "./connection": "./src/connection.mjs",
59
60
  "./valkey-auth": "./src/valkey-auth.mjs",
60
61
  "./job-id": "./src/job-id.mjs",
@@ -74,6 +75,7 @@
74
75
  "./backend-conformance": "./src/backend-conformance.mjs",
75
76
  "./live-probes": "./src/live-probes.mjs",
76
77
  "./triggers": "./src/triggers.mjs",
78
+ "./mcp-config": "./src/mcp-config.mjs",
77
79
  "./triggers-file": "./src/triggers-file.mjs",
78
80
  "./packages": "./src/packages.mjs",
79
81
  "./pause-windows": "./src/pause-windows.mjs",
@@ -113,7 +115,7 @@
113
115
  "start": "node src/cli.mjs worker"
114
116
  },
115
117
  "dependencies": {
116
- "@earendil-works/pi-ai": "1.1.0",
118
+ "@earendil-works/pi-ai": "1.2.0",
117
119
  "@octokit/auth-app": "8.2.0",
118
120
  "@octokit/rest": "22.0.1",
119
121
  "bullmq": "5.80.4",
@@ -22,6 +22,7 @@
22
22
  */
23
23
 
24
24
  import { configError } from "./config.mjs";
25
+ import { PARKED_LABEL } from "./parked.mjs";
25
26
  import { InfraRetry } from "./processor.mjs";
26
27
  import { fetchFailureReason } from "./gitlab-identity.mjs";
27
28
  import { isDeterminateFetchFailure } from "./transient.mjs";
@@ -201,7 +202,33 @@ export function makeAzureHost({ orgUrl, fetchFn = fetch } = {}) {
201
202
  return { headRef: stripRefsHeads(pr?.sourceRefName), headRepo: sameRepo ? job?.repo : null };
202
203
  }
203
204
 
204
- return { resolveDefaultBranchSha, isDefaultBranchProtected, postStatusComment, resolvePullRequestHead };
205
+ /**
206
+ * Add or remove the parked marker on a work item, as a TAG (issue #625, parked.mjs): Azure work items have
207
+ * tags, not labels, and the receiver reads `System.Tags` into the same `[{ name }]` shape. A tags field is written
208
+ * whole, so this reads it, edits the one tag and writes the result; Azure creates a tag the project lacks. The caller treats any throw as fail-open:
209
+ * the session store's sidecar, not this label, decides whether a reply resumes anything.
210
+ */
211
+ async function setParkedLabel(ref, target, on, token) {
212
+ const { project } = scopeOf(ref);
213
+ const path = `/${encodeURIComponent(project)}/_apis/wit/workitems/${encodeURIComponent(target?.number)}`;
214
+ const item = await get(`${path}?fields=System.Tags&api-version=${API_VERSION}`, token);
215
+ const tags = String(item?.fields?.["System.Tags"] ?? "").split(";").map((tag) => tag.trim()).filter((tag) => tag !== "" && tag !== PARKED_LABEL);
216
+ const value = (on ? [...tags, PARKED_LABEL] : tags).join("; ");
217
+ let res;
218
+ try {
219
+ res = await fetchFn(`${root}${path}?api-version=${API_VERSION}`, {
220
+ method: "PATCH",
221
+ headers: { Authorization: authHeader(token), "content-type": "application/json-patch+json" },
222
+ body: JSON.stringify([{ op: "add", path: "/fields/System.Tags", value }]),
223
+ redirect: "error",
224
+ });
225
+ } catch (err) {
226
+ throw new InfraRetry(`azure-host: PATCH ${path} failed (${fetchFailureReason(err)})`);
227
+ }
228
+ if (!res.ok) throw new InfraRetry(`azure-host: PATCH ${path} returned ${res.status}`);
229
+ }
230
+
231
+ return { resolveDefaultBranchSha, isDefaultBranchProtected, postStatusComment, resolvePullRequestHead, setParkedLabel };
205
232
  }
206
233
 
207
234
  /**
@@ -68,6 +68,31 @@ function prReplicaLines(replica, replicas) {
68
68
  ];
69
69
  }
70
70
 
71
+ /**
72
+ * What a resumed run is told to do with the activity (issue #624). On a pull request the branch and the pull
73
+ * request exist, so the words are the ones this envelope always had. On a work item neither is certain: the
74
+ * previous run may have opened nothing (it concluded there was nothing to change, or it stopped to ask), and
75
+ * "push to the same branch ... do not open a second" would leave the work on a branch nobody reviews while the
76
+ * human who asked waits on the work item for a reply. So the issue form names the issue branch, checks for its pull
77
+ * request first, and replies where the activity came from. No replica argument: run.resume refuses replicas.
78
+ */
79
+ function resumedActionLines(target, n) {
80
+ if (target?.type === "pull_request") {
81
+ return [
82
+ "Address the activity quoted below. If it asks for changes, make them, push to the same branch with",
83
+ "`git push --force-with-lease`, and reply on the pull request saying what you did or why you could",
84
+ "not. Do not open a second pull request -- your push updates the existing one.",
85
+ ];
86
+ }
87
+ const branch = issueBranch(n);
88
+ return [
89
+ "Address the activity quoted below, then reply on the work item saying what you did or why you could not.",
90
+ `If it asks for changes, commit them to \`${branch}\` and publish with \`git push --force-with-lease\` to that`,
91
+ `branch only. Check first for an active pull request from it (\`az repos pr list --source-branch ${branch} --status active\`):`,
92
+ `if one exists your push updates it, so do not create another; only if none exists, run \`az repos pr create --source-branch ${branch}\`.`,
93
+ ];
94
+ }
95
+
71
96
  function buildResumedPrompt(flow, target, comment, instructions) {
72
97
  const n = normalizeNumber(target?.number);
73
98
  const noun = target?.type === "pull_request" ? "pull request" : "work item";
@@ -81,9 +106,7 @@ function buildResumedPrompt(flow, target, comment, instructions) {
81
106
  `\`az repos pr show --id ${n}\` (or \`az boards work-item show --id ${n}\`) before doing anything,`,
82
107
  `then follow the "${flow}" skill from the top.`,
83
108
  "",
84
- "Address the activity quoted below. If it asks for changes, make them, push to the same branch with",
85
- "`git push --force-with-lease`, and reply on the pull request saying what you did or why you could",
86
- "not. Do not open a second pull request -- your push updates the existing one.",
109
+ ...resumedActionLines(target, n),
87
110
  "",
88
111
  // Above the never-merge paragraph, so the harness has the last word before the data region.
89
112
  ...(instructionBlock(instructions) ? [instructionBlock(instructions), ""] : []),
package/src/config.mjs CHANGED
@@ -31,7 +31,7 @@ import { DOLLAR_ENV_NAMES, DOLLAR_WINDOW_KEYS, checkDollarInvariant, optionalUsd
31
31
  */
32
32
  export const DEFAULT_VALKEY_URL = "redis://127.0.0.1:6379";
33
33
  /** The model a job runs on when nothing names one (`PI_MODEL` unset): one copy, for `loadConfig` and doctor. */
34
- export const DEFAULT_MODEL = "claude-sonnet-4-5-20250929";
34
+ export const DEFAULT_MODEL = "claude-sonnet-5-5";
35
35
  /** The provider a job runs on when nothing names one (`PI_PROVIDER` unset). */
36
36
  export const DEFAULT_PROVIDER = "anthropic";
37
37
 
@@ -394,7 +394,7 @@ export function globalExtensionsEnabled(env) {
394
394
  * spend caps above, the per-job token scoping, or the admin-extension recursion block.
395
395
  */
396
396
  export function loadConfig(env = process.env, { fileExists = existsSync } = {}) {
397
- const model = env.PI_MODEL ?? DEFAULT_MODEL; // dated snapshot; deterministic per CONST-PI-VERSION-PINNED
397
+ const model = env.PI_MODEL ?? DEFAULT_MODEL; // an exact model version, not a moving alias; deterministic per CONST-PI-VERSION-PINNED
398
398
  // #227. Hoisted above the object because `defaultBackend` INDEXES `backends`, and a property cannot read
399
399
  // a sibling of the literal it is in. (Calling the parser twice would be harmless -- `egressEnabled` is
400
400
  // called twice a few properties down for the same reason -- so this is about the index, not the throw.)
package/src/doctor.mjs CHANGED
@@ -66,12 +66,14 @@ import { isTransientOverlayRead, overlayProviderProblem } from "./model-catalog.
66
66
  import { EMPTY_USD_FINGERPRINT, usdFingerprint } from "./dollar-fingerprint.mjs";
67
67
  import { splitModelEntry } from "./model-ref.mjs";
68
68
  import { ignoredOutputCapModels, outputCapView, outputUnboundable } from "./output-cap.mjs";
69
- import { KEYLESS_API_KEY, KEYLESS_HOW, MODEL_ENDPOINTS_FILE_NAME, MODEL_ENDPOINTS_INCLUDE_NAME, MODEL_ENDPOINT_ID_RE, OVERLAY_LINK_FIX, OVERLAY_NOT_A_FILE_FIX, RENAMED_PROVIDERS, baseUrlTarget, keylessVerdict, loadModelEndpoints, providerRenameHint, readOverlayModels, renderEndpointsInclude, unreportedUsageModels } from "./model-endpoints.mjs";
69
+ import { KEYLESS_API_KEY, KEYLESS_HOW, MODEL_ENDPOINTS_FILE_NAME, MODEL_ENDPOINTS_INCLUDE_NAME, MODEL_ENDPOINT_ID_RE, OVERLAY_LINK_FIX, OVERLAY_NOT_A_FILE_FIX, RENAMED_PROVIDERS, baseUrlTarget, endpointsAt, keylessVerdict, loadModelEndpoints, providerRenameHint, readOverlayModels, renderEndpointsInclude, unreportedUsageModels } from "./model-endpoints.mjs";
70
70
  import { declaredEndpointsIn, endpointsDeclaredIn, reloadCommand, rulesFileIncludes, rulesPredateEndpointsLine } from "./egress-cli.mjs";
71
71
  import { loadPauseWindows, parseScopeString } from "./pause-windows.mjs";
72
72
  import { WAIT_AFTER_MAX_DEFAULT_MS, afterInstantMs, parseWaitProfiles } from "./wait-for.mjs";
73
73
  import { isForgeKind } from "./forges.mjs";
74
74
  import { findLiteralSecret, ADMIN_RE } from "./import-pi.mjs";
75
+ import { mcpServerWhere, readOverlayMcp } from "./mcp-config.mjs";
76
+ import { allowlistEntryFor, egressRefusalFor, packageRunnerHosts, parseAllowlist } from "./egress-allowlist.mjs";
75
77
  import { agentDirFrom, readHostPi } from "./host-pi.mjs";
76
78
  import { PACKAGES_SUBDIR, readStagedSkills, readStageManifest } from "./packages.mjs";
77
79
  import { copySkillTree } from "./copy-tree.mjs";
@@ -2728,6 +2730,23 @@ export async function collectChecks(shellVars, seams) {
2728
2730
  },
2729
2731
  },
2730
2732
  });
2733
+ // The overlay's MCP servers (REQ-MCP-OPERATOR-SERVERS), through the one parser (mcp-config.mjs).
2734
+ let mcpAllowlist = [];
2735
+ try {
2736
+ mcpAllowlist = parseAllowlist(keylessIo.readFileSync(join(seams.cwd, "egress-allowlist.conf"), "utf8"));
2737
+ } catch {
2738
+ // No allowlist in this folder: every server outside a declared endpoint reads as unreachable, which it is.
2739
+ }
2740
+ checks.push(
2741
+ ...overlayMcpChecks({
2742
+ overlay,
2743
+ env,
2744
+ fileExists,
2745
+ read: () => readOverlayMcp(overlay, { readFileSync: seams.readOverlayFile ?? ((p, enc) => readFileSync(p, enc)), ...(seams.lstatOverlayFile ? { lstatSync: seams.lstatOverlayFile } : {}) }),
2746
+ allowlist: mcpAllowlist,
2747
+ endpoints: () => (seams.declaredEndpoints ?? ((a) => declaredEndpointsIn({ ...a, fs: keylessIo })))({ env, cwd: seams.cwd, platform: seams.platform ?? process.platform, valkeyUrl: env.VALKEY_URL ?? DEFAULT_VALKEY_URL }),
2748
+ }),
2749
+ );
2731
2750
  const modelsPath = join(overlay, "models.json");
2732
2751
  // Through the one reader (`readOverlayModels`, PR #520): an exists test answered false for a file under an
2733
2752
  // unreadable directory, so this line passed in silence on a file nobody had read. Absent is a pass (nothing to
@@ -5904,6 +5923,79 @@ export function forgeUrlEgressChecks(env, forges = []) {
5904
5923
  return checks;
5905
5924
  }
5906
5925
 
5926
+ /**
5927
+ * The overlay's MCP servers (REQ-MCP-OPERATOR-SERVERS): whether `mcp.json` reads, each server a job may be offered,
5928
+ * each one refused and why, no `mcp-auth.json` beside it, and, with the egress policy on, whether each streamable HTTP
5929
+ * server is one the proxy lets a job reach. A refused server is one no job can use, so each is ✗; an unreachable one
5930
+ * is ⚠, since the policy may be about to change.
5931
+ * `read` throws the reader's errors; `endpoints` is called only when a server needs it.
5932
+ */
5933
+ export function overlayMcpChecks({ overlay, env, fileExists, read, allowlist = [], endpoints = () => [] }) {
5934
+ const checks = [];
5935
+ const authPath = join(overlay, "mcp-auth.json");
5936
+ if (fileExists(authPath)) {
5937
+ checks.push({
5938
+ ok: false,
5939
+ label: "Overlay holds mcp-auth.json, pi's MCP sign-ins, which no job may hold",
5940
+ fix: "delete mcp-auth.json from the overlay; a job's MCP server takes its credential as a ${NAME} reference from run.secrets: docs/mcp.md",
5941
+ fixAction: {
5942
+ tier: "prompt",
5943
+ describe: `rm ${authPath}`,
5944
+ run: async ({ rm }) => {
5945
+ rm(authPath);
5946
+ return { ok: true };
5947
+ },
5948
+ },
5949
+ });
5950
+ }
5951
+ let parsed;
5952
+ try {
5953
+ parsed = read();
5954
+ } catch (error) {
5955
+ const why = error?.mcpConfig ? error.message : `cannot be read (${error?.code ?? "unreadable"})`;
5956
+ checks.push({ ok: false, label: `Overlay mcp.json: ${why}`, fix: "re-run `pi-dispatch import-pi`, or fix the file: docs/mcp.md. Until it reads, no job can use an MCP server" });
5957
+ return checks;
5958
+ }
5959
+ if (parsed === null) return checks;
5960
+ const names = Object.keys(parsed.servers);
5961
+ checks.push({ ok: true, label: names.length === 0 ? "Overlay mcp.json offers no MCP server" : `Overlay mcp.json offers ${names.length} MCP server${names.length === 1 ? "" : "s"} to triggers that name them (${names.join(", ")})` });
5962
+ for (const { name, problem } of parsed.refused) {
5963
+ checks.push({ ok: false, label: `Overlay mcp.json: server ${quotedShown(name)} is refused: ${problem}`, fix: "fix the entry and re-run `pi-dispatch import-pi`: docs/mcp.md. Until then no job can use it" });
5964
+ }
5965
+ let armed = false;
5966
+ try {
5967
+ armed = egressArmed(env) === true;
5968
+ } catch {
5969
+ // A malformed PI_EGRESS: the .env check reports it.
5970
+ }
5971
+ let declared = null;
5972
+ for (const name of names) {
5973
+ const server = parsed.servers[name];
5974
+ if (server.packageRunner) {
5975
+ const missing = packageRunnerHosts(server.command).filter((host) => allowlistEntryFor(allowlist, host) === null);
5976
+ const listed = missing.length === 0;
5977
+ const registry = missing.join(" and ");
5978
+ checks.push({
5979
+ ok: false,
5980
+ warn: true,
5981
+ label: `MCP server ${name} runs ${mcpServerWhere(server)}, which fetches the server from a package registry at the start of every job that uses it`,
5982
+ fix: armed && !listed ? `install the server in the job image instead, or allowlist ${registry} in egress-allowlist.conf: docs/mcp.md` : "install the server in the job image instead, so every job runs the version you chose: docs/mcp.md",
5983
+ });
5984
+ }
5985
+ if (!armed || server.transport !== "http" || server.enabled === false) continue;
5986
+ declared ??= endpoints();
5987
+ const refusal = egressRefusalFor(server.url, { allowlist, endpoints: declared });
5988
+ if (refusal === null) continue;
5989
+ checks.push({
5990
+ ok: false,
5991
+ warn: true,
5992
+ label: `MCP server ${name} (${mcpServerWhere(server)}) is not reachable with the egress policy on: ${refusal}`,
5993
+ fix: "use an https:// url and allowlist its host for 443, or declare its host and port in model-endpoints.json: docs/egress.md. Until then no job can reach it",
5994
+ });
5995
+ }
5996
+ return checks;
5997
+ }
5998
+
5907
5999
  /** The canary's three probes. ONE list: the loop names its containers from it and the sweep matches on it. */
5908
6000
  export const CANARY_PROBE_SLUGS = Object.freeze(["provider", "unlisted", "plainhttp"]);
5909
6001
 
@@ -6153,6 +6245,7 @@ async function egressChecks(env, seams, { dockerCode, imageCode, jobImage, endpo
6153
6245
  const { declaredEndpoints = ({ env: e, cwd, platform }) => declaredEndpointsIn({ env: e, cwd, fs: { readFileSync, existsSync }, platform }), hostAddresses = () => lanIPv4Addresses(networkInterfaces()) } = seams;
6154
6246
  const declared = declaredEndpoints({ env, cwd: seams.cwd, platform: seams.platform ?? process.platform });
6155
6247
  const endpoints = declared.length > 0 && !(!custom && inFolder && !needs.rulesInclude) ? declared : [];
6248
+ const mcpOnly = endpoints.length > 0 ? mcpOnlyEndpoints({ endpoints, ...overlayEndpointUsers(env, seams) }) : new Map();
6156
6249
  if (endpoints.length > 0) {
6157
6250
  const runtime = routeRuntimeFromFacts(seams.readFactsOnce ? await seams.readFactsOnce() : null, seams.platform ?? process.platform);
6158
6251
  const addresses = hostAddresses();
@@ -6259,7 +6352,7 @@ async function egressChecks(env, seams, { dockerCode, imageCode, jobImage, endpo
6259
6352
  // script. docker's probes keep `runCmdCapture` (its 30 s bound, stderr merged, SIGTERM at the bound) rather than the
6260
6353
  // bounded step runner, so docker's spawns, and what a probe that never launched leaves for the teardown, are exactly
6261
6354
  // what they were: pinned in doctor.test.mjs against the output and argv captured before the move.
6262
- const canary = await runEgressCanary({ run: liveRunVia(spawn), probeRun: (args) => runCmdCapture(spawn, "docker", args), proxy, image: jobImage, pid, gate, endpoints });
6355
+ const canary = await runEgressCanary({ run: liveRunVia(spawn), probeRun: (args) => runCmdCapture(spawn, "docker", args), proxy, image: jobImage, pid, gate, endpoints, mcpOnly });
6263
6356
  // Through a stale proxy the canary reads THAT proxy's policy (exec round 3): a failure is the proxy's to fix, never
6264
6357
  // this deployment's allowlist, which it may not even mount.
6265
6358
  checks.push(...(staleRunning ? canary.checks.map((c) => (c.ok ? c : { ...c, fix: `the canary ran through ${proxy}, which is not this deployment's proxy (above), so this says nothing about egress-allowlist.conf: ${replaceFix}, then re-run doctor` })) : canary.checks));
@@ -6353,8 +6446,10 @@ const CANARY_NO_WORKSPACE = "/nonexistent/pi-dispatch-egress-canary-mounts-nothi
6353
6446
  *
6354
6447
  * `endpoints` (issue #503) are the declared model endpoints to prove on the same network after the three
6355
6448
  * (`runEndpointProbes`), `[]` by default, so the conformance script and a deployment with none run exactly the three.
6449
+ * `mcpOnly` maps the ids of those only an overlay MCP server uses (`mcpOnlyEndpoints`), whose `models` probe proves
6450
+ * the route and is not judged as a model server's answer.
6356
6451
  */
6357
- export async function runEgressCanary({ run, bin = "docker", proxy, image, pid = process.pid, user = null, probeRun = null, endpoints = [], size = DEFAULT_JOB_SIZE, hostCpus = null, gate = makeDetachGate((args, opts) => run(args, { timeoutMs: opts?.timeoutMs ?? CANARY_STEP_TIMEOUT_MS }), { bin }) }) {
6452
+ export async function runEgressCanary({ run, bin = "docker", proxy, image, pid = process.pid, user = null, probeRun = null, endpoints = [], mcpOnly = new Map(), size = DEFAULT_JOB_SIZE, hostCpus = null, gate = makeDetachGate((args, opts) => run(args, { timeoutMs: opts?.timeoutMs ?? CANARY_STEP_TIMEOUT_MS }), { bin }) }) {
6358
6453
  const venue = canaryVenueFor(bin);
6359
6454
  const probe = probeRun ?? ((args) => run(args, { timeoutMs: bin === "podman" ? PODMAN_FIRST_START_TIMEOUT_MS : RUN_TIMEOUTS.cmd }));
6360
6455
  const checks = [];
@@ -6509,7 +6604,7 @@ export async function runEgressCanary({ run, bin = "docker", proxy, image, pid =
6509
6604
  if (staleRunner) {
6510
6605
  checks.push({ ok: false, warn: true, label: `${venue.prefix}Model endpoints: not probed, because the job image has no runner module (above), and their probes take the runner's route`, fix: "use a job image built after issue #427, then re-run doctor" });
6511
6606
  } else {
6512
- checks.push(...(await runEndpointProbes({ probe, bin, pid, network: net, proxy, image, user, endpoints, venue, unfinished, size, hostCpus })));
6607
+ checks.push(...(await runEndpointProbes({ probe, bin, pid, network: net, proxy, image, user, endpoints, mcpOnly, venue, unfinished, size, hostCpus })));
6513
6608
  }
6514
6609
  }
6515
6610
  } finally {
@@ -6613,6 +6708,66 @@ function endpointsById(endpoints) {
6613
6708
  return [...endpoints].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
6614
6709
  }
6615
6710
 
6711
+ /**
6712
+ * The declared endpoints that an overlay MCP server dials (an enabled streamable HTTP server's `url` in `mcp.json`, the
6713
+ * servers doctor's own MCP check and the pickup judge) and no overlay model does (a provider's or a model's `baseUrl` in `models.json`), each matched by `endpointsAt`, as the
6714
+ * pickup's egress check matches an MCP server to an endpoint. docs/egress.md has an operator declare an MCP server off
6715
+ * port 443 there, and such a server does not serve `/v1/models`: it answers 404 or 405, and the `models` probe used to
6716
+ * warn on every run that the port was not the model server's. Its tunnel is still opened and its route proved; only its
6717
+ * answer is not judged as a model server's. An endpoint both use is probed as a model server, since a model needs that.
6718
+ * A models.json that could not be read is not evidence that no model uses an endpoint, so nothing is MCP-only then.
6719
+ * Returns a Map of endpoint id to `{ tls }`, `tls` when a server's url on it is `https:`: the probe's plain request then
6720
+ * meets a TLS server, which closes the socket rather than answer (`endpointProbeCheck`).
6721
+ */
6722
+ export function mcpOnlyEndpoints({ endpoints, mcp = null, models = null, modelsUnread = false }) {
6723
+ if (modelsUnread || !Array.isArray(endpoints) || endpoints.length === 0) return new Map();
6724
+ const idsAt = (urls) => new Set(urls.flatMap((url) => endpointsAt(url, endpoints).map((e) => e.id)));
6725
+ const mcpUrls = Object.values(mcp?.servers ?? {})
6726
+ .filter((server) => server.transport === "http" && server.enabled !== false)
6727
+ .map((server) => server.url);
6728
+ const modelUrls = [];
6729
+ const providers = models?.providers;
6730
+ for (const entry of providers !== null && typeof providers === "object" ? Object.values(providers) : []) {
6731
+ if (entry === null || typeof entry !== "object") continue;
6732
+ modelUrls.push(entry.baseUrl);
6733
+ for (const model of Array.isArray(entry.models) ? entry.models : []) modelUrls.push(model?.baseUrl);
6734
+ }
6735
+ const byModels = idsAt(modelUrls);
6736
+ const only = new Map();
6737
+ for (const url of mcpUrls) {
6738
+ for (const { id } of endpointsAt(url, endpoints)) {
6739
+ if (byModels.has(id)) continue;
6740
+ only.set(id, { tls: only.get(id)?.tls === true || new URL(url).protocol === "https:" });
6741
+ }
6742
+ }
6743
+ return only;
6744
+ }
6745
+
6746
+ /**
6747
+ * The overlay's mcp.json and models.json for `mcpOnlyEndpoints`, through the readers the overlay section uses and its
6748
+ * seams. An mcp.json that cannot be read offers no server here (its own line says why); a models.json that cannot be read
6749
+ * is `modelsUnread`, which keeps every endpoint probed as a model server.
6750
+ */
6751
+ function overlayEndpointUsers(env, seams) {
6752
+ const overlay = env.PI_GLOBAL_PI_DIR;
6753
+ if (typeof overlay !== "string" || !isAbsolute(overlay)) return {};
6754
+ const io = { readFileSync: seams.readOverlayFile ?? ((p, enc) => readFileSync(p, enc)), ...(seams.lstatOverlayFile ? { lstatSync: seams.lstatOverlayFile } : {}) };
6755
+ let mcp = null;
6756
+ try {
6757
+ mcp = readOverlayMcp(overlay, io);
6758
+ } catch {
6759
+ // Named by the overlay section's own line.
6760
+ }
6761
+ try {
6762
+ return { mcp, models: readOverlayModels(overlay, io) };
6763
+ } catch {
6764
+ return { mcp, modelsUnread: true };
6765
+ }
6766
+ }
6767
+
6768
+ /** The `blocked` codes of a socket the far end closed: what an https server does with the probe's plain request. */
6769
+ const SOCKET_CLOSED_CODES = Object.freeze(["UND_ERR_SOCKET", "ECONNRESET"]);
6770
+
6616
6771
  /** The exits each probe's script ends a READING with; any other exit is a container that did not run it. */
6617
6772
  const ENDPOINT_PROBE_READ_EXITS = Object.freeze({ models: [0, 3, 6], nextport: [0, 3, 6], plain: [0, 3] });
6618
6773
 
@@ -6625,8 +6780,8 @@ const ENDPOINT_PROBE_READ_EXITS = Object.freeze({ models: [0, 3, 6], nextport: [
6625
6780
  * - `plain`: a 403 carrying squid's `X-Squid-Error: ERR_ACCESS_DENIED`. A bare 403 may be the server's own answer to a
6626
6781
  * request the proxy forwarded.
6627
6782
  */
6628
- function endpointProbeCheck({ slug, endpoint, answer, venue, bin, proxy, next }) {
6629
- const head = `${venue.prefix}Model endpoint ${endpoint.id}`;
6783
+ function endpointProbeCheck({ slug, endpoint, answer, venue, bin, proxy, next, mcpOnly = null }) {
6784
+ const head = mcpOnly ? `${venue.prefix}MCP server endpoint ${endpoint.id}` : `${venue.prefix}Model endpoint ${endpoint.id}`;
6630
6785
  const at = endpointAddress(endpoint.host, endpoint.port);
6631
6786
  const reading = endpointProbeReading(answer);
6632
6787
  const tag = { id: endpoint.id, probe: slug, kind: reading?.kind ?? null, status: reading?.status ?? null };
@@ -6641,6 +6796,19 @@ function endpointProbeCheck({ slug, endpoint, answer, venue, bin, proxy, next })
6641
6796
  }
6642
6797
  const reload = reloadCommand(bin, proxy);
6643
6798
  if (slug === "models") {
6799
+ // An endpoint only an overlay MCP server uses: any answer through the tunnel proves the route and the rule, and
6800
+ // what it says to /v1/models is not a model server's answer (`mcpOnlyEndpoints`). Not reaching it still fails below.
6801
+ if (mcpOnly && reading.kind === "reached") {
6802
+ return { ok: true, warn: true, label: `${head}: the route through the proxy works (the tunnel to ${at} opened and the server answered ${reading.status}); no overlay model uses it, so it is not probed as a model server`, endpointProbe: tag };
6803
+ }
6804
+ // An https MCP server meets the probe's plain request as TLS servers do: it closes the socket (measured: undici reads
6805
+ // UND_ERR_SOCKET, "other side closed"; a reset is the same fact). Only a CONNECT the proxy let through reaches the
6806
+ // server to be closed by it, since a refused tunnel reads as `tunnel`, so the route is proved. Not a timeout, and
6807
+ // not a code that says the proxy itself was not reached (ECONNREFUSED to it, a name it could not resolve).
6808
+ if (mcpOnly?.tls && reading.kind === "blocked" && SOCKET_CLOSED_CODES.includes(reading.detail)) {
6809
+ return { ok: true, warn: true, label: `${head}: the route through the proxy works (the tunnel to ${at} opened and the server closed a plain request, as a TLS server does: ${reading.detail}); no overlay model uses it, so it is not probed as a model server`, endpointProbe: tag };
6810
+ }
6811
+ const server = mcpOnly ? "MCP server" : "model server";
6644
6812
  if (reading.kind === "reached" && reading.status === 200) return { ok: true, warn: true, label: `${head} answers through the proxy (GET http://${at}/v1/models through a CONNECT tunnel: 200)`, endpointProbe: tag };
6645
6813
  // The SERVER refused the request for want of a key (vLLM --api-key, a gateway in front): the route and the rule are
6646
6814
  // proved, since only an open tunnel carries the server's own answer. Said apart from a fault, still warn-tier.
@@ -6673,10 +6841,12 @@ function endpointProbeCheck({ slug, endpoint, answer, venue, bin, proxy, next })
6673
6841
  : reading.kind === "reached"
6674
6842
  ? "check that this port is the model server's and that it serves the OpenAI-compatible API under /v1 (Ollama, llama-server, vLLM and LM Studio do)"
6675
6843
  : timedOut
6676
- ? "the server is there but stuck or busy (a model still loading, every slot taken): check its own log, then re-run doctor"
6844
+ ? mcpOnly
6845
+ ? "the server is there but stuck or busy: check its own log, then re-run doctor"
6846
+ : "the server is there but stuck or busy (a model still loading, every slot taken): check its own log, then re-run doctor"
6677
6847
  : bin === "podman"
6678
- ? `start the model server, bound to this host's LAN address or 0.0.0.0 (never 127.0.0.1 alone), and declare a server on this host as host.containers.internal (docs/egress.md, "Local model servers"). A job meets this as "Connection error"`
6679
- : `start the model server, and check it listens where the proxy reaches it (docs/egress.md, "Local model servers"; on Docker Engine that is 172.17.0.1 or 0.0.0.0, never 127.0.0.1 alone). A job meets this as "Connection error"`;
6848
+ ? `start the ${server}, bound to this host's LAN address or 0.0.0.0 (never 127.0.0.1 alone), and declare a server on this host as host.containers.internal (docs/egress.md, "Local model servers"). A job meets this as "Connection error"`
6849
+ : `start the ${server}, and check it listens where the proxy reaches it (docs/egress.md, "Local model servers"; on Docker Engine that is 172.17.0.1 or 0.0.0.0, never 127.0.0.1 alone). A job meets this as "Connection error"`;
6680
6850
  return { ok: false, warn: true, label: `${head} does NOT answer: ${why}`, fix, endpointProbe: tag };
6681
6851
  }
6682
6852
  const wider = `the proxy's rules are wider than model-endpoints.conf renders: compare the include inside it (\`${bin} exec ${proxy} cat ${ENDPOINTS_INCLUDE_IN_PROXY}\`) with \`pi-dispatch egress render\`'s, check egress-allowlist.conf does not list ${endpoint.host}, then \`${reload}\``;
@@ -6725,7 +6895,7 @@ export function undeclaredPortNear(endpoint, endpoints) {
6725
6895
  * variable as well on docker: the runner's dispatcher sends an `http://` origin to HTTP_PROXY, which docker's canary
6726
6896
  * argv does not otherwise carry (podman's is a job's environment and has it).
6727
6897
  */
6728
- async function runEndpointProbes({ probe, bin, pid, network, proxy, image, user, endpoints, venue, unfinished, size = DEFAULT_JOB_SIZE, hostCpus = null }) {
6898
+ async function runEndpointProbes({ probe, bin, pid, network, proxy, image, user, endpoints, mcpOnly = new Map(), venue, unfinished, size = DEFAULT_JOB_SIZE, hostCpus = null }) {
6729
6899
  const checks = [];
6730
6900
  for (const endpoint of endpointsById(endpoints)) {
6731
6901
  const next = undeclaredPortNear(endpoint, endpoints);
@@ -6740,7 +6910,7 @@ async function runEndpointProbes({ probe, bin, pid, network, proxy, image, user,
6740
6910
  const name = egressEndpointProbe(slug, endpoint.id, pid);
6741
6911
  const answer = await probe(egressCanaryProbeArgs({ bin, slug, name, pid, network, proxy, image, url, user, script, httpProxy: true, size, hostCpus }));
6742
6912
  if (answer?.code === null && answer.ended !== "error") unfinished.push(name);
6743
- checks.push(endpointProbeCheck({ slug, endpoint, answer, venue, bin, proxy, next }));
6913
+ checks.push(endpointProbeCheck({ slug, endpoint, answer, venue, bin, proxy, next, mcpOnly: mcpOnly.get(endpoint.id) ?? null }));
6744
6914
  }
6745
6915
  }
6746
6916
  return checks;
@@ -8706,6 +8876,7 @@ export async function podmanChecks(env, seams, { jobImage, jobImageNote = "" })
8706
8876
  // Issue #503: the declared model endpoints `--live` probes on this venue, `[]` for none; and whether the rules the
8707
8877
  // proxy runs include their file (only the shipped proxy's account copy is read; another proxy must include it).
8708
8878
  let endpoints = [];
8879
+ let mcpOnly = new Map();
8709
8880
  let endpointRulesInclude = true;
8710
8881
  if (armed !== false) {
8711
8882
  // `.State.Status` (issue #453): only `running` carries traffic; Podman reports paused and between-restarts states
@@ -8772,6 +8943,7 @@ export async function podmanChecks(env, seams, { jobImage, jobImageNote = "" })
8772
8943
  const declaredList = declaredEndpoints({ env, cwd: seams.cwd, platform: seams.platform ?? process.platform });
8773
8944
  if (declaredList.length > 0 && endpointRulesInclude) {
8774
8945
  endpoints = declaredList;
8946
+ mcpOnly = mcpOnlyEndpoints({ endpoints, ...overlayEndpointUsers(env, seams) });
8775
8947
  // The helper as Podman 5 names it in `podman info` (`rootlessNetworkCmd`), else as this account's running rootless
8776
8948
  // network shows it (`observeRootlessNetns`, the record or the 4.x argv the worker's widening check reads), else
8777
8949
  // not known, which the route row says as such.
@@ -8795,7 +8967,7 @@ export async function podmanChecks(env, seams, { jobImage, jobImageNote = "" })
8795
8967
  if (keeperNetwork) checks.push(keeperNetwork);
8796
8968
  }
8797
8969
 
8798
- forLive = { run: true, user, relabel, info, imagePresent, egress: { armed, proxy, proxyRunning, keeperBlocked, endpoints } };
8970
+ forLive = { run: true, user, relabel, info, imagePresent, egress: { armed, proxy, proxyRunning, keeperBlocked, endpoints, mcpOnly } };
8799
8971
  return { checks, observed, relabel, imagesReadable, imageDigest, forLive };
8800
8972
  }
8801
8973
 
@@ -9320,7 +9492,7 @@ async function podmanEgressCanary({ podman, readInfo, run, image, pid, isAlive,
9320
9492
  const endpoints = podman.egress.endpoints ?? [];
9321
9493
  const more = endpoints.length > 0 ? `; then three per declared model endpoint (${endpointsById(endpoints).map((e) => e.id).join(", ")}), named ${EGRESS_ENDPOINT_PROBE_PREFIX}<probe>-<id>-${pid}, removed the same way` : "";
9322
9494
  announce(`starting ${probes.slice(0, -1).join(", ")} and ${probes.at(-1)} from ${image} (as the job user ${podman.user}) on the --internal network ${egressCanaryNetwork(pid)}, with ${podman.egress.proxy} attached, to read the egress allowlist back; all three are removed when the canary ends${more}`);
9323
- const canary = await runEgressCanary({ run, bin: "podman", proxy: podman.egress.proxy, image, pid, user: podman.user, gate, endpoints, size, hostCpus: again.info?.hostCpus ?? null });
9495
+ const canary = await runEgressCanary({ run, bin: "podman", proxy: podman.egress.proxy, image, pid, user: podman.user, gate, endpoints, mcpOnly: podman.egress.mcpOnly ?? new Map(), size, hostCpus: again.info?.hostCpus ?? null });
9324
9496
  return { checks: [...checks, ...canary.checks], results: canary.results };
9325
9497
  }
9326
9498
 
@@ -0,0 +1,81 @@
1
+ /**
2
+ * The egress allowlist as the proxy reads it, and whether a URL a job dials gets through (docs/egress.md). Squid
3
+ * matches `egress-allowlist.conf` as a `dstdomain` file (deploy/egress-proxy.conf, `acl allowed dstdomain -n`): each
4
+ * line, trimmed and with a `#` comment removed, is ONE entry, and an entry with a leading dot admits that domain and
5
+ * every name under it. A line with two words is one entry that matches nothing (measured on the pinned squid: both
6
+ * names refused), so it admits nothing here either. A job's runner sends every request as a CONNECT
7
+ * (image/runner/src/env-proxy.mjs, `proxyTunnel: true`), and the proxy allows a CONNECT to an allowlisted name on port
8
+ * 443 only, never to a host that resolves to this machine (`to_host_local`), and to a declared model endpoint on
9
+ * exactly its host and port (`model-endpoints.conf`).
10
+ *
11
+ * The proxy stays the enforcement. This is the same reading done before anything is spent, so a job whose server the
12
+ * policy cannot serve is refused with a reason instead of failing at its first call. It is a necessary condition, not
13
+ * a sufficient one: a NAME that resolves to this machine is refused by the proxy and not here, since this reads no DNS.
14
+ */
15
+ import { isIP } from "node:net";
16
+ import { basename } from "node:path";
17
+ import { isProxyLocalHost } from "./backends.mjs";
18
+ import { baseUrlTarget, endpointsAt } from "./model-endpoints.mjs";
19
+
20
+ /** The entries of an allowlist's text, lowercased, in order: one per line, a line with inner whitespace skipped. */
21
+ export function parseAllowlist(text) {
22
+ const entries = [];
23
+ for (const raw of String(text ?? "").split(/\r?\n/)) {
24
+ // A comment starts at a `#` that opens the line or follows whitespace; one inside a word is part of the word,
25
+ // which then matches no host.
26
+ const line = raw.replace(/(^|\s)#.*$/, "").trim();
27
+ if (line === "" || /\s/.test(line)) continue;
28
+ entries.push(line.toLowerCase());
29
+ }
30
+ return entries;
31
+ }
32
+
33
+ /** The first entry that admits `host`, or null: the name itself, or a leading-dot entry for it or a name above it. */
34
+ export function allowlistEntryFor(entries, host) {
35
+ const name = String(host).toLowerCase();
36
+ for (const entry of entries) {
37
+ if (entry.startsWith(".") ? name === entry.slice(1) || name.endsWith(entry) : name === entry) return entry;
38
+ }
39
+ return null;
40
+ }
41
+
42
+ /** Whether `host` (as `baseUrlTarget` gives it) is an address squid's `to_host_local` refuses, or `localhost`. */
43
+ function hostIsLocal(host) {
44
+ const bare = host.startsWith("[") ? host.slice(1, -1) : host;
45
+ const kind = isIP(bare) === 4 ? "ipv4" : isIP(bare) === 6 ? "ipv6" : "name";
46
+ if (isProxyLocalHost(kind, bare)) return true;
47
+ if (kind === "ipv4") return bare.startsWith("169.254.");
48
+ if (kind === "ipv6") return /^fe[89ab][0-9a-f]:/i.test(bare);
49
+ return false;
50
+ }
51
+
52
+ /**
53
+ * Why a job's CONNECT to `url` would be refused by the proxy, or null when it gets through: a declared endpoint's
54
+ * exact host and port, or an allowlisted name on port 443, over https, that is not this machine. Both schemes go as
55
+ * a CONNECT, so the proxy itself does not tell them apart.
56
+ */
57
+ export function egressRefusalFor(url, { allowlist = [], endpoints = [] } = {}) {
58
+ const target = baseUrlTarget(url);
59
+ if (target === null) return "it is not an http or https URL";
60
+ if (endpointsAt(url, endpoints).length > 0) return null;
61
+ const entry = allowlistEntryFor(allowlist, target.host);
62
+ if (entry !== null && hostIsLocal(target.host)) return `${target.host} is the proxy's own machine, which it never opens; a server on this machine is declared in model-endpoints.json under host.docker.internal or host.containers.internal`;
63
+ // The proxy tunnels to 443 whatever speaks inside, so an `http://` url on 443 is plain HTTP to what is almost always
64
+ // a TLS port, carrying the server's headers in clear inside the tunnel: refused, with the fix.
65
+ if (entry !== null && target.port === 443 && new URL(url).protocol !== "https:") return `the egress proxy opens ${target.host} on port 443 for TLS, and this url is http://; use https://`;
66
+ if (entry !== null && target.port === 443) return null;
67
+ if (entry !== null) return `the egress proxy opens ${target.host} on port 443 only, and this is port ${target.port}; declare ${target.host}:${target.port} in model-endpoints.json to open exactly that port`;
68
+ return `${target.host} is not in egress-allowlist.conf${target.port === 443 ? "" : ", and port " + target.port + " opens only as a declared endpoint in model-endpoints.json"}`;
69
+ }
70
+
71
+ /**
72
+ * Every host a package runner fetches from at start, by name, since the proxy matches names: uv and pipx take the
73
+ * index from pypi.org and the files from files.pythonhosted.org, yarn's default registry is its own name, and the npm
74
+ * family reads registry.npmjs.org.
75
+ */
76
+ export function packageRunnerHosts(command) {
77
+ const runner = basename(String(command));
78
+ if (["uv", "uvx", "pipx"].includes(runner)) return ["pypi.org", "files.pythonhosted.org"];
79
+ if (runner === "yarn") return ["registry.yarnpkg.com"];
80
+ return ["registry.npmjs.org"];
81
+ }
@@ -367,7 +367,7 @@ function resolveEnvName(provider) {
367
367
  * `allowGlobalExtensions` defaults to TRUE here, matching loadConfig's default (REQ-GLOBAL-PI-OVERLAY): a
368
368
  * caller that says nothing gets the operator's staged setup, and only an explicit `false` withholds it.
369
369
  */
370
- export function buildContainerEnv({ provider, model, maxTurns, maxTokens, maxCostMicros = null, jobId, githubToken, forgeKind, forgeHosts = {}, hostEnv, allowGlobalExtensions = true, packagePaths = [], forwardEnv = [], secrets = {}, sessionFile = null, flow = null, command = null, excludeTools = [], allowedModels = null, authFromPi = false, egress = false, egressProxy, agentDir, home = null, readFile = readFileSync, modelEndpoints = null, exitAuth = false }) {
370
+ export function buildContainerEnv({ provider, model, maxTurns, maxTokens, maxCostMicros = null, jobId, githubToken, forgeKind, forgeHosts = {}, hostEnv, allowGlobalExtensions = true, packagePaths = [], forwardEnv = [], secrets = {}, sessionFile = null, flow = null, command = null, excludeTools = [], mcpServers = [], awaitInput = false, allowedModels = null, authFromPi = false, egress = false, egressProxy, agentDir, home = null, readFile = readFileSync, modelEndpoints = null, exitAuth = false }) {
371
371
  // The provider credential(s), by pi's expected variable name(s) -- from the worker env, or (when
372
372
  // PI_AUTH_FROM_PI is set and the env has none) host-side from pi's auth.json. Throws (config) if
373
373
  // neither source yields one, which the processor turns into a policy refusal that refunds any reserve
@@ -429,6 +429,13 @@ export function buildContainerEnv({ provider, model, maxTurns, maxTokens, maxCos
429
429
  // second-validator rule directly above). Absent means the full pinned default set, never an empty
430
430
  // string, for PI_PACKAGES' reason.
431
431
  PI_EXCLUDE_TOOLS: excludeTools.length > 0 ? excludeTools.join(",") : undefined,
432
+ // The trigger's run.mcp (REQ-MCP-OPERATOR-SERVERS): the overlay servers the job's session connects, so the
433
+ // runner can hold `/job/mcp.json` to the names the worker meant. Comma-safe for excludeTools' reason (the loader
434
+ // admits only lowercase letters, digits and `-`). Absent means none, never an empty string.
435
+ PI_MCP_SERVERS: mcpServers.length > 0 ? mcpServers.join(",") : undefined,
436
+ // The trigger's run.awaitInput (issue #625): "1" offers the job the tool a parked run ends on. Absent means not
437
+ // armed, never "0" or an empty string, for PI_PACKAGES' reason.
438
+ PI_AWAIT_INPUT: awaitInput === true ? "1" : undefined,
432
439
  // The job's EFFECTIVE allowed-model list (issue #502): its trigger's `run.models`, else the deployment's
433
440
  // PI_ALLOWED_MODELS. Comma-joined `provider/model` entries, which the runner splits at each entry's first `/`
434
441
  // (image/runner/src/config.mjs). Comma is safe because both list parsers refuse an entry carrying one. NULL is