@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.
- package/.env.example +6 -4
- package/package.json +4 -2
- package/src/azure-host.mjs +28 -1
- package/src/azure-prompt.mjs +26 -3
- package/src/config.mjs +2 -2
- package/src/doctor.mjs +185 -13
- package/src/egress-allowlist.mjs +81 -0
- package/src/env-allowlist.mjs +8 -1
- package/src/forgejo-host.mjs +33 -1
- package/src/forgejo-prompt.mjs +26 -3
- package/src/github-host.mjs +21 -1
- package/src/github-prompt.mjs +26 -3
- package/src/gitlab-host.mjs +24 -1
- package/src/gitlab-prompt.mjs +26 -3
- package/src/host-pi.mjs +8 -5
- package/src/image-preflight.mjs +35 -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-catalog.mjs +2 -0
- package/src/model-endpoints.mjs +8 -3
- package/src/models-json.mjs +65 -53
- package/src/outbox.mjs +7 -1
- package/src/parked.mjs +18 -0
- package/src/prepare.mjs +12 -4
- package/src/processor.mjs +81 -4
- package/src/provider-steering.mjs +1 -0
- package/src/queue.mjs +10 -2
- package/src/reserved-env.mjs +2 -0
- package/src/run-container.mjs +6 -0
- package/src/run-history.mjs +29 -2
- package/src/schedules.mjs +1 -1
- package/src/session-store.mjs +45 -2
- package/src/start.mjs +59 -1
- package/src/triggers-file.mjs +7 -0
- package/src/triggers.mjs +152 -7
package/src/forgejo-host.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
/**
|
package/src/forgejo-prompt.mjs
CHANGED
|
@@ -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
|
-
|
|
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), ""] : []),
|
package/src/github-host.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
/**
|
package/src/github-prompt.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
package/src/gitlab-host.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
/**
|
package/src/gitlab-prompt.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
"
|
|
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
|
|
package/src/image-preflight.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { imageRefProblem } from "./image-ref.mjs";
|
|
3
|
+
import { FORGE_MCP_SERVER_NAME } from "./mcp-config.mjs";
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Which image a job runs in, and whether it is on this host.
|
|
@@ -44,7 +45,9 @@ export function resolveJobImage(job, defaultImage) {
|
|
|
44
45
|
* { forgeUnsupported } -- present, but declares it cannot serve this job's forge => POLICY, refuse
|
|
45
46
|
* { <gate.result>, declared } -- present, but does not declare a capability this job needs, one key per
|
|
46
47
|
* CAPABILITY_GATES row (replicaUnsupported, commandUnsupported,
|
|
47
|
-
* excludeToolsUnsupported, modelPolicyUnsupported, costCapUnsupported
|
|
48
|
+
* excludeToolsUnsupported, modelPolicyUnsupported, costCapUnsupported, mcpUnsupported,
|
|
49
|
+
* forgeMcpUnsupported)
|
|
50
|
+
* => POLICY
|
|
48
51
|
*
|
|
49
52
|
* A non-zero `docker image inspect` is AMBIGUOUS -- an absent image and an unreachable daemon both exit 1 --
|
|
50
53
|
* so the failure path disambiguates POSITIVELY with `docker info` rather than by matching docker's stderr.
|
|
@@ -191,6 +194,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,
|
|
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
|
|
23
|
-
* learn what pi has installed and what the operator turned off;
|
|
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
|
|
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
|
*
|