@edgehero/pi-dispatch 4.0.1 → 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.
@@ -0,0 +1,262 @@
1
+ /**
2
+ * The pickup's MCP question (REQ-MCP-OPERATOR-SERVERS, DES-MCP-EGRESS-PREFLIGHT): can this job use the overlay MCP
3
+ * servers its trigger names (`run.mcp`)? Answered before anything is spent (CONST-BUDGET-BEFORE-TOKENS), from the
4
+ * overlay's `mcp.json` read once for the job, and the answer carries the per-job document `prepare` writes to
5
+ * `/job/mcp.json`, so the servers the job connects are exactly the servers this check passed.
6
+ *
7
+ * One of:
8
+ * { ok: true, document } `document` null when the job names no server
9
+ * { refused, name, why } a determinate refusal, returned (CONST-RETRY-INFRA-ONLY): `refused` is the reason
10
+ * { unavailable: code } the overlay could not be read just now, an errno that may pass: retried
11
+ *
12
+ * The refusals, in the order they are asked:
13
+ * mcp-list-invalid the job's own list is not one the loader writes (queue data from another version, or a
14
+ * hand-added job);
15
+ * mcp-config-invalid the overlay's mcp.json cannot be read whole (or there is no overlay, or no file, when the
16
+ * job names a server other than `forge`);
17
+ * mcp-server-unknown a name the file does not offer, offers disabled, or refuses (doctor names why); or
18
+ * `forge` on a job that is not a github or gitlab one;
19
+ * mcp-secret-reserved a reference to a variable this deployment writes or pi reads for the job itself (a
20
+ * credential the job spends with, `providerKeyNames`; a minted forge token; the egress or
21
+ * runner variables): a server must never be handed the job's own credentials. Asked of
22
+ * every reference before the next rule;
23
+ * mcp-secret-unmapped a reference the job will not have: not a `run.secrets` name, and not a PI_FORWARD_ENV
24
+ * name the worker's environment holds a value for;
25
+ * mcp-excluded the trigger's own run.excludeTools leaves a server no way in that it had: its tools and
26
+ * its resources reachable only through tools it removed (`mcpRouteProblem`);
27
+ * mcp-egress-unreachable with the egress policy on, a streamable HTTP server the proxy would not let the job reach,
28
+ * or the forge server's API host (api.github.com, or the configured GitLab's) unlisted;
29
+ * mcp-package-runner with the egress policy on, a package runner whose registry the allowlist does not hold.
30
+ *
31
+ * `forge` (DES-FORGE-MCP) is not read from the overlay: its entry is the worker's (`forgeMcpServer`), so a job naming
32
+ * only it needs no mcp.json at all, and it is exempt from the two secret rules, its one reference being the forge token
33
+ * the worker itself writes into the container for this job. The overlay's top-level switches still govern it when the
34
+ * file is there, so a file that is there and cannot be read refuses it like any other server.
35
+ */
36
+ import { allowlistEntryFor, egressRefusalFor, packageRunnerHosts } from "./egress-allowlist.mjs";
37
+ import { FORGE_MCP_KINDS, FORGE_MCP_SERVER_NAME, forgeMcpEgressUrl, forgeMcpServer, gitlabUrlProblem, jobMcpDocument, MCP_SERVER_NAME_RE, readOverlayMcp } from "./mcp-config.mjs";
38
+ import { MCP_SERVERS_MAX, mcpServerToolPattern } from "./triggers.mjs";
39
+
40
+ /**
41
+ * Errnos a read of the overlay may pass on a second try. Restated from model-catalog.mjs `OVERLAY_TRANSIENT_READ_CODES`
42
+ * rather than imported, since that module loads pi's catalog; mcp-preflight.test.mjs holds the two equal.
43
+ */
44
+ export const MCP_TRANSIENT_READ_CODES = new Set(["EIO", "EAGAIN", "EMFILE", "ENFILE"]);
45
+
46
+ /** A variable reference in a pi config value, `$NAME` or `${NAME}`, anywhere in the string (pi's own grammar). */
47
+ const CONFIG_REF_RE = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)/g;
48
+
49
+ /**
50
+ * The credentials a job is set up to spend with, as variable names: each builtin key variable of the providers it may
51
+ * call (`providers`: its main provider and the ones on its model list), and every variable the overlay models.json
52
+ * references anywhere (a custom provider's `apiKey` in any form, its headers, a model's or an override's headers),
53
+ * since pi reads each of them for model calls. A builtin provider the job cannot call (its model list does not name
54
+ * it, `jobProviders`) is not counted: its variable is one the operator may hand a server on purpose (`HF_TOKEN` for a
55
+ * Hugging Face MCP server), as `run.secrets` may. A custom provider whose key is a `!command` names no variable this
56
+ * can see: the operator who writes both files is the one who can avoid that pairing.
57
+ */
58
+ export function providerKeyNames({ providers = [], candidatesOf = () => [], overlayModels = null } = {}) {
59
+ const names = new Set();
60
+ for (const provider of providers) for (const name of candidatesOf(provider) ?? []) if (typeof name === "string") names.add(name);
61
+ const walk = (value) => {
62
+ if (typeof value === "string") for (const match of value.matchAll(CONFIG_REF_RE)) names.add(match[1] ?? match[2]);
63
+ else if (value !== null && typeof value === "object") Object.values(value).forEach(walk);
64
+ };
65
+ walk(overlayModels);
66
+ return names;
67
+ }
68
+
69
+ /**
70
+ * The providers a job may call: its own, and each on its model list (the trigger's, else the deployment's), which the
71
+ * runner's model guard holds every call to. With no list there is no guard, and the job may call any provider whose
72
+ * key its container holds (a pi child of its own, `--provider` and all), so every builtin provider (`everyProvider`)
73
+ * counts. An operator who hands a server a provider's variable on purpose (`HF_TOKEN`) gives that trigger a list.
74
+ */
75
+ export function jobProviders(job, { defaultProvider = null, allowedModels = null, everyProvider = [] } = {}) {
76
+ const list = Array.isArray(job?.models) ? job.models : Array.isArray(allowedModels) ? allowedModels : null;
77
+ const listed = list === null ? everyProvider : list.map((ref) => (typeof ref === "string" ? ref.split("/")[0] : ref?.provider));
78
+ return [...new Set([job?.provider ?? defaultProvider, ...listed].filter((p) => typeof p === "string" && p !== ""))];
79
+ }
80
+
81
+ /**
82
+ * The check, bound to this deployment. `reserved(job)` is the set of variable names the job's own container holds for
83
+ * itself; `hostEnv` is the worker's environment, which decides whether a PI_FORWARD_ENV name reaches the job at all;
84
+ * `allowlist()` and `endpoints()` are read only when a server needs them, and `allowlist()` throws what its read threw.
85
+ * `gitlabUrl` is the worker's configured GitLab URL (null when it serves no GitLab), which a gitlab job's forge server
86
+ * calls.
87
+ */
88
+ export function makeMcpCheck({ globalPiDir, forwardEnv = [], hostEnv = {}, egress = false, reserved = () => new Set(), allowlist = () => [], endpoints = () => [], read = readOverlayMcp, gitlabUrl = null }) {
89
+ return (job) => {
90
+ const listed = job?.mcp;
91
+ if (listed === undefined) return { ok: true, document: null };
92
+ // The loader's shape, asked again: queue data is not always the current receiver's (an older or newer service,
93
+ // a hand-added job, a stored repeatable), and a list that is not one must neither pass as "none" nor reach the
94
+ // public comment. The name is withheld from the comment when it is not a valid name.
95
+ if (!Array.isArray(listed) || listed.length === 0 || listed.length > MCP_SERVERS_MAX || new Set(listed).size !== listed.length || listed.some((name) => typeof name !== "string" || !MCP_SERVER_NAME_RE.test(name))) {
96
+ return { refused: "mcp-list-invalid", name: null, why: "the job's run.mcp is not a list of server names the loader accepts" };
97
+ }
98
+ const names = listed;
99
+ const overlayNames = names.filter((name) => name !== FORGE_MCP_SERVER_NAME);
100
+ let parsed;
101
+ try {
102
+ parsed = read(globalPiDir || "");
103
+ } catch (error) {
104
+ if (!error?.mcpConfig && MCP_TRANSIENT_READ_CODES.has(error?.code)) return { unavailable: error.code };
105
+ return { refused: "mcp-config-invalid", name: null, why: error?.mcpConfig ? "the overlay's mcp.json cannot be read whole" : `the overlay's mcp.json cannot be read (${error?.code ?? "unreadable"})` };
106
+ }
107
+ if (parsed === null) {
108
+ if (overlayNames.length > 0) return { refused: "mcp-config-invalid", name: null, why: globalPiDir ? "the overlay holds no mcp.json" : "this deployment has no overlay (PI_GLOBAL_PI_DIR)" };
109
+ // Only `forge`: the overlay's defaults, as an empty file reads.
110
+ parsed = { autoEnableCodemode: true, codemodeModels: true, servers: {}, refused: [] };
111
+ }
112
+ const refusedHere = new Map(parsed.refused.map((r) => [r.name, r.problem]));
113
+ let servers = parsed.servers;
114
+ if (overlayNames.length !== names.length) {
115
+ // The kind is queue data like the list: a job from elsewhere naming `forge` on a kind with no forge server (or
116
+ // no forge token) is refused here, never handed another forge's entry.
117
+ if (!FORGE_MCP_KINDS.includes(job.kind)) return { refused: "mcp-server-unknown", name: FORGE_MCP_SERVER_NAME, why: "the forge's own server is offered to a github or gitlab job only" };
118
+ if (job.kind === "gitlab" && (typeof gitlabUrl !== "string" || gitlabUrl === "")) return { refused: "mcp-server-unknown", name: FORGE_MCP_SERVER_NAME, why: "this worker has no GitLab URL configured" };
119
+ const urlProblem = job.kind === "gitlab" ? gitlabUrlProblem(gitlabUrl) : null;
120
+ if (urlProblem !== null) return { refused: "mcp-server-unknown", name: FORGE_MCP_SERVER_NAME, why: `GITLAB_URL cannot be handed to the forge server: ${urlProblem}` };
121
+ servers = { ...parsed.servers, [FORGE_MCP_SERVER_NAME]: forgeMcpServer(job.kind, { gitlabUrl }) };
122
+ }
123
+ for (const name of overlayNames) {
124
+ const server = Object.hasOwn(servers, name) ? servers[name] : undefined;
125
+ if (!server) return { refused: "mcp-server-unknown", name, why: refusedHere.has(name) ? "the overlay's mcp.json refuses it" : "the overlay's mcp.json does not offer it" };
126
+ if (server.enabled === false) return { refused: "mcp-server-unknown", name, why: "the overlay's mcp.json turns it off" };
127
+ }
128
+ // A forwarded name reaches the job only when the worker holds a value for it (env-allowlist.mjs buildContainerEnv).
129
+ const forwarded = forwardEnv.filter((ref) => typeof hostEnv[ref] === "string" && hostEnv[ref] !== "");
130
+ const mapped = new Set([...Object.keys(job.secrets ?? {}), ...forwarded]);
131
+ let own;
132
+ try {
133
+ own = reserved(job);
134
+ } catch (error) {
135
+ // The overlay models.json read inside `reserved` that may pass on a retry: without it, a custom provider's
136
+ // credential would drop out of the set for this one pickup.
137
+ if (MCP_TRANSIENT_READ_CODES.has(error?.code)) return { unavailable: error.code };
138
+ throw error;
139
+ }
140
+ // The forge server is exempt from both rules: its one reference is the token the worker mints for this job and
141
+ // writes under exactly that name, which is the reason every other server is refused it.
142
+ for (const name of overlayNames) {
143
+ for (const ref of servers[name].refs) {
144
+ if (own.has(ref)) return { refused: "mcp-secret-reserved", name, why: `it references ${ref}, which this deployment uses for the job's own credentials` };
145
+ }
146
+ }
147
+ for (const name of overlayNames) {
148
+ for (const ref of servers[name].refs) {
149
+ if (!mapped.has(ref)) return { refused: "mcp-secret-unmapped", name, why: `it references ${ref}, which the trigger does not pass (run.secrets, or a PI_FORWARD_ENV name the worker holds a value for)` };
150
+ }
151
+ }
152
+ const cut = mcpRouteProblem(servers, names, new Set(Array.isArray(job.excludeTools) ? job.excludeTools : []), { autoEnableCodemode: parsed.autoEnableCodemode });
153
+ if (cut !== null) return { refused: "mcp-excluded", name: cut.name, why: cut.why };
154
+ if (egress) {
155
+ let list = null;
156
+ let declared = null;
157
+ // The allowlist read that may pass on a retry is retried, like the overlay's; any other failure refuses, since
158
+ // without the list no server outside a declared endpoint can be judged reachable.
159
+ const readList = () => {
160
+ try {
161
+ return allowlist();
162
+ } catch (error) {
163
+ if (MCP_TRANSIENT_READ_CODES.has(error?.code)) throw Object.assign(new Error("transient"), { transient: error.code });
164
+ throw Object.assign(new Error("unreadable"), { unreadable: error?.code ?? "unreadable" });
165
+ }
166
+ };
167
+ try {
168
+ for (const name of names) {
169
+ const server = servers[name];
170
+ if (name === FORGE_MCP_SERVER_NAME) {
171
+ // Its API host, judged as a streamable HTTP server's url is: the proxy sees the same CONNECT either way.
172
+ list ??= readList();
173
+ declared ??= endpoints();
174
+ const refusal = egressRefusalFor(forgeMcpEgressUrl(job.kind, { gitlabUrl }), { allowlist: list, endpoints: declared });
175
+ if (refusal !== null) return { refused: "mcp-egress-unreachable", name, why: refusal };
176
+ } else if (server.transport === "http") {
177
+ list ??= readList();
178
+ declared ??= endpoints();
179
+ const refusal = egressRefusalFor(server.url, { allowlist: list, endpoints: declared });
180
+ if (refusal !== null) return { refused: "mcp-egress-unreachable", name, why: refusal };
181
+ } else if (server.packageRunner) {
182
+ list ??= readList();
183
+ const missing = packageRunnerHosts(server.command).filter((host) => allowlistEntryFor(list, host) === null);
184
+ if (missing.length > 0) return { refused: "mcp-package-runner", name, why: `it fetches its server from ${missing.join(" and ")}, which egress-allowlist.conf does not hold` };
185
+ }
186
+ }
187
+ } catch (error) {
188
+ if (error?.transient) return { unavailable: error.transient };
189
+ if (error?.unreadable) return { refused: "mcp-egress-unreachable", name: null, why: `egress-allowlist.conf cannot be read (${error.unreadable})` };
190
+ throw error;
191
+ }
192
+ }
193
+ return { ok: true, document: jobMcpDocument({ ...parsed, servers }, names) };
194
+ };
195
+ }
196
+
197
+ /**
198
+ * A server's exposures as pi reads them (`getMcpToolExposure`): an exact tool name first, then the patterns in order,
199
+ * the first that matches winning, else the server's own. So a pattern after `*` never applies, and `*` replaces the
200
+ * default; any exact name and any earlier pattern may still match a tool.
201
+ */
202
+ function exposuresOf(server) {
203
+ const own = server.exposure ?? "codemode";
204
+ const entries = Object.entries(server.toolExposure ?? {}).filter(([key]) => !key.startsWith("dispatch_"));
205
+ const tools = entries.filter(([key]) => !key.includes("*")).map(([, value]) => value);
206
+ let base = own;
207
+ for (const [key, value] of entries.filter(([key]) => key.includes("*"))) {
208
+ if (key === "*") {
209
+ base = value;
210
+ break;
211
+ }
212
+ tools.push(value);
213
+ }
214
+ return { own, tools: [base, ...tools] };
215
+ }
216
+
217
+ /**
218
+ * Whether each named server keeps a way in through the tools `excluded` leaves, as pi 1.1.0 routes them: a `direct` tool
219
+ * needs nothing; a `codemode` or `deferred` tool is reached by whichever discovery tool is active, since codemode calls
220
+ * both kinds and tool_search loads both, and pi activates codemode when some server has a codemode tool (and
221
+ * `autoEnableCodemode` is not false) and tool_search when some server has a deferred one; the resource tools take their
222
+ * server's own exposure, and a `hidden` server's resources are never offered. Judged only for what the trigger's
223
+ * exclusions take away: a server the overlay leaves no way in is the overlay's, not this trigger's to be refused for.
224
+ * Returns `{ name, why }` for the first server the exclusions cut off, or null.
225
+ */
226
+ export function mcpRouteProblem(servers, names, excluded, { autoEnableCodemode = true } = {}) {
227
+ const routes = (removed) => {
228
+ const all = names.map((name) => exposuresOf(servers[name]));
229
+ const has = (kind) => all.some(({ own, tools }) => own === kind || tools.includes(kind));
230
+ const discovery = (!removed.has("codemode") && autoEnableCodemode !== false && has("codemode")) || (!removed.has("tool_search") && has("deferred"));
231
+ const reach = (exposure) => exposure === "direct" || ((exposure === "codemode" || exposure === "deferred") && discovery);
232
+ const widest = ["direct", "codemode", "deferred"].find((kind) => all.some(({ own }) => own === kind)) ?? "hidden";
233
+ return names.map((name, i) => {
234
+ const { own, tools } = all[i];
235
+ const toolRoute = !removed.has(mcpServerToolPattern(name)) && tools.some(reach);
236
+ // pi registers the resource tools once, with the widest exposure among the servers that offer resources
237
+ // and are not hidden (syncResourceTools), and read_mcp_resource reads any of them. Whether a server lists
238
+ // resources is unknown until it connects, so each one that is not hidden is assumed to.
239
+ const resourceRoute = !removed.has("read_mcp_resource") && own !== "hidden" && reach(widest);
240
+ return toolRoute || resourceRoute;
241
+ });
242
+ };
243
+ const before = routes(new Set());
244
+ const after = routes(excluded);
245
+ const i = names.findIndex((_, k) => before[k] && !after[k]);
246
+ return i < 0 ? null : { name: names[i], why: "run.excludeTools removes every tool that reaches it (its own, the discovery tools that reach them, and read_mcp_resource)" };
247
+ }
248
+
249
+ /**
250
+ * The refusal's public sentence, per reason. Fixed text: the comment posts on the issue, and `why` can name an
251
+ * internal host or a variable, which belong in the worker's log, not in a thread anyone can read.
252
+ */
253
+ export const MCP_REFUSAL_COMMENT = Object.freeze({
254
+ "mcp-list-invalid": "the list this job carries is not one the triggers file's loader writes (a service in this deployment may be at another version)",
255
+ "mcp-config-invalid": "this deployment's list of MCP servers (mcp.json in the overlay) cannot be used",
256
+ "mcp-server-unknown": "this deployment does not offer that server",
257
+ "mcp-secret-reserved": "that server would be handed a credential this deployment keeps for the job itself",
258
+ "mcp-secret-unmapped": "a credential that server needs is not passed by this trigger",
259
+ "mcp-excluded": "this trigger's run.excludeTools removes every way the job could reach that server",
260
+ "mcp-egress-unreachable": "the egress policy does not let a job reach that server",
261
+ "mcp-package-runner": "the egress policy does not let a job reach the registry that server is fetched from",
262
+ });
@@ -363,7 +363,12 @@ export function modelEntryOf(models, provider, modelId) {
363
363
  return { entry, defined, override: override !== null && typeof override === "object" ? override : null };
364
364
  }
365
365
 
366
- function matching(baseUrl, endpoints) {
366
+ /**
367
+ * The declared endpoints a URL dials: those whose host AND port `baseUrlTarget` reads from it. The one match, for a
368
+ * model's baseUrl here, an MCP server's url at the pickup's egress check (`egressRefusalFor`) and doctor's endpoint
369
+ * probes alike, so the three cannot disagree on which endpoint a URL is.
370
+ */
371
+ export function endpointsAt(baseUrl, endpoints) {
367
372
  const target = baseUrlTarget(baseUrl);
368
373
  if (target === null || !Array.isArray(endpoints)) return [];
369
374
  return endpoints.filter((e) => e.host === target.host && e.port === target.port);
@@ -380,7 +385,7 @@ export function endpointsForModel({ models, provider, modelId, endpoints }) {
380
385
  if (entry === null) return [];
381
386
  const model = modelsOf(entry).find((m) => m.id === modelId);
382
387
  const baseUrl = typeof model?.baseUrl === "string" ? model.baseUrl : entry.baseUrl;
383
- return matching(baseUrl, endpoints);
388
+ return endpointsAt(baseUrl, endpoints);
384
389
  }
385
390
 
386
391
  /**
@@ -391,7 +396,7 @@ export function endpointsForModel({ models, provider, modelId, endpoints }) {
391
396
  export function endpointsForProvider({ models, provider, endpoints }) {
392
397
  const entry = providerOf(models, provider);
393
398
  if (entry === null) return [];
394
- return modelsOf(entry).map((m) => ({ modelId: m.id, endpoints: matching(typeof m.baseUrl === "string" ? m.baseUrl : entry.baseUrl, endpoints) }));
399
+ return modelsOf(entry).map((m) => ({ modelId: m.id, endpoints: endpointsAt(typeof m.baseUrl === "string" ? m.baseUrl : entry.baseUrl, endpoints) }));
395
400
  }
396
401
 
397
402
  /**
package/src/outbox.mjs CHANGED
@@ -3,6 +3,7 @@ import { join } from "node:path";
3
3
  import { readFlowGate as realReadFlowGate } from "./flow-gate.mjs";
4
4
  import { chainedJobId } from "./job-id.mjs";
5
5
  import { enqueueLocalJob } from "./queue.mjs";
6
+ import { withoutMcpExclusions } from "./triggers.mjs";
6
7
 
7
8
  /**
8
9
  * The outbox chain collector: the host-side reader of a completed LOCAL parent's `/outbox`, the
@@ -207,7 +208,9 @@ export function makeCollectChain({ queue, enqueue = enqueueLocalJob, readFlowGat
207
208
  // parent would chain a child that can edit and run bash, a widening no operator wrote.
208
209
  // Off `job.data`, never off `req` -- the request file can neither set nor drop it (explicit
209
210
  // property reads only, above), so the agent cannot widen its child by omitting a key.
210
- excludeTools: job.data?.excludeTools,
211
+ // Less the names of the parent's MCP tools: the child connects no server (`mcp` below), its runner knows
212
+ // none of them, and they would refuse it at start while removing nothing.
213
+ excludeTools: withoutMcpExclusions(job.data?.excludeTools),
211
214
  // `secrets`/`secretsProfile` are deliberately ABSENT, and the two lines above are exactly why
212
215
  // this comment exists: their reasoning reads as though it should apply here too, and it must
213
216
  // not. An image and a skills directory are toolchain; a resolved credential is a capability.
@@ -219,6 +222,9 @@ export function makeCollectChain({ queue, enqueue = enqueueLocalJob, readFlowGat
219
222
  // `portfolio` (issue #505) is ABSENT for the same reason, and more plainly: it is budget authority, granted
220
223
  // to one cron trigger the operator reviewed. A child is an agent's request, so inheriting the flag would let
221
224
  // a portfolio job queue a second plan writer of its own choosing.
225
+ // `mcp` (REQ-MCP-OPERATOR-SERVERS) is ABSENT for the secrets' reason: a server is reached with the
226
+ // credentials its trigger maps, a capability granted to the trigger the operator reviewed. A child
227
+ // without it connects no server, which is the narrowing direction.
222
228
  chainDepth: childDepth,
223
229
  parentJobId: job.id,
224
230
  // chainedJobId deliberately does NOT take the image: the child's identity is (parent, flow, task).
package/src/prepare.mjs CHANGED
@@ -1,8 +1,9 @@
1
- import { mkdtempSync, rmSync } from "node:fs";
1
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
2
  import { rm } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
4
  import { resolveBackendName } from "./backend-registry.mjs";
5
5
  import { ensureJobsDir } from "./config.mjs";
6
+ import { JOB_MCP_FILE } from "./mcp-config.mjs";
6
7
  import { resolveJobImage } from "./image-preflight.mjs";
7
8
  import { retainJobDir } from "./sandbox-store.mjs";
8
9
  import { prepareGithubWorkspace } from "./prepare-github.mjs";
@@ -15,6 +16,7 @@ import { buildAzurePrompt } from "./azure-prompt.mjs";
15
16
  import { prepareLocalWorkspace } from "./prepare-local.mjs";
16
17
  import { copySkillTree } from "./copy-tree.mjs";
17
18
  import { recordedJobSize } from "./job-size.mjs";
19
+ import { scheduledForMillis } from "./repeat-slot.mjs";
18
20
 
19
21
  /**
20
22
  * The subdirectory of the per-job dir a trigger's injected skills are copied into, so they reach the
@@ -83,7 +85,7 @@ export function makePrepareWorkspace({
83
85
  removeDir = (dir) => rmSync(dir, { recursive: true, force: true }),
84
86
  }) {
85
87
  ensureDir(jobsDir);
86
- return async function prepareWorkspace(job, token, { queueJobId, piVersion = null, jobUser = null, podmanStore = null, portfolio = false, size = null } = {}) {
88
+ return async function prepareWorkspace(job, token, { queueJobId, piVersion = null, jobUser = null, podmanStore = null, portfolio = false, size = null, mcp = null } = {}) {
87
89
  ensureDir(jobsDir);
88
90
  const jobDir = mkdtempSync(join(jobsDir, "job-"));
89
91
  // Issue #524: a THROW out of anything below leaves `jobDir` to nobody. The processor tears down only what
@@ -93,7 +95,7 @@ export function makePrepareWorkspace({
93
95
  // covers the thrown ones, in one place for every kind, rather than in each preparer that can throw.
94
96
  // A retry makes a fresh directory, so nothing is lost by removing this one.
95
97
  try {
96
- return await prepareInto(job, token, jobDir, { queueJobId, piVersion, jobUser, podmanStore, portfolio, size });
98
+ return await prepareInto(job, token, jobDir, { queueJobId, piVersion, jobUser, podmanStore, portfolio, size, mcp });
97
99
  } catch (error) {
98
100
  // GUARDED: a removal that fails (a busy mount, a permission flipped mid-job) must never replace the error
99
101
  // that is the job's actual outcome. The directory is then left, which is what happened before this catch.
@@ -104,7 +106,7 @@ export function makePrepareWorkspace({
104
106
  }
105
107
  };
106
108
 
107
- async function prepareInto(job, token, jobDir, { queueJobId, piVersion, jobUser, podmanStore, portfolio, size }) {
109
+ async function prepareInto(job, token, jobDir, { queueJobId, piVersion, jobUser, podmanStore, portfolio, size, mcp }) {
108
110
  // The trigger's injected skills (REQ-PER-TRIGGER-SKILLS, issue #60), COPIED here rather than
109
111
  // mounted, and copied ONCE for every job kind because this is where local and forge converge.
110
112
  //
@@ -125,6 +127,13 @@ export function makePrepareWorkspace({
125
127
  // a host path by a later edit (the same shape packages.mjs's `dropped` record has).
126
128
  log("trigger_skills_injected", { dirs: injected.dirs, files: injected.files, bytes: injected.bytes });
127
129
  }
130
+ // The job's MCP servers (REQ-MCP-OPERATOR-SERVERS), as the processor's check passed them: written here, beside the
131
+ // injected skills, for their reason (no new mount, and fixed for the life of the job). References only, never a
132
+ // value (mcp-config.mjs `jobMcpDocument`), and read-only to the job like everything under /job.
133
+ if (mcp) {
134
+ writeFileSync(join(jobDir, JOB_MCP_FILE), `${JSON.stringify(mcp, null, 2)}\n`, { mode: 0o444 });
135
+ log("mcp_config_written", { servers: Object.keys(mcp.servers).length, refs: Object.values(mcp.refs).reduce((n, list) => n + list.length, 0) });
136
+ }
128
137
  // What `cleanup` needs to retain this run's directory, stamped here because this is the only place
129
138
  // that holds all of it at once. Applied to the RESULT rather than mutated in, so a preparer's
130
139
  // `{ outcome: "policy" }` refusal -- which carries no jobDir -- is passed through untouched.
@@ -214,15 +223,6 @@ function localEventContext(job, queueJobId, findPreviousRun) {
214
223
  return { source: "manual" };
215
224
  }
216
225
 
217
- /** Parse the millis out of a `repeat:<id>:<millis>` BullMQ scheduled jobId, or null. */
218
- function scheduledForMillis(queueJobId) {
219
- if (typeof queueJobId !== "string" || !queueJobId.startsWith("repeat:")) return null;
220
- const tail = queueJobId.slice(queueJobId.lastIndexOf(":") + 1);
221
- if (tail === "") return null;
222
- const millis = Number(tail);
223
- return Number.isFinite(millis) ? millis : null;
224
- }
225
-
226
226
  /**
227
227
  * Attach the sandbox stamp to a successful prepare, and to nothing else.
228
228
  *
package/src/processor.mjs CHANGED
@@ -7,6 +7,7 @@ import { unqualifiedScope } from "./pause-windows.mjs";
7
7
  import { PROJECT_CAP_REASON } from "./scoped-limits.mjs";
8
8
  import { DEFAULT_SECRETS_PROFILE, secretsArmed } from "./secrets.mjs";
9
9
  import { RESERVED_ENV_NAMES } from "./triggers.mjs";
10
+ import { MCP_REFUSAL_COMMENT } from "./mcp-preflight.mjs";
10
11
  import { EXIT_COMPLETED, EXIT_INFRA, EXIT_POLICY } from "./exit-code.mjs";
11
12
  import { COST_CAP_WHYS, EXIT_OOM_KILLED, RUNNER_POLICY_REASONS } from "./run-history.mjs";
12
13
  import { DEFAULT_EGRESS_PROXY } from "./egress.mjs";
@@ -129,6 +130,16 @@ export const TERMINAL_COMMENTS = {
129
130
  "model-not-allowed": "Stopped: the run tried to call an AI model this trigger does not allow, or to change an AI request in a way it does not allow, so the call was not made. Partial work may exist. Not retried.",
130
131
  "cost-cap-unenforceable": "Stopped: this run has a cost limit, and the job image could not enforce it before each AI call, so nothing was sent to the AI provider. The operator needs to update the job image. Not retried.",
131
132
  "model-policy-unenforceable": "Stopped: this run is limited to certain AI models, and the job image could not enforce that before each AI call, so nothing was sent to the AI provider. The operator needs to update the job image. Not retried.",
133
+ // The runner's refusals of the job's MCP servers, each before the prompt. Never the server's name, its host or a
134
+ // variable: the exit line carries the name and the cause in its message, which the worker never parses (it reaches
135
+ // the worker's log as the container's output). Each says what the operator can check; the issue's author can act on
136
+ // none of it.
137
+ "mcp-unavailable": "Stopped: an MCP server this trigger names could not be used (it did not start, failed its handshake or refused the connection, an authentication error among them), so the run ended before the agent started. The operator needs to check that server and the job image. Not retried.",
138
+ "mcp-config-invalid": "Stopped: the list of MCP servers the worker wrote for this job is not one the job image accepts, so the run ended before the agent started. The operator needs to run a worker and a job image of the same release. Not retried.",
139
+ "mcp-config-mismatch": "Stopped: the MCP servers the worker wrote for this job are not the ones it named for it, so the run ended before the agent started. The operator needs to run a worker and a job image of the same release. Not retried.",
140
+ "mcp-secret-missing": "Stopped: a credential an MCP server this trigger names needs was not set inside the job, so the run ended before the agent started. The operator needs to check the secrets this trigger passes. Not retried.",
141
+ "mcp-command-missing": "Stopped: the job image does not have the program an MCP server this trigger names runs, so the run ended before the agent started. The operator needs to install that server in the job image or the overlay. Not retried.",
142
+ "mcp-shadowed": "Stopped: another extension in this job registers a tool or command that only the job's MCP servers may, so the run ended before the agent started. The operator needs to remove that extension or this trigger's MCP servers. Not retried.",
132
143
  // Issue #596. Never the size or the project: both are operator configuration, and the reader may be an issue author
133
144
  // who can act on neither. The worker log and the run record carry them.
134
145
  [EXIT_OOM_KILLED]: "Stopped: the job's container ran out of memory and was stopped. Partial work may exist. Not retried, because the same size would stop the same way. The operator can raise this job's memory size.",
@@ -307,6 +318,10 @@ export async function runJob(job, deps) {
307
318
  // would leave an INJECTED resolver running on every job, which is how a probe nobody wanted starts
308
319
  // spawning a subprocess per delivery to learn nothing.
309
320
  resolveSecrets = async (job) => ({ profileUnknown: job.secretsProfile ?? DEFAULT_SECRETS_PROFILE }),
321
+ // (job) => { ok, document } | { refused, name, why } | { unavailable: code } (REQ-MCP-OPERATOR-SERVERS,
322
+ // mcp-preflight.mjs). The default passes a job that names no server and refuses one that does, so a wiring that
323
+ // forgot the check can never run a job without the servers its trigger names.
324
+ checkMcp = (job) => (job?.mcp !== undefined ? { refused: "mcp-config-invalid", name: null, why: "this worker reads no MCP servers" } : { ok: true, document: null }),
310
325
  // #227. Which backends this deployment BLESSED (PI_BACKENDS). Defaults to the one name every
311
326
  // deployment already runs rather than to admit-everything: a wiring that says nothing blesses
312
327
  // `local` only, so a job naming anything else is refused instead of running somewhere the operator
@@ -491,7 +506,7 @@ export async function runJob(job, deps) {
491
506
  // Three causes, the likeliest first (PR #536's review, round 3, made the check strict): the trigger gained the
492
507
  // field after this job was queued, a service is below the version that carries it, or one still reads an
493
508
  // older copy of the triggers file. The first is fixed by re-running the job, so the comment says so.
494
- await comment(job, `Refused: this trigger sets \`run.${skew.field}\`, but the job reached the worker without it, so it would have run without that limit. Either the trigger changed after this job was queued (re-run it), or a service in this deployment is stale: below the version that carries the field, or still reading an older copy of the triggers file and in need of a restart. Not run.`);
509
+ await comment(job, `Refused: this trigger sets \`run.${skew.field}\`, but the job reached the worker without it, so it would have run as though the trigger did not set it. Either the trigger changed after this job was queued (re-run it), or a service in this deployment is stale: below the version that carries the field, or still reading an older copy of the triggers file and in need of a restart. Not run.`);
495
510
  log("refused_trigger_skew", { triggerIndex: job.trigger?.matched?.index ?? null, field: skew.field, causes: "trigger-changed-after-queue-or-stale-service" });
496
511
  return { outcome: "policy", reason: "trigger-skew", exitCode: null, turns: null, tokens: null, provider: job.provider ?? null, model: job.model ?? null, budgetReserved: false };
497
512
  }
@@ -974,6 +989,25 @@ export async function runJob(job, deps) {
974
989
  return { outcome: "policy", reason: "skills-dir-missing", exitCode: null, turns: null, tokens: null, provider: job.provider ?? null, model: job.model ?? null, budgetReserved: false }; // return => not retried
975
990
  }
976
991
 
992
+ // REQ-MCP-OPERATOR-SERVERS. A trigger that named MCP servers its job cannot use would run its flow without them,
993
+ // or hand a server the job's own credentials, or open a connection the egress proxy refuses at its first call:
994
+ // each a plausible report and a clean exit. One read of the overlay's mcp.json, no credential and no network, so
995
+ // it sits with the free refusals before the secrets resolver (the one gate that spawns) and before anything that
996
+ // spends (CONST-BUDGET-BEFORE-TOKENS). Its answer carries the per-job document prepare writes, so the servers the
997
+ // job connects are exactly the ones this passed.
998
+ const mcpChecked = checkMcp(job);
999
+ if (mcpChecked.unavailable) {
1000
+ throw new InfraRetry(`an overlay file the MCP check reads (mcp.json, models.json or egress-allowlist.conf) could not be read just now (${mcpChecked.unavailable})`, { reason: "container-never-started", provider: job.provider ?? null, model: job.model ?? null });
1001
+ }
1002
+ if (mcpChecked.refused) {
1003
+ // The server NAME is the operator's own configuration, the same class as the image ref on the image refusal.
1004
+ // `why` can name an internal host or a variable (never a value, a url's path or its query), so it goes to the
1005
+ // worker's log and the public comment says only which rule refused.
1006
+ await comment(job, `Refused: this trigger names MCP server${mcpChecked.name ? ` "${mcpChecked.name}"` : "s"} in \`run.mcp\`, and ${MCP_REFUSAL_COMMENT[mcpChecked.refused] ?? "it cannot be used"}. The job would have run without the servers its flow was written against; the worker's log says why (\`refused_mcp\`). Not run.`);
1007
+ log("refused_mcp", { kind: job.kind ?? null, reason: mcpChecked.refused, server: mcpChecked.name ?? null, why: mcpChecked.why });
1008
+ return { outcome: "policy", reason: mcpChecked.refused, exitCode: null, turns: null, tokens: null, provider: job.provider ?? null, model: job.model ?? null, budgetReserved: false }; // return => not retried
1009
+ }
1010
+
977
1011
  // REQ-TRIGGER-SECRETS. A trigger that named secret references the worker cannot resolve would run its
978
1012
  // flow with those variables UNSET, get a 401 from whatever it was meant to reach, write a plausible
979
1013
  // report about why the integration is down, and exit 0.
@@ -1107,7 +1141,7 @@ export async function runJob(job, deps) {
1107
1141
  // `podmanStore` (issue #429) only where the venue's job user carried one: the podman store the container ran in.
1108
1142
  // `portfolio` (issue #505) only for a job the gate above confirmed: prepare then writes /job/portfolio.json, after
1109
1143
  // asking the live file once more. Absent otherwise, so every other job's prepare call is unchanged.
1110
- prepared = await prepareWorkspace(job, token, { piVersion, jobUser: { user: jobUser?.user ?? null, home: jobUser?.home ?? null }, ...(typeof jobUser?.store === "string" ? { podmanStore: jobUser.store } : {}), ...(portfolio ? { portfolio: true } : {}), ...(jobSize ? { size: jobSize } : {}) }); // resolves SHA, clones, materialises .pi/, writes prompt
1144
+ prepared = await prepareWorkspace(job, token, { piVersion, jobUser: { user: jobUser?.user ?? null, home: jobUser?.home ?? null }, ...(typeof jobUser?.store === "string" ? { podmanStore: jobUser.store } : {}), ...(portfolio ? { portfolio: true } : {}), ...(jobSize ? { size: jobSize } : {}), ...(mcpChecked.document ? { mcp: mcpChecked.document } : {}) }); // resolves SHA, clones, materialises .pi/, writes prompt
1111
1145
 
1112
1146
  // A determinate prepare refusal -- sha-gone (the default branch advanced past the resolved tip),
1113
1147
  // or a `pi-*` materialiser cap breach (the repo's .pi/ is too large to place in /job, issue #60)
package/src/queue.mjs CHANGED
@@ -176,7 +176,7 @@ export function swallowedRunSentence(jobId, existing) {
176
176
  return `an identical run was queued${hhmm} as ${jobId} (${existing?.state ?? "already queued or done"}); nothing new was queued.`;
177
177
  }
178
178
 
179
- async function addLocalJob(queue, { folder, flow, task, command, provider, model, maxTurns, models, maxCostUsd, image, backend, excludeTools, skillsDir, secrets, secretsProfile, chainDepth, parentJobId, jobId, enqueueNonce, now = new Date() }) {
179
+ async function addLocalJob(queue, { folder, flow, task, command, provider, model, maxTurns, models, maxCostUsd, image, backend, excludeTools, mcp, skillsDir, secrets, secretsProfile, chainDepth, parentJobId, jobId, enqueueNonce, now = new Date() }) {
180
180
  const minute = now.toISOString().slice(0, 16); // YYYY-MM-DDTHH:MM -- the dedup window
181
181
  // A caller-supplied jobId (the outbox collector's retry-idempotent chainedJobId) wins; otherwise the
182
182
  // minute-windowed localJobId is the dedup key. A command job (issue #189) fills the flow slot with
@@ -217,6 +217,7 @@ async function addLocalJob(queue, { folder, flow, task, command, provider, model
217
217
  // same reason: `trigger` is copied verbatim into /job/event.json, and a permission boundary is the
218
218
  // worker's business, never agent-visible input to reason about.
219
219
  ...(excludeTools !== undefined && { excludeTools }),
220
+ ...(mcp !== undefined && { mcp }),
220
221
  // The host directory of operator-authored skills this trigger injects (REQ-PER-TRIGGER-SKILLS).
221
222
  // Conditional like `image`, so an unflagged job's data stays byte-identical, and at JOB level rather
222
223
  // than inside `trigger` because a worker-host path is an execution knob, not a fact about the
@@ -310,7 +311,7 @@ export async function enqueueGitLabJob(queue, fields) {
310
311
  * window, replicas never coalesce against each other, and an unflagged job's dedup id is the same string it
311
312
  * has always been.
312
313
  */
313
- export async function enqueueForgeJob(queue, kind, { repo, projectId, azure, target, flow, command, trigger, provider, model, maxTurns, models, maxCostUsd, packages, image, backend, excludeTools, skillsDir, instructions, resume, secrets, secretsProfile, waitFor, replica, replicas }) {
314
+ export async function enqueueForgeJob(queue, kind, { repo, projectId, azure, target, flow, command, trigger, provider, model, maxTurns, models, maxCostUsd, packages, image, backend, excludeTools, mcp, skillsDir, instructions, resume, secrets, secretsProfile, waitFor, replica, replicas }) {
314
315
  const jobId = forgeDeliveryJobId(kind, trigger?.deliveryId, replica);
315
316
  // `packages` (whether to load the operator-staged pi packages) and `image` (which container image to run)
316
317
  // come off the MATCHED trigger (INT-TRIGGERS-FILE-CONTRACT / REQ-GLOBAL-PI-OVERLAY) and land on `data`
@@ -351,6 +352,7 @@ export async function enqueueForgeJob(queue, kind, { repo, projectId, azure, tar
351
352
  // same reason: `trigger` is copied verbatim into /job/event.json, and a permission boundary is the
352
353
  // worker's business, never agent-visible input to reason about.
353
354
  ...(excludeTools !== undefined && { excludeTools }),
355
+ ...(mcp !== undefined && { mcp }),
354
356
  // #501. The trigger's per-job dollar cap, as written. Conditional like `excludeTools`, at JOB level for its
355
357
  // reason, and a narrowing like it: the worker runs the job under the smaller of this and the deployment cap.
356
358
  ...(maxCostUsd !== undefined && { maxCostUsd }),
@@ -0,0 +1,13 @@
1
+ /**
2
+ * The scheduled-for instant of a BullMQ job scheduler's job, read from the deterministic `repeat:<id>:<millis>` id it
3
+ * mints (DES-CRON-VIA-BULLMQ-SCHEDULER). A leaf module, so the run record and the prepare step read one parser.
4
+ */
5
+
6
+ /** Parse the millis out of a `repeat:<id>:<millis>` BullMQ scheduled jobId, or null. */
7
+ export function scheduledForMillis(queueJobId) {
8
+ if (typeof queueJobId !== "string" || !queueJobId.startsWith("repeat:")) return null;
9
+ const tail = queueJobId.slice(queueJobId.lastIndexOf(":") + 1);
10
+ if (tail === "") return null;
11
+ const millis = Number(tail);
12
+ return Number.isFinite(millis) ? millis : null;
13
+ }
@@ -43,6 +43,7 @@ export const CONTAINER_ENV_NAMES = new Set([
43
43
  "PI_FLOW",
44
44
  "PI_COMMAND",
45
45
  "PI_EXCLUDE_TOOLS",
46
+ "PI_MCP_SERVERS",
46
47
  // Issues #501, #502: the per-job dollar cap (written by the worker since #501, from the trigger's and the
47
48
  // deployment's caps) and the allowed-model list (reserved until #502 writes it). Neither can be bound through
48
49
  // `run.secrets`: a trigger setting its own cap, or its own list, would be the policy choosing itself.
@@ -146,6 +146,8 @@ export function makeRunContainer({
146
146
  // non-empty validated array; the guard is the same defensive shape `flow` above wears, so a
147
147
  // hand-built job with junk in the field emits no variable rather than an empty one.
148
148
  excludeTools: Array.isArray(job.excludeTools) && job.excludeTools.length > 0 ? job.excludeTools : undefined,
149
+ // REQ-MCP-OPERATOR-SERVERS: the trigger's MCP server names, off `job` like excludeTools.
150
+ mcpServers: Array.isArray(job.mcp) && job.mcp.length > 0 ? job.mcp : undefined,
149
151
  // Issue #502: the effective allowed-model list, off `job` like excludeTools. index.mjs `effectiveJobOf` folds
150
152
  // the deployment's PI_ALLOWED_MODELS in under the trigger's own, so `job.models` is already the one list.
151
153
  allowedModels: Array.isArray(job.models) && job.models.length > 0 ? job.models : null,
@@ -0,0 +1,29 @@
1
+ /**
2
+ * The rule for a run record's `earlier` (issue #599, INT-RUN-HISTORY-FILE-CONTRACT), in a module that imports only the
3
+ * worker name rule, so the record writer (run-history.mjs, which re-exports both names) and the capacity report, a pure
4
+ * reader, rebuild it with ONE validator.
5
+ */
6
+
7
+ import { WORKER_NAME_RE } from "./worker-name.mjs";
8
+
9
+ const recordInt = (v) => (Number.isSafeInteger(v) && v >= 0 ? v : null);
10
+
11
+ /** The most earlier attempts a record carries: the newest are kept. */
12
+ export const EARLIER_MAX = 4;
13
+
14
+ /** One earlier attempt as a record carries it, or null: a worker name, two canonical ISO instants in order, two sizes. */
15
+ function earlierEntry(e) {
16
+ if (e === null || typeof e !== "object" || Array.isArray(e)) return null;
17
+ const instant = (v) => (typeof v === "string" && Number.isFinite(Date.parse(v)) && new Date(Date.parse(v)).toISOString() === v ? v : null);
18
+ const startedAt = instant(e.startedAt);
19
+ const endedAt = instant(e.endedAt);
20
+ if (typeof e.host !== "string" || !WORKER_NAME_RE.test(e.host) || startedAt === null || endedAt === null || Date.parse(endedAt) < Date.parse(startedAt)) return null;
21
+ return { host: e.host, startedAt, endedAt, memMiB: recordInt(e.memMiB), cpuCenti: recordInt(e.cpuCenti) };
22
+ }
23
+
24
+ /** A record's `earlier`, rebuilt: the valid entries, the newest `EARLIER_MAX` of them, or null when none is. */
25
+ export function recordedEarlier(list) {
26
+ if (!Array.isArray(list)) return null;
27
+ const kept = list.map(earlierEntry).filter((e) => e !== null).slice(-EARLIER_MAX);
28
+ return kept.length > 0 ? kept : null;
29
+ }