@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.
@@ -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
+ });
@@ -125,6 +125,8 @@ export function overlayProviderProblem(config, providerId) {
125
125
  if (!api) return "no-api";
126
126
  const baseUrl = def.baseUrl ?? config.baseUrl ?? defaults?.baseUrl;
127
127
  if (!baseUrl) return "no-baseUrl";
128
+ // pi 1.2.0's schema refuses both first (a token count must be above zero), so a file reaching here holds none; kept
129
+ // because provider-composer.js still checks them, and the mirror follows that file, not the schema in front of it.
128
130
  if (def.contextWindow !== undefined && def.contextWindow <= 0) return "contextWindow";
129
131
  if (def.maxTokens !== undefined && def.maxTokens <= 0) return "maxTokens";
130
132
  const composed = { id: def.id, api, baseUrl };
@@ -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
  /**
@@ -121,8 +121,13 @@ const T = {
121
121
  const opt = (check) => [check, true];
122
122
  const req = (check) => [check, false];
123
123
 
124
- // The schema, transcribed from model-config.js at the pin (1.0.3; worker/test/models-json.test.mjs holds the file's
125
- // content hash), in its order.
124
+ // The schema, transcribed at the pin (1.2.0; worker/test/models-json.test.mjs holds each file's content hash). Since
125
+ // 1.2.0 model-config.js builds it from two pi-ai modules, `providers/model-schema.js` (cost, prompt cache, input
126
+ // limits, thinking map, modalities) and `providers/compat-schema.js` (compat), so all three are pinned.
127
+ //
128
+ // The change that matters in 1.2.0: `compat` was a UNION of three object schemas that each admit extra keys, so a value
129
+ // one api's schema refused passed through another (`supportsStore: "x"` loaded). It is now ONE object, the superset of
130
+ // five apis' properties, so every known compat key is type-checked wherever `compat` appears.
126
131
  const PercentileCutoffs = T.obj({ p50: opt(T.num()), p75: opt(T.num()), p90: opt(T.num()), p99: opt(T.num()) });
127
132
  const numOrStr = T.union(T.num(), T.str());
128
133
  const OpenRouterRouting = T.obj({
@@ -141,70 +146,76 @@ const OpenRouterRouting = T.obj({
141
146
  preferred_max_latency: opt(T.union(T.num(), PercentileCutoffs)),
142
147
  });
143
148
  const VercelGatewayRouting = T.obj({ only: opt(T.arr(T.str())), order: opt(T.arr(T.str())) });
149
+ // model-schema.js: `Partial(Record(Enum(levels), String | Null))`, which checks the seven level keys and admits others.
144
150
  const ThinkingValue = T.union(T.str(), T.nul());
145
151
  const ThinkingLevelMap = T.obj({ off: opt(ThinkingValue), minimal: opt(ThinkingValue), low: opt(ThinkingValue), medium: opt(ThinkingValue), high: opt(ThinkingValue), xhigh: opt(ThinkingValue), max: opt(ThinkingValue) });
146
152
  // pi 1.0.2 (issue #587): a model's sampling parameters, and the same per thinking level, merged per call over them.
147
153
  const SamplingParams = T.rec(T.unknown());
148
154
  const SamplingParamsByThinkingLevel = T.obj({ off: opt(SamplingParams), minimal: opt(SamplingParams), low: opt(SamplingParams), medium: opt(SamplingParams), high: opt(SamplingParams), xhigh: opt(SamplingParams), max: opt(SamplingParams) });
149
- const KwargScalar = T.union(T.str(), T.num(), T.bool(), T.nul());
150
- const KwargVariable = T.obj({ $var: req(T.union(T.lit("thinking.enabled"), T.lit("thinking.effort"))), omitWhenOff: opt(T.bool()) });
151
- const Kwarg = T.union(KwargScalar, KwargVariable);
155
+ const KwargValue = T.union(T.str(), T.num(), T.bool(), T.nul(), T.obj({ $var: req(T.union(T.lit("thinking.enabled"), T.lit("thinking.effort"), T.lit("thinking.budget"))), omitWhenOff: opt(T.bool()) }));
152
156
  const affinity = T.union(T.lit("openai"), T.lit("openai-nosession"), T.lit("openrouter"));
153
- const OpenAICompletionsCompat = T.obj({
154
- supportsStore: opt(T.bool()),
155
- supportsDeveloperRole: opt(T.bool()),
156
- supportsReasoningEffort: opt(T.bool()),
157
- supportsUsageInStreaming: opt(T.bool()),
158
- supportsFinishReason: opt(T.bool()),
159
- maxTokensField: opt(T.union(T.lit("max_completion_tokens"), T.lit("max_tokens"))),
160
- requiresToolResultName: opt(T.bool()),
161
- requiresAssistantAfterToolResult: opt(T.bool()),
162
- requiresThinkingAsText: opt(T.bool()),
163
- requiresReasoningContentOnAssistantMessages: opt(T.bool()),
164
- thinkingFormat: opt(T.union(...["openai", "openrouter", "together", "baseten", "deepseek", "zai", "qwen", "chat-template", "qwen-chat-template", "string-thinking", "ant-ling"].map((x) => T.lit(x)))),
165
- chatTemplateKwargs: opt(T.rec(Kwarg)),
166
- chatTemplateArgs: opt(T.rec(Kwarg)),
167
- cacheControlFormat: opt(T.lit("anthropic")),
168
- openRouterRouting: opt(OpenRouterRouting),
169
- vercelGatewayRouting: opt(VercelGatewayRouting),
170
- supportsOpenAIGrammarTools: opt(T.bool()),
171
- supportsStrictMode: opt(T.bool()),
172
- sendSessionAffinityHeaders: opt(T.bool()),
173
- sessionAffinityFormat: opt(affinity),
174
- supportsLongCacheRetention: opt(T.bool()),
175
- vllmPriority: opt(T.num()),
176
- });
177
- const OpenAIResponsesCompat = T.obj({
178
- supportsDeveloperRole: opt(T.bool()),
179
- sessionAffinityFormat: opt(affinity),
180
- supportsLongCacheRetention: opt(T.bool()),
181
- supportsStrictMode: opt(T.bool()),
182
- supportsOpenAIGrammarTools: opt(T.bool()),
183
- supportsMaxOutputTokens: opt(T.bool()),
184
- });
185
157
  const costRates = { input: req(T.num()), output: req(T.num()), cacheRead: req(T.num()), cacheWrite: req(T.num()) };
186
158
  const ModelCostTier = T.obj({ inputTokensAbove: req(T.num()), ...costRates });
187
159
  const ModelCost = T.obj({ ...costRates, tiers: opt(T.arr(ModelCostTier)) });
160
+ // `Partial(Record(Enum(["short", "long"]), Number > 0))`, like the thinking map.
188
161
  const ModelPromptCache = T.obj({ short: opt(T.num({ exclusiveMinimum: 0 })), long: opt(T.num({ exclusiveMinimum: 0 })) });
189
162
  const posInt = T.int({ minimum: 1 });
190
163
  const ImageResize = T.obj({ maxWidth: opt(posInt), maxHeight: opt(posInt), maxBytes: opt(posInt), jpegQuality: opt(T.int({ minimum: 1, maximum: 100 })) });
191
164
  const ModelInputLimits = T.obj({ maxRequestBytes: opt(posInt), images: opt(T.obj({ resize: opt(ImageResize), maxPerMessage: opt(posInt), maxPerRequest: opt(posInt) })) });
192
- const AnthropicMessagesCompat = T.obj({
193
- supportsEagerToolInputStreaming: opt(T.bool()),
194
- supportsLongCacheRetention: opt(T.bool()),
195
- sendSessionAffinityHeaders: opt(T.bool()),
196
- supportsCacheControlOnTools: opt(T.bool()),
197
- supportsTemperature: opt(T.bool()),
198
- forceAdaptiveThinking: opt(T.bool()),
199
- allowEmptySignature: opt(T.bool()),
200
- supportsStrictTools: opt(T.bool()),
201
- supportsMidConvoEffort: opt(T.bool()),
165
+ // compat-schema.js's ProviderCompatSchema: the merged properties of the OpenAI completions, OpenAI responses, Anthropic
166
+ // messages, Bedrock and Mistral schemas. A key two of them share takes pi's generic override, which for every shared
167
+ // key here is the same type as each api's own (a boolean, or the three-way affinity format), so the superset is exact.
168
+ const bool = opt(T.bool());
169
+ const ProviderCompat = T.obj({
170
+ // OpenAI completions
171
+ supportsStore: bool,
172
+ supportsDeveloperRole: bool,
173
+ supportsReasoningEffort: bool,
174
+ supportsUsageInStreaming: bool,
175
+ supportsFinishReason: bool,
176
+ maxTokensField: opt(T.union(T.lit("max_completion_tokens"), T.lit("max_tokens"))),
177
+ requiresToolResultName: bool,
178
+ requiresAssistantAfterToolResult: bool,
179
+ requiresThinkingAsText: bool,
180
+ requiresReasoningContentOnAssistantMessages: bool,
181
+ thinkingFormat: opt(T.union(...["openai", "openrouter", "deepseek", "together", "baseten", "zai", "qwen", "chat-template", "qwen-chat-template", "string-thinking", "ant-ling"].map((x) => T.lit(x)))),
182
+ chatTemplateKwargs: opt(T.rec(KwargValue)),
183
+ chatTemplateArgs: opt(T.rec(KwargValue)),
184
+ openRouterRouting: opt(OpenRouterRouting),
185
+ vercelGatewayRouting: opt(VercelGatewayRouting),
186
+ zaiToolStream: bool,
187
+ thinkingTokenBudgetField: opt(T.union(T.lit("thinking_token_budget"), T.lit("thinking_budget"), T.lit("thinking_budget_tokens"))),
188
+ supportsThinkingTokenBudget: bool,
189
+ supportsOpenAIGrammarTools: bool,
190
+ supportsMidConvoSystemMessages: bool,
191
+ supportsMidConvoToolAdditions: bool,
192
+ supportsStrictMode: bool,
193
+ cacheControlFormat: opt(T.lit("anthropic")),
194
+ sendSessionAffinityHeaders: bool,
195
+ sessionAffinityFormat: opt(affinity),
196
+ supportsLongCacheRetention: bool,
197
+ vllmPriority: opt(T.num()),
198
+ // OpenAI responses
199
+ supportsAdditionalTools: bool,
200
+ supportsToolSearch: bool,
201
+ supportsExplicitPromptCacheMode: bool,
202
+ supportsMaxOutputTokens: bool,
203
+ // Anthropic messages
204
+ supportsEagerToolInputStreaming: bool,
205
+ supportsCacheControlOnTools: bool,
206
+ supportsTemperature: bool,
207
+ forceAdaptiveThinking: bool,
208
+ allowEmptySignature: bool,
209
+ supportsStrictTools: bool,
210
+ supportsMidConvoEffort: bool,
211
+ supportsMidConvoToolChanges: bool,
202
212
  allowedFallbackModels: opt(T.arr(T.obj({ provider: req(T.str({ minLength: 1 })), model: req(T.str({ minLength: 1 })), cost: req(ModelCost) }), { maxItems: 3 })),
203
213
  });
204
- const ProviderCompat = T.union(OpenAICompletionsCompat, OpenAIResponsesCompat, AnthropicMessagesCompat);
205
214
  const nonEmpty = T.str({ minLength: 1 });
206
215
  const inputKinds = T.arr(T.union(T.lit("text"), T.lit("image")));
207
216
  const headers = T.rec(T.str());
217
+ // 1.2.0: a token count must be above zero, where any number passed before.
218
+ const tokenCount = T.num({ exclusiveMinimum: 0 });
208
219
  const ModelDefinition = T.obj({
209
220
  id: req(nonEmpty),
210
221
  name: opt(nonEmpty),
@@ -216,8 +227,8 @@ const ModelDefinition = T.obj({
216
227
  inputLimits: opt(ModelInputLimits),
217
228
  cost: opt(ModelCost),
218
229
  promptCache: opt(ModelPromptCache),
219
- contextWindow: opt(T.num()),
220
- maxTokens: opt(T.num()),
230
+ contextWindow: opt(tokenCount),
231
+ maxTokens: opt(tokenCount),
221
232
  samplingParams: opt(SamplingParams),
222
233
  samplingParamsByThinkingLevel: opt(SamplingParamsByThinkingLevel),
223
234
  headers: opt(headers),
@@ -231,8 +242,8 @@ const ModelOverride = T.obj({
231
242
  inputLimits: opt(ModelInputLimits),
232
243
  cost: opt(T.obj({ input: opt(T.num()), output: opt(T.num()), cacheRead: opt(T.num()), cacheWrite: opt(T.num()), tiers: opt(T.arr(ModelCostTier)) })),
233
244
  promptCache: opt(ModelPromptCache),
234
- contextWindow: opt(T.num()),
235
- maxTokens: opt(T.num()),
245
+ contextWindow: opt(tokenCount),
246
+ maxTokens: opt(tokenCount),
236
247
  samplingParams: opt(SamplingParams),
237
248
  samplingParamsByThinkingLevel: opt(SamplingParamsByThinkingLevel),
238
249
  headers: opt(headers),
@@ -250,7 +261,8 @@ const ProviderConfig = T.obj({
250
261
  models: opt(T.arr(ModelDefinition)),
251
262
  modelOverrides: opt(T.rec(ModelOverride)),
252
263
  });
253
- const ModelsConfig = T.obj({ providers: req(T.rec(ProviderConfig)) });
264
+ // 1.2.0: a `$schema` key (the published https://pi.dev/schemas/models.schema.json) is declared, as a string.
265
+ const ModelsConfig = T.obj({ $schema: opt(T.str()), providers: req(T.rec(ProviderConfig)) });
254
266
 
255
267
  /**
256
268
  * Parse overlay `models.json` TEXT as pi does. `{ value }` when pi would load it, else `{ error }` with a fixed
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/parked.mjs ADDED
@@ -0,0 +1,18 @@
1
+ /**
2
+ * REQ-AWAIT-INPUT, the reply half (issue #625): the forge marker on an issue whose last run is waiting for an answer.
3
+ *
4
+ * Imported by both services, like capabilities.mjs, because the receiver routes on it and the worker writes it: two
5
+ * spellings of one label would be two places for one to drift while every test stayed green.
6
+ *
7
+ * THE LABEL IS A HINT, THE SIDECAR IS THE TRUTH. The receiver cannot read the session store (on a fleet it may not
8
+ * even run beside one), so a label the forge already sends in every comment payload is what lets it route a reply
9
+ * that carries no trigger phrase. Anyone who may label an issue can set it, so it decides nothing on its own: the
10
+ * worker refuses such a reply before any spend unless the key's `parked` sidecar exists (`not-parked`). And it
11
+ * widens WHICH comments start a job, never WHO may: the comment trigger's author gate still runs first.
12
+ */
13
+ export const PARKED_LABEL = "pi-awaiting-input";
14
+
15
+ /** Whether a subset's `[{ name }]` label list (or Azure's tag list, the same shape) carries the marker. */
16
+ export function hasParkedLabel(labels) {
17
+ return Array.isArray(labels) && labels.some((label) => label?.name === PARKED_LABEL);
18
+ }
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";
@@ -84,7 +85,7 @@ export function makePrepareWorkspace({
84
85
  removeDir = (dir) => rmSync(dir, { recursive: true, force: true }),
85
86
  }) {
86
87
  ensureDir(jobsDir);
87
- 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 } = {}) {
88
89
  ensureDir(jobsDir);
89
90
  const jobDir = mkdtempSync(join(jobsDir, "job-"));
90
91
  // Issue #524: a THROW out of anything below leaves `jobDir` to nobody. The processor tears down only what
@@ -94,7 +95,7 @@ export function makePrepareWorkspace({
94
95
  // covers the thrown ones, in one place for every kind, rather than in each preparer that can throw.
95
96
  // A retry makes a fresh directory, so nothing is lost by removing this one.
96
97
  try {
97
- 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 });
98
99
  } catch (error) {
99
100
  // GUARDED: a removal that fails (a busy mount, a permission flipped mid-job) must never replace the error
100
101
  // that is the job's actual outcome. The directory is then left, which is what happened before this catch.
@@ -105,7 +106,7 @@ export function makePrepareWorkspace({
105
106
  }
106
107
  };
107
108
 
108
- 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 }) {
109
110
  // The trigger's injected skills (REQ-PER-TRIGGER-SKILLS, issue #60), COPIED here rather than
110
111
  // mounted, and copied ONCE for every job kind because this is where local and forge converge.
111
112
  //
@@ -126,6 +127,13 @@ export function makePrepareWorkspace({
126
127
  // a host path by a later edit (the same shape packages.mjs's `dropped` record has).
127
128
  log("trigger_skills_injected", { dirs: injected.dirs, files: injected.files, bytes: injected.bytes });
128
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
+ }
129
137
  // What `cleanup` needs to retain this run's directory, stamped here because this is the only place
130
138
  // that holds all of it at once. Applied to the RESULT rather than mutated in, so a preparer's
131
139
  // `{ outcome: "policy" }` refusal -- which carries no jobDir -- is passed through untouched.