@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
|
@@ -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
|
+
});
|
package/src/model-endpoints.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
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";
|
|
@@ -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.
|
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
|
|
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 }),
|
package/src/reserved-env.mjs
CHANGED
|
@@ -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.
|
package/src/run-container.mjs
CHANGED
|
@@ -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,
|
package/src/run-history.mjs
CHANGED
|
@@ -164,8 +164,15 @@ export function parseExitTurns(text) {
|
|
|
164
164
|
* it could not enforce before a call. The two refusals are live from the first image that knows the variables.
|
|
165
165
|
* `cost-cap` is live from the image that declares `costCap`, whose runner carries the cost guard, and
|
|
166
166
|
* `model-not-allowed` from the image that declares `modelPolicy`, whose runner carries the model guard.
|
|
167
|
+
*
|
|
168
|
+
* Then the runner's exit-2 refusals of a job's MCP servers (`MCP_RUNNER_REASONS`), each before the prompt. They read as
|
|
169
|
+
* `runner-policy` until they were named here, which hid an operator's fix (a server that answers 401, a command the
|
|
170
|
+
* image lacks) behind budget wording. `mcp-config-invalid` is also the pickup's FREE refusal of an overlay mcp.json it
|
|
171
|
+
* cannot read whole; that one returns `budgetReserved: false` and pages nobody, as the free `model-not-allowed` does.
|
|
172
|
+
* Live from the first image that declares `mcp`: an image built before the worker named them prints the same line.
|
|
167
173
|
*/
|
|
168
|
-
export const
|
|
174
|
+
export const MCP_RUNNER_REASONS = Object.freeze(["mcp-unavailable", "mcp-config-invalid", "mcp-config-mismatch", "mcp-secret-missing", "mcp-command-missing", "mcp-shadowed"]);
|
|
175
|
+
export const RUNNER_POLICY_REASONS = new Set(["provider-auth-refused", "cost-cap", "model-not-allowed", "cost-cap-unenforceable", "model-policy-unenforceable", ...MCP_RUNNER_REASONS]);
|
|
169
176
|
|
|
170
177
|
/**
|
|
171
178
|
* The reason off the decisive exit line (`decisiveExitLine`), or `null`: a member of `RUNNER_POLICY_REASONS`
|
package/src/schedules.mjs
CHANGED
|
@@ -182,7 +182,7 @@ function normalizeCronSchedule({ on, run }, path, existsSync, fleet) {
|
|
|
182
182
|
// `portfolio` (issue #505) is conditional like `command`, so an unflagged schedule grows no key, and it rides at JOB
|
|
183
183
|
// level, never inside `trigger`: `trigger` is copied verbatim into `/job/event.json`, and whether a job may send a
|
|
184
184
|
// budget plan is the worker's decision to re-check against the live file, not a fact for the agent to read.
|
|
185
|
-
const data = { kind: "local", folder: run.folder, flow: run.flow, task: run.task, ...(run.command !== undefined && { command: run.command }), provider: run.provider, model: run.model, maxTurns: run.maxTurns, github: run.github, packages: run.packages, image: run.image, ...(run.backend !== undefined && { backend: run.backend }), ...(run.excludeTools !== undefined && { excludeTools: run.excludeTools }), ...(run.models !== undefined && { models: run.models }), ...(run.maxCostUsd !== undefined && { maxCostUsd: run.maxCostUsd }), ...(run.skillsDir !== undefined && { skillsDir: run.skillsDir }), ...(run.secrets !== undefined && { secrets: run.secrets }), ...(run.secretsProfile !== undefined && { secretsProfile: run.secretsProfile }), resume: run.resume, ...(run.portfolio !== undefined && { portfolio: run.portfolio }), trigger: { id: on.id, pattern: on.pattern } };
|
|
185
|
+
const data = { kind: "local", folder: run.folder, flow: run.flow, task: run.task, ...(run.command !== undefined && { command: run.command }), provider: run.provider, model: run.model, maxTurns: run.maxTurns, github: run.github, packages: run.packages, image: run.image, ...(run.backend !== undefined && { backend: run.backend }), ...(run.excludeTools !== undefined && { excludeTools: run.excludeTools }), ...(run.mcp !== undefined && { mcp: run.mcp }), ...(run.models !== undefined && { models: run.models }), ...(run.maxCostUsd !== undefined && { maxCostUsd: run.maxCostUsd }), ...(run.skillsDir !== undefined && { skillsDir: run.skillsDir }), ...(run.secrets !== undefined && { secrets: run.secrets }), ...(run.secretsProfile !== undefined && { secretsProfile: run.secretsProfile }), resume: run.resume, ...(run.portfolio !== undefined && { portfolio: run.portfolio }), trigger: { id: on.id, pattern: on.pattern } };
|
|
186
186
|
// Retention only; the deterministic repeat:<id>:<millis> jobId supplies dedup, so no jobId here, and
|
|
187
187
|
// scheduler jobs are not retried (DES-CRON-VIA-BULLMQ-SCHEDULER) so no attempts/backoff.
|
|
188
188
|
const opts = { removeOnComplete: { age: 24 * 3600 }, removeOnFail: { age: 7 * 24 * 3600 } };
|
package/src/start.mjs
CHANGED
|
@@ -62,7 +62,10 @@ import { PODMAN_BOOT_REFUSING_CAUSES, PODMAN_INFO_TIMEOUT_MS, cachedPodmanInfo,
|
|
|
62
62
|
import { PODMAN_RESTART_HOLD_EXPIRED, makePodmanServiceReader, onceFs, makeRootfulMemory, observeHost, observeRootfulConf, readRootfulService, rootfulConfRefusal, rootfulConfRetries, rootfulUnreadList, runtimeObservationKey } from "./runtime-observations.mjs";
|
|
63
63
|
|
|
64
64
|
import { makeRunContainer } from "./run-container.mjs";
|
|
65
|
-
import { resolveProviderCredential } from "./env-allowlist.mjs";
|
|
65
|
+
import { piProviders, providerKeyCandidates, resolveProviderCredential } from "./env-allowlist.mjs";
|
|
66
|
+
import { parseAllowlist } from "./egress-allowlist.mjs";
|
|
67
|
+
import { MCP_TRANSIENT_READ_CODES, jobProviders, makeMcpCheck, providerKeyNames } from "./mcp-preflight.mjs";
|
|
68
|
+
import { RESERVED_ENV_NAMES } from "./triggers.mjs";
|
|
66
69
|
import { makeSecretsResolver } from "./secrets.mjs";
|
|
67
70
|
import { buildRecord, EXIT_OOM_KILLED, makeFindPreviousRun, makeLogReaper, makeLogSink, makeReadRecord, makeRecordWriter, makeSettledRecord, newerRecord, RUNNER_POLICY_REASONS, sanitizeJobId, UNREADABLE_RECORD } from "./run-history.mjs";
|
|
68
71
|
import { makeRunMirror, readMirroredRecord } from "./run-mirror.mjs";
|
|
@@ -2256,6 +2259,42 @@ export async function startWorker(
|
|
|
2256
2259
|
// injected env they were not, which is the divergence this file already calls out by name for
|
|
2257
2260
|
// sessionsDir. One deployment value, one place, same rule as the profiles below it.
|
|
2258
2261
|
resolveSecrets: makeSecretsResolverFn({ envProfiles: config.secretProfiles, roots: config.secretResolverRoots, timeoutMs: config.secretResolveTimeoutMs, forwardEnv: config.forwardEnv, hostEnv: env, log }),
|
|
2262
|
+
// REQ-MCP-OPERATOR-SERVERS: the overlay's mcp.json read once per job that names a server, never at boot (an
|
|
2263
|
+
// operator who re-runs import-pi must not have to restart the worker). The allowlist is the one the proxy
|
|
2264
|
+
// mounts, this folder's egress-allowlist.conf on both venues (up.mjs, podman-stack.mjs), and the endpoints
|
|
2265
|
+
// are the pickup's snapshot, so the check and the proxy read the same declarations.
|
|
2266
|
+
checkMcp: makeMcpCheck({
|
|
2267
|
+
globalPiDir: config.globalPiDir,
|
|
2268
|
+
forwardEnv: config.forwardEnv,
|
|
2269
|
+
hostEnv: env,
|
|
2270
|
+
egress: config.egress,
|
|
2271
|
+
// DES-FORGE-MCP: the GitLab a gitlab job's forge server calls, the same URL its clone and its glab use.
|
|
2272
|
+
gitlabUrl: config.gitlab?.apiUrl ?? null,
|
|
2273
|
+
// The credentials the job is set up to spend with (providerKeyNames). A read of the overlay models.json that
|
|
2274
|
+
// may pass on a retry is thrown to the check, which retries the job; any other failure adds no overlay
|
|
2275
|
+
// names, and the model gates, which ran first, refuse every job such a file breaks.
|
|
2276
|
+
reserved: (job) => {
|
|
2277
|
+
let overlayModels = null;
|
|
2278
|
+
try {
|
|
2279
|
+
overlayModels = readOverlayModels(config.globalPiDir);
|
|
2280
|
+
} catch (error) {
|
|
2281
|
+
if (MCP_TRANSIENT_READ_CODES.has(error?.code)) throw error;
|
|
2282
|
+
}
|
|
2283
|
+
const providers = jobProviders(job, { defaultProvider: config.provider, allowedModels: config.allowedModels, everyProvider: piProviders() });
|
|
2284
|
+
return new Set([...RESERVED_ENV_NAMES, ...providerKeyNames({ providers, candidatesOf: providerKeyCandidates, overlayModels })]);
|
|
2285
|
+
},
|
|
2286
|
+
allowlist: () => {
|
|
2287
|
+
try {
|
|
2288
|
+
return parseAllowlist(readFileSync(join(process.cwd(), "egress-allowlist.conf"), "utf8"));
|
|
2289
|
+
} catch (error) {
|
|
2290
|
+
// No allowlist here: every server outside a declared endpoint is unreachable, which it is. Any other
|
|
2291
|
+
// failure is the check's to classify (a transient errno is retried).
|
|
2292
|
+
if (error?.code === "ENOENT") return [];
|
|
2293
|
+
throw error;
|
|
2294
|
+
}
|
|
2295
|
+
},
|
|
2296
|
+
endpoints: () => modelEndpoints.current,
|
|
2297
|
+
}),
|
|
2259
2298
|
// #227. What PI_BACKENDS blessed, so a trigger naming an unblessed venue refuses pre-spend. The
|
|
2260
2299
|
// panel's picker is bounded by the same list, and this is the half that binds: the overlay is
|
|
2261
2300
|
// not the reviewed artifact (DES-PER-TRIGGER-SECRET-PROFILE).
|
package/src/triggers-file.mjs
CHANGED
|
@@ -475,6 +475,9 @@ export const AUTHORED_NARROWING_FIELDS = Object.freeze([
|
|
|
475
475
|
// unknown), the job would run under the deployment's wider cap, or with none, on a clean record. Any present
|
|
476
476
|
// value counts as authored; whether it is a valid amount is the loader's question, not this one.
|
|
477
477
|
Object.freeze({ key: "maxCostUsd", authored: (v) => v !== undefined && v !== null }),
|
|
478
|
+
// REQ-MCP-OPERATOR-SERVERS: the MCP servers a trigger's jobs connect. Not a widening when dropped but the same
|
|
479
|
+
// silent outcome: the job runs its flow without the servers it was written against, on a clean record.
|
|
480
|
+
Object.freeze({ key: "mcp", authored: (v) => Array.isArray(v) && v.length > 0 }),
|
|
478
481
|
]);
|
|
479
482
|
|
|
480
483
|
export function makeCheckWaitSkew({ triggersPath, fs = nodeFs }) {
|