@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/.env.example
CHANGED
|
@@ -33,15 +33,16 @@ ANTHROPIC_API_KEY=
|
|
|
33
33
|
|
|
34
34
|
# --- Which provider/model to run by default (override per job with --provider / --model) ---
|
|
35
35
|
PI_PROVIDER=anthropic
|
|
36
|
-
#
|
|
37
|
-
|
|
36
|
+
# Name an exact model version (claude-sonnet-5-5, or a dated id such as claude-sonnet-4-5-20250929); an alias
|
|
37
|
+
# that moves to a newer model can change cost.
|
|
38
|
+
PI_MODEL=claude-sonnet-5-5
|
|
38
39
|
# optional: the only models a job may call, comma-separated provider/model (at most 16). A trigger's own
|
|
39
40
|
# run.models replaces this list for its jobs. The default model must be on it, or every job that uses the
|
|
40
41
|
# default is refused before it spends. Env only: the settings overlay cannot set or widen it.
|
|
41
42
|
# Unset = any model. Needs a job image that declares the modelPolicy capability.
|
|
42
43
|
# No spaces, not even after a comma: a shell that sources this file cuts the value at the first space
|
|
43
44
|
# and leaves it unset, so jobs would run with no list (the worker refuses a spaced value at boot).
|
|
44
|
-
# PI_ALLOWED_MODELS=anthropic/claude-sonnet-
|
|
45
|
+
# PI_ALLOWED_MODELS=anthropic/claude-sonnet-5-5,anthropic/claude-haiku-5-5
|
|
45
46
|
|
|
46
47
|
# --- Spend + concurrency guards (money bounds; all have conservative defaults) ---
|
|
47
48
|
# per-job turn cap -- pi has none of its own, so the harness imposes one
|
|
@@ -433,7 +434,8 @@ GITHUB_APP_PRIVATE_KEY=
|
|
|
433
434
|
# Optional. Set these only to service GitLab projects; leaving GITLAB_TOKEN unset means no /gitlab
|
|
434
435
|
# endpoint exists at all, rather than one that answers 401. See docs/gitlab.md.
|
|
435
436
|
#
|
|
436
|
-
# A PROJECT access token with the `api` scope and the Developer role
|
|
437
|
+
# A PROJECT access token with the `api` scope and the Developer role, not Maintainer (docs/gitlab.md:
|
|
438
|
+
# a Maintainer token lets the job unprotect a branch or change who may merge). `api` is the narrowest
|
|
437
439
|
# scope that can post a note -- GitLab offers no contents-vs-issues split -- so scope it to one project
|
|
438
440
|
# and rotate it (CONST-TOKEN-SCOPED-PER-JOB). A GROUP token reaches every project in the group.
|
|
439
441
|
GITLAB_TOKEN=
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@edgehero/pi-dispatch",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.3.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "The pi-dispatch worker and CLI: runs the pi coding agent as a self-hosted service, one locked down Docker or Podman container per job, with spend caps checked before anything is spent, plus init, up, doctor and service install.",
|
|
6
6
|
"keywords": [
|
|
@@ -55,6 +55,7 @@
|
|
|
55
55
|
"./git-dirty": "./src/git-dirty.mjs",
|
|
56
56
|
"./queue": "./src/queue.mjs",
|
|
57
57
|
"./capabilities": "./src/capabilities.mjs",
|
|
58
|
+
"./parked": "./src/parked.mjs",
|
|
58
59
|
"./connection": "./src/connection.mjs",
|
|
59
60
|
"./valkey-auth": "./src/valkey-auth.mjs",
|
|
60
61
|
"./job-id": "./src/job-id.mjs",
|
|
@@ -74,6 +75,7 @@
|
|
|
74
75
|
"./backend-conformance": "./src/backend-conformance.mjs",
|
|
75
76
|
"./live-probes": "./src/live-probes.mjs",
|
|
76
77
|
"./triggers": "./src/triggers.mjs",
|
|
78
|
+
"./mcp-config": "./src/mcp-config.mjs",
|
|
77
79
|
"./triggers-file": "./src/triggers-file.mjs",
|
|
78
80
|
"./packages": "./src/packages.mjs",
|
|
79
81
|
"./pause-windows": "./src/pause-windows.mjs",
|
|
@@ -113,7 +115,7 @@
|
|
|
113
115
|
"start": "node src/cli.mjs worker"
|
|
114
116
|
},
|
|
115
117
|
"dependencies": {
|
|
116
|
-
"@earendil-works/pi-ai": "1.
|
|
118
|
+
"@earendil-works/pi-ai": "1.2.0",
|
|
117
119
|
"@octokit/auth-app": "8.2.0",
|
|
118
120
|
"@octokit/rest": "22.0.1",
|
|
119
121
|
"bullmq": "5.80.4",
|
package/src/azure-host.mjs
CHANGED
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
24
|
import { configError } from "./config.mjs";
|
|
25
|
+
import { PARKED_LABEL } from "./parked.mjs";
|
|
25
26
|
import { InfraRetry } from "./processor.mjs";
|
|
26
27
|
import { fetchFailureReason } from "./gitlab-identity.mjs";
|
|
27
28
|
import { isDeterminateFetchFailure } from "./transient.mjs";
|
|
@@ -201,7 +202,33 @@ export function makeAzureHost({ orgUrl, fetchFn = fetch } = {}) {
|
|
|
201
202
|
return { headRef: stripRefsHeads(pr?.sourceRefName), headRepo: sameRepo ? job?.repo : null };
|
|
202
203
|
}
|
|
203
204
|
|
|
204
|
-
|
|
205
|
+
/**
|
|
206
|
+
* Add or remove the parked marker on a work item, as a TAG (issue #625, parked.mjs): Azure work items have
|
|
207
|
+
* tags, not labels, and the receiver reads `System.Tags` into the same `[{ name }]` shape. A tags field is written
|
|
208
|
+
* whole, so this reads it, edits the one tag and writes the result; Azure creates a tag the project lacks. The caller treats any throw as fail-open:
|
|
209
|
+
* the session store's sidecar, not this label, decides whether a reply resumes anything.
|
|
210
|
+
*/
|
|
211
|
+
async function setParkedLabel(ref, target, on, token) {
|
|
212
|
+
const { project } = scopeOf(ref);
|
|
213
|
+
const path = `/${encodeURIComponent(project)}/_apis/wit/workitems/${encodeURIComponent(target?.number)}`;
|
|
214
|
+
const item = await get(`${path}?fields=System.Tags&api-version=${API_VERSION}`, token);
|
|
215
|
+
const tags = String(item?.fields?.["System.Tags"] ?? "").split(";").map((tag) => tag.trim()).filter((tag) => tag !== "" && tag !== PARKED_LABEL);
|
|
216
|
+
const value = (on ? [...tags, PARKED_LABEL] : tags).join("; ");
|
|
217
|
+
let res;
|
|
218
|
+
try {
|
|
219
|
+
res = await fetchFn(`${root}${path}?api-version=${API_VERSION}`, {
|
|
220
|
+
method: "PATCH",
|
|
221
|
+
headers: { Authorization: authHeader(token), "content-type": "application/json-patch+json" },
|
|
222
|
+
body: JSON.stringify([{ op: "add", path: "/fields/System.Tags", value }]),
|
|
223
|
+
redirect: "error",
|
|
224
|
+
});
|
|
225
|
+
} catch (err) {
|
|
226
|
+
throw new InfraRetry(`azure-host: PATCH ${path} failed (${fetchFailureReason(err)})`);
|
|
227
|
+
}
|
|
228
|
+
if (!res.ok) throw new InfraRetry(`azure-host: PATCH ${path} returned ${res.status}`);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return { resolveDefaultBranchSha, isDefaultBranchProtected, postStatusComment, resolvePullRequestHead, setParkedLabel };
|
|
205
232
|
}
|
|
206
233
|
|
|
207
234
|
/**
|
package/src/azure-prompt.mjs
CHANGED
|
@@ -68,6 +68,31 @@ function prReplicaLines(replica, replicas) {
|
|
|
68
68
|
];
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
/**
|
|
72
|
+
* What a resumed run is told to do with the activity (issue #624). On a pull request the branch and the pull
|
|
73
|
+
* request exist, so the words are the ones this envelope always had. On a work item neither is certain: the
|
|
74
|
+
* previous run may have opened nothing (it concluded there was nothing to change, or it stopped to ask), and
|
|
75
|
+
* "push to the same branch ... do not open a second" would leave the work on a branch nobody reviews while the
|
|
76
|
+
* human who asked waits on the work item for a reply. So the issue form names the issue branch, checks for its pull
|
|
77
|
+
* request first, and replies where the activity came from. No replica argument: run.resume refuses replicas.
|
|
78
|
+
*/
|
|
79
|
+
function resumedActionLines(target, n) {
|
|
80
|
+
if (target?.type === "pull_request") {
|
|
81
|
+
return [
|
|
82
|
+
"Address the activity quoted below. If it asks for changes, make them, push to the same branch with",
|
|
83
|
+
"`git push --force-with-lease`, and reply on the pull request saying what you did or why you could",
|
|
84
|
+
"not. Do not open a second pull request -- your push updates the existing one.",
|
|
85
|
+
];
|
|
86
|
+
}
|
|
87
|
+
const branch = issueBranch(n);
|
|
88
|
+
return [
|
|
89
|
+
"Address the activity quoted below, then reply on the work item saying what you did or why you could not.",
|
|
90
|
+
`If it asks for changes, commit them to \`${branch}\` and publish with \`git push --force-with-lease\` to that`,
|
|
91
|
+
`branch only. Check first for an active pull request from it (\`az repos pr list --source-branch ${branch} --status active\`):`,
|
|
92
|
+
`if one exists your push updates it, so do not create another; only if none exists, run \`az repos pr create --source-branch ${branch}\`.`,
|
|
93
|
+
];
|
|
94
|
+
}
|
|
95
|
+
|
|
71
96
|
function buildResumedPrompt(flow, target, comment, instructions) {
|
|
72
97
|
const n = normalizeNumber(target?.number);
|
|
73
98
|
const noun = target?.type === "pull_request" ? "pull request" : "work item";
|
|
@@ -81,9 +106,7 @@ function buildResumedPrompt(flow, target, comment, instructions) {
|
|
|
81
106
|
`\`az repos pr show --id ${n}\` (or \`az boards work-item show --id ${n}\`) before doing anything,`,
|
|
82
107
|
`then follow the "${flow}" skill from the top.`,
|
|
83
108
|
"",
|
|
84
|
-
|
|
85
|
-
"`git push --force-with-lease`, and reply on the pull request saying what you did or why you could",
|
|
86
|
-
"not. Do not open a second pull request -- your push updates the existing one.",
|
|
109
|
+
...resumedActionLines(target, n),
|
|
87
110
|
"",
|
|
88
111
|
// Above the never-merge paragraph, so the harness has the last word before the data region.
|
|
89
112
|
...(instructionBlock(instructions) ? [instructionBlock(instructions), ""] : []),
|
package/src/config.mjs
CHANGED
|
@@ -31,7 +31,7 @@ import { DOLLAR_ENV_NAMES, DOLLAR_WINDOW_KEYS, checkDollarInvariant, optionalUsd
|
|
|
31
31
|
*/
|
|
32
32
|
export const DEFAULT_VALKEY_URL = "redis://127.0.0.1:6379";
|
|
33
33
|
/** The model a job runs on when nothing names one (`PI_MODEL` unset): one copy, for `loadConfig` and doctor. */
|
|
34
|
-
export const DEFAULT_MODEL = "claude-sonnet-
|
|
34
|
+
export const DEFAULT_MODEL = "claude-sonnet-5-5";
|
|
35
35
|
/** The provider a job runs on when nothing names one (`PI_PROVIDER` unset). */
|
|
36
36
|
export const DEFAULT_PROVIDER = "anthropic";
|
|
37
37
|
|
|
@@ -394,7 +394,7 @@ export function globalExtensionsEnabled(env) {
|
|
|
394
394
|
* spend caps above, the per-job token scoping, or the admin-extension recursion block.
|
|
395
395
|
*/
|
|
396
396
|
export function loadConfig(env = process.env, { fileExists = existsSync } = {}) {
|
|
397
|
-
const model = env.PI_MODEL ?? DEFAULT_MODEL; //
|
|
397
|
+
const model = env.PI_MODEL ?? DEFAULT_MODEL; // an exact model version, not a moving alias; deterministic per CONST-PI-VERSION-PINNED
|
|
398
398
|
// #227. Hoisted above the object because `defaultBackend` INDEXES `backends`, and a property cannot read
|
|
399
399
|
// a sibling of the literal it is in. (Calling the parser twice would be harmless -- `egressEnabled` is
|
|
400
400
|
// called twice a few properties down for the same reason -- so this is about the index, not the throw.)
|
package/src/doctor.mjs
CHANGED
|
@@ -66,12 +66,14 @@ import { isTransientOverlayRead, overlayProviderProblem } from "./model-catalog.
|
|
|
66
66
|
import { EMPTY_USD_FINGERPRINT, usdFingerprint } from "./dollar-fingerprint.mjs";
|
|
67
67
|
import { splitModelEntry } from "./model-ref.mjs";
|
|
68
68
|
import { ignoredOutputCapModels, outputCapView, outputUnboundable } from "./output-cap.mjs";
|
|
69
|
-
import { KEYLESS_API_KEY, KEYLESS_HOW, MODEL_ENDPOINTS_FILE_NAME, MODEL_ENDPOINTS_INCLUDE_NAME, MODEL_ENDPOINT_ID_RE, OVERLAY_LINK_FIX, OVERLAY_NOT_A_FILE_FIX, RENAMED_PROVIDERS, baseUrlTarget, keylessVerdict, loadModelEndpoints, providerRenameHint, readOverlayModels, renderEndpointsInclude, unreportedUsageModels } from "./model-endpoints.mjs";
|
|
69
|
+
import { KEYLESS_API_KEY, KEYLESS_HOW, MODEL_ENDPOINTS_FILE_NAME, MODEL_ENDPOINTS_INCLUDE_NAME, MODEL_ENDPOINT_ID_RE, OVERLAY_LINK_FIX, OVERLAY_NOT_A_FILE_FIX, RENAMED_PROVIDERS, baseUrlTarget, endpointsAt, keylessVerdict, loadModelEndpoints, providerRenameHint, readOverlayModels, renderEndpointsInclude, unreportedUsageModels } from "./model-endpoints.mjs";
|
|
70
70
|
import { declaredEndpointsIn, endpointsDeclaredIn, reloadCommand, rulesFileIncludes, rulesPredateEndpointsLine } from "./egress-cli.mjs";
|
|
71
71
|
import { loadPauseWindows, parseScopeString } from "./pause-windows.mjs";
|
|
72
72
|
import { WAIT_AFTER_MAX_DEFAULT_MS, afterInstantMs, parseWaitProfiles } from "./wait-for.mjs";
|
|
73
73
|
import { isForgeKind } from "./forges.mjs";
|
|
74
74
|
import { findLiteralSecret, ADMIN_RE } from "./import-pi.mjs";
|
|
75
|
+
import { mcpServerWhere, readOverlayMcp } from "./mcp-config.mjs";
|
|
76
|
+
import { allowlistEntryFor, egressRefusalFor, packageRunnerHosts, parseAllowlist } from "./egress-allowlist.mjs";
|
|
75
77
|
import { agentDirFrom, readHostPi } from "./host-pi.mjs";
|
|
76
78
|
import { PACKAGES_SUBDIR, readStagedSkills, readStageManifest } from "./packages.mjs";
|
|
77
79
|
import { copySkillTree } from "./copy-tree.mjs";
|
|
@@ -2728,6 +2730,23 @@ export async function collectChecks(shellVars, seams) {
|
|
|
2728
2730
|
},
|
|
2729
2731
|
},
|
|
2730
2732
|
});
|
|
2733
|
+
// The overlay's MCP servers (REQ-MCP-OPERATOR-SERVERS), through the one parser (mcp-config.mjs).
|
|
2734
|
+
let mcpAllowlist = [];
|
|
2735
|
+
try {
|
|
2736
|
+
mcpAllowlist = parseAllowlist(keylessIo.readFileSync(join(seams.cwd, "egress-allowlist.conf"), "utf8"));
|
|
2737
|
+
} catch {
|
|
2738
|
+
// No allowlist in this folder: every server outside a declared endpoint reads as unreachable, which it is.
|
|
2739
|
+
}
|
|
2740
|
+
checks.push(
|
|
2741
|
+
...overlayMcpChecks({
|
|
2742
|
+
overlay,
|
|
2743
|
+
env,
|
|
2744
|
+
fileExists,
|
|
2745
|
+
read: () => readOverlayMcp(overlay, { readFileSync: seams.readOverlayFile ?? ((p, enc) => readFileSync(p, enc)), ...(seams.lstatOverlayFile ? { lstatSync: seams.lstatOverlayFile } : {}) }),
|
|
2746
|
+
allowlist: mcpAllowlist,
|
|
2747
|
+
endpoints: () => (seams.declaredEndpoints ?? ((a) => declaredEndpointsIn({ ...a, fs: keylessIo })))({ env, cwd: seams.cwd, platform: seams.platform ?? process.platform, valkeyUrl: env.VALKEY_URL ?? DEFAULT_VALKEY_URL }),
|
|
2748
|
+
}),
|
|
2749
|
+
);
|
|
2731
2750
|
const modelsPath = join(overlay, "models.json");
|
|
2732
2751
|
// Through the one reader (`readOverlayModels`, PR #520): an exists test answered false for a file under an
|
|
2733
2752
|
// unreadable directory, so this line passed in silence on a file nobody had read. Absent is a pass (nothing to
|
|
@@ -5904,6 +5923,79 @@ export function forgeUrlEgressChecks(env, forges = []) {
|
|
|
5904
5923
|
return checks;
|
|
5905
5924
|
}
|
|
5906
5925
|
|
|
5926
|
+
/**
|
|
5927
|
+
* The overlay's MCP servers (REQ-MCP-OPERATOR-SERVERS): whether `mcp.json` reads, each server a job may be offered,
|
|
5928
|
+
* each one refused and why, no `mcp-auth.json` beside it, and, with the egress policy on, whether each streamable HTTP
|
|
5929
|
+
* server is one the proxy lets a job reach. A refused server is one no job can use, so each is ✗; an unreachable one
|
|
5930
|
+
* is ⚠, since the policy may be about to change.
|
|
5931
|
+
* `read` throws the reader's errors; `endpoints` is called only when a server needs it.
|
|
5932
|
+
*/
|
|
5933
|
+
export function overlayMcpChecks({ overlay, env, fileExists, read, allowlist = [], endpoints = () => [] }) {
|
|
5934
|
+
const checks = [];
|
|
5935
|
+
const authPath = join(overlay, "mcp-auth.json");
|
|
5936
|
+
if (fileExists(authPath)) {
|
|
5937
|
+
checks.push({
|
|
5938
|
+
ok: false,
|
|
5939
|
+
label: "Overlay holds mcp-auth.json, pi's MCP sign-ins, which no job may hold",
|
|
5940
|
+
fix: "delete mcp-auth.json from the overlay; a job's MCP server takes its credential as a ${NAME} reference from run.secrets: docs/mcp.md",
|
|
5941
|
+
fixAction: {
|
|
5942
|
+
tier: "prompt",
|
|
5943
|
+
describe: `rm ${authPath}`,
|
|
5944
|
+
run: async ({ rm }) => {
|
|
5945
|
+
rm(authPath);
|
|
5946
|
+
return { ok: true };
|
|
5947
|
+
},
|
|
5948
|
+
},
|
|
5949
|
+
});
|
|
5950
|
+
}
|
|
5951
|
+
let parsed;
|
|
5952
|
+
try {
|
|
5953
|
+
parsed = read();
|
|
5954
|
+
} catch (error) {
|
|
5955
|
+
const why = error?.mcpConfig ? error.message : `cannot be read (${error?.code ?? "unreadable"})`;
|
|
5956
|
+
checks.push({ ok: false, label: `Overlay mcp.json: ${why}`, fix: "re-run `pi-dispatch import-pi`, or fix the file: docs/mcp.md. Until it reads, no job can use an MCP server" });
|
|
5957
|
+
return checks;
|
|
5958
|
+
}
|
|
5959
|
+
if (parsed === null) return checks;
|
|
5960
|
+
const names = Object.keys(parsed.servers);
|
|
5961
|
+
checks.push({ ok: true, label: names.length === 0 ? "Overlay mcp.json offers no MCP server" : `Overlay mcp.json offers ${names.length} MCP server${names.length === 1 ? "" : "s"} to triggers that name them (${names.join(", ")})` });
|
|
5962
|
+
for (const { name, problem } of parsed.refused) {
|
|
5963
|
+
checks.push({ ok: false, label: `Overlay mcp.json: server ${quotedShown(name)} is refused: ${problem}`, fix: "fix the entry and re-run `pi-dispatch import-pi`: docs/mcp.md. Until then no job can use it" });
|
|
5964
|
+
}
|
|
5965
|
+
let armed = false;
|
|
5966
|
+
try {
|
|
5967
|
+
armed = egressArmed(env) === true;
|
|
5968
|
+
} catch {
|
|
5969
|
+
// A malformed PI_EGRESS: the .env check reports it.
|
|
5970
|
+
}
|
|
5971
|
+
let declared = null;
|
|
5972
|
+
for (const name of names) {
|
|
5973
|
+
const server = parsed.servers[name];
|
|
5974
|
+
if (server.packageRunner) {
|
|
5975
|
+
const missing = packageRunnerHosts(server.command).filter((host) => allowlistEntryFor(allowlist, host) === null);
|
|
5976
|
+
const listed = missing.length === 0;
|
|
5977
|
+
const registry = missing.join(" and ");
|
|
5978
|
+
checks.push({
|
|
5979
|
+
ok: false,
|
|
5980
|
+
warn: true,
|
|
5981
|
+
label: `MCP server ${name} runs ${mcpServerWhere(server)}, which fetches the server from a package registry at the start of every job that uses it`,
|
|
5982
|
+
fix: armed && !listed ? `install the server in the job image instead, or allowlist ${registry} in egress-allowlist.conf: docs/mcp.md` : "install the server in the job image instead, so every job runs the version you chose: docs/mcp.md",
|
|
5983
|
+
});
|
|
5984
|
+
}
|
|
5985
|
+
if (!armed || server.transport !== "http" || server.enabled === false) continue;
|
|
5986
|
+
declared ??= endpoints();
|
|
5987
|
+
const refusal = egressRefusalFor(server.url, { allowlist, endpoints: declared });
|
|
5988
|
+
if (refusal === null) continue;
|
|
5989
|
+
checks.push({
|
|
5990
|
+
ok: false,
|
|
5991
|
+
warn: true,
|
|
5992
|
+
label: `MCP server ${name} (${mcpServerWhere(server)}) is not reachable with the egress policy on: ${refusal}`,
|
|
5993
|
+
fix: "use an https:// url and allowlist its host for 443, or declare its host and port in model-endpoints.json: docs/egress.md. Until then no job can reach it",
|
|
5994
|
+
});
|
|
5995
|
+
}
|
|
5996
|
+
return checks;
|
|
5997
|
+
}
|
|
5998
|
+
|
|
5907
5999
|
/** The canary's three probes. ONE list: the loop names its containers from it and the sweep matches on it. */
|
|
5908
6000
|
export const CANARY_PROBE_SLUGS = Object.freeze(["provider", "unlisted", "plainhttp"]);
|
|
5909
6001
|
|
|
@@ -6153,6 +6245,7 @@ async function egressChecks(env, seams, { dockerCode, imageCode, jobImage, endpo
|
|
|
6153
6245
|
const { declaredEndpoints = ({ env: e, cwd, platform }) => declaredEndpointsIn({ env: e, cwd, fs: { readFileSync, existsSync }, platform }), hostAddresses = () => lanIPv4Addresses(networkInterfaces()) } = seams;
|
|
6154
6246
|
const declared = declaredEndpoints({ env, cwd: seams.cwd, platform: seams.platform ?? process.platform });
|
|
6155
6247
|
const endpoints = declared.length > 0 && !(!custom && inFolder && !needs.rulesInclude) ? declared : [];
|
|
6248
|
+
const mcpOnly = endpoints.length > 0 ? mcpOnlyEndpoints({ endpoints, ...overlayEndpointUsers(env, seams) }) : new Map();
|
|
6156
6249
|
if (endpoints.length > 0) {
|
|
6157
6250
|
const runtime = routeRuntimeFromFacts(seams.readFactsOnce ? await seams.readFactsOnce() : null, seams.platform ?? process.platform);
|
|
6158
6251
|
const addresses = hostAddresses();
|
|
@@ -6259,7 +6352,7 @@ async function egressChecks(env, seams, { dockerCode, imageCode, jobImage, endpo
|
|
|
6259
6352
|
// script. docker's probes keep `runCmdCapture` (its 30 s bound, stderr merged, SIGTERM at the bound) rather than the
|
|
6260
6353
|
// bounded step runner, so docker's spawns, and what a probe that never launched leaves for the teardown, are exactly
|
|
6261
6354
|
// what they were: pinned in doctor.test.mjs against the output and argv captured before the move.
|
|
6262
|
-
const canary = await runEgressCanary({ run: liveRunVia(spawn), probeRun: (args) => runCmdCapture(spawn, "docker", args), proxy, image: jobImage, pid, gate, endpoints });
|
|
6355
|
+
const canary = await runEgressCanary({ run: liveRunVia(spawn), probeRun: (args) => runCmdCapture(spawn, "docker", args), proxy, image: jobImage, pid, gate, endpoints, mcpOnly });
|
|
6263
6356
|
// Through a stale proxy the canary reads THAT proxy's policy (exec round 3): a failure is the proxy's to fix, never
|
|
6264
6357
|
// this deployment's allowlist, which it may not even mount.
|
|
6265
6358
|
checks.push(...(staleRunning ? canary.checks.map((c) => (c.ok ? c : { ...c, fix: `the canary ran through ${proxy}, which is not this deployment's proxy (above), so this says nothing about egress-allowlist.conf: ${replaceFix}, then re-run doctor` })) : canary.checks));
|
|
@@ -6353,8 +6446,10 @@ const CANARY_NO_WORKSPACE = "/nonexistent/pi-dispatch-egress-canary-mounts-nothi
|
|
|
6353
6446
|
*
|
|
6354
6447
|
* `endpoints` (issue #503) are the declared model endpoints to prove on the same network after the three
|
|
6355
6448
|
* (`runEndpointProbes`), `[]` by default, so the conformance script and a deployment with none run exactly the three.
|
|
6449
|
+
* `mcpOnly` maps the ids of those only an overlay MCP server uses (`mcpOnlyEndpoints`), whose `models` probe proves
|
|
6450
|
+
* the route and is not judged as a model server's answer.
|
|
6356
6451
|
*/
|
|
6357
|
-
export async function runEgressCanary({ run, bin = "docker", proxy, image, pid = process.pid, user = null, probeRun = null, endpoints = [], size = DEFAULT_JOB_SIZE, hostCpus = null, gate = makeDetachGate((args, opts) => run(args, { timeoutMs: opts?.timeoutMs ?? CANARY_STEP_TIMEOUT_MS }), { bin }) }) {
|
|
6452
|
+
export async function runEgressCanary({ run, bin = "docker", proxy, image, pid = process.pid, user = null, probeRun = null, endpoints = [], mcpOnly = new Map(), size = DEFAULT_JOB_SIZE, hostCpus = null, gate = makeDetachGate((args, opts) => run(args, { timeoutMs: opts?.timeoutMs ?? CANARY_STEP_TIMEOUT_MS }), { bin }) }) {
|
|
6358
6453
|
const venue = canaryVenueFor(bin);
|
|
6359
6454
|
const probe = probeRun ?? ((args) => run(args, { timeoutMs: bin === "podman" ? PODMAN_FIRST_START_TIMEOUT_MS : RUN_TIMEOUTS.cmd }));
|
|
6360
6455
|
const checks = [];
|
|
@@ -6509,7 +6604,7 @@ export async function runEgressCanary({ run, bin = "docker", proxy, image, pid =
|
|
|
6509
6604
|
if (staleRunner) {
|
|
6510
6605
|
checks.push({ ok: false, warn: true, label: `${venue.prefix}Model endpoints: not probed, because the job image has no runner module (above), and their probes take the runner's route`, fix: "use a job image built after issue #427, then re-run doctor" });
|
|
6511
6606
|
} else {
|
|
6512
|
-
checks.push(...(await runEndpointProbes({ probe, bin, pid, network: net, proxy, image, user, endpoints, venue, unfinished, size, hostCpus })));
|
|
6607
|
+
checks.push(...(await runEndpointProbes({ probe, bin, pid, network: net, proxy, image, user, endpoints, mcpOnly, venue, unfinished, size, hostCpus })));
|
|
6513
6608
|
}
|
|
6514
6609
|
}
|
|
6515
6610
|
} finally {
|
|
@@ -6613,6 +6708,66 @@ function endpointsById(endpoints) {
|
|
|
6613
6708
|
return [...endpoints].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
6614
6709
|
}
|
|
6615
6710
|
|
|
6711
|
+
/**
|
|
6712
|
+
* The declared endpoints that an overlay MCP server dials (an enabled streamable HTTP server's `url` in `mcp.json`, the
|
|
6713
|
+
* servers doctor's own MCP check and the pickup judge) and no overlay model does (a provider's or a model's `baseUrl` in `models.json`), each matched by `endpointsAt`, as the
|
|
6714
|
+
* pickup's egress check matches an MCP server to an endpoint. docs/egress.md has an operator declare an MCP server off
|
|
6715
|
+
* port 443 there, and such a server does not serve `/v1/models`: it answers 404 or 405, and the `models` probe used to
|
|
6716
|
+
* warn on every run that the port was not the model server's. Its tunnel is still opened and its route proved; only its
|
|
6717
|
+
* answer is not judged as a model server's. An endpoint both use is probed as a model server, since a model needs that.
|
|
6718
|
+
* A models.json that could not be read is not evidence that no model uses an endpoint, so nothing is MCP-only then.
|
|
6719
|
+
* Returns a Map of endpoint id to `{ tls }`, `tls` when a server's url on it is `https:`: the probe's plain request then
|
|
6720
|
+
* meets a TLS server, which closes the socket rather than answer (`endpointProbeCheck`).
|
|
6721
|
+
*/
|
|
6722
|
+
export function mcpOnlyEndpoints({ endpoints, mcp = null, models = null, modelsUnread = false }) {
|
|
6723
|
+
if (modelsUnread || !Array.isArray(endpoints) || endpoints.length === 0) return new Map();
|
|
6724
|
+
const idsAt = (urls) => new Set(urls.flatMap((url) => endpointsAt(url, endpoints).map((e) => e.id)));
|
|
6725
|
+
const mcpUrls = Object.values(mcp?.servers ?? {})
|
|
6726
|
+
.filter((server) => server.transport === "http" && server.enabled !== false)
|
|
6727
|
+
.map((server) => server.url);
|
|
6728
|
+
const modelUrls = [];
|
|
6729
|
+
const providers = models?.providers;
|
|
6730
|
+
for (const entry of providers !== null && typeof providers === "object" ? Object.values(providers) : []) {
|
|
6731
|
+
if (entry === null || typeof entry !== "object") continue;
|
|
6732
|
+
modelUrls.push(entry.baseUrl);
|
|
6733
|
+
for (const model of Array.isArray(entry.models) ? entry.models : []) modelUrls.push(model?.baseUrl);
|
|
6734
|
+
}
|
|
6735
|
+
const byModels = idsAt(modelUrls);
|
|
6736
|
+
const only = new Map();
|
|
6737
|
+
for (const url of mcpUrls) {
|
|
6738
|
+
for (const { id } of endpointsAt(url, endpoints)) {
|
|
6739
|
+
if (byModels.has(id)) continue;
|
|
6740
|
+
only.set(id, { tls: only.get(id)?.tls === true || new URL(url).protocol === "https:" });
|
|
6741
|
+
}
|
|
6742
|
+
}
|
|
6743
|
+
return only;
|
|
6744
|
+
}
|
|
6745
|
+
|
|
6746
|
+
/**
|
|
6747
|
+
* The overlay's mcp.json and models.json for `mcpOnlyEndpoints`, through the readers the overlay section uses and its
|
|
6748
|
+
* seams. An mcp.json that cannot be read offers no server here (its own line says why); a models.json that cannot be read
|
|
6749
|
+
* is `modelsUnread`, which keeps every endpoint probed as a model server.
|
|
6750
|
+
*/
|
|
6751
|
+
function overlayEndpointUsers(env, seams) {
|
|
6752
|
+
const overlay = env.PI_GLOBAL_PI_DIR;
|
|
6753
|
+
if (typeof overlay !== "string" || !isAbsolute(overlay)) return {};
|
|
6754
|
+
const io = { readFileSync: seams.readOverlayFile ?? ((p, enc) => readFileSync(p, enc)), ...(seams.lstatOverlayFile ? { lstatSync: seams.lstatOverlayFile } : {}) };
|
|
6755
|
+
let mcp = null;
|
|
6756
|
+
try {
|
|
6757
|
+
mcp = readOverlayMcp(overlay, io);
|
|
6758
|
+
} catch {
|
|
6759
|
+
// Named by the overlay section's own line.
|
|
6760
|
+
}
|
|
6761
|
+
try {
|
|
6762
|
+
return { mcp, models: readOverlayModels(overlay, io) };
|
|
6763
|
+
} catch {
|
|
6764
|
+
return { mcp, modelsUnread: true };
|
|
6765
|
+
}
|
|
6766
|
+
}
|
|
6767
|
+
|
|
6768
|
+
/** The `blocked` codes of a socket the far end closed: what an https server does with the probe's plain request. */
|
|
6769
|
+
const SOCKET_CLOSED_CODES = Object.freeze(["UND_ERR_SOCKET", "ECONNRESET"]);
|
|
6770
|
+
|
|
6616
6771
|
/** The exits each probe's script ends a READING with; any other exit is a container that did not run it. */
|
|
6617
6772
|
const ENDPOINT_PROBE_READ_EXITS = Object.freeze({ models: [0, 3, 6], nextport: [0, 3, 6], plain: [0, 3] });
|
|
6618
6773
|
|
|
@@ -6625,8 +6780,8 @@ const ENDPOINT_PROBE_READ_EXITS = Object.freeze({ models: [0, 3, 6], nextport: [
|
|
|
6625
6780
|
* - `plain`: a 403 carrying squid's `X-Squid-Error: ERR_ACCESS_DENIED`. A bare 403 may be the server's own answer to a
|
|
6626
6781
|
* request the proxy forwarded.
|
|
6627
6782
|
*/
|
|
6628
|
-
function endpointProbeCheck({ slug, endpoint, answer, venue, bin, proxy, next }) {
|
|
6629
|
-
const head = `${venue.prefix}Model endpoint ${endpoint.id}`;
|
|
6783
|
+
function endpointProbeCheck({ slug, endpoint, answer, venue, bin, proxy, next, mcpOnly = null }) {
|
|
6784
|
+
const head = mcpOnly ? `${venue.prefix}MCP server endpoint ${endpoint.id}` : `${venue.prefix}Model endpoint ${endpoint.id}`;
|
|
6630
6785
|
const at = endpointAddress(endpoint.host, endpoint.port);
|
|
6631
6786
|
const reading = endpointProbeReading(answer);
|
|
6632
6787
|
const tag = { id: endpoint.id, probe: slug, kind: reading?.kind ?? null, status: reading?.status ?? null };
|
|
@@ -6641,6 +6796,19 @@ function endpointProbeCheck({ slug, endpoint, answer, venue, bin, proxy, next })
|
|
|
6641
6796
|
}
|
|
6642
6797
|
const reload = reloadCommand(bin, proxy);
|
|
6643
6798
|
if (slug === "models") {
|
|
6799
|
+
// An endpoint only an overlay MCP server uses: any answer through the tunnel proves the route and the rule, and
|
|
6800
|
+
// what it says to /v1/models is not a model server's answer (`mcpOnlyEndpoints`). Not reaching it still fails below.
|
|
6801
|
+
if (mcpOnly && reading.kind === "reached") {
|
|
6802
|
+
return { ok: true, warn: true, label: `${head}: the route through the proxy works (the tunnel to ${at} opened and the server answered ${reading.status}); no overlay model uses it, so it is not probed as a model server`, endpointProbe: tag };
|
|
6803
|
+
}
|
|
6804
|
+
// An https MCP server meets the probe's plain request as TLS servers do: it closes the socket (measured: undici reads
|
|
6805
|
+
// UND_ERR_SOCKET, "other side closed"; a reset is the same fact). Only a CONNECT the proxy let through reaches the
|
|
6806
|
+
// server to be closed by it, since a refused tunnel reads as `tunnel`, so the route is proved. Not a timeout, and
|
|
6807
|
+
// not a code that says the proxy itself was not reached (ECONNREFUSED to it, a name it could not resolve).
|
|
6808
|
+
if (mcpOnly?.tls && reading.kind === "blocked" && SOCKET_CLOSED_CODES.includes(reading.detail)) {
|
|
6809
|
+
return { ok: true, warn: true, label: `${head}: the route through the proxy works (the tunnel to ${at} opened and the server closed a plain request, as a TLS server does: ${reading.detail}); no overlay model uses it, so it is not probed as a model server`, endpointProbe: tag };
|
|
6810
|
+
}
|
|
6811
|
+
const server = mcpOnly ? "MCP server" : "model server";
|
|
6644
6812
|
if (reading.kind === "reached" && reading.status === 200) return { ok: true, warn: true, label: `${head} answers through the proxy (GET http://${at}/v1/models through a CONNECT tunnel: 200)`, endpointProbe: tag };
|
|
6645
6813
|
// The SERVER refused the request for want of a key (vLLM --api-key, a gateway in front): the route and the rule are
|
|
6646
6814
|
// proved, since only an open tunnel carries the server's own answer. Said apart from a fault, still warn-tier.
|
|
@@ -6673,10 +6841,12 @@ function endpointProbeCheck({ slug, endpoint, answer, venue, bin, proxy, next })
|
|
|
6673
6841
|
: reading.kind === "reached"
|
|
6674
6842
|
? "check that this port is the model server's and that it serves the OpenAI-compatible API under /v1 (Ollama, llama-server, vLLM and LM Studio do)"
|
|
6675
6843
|
: timedOut
|
|
6676
|
-
?
|
|
6844
|
+
? mcpOnly
|
|
6845
|
+
? "the server is there but stuck or busy: check its own log, then re-run doctor"
|
|
6846
|
+
: "the server is there but stuck or busy (a model still loading, every slot taken): check its own log, then re-run doctor"
|
|
6677
6847
|
: bin === "podman"
|
|
6678
|
-
? `start the
|
|
6679
|
-
: `start the
|
|
6848
|
+
? `start the ${server}, bound to this host's LAN address or 0.0.0.0 (never 127.0.0.1 alone), and declare a server on this host as host.containers.internal (docs/egress.md, "Local model servers"). A job meets this as "Connection error"`
|
|
6849
|
+
: `start the ${server}, and check it listens where the proxy reaches it (docs/egress.md, "Local model servers"; on Docker Engine that is 172.17.0.1 or 0.0.0.0, never 127.0.0.1 alone). A job meets this as "Connection error"`;
|
|
6680
6850
|
return { ok: false, warn: true, label: `${head} does NOT answer: ${why}`, fix, endpointProbe: tag };
|
|
6681
6851
|
}
|
|
6682
6852
|
const wider = `the proxy's rules are wider than model-endpoints.conf renders: compare the include inside it (\`${bin} exec ${proxy} cat ${ENDPOINTS_INCLUDE_IN_PROXY}\`) with \`pi-dispatch egress render\`'s, check egress-allowlist.conf does not list ${endpoint.host}, then \`${reload}\``;
|
|
@@ -6725,7 +6895,7 @@ export function undeclaredPortNear(endpoint, endpoints) {
|
|
|
6725
6895
|
* variable as well on docker: the runner's dispatcher sends an `http://` origin to HTTP_PROXY, which docker's canary
|
|
6726
6896
|
* argv does not otherwise carry (podman's is a job's environment and has it).
|
|
6727
6897
|
*/
|
|
6728
|
-
async function runEndpointProbes({ probe, bin, pid, network, proxy, image, user, endpoints, venue, unfinished, size = DEFAULT_JOB_SIZE, hostCpus = null }) {
|
|
6898
|
+
async function runEndpointProbes({ probe, bin, pid, network, proxy, image, user, endpoints, mcpOnly = new Map(), venue, unfinished, size = DEFAULT_JOB_SIZE, hostCpus = null }) {
|
|
6729
6899
|
const checks = [];
|
|
6730
6900
|
for (const endpoint of endpointsById(endpoints)) {
|
|
6731
6901
|
const next = undeclaredPortNear(endpoint, endpoints);
|
|
@@ -6740,7 +6910,7 @@ async function runEndpointProbes({ probe, bin, pid, network, proxy, image, user,
|
|
|
6740
6910
|
const name = egressEndpointProbe(slug, endpoint.id, pid);
|
|
6741
6911
|
const answer = await probe(egressCanaryProbeArgs({ bin, slug, name, pid, network, proxy, image, url, user, script, httpProxy: true, size, hostCpus }));
|
|
6742
6912
|
if (answer?.code === null && answer.ended !== "error") unfinished.push(name);
|
|
6743
|
-
checks.push(endpointProbeCheck({ slug, endpoint, answer, venue, bin, proxy, next }));
|
|
6913
|
+
checks.push(endpointProbeCheck({ slug, endpoint, answer, venue, bin, proxy, next, mcpOnly: mcpOnly.get(endpoint.id) ?? null }));
|
|
6744
6914
|
}
|
|
6745
6915
|
}
|
|
6746
6916
|
return checks;
|
|
@@ -8706,6 +8876,7 @@ export async function podmanChecks(env, seams, { jobImage, jobImageNote = "" })
|
|
|
8706
8876
|
// Issue #503: the declared model endpoints `--live` probes on this venue, `[]` for none; and whether the rules the
|
|
8707
8877
|
// proxy runs include their file (only the shipped proxy's account copy is read; another proxy must include it).
|
|
8708
8878
|
let endpoints = [];
|
|
8879
|
+
let mcpOnly = new Map();
|
|
8709
8880
|
let endpointRulesInclude = true;
|
|
8710
8881
|
if (armed !== false) {
|
|
8711
8882
|
// `.State.Status` (issue #453): only `running` carries traffic; Podman reports paused and between-restarts states
|
|
@@ -8772,6 +8943,7 @@ export async function podmanChecks(env, seams, { jobImage, jobImageNote = "" })
|
|
|
8772
8943
|
const declaredList = declaredEndpoints({ env, cwd: seams.cwd, platform: seams.platform ?? process.platform });
|
|
8773
8944
|
if (declaredList.length > 0 && endpointRulesInclude) {
|
|
8774
8945
|
endpoints = declaredList;
|
|
8946
|
+
mcpOnly = mcpOnlyEndpoints({ endpoints, ...overlayEndpointUsers(env, seams) });
|
|
8775
8947
|
// The helper as Podman 5 names it in `podman info` (`rootlessNetworkCmd`), else as this account's running rootless
|
|
8776
8948
|
// network shows it (`observeRootlessNetns`, the record or the 4.x argv the worker's widening check reads), else
|
|
8777
8949
|
// not known, which the route row says as such.
|
|
@@ -8795,7 +8967,7 @@ export async function podmanChecks(env, seams, { jobImage, jobImageNote = "" })
|
|
|
8795
8967
|
if (keeperNetwork) checks.push(keeperNetwork);
|
|
8796
8968
|
}
|
|
8797
8969
|
|
|
8798
|
-
forLive = { run: true, user, relabel, info, imagePresent, egress: { armed, proxy, proxyRunning, keeperBlocked, endpoints } };
|
|
8970
|
+
forLive = { run: true, user, relabel, info, imagePresent, egress: { armed, proxy, proxyRunning, keeperBlocked, endpoints, mcpOnly } };
|
|
8799
8971
|
return { checks, observed, relabel, imagesReadable, imageDigest, forLive };
|
|
8800
8972
|
}
|
|
8801
8973
|
|
|
@@ -9320,7 +9492,7 @@ async function podmanEgressCanary({ podman, readInfo, run, image, pid, isAlive,
|
|
|
9320
9492
|
const endpoints = podman.egress.endpoints ?? [];
|
|
9321
9493
|
const more = endpoints.length > 0 ? `; then three per declared model endpoint (${endpointsById(endpoints).map((e) => e.id).join(", ")}), named ${EGRESS_ENDPOINT_PROBE_PREFIX}<probe>-<id>-${pid}, removed the same way` : "";
|
|
9322
9494
|
announce(`starting ${probes.slice(0, -1).join(", ")} and ${probes.at(-1)} from ${image} (as the job user ${podman.user}) on the --internal network ${egressCanaryNetwork(pid)}, with ${podman.egress.proxy} attached, to read the egress allowlist back; all three are removed when the canary ends${more}`);
|
|
9323
|
-
const canary = await runEgressCanary({ run, bin: "podman", proxy: podman.egress.proxy, image, pid, user: podman.user, gate, endpoints, size, hostCpus: again.info?.hostCpus ?? null });
|
|
9495
|
+
const canary = await runEgressCanary({ run, bin: "podman", proxy: podman.egress.proxy, image, pid, user: podman.user, gate, endpoints, mcpOnly: podman.egress.mcpOnly ?? new Map(), size, hostCpus: again.info?.hostCpus ?? null });
|
|
9324
9496
|
return { checks: [...checks, ...canary.checks], results: canary.results };
|
|
9325
9497
|
}
|
|
9326
9498
|
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The egress allowlist as the proxy reads it, and whether a URL a job dials gets through (docs/egress.md). Squid
|
|
3
|
+
* matches `egress-allowlist.conf` as a `dstdomain` file (deploy/egress-proxy.conf, `acl allowed dstdomain -n`): each
|
|
4
|
+
* line, trimmed and with a `#` comment removed, is ONE entry, and an entry with a leading dot admits that domain and
|
|
5
|
+
* every name under it. A line with two words is one entry that matches nothing (measured on the pinned squid: both
|
|
6
|
+
* names refused), so it admits nothing here either. A job's runner sends every request as a CONNECT
|
|
7
|
+
* (image/runner/src/env-proxy.mjs, `proxyTunnel: true`), and the proxy allows a CONNECT to an allowlisted name on port
|
|
8
|
+
* 443 only, never to a host that resolves to this machine (`to_host_local`), and to a declared model endpoint on
|
|
9
|
+
* exactly its host and port (`model-endpoints.conf`).
|
|
10
|
+
*
|
|
11
|
+
* The proxy stays the enforcement. This is the same reading done before anything is spent, so a job whose server the
|
|
12
|
+
* policy cannot serve is refused with a reason instead of failing at its first call. It is a necessary condition, not
|
|
13
|
+
* a sufficient one: a NAME that resolves to this machine is refused by the proxy and not here, since this reads no DNS.
|
|
14
|
+
*/
|
|
15
|
+
import { isIP } from "node:net";
|
|
16
|
+
import { basename } from "node:path";
|
|
17
|
+
import { isProxyLocalHost } from "./backends.mjs";
|
|
18
|
+
import { baseUrlTarget, endpointsAt } from "./model-endpoints.mjs";
|
|
19
|
+
|
|
20
|
+
/** The entries of an allowlist's text, lowercased, in order: one per line, a line with inner whitespace skipped. */
|
|
21
|
+
export function parseAllowlist(text) {
|
|
22
|
+
const entries = [];
|
|
23
|
+
for (const raw of String(text ?? "").split(/\r?\n/)) {
|
|
24
|
+
// A comment starts at a `#` that opens the line or follows whitespace; one inside a word is part of the word,
|
|
25
|
+
// which then matches no host.
|
|
26
|
+
const line = raw.replace(/(^|\s)#.*$/, "").trim();
|
|
27
|
+
if (line === "" || /\s/.test(line)) continue;
|
|
28
|
+
entries.push(line.toLowerCase());
|
|
29
|
+
}
|
|
30
|
+
return entries;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The first entry that admits `host`, or null: the name itself, or a leading-dot entry for it or a name above it. */
|
|
34
|
+
export function allowlistEntryFor(entries, host) {
|
|
35
|
+
const name = String(host).toLowerCase();
|
|
36
|
+
for (const entry of entries) {
|
|
37
|
+
if (entry.startsWith(".") ? name === entry.slice(1) || name.endsWith(entry) : name === entry) return entry;
|
|
38
|
+
}
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Whether `host` (as `baseUrlTarget` gives it) is an address squid's `to_host_local` refuses, or `localhost`. */
|
|
43
|
+
function hostIsLocal(host) {
|
|
44
|
+
const bare = host.startsWith("[") ? host.slice(1, -1) : host;
|
|
45
|
+
const kind = isIP(bare) === 4 ? "ipv4" : isIP(bare) === 6 ? "ipv6" : "name";
|
|
46
|
+
if (isProxyLocalHost(kind, bare)) return true;
|
|
47
|
+
if (kind === "ipv4") return bare.startsWith("169.254.");
|
|
48
|
+
if (kind === "ipv6") return /^fe[89ab][0-9a-f]:/i.test(bare);
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Why a job's CONNECT to `url` would be refused by the proxy, or null when it gets through: a declared endpoint's
|
|
54
|
+
* exact host and port, or an allowlisted name on port 443, over https, that is not this machine. Both schemes go as
|
|
55
|
+
* a CONNECT, so the proxy itself does not tell them apart.
|
|
56
|
+
*/
|
|
57
|
+
export function egressRefusalFor(url, { allowlist = [], endpoints = [] } = {}) {
|
|
58
|
+
const target = baseUrlTarget(url);
|
|
59
|
+
if (target === null) return "it is not an http or https URL";
|
|
60
|
+
if (endpointsAt(url, endpoints).length > 0) return null;
|
|
61
|
+
const entry = allowlistEntryFor(allowlist, target.host);
|
|
62
|
+
if (entry !== null && hostIsLocal(target.host)) return `${target.host} is the proxy's own machine, which it never opens; a server on this machine is declared in model-endpoints.json under host.docker.internal or host.containers.internal`;
|
|
63
|
+
// The proxy tunnels to 443 whatever speaks inside, so an `http://` url on 443 is plain HTTP to what is almost always
|
|
64
|
+
// a TLS port, carrying the server's headers in clear inside the tunnel: refused, with the fix.
|
|
65
|
+
if (entry !== null && target.port === 443 && new URL(url).protocol !== "https:") return `the egress proxy opens ${target.host} on port 443 for TLS, and this url is http://; use https://`;
|
|
66
|
+
if (entry !== null && target.port === 443) return null;
|
|
67
|
+
if (entry !== null) return `the egress proxy opens ${target.host} on port 443 only, and this is port ${target.port}; declare ${target.host}:${target.port} in model-endpoints.json to open exactly that port`;
|
|
68
|
+
return `${target.host} is not in egress-allowlist.conf${target.port === 443 ? "" : ", and port " + target.port + " opens only as a declared endpoint in model-endpoints.json"}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Every host a package runner fetches from at start, by name, since the proxy matches names: uv and pipx take the
|
|
73
|
+
* index from pypi.org and the files from files.pythonhosted.org, yarn's default registry is its own name, and the npm
|
|
74
|
+
* family reads registry.npmjs.org.
|
|
75
|
+
*/
|
|
76
|
+
export function packageRunnerHosts(command) {
|
|
77
|
+
const runner = basename(String(command));
|
|
78
|
+
if (["uv", "uvx", "pipx"].includes(runner)) return ["pypi.org", "files.pythonhosted.org"];
|
|
79
|
+
if (runner === "yarn") return ["registry.yarnpkg.com"];
|
|
80
|
+
return ["registry.npmjs.org"];
|
|
81
|
+
}
|
package/src/env-allowlist.mjs
CHANGED
|
@@ -367,7 +367,7 @@ function resolveEnvName(provider) {
|
|
|
367
367
|
* `allowGlobalExtensions` defaults to TRUE here, matching loadConfig's default (REQ-GLOBAL-PI-OVERLAY): a
|
|
368
368
|
* caller that says nothing gets the operator's staged setup, and only an explicit `false` withholds it.
|
|
369
369
|
*/
|
|
370
|
-
export function buildContainerEnv({ provider, model, maxTurns, maxTokens, maxCostMicros = null, jobId, githubToken, forgeKind, forgeHosts = {}, hostEnv, allowGlobalExtensions = true, packagePaths = [], forwardEnv = [], secrets = {}, sessionFile = null, flow = null, command = null, excludeTools = [], allowedModels = null, authFromPi = false, egress = false, egressProxy, agentDir, home = null, readFile = readFileSync, modelEndpoints = null, exitAuth = false }) {
|
|
370
|
+
export function buildContainerEnv({ provider, model, maxTurns, maxTokens, maxCostMicros = null, jobId, githubToken, forgeKind, forgeHosts = {}, hostEnv, allowGlobalExtensions = true, packagePaths = [], forwardEnv = [], secrets = {}, sessionFile = null, flow = null, command = null, excludeTools = [], mcpServers = [], awaitInput = false, allowedModels = null, authFromPi = false, egress = false, egressProxy, agentDir, home = null, readFile = readFileSync, modelEndpoints = null, exitAuth = false }) {
|
|
371
371
|
// The provider credential(s), by pi's expected variable name(s) -- from the worker env, or (when
|
|
372
372
|
// PI_AUTH_FROM_PI is set and the env has none) host-side from pi's auth.json. Throws (config) if
|
|
373
373
|
// neither source yields one, which the processor turns into a policy refusal that refunds any reserve
|
|
@@ -429,6 +429,13 @@ export function buildContainerEnv({ provider, model, maxTurns, maxTokens, maxCos
|
|
|
429
429
|
// second-validator rule directly above). Absent means the full pinned default set, never an empty
|
|
430
430
|
// string, for PI_PACKAGES' reason.
|
|
431
431
|
PI_EXCLUDE_TOOLS: excludeTools.length > 0 ? excludeTools.join(",") : undefined,
|
|
432
|
+
// The trigger's run.mcp (REQ-MCP-OPERATOR-SERVERS): the overlay servers the job's session connects, so the
|
|
433
|
+
// runner can hold `/job/mcp.json` to the names the worker meant. Comma-safe for excludeTools' reason (the loader
|
|
434
|
+
// admits only lowercase letters, digits and `-`). Absent means none, never an empty string.
|
|
435
|
+
PI_MCP_SERVERS: mcpServers.length > 0 ? mcpServers.join(",") : undefined,
|
|
436
|
+
// The trigger's run.awaitInput (issue #625): "1" offers the job the tool a parked run ends on. Absent means not
|
|
437
|
+
// armed, never "0" or an empty string, for PI_PACKAGES' reason.
|
|
438
|
+
PI_AWAIT_INPUT: awaitInput === true ? "1" : undefined,
|
|
432
439
|
// The job's EFFECTIVE allowed-model list (issue #502): its trigger's `run.models`, else the deployment's
|
|
433
440
|
// PI_ALLOWED_MODELS. Comma-joined `provider/model` entries, which the runner splits at each entry's first `/`
|
|
434
441
|
// (image/runner/src/config.mjs). Comma is safe because both list parsers refuse an entry carrying one. NULL is
|