@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/.env.example +2 -1
- package/package.json +2 -1
- package/src/doctor.mjs +185 -13
- package/src/egress-allowlist.mjs +81 -0
- package/src/env-allowlist.mjs +5 -1
- package/src/image-preflight.mjs +25 -1
- package/src/import-pi.mjs +113 -8
- package/src/mcp-config.mjs +466 -0
- package/src/mcp-preflight.mjs +262 -0
- package/src/model-endpoints.mjs +8 -3
- package/src/outbox.mjs +7 -1
- package/src/prepare.mjs +12 -4
- package/src/processor.mjs +36 -2
- package/src/queue.mjs +4 -2
- package/src/reserved-env.mjs +1 -0
- package/src/run-container.mjs +2 -0
- package/src/run-history.mjs +8 -1
- package/src/schedules.mjs +1 -1
- package/src/start.mjs +40 -1
- package/src/triggers-file.mjs +3 -0
- package/src/triggers.mjs +123 -7
package/.env.example
CHANGED
|
@@ -433,7 +433,8 @@ GITHUB_APP_PRIVATE_KEY=
|
|
|
433
433
|
# Optional. Set these only to service GitLab projects; leaving GITLAB_TOKEN unset means no /gitlab
|
|
434
434
|
# endpoint exists at all, rather than one that answers 401. See docs/gitlab.md.
|
|
435
435
|
#
|
|
436
|
-
# A PROJECT access token with the `api` scope and the Developer role
|
|
436
|
+
# A PROJECT access token with the `api` scope and the Developer role, not Maintainer (docs/gitlab.md:
|
|
437
|
+
# a Maintainer token lets the job unprotect a branch or change who may merge). `api` is the narrowest
|
|
437
438
|
# scope that can post a note -- GitLab offers no contents-vs-issues split -- so scope it to one project
|
|
438
439
|
# and rotate it (CONST-TOKEN-SCOPED-PER-JOB). A GROUP token reaches every project in the group.
|
|
439
440
|
GITLAB_TOKEN=
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@edgehero/pi-dispatch",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.2.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": [
|
|
@@ -74,6 +74,7 @@
|
|
|
74
74
|
"./backend-conformance": "./src/backend-conformance.mjs",
|
|
75
75
|
"./live-probes": "./src/live-probes.mjs",
|
|
76
76
|
"./triggers": "./src/triggers.mjs",
|
|
77
|
+
"./mcp-config": "./src/mcp-config.mjs",
|
|
77
78
|
"./triggers-file": "./src/triggers-file.mjs",
|
|
78
79
|
"./packages": "./src/packages.mjs",
|
|
79
80
|
"./pause-windows": "./src/pause-windows.mjs",
|
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
|
-
?
|
|
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
|
|
6679
|
-
: `start the
|
|
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
|
+
}
|
package/src/env-allowlist.mjs
CHANGED
|
@@ -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 = [], 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,10 @@ 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,
|
|
432
436
|
// The job's EFFECTIVE allowed-model list (issue #502): its trigger's `run.models`, else the deployment's
|
|
433
437
|
// PI_ALLOWED_MODELS. Comma-joined `provider/model` entries, which the runner splits at each entry's first `/`
|
|
434
438
|
// (image/runner/src/config.mjs). Comma is safe because both list parsers refuse an entry carrying one. NULL is
|
package/src/image-preflight.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { imageRefProblem } from "./image-ref.mjs";
|
|
3
|
+
import { FORGE_MCP_SERVER_NAME } from "./mcp-config.mjs";
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Which image a job runs in, and whether it is on this host.
|
|
@@ -44,7 +45,9 @@ export function resolveJobImage(job, defaultImage) {
|
|
|
44
45
|
* { forgeUnsupported } -- present, but declares it cannot serve this job's forge => POLICY, refuse
|
|
45
46
|
* { <gate.result>, declared } -- present, but does not declare a capability this job needs, one key per
|
|
46
47
|
* CAPABILITY_GATES row (replicaUnsupported, commandUnsupported,
|
|
47
|
-
* excludeToolsUnsupported, modelPolicyUnsupported, costCapUnsupported
|
|
48
|
+
* excludeToolsUnsupported, modelPolicyUnsupported, costCapUnsupported, mcpUnsupported,
|
|
49
|
+
* forgeMcpUnsupported)
|
|
50
|
+
* => POLICY
|
|
48
51
|
*
|
|
49
52
|
* A non-zero `docker image inspect` is AMBIGUOUS -- an absent image and an unreachable daemon both exit 1 --
|
|
50
53
|
* so the failure path disambiguates POSITIVELY with `docker info` rather than by matching docker's stderr.
|
|
@@ -191,6 +194,27 @@ export const CAPABILITY_GATES = Object.freeze([
|
|
|
191
194
|
event: "refused_image_cost_cap_unsupported",
|
|
192
195
|
comment: (image, declared) => `Refused: the job image "${image}" does not declare cost-cap support (\`dev.pi-dispatch.capabilities\` ${declared}), so its runner would ignore the per-job dollar cap and run this job with no cap at all. Rebuild the image from a version that has this feature. Not run.`,
|
|
193
196
|
}),
|
|
197
|
+
// REQ-MCP-OPERATOR-SERVERS: a runner that predates run.mcp reads no /job/mcp.json, so the job would run without
|
|
198
|
+
// the servers its flow was written against and record a clean exit while the file reads as though it named them.
|
|
199
|
+
Object.freeze({
|
|
200
|
+
token: "mcp",
|
|
201
|
+
needed: (job) => Array.isArray(job?.mcp) && job.mcp.length > 0,
|
|
202
|
+
result: "mcpUnsupported",
|
|
203
|
+
reason: "job-image-mcp-unsupported",
|
|
204
|
+
event: "refused_image_mcp_unsupported",
|
|
205
|
+
comment: (image, declared) => `Refused: the job image "${image}" does not declare MCP support (\`dev.pi-dispatch.capabilities\` ${declared}), so its runner would not connect the servers \`run.mcp\` names. Rebuild the image from a version that has this feature. Not run.`,
|
|
206
|
+
}),
|
|
207
|
+
// DES-FORGE-MCP: `forge` in run.mcp starts a server the IMAGE carries (github-mcp-server, mcp-gitlab), not one the
|
|
208
|
+
// overlay names. An image with `mcp` and without them would pass every gate, reserve the budget, and refuse at the
|
|
209
|
+
// container's start as mcp-command-missing; this row says so before anything is spent.
|
|
210
|
+
Object.freeze({
|
|
211
|
+
token: "forgeMcp",
|
|
212
|
+
needed: (job) => Array.isArray(job?.mcp) && job.mcp.includes(FORGE_MCP_SERVER_NAME),
|
|
213
|
+
result: "forgeMcpUnsupported",
|
|
214
|
+
reason: "job-image-forge-mcp-unsupported",
|
|
215
|
+
event: "refused_image_forge_mcp_unsupported",
|
|
216
|
+
comment: (image, declared) => `Refused: the job image "${image}" does not declare the forge's own MCP server (\`dev.pi-dispatch.capabilities\` ${declared}), so it does not carry the server \`run.mcp\` names as \`forge\`. Rebuild the image from a version that has this feature. Not run.`,
|
|
217
|
+
}),
|
|
194
218
|
]);
|
|
195
219
|
|
|
196
220
|
/**
|