@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.
@@ -21,6 +21,7 @@
21
21
  */
22
22
 
23
23
  import { configError } from "./config.mjs";
24
+ import { PARKED_LABEL } from "./parked.mjs";
24
25
  import { InfraRetry } from "./processor.mjs";
25
26
  import { fetchFailureReason } from "./gitlab-identity.mjs";
26
27
  import { matchesBranch } from "./gitlab-host.mjs";
@@ -153,7 +154,38 @@ export function makeForgejoHost({ apiUrl, fetchFn = fetch } = {}) {
153
154
  return { headRef: pr?.head?.ref, headRepo: pr?.head?.repo?.full_name };
154
155
  }
155
156
 
156
- return { resolveDefaultBranchSha, isDefaultBranchProtected, postStatusComment, resolvePullRequestHead };
157
+ /**
158
+ * Add or remove the parked marker on an issue (issue #625, parked.mjs). Forgejo adds and removes a
159
+ * label by NAME (its IssueLabelsOption and the `{identifier}` path take a name or an id), but only one the repository
160
+ * already has, so adding first creates the repository label; a label that already exists answers 409 or 422 there. The caller treats any throw as fail-open:
161
+ * the session store's sidecar, not this label, decides whether a reply resumes anything.
162
+ */
163
+ async function setParkedLabel(ref, target, on, token) {
164
+ const [owner, name] = splitRepo(ref);
165
+ const send = async (method, path, body, accept = []) => {
166
+ let res;
167
+ try {
168
+ res = await fetchFn(`${root}${path}`, {
169
+ method,
170
+ headers: { Authorization: `token ${token}`, "content-type": "application/json" },
171
+ ...(body !== undefined && { body: JSON.stringify(body) }),
172
+ redirect: "error",
173
+ });
174
+ } catch (err) {
175
+ throw new InfraRetry(`forgejo-host: ${method} ${path} failed (${fetchFailureReason(err)})`);
176
+ }
177
+ if (!res.ok && !accept.includes(res.status)) throw new InfraRetry(`forgejo-host: ${method} ${path} returned ${res.status}`);
178
+ };
179
+ const issue = `/repos/${owner}/${name}/issues/${encodeURIComponent(target?.number)}/labels`;
180
+ if (on) {
181
+ await send("POST", `/repos/${owner}/${name}/labels`, { name: PARKED_LABEL, color: "#fbca04", description: "pi-dispatch: the last run is waiting for an answer" }, [409, 422]);
182
+ await send("POST", issue, { labels: [PARKED_LABEL] });
183
+ return;
184
+ }
185
+ await send("DELETE", `${issue}/${encodeURIComponent(PARKED_LABEL)}`, undefined, [404]);
186
+ }
187
+
188
+ return { resolveDefaultBranchSha, isDefaultBranchProtected, postStatusComment, resolvePullRequestHead, setParkedLabel };
157
189
  }
158
190
 
159
191
  /**
@@ -74,6 +74,31 @@ function prReplicaLines(replica, replicas) {
74
74
  * toward the full envelope, and a bare "address the feedback" over a cold session is an agent with no idea
75
75
  * what it was asked to do. `tea`, never `gh`: this envelope's whole reason for existing.
76
76
  */
77
+ /**
78
+ * What a resumed run is told to do with the activity (issue #624). On a pull request the branch and the pull
79
+ * request exist, so the words are the ones this envelope always had. On an issue neither is certain: the
80
+ * previous run may have opened nothing (it concluded there was nothing to change, or it stopped to ask), and
81
+ * "push to the same branch ... do not open a second" would leave the work on a branch nobody reviews while the
82
+ * human who asked waits on the issue for a reply. So the issue form names the issue branch, checks for its pull
83
+ * request first, and replies where the activity came from. No replica argument: run.resume refuses replicas.
84
+ */
85
+ function resumedActionLines(target, n) {
86
+ if (target?.type === "pull_request") {
87
+ return [
88
+ "Address the activity quoted below. If it asks for changes, make them, push to the same branch with",
89
+ "`git push --force-with-lease`, and reply on the pull request saying what you did or why you could",
90
+ "not. Do not open a second pull request -- your push updates the existing one.",
91
+ ];
92
+ }
93
+ const branch = issueBranch(n);
94
+ return [
95
+ "Address the activity quoted below, then reply on the issue saying what you did or why you could not.",
96
+ `If it asks for changes, commit them to \`${branch}\` and publish with \`git push --force-with-lease\` to that`,
97
+ `branch only. Check first for an open pull request from it (\`tea pr list --state open\`, look for \`${branch}\`):`,
98
+ `if one exists your push updates it, so do not open a second; only if none exists, run \`tea pr create --head ${branch}\`.`,
99
+ ];
100
+ }
101
+
77
102
  function buildResumedPrompt(flow, target, comment, instructions) {
78
103
  const n = normalizeNumber(target?.number);
79
104
  const noun = target?.type === "pull_request" ? "pull request" : "issue";
@@ -86,9 +111,7 @@ function buildResumedPrompt(flow, target, comment, instructions) {
86
111
  `If you do not recognise this ${noun}, treat this as a fresh start: read it with \`tea pr ${n}\``,
87
112
  `(or \`tea issue ${n}\`) before doing anything, then follow the "${flow}" skill from the top.`,
88
113
  "",
89
- "Address the activity quoted below. If it asks for changes, make them, push to the same branch with",
90
- "`git push --force-with-lease`, and reply on the pull request saying what you did or why you could",
91
- "not. Do not open a second pull request -- your push updates the existing one.",
114
+ ...resumedActionLines(target, n),
92
115
  "",
93
116
  // Above the never-merge paragraph, so the harness has the last word before the data region.
94
117
  ...(instructionBlock(instructions) ? [instructionBlock(instructions), ""] : []),
@@ -26,6 +26,7 @@
26
26
  import { Octokit } from "@octokit/rest";
27
27
  import { configError } from "./config.mjs";
28
28
  import { octokitLogOptions } from "./octokit-log.mjs";
29
+ import { PARKED_LABEL } from "./parked.mjs";
29
30
  import { InfraRetry } from "./processor.mjs";
30
31
 
31
32
  /**
@@ -134,7 +135,26 @@ export function makeGitHubHost({ log, octokitFor = (token) => new Octokit({ auth
134
135
  return { headRef: data?.head?.ref, headRepo: data?.head?.repo?.full_name };
135
136
  }
136
137
 
137
- return { resolveDefaultBranchSha, isDefaultBranchProtected, postStatusComment, resolvePullRequestHead };
138
+ /**
139
+ * Add or remove the parked marker on an issue (issue #625, parked.mjs). GitHub creates a label the repository lacks
140
+ * when it is added, so there is nothing to create first. Removing a label the issue does not carry is a 404, which is
141
+ * the state asked for. The caller treats any throw as fail-open: the session store's sidecar, not this label, decides.
142
+ */
143
+ async function setParkedLabel(repo, target, on, token) {
144
+ const [owner, name] = splitRepo(repo);
145
+ const octokit = octokitFor(token);
146
+ if (on) {
147
+ await octokit.request("POST /repos/{owner}/{repo}/issues/{issue_number}/labels", { owner, repo: name, issue_number: target?.number, labels: [PARKED_LABEL] });
148
+ return;
149
+ }
150
+ try {
151
+ await octokit.request("DELETE /repos/{owner}/{repo}/issues/{issue_number}/labels/{name}", { owner, repo: name, issue_number: target?.number, name: PARKED_LABEL });
152
+ } catch (error) {
153
+ if (error?.status !== 404) throw error;
154
+ }
155
+ }
156
+
157
+ return { resolveDefaultBranchSha, isDefaultBranchProtected, postStatusComment, resolvePullRequestHead, setParkedLabel };
138
158
  }
139
159
 
140
160
  /**
@@ -155,6 +155,31 @@ function prReplicaLines(replica, replicas) {
155
155
  * The safety paragraph is repeated verbatim rather than assumed inherited. It is cheap, and the whole
156
156
  * premise of a long-lived transcript is that early turns get compacted away.
157
157
  */
158
+ /**
159
+ * What a resumed run is told to do with the activity (issue #624). On a pull request the branch and the pull
160
+ * request exist, so the words are the ones this envelope always had. On an issue neither is certain: the
161
+ * previous run may have opened nothing (it concluded there was nothing to change, or it stopped to ask), and
162
+ * "push to the same branch ... do not open a second" would leave the work on a branch nobody reviews while the
163
+ * human who asked waits on the issue for a reply. So the issue form names the issue branch, checks for its pull
164
+ * request first, and replies where the activity came from. No replica argument: run.resume refuses replicas.
165
+ */
166
+ function resumedActionLines(target, n) {
167
+ if (target?.type === "pull_request") {
168
+ return [
169
+ "Address the activity quoted below. If it asks for changes, make them, push to the same branch with",
170
+ "`git push --force-with-lease`, and reply on the pull request saying what you did or why you could",
171
+ "not. Do not open a second pull request -- your push updates the existing one.",
172
+ ];
173
+ }
174
+ const branch = issueBranch(n);
175
+ return [
176
+ "Address the activity quoted below, then reply on the issue saying what you did or why you could not.",
177
+ `If it asks for changes, commit them to \`${branch}\` and publish with \`git push --force-with-lease\` to that`,
178
+ `branch only. Check first for an open pull request from it (\`gh pr list --head ${branch} --state open\`):`,
179
+ "if one exists your push updates it, so do not open a second; only if none exists, run `gh pr create`.",
180
+ ];
181
+ }
182
+
158
183
  function buildResumedPrompt(flow, target, comment, review, instructions) {
159
184
  const n = normalizeNumber(target?.number);
160
185
  const noun = target?.type === "pull_request" ? "pull request" : "issue";
@@ -168,9 +193,7 @@ function buildResumedPrompt(flow, target, comment, review, instructions) {
168
193
  `and \`gh pr diff ${n}\` (or \`gh issue view ${n}\`) before doing anything, then follow the`,
169
194
  `"${flow}" skill from the top.`,
170
195
  "",
171
- "Address the activity quoted below. If it asks for changes, make them, push to the same branch with",
172
- "`git push --force-with-lease`, and reply on the pull request saying what you did or why you could",
173
- "not. Do not open a second pull request -- your push updates the existing one.",
196
+ ...resumedActionLines(target, n),
174
197
  "",
175
198
  // The operator block sits HERE and not after: later text reads as more specific, and the harness's
176
199
  // non-negotiables must be the last thing before the data region rather than something an operator
@@ -24,6 +24,7 @@
24
24
  */
25
25
 
26
26
  import { configError } from "./config.mjs";
27
+ import { PARKED_LABEL } from "./parked.mjs";
27
28
  import { InfraRetry } from "./processor.mjs";
28
29
  import { fetchFailureReason } from "./gitlab-identity.mjs";
29
30
  import { isDeterminateFetchFailure } from "./transient.mjs";
@@ -164,7 +165,29 @@ export function makeGitLabHost({ apiUrl = "https://gitlab.com", fetchFn = fetch
164
165
  return { headRef: mr?.source_branch, headRepo: sameProject ? job?.repo : null };
165
166
  }
166
167
 
167
- return { resolveDefaultBranchSha, isDefaultBranchProtected, postStatusComment, resolvePullRequestHead };
168
+ /**
169
+ * Add or remove the parked marker on an issue (issue #625, parked.mjs). `add_labels` creates a
170
+ * project label the project lacks, and `remove_labels` of one the issue does not carry changes nothing. The caller treats any throw as fail-open:
171
+ * the session store's sidecar, not this label, decides whether a reply resumes anything.
172
+ */
173
+ async function setParkedLabel(projectRef, target, on, token) {
174
+ const id = projectId(projectRef);
175
+ const path = `/projects/${id}/issues/${encodeURIComponent(target?.number)}`;
176
+ let res;
177
+ try {
178
+ res = await fetchFn(`${root}${path}`, {
179
+ method: "PUT",
180
+ headers: { "PRIVATE-TOKEN": token, "content-type": "application/json" },
181
+ body: JSON.stringify(on ? { add_labels: PARKED_LABEL } : { remove_labels: PARKED_LABEL }),
182
+ redirect: "error",
183
+ });
184
+ } catch (err) {
185
+ throw new InfraRetry(`gitlab-host: PUT ${path} failed (${fetchFailureReason(err)})`);
186
+ }
187
+ if (!res.ok) throw new InfraRetry(`gitlab-host: PUT ${path} returned ${res.status}`);
188
+ }
189
+
190
+ return { resolveDefaultBranchSha, isDefaultBranchProtected, postStatusComment, resolvePullRequestHead, setParkedLabel };
168
191
  }
169
192
 
170
193
  /**
@@ -76,6 +76,31 @@ function mrReplicaLines(replica, replicas) {
76
76
  * toward the full envelope, and a bare "address the feedback" over a cold session is an agent with no
77
77
  * idea what it was asked to do. `glab`, never `gh`: this envelope's whole reason for existing.
78
78
  */
79
+ /**
80
+ * What a resumed run is told to do with the activity (issue #624). On a merge request the branch and the pull
81
+ * request exist, so the words are the ones this envelope always had. On an issue neither is certain: the
82
+ * previous run may have opened nothing (it concluded there was nothing to change, or it stopped to ask), and
83
+ * "push to the same branch ... do not open a second" would leave the work on a branch nobody reviews while the
84
+ * human who asked waits on the issue for a reply. So the issue form names the issue branch, checks for its pull
85
+ * request first, and replies where the activity came from. No replica argument: run.resume refuses replicas.
86
+ */
87
+ function resumedActionLines(target, n) {
88
+ if (target?.type === "pull_request") {
89
+ return [
90
+ "Address the activity quoted below. If it asks for changes, make them, push to the same branch with",
91
+ "`git push --force-with-lease`, and reply on the merge request saying what you did or why you could",
92
+ "not. Do not open a second merge request -- your push updates the existing one.",
93
+ ];
94
+ }
95
+ const branch = issueBranch(n);
96
+ return [
97
+ "Address the activity quoted below, then reply on the issue saying what you did or why you could not.",
98
+ `If it asks for changes, commit them to \`${branch}\` and publish with \`git push --force-with-lease\` to that`,
99
+ `branch only. Check first for an open merge request from it (\`glab mr list --source-branch ${branch}\`):`,
100
+ "if one exists your push updates it, so do not open a second; only if none exists, run `glab mr create`.",
101
+ ];
102
+ }
103
+
79
104
  function buildResumedPrompt(flow, target, comment, instructions) {
80
105
  const n = normalizeNumber(target?.number);
81
106
  const noun = target?.type === "pull_request" ? "merge request" : "issue";
@@ -89,9 +114,7 @@ function buildResumedPrompt(flow, target, comment, instructions) {
89
114
  `and \`glab mr diff ${n}\` (or \`glab issue view ${n}\`) before doing anything, then follow the`,
90
115
  `"${flow}" skill from the top.`,
91
116
  "",
92
- "Address the activity quoted below. If it asks for changes, make them, push to the same branch with",
93
- "`git push --force-with-lease`, and reply on the merge request saying what you did or why you could",
94
- "not. Do not open a second merge request -- your push updates the existing one.",
117
+ ...resumedActionLines(target, n),
95
118
  "",
96
119
  // Above the never-merge paragraph, so the harness has the last word before the data region.
97
120
  ...(instructionBlock(instructions) ? [instructionBlock(instructions), ""] : []),
package/src/host-pi.mjs CHANGED
@@ -72,11 +72,14 @@ export const PINNED_PI_NEEDLES = {
72
72
  "const path = `${BUILTIN_PATH_PREFIX}${name}`;",
73
73
  ],
74
74
  "dist/core/source-info.js": ['export const BUILTIN_PATH_PREFIX = "builtin:";'],
75
- "dist/core/settings-manager.d.ts": [
76
- "export type PackageSource = string | {",
77
- " autoload?: boolean;",
78
- " packages?: PackageSource[];",
79
- " extensions?: string[];",
75
+ // The settings shape parseHostSettings reads. Since pi 1.2.0 it is a TypeBox schema in settings-schema (the same
76
+ // union: a string, or an object with `source` and an optional `autoload`), and pi still loads the file without
77
+ // validating it, which is why the parse here stays lenient.
78
+ "dist/core/settings-schema.d.ts": [
79
+ "declare const PackageSourceSchema: Type.TUnion<[Type.TString, Type.TObject<{",
80
+ " autoload: Type.TOptional<Type.TBoolean>;",
81
+ " packages: Type.TOptional<Type.TArray<Type.TUnion<[Type.TString, Type.TObject<{",
82
+ " extensions: Type.TOptional<Type.TArray<Type.TString>>;",
80
83
  ],
81
84
  };
82
85
 
@@ -1,5 +1,6 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { imageRefProblem } from "./image-ref.mjs";
3
+ import { FORGE_MCP_SERVER_NAME } from "./mcp-config.mjs";
3
4
 
4
5
  /**
5
6
  * Which image a job runs in, and whether it is on this host.
@@ -44,7 +45,9 @@ export function resolveJobImage(job, defaultImage) {
44
45
  * { forgeUnsupported } -- present, but declares it cannot serve this job's forge => POLICY, refuse
45
46
  * { <gate.result>, declared } -- present, but does not declare a capability this job needs, one key per
46
47
  * CAPABILITY_GATES row (replicaUnsupported, commandUnsupported,
47
- * excludeToolsUnsupported, modelPolicyUnsupported, costCapUnsupported) => POLICY
48
+ * excludeToolsUnsupported, modelPolicyUnsupported, costCapUnsupported, mcpUnsupported,
49
+ * forgeMcpUnsupported)
50
+ * => POLICY
48
51
  *
49
52
  * A non-zero `docker image inspect` is AMBIGUOUS -- an absent image and an unreachable daemon both exit 1 --
50
53
  * so the failure path disambiguates POSITIVELY with `docker info` rather than by matching docker's stderr.
@@ -191,6 +194,37 @@ export const CAPABILITY_GATES = Object.freeze([
191
194
  event: "refused_image_cost_cap_unsupported",
192
195
  comment: (image, declared) => `Refused: the job image "${image}" does not declare cost-cap support (\`dev.pi-dispatch.capabilities\` ${declared}), so its runner would ignore the per-job dollar cap and run this job with no cap at all. Rebuild the image from a version that has this feature. Not run.`,
193
196
  }),
197
+ // REQ-MCP-OPERATOR-SERVERS: a runner that predates run.mcp reads no /job/mcp.json, so the job would run without
198
+ // the servers its flow was written against and record a clean exit while the file reads as though it named them.
199
+ Object.freeze({
200
+ token: "mcp",
201
+ needed: (job) => Array.isArray(job?.mcp) && job.mcp.length > 0,
202
+ result: "mcpUnsupported",
203
+ reason: "job-image-mcp-unsupported",
204
+ event: "refused_image_mcp_unsupported",
205
+ comment: (image, declared) => `Refused: the job image "${image}" does not declare MCP support (\`dev.pi-dispatch.capabilities\` ${declared}), so its runner would not connect the servers \`run.mcp\` names. Rebuild the image from a version that has this feature. Not run.`,
206
+ }),
207
+ // DES-FORGE-MCP: `forge` in run.mcp starts a server the IMAGE carries (github-mcp-server, mcp-gitlab), not one the
208
+ // overlay names. An image with `mcp` and without them would pass every gate, reserve the budget, and refuse at the
209
+ // container's start as mcp-command-missing; this row says so before anything is spent.
210
+ Object.freeze({
211
+ token: "forgeMcp",
212
+ needed: (job) => Array.isArray(job?.mcp) && job.mcp.includes(FORGE_MCP_SERVER_NAME),
213
+ result: "forgeMcpUnsupported",
214
+ reason: "job-image-forge-mcp-unsupported",
215
+ event: "refused_image_forge_mcp_unsupported",
216
+ comment: (image, declared) => `Refused: the job image "${image}" does not declare the forge's own MCP server (\`dev.pi-dispatch.capabilities\` ${declared}), so it does not carry the server \`run.mcp\` names as \`forge\`. Rebuild the image from a version that has this feature. Not run.`,
217
+ }),
218
+ // Issue #625: a runner that predates run.awaitInput reads no PI_AWAIT_INPUT, so the agent is never offered the tool a
219
+ // parked run ends on: it guesses or gives up, and the run ends without the question the trigger was armed for.
220
+ Object.freeze({
221
+ token: "awaitInput",
222
+ needed: (job) => job?.awaitInput === true,
223
+ result: "awaitInputUnsupported",
224
+ reason: "job-image-await-input-unsupported",
225
+ event: "refused_image_await_input_unsupported",
226
+ comment: (image, declared) => `Refused: the job image "${image}" does not declare support for stopping to wait for an answer (\`dev.pi-dispatch.capabilities\` ${declared}), so its runner would ignore \`run.awaitInput\`. Rebuild the image from a version that has this feature. Not run.`,
227
+ }),
194
228
  ]);
195
229
 
196
230
  /**
package/src/import-pi.mjs CHANGED
@@ -7,7 +7,8 @@
7
7
  * `auth.json` / env and reaches the container through the env allowlist, never a mounted file. So this
8
8
  * command copies only the safe subset and REFUSES a `models.json` that embeds a literal key.
9
9
  *
10
- * Copied: models.json (definitions only, sanitized), skills/, APPEND_SYSTEM.md, and extensions/ (verbatim;
10
+ * Copied: models.json (definitions only, sanitized), skills/, APPEND_SYSTEM.md, mcp.json (rewritten to the
11
+ * servers a job may connect, references only; `--no-mcp` leaves it out), and extensions/ (verbatim;
11
12
  * the admin extension is hard-blocked, and one the operator disabled in `pi config` is left behind).
12
13
  * Extensions come along BY DEFAULT -- staging is the vetting step, and an overlay missing the
13
14
  * operator's own extensions is not the setup they asked for -- with `--no-extensions` as the escape
@@ -19,17 +20,19 @@
19
20
  * setup and staged at the exact version their host runs (issue #102); `pi-packages.json` becomes
20
21
  * the override-and-addition layer rather than the only road in, and `--no-host-packages` restores
21
22
  * the declared-only behaviour exactly.
22
- * Never: auth.json, settings.json, sessions/, themes/, prompts/, tools/. Discovery READS settings.json to
23
- * learn what pi has installed and what the operator turned off; reading is not copying, and no part
24
- * of that file reaches the overlay.
23
+ * Never: auth.json, settings.json, sessions/, themes/, prompts/, tools/, mcp-auth.json (pi's MCP sign-ins).
24
+ * Discovery READS settings.json to learn what pi has installed and what the operator turned off;
25
+ * reading is not copying, and no part of that file reaches the overlay.
25
26
  */
26
- import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, lstatSync, statSync, copyFileSync, renameSync, rmSync } from "node:fs";
27
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, lstatSync, statSync, copyFileSync, renameSync, rmSync, unlinkSync } from "node:fs";
27
28
  import { execFile } from "node:child_process";
29
+ import { randomBytes } from "node:crypto";
28
30
  import { join, resolve } from "node:path";
29
31
  import { promisify } from "node:util";
30
32
  import { FROM_HOST, PACKAGES_SUBDIR, RESOURCE_DIRS, STAGE_MANIFEST, mergeHostPackages, parsePackagesFile } from "./packages.mjs";
31
33
  import { agentDirFrom, readHostPi } from "./host-pi.mjs";
32
34
  import { ENTRY_NAME_RE, copyDirContents, copySkillTree } from "./copy-tree.mjs";
35
+ import { MCP_CONFIG_FILE_NAME, MCP_CONFIG_MAX_BYTES, mcpServerWhere, overlayMcpText, parseOverlayMcp } from "./mcp-config.mjs";
33
36
 
34
37
  /**
35
38
  * A valid skill/extension entry name. Defined in copy-tree.mjs, which is the module that enforces it,
@@ -46,7 +49,7 @@ export const ADMIN_RE = /pi-dispatch|dispatch-admin/i;
46
49
  * silently means "third-party code you did not expect runs in every job container". A typo must not be able
47
50
  * to widen what loads.
48
51
  */
49
- const BOOL_FLAGS = new Set(["--no-extensions", "--with-extensions", "--with-packages", "--host-packages", "--no-host-packages"]);
52
+ const BOOL_FLAGS = new Set(["--no-extensions", "--with-extensions", "--with-packages", "--host-packages", "--no-host-packages", "--no-mcp"]);
50
53
  const VALUE_FLAGS = new Set(["--from", "--to", "--packages-file"]);
51
54
 
52
55
  const execFileAsync = promisify(execFile);
@@ -98,7 +101,7 @@ export async function runImportPi(argv = [], deps = {}) {
98
101
  out = (s) => process.stdout.write(s),
99
102
  // lstatSync rides along for the shared copier, whose symlink guard is the whole point of it: the
100
103
  // walker this dir's copies used to go through guarded with statSync, which FOLLOWS links.
101
- fs = { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, lstatSync, statSync, copyFileSync, renameSync, rmSync },
104
+ fs = { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, lstatSync, statSync, copyFileSync, renameSync, rmSync, unlinkSync },
102
105
  exec = defaultExec,
103
106
  // Injected like `fs`/`exec`/`out` so the win32 npm branch below is reachable from a test on any host;
104
107
  // it is the branch that was dead on arrival precisely because nothing could exercise it here.
@@ -181,6 +184,13 @@ export async function runImportPi(argv = [], deps = {}) {
181
184
  results.push(["APPEND_SYSTEM.md", "global persona (layers under each repo's persona)"]);
182
185
  }
183
186
 
187
+ // mcp.json -- the MCP servers jobs may connect (REQ-MCP-OPERATOR-SERVERS). REWRITTEN, never copied: only the servers
188
+ // a job may connect are written, each in pi's shape with every header and env value a reference, so a host entry
189
+ // with a literal value, an OAuth sign-in or a key this deployment does not take never reaches the overlay. Each one
190
+ // left behind is named with why. The overlay's file follows the host's (`stageMcp`), since a job would otherwise
191
+ // keep connecting servers the operator no longer offers.
192
+ results.push(...stageMcp({ fs, from, to, skip: argv.includes("--no-mcp") }));
193
+
184
194
  // extensions/ -- the sharp edge, and copied BY DEFAULT: the operator staged this overlay to be their own
185
195
  // setup, and one they have to arm twice more is not that. The admin extension stays hard-blocked (it can
186
196
  // enqueue paid jobs from inside a container: a recursion vector, not part of this relaxation).
@@ -268,11 +278,106 @@ export async function runImportPi(argv = [], deps = {}) {
268
278
  // A note-less row is a list item under the row above it (the extension names), so it prints bare rather
269
279
  // than padded out to a column that has nothing to hold.
270
280
  for (const [name, note] of results) out(note ? ` ${name.padEnd(18)} ${note}\n` : ` ${name}\n`);
271
- out(`\n (auth.json, settings.json, sessions/ are never copied — your credential stays in env/auth.json.)\n`);
281
+ out(`\n (auth.json, settings.json, sessions/ and mcp-auth.json are never copied: your credential stays in env/auth.json.)\n`);
272
282
  out(nextSteps(to, withExtensions, withPackages, withHostPackages && hostPi.packages.length > 0));
273
283
  return 0;
274
284
  }
275
285
 
286
+ /**
287
+ * Stage the host's `mcp.json` into the overlay, rewritten to the servers a job may connect. Returns the result rows.
288
+ * The overlay's file follows the host's: it is replaced by each import, and removed when the host has none, when
289
+ * `--no-mcp` is passed, or when the host offers no server a job may connect, so a hand-written overlay file is
290
+ * replaced too (docs/mcp.md says so). One exception keeps what is there: a host file that cannot be read whole (not
291
+ * JSON, a duplicate key, too large) changes nothing, since the operator is mid-edit and the last good list stands.
292
+ * The overlay's file is never followed if it is a link: a new file is renamed over it, so a link back to the host's
293
+ * own file cannot make this command rewrite it.
294
+ */
295
+ function stageMcp({ fs, from, to, skip }) {
296
+ const src = join(from, MCP_CONFIG_FILE_NAME);
297
+ const dst = join(to, MCP_CONFIG_FILE_NAME);
298
+ const rows = [];
299
+ const existing = () => {
300
+ try {
301
+ return fs.lstatSync(dst);
302
+ } catch {
303
+ return null;
304
+ }
305
+ };
306
+ const removeOld = (why) => {
307
+ const entry = existing();
308
+ if (entry === null) return true;
309
+ if (entry.isDirectory()) {
310
+ rows.push([`${MCP_CONFIG_FILE_NAME} (overlay)`, `WARN: ${dst} is a folder; remove it by hand`]);
311
+ return false;
312
+ }
313
+ // unlink, not rmSync: `force` skips a dangling link, which the write would then follow.
314
+ fs.unlinkSync(dst);
315
+ rows.push([`${MCP_CONFIG_FILE_NAME} (overlay)`, `removed (${why})`]);
316
+ return true;
317
+ };
318
+ if (skip) {
319
+ if (fs.existsSync(src)) rows.push([MCP_CONFIG_FILE_NAME, "skipped -- --no-mcp was passed (jobs are offered no MCP server)"]);
320
+ removeOld("--no-mcp");
321
+ return rows;
322
+ }
323
+ if (!fs.existsSync(src)) {
324
+ removeOld(`no ${MCP_CONFIG_FILE_NAME} in your pi setup`);
325
+ return rows;
326
+ }
327
+ let parsed;
328
+ try {
329
+ // statSync follows a link, so a link to a regular file is read and one to a pipe or device is not: a FIFO would
330
+ // block this read forever, and /dev/zero would never end.
331
+ const stat = fs.statSync(src);
332
+ if (!stat.isFile()) throw Object.assign(new Error(`${src}: is not a regular file`), { mcpConfig: true });
333
+ if (stat.size > MCP_CONFIG_MAX_BYTES) throw Object.assign(new Error(`${src}: is larger than ${MCP_CONFIG_MAX_BYTES} bytes`), { mcpConfig: true });
334
+ parsed = parseOverlayMcp(String(fs.readFileSync(src, "utf8")), src);
335
+ } catch (error) {
336
+ const kept = existing() === null ? "" : "; the overlay's file is kept as it was";
337
+ rows.push([MCP_CONFIG_FILE_NAME, `SKIPPED -- ${error?.mcpConfig ? error.message : `${src}: cannot be read (${error?.code ?? "unreadable"})`}${kept}`]);
338
+ return rows;
339
+ }
340
+ const names = Object.keys(parsed.servers);
341
+ if (names.length > 0) {
342
+ // Replaced, never written through: a link or an earlier file goes first, and a folder stops the write.
343
+ const entry = existing();
344
+ if (entry?.isDirectory()) {
345
+ rows.push([MCP_CONFIG_FILE_NAME, `WARN: ${dst} is a folder; remove it by hand, then re-run`]);
346
+ } else {
347
+ // A new file renamed over the old: the rename replaces a link rather than its target, and a write that fails
348
+ // leaves the last good list in place.
349
+ // A name no earlier run left, opened only if it does not exist (`wx`), so it is never a link to follow; removed
350
+ // again if the write or the rename fails, since every job mounts this folder.
351
+ const tmp = `${dst}.${randomBytes(6).toString("hex")}.tmp`;
352
+ try {
353
+ fs.writeFileSync(tmp, overlayMcpText(parsed), { flag: "wx", mode: 0o644 });
354
+ fs.renameSync(tmp, dst);
355
+ } catch (error) {
356
+ try {
357
+ fs.unlinkSync(tmp);
358
+ } catch {
359
+ // Never written.
360
+ }
361
+ throw error;
362
+ }
363
+ rows.push([MCP_CONFIG_FILE_NAME, `${names.length} MCP server${names.length === 1 ? "" : "s"} offered to jobs`]);
364
+ for (const name of names) {
365
+ const server = parsed.servers[name];
366
+ const runner = server.packageRunner ? ", fetches its server from a registry at start" : "";
367
+ rows.push([` - ${name} (${server.transport}, ${mcpServerWhere(server)}${runner})`, ""]);
368
+ }
369
+ }
370
+ } else {
371
+ rows.push([MCP_CONFIG_FILE_NAME, "no MCP server a job may connect"]);
372
+ removeOld("nothing to offer");
373
+ }
374
+ if (parsed.refused.length > 0) {
375
+ rows.push([`${MCP_CONFIG_FILE_NAME} (left)`, `${parsed.refused.length} server${parsed.refused.length === 1 ? "" : "s"} not staged`]);
376
+ for (const { name, problem } of parsed.refused) rows.push([` - ${JSON.stringify(name)}: ${problem}`, ""]);
377
+ }
378
+ return rows;
379
+ }
380
+
276
381
  /**
277
382
  * Copy `<src>/<name>/**` for each valid, non-symlink child dir. Returns the count copied.
278
383
  *