@edgehero/pi-dispatch 4.3.1 → 5.0.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 +84 -3
- package/README.md +20 -7
- package/deploy/console.service +52 -0
- package/deploy/docker-compose.yml +3 -1
- package/deploy/pi-dispatch-valkey.container +4 -2
- package/deploy/sandbox-gateway.service +23 -0
- package/deploy/worker-env-wrapper.cmd +6 -0
- package/deploy/worker-env-wrapper.sh +10 -1
- package/package.json +117 -19
- package/src/agent-snapshot.mjs +49 -0
- package/src/answerer-runtime.mjs +194 -0
- package/src/answerer.mjs +178 -0
- package/src/approval-policy.mjs +76 -0
- package/src/approval-runtime.mjs +188 -0
- package/src/approval-session.mjs +137 -0
- package/src/approval-state.mjs +170 -0
- package/src/auth-cli.mjs +86 -0
- package/src/auth-connections.mjs +145 -0
- package/src/auth-policy.mjs +32 -0
- package/src/auth-store.mjs +97 -0
- package/src/azure-host.mjs +66 -4
- package/src/azure-prompt.mjs +5 -4
- package/src/backend-conformance.mjs +34 -2
- package/src/backend-local.mjs +103 -34
- package/src/backend-openshell.mjs +1540 -0
- package/src/backend-podman.mjs +63 -2
- package/src/backend-registry.mjs +61 -4
- package/src/backends.mjs +395 -6
- package/src/bullmq-internals.mjs +208 -0
- package/src/canary-cli.mjs +28 -0
- package/src/canary-lock.mjs +41 -0
- package/src/canary-runtime.mjs +237 -0
- package/src/canary.mjs +53 -0
- package/src/cancel-cli.mjs +47 -34
- package/src/cancel-state.mjs +213 -15
- package/src/capabilities.mjs +36 -1
- package/src/capacity-cli.mjs +28 -8
- package/src/capacity-records.mjs +75 -16
- package/src/capacity.mjs +65 -0
- package/src/chat-cli.mjs +288 -0
- package/src/chat-fork.mjs +547 -0
- package/src/chat-mode.mjs +156 -0
- package/src/chat-view.mjs +720 -0
- package/src/checkpoint-paths.mjs +113 -0
- package/src/cli.mjs +128 -41
- package/src/comment-tables.mjs +322 -0
- package/src/community-auth-adapters.mjs +87 -0
- package/src/community-auth-helper.mjs +76 -0
- package/src/community-auth-metadata.mjs +6 -0
- package/src/community-auth-output-pins.mjs +38 -0
- package/src/community-auth-patches.mjs +168 -0
- package/src/community-auth-pins.mjs +38 -0
- package/src/config.mjs +190 -29
- package/src/connection.mjs +83 -7
- package/src/console-answer.mjs +113 -0
- package/src/console-audit.mjs +604 -0
- package/src/console-bootstrap.mjs +159 -0
- package/src/console-client.mjs +848 -0
- package/src/console-probe.mjs +106 -0
- package/src/console-tokens.mjs +459 -0
- package/src/container-names.mjs +51 -0
- package/src/container-spec.mjs +20 -1
- package/src/cpu-reserve.mjs +4 -1
- package/src/defer-reasons.mjs +200 -0
- package/src/defer-write.mjs +208 -0
- package/src/deployment-venue.mjs +23 -6
- package/src/docker-run.mjs +11 -0
- package/src/doctor-bundles.mjs +130 -0
- package/src/doctor-model-proposals.mjs +109 -0
- package/src/doctor-openshell.mjs +238 -0
- package/src/doctor-results.mjs +602 -0
- package/src/doctor-spawner.mjs +215 -0
- package/src/doctor.mjs +1639 -196
- package/src/dollar-budget.mjs +212 -3
- package/src/endpoint-status.mjs +100 -0
- package/src/env-allowlist.mjs +96 -6
- package/src/env-file.mjs +6 -4
- package/src/file-expect.mjs +47 -0
- package/src/forgejo-host.mjs +42 -5
- package/src/forgejo-prompt.mjs +5 -4
- package/src/forges.mjs +33 -3
- package/src/fork-job.mjs +58 -0
- package/src/fork-policy.mjs +23 -0
- package/src/fork-store.mjs +338 -0
- package/src/get-token.mjs +1 -1
- package/src/git-dirty.mjs +119 -14
- package/src/git-hardening.mjs +85 -0
- package/src/github-host.mjs +31 -6
- package/src/github-prompt.mjs +7 -16
- package/src/gitlab-host.mjs +42 -3
- package/src/gitlab-prompt.mjs +5 -4
- package/src/held-document.mjs +23 -0
- package/src/held-replies.mjs +325 -0
- package/src/held-settle.mjs +266 -0
- package/src/hook-runs.mjs +188 -0
- package/src/host-budget.mjs +8 -5
- package/src/host-clone.mjs +72 -0
- package/src/host-loads.mjs +557 -0
- package/src/host-registry.mjs +66 -6
- package/src/host-telemetry.mjs +92 -0
- package/src/http-body.mjs +78 -0
- package/src/image-preflight.mjs +156 -13
- package/src/import-pi.mjs +60 -172
- package/src/inbox-relay.mjs +482 -0
- package/src/inbox-settle.mjs +196 -0
- package/src/index.mjs +734 -129
- package/src/init.mjs +35 -5
- package/src/job-data-version.mjs +75 -0
- package/src/job-id.mjs +30 -15
- package/src/job-layout.mjs +22 -0
- package/src/job-size.mjs +16 -0
- package/src/live-jobs.mjs +14 -6
- package/src/live-probes.mjs +6 -2
- package/src/live-transcript.mjs +872 -0
- package/src/loaded-record.mjs +609 -0
- package/src/mcp-config.mjs +1 -1
- package/src/mcp-preflight.mjs +36 -5
- package/src/model-attempts.mjs +84 -0
- package/src/model-catalog.mjs +83 -0
- package/src/model-cooldowns.mjs +22 -0
- package/src/model-failover-protocol.mjs +11 -0
- package/src/model-failover.mjs +134 -0
- package/src/model-observation.mjs +50 -0
- package/src/model-proposal-protocol.mjs +21 -0
- package/src/model-proposals.mjs +75 -0
- package/src/model-ref.mjs +67 -10
- package/src/model-roles-file.mjs +24 -0
- package/src/model-roles.mjs +73 -0
- package/src/model-status.mjs +34 -0
- package/src/models-json.mjs +4 -1
- package/src/money.mjs +1 -1
- package/src/oauth-openai.mjs +121 -0
- package/src/older-state.mjs +249 -0
- package/src/on-failure.mjs +17 -13
- package/src/open-browser.mjs +31 -2
- package/src/opener-cli.mjs +135 -0
- package/src/opener-protocol.mjs +27 -0
- package/src/opener-terminal.mjs +128 -0
- package/src/openshell-cli.mjs +155 -0
- package/src/openshell-policy.mjs +163 -0
- package/src/outbox.mjs +14 -0
- package/src/output-cap.mjs +1 -0
- package/src/package-canary-record.mjs +26 -0
- package/src/package-canary.mjs +135 -0
- package/src/package-names.mjs +9 -0
- package/src/package-stage.mjs +418 -0
- package/src/packages.mjs +24 -16
- package/src/parked.mjs +2 -1
- package/src/pause-windows.mjs +136 -17
- package/src/podman-stack.mjs +40 -14
- package/src/portfolio-snapshot.mjs +26 -7
- package/src/prepare-github.mjs +41 -77
- package/src/prepare-local.mjs +32 -2
- package/src/prepare.mjs +45 -11
- package/src/private-store-boundary.mjs +70 -0
- package/src/processor.mjs +1550 -173
- package/src/projects.mjs +23 -0
- package/src/prompt-fence.mjs +18 -0
- package/src/provider-key.mjs +7 -0
- package/src/provider-steering.mjs +1 -0
- package/src/question-context.mjs +35 -0
- package/src/question-group-runtime.mjs +91 -0
- package/src/question-groups.mjs +171 -0
- package/src/queue.mjs +264 -55
- package/src/reason-catalog.mjs +448 -0
- package/src/receiver-status.mjs +230 -0
- package/src/record-reason.mjs +37 -0
- package/src/recovery-queue.mjs +566 -0
- package/src/redact-secrets.mjs +219 -0
- package/src/reserve-estimate.mjs +101 -0
- package/src/reserved-env.mjs +36 -1
- package/src/retention.mjs +67 -0
- package/src/run-budget-ledger.mjs +283 -0
- package/src/run-checkpoint.mjs +627 -0
- package/src/run-container.mjs +749 -33
- package/src/run-history.mjs +791 -45
- package/src/run-mirror.mjs +75 -7
- package/src/run-progress.mjs +751 -0
- package/src/runtime-observations.mjs +3 -1
- package/src/runtime-settings.mjs +272 -37
- package/src/sandbox-cli.mjs +78 -2
- package/src/sandbox-deployment.mjs +51 -0
- package/src/sandbox-gateway-protocol.mjs +378 -0
- package/src/sandbox-hosts.mjs +100 -0
- package/src/sandbox-inventory.mjs +83 -0
- package/src/sandbox-policy.mjs +225 -0
- package/src/sandbox-prepare-requests.mjs +145 -0
- package/src/sandbox-prepare.mjs +285 -0
- package/src/sandbox-refs.mjs +70 -0
- package/src/sandbox-session-monitor.mjs +4 -0
- package/src/sandbox-session-watch.mjs +124 -0
- package/src/sandbox-sessions.mjs +161 -0
- package/src/sandbox-store.mjs +63 -6
- package/src/sandbox-workspace-guard.mjs +39 -0
- package/src/sandbox.mjs +27 -200
- package/src/scheduler-stall-guard.mjs +14 -8
- package/src/schedules.mjs +19 -6
- package/src/secret-bundles.mjs +245 -0
- package/src/secret-profiles.mjs +103 -3
- package/src/secrets.mjs +19 -9
- package/src/service-env.mjs +38 -5
- package/src/service.mjs +638 -106
- package/src/session-key.mjs +60 -0
- package/src/session-store.mjs +168 -10
- package/src/setup-contract.mjs +40 -0
- package/src/setup-egress.mjs +113 -0
- package/src/setup-host-entry.mjs +33 -0
- package/src/setup-host.mjs +242 -0
- package/src/setup-lock.mjs +41 -0
- package/src/size-records.mjs +15 -3
- package/src/start.mjs +1353 -135
- package/src/status-comment.mjs +415 -0
- package/src/stderr-transcript.mjs +318 -0
- package/src/steer-ack.mjs +122 -0
- package/src/steer-cli.mjs +213 -0
- package/src/steer-framing.mjs +185 -0
- package/src/steer-inbox.mjs +693 -0
- package/src/store-version.mjs +139 -0
- package/src/subscription-config.mjs +56 -0
- package/src/subscription-limits.mjs +3 -0
- package/src/subscription-quota.mjs +259 -0
- package/src/subscription-runtime.mjs +123 -0
- package/src/subscription-status.mjs +42 -0
- package/src/subscription-wait.mjs +11 -0
- package/src/subscriptions.mjs +7 -1
- package/src/thinking-level.mjs +19 -0
- package/src/touch-outbox.mjs +61 -0
- package/src/touch-state.mjs +133 -0
- package/src/triggers-file.mjs +299 -39
- package/src/triggers.mjs +780 -38
- package/src/trust-paths.mjs +171 -0
- package/src/up.mjs +398 -51
- package/src/updates-cli.mjs +61 -0
- package/src/updates-look.mjs +127 -0
- package/src/updates-reconcile.mjs +182 -0
- package/src/updates-subscription-adapters.mjs +8 -0
- package/src/updates.mjs +252 -0
- package/src/valkey-auth.mjs +32 -9
- package/src/valkey-endpoint.mjs +14 -4
- package/src/wait-for.mjs +1 -1
- package/src/wait-state.mjs +62 -3
- package/src/watch-board.mjs +38 -0
- package/src/watch-closer.mjs +1 -1
- package/src/watch-contract.mjs +102 -0
- package/src/watch-decision.mjs +78 -0
- package/src/watch-drainer.mjs +14 -0
- package/src/watch-issue-adapter.mjs +44 -0
- package/src/watch-issue.mjs +111 -0
- package/src/watch-record.mjs +10 -0
- package/src/watch-runtime.mjs +167 -0
- package/src/watch-store.mjs +183 -0
- package/src/watch-trace.mjs +34 -0
- package/src/workspace-checkpoint.mjs +1750 -0
- package/src/zero-touch-fixes.mjs +99 -0
- package/src/zero-touch.mjs +225 -0
- package/src/repeat-slot.mjs +0 -13
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/** Audited adaptations of MIT community packages. No extension executes while staging or verifying. */
|
|
2
|
+
import * as nodeFs from "node:fs";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { join, relative, dirname, isAbsolute } from "node:path";
|
|
5
|
+
import { COMMUNITY_SOURCE_PINS } from "./community-auth-pins.mjs";
|
|
6
|
+
import { COMMUNITY_ADAPTED_SOURCE_PINS } from "./community-auth-output-pins.mjs";
|
|
7
|
+
|
|
8
|
+
import { COMMUNITY_AUTH_ADAPTERS } from "./community-auth-metadata.mjs";
|
|
9
|
+
export { COMMUNITY_AUTH_ADAPTERS } from "./community-auth-metadata.mjs";
|
|
10
|
+
const sha = (bytes) => createHash("sha256").update(bytes).digest("hex");
|
|
11
|
+
const fail = (code) => { const error = new Error(code); error.code = code; throw error; };
|
|
12
|
+
function replaceOnce(source, before, after) {
|
|
13
|
+
if (!source.includes(before) || source.indexOf(before) !== source.lastIndexOf(before)) fail("auth-adapter-source-mismatch");
|
|
14
|
+
return source.replace(before, after);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function adaptCommunitySource(adapter, path, input) {
|
|
18
|
+
let source = input;
|
|
19
|
+
if (adapter === "pi-anthropic-oauth@0.3.6") {
|
|
20
|
+
if (path === "src/index.ts") {
|
|
21
|
+
source = replaceOnce(source, 'name: "Claude Pro/Max",', 'name: "Claude Pro/Max",\n isSubscription: true,');
|
|
22
|
+
source = replaceOnce(source, ' pi.registerProvider("anthropic", {', ' Object.defineProperty(streamAnthropicOAuth, Symbol.for("pi-dispatch.subscription-adapter"), { value: "pi-anthropic-oauth@0.3.6" });\n pi.registerProvider("anthropic", {');
|
|
23
|
+
}
|
|
24
|
+
if (path === "src/auth.ts") {
|
|
25
|
+
source = replaceOnce(source, "callbacks: OAuthLoginCallbacks,\n):", "callbacks: OAuthLoginCallbacks,\n now: () => number = Date.now,\n):");
|
|
26
|
+
source = replaceOnce(source, "credentials: OAuthCredentials,\n):", "credentials: OAuthCredentials,\n signal?: AbortSignal,\n now: () => number = Date.now,\n):");
|
|
27
|
+
source = replaceOnce(source, 'headers: makeTokenHeaders(),\n body: JSON.stringify({\n grant_type: "refresh_token",', 'headers: makeTokenHeaders(),\n signal,\n body: JSON.stringify({\n grant_type: "refresh_token",');
|
|
28
|
+
source = replaceOnce(source, ' if (isTransientTokenError(error) && credentials.expires > Date.now()) {\n return { ...credentials, expires: Date.now() + 30_000 };\n }\n', "");
|
|
29
|
+
source = source.replaceAll("expires: Date.now() + data.expires_in", "expires: now() + data.expires_in");
|
|
30
|
+
source = replaceOnce(source, " const response = await fetch(url, init);", " init.signal?.throwIfAborted();\n const response = await dispatchFetch(url, { ...init, signal: AbortSignal.any([...(init.signal ? [init.signal] : []), AbortSignal.timeout(15_000)]) });");
|
|
31
|
+
source = replaceOnce(source, " await new Promise((resolve) => setTimeout(resolve, delayMs));", " await new Promise<void>((resolve, reject) => {\n const done = () => { init.signal?.removeEventListener(\"abort\", abort); resolve(); };\n const timer = setTimeout(done, delayMs);\n const abort = () => { clearTimeout(timer); reject(new Error(\"auth-network-unavailable\")); };\n init.signal?.addEventListener(\"abort\", abort, { once: true });\n if (init.signal?.aborted) abort();\n });");
|
|
32
|
+
source = replaceOnce(source, ' `${label} failed: ${response.status} ${bodyText}`,', ' `auth-token-http-${response.status}`,');
|
|
33
|
+
source = replaceOnce(source, ' throw new Error(`Token refresh failed: ${detail}`);', ' throw error;');
|
|
34
|
+
source = replaceOnce(source, " localAuthorization = await createLocalAuthorization(state);", " localAuthorization = await createLocalAuthorization(state);\n callbacks.signal?.addEventListener(\"abort\", () => localAuthorization?.cancel(), { once: true });\n if (callbacks.signal?.aborted) localAuthorization.cancel();");
|
|
35
|
+
source = 'let dispatchFetch: typeof fetch = globalThis.fetch;\nexport function configureDispatchAuth(transport: typeof fetch) { dispatchFetch = transport; }\n' + source;
|
|
36
|
+
}
|
|
37
|
+
if (path === "src/prompt.ts") {
|
|
38
|
+
const start = source.indexOf(" const paragraphs = text.split(");
|
|
39
|
+
const end = source.indexOf("\n}\n\nfunction resolvePiRewritePattern", start);
|
|
40
|
+
if (start < 0 || end < start) fail("auth-adapter-source-mismatch");
|
|
41
|
+
source = source.slice(0, start) + " return text;" + source.slice(end);
|
|
42
|
+
}
|
|
43
|
+
if (path === "src/convert.ts") {
|
|
44
|
+
source = replaceOnce(source, "export function convertPiToolsToAnthropic(tools: Tool[], isOAuth: boolean): ToolUnion[] {", "export function convertPiToolsToAnthropic(tools: Tool[], isOAuth: boolean): ToolUnion[] {\n const names = tools.map((tool) => isOAuth ? toClaudeCodeToolName(tool.name) : tool.name);\n if (new Set(names).size !== names.length) throw new Error(\"auth-tool-name-collision\");");
|
|
45
|
+
}
|
|
46
|
+
if (path === "src/stream.ts") {
|
|
47
|
+
source = replaceOnce(source, " dangerouslyAllowBrowser: true,", " dangerouslyAllowBrowser: true,\n fetch: options?.fetch,");
|
|
48
|
+
source = replaceOnce(source, " // Raw stream instead of the MessageStream helper:", " const checkedParams = (await options?.onPayload?.(params, model)) ?? params;\n\n // Raw stream instead of the MessageStream helper:");
|
|
49
|
+
source = replaceOnce(source, ".create(params, {", ".create(checkedParams as MessageCreateParamsStreaming, {");
|
|
50
|
+
source = replaceOnce(source, " } catch {\n // Response hooks are best-effort and should not break streaming.\n }", " } catch (error) { throw error; }");
|
|
51
|
+
source = replaceOnce(source, " cache_creation_input_tokens?: number | null;", " cache_creation_input_tokens?: number | null;\n cache_creation?: { ephemeral_1h_input_tokens?: number | null };");
|
|
52
|
+
source = replaceOnce(source, " const thinkingTokens = usage.output_tokens_details?.thinking_tokens;", " if (usage.cache_creation?.ephemeral_1h_input_tokens != null) output.usage.cacheWrite1h = usage.cache_creation.ephemeral_1h_input_tokens;\n const thinkingTokens = usage.output_tokens_details?.thinking_tokens;");
|
|
53
|
+
source = replaceOnce(source, " output.usage.totalTokens =", " output.usage.cacheWrite1h = (event.message.usage as MessageDeltaUsage).cache_creation?.ephemeral_1h_input_tokens ?? 0;\n output.usage.totalTokens =");
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (adapter === "@gotgenes/pi-anthropic-auth@3.4.3" && path === "src/oauth-transport.ts") {
|
|
57
|
+
// Shape first, then let the dispatch model/payload/cost guards inspect what will actually be sent.
|
|
58
|
+
source = replaceOnce(source, " const upstream = await composeCallerOnPayload(payload, payloadModel);", " const upstream = payload;");
|
|
59
|
+
source = replaceOnce(source, " return upstream;", " return composeCallerOnPayload(upstream, payloadModel);");
|
|
60
|
+
source = replaceOnce(source, " versionSync?.recordRequest(upstream);\n return shapeAnthropicOAuthPayload(upstream);", " const checked = await composeCallerOnPayload(shapeAnthropicOAuthPayload(upstream), payloadModel);\n versionSync?.recordRequest(checked);\n return checked;");
|
|
61
|
+
// Keep the caller's measured fetch. No body rewrite or second request may follow the final payload check.
|
|
62
|
+
source = replaceOnce(source, " ...(versionSync ? { fetch: versionSync.fetch } : {}),", "");
|
|
63
|
+
}
|
|
64
|
+
if (adapter === "@gotgenes/pi-anthropic-auth@3.4.3" && path === "src/index.ts") source = replaceOnce(source, ' pi.unregisterProvider("anthropic");', ' Object.defineProperty(streamSimple, Symbol.for("pi-dispatch.subscription-adapter"), { value: "@gotgenes/pi-anthropic-auth@3.4.3" });\n pi.unregisterProvider("anthropic");');
|
|
65
|
+
if (adapter === "@gotgenes/pi-anthropic-auth@3.4.3" && path === "src/system-prompt-shaping.ts") {
|
|
66
|
+
for (const [signature, next, returned] of [
|
|
67
|
+
["export function shapeAnthropicOAuthSystemPrompt(systemPrompt: string): string {", "\n}\n\nfunction isPiOwnedSection", "systemPrompt"],
|
|
68
|
+
["export function shapeSystemUpdateText(text: string): string | undefined {", "\n}\n\n/**\n * Apply the rule table", "text"],
|
|
69
|
+
]) {
|
|
70
|
+
const start = source.indexOf(signature);
|
|
71
|
+
const end = source.indexOf(next, start);
|
|
72
|
+
if (start < 0 || end < start) fail("auth-adapter-source-mismatch");
|
|
73
|
+
source = source.slice(0, start + signature.length) + `\n return ${returned};` + source.slice(end);
|
|
74
|
+
}
|
|
75
|
+
const signature = "export function shapeSystemBlocks(blocks: TextBlock[]): TextBlock[] {";
|
|
76
|
+
const start = source.indexOf(signature);
|
|
77
|
+
if (start < 0 || !source.trimEnd().endsWith("}")) fail("auth-adapter-source-mismatch");
|
|
78
|
+
source = source.slice(0, start + signature.length) + "\n return blocks;\n}\n";
|
|
79
|
+
}
|
|
80
|
+
return source;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// A changed adaptation must be explicitly restaged even when the upstream package version stays the same.
|
|
84
|
+
export const COMMUNITY_ADAPTER_REVISION = sha(JSON.stringify(COMMUNITY_SOURCE_PINS) + adaptCommunitySource.toString());
|
|
85
|
+
|
|
86
|
+
function regularFile(fs, path) { const stat = fs.lstatSync(path); if (!stat.isFile() || stat.isSymbolicLink()) fail("auth-adapter-unsafe-path"); return stat; }
|
|
87
|
+
export function communityPackageDigest(root, { fs = nodeFs } = {}) {
|
|
88
|
+
let files = 0;
|
|
89
|
+
let bytes = 0;
|
|
90
|
+
const entries = [];
|
|
91
|
+
function walk(path) {
|
|
92
|
+
const stat = fs.lstatSync(path);
|
|
93
|
+
if (stat.isSymbolicLink()) fail("auth-adapter-unsafe-path");
|
|
94
|
+
if (stat.isDirectory()) { for (const name of fs.readdirSync(path).sort()) if (join(path, name) !== join(root, ".dispatch-auth-adapter.json")) walk(join(path, name)); return; }
|
|
95
|
+
if (!stat.isFile() || ++files > 20_000 || (bytes += stat.size) > 150 * 1024 * 1024) fail("auth-adapter-tree-unbounded");
|
|
96
|
+
entries.push(`${relative(root, path).split("\\").join("/")}\0${sha(fs.readFileSync(path))}\0`);
|
|
97
|
+
}
|
|
98
|
+
walk(root);
|
|
99
|
+
return sha(entries.sort().join(""));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function packageCheck(adapter, root, fs) {
|
|
103
|
+
if (typeof root !== "string" || !isAbsolute(root)) fail("auth-adapter-unsafe-path");
|
|
104
|
+
const pin = COMMUNITY_AUTH_ADAPTERS[adapter];
|
|
105
|
+
if (!pin?.community) fail("auth-adapter-unsupported");
|
|
106
|
+
regularFile(fs, join(root, "package.json"));
|
|
107
|
+
const pkg = JSON.parse(fs.readFileSync(join(root, "package.json"), "utf8"));
|
|
108
|
+
if (pkg.name !== pin.package || pkg.version !== pin.version) fail("auth-adapter-version-mismatch");
|
|
109
|
+
if (adapter === "pi-anthropic-oauth@0.3.6") {
|
|
110
|
+
const path = join(root, "node_modules/@anthropic-ai/sdk/package.json");
|
|
111
|
+
regularFile(fs, path);
|
|
112
|
+
const sdk = JSON.parse(fs.readFileSync(path, "utf8"));
|
|
113
|
+
if (sdk.name !== "@anthropic-ai/sdk" || sdk.version !== "0.124.0") fail("auth-adapter-version-mismatch");
|
|
114
|
+
}
|
|
115
|
+
const seen = new Set();
|
|
116
|
+
function dependencies(directory) {
|
|
117
|
+
if (seen.has(directory)) return;
|
|
118
|
+
seen.add(directory);
|
|
119
|
+
const manifestPath = join(directory, "package.json");
|
|
120
|
+
regularFile(fs, manifestPath);
|
|
121
|
+
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
|
122
|
+
for (const name of Object.keys(manifest.dependencies ?? {})) {
|
|
123
|
+
if (!/^(?:@[A-Za-z0-9._-]+\/)?[A-Za-z0-9._-]+$/.test(name)) fail("auth-adapter-dependencies-unresolved");
|
|
124
|
+
let search = directory;
|
|
125
|
+
let resolved;
|
|
126
|
+
while (search === root || search.startsWith(`${root}/`)) {
|
|
127
|
+
const candidate = join(search, "node_modules", name);
|
|
128
|
+
if (fs.existsSync(join(candidate, "package.json"))) { resolved = candidate; break; }
|
|
129
|
+
search = dirname(search);
|
|
130
|
+
}
|
|
131
|
+
if (!resolved) fail("auth-adapter-dependencies-unresolved");
|
|
132
|
+
dependencies(resolved);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
dependencies(root);
|
|
136
|
+
return pin;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function prepareCommunityAuthAdapter({ adapter, root, fs = nodeFs }) {
|
|
140
|
+
const pin = packageCheck(adapter, root, fs);
|
|
141
|
+
const outputs = {};
|
|
142
|
+
for (const [path, expected] of Object.entries(COMMUNITY_SOURCE_PINS[adapter])) {
|
|
143
|
+
regularFile(fs, join(root, path));
|
|
144
|
+
const original = fs.readFileSync(join(root, path), "utf8");
|
|
145
|
+
if (sha(original) !== expected) fail("auth-adapter-source-mismatch");
|
|
146
|
+
outputs[path] = adaptCommunitySource(adapter, path, original);
|
|
147
|
+
}
|
|
148
|
+
// Verify every source before changing one; partial staging never receives an approval receipt.
|
|
149
|
+
for (const [path, source] of Object.entries(outputs)) fs.writeFileSync(join(root, path), source);
|
|
150
|
+
const digest = communityPackageDigest(root, { fs });
|
|
151
|
+
const receipt = { version: 1, adapter, adapterRevision: COMMUNITY_ADAPTER_REVISION, package: pin.package, packageVersion: pin.version, digest, sources: Object.fromEntries(Object.entries(outputs).map(([path, source]) => [path, sha(source)])) };
|
|
152
|
+
fs.writeFileSync(join(root, ".dispatch-auth-adapter.json"), JSON.stringify(receipt));
|
|
153
|
+
return { adapter, package: pin.package, version: pin.version, digest };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function verifyCommunityAuthAdapter({ adapter, root, digest, fs = nodeFs }) {
|
|
157
|
+
if (typeof digest !== "string" || !/^[a-f0-9]{64}$/.test(digest)) fail("auth-adapter-unpinned");
|
|
158
|
+
const pin = packageCheck(adapter, root, fs);
|
|
159
|
+
regularFile(fs, join(root, ".dispatch-auth-adapter.json"));
|
|
160
|
+
const receipt = JSON.parse(fs.readFileSync(join(root, ".dispatch-auth-adapter.json"), "utf8"));
|
|
161
|
+
if (receipt.version !== 1 || receipt.adapter !== adapter || receipt.adapterRevision !== COMMUNITY_ADAPTER_REVISION || receipt.digest !== digest || communityPackageDigest(root, { fs }) !== digest) fail("auth-adapter-unpinned");
|
|
162
|
+
for (const path of Object.keys(COMMUNITY_SOURCE_PINS[adapter])) {
|
|
163
|
+
regularFile(fs, join(root, path));
|
|
164
|
+
const actual = sha(fs.readFileSync(join(root, path)));
|
|
165
|
+
if (actual !== receipt.sources?.[path] || actual !== COMMUNITY_ADAPTED_SOURCE_PINS[adapter]?.[path]) fail("auth-adapter-source-mismatch");
|
|
166
|
+
}
|
|
167
|
+
return { adapter, package: pin.package, version: pin.version, digest, compatibility: "verified" };
|
|
168
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Published npm artifacts, MIT licenses retained in staged packages and test fixtures.
|
|
2
|
+
export const COMMUNITY_SOURCE_PINS = {
|
|
3
|
+
"pi-anthropic-oauth@0.3.6": {
|
|
4
|
+
"LICENSE": "e46cbd8e0af9d9642434b1f2c80e5de17253a6766f0e334e676b1486f470f6f3",
|
|
5
|
+
"README.md": "2cb469bb8c3e2f5561de322e6758ed3f81b4fe0fd11bf27864b81f5df511fca7",
|
|
6
|
+
"package.json": "53eb5f1772b1bdf571a8d5395c48be2b5c92fb2aec69743e8b85ae9248167068",
|
|
7
|
+
"src/auth.ts": "31d1c7df052c5353b1c198fa52a2f0b1b416bc83f6496e3c502b738b3ca4b461",
|
|
8
|
+
"src/convert.ts": "5263f2f308b674cdde710c087c4d7d8ff8dd19cae965a06a02e4bfcb0c1940d0",
|
|
9
|
+
"src/index.ts": "32c73197d08cc87bd6e69caea7591cbe116a1630a4777135e330f8d4992078d4",
|
|
10
|
+
"src/prompt.ts": "e3c7c7d595a96be754cea87e9866ae58756e78e7a8112933c9dc85284fd74a1a",
|
|
11
|
+
"src/stream.ts": "7b26bd0758d45e80ce8703d1f5a4dd7713cba25d64c76d17f0271424e38aa2b9",
|
|
12
|
+
"src/usage.ts": "a8c9054514d93fae5e689ddc647fb41d4c91f6a64ccab1c79e59efb2d027d0cb",
|
|
13
|
+
"src/version.ts": "0de73b8019b0cba6fc315908deac2a406a2b447f991b28c5de3bf2bdf3c56950"
|
|
14
|
+
},
|
|
15
|
+
"@gotgenes/pi-anthropic-auth@3.4.3": {
|
|
16
|
+
"CHANGELOG.md": "89c464b1114b2e317492e8ef0ad0d87c49f6f1378af1d1e81592873ba551edd0",
|
|
17
|
+
"LICENSE": "2770a907d8f37ee6fcf820b7f9fc2f51ec4de2aeacf2eeaca0d71b5f18938c61",
|
|
18
|
+
"README.md": "5e111b6319c5824e1df60f886eede5256784b2f41e3545b889c6d23cb0c551aa",
|
|
19
|
+
"package.json": "a2ccd4a410071897465739bf63fbe91f851e1bf66ae2025926f5c4c29cb6a4bb",
|
|
20
|
+
"src/account-profile.ts": "1596f0c4d6e416c72057f91380f7cff6ed032738c9419e5a5881958f8a191cc1",
|
|
21
|
+
"src/anthropic-message.ts": "9e9cd18f9a5126a3cce20baa042751b635cc16280e4e69050026c975950b5699",
|
|
22
|
+
"src/billing-header.ts": "69c88b5f1eda91a1e070bdc91f07c10c9a607d0ddfabbea4f007f38bb011de55",
|
|
23
|
+
"src/billing-version-sync.ts": "f7c20f42eb244bac1300fec8fb56133fd10c621c09c5db93fc28ad77f5083f47",
|
|
24
|
+
"src/claude-code-version.ts": "ab44ae9493f9cc404586961ab2f0a7c1b51123d01cafc8f94e72ff1b8db6d961",
|
|
25
|
+
"src/constants.ts": "03ac3d5c0d54a3e78ba37fe517da58e3e4632950e3d1d331a80830dd6d50df65",
|
|
26
|
+
"src/debug.ts": "a06644b3d52c8040b61338820881ab83acda3a0238b177261b9f5d6c4464991b",
|
|
27
|
+
"src/diagnostics.ts": "6ef582e93c3c8eba564202c2f4c079304561783a4a1dcb08007b206f5fc19880",
|
|
28
|
+
"src/extension-config.ts": "886446388fb75c0094c78caa4196a58025fe2a72ddab762416792d292723751d",
|
|
29
|
+
"src/extra-provider-shaping.ts": "ec95df3f3dd34a2a50cc8d790e35f8d774b506634fdb34893db7f654051add87",
|
|
30
|
+
"src/host-transport.ts": "c5a130e95da77636437695f4bce6987a34790464d66608352def7a6de967adef",
|
|
31
|
+
"src/index.ts": "b9e46cb59ca1f1d30f85979a65c8d26031672a479b01b47b6682f989f1a71dcb",
|
|
32
|
+
"src/oauth-transport.ts": "bc3d3c78143093089dfebc9ebdde35ccf9bf3bc1a8db4cb14de25a4b16740deb",
|
|
33
|
+
"src/request-shaping.ts": "0583250b5f18cd01ac9f030635ecfb0f2b0284fa2c85c51747153bd08efc7722",
|
|
34
|
+
"src/system-prompt-sections.ts": "9d922f8d9feb7a4b6ef45e12b9b47db04052cdf8a096d4f7dac87d2f30aa9036",
|
|
35
|
+
"src/system-prompt-shaping.ts": "524d6e877eec2d58b9d0ba9539b89053f7a65bf34f55d33b11611909e517a138",
|
|
36
|
+
"src/version-rejection.ts": "aac221a3877251fe90d0afba6d9cba65eedd52d360d44e15a2bbe449b544eef1"
|
|
37
|
+
}
|
|
38
|
+
};
|
package/src/config.mjs
CHANGED
|
@@ -7,18 +7,21 @@
|
|
|
7
7
|
|
|
8
8
|
import { chmodSync, existsSync, lstatSync, mkdirSync, realpathSync, statSync } from "node:fs";
|
|
9
9
|
import { homedir, hostname, tmpdir } from "node:os";
|
|
10
|
-
import { delimiter, isAbsolute, posix } from "node:path";
|
|
11
|
-
import {
|
|
10
|
+
import { delimiter, isAbsolute, posix, relative, resolve, sep } from "node:path";
|
|
11
|
+
import { SESSION_KEY_PATTERN } from "./session-key.mjs";
|
|
12
|
+
import { DEFAULT_BACKEND, OPENSHELL_BACKENDS, OPENSHELL_BIND_BACKEND, backendRefusals, parseBackendFloor, parseBackendList } from "./backends.mjs";
|
|
12
13
|
import { egressArmed, egressProxyName } from "./egress.mjs";
|
|
13
14
|
import { MINTED_TOKEN_VARS, RECEIVER_ONLY_VARS } from "./forges.mjs";
|
|
14
15
|
import { SWEEP_INTERVAL_HOURS, SWEEP_INTERVAL_MAX_HOURS } from "./retention-sweep.mjs";
|
|
15
|
-
import {
|
|
16
|
+
import { RUN_MIRROR_MAX_RUNS_DEFAULT, RUN_MIRROR_MAX_RUNS_MAX, RUN_MIRROR_MAX_RUNS_MIN } from "./retention.mjs";
|
|
17
|
+
import { DOCTOR_INTERVAL_HOURS, DOCTOR_INTERVAL_MAX_HOURS } from "./doctor-spawner.mjs";
|
|
18
|
+
import { parseSecretBundleProfiles, parseSecretProfiles } from "./secret-profiles.mjs";
|
|
16
19
|
import { WAIT_AFTER_MAX_DEFAULT_MS, WAIT_INTERVAL_FLOOR_MS, parseWaitProfiles } from "./wait-for.mjs";
|
|
17
20
|
import { imageRefProblem } from "./image-ref.mjs";
|
|
18
21
|
import { jobSizeDefaults } from "./job-size.mjs";
|
|
19
22
|
import { hostBudgetSettings } from "./host-budget.mjs";
|
|
20
23
|
import { CONTAINER_ENV_NAMES, KEYLESS_ENV_NAME, RUNNER_ENV_NAMES } from "./reserved-env.mjs";
|
|
21
|
-
import { modelListProblem } from "./model-ref.mjs";
|
|
24
|
+
import { modelIdProblem, modelListProblem, providerIdProblem } from "./model-ref.mjs";
|
|
22
25
|
import { WORKER_NAME_RE } from "./worker-name.mjs";
|
|
23
26
|
|
|
24
27
|
export { WORKER_NAME_RE };
|
|
@@ -170,7 +173,7 @@ const WORKER_ONLY_WHY = Object.freeze({
|
|
|
170
173
|
*/
|
|
171
174
|
export const EGRESS_ENV_VARS = new Set(["HTTPS_PROXY", "HTTP_PROXY", "NO_PROXY", "NODE_USE_ENV_PROXY"]);
|
|
172
175
|
|
|
173
|
-
function forwardEnvList(raw, egressArmed = false) {
|
|
176
|
+
export function forwardEnvList(raw, egressArmed = false) {
|
|
174
177
|
const names = commaList(raw);
|
|
175
178
|
const minted = names.filter((n) => MINTED_TOKEN_VARS.has(n));
|
|
176
179
|
if (minted.length > 0) {
|
|
@@ -223,6 +226,33 @@ function forwardEnvList(raw, egressArmed = false) {
|
|
|
223
226
|
return names;
|
|
224
227
|
}
|
|
225
228
|
|
|
229
|
+
/**
|
|
230
|
+
* `PI_MODEL` (issue #673): the deployment's default model, held to the one model-id rule every other source of a
|
|
231
|
+
* job's model is (`modelIdProblem`: a trigger's `run.model`, the settings overlay, `pi-dispatch run --model`). The
|
|
232
|
+
* run record writes whichever source won, and the console's runs filter accepts every id that rule does, so a run
|
|
233
|
+
* on the env default is never one the console cannot find. Refused at boot, like every other knob here; doctor
|
|
234
|
+
* fails the same row first. SET BUT EMPTY IS REFUSED, not read as unset: before #673 an empty value survived
|
|
235
|
+
* `??` and every job ran on a model named "", and an `.env` line `PI_MODEL=` is more likely a template left
|
|
236
|
+
* half-filled than a wish for the default. The value is never echoed. The original case is kept: pi resolves
|
|
237
|
+
* ids exactly, and only the check lowercases.
|
|
238
|
+
*/
|
|
239
|
+
export function modelFrom(env) {
|
|
240
|
+
const raw = env.PI_MODEL;
|
|
241
|
+
if (raw === undefined) return DEFAULT_MODEL;
|
|
242
|
+
const problem = modelIdProblem(raw);
|
|
243
|
+
if (problem !== null) throw configError(`invalid PI_MODEL: it ${problem} (leave it unset for the default)`);
|
|
244
|
+
return raw;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** `PI_PROVIDER` (issue #673), by `modelFrom`'s rule against the provider shape. */
|
|
248
|
+
export function providerFrom(env) {
|
|
249
|
+
const raw = env.PI_PROVIDER;
|
|
250
|
+
if (raw === undefined) return DEFAULT_PROVIDER;
|
|
251
|
+
const problem = providerIdProblem(raw);
|
|
252
|
+
if (problem !== null) throw configError(`invalid PI_PROVIDER: it ${problem} (leave it unset for the default)`);
|
|
253
|
+
return raw;
|
|
254
|
+
}
|
|
255
|
+
|
|
226
256
|
/**
|
|
227
257
|
* PI_ALLOWED_MODELS (issue #502): the deployment's allowed-model list, comma-separated `provider/model`, the
|
|
228
258
|
* grammar a trigger's `run.models` has (`modelListProblem`, model-ref.mjs), so a list means the same thing in
|
|
@@ -289,6 +319,27 @@ function backendFloorOf(env) {
|
|
|
289
319
|
}
|
|
290
320
|
}
|
|
291
321
|
|
|
322
|
+
/**
|
|
323
|
+
* The openshell venue's two settings (issue #684): `PI_OPENSHELL_ENGINE`, the gateway's compute driver whose CLI reads the
|
|
324
|
+
* job image (`docker`, the default, or `podman`, the two v1 admits), and `PI_OPENSHELL_GATEWAY`, a registered gateway's
|
|
325
|
+
* name (unset: the CLI's active one). Judged only while the venue is blessed, as doctor judges them, so a value left in
|
|
326
|
+
* the env of a deployment that never chose the venue refuses nothing; while blessed a bad one stops the boot here rather
|
|
327
|
+
* than every job at its start.
|
|
328
|
+
*
|
|
329
|
+
* Issue #686 part 3: both OpenShell venues share them (one gateway, one engine), so either name in PI_BACKENDS judges
|
|
330
|
+
* them. Bind mode is refused on the Podman driver: it was measured on the Docker driver only, and a rootless Podman maps
|
|
331
|
+
* the sandbox's uid away from the worker's, so a bind would be neither readable nor removable by the uid that owns it.
|
|
332
|
+
*/
|
|
333
|
+
export function openshellFrom(env, backends) {
|
|
334
|
+
const engine = env.PI_OPENSHELL_ENGINE === undefined || env.PI_OPENSHELL_ENGINE === "" ? "docker" : env.PI_OPENSHELL_ENGINE;
|
|
335
|
+
const gateway = env.PI_OPENSHELL_GATEWAY || null;
|
|
336
|
+
if (!OPENSHELL_BACKENDS.some((name) => backends.includes(name))) return { engine, gateway };
|
|
337
|
+
if (engine !== "docker" && engine !== "podman") throw configError(`PI_OPENSHELL_ENGINE must be docker or podman, the compute drivers the openshell venue admits (got ${JSON.stringify(engine)})`);
|
|
338
|
+
if (gateway !== null && !/^[A-Za-z0-9][A-Za-z0-9_.-]{0,62}$/.test(gateway)) throw configError("PI_OPENSHELL_GATEWAY must be a registered gateway's name (`openshell gateway list`), or unset for the CLI's active gateway");
|
|
339
|
+
if (engine === "podman" && backends.includes(OPENSHELL_BIND_BACKEND)) throw configError(`PI_BACKENDS names ${OPENSHELL_BIND_BACKEND} with PI_OPENSHELL_ENGINE=podman: bind mode was measured on OpenShell's Docker driver only, and a rootless Podman maps the sandbox's uid away from the worker's, so its binds would not be the worker's to read or remove. Use the docker driver, or the copy venue (openshell)`);
|
|
340
|
+
return { engine, gateway };
|
|
341
|
+
}
|
|
342
|
+
|
|
292
343
|
/**
|
|
293
344
|
* THE BOOT REFUSAL. A deployment whose configuration needs something its backends cannot provide is refused
|
|
294
345
|
* here, named backend by named property, rather than discovering it per job.
|
|
@@ -389,6 +440,37 @@ export function globalExtensionsEnabled(env) {
|
|
|
389
440
|
);
|
|
390
441
|
}
|
|
391
442
|
|
|
443
|
+
/**
|
|
444
|
+
* Does the worker publish each running job's transcript to Valkey (issue #643, `DES-LIVE-TRANSCRIPT-STREAM`)?
|
|
445
|
+
*
|
|
446
|
+
* ON by default: watching a run is what the console and the panel are for, and what leaves the host is redacted,
|
|
447
|
+
* capped at 2,000 entries and gone 24 hours after the run. `PI_LIVE_TRANSCRIPTS=0` is the opt-out, for a deployment
|
|
448
|
+
* that must keep transcript text (PII: issue and tool text) off its Valkey. Strict, for `globalExtensionsEnabled`'s
|
|
449
|
+
* reason: `PI_LIVE_TRANSCRIPTS=false` read as ON would be an operator believing PII stays on the host while it does
|
|
450
|
+
* not, so any value but unset, empty, "1" or "0" refuses to boot.
|
|
451
|
+
*/
|
|
452
|
+
export function liveTranscriptsEnabled(env) {
|
|
453
|
+
const raw = env.PI_LIVE_TRANSCRIPTS;
|
|
454
|
+
if (raw === undefined || raw === "" || raw === "1") return true;
|
|
455
|
+
if (raw === "0") return false;
|
|
456
|
+
throw configError(`invalid PI_LIVE_TRANSCRIPTS: ${JSON.stringify(raw)} (want "0" to keep transcripts off Valkey, or leave it unset to publish them)`);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Does the worker read each running job's progress from its transcript (issue #644, `DES-RUN-PROGRESS`)? Turns, steps,
|
|
461
|
+
* the tool running and a declared phase, to the `run:progress:` key, the run record's `progress` and, where a trigger
|
|
462
|
+
* opts in, the forge status comment. ON by default, like the live transcript. `PI_RUN_PROGRESS=0` turns all three off.
|
|
463
|
+
* Strict, for `liveTranscriptsEnabled`'s reason: any value but unset, empty, "1" or "0" refuses to boot. Independent of
|
|
464
|
+
* `PI_LIVE_TRANSCRIPTS`: with transcripts off and progress on, the file is still tailed and no transcript text is
|
|
465
|
+
* published (the key carries a redacted phase name and note only, from a job that opted into `run.progress`).
|
|
466
|
+
*/
|
|
467
|
+
export function runProgressEnabled(env) {
|
|
468
|
+
const raw = env.PI_RUN_PROGRESS;
|
|
469
|
+
if (raw === undefined || raw === "" || raw === "1") return true;
|
|
470
|
+
if (raw === "0") return false;
|
|
471
|
+
throw configError(`invalid PI_RUN_PROGRESS: ${JSON.stringify(raw)} (want "0" to turn run progress off, or leave it unset to read it)`);
|
|
472
|
+
}
|
|
473
|
+
|
|
392
474
|
/**
|
|
393
475
|
* Parse the worker's config from `env` (default process.env). Every MONEY default is conservative:
|
|
394
476
|
* spend controls (`PI_DAILY_CAP`, `PI_MAX_TURNS`) exist to bound money, so they default low, and a
|
|
@@ -402,12 +484,14 @@ export function globalExtensionsEnabled(env) {
|
|
|
402
484
|
* spend caps above, the per-job token scoping, or the admin-extension recursion block.
|
|
403
485
|
*/
|
|
404
486
|
export function loadConfig(env = process.env, { fileExists = existsSync } = {}) {
|
|
405
|
-
const model = env
|
|
487
|
+
const model = modelFrom(env); // an exact model version, not a moving alias; deterministic per CONST-PI-VERSION-PINNED. Shape-checked (#673)
|
|
406
488
|
// #227. Hoisted above the object because `defaultBackend` INDEXES `backends`, and a property cannot read
|
|
407
489
|
// a sibling of the literal it is in. (Calling the parser twice would be harmless -- `egressEnabled` is
|
|
408
490
|
// called twice a few properties down for the same reason -- so this is about the index, not the throw.)
|
|
409
491
|
const backends = backendSet(env);
|
|
410
492
|
const backendFloor = backendFloorOf(env);
|
|
493
|
+
// Issue #761: hoisted for the same reason, `consoleAuditRetentionDays` inherits it when its own key is unset.
|
|
494
|
+
const logRetentionDays = nonNegativeInt(env, "PI_LOG_RETENTION_DAYS", 30); // 0 = keep forever
|
|
411
495
|
const config = {
|
|
412
496
|
valkeyUrl: env.VALKEY_URL ?? DEFAULT_VALKEY_URL,
|
|
413
497
|
// Issue #57. What this machine calls itself: the key of its registry row, the `host` on every log
|
|
@@ -423,7 +507,7 @@ export function loadConfig(env = process.env, { fileExists = existsSync } = {})
|
|
|
423
507
|
weeklyCap: optionalBoundedInt(env, "PI_WEEKLY_CAP", 1), // REQ-SPEND-CAPS-MULTI-WINDOW; null = weekly window disabled
|
|
424
508
|
monthlyCap: optionalBoundedInt(env, "PI_MONTHLY_CAP", 1), // null = monthly window disabled
|
|
425
509
|
softHoldPct: optionalBoundedInt(env, "PI_SOFT_HOLD_PCT", 1, 99), // null = soft-hold band disabled
|
|
426
|
-
provider: env
|
|
510
|
+
provider: providerFrom(env),
|
|
427
511
|
model,
|
|
428
512
|
maxTurns: positiveInt(env, "PI_MAX_TURNS", 30), // pi has no turn limit; we impose one
|
|
429
513
|
allowedModels: allowedModelsFrom(env), // issue #502: the deployment's allowed-model list; null = unrestricted. ENV ONLY, never the settings overlay
|
|
@@ -464,6 +548,8 @@ export function loadConfig(env = process.env, { fileExists = existsSync } = {})
|
|
|
464
548
|
egressProxy: egressProxyName(env), // one derivation, shared with the panel's sandbox (#277)
|
|
465
549
|
forwardEnv: forwardEnvList(env.PI_FORWARD_ENV, egressEnabled(env)), // extra host var NAMES to forward (e.g. a custom provider's key); explicit allowlist, GitHub token names refused
|
|
466
550
|
authFromPi: env.PI_AUTH_FROM_PI !== "0", // ON by default: use the key in ~/.pi/agent/auth.json when the env has none (api-key only). PI_AUTH_FROM_PI=0 forces env-only.
|
|
551
|
+
authConnectionsFile: env.PI_AUTH_CONNECTIONS_FILE ? resolve(env.PI_AUTH_CONNECTIONS_FILE) : null,
|
|
552
|
+
authStoreDir: env.PI_AUTH_STORE_DIR ? resolve(env.PI_AUTH_STORE_DIR) : null,
|
|
467
553
|
jobsDir: jobsDirPath(env),
|
|
468
554
|
// REQ-RESURRECTABLE-SANDBOX. `||` (not `??`) so an empty string falls back, matching logsDir.
|
|
469
555
|
sandboxDir: env.PI_SANDBOX_DIR || defaultSandboxDir(env),
|
|
@@ -485,13 +571,31 @@ export function loadConfig(env = process.env, { fileExists = existsSync } = {})
|
|
|
485
571
|
logsDir: logsDirPath(env), // || (not ??) inside logsDirPath, so an empty string falls back to the default
|
|
486
572
|
settingsFile: settingsFilePath(env), // || (not ??) inside settingsFilePath, so an empty string falls back; INT-CONFIG-OVERLAY-CONTRACT
|
|
487
573
|
captureJobLogs: env.PI_CAPTURE_JOB_LOGS === "1", // no-pii-in-logs: raw job-log capture is opt-in; anything but "1" is off
|
|
488
|
-
|
|
574
|
+
liveTranscripts: liveTranscriptsEnabled(env), // issue #643: the live transcript stream, ON unless PI_LIVE_TRANSCRIPTS=0
|
|
575
|
+
runProgress: runProgressEnabled(env), // issue #644: run progress (key, record field, status comment), ON unless PI_RUN_PROGRESS=0
|
|
576
|
+
logRetentionDays,
|
|
577
|
+
// Issue #761: the console audit trail's own retention (`console-audit/` month files), so that shortening the run
|
|
578
|
+
// history for privacy does not shorten the security trail with it. Unset or empty INHERITS `logRetentionDays`,
|
|
579
|
+
// 0 included (keep forever), which is exactly what every release before this one did; "0" keeps forever; any other
|
|
580
|
+
// non-negative integer is days; anything else refuses to boot naming the key. The worker reads it because the worker
|
|
581
|
+
// runs the sweep (start.mjs `makeConsoleAuditReaper`); the console never reaps. Not a `PI_CONSOLE_*` key the console
|
|
582
|
+
// reads (doctor's `CONSOLE_ENV_KEYS`): setting only this one must not make doctor think a console exists here.
|
|
583
|
+
consoleAuditRetentionDays: nonNegativeInt(env, "PI_CONSOLE_AUDIT_RETENTION_DAYS", logRetentionDays),
|
|
584
|
+
// Issue #761: how many runs the shared run index (`runs:index`) keeps. Every writer trims the shared index to its
|
|
585
|
+
// OWN cap, so in a fleet the lowest setting wins, and each trim raises the fleet's horizon (`runs:horizon`) so a
|
|
586
|
+
// reader sees the cut rather than idle time. Bounded both ways (retention.mjs says why): a value out of bounds, or
|
|
587
|
+
// not a plain integer, refuses to boot naming the key; unset or empty is the default.
|
|
588
|
+
runMirrorMaxRuns: boundedInt(env, "PI_RUN_MIRROR_MAX_RUNS", RUN_MIRROR_MAX_RUNS_DEFAULT, RUN_MIRROR_MAX_RUNS_MIN, `an integer ${RUN_MIRROR_MAX_RUNS_MIN}-${RUN_MIRROR_MAX_RUNS_MAX}`, RUN_MIRROR_MAX_RUNS_MAX),
|
|
489
589
|
// issue #292, OQ-007: how often the three host-side retention sweeps re-run while the worker is up.
|
|
490
590
|
// 0 = BOOT-ONLY, which is byte-identical to every version before this one -- start.mjs does not even
|
|
491
591
|
// CONSTRUCT the sweep at 0, so no timer, no closer and no second invocation of any reaper is reachable.
|
|
492
592
|
// The ceiling is enforced because setInterval clamps a delay past 2^31-1 ms to 1ms (see
|
|
493
593
|
// SWEEP_INTERVAL_MAX_HOURS); a hot loop over the filesystem is a worse failure than a slow sweep.
|
|
494
594
|
sweepIntervalHours: nonNegativeInt(env, "PI_SWEEP_INTERVAL_HOURS", SWEEP_INTERVAL_HOURS, SWEEP_INTERVAL_MAX_HOURS),
|
|
595
|
+
// Issue #712: how often the worker runs `pi-dispatch doctor --publish` as a child (the first run shortly after boot).
|
|
596
|
+
// 0 = never, and start.mjs then constructs nothing; capped for the sweep's reason (setInterval's clamp).
|
|
597
|
+
updatesLook: env.PI_UPDATES_LOOK === "1",
|
|
598
|
+
doctorIntervalHours: nonNegativeInt(env, "PI_DOCTOR_INTERVAL_HOURS", DOCTOR_INTERVAL_HOURS, DOCTOR_INTERVAL_MAX_HOURS),
|
|
495
599
|
// REQ-RESUMABLE-SESSION. NO DEFAULT AT ALL, deliberately unlike every sibling above: unset means the
|
|
496
600
|
// feature is unavailable, and a trigger that armed run.resume then refuses PRE-SPEND rather than
|
|
497
601
|
// running silently without persistence. A transcript is the most PII-bearing artifact this system
|
|
@@ -500,6 +604,9 @@ export function loadConfig(env = process.env, { fileExists = existsSync } = {})
|
|
|
500
604
|
// not rest on where the other stores default: issue #290 moved logs and settings to ~/.pi-dispatch
|
|
501
605
|
// and this one still has no default, for the same reason it never did.
|
|
502
606
|
sessionsDir: env.PI_SESSIONS_DIR || null,
|
|
607
|
+
// Issue #645: where workspace checkpoints live. Resolved, defaulting INSIDE the session store and null when neither
|
|
608
|
+
// is set; `checkpointsDirPath` says why. Read here so doctor and the (later) checkpointer cannot derive it twice.
|
|
609
|
+
checkpointsDir: checkpointsStoreRefusingCollision(env),
|
|
503
610
|
sessionsTtlDays: nonNegativeInt(env, "PI_SESSIONS_TTL_DAYS", 14), // 0 = keep forever
|
|
504
611
|
// A bound on how large a transcript may be before it stops being resumed. Not disk hygiene: an
|
|
505
612
|
// oversized transcript is a prefill an operator never sized PI_MAX_TOKENS for.
|
|
@@ -529,6 +636,12 @@ export function loadConfig(env = process.env, { fileExists = existsSync } = {})
|
|
|
529
636
|
// hazard `delimitedList` above exists for, arriving from the other side. Unset = the feature is off and
|
|
530
637
|
// any trigger naming secrets refuses pre-spend, which is why there is no default profile to fall into.
|
|
531
638
|
secretProfiles: parseSecretProfiles(env.PI_SECRET_PROFILES),
|
|
639
|
+
// Issue #704. The operator's declared BUNDLE resolvers, `name:absolute-path:format` (dotenv or json, required).
|
|
640
|
+
// Its own variable, never a flag on PI_SECRET_PROFILES (secret-profiles.mjs says why). Unset = no bundle can
|
|
641
|
+
// resolve on this host, so a project bound to one refuses pre-spend as secret-bundle-profile-unknown. Bounded by
|
|
642
|
+
// the same PI_SECRET_RESOLVER_ROOTS below for its overlay half, and timed by PI_SECRET_RESOLVE_TIMEOUT_MS: one
|
|
643
|
+
// call per job, so one timeout, never a multiple of it.
|
|
644
|
+
secretBundleProfiles: parseSecretBundleProfiles(env.PI_SECRET_BUNDLES),
|
|
532
645
|
// The directories a resolver may live in. Default [] FAILS CLOSED exactly as dispatchRunRoots does, and
|
|
533
646
|
// for a sharper version of its reason: this bounds paths that can arrive from the settings overlay,
|
|
534
647
|
// which is not the reviewed artifact `triggers.json` is. Unset means the panel can declare no profile
|
|
@@ -587,6 +700,7 @@ export function loadConfig(env = process.env, { fileExists = existsSync } = {})
|
|
|
587
700
|
gitlab: loadGitLabAuth(env),
|
|
588
701
|
forgejo: loadForgejoAuth(env),
|
|
589
702
|
azure: loadAzureAuth(env),
|
|
703
|
+
openshell: openshellFrom(env, backends), // issue #684: the openshell venue's engine and gateway
|
|
590
704
|
};
|
|
591
705
|
|
|
592
706
|
// #227, and it runs AFTER the object is built rather than inside it: the refusal reads `egress` as well
|
|
@@ -710,11 +824,10 @@ export function loadGitHubAuth(env, fileExists) {
|
|
|
710
824
|
return { source, patVar, appId, installationId, privateKeyPath, privateKey };
|
|
711
825
|
}
|
|
712
826
|
|
|
713
|
-
// env-internal TMPDIR, TEMP: the OS temp dir, read to place the default job
|
|
827
|
+
// env-internal TMPDIR, TEMP: the OS temp dir, read to place the default job and sandbox paths
|
|
714
828
|
// below -- and, only on a host with no home directory to name, as the last-resort fallback for the
|
|
715
829
|
// durable state root. Not a variable of this project's and not a deployment knob: PI_JOBS_DIR,
|
|
716
|
-
// PI_LOGS_DIR
|
|
717
|
-
// .env.example says so.
|
|
830
|
+
// PI_LOGS_DIR and PI_SETTINGS_FILE are how an operator moves any of them, and .env.example says so.
|
|
718
831
|
|
|
719
832
|
/** This process's effective uid, the one its files are created as, or null where the platform has none (Windows). */
|
|
720
833
|
function currentUid() {
|
|
@@ -722,7 +835,7 @@ function currentUid() {
|
|
|
722
835
|
}
|
|
723
836
|
|
|
724
837
|
/**
|
|
725
|
-
* The per-account root under the OS temp dir that the default jobs
|
|
838
|
+
* The per-account root under the OS temp dir that the default jobs and sandbox paths live in (issue #464):
|
|
726
839
|
* `<tmp>/pi-dispatch-<uid>`, or `<tmp>/pi-dispatch` where the platform has no uid (Windows, whose TEMP is per user).
|
|
727
840
|
*
|
|
728
841
|
* Per ACCOUNT because the OS temp dir is shared by every account on a host. The old `<tmp>/pi-dispatch` was created by
|
|
@@ -805,7 +918,7 @@ export function sandboxDirOwnerFix(dir) {
|
|
|
805
918
|
|
|
806
919
|
/**
|
|
807
920
|
* Secure the per-account root (`ensureAccountTempRoot`) when `path` lies in it, else do nothing (issue #464). The one
|
|
808
|
-
* rule for every default that can live there: the jobs
|
|
921
|
+
* rule for every default that can live there: the jobs and sandbox dirs, and, on an account with no home, the
|
|
809
922
|
* run history and the settings overlay (`defaultStateDir`). Returns whether it did.
|
|
810
923
|
*/
|
|
811
924
|
export function ensureUnderAccountRoot(path, { env = process.env, uid = currentUid(), fs = { mkdirSync, lstatSync, statSync, chmodSync } } = {}) {
|
|
@@ -825,8 +938,8 @@ export function jobsDirOwnerFix(dir, inRoot) {
|
|
|
825
938
|
|
|
826
939
|
/**
|
|
827
940
|
* Create `root` mode 0700 when it is absent, and refuse it unless it is a real directory (never a symlink, lstat) owned
|
|
828
|
-
* by `uid`; one this account owns with group or other bits is tightened to 0700. Exported for
|
|
829
|
-
*
|
|
941
|
+
* by `uid`; one this account owns with group or other bits is tightened to 0700. Exported for its tests and for
|
|
942
|
+
* doctor's reading of its refusals. Throws a `configError` naming the path and the remedy.
|
|
830
943
|
*
|
|
831
944
|
* lstat FIRST, never a recursive mkdir first (gate round 1, measured on Fedora 44): with fs.protected_symlinks on (the
|
|
832
945
|
* default on Fedora and Ubuntu), following another account's symlink under the sticky /tmp fails EACCES, and the
|
|
@@ -1037,6 +1150,67 @@ export function logsDirPath(env = process.env, home) {
|
|
|
1037
1150
|
return env.PI_LOGS_DIR || defaultLogsDir(env, home);
|
|
1038
1151
|
}
|
|
1039
1152
|
|
|
1153
|
+
/** The checkpoint store's directory name under PI_SESSIONS_DIR when PI_CHECKPOINTS_DIR does not name one (issue #645). */
|
|
1154
|
+
export const CHECKPOINTS_SUBDIR = ".checkpoints";
|
|
1155
|
+
|
|
1156
|
+
/**
|
|
1157
|
+
* The checkpoint store (issue #645): `PI_CHECKPOINTS_DIR`, else `<PI_SESSIONS_DIR>/.checkpoints`, else `null`, resolved
|
|
1158
|
+
* against the working directory so doctor and the worker name the same absolute path.
|
|
1159
|
+
*
|
|
1160
|
+
* DEFAULTED INSIDE THE SESSION STORE, and only there, on `sessionsDir`'s own reasoning: a checkpoint holds a job's
|
|
1161
|
+
* workspace, which is as PII-bearing as a transcript, so it gets no default of its own on a path nobody chose (a temp
|
|
1162
|
+
* dir above all). Where the operator already chose a private host-only directory for transcripts, a sibling inside it
|
|
1163
|
+
* inherits that decision. The DOT name keeps it out of the session reaper, which judges only key-shaped names and skips
|
|
1164
|
+
* dot names silently (`reapSessions`), so the session TTL never removes a checkpoint. `null` means the feature has
|
|
1165
|
+
* nowhere to write, and whatever uses it says so rather than writing somewhere.
|
|
1166
|
+
*/
|
|
1167
|
+
export function checkpointsDirPath(env = process.env) {
|
|
1168
|
+
if (env.PI_CHECKPOINTS_DIR) return resolve(env.PI_CHECKPOINTS_DIR);
|
|
1169
|
+
if (env.PI_SESSIONS_DIR) return resolve(env.PI_SESSIONS_DIR, CHECKPOINTS_SUBDIR);
|
|
1170
|
+
return null;
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
/**
|
|
1174
|
+
* Why the checkpoint store and the session store cannot share this layout, or `null` when they can (issue #645). Both
|
|
1175
|
+
* stores name their entries by 32 lower-case hex characters and each sweeps its own, so a layout where one store's
|
|
1176
|
+
* entries are the other's makes each sweep delete the other's folders: the session reaper removes a key-shaped
|
|
1177
|
+
* directory once it ages, whatever is inside it. Three layouts collide:
|
|
1178
|
+
*
|
|
1179
|
+
* - the same directory (`PI_CHECKPOINTS_DIR` equal to `PI_SESSIONS_DIR`);
|
|
1180
|
+
* - the session store INSIDE the checkpoint store, where the checkpoint sweep judges the session store's folders;
|
|
1181
|
+
* - the checkpoint store inside the session store BELOW a key-shaped name (`<sessions>/<32 hex>[/...]`), which the
|
|
1182
|
+
* session reaper takes for a session key and removes whole.
|
|
1183
|
+
*
|
|
1184
|
+
* The default (`<sessions>/.checkpoints`) and any other non-key child are fine: the reaper judges only key-shaped names
|
|
1185
|
+
* and skips dot names silently. Compared as resolved paths, the way both stores open them; a link between the two is
|
|
1186
|
+
* not followed, which a later realpath-based check could add if a layout ever needs it.
|
|
1187
|
+
*/
|
|
1188
|
+
export function checkpointStoreCollision(sessionsDir, checkpointsDir) {
|
|
1189
|
+
if (!sessionsDir || !checkpointsDir) return null;
|
|
1190
|
+
const sessions = resolve(sessionsDir);
|
|
1191
|
+
const checkpoints = resolve(checkpointsDir);
|
|
1192
|
+
if (sessions === checkpoints) return "they are the same directory";
|
|
1193
|
+
const inside = (child, parent) => {
|
|
1194
|
+
const rel = relative(parent, child);
|
|
1195
|
+
// `..` as a whole first segment only: a child literally named `..notes` is inside.
|
|
1196
|
+
return rel !== "" && rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel) ? rel : null;
|
|
1197
|
+
};
|
|
1198
|
+
if (inside(sessions, checkpoints) !== null) return "the session store is inside the checkpoint store";
|
|
1199
|
+
const below = inside(checkpoints, sessions);
|
|
1200
|
+
if (below !== null && SESSION_KEY_PATTERN.test(below.split(sep)[0])) return "the checkpoint store is inside a session key's directory, which the session reaper removes whole";
|
|
1201
|
+
return null;
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
/** `checkpointsDirPath`, refused at load where it collides with the session store (`checkpointStoreCollision`). */
|
|
1205
|
+
function checkpointsStoreRefusingCollision(env) {
|
|
1206
|
+
const dir = checkpointsDirPath(env);
|
|
1207
|
+
const why = checkpointStoreCollision(env.PI_SESSIONS_DIR || null, dir);
|
|
1208
|
+
if (why) {
|
|
1209
|
+
throw configError(`PI_CHECKPOINTS_DIR (${dir}) and PI_SESSIONS_DIR (${resolve(env.PI_SESSIONS_DIR)}) collide: ${why}, so the session reaper and the checkpoint sweep would delete each other's folders. Unset PI_CHECKPOINTS_DIR (the default is <PI_SESSIONS_DIR>/.checkpoints) or point it at a directory outside PI_SESSIONS_DIR`);
|
|
1210
|
+
}
|
|
1211
|
+
return dir;
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1040
1214
|
/**
|
|
1041
1215
|
* The resolved settings-overlay path. Lives HERE, beside `logsDirPath`, so the pair of durable stores has
|
|
1042
1216
|
* one derivation each and `loadConfig` does not open-code either. `runtime-settings.mjs` re-exports it, so
|
|
@@ -1200,7 +1374,7 @@ export function defaultWorkerName() {
|
|
|
1200
1374
|
* typed is refused loudly and never quietly altered. Defaulting is a convenience, so it must not be able
|
|
1201
1375
|
* to fail; declaring is a statement, so a typo in it must not become a different machine's name.
|
|
1202
1376
|
*/
|
|
1203
|
-
function workerName(env) {
|
|
1377
|
+
export function workerName(env) {
|
|
1204
1378
|
const declared = env.PI_WORKER_NAME;
|
|
1205
1379
|
if (declared === undefined || declared === "") return defaultWorkerName();
|
|
1206
1380
|
if (!WORKER_NAME_RE.test(declared)) {
|
|
@@ -1212,19 +1386,6 @@ function workerName(env) {
|
|
|
1212
1386
|
return declared;
|
|
1213
1387
|
}
|
|
1214
1388
|
|
|
1215
|
-
export function defaultGraphDir(env = process.env, uid = currentUid()) {
|
|
1216
|
-
// Under the OS temp dir by default, beside logs/ and jobs/ -- the admin's graph HTML artifact
|
|
1217
|
-
// (issue #54) is host-side display output on the defaultLogsDir doctrine, and deliberately NOT
|
|
1218
|
-
// inside logsDir: INT-RUN-HISTORY-FILE-CONTRACT names that directory's filename shape, and a
|
|
1219
|
-
// stray .html beside the sidecars would widen a contract for a file that is not a record.
|
|
1220
|
-
// Overridable with PI_GRAPH_DIR; exported so the admin resolves the same default without
|
|
1221
|
-
// loadConfig, like defaultSandboxDir above.
|
|
1222
|
-
// Issue #464: under the per-account root, for the jobs dir's reason: another account's `<tmp>/pi-dispatch` made
|
|
1223
|
-
// this one uncreatable.
|
|
1224
|
-
return `${accountTempRoot(env, uid)}/graph`;
|
|
1225
|
-
}
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
1389
|
/**
|
|
1229
1390
|
* The worker's Azure DevOps auth config, or `null` when none is configured -- same presence rule as the
|
|
1230
1391
|
* other two optional forges.
|