@edgehero/pi-dispatch 4.3.1 → 5.1.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 +21 -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 +118 -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 +136 -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 +325 -0
- package/src/console-client.mjs +848 -0
- package/src/console-devices.mjs +372 -0
- package/src/console-install.mjs +239 -0
- package/src/console-open.mjs +99 -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 +1657 -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/envelope.mjs +5 -3
- 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 +4 -2
- 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 +12 -2
- 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 +36 -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 +402 -53
- 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,242 @@
|
|
|
1
|
+
/** Closed host-side setup operations. HTTP never chooses a program, argv, directory or environment (#774). */
|
|
2
|
+
import * as nodeFs from "node:fs";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { homedir, hostname, userInfo } from "node:os";
|
|
5
|
+
import { dirname, join, resolve } from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { deploymentServiceEnv } from "./service-env.mjs";
|
|
8
|
+
import { ENV_WRITER_FS, planEnvEdit, readEnvAssignments, updateEnvFile } from "./env-file.mjs";
|
|
9
|
+
import { planServiceSetup, runService } from "./service.mjs";
|
|
10
|
+
import { apiKeyVariable, needsHostAuthentication, nonApiKeyKind } from "./provider-key.mjs";
|
|
11
|
+
import { resolveSetupWorkerEntry } from "./setup-host-entry.mjs";
|
|
12
|
+
import { SETUP_LIMITS, SETUP_VERSION, validSetupRequest } from "./setup-contract.mjs";
|
|
13
|
+
import { acquireSetupLock } from "./setup-lock.mjs";
|
|
14
|
+
import { providerFrom, settingsFilePath, workerName } from "./config.mjs";
|
|
15
|
+
import { readOverlay } from "./runtime-settings.mjs";
|
|
16
|
+
|
|
17
|
+
const hash = (value) => createHash("sha256").update(typeof value === "string" || Buffer.isBuffer(value) ? value : JSON.stringify(value)).digest("hex");
|
|
18
|
+
const MODULE_DIR = dirname(fileURLToPath(import.meta.url));
|
|
19
|
+
function failure(reason) { return { refusal: reason }; }
|
|
20
|
+
|
|
21
|
+
function sourceSnapshot(path, ctx) {
|
|
22
|
+
let fd;
|
|
23
|
+
try {
|
|
24
|
+
// Check the spelling as well as its target: an untrusted parent must not be able to
|
|
25
|
+
// redirect the settings/policy read between preview and the credential rename.
|
|
26
|
+
for (let parent = dirname(path); ; parent = dirname(parent)) {
|
|
27
|
+
let st;
|
|
28
|
+
try { st = ctx.fs.lstatSync(parent); } catch (e) { if (e?.code !== "ENOENT") throw e; }
|
|
29
|
+
if (st && (!st.isDirectory() || (st.uid !== ctx.uid && st.uid !== 0) || ((st.mode & 0o022) && !(st.mode & 0o1000)))) return failure("env-unsafe");
|
|
30
|
+
if (parent === dirname(parent)) break;
|
|
31
|
+
}
|
|
32
|
+
const st = ctx.fs.lstatSync(path);
|
|
33
|
+
if (!st.isFile() || st.isSymbolicLink() || (st.uid !== ctx.uid && st.uid !== 0) || (st.mode & 0o022) || st.size > 1024 * 1024) return failure("env-unsafe");
|
|
34
|
+
fd = ctx.fs.openSync(path, nodeFs.constants.O_RDONLY | nodeFs.constants.O_NOFOLLOW | nodeFs.constants.O_NONBLOCK);
|
|
35
|
+
const opened = ctx.fs.fstatSync(fd);
|
|
36
|
+
if (!opened.isFile() || opened.dev !== st.dev || opened.ino !== st.ino || (opened.uid !== ctx.uid && opened.uid !== 0) || (opened.mode & 0o022) || opened.size > 1024 * 1024) return failure("env-unsafe");
|
|
37
|
+
const bytes = ctx.fs.readFileSync(fd);
|
|
38
|
+
if (bytes.length > 1024 * 1024) return failure("env-unsafe");
|
|
39
|
+
return { path, digest: hash({ bytes: hash(bytes), dev: opened.dev, ino: opened.ino, mode: opened.mode, uid: opened.uid }), bytes };
|
|
40
|
+
} catch (e) { return e?.code === "ENOENT" ? { path, digest: null, bytes: null } : failure("env-unsafe"); }
|
|
41
|
+
finally { if (fd !== undefined) ctx.fs.closeSync(fd); }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** One judged descriptor, so a FIFO/symlink or another account's file is never a setup input. */
|
|
45
|
+
function privateEnv(dir, { fs, uid }) {
|
|
46
|
+
const path = join(dir, ".env");
|
|
47
|
+
let fd;
|
|
48
|
+
try {
|
|
49
|
+
const st = fs.lstatSync(path);
|
|
50
|
+
if (!st.isFile() || st.isSymbolicLink() || st.uid !== uid || (st.mode & 0o077) !== 0) return failure("env-unsafe");
|
|
51
|
+
fd = fs.openSync(path, (fs.constants?.O_RDONLY ?? nodeFs.constants.O_RDONLY) | (fs.constants?.O_NOFOLLOW ?? nodeFs.constants.O_NOFOLLOW));
|
|
52
|
+
const opened = fs.fstatSync(fd);
|
|
53
|
+
if (!opened.isFile() || opened.dev !== st.dev || opened.ino !== st.ino || opened.uid !== uid || (opened.mode & 0o077) !== 0) return failure("env-unsafe");
|
|
54
|
+
const bytes = fs.readFileSync(fd);
|
|
55
|
+
if (bytes.length > 1024 * 1024) return failure("env-unsafe");
|
|
56
|
+
return { path, bytes, identity: { dev: opened.dev, ino: opened.ino, mode: opened.mode, uid: opened.uid }, digest: hash(bytes) };
|
|
57
|
+
} catch { return failure("env-unsafe"); } finally { if (fd !== undefined) fs.closeSync(fd); }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function providerPlan(request, ctx, envRead) {
|
|
61
|
+
const keys = ["PI_PROVIDER", "PI_AUTH_CONNECTIONS_FILE", "PI_SETTINGS_FILE", "PI_AUTH_FROM_PI", "PI_CODING_AGENT_DIR"];
|
|
62
|
+
const declared = deploymentServiceEnv({ env: {}, dir: ctx.dir, keys, fs: ctx.fs, uid: ctx.uid, platform: ctx.platform });
|
|
63
|
+
if (declared.untrusted || declared.unreadable || declared.hazardSkipped?.length || declared.unread?.length) return failure("env-unsafe");
|
|
64
|
+
const configured = { ...Object.fromEntries(keys.filter((key) => typeof ctx.env[key] === "string").map((key) => [key, ctx.env[key]])), ...declared.env };
|
|
65
|
+
const settingsPath = resolve(ctx.dir, settingsFilePath(configured, ctx.serviceDeps.home));
|
|
66
|
+
const settings = sourceSnapshot(settingsPath, ctx);
|
|
67
|
+
if (settings.refusal) return settings;
|
|
68
|
+
const loaded = readOverlay(settingsPath, { fs: { readFileSync: () => { if (settings.bytes === null) throw Object.assign(new Error(), { code: "ENOENT" }); return String(settings.bytes); } } });
|
|
69
|
+
if (loaded.invalid) return failure("env-unsafe");
|
|
70
|
+
let provider;
|
|
71
|
+
try { provider = request.input?.provider ?? loaded.overlay.provider ?? providerFrom(configured); } catch { return failure("provider-unsupported"); }
|
|
72
|
+
const providers = await ctx.providerNames();
|
|
73
|
+
if (!providers.includes(provider)) return failure("provider-unsupported");
|
|
74
|
+
if (needsHostAuthentication(provider)) return failure("provider-host-auth");
|
|
75
|
+
const candidates = await ctx.providerCandidates(provider);
|
|
76
|
+
const key = apiKeyVariable(candidates);
|
|
77
|
+
if (!key || nonApiKeyKind(key) !== null || (request.input?.key !== undefined && request.input.key !== key)) return failure("provider-unsupported");
|
|
78
|
+
const aliases = [];
|
|
79
|
+
for (const name of providers) {
|
|
80
|
+
const candidate = apiKeyVariable(await ctx.providerCandidates(name));
|
|
81
|
+
if (candidate === key && nonApiKeyKind(candidate) === null) aliases.push(name);
|
|
82
|
+
}
|
|
83
|
+
aliases.sort();
|
|
84
|
+
const mode = request.operation === "setup-provider-key-replace" ? "replace" : "add";
|
|
85
|
+
// A managed policy is deliberately not changed here. Its precedence belongs to the reviewed auth path.
|
|
86
|
+
const sources = [settings];
|
|
87
|
+
if (configured.PI_AUTH_CONNECTIONS_FILE) {
|
|
88
|
+
const policy = sourceSnapshot(resolve(ctx.dir, configured.PI_AUTH_CONNECTIONS_FILE), ctx);
|
|
89
|
+
if (policy.refusal || policy.bytes === null) return failure("env-unsafe");
|
|
90
|
+
try {
|
|
91
|
+
const { parseAuthConnections } = await import("./auth-connections.mjs");
|
|
92
|
+
const config = parseAuthConnections(String(policy.bytes));
|
|
93
|
+
if (config.bindings[provider]) return failure("provider-managed");
|
|
94
|
+
} catch { return failure("env-unsafe"); }
|
|
95
|
+
sources.push(policy);
|
|
96
|
+
}
|
|
97
|
+
if (candidates.some((name) => typeof ctx.env[name] === "string" && ctx.env[name] !== "")) return failure("provider-shadowed");
|
|
98
|
+
const present = deploymentServiceEnv({ env: {}, dir: ctx.dir, keys: candidates, fs: ctx.fs, uid: ctx.uid, platform: ctx.platform });
|
|
99
|
+
if (present.untrusted || present.unreadable || present.hazardSkipped?.length || present.unread?.length) return failure("env-unsafe");
|
|
100
|
+
if (candidates.some((name) => name !== key && Boolean(present.env[name]))) return failure("provider-shadowed");
|
|
101
|
+
if (String(envRead.bytes).split("\n").filter((line) => new RegExp(`^[ \\t]*(?:export[ \\t]+)?${key}[ \\t]*=`).test(line)).length > 1) return failure("env-unsafe");
|
|
102
|
+
const assigned = typeof present.env[key] === "string" && present.env[key] !== "";
|
|
103
|
+
if (mode === "add" && assigned) return failure("key-present");
|
|
104
|
+
if (mode === "replace" && !assigned) return failure("key-missing");
|
|
105
|
+
let source = assigned ? "deployment-env" : "none";
|
|
106
|
+
if (!assigned && configured.PI_AUTH_FROM_PI !== "0") {
|
|
107
|
+
// Only identify the declaration being superseded. Its value never leaves this child,
|
|
108
|
+
// and this does not claim the provider has accepted or can spend with that credential.
|
|
109
|
+
const agentDir = resolve(ctx.dir, configured.PI_CODING_AGENT_DIR || join(ctx.serviceDeps.home, ".pi", "agent"));
|
|
110
|
+
const login = sourceSnapshot(join(agentDir, "auth.json"), ctx);
|
|
111
|
+
if (login.refusal) return login;
|
|
112
|
+
if (login.bytes !== null) {
|
|
113
|
+
let auth;
|
|
114
|
+
try { auth = JSON.parse(String(login.bytes)); } catch { return failure("env-unsafe"); }
|
|
115
|
+
if (!auth || typeof auth !== "object" || Array.isArray(auth)) return failure("env-unsafe");
|
|
116
|
+
const entry = Object.hasOwn(auth, provider) ? auth[provider] : null;
|
|
117
|
+
if (entry?.type === "api_key" && typeof entry.key === "string" && entry.key.trim() !== "") source = "pi-api-key";
|
|
118
|
+
}
|
|
119
|
+
sources.push(login);
|
|
120
|
+
}
|
|
121
|
+
// A single loader-proven empty quoted assignment needs the overwrite transform too:
|
|
122
|
+
// the generic never-clobber transform deliberately preserves even an explicit "".
|
|
123
|
+
const overwrite = mode === "replace" || typeof present.env[key] === "string";
|
|
124
|
+
const plan = planEnvEdit(envRead.bytes, envRead.path, key, "setup-preview-placeholder", { platform: ctx.platform, overwrite });
|
|
125
|
+
if (plan.error || (mode === "add" && !plan.changed)) return failure("env-unsafe");
|
|
126
|
+
return { component: "provider", provider, key, mode, aliases, source, overwrite, effects: ["write-provider-key"], inputs: { provider, key, mode, aliases, source, env: envRead.digest, identity: envRead.identity, config: configured, sources: sources.map(({ path, digest }) => ({ path, digest })) }, sources, envRead };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function makePlan(request, ctx) {
|
|
130
|
+
const envRead = privateEnv(ctx.dir, ctx);
|
|
131
|
+
if (envRead.refusal) return envRead;
|
|
132
|
+
if (["setup-provider-key", "setup-provider-key-replace"].includes(request.operation)) return providerPlan(request, ctx, envRead);
|
|
133
|
+
if (request.operation === "setup-egress-start") {
|
|
134
|
+
const { planSetupEgress } = await import("./setup-egress.mjs");
|
|
135
|
+
const got = await planSetupEgress(ctx);
|
|
136
|
+
return got.refusal ? got : { ...got, component: "egress", inputs: { ...got.inputs, env: envRead.digest } };
|
|
137
|
+
}
|
|
138
|
+
if (request.operation === "setup-doctor") return { component: "doctor", effects: ["publish-doctor"], inputs: { env: envRead.digest } };
|
|
139
|
+
const [, component, operation] = /^setup-(worker|receiver)-(install|start|stop|restart)$/.exec(request.operation) ?? [];
|
|
140
|
+
if (!component) return failure("invalid-request");
|
|
141
|
+
const argv = [operation, ...(component === "receiver" ? ["--receiver"] : [])];
|
|
142
|
+
const planned = await ctx.servicePlan(argv, ctx.serviceDeps);
|
|
143
|
+
if (!planned || typeof planned !== "object") return failure("unavailable");
|
|
144
|
+
if (planned.refusal) return planned;
|
|
145
|
+
return { component, effects: planned.effects, inputs: { env: envRead.digest, unit: planned }, service: { argv, planned } };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Injectable worker boundary; production callers enter through the fixed CLI's stdin only. */
|
|
149
|
+
export async function runSetupHost(request, deps = {}) {
|
|
150
|
+
const base = { version: SETUP_VERSION, id: /^[a-f0-9]{32}$/.test(request?.id ?? "") ? request.id : "0".repeat(32), operation: request?.operation };
|
|
151
|
+
const answer = (state, reason, extra = {}) => ({ ...base, state, reason, ...extra });
|
|
152
|
+
if (!validSetupRequest(request)) return answer("refused", "invalid-request");
|
|
153
|
+
const fs = deps.fs ?? nodeFs;
|
|
154
|
+
const platform = deps.platform ?? process.platform;
|
|
155
|
+
const uid = deps.uid ?? process.geteuid?.();
|
|
156
|
+
const env = deps.env ?? process.env;
|
|
157
|
+
let dir;
|
|
158
|
+
try {
|
|
159
|
+
dir = fs.realpathSync(deps.cwd ?? process.cwd());
|
|
160
|
+
const st = fs.statSync(dir);
|
|
161
|
+
if (!st.isDirectory() || st.uid !== uid || (st.mode & 0o022)) return answer("refused", "unsafe-deployment");
|
|
162
|
+
} catch { return answer("refused", "unsafe-deployment"); }
|
|
163
|
+
if (!["linux", "darwin"].includes(platform) || (platform === "darwin" && uid === 0)) return answer("refused", "unsupported");
|
|
164
|
+
const entry = (deps.resolveEntry ?? resolveSetupWorkerEntry)();
|
|
165
|
+
if (!entry?.entry) return answer("refused", "unsafe-entry");
|
|
166
|
+
const serviceDeps = { env, cwd: dir, platform, euid: uid, fs, home: deps.home ?? homedir(), moduleDir: dirname(entry.entry), ...(deps.serviceDeps ?? {}) };
|
|
167
|
+
const ctx = { dir, fs, uid, platform, env, serviceDeps, servicePlan: deps.servicePlan ?? planServiceSetup, runService: deps.runService ?? runService, ...(deps.egressDeps ?? {}), providerCandidates: deps.providerCandidates ?? (async (provider) => (await import("./env-allowlist.mjs")).providerKeyCandidates(provider)), providerNames: deps.providerNames ?? (async () => (await import("./env-allowlist.mjs")).piProviders()) };
|
|
168
|
+
let lock;
|
|
169
|
+
try {
|
|
170
|
+
if (request.phase === "apply") {
|
|
171
|
+
lock = acquireSetupLock(dir, { fs, uid });
|
|
172
|
+
if (lock.refusal) return answer("refused", lock.refusal);
|
|
173
|
+
}
|
|
174
|
+
const plan = await makePlan(request, ctx);
|
|
175
|
+
if (plan.refusal) return answer("refused", plan.refusal);
|
|
176
|
+
const workerRead = deploymentServiceEnv({ env: {}, dir, keys: ["PI_WORKER_NAME"], fs, uid, platform });
|
|
177
|
+
if (workerRead.untrusted || workerRead.unreadable || workerRead.unread?.length || workerRead.hazardSkipped?.length) return answer("refused", "unsafe-deployment");
|
|
178
|
+
const registeredName = workerName({ ...env, ...workerRead.env });
|
|
179
|
+
const digest = hash({ operation: request.operation, entry: entry.entry, uid, dir, registeredName, inputs: plan.inputs });
|
|
180
|
+
const publicPlan = { digest, component: plan.component, manager: platform === "linux" ? "systemd" : "launchd", host: String((deps.hostname ?? hostname)()).replace(/[^A-Za-z0-9._-]/g, "_").slice(0, 64), workerName: registeredName, account: String(deps.account ?? userInfo().username).replace(/[^A-Za-z0-9._-]/g, "_").slice(0, 64), effects: plan.effects, ...(plan.key ? { key: plan.key, provider: plan.provider, mode: plan.mode, aliases: plan.aliases, source: plan.source } : {}) };
|
|
181
|
+
if (request.phase === "plan") return answer("planned", "ready", { plan: publicPlan });
|
|
182
|
+
if (request.phase === "status") return answer(plan.service?.planned.active ? "applied" : "unchanged", "ready", { plan: publicPlan });
|
|
183
|
+
if (request.expected !== digest) return answer("refused", "plan-stale");
|
|
184
|
+
if (plan.component === "provider") {
|
|
185
|
+
const value = request.input?.value;
|
|
186
|
+
if (typeof value !== "string" || !value.trim() || Buffer.byteLength(value, "utf8") > SETUP_LIMITS.keyBytes || /[\u0000-\u0020\u007f$`]/.test(value) || value.startsWith("!") || value.includes("sk-ant-oat")) return answer("refused", "key-invalid");
|
|
187
|
+
const before = plan.envRead;
|
|
188
|
+
const overwrite = plan.overwrite;
|
|
189
|
+
const edit = planEnvEdit(before.bytes, before.path, plan.key, value, { platform, overwrite });
|
|
190
|
+
if (edit.error) return answer("refused", "key-invalid");
|
|
191
|
+
const expectedInputs = () => {
|
|
192
|
+
const current = privateEnv(dir, ctx);
|
|
193
|
+
if (current.refusal || hash(current.identity) !== hash(before.identity) || current.digest !== before.digest || plan.sources.some((source) => { const current = sourceSnapshot(source.path, ctx); return current.refusal || current.digest !== source.digest; })) throw Object.assign(new Error("stale setup input"), { code: "SETUP_STALE" });
|
|
194
|
+
};
|
|
195
|
+
const guardedFs = { ...ENV_WRITER_FS, ...fs, readFileSync(path, ...args) {
|
|
196
|
+
if (path !== before.path) return fs.readFileSync(path, ...args);
|
|
197
|
+
// The writer must transform the exact judged bytes. Rereading this pathname
|
|
198
|
+
// could copy a transient competing edit even if the old file is restored later.
|
|
199
|
+
expectedInputs();
|
|
200
|
+
return Buffer.from(before.bytes);
|
|
201
|
+
}, renameSync(from, to) {
|
|
202
|
+
expectedInputs();
|
|
203
|
+
fs.renameSync(from, to);
|
|
204
|
+
} };
|
|
205
|
+
let saved;
|
|
206
|
+
try { saved = updateEnvFile(before.path, plan.key, value, { fs: guardedFs, platform, narrow: true, overwrite }); }
|
|
207
|
+
catch (e) { return answer(e?.code === "SETUP_STALE" ? "refused" : "unknown", e?.code === "SETUP_STALE" ? "plan-stale" : "process-failed"); }
|
|
208
|
+
return answer(saved.changed ? "applied" : "unchanged", "ready", { effects: saved.changed ? plan.effects : [] });
|
|
209
|
+
}
|
|
210
|
+
if (plan.component === "doctor") {
|
|
211
|
+
const doctor = deps.runDoctor ?? (await import("./doctor.mjs")).runDoctor;
|
|
212
|
+
let published = false;
|
|
213
|
+
const doctorOpts = { cwd: dir, setupReadOnly: true, publish: true, out: () => {}, writeErr: () => {}, ...(deps.doctorDeps ?? {}), publishDoctor: async (...args) => { const writer = deps.publishDoctor ?? (await import("./doctor.mjs")).defaultPublishDoctor; const result = await writer(...args); published = result?.published === true || result?.ok === true || result === undefined; return result; } };
|
|
214
|
+
await doctor(env, doctorOpts);
|
|
215
|
+
return answer(published ? "applied" : "partial", published ? "doctor-published" : "doctor-incomplete", { effects: plan.effects });
|
|
216
|
+
}
|
|
217
|
+
if (plan.component === "egress") {
|
|
218
|
+
const { applySetupEgress } = await import("./setup-egress.mjs");
|
|
219
|
+
const result = await applySetupEgress(plan, ctx);
|
|
220
|
+
return answer(result.state, result.reason, { effects: result.effects ?? [] });
|
|
221
|
+
}
|
|
222
|
+
const code = await ctx.runService(plan.service.argv, { ...serviceDeps, setupExpected: plan.service.planned, out: () => {}, err: () => {} });
|
|
223
|
+
const readback = await ctx.servicePlan(["start", ...(plan.component === "receiver" ? ["--receiver"] : [])], serviceDeps);
|
|
224
|
+
const wanted = plan.service.argv[0] !== "stop";
|
|
225
|
+
if (!readback?.refusal && Boolean(readback.active) === wanted) return answer(code === 0 ? "applied" : "partial", code === 0 ? "ready" : "process-failed", { effects: plan.effects });
|
|
226
|
+
return answer("unknown", code === 0 ? "readback-unavailable" : "process-failed", { effects: plan.effects });
|
|
227
|
+
} catch { return answer(request.phase === "apply" ? "unknown" : "refused", "unavailable"); }
|
|
228
|
+
finally { lock?.release?.(); }
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export async function runSetupHostCli({ input = process.stdin, out = (text) => process.stdout.write(text), ...deps } = {}) {
|
|
232
|
+
let bytes = 0;
|
|
233
|
+
const chunks = [];
|
|
234
|
+
try {
|
|
235
|
+
for await (const chunk of input) { bytes += Buffer.byteLength(chunk); if (bytes > SETUP_LIMITS.inputBytes) return 2; chunks.push(Buffer.from(chunk)); }
|
|
236
|
+
const request = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
237
|
+
if (!validSetupRequest(request)) return 2;
|
|
238
|
+
const result = await runSetupHost(request, deps);
|
|
239
|
+
out(`${JSON.stringify(result)}\n`);
|
|
240
|
+
return result.state === "refused" ? 2 : 0;
|
|
241
|
+
} catch { return 2; }
|
|
242
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/** One filesystem lock for every setup mutation of a deployment, including private viewer writes. */
|
|
2
|
+
import * as nodeFs from "node:fs";
|
|
3
|
+
import { randomBytes } from "node:crypto";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
|
|
6
|
+
/** Canonical, account-owned directory incarnation bound to a setup grant. */
|
|
7
|
+
export function setupDeploymentIdentity(deployment, { fs = nodeFs, uid = process.geteuid?.() } = {}) {
|
|
8
|
+
try {
|
|
9
|
+
const dir = fs.realpathSync(deployment);
|
|
10
|
+
const st = fs.statSync(dir);
|
|
11
|
+
if (!st.isDirectory() || st.uid !== uid || (st.mode & 0o022)) return null;
|
|
12
|
+
for (let parent = dirname(dir); ; parent = dirname(parent)) {
|
|
13
|
+
const ancestor = fs.statSync(parent);
|
|
14
|
+
if (!ancestor.isDirectory() || (ancestor.uid !== uid && ancestor.uid !== 0) || ((ancestor.mode & 0o022) && !(ancestor.mode & 0o1000))) return null;
|
|
15
|
+
if (parent === dirname(parent)) break;
|
|
16
|
+
}
|
|
17
|
+
return { path: dir, uid: st.uid, dev: st.dev, ino: st.ino };
|
|
18
|
+
} catch { return null; }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function acquireSetupLock(deployment, { fs = nodeFs, uid = process.geteuid?.(), random = randomBytes, pid = process.pid } = {}) {
|
|
22
|
+
const identity = setupDeploymentIdentity(deployment, { fs, uid });
|
|
23
|
+
if (!identity) return { refusal: "unsafe-deployment" };
|
|
24
|
+
const path = join(identity.path, ".pi-dispatch-setup.lock");
|
|
25
|
+
const text = `${pid}:${random(16).toString("hex")}`;
|
|
26
|
+
try { fs.writeFileSync(path, text, { flag: "wx", mode: 0o600 }); }
|
|
27
|
+
catch { return { refusal: "setup-busy" }; }
|
|
28
|
+
// A dead process does not prove the file still belongs to that process: two reap callers
|
|
29
|
+
// could unlink a new winner's lock. Leave crash recovery to a host operator instead.
|
|
30
|
+
return { release() {
|
|
31
|
+
let fd;
|
|
32
|
+
try {
|
|
33
|
+
fd = fs.openSync(path, nodeFs.constants.O_RDONLY | nodeFs.constants.O_NOFOLLOW | nodeFs.constants.O_NONBLOCK);
|
|
34
|
+
const opened = fs.fstatSync(fd);
|
|
35
|
+
if (!opened.isFile() || opened.uid !== uid || (opened.mode & 0o077) || fs.readFileSync(fd, "utf8") !== text) return;
|
|
36
|
+
const current = fs.lstatSync(path);
|
|
37
|
+
if (!current.isSymbolicLink() && current.ino === opened.ino && current.dev === opened.dev) fs.unlinkSync(path);
|
|
38
|
+
} catch { /* A removed/replaced lock is not ours to release. */ }
|
|
39
|
+
finally { if (fd !== undefined) fs.closeSync(fd); }
|
|
40
|
+
} };
|
|
41
|
+
}
|
package/src/size-records.mjs
CHANGED
|
@@ -11,7 +11,10 @@
|
|
|
11
11
|
* - only a record that carries measurements (an object `resources` and a `size`) is kept, and of those the NEWEST
|
|
12
12
|
* `SUGGEST_WINDOW_RUNS` per project (by `endedAt`), since no suggestion reads more. The filter runs BEFORE the
|
|
13
13
|
* cut: a run refused before its container started (a budget or policy refusal) writes a record with neither, and
|
|
14
|
-
* cut first, fifty such refusals would crowd every measured run out of the window
|
|
14
|
+
* cut first, fifty such refusals would crowd every measured run out of the window;
|
|
15
|
+
* - a record from a NEWER pi-dispatch (issue #761, run-history.mjs `recordReadable`) is skipped and COUNTED
|
|
16
|
+
* (`newerSkipped`): a suggestion reads `resources` and `size`, which that release may have given another meaning,
|
|
17
|
+
* and a misread measurement would size a project wrong. Only one of a project and the window is counted.
|
|
15
18
|
*
|
|
16
19
|
* Never throws: an absent directory holds no records; another unreadable one is `unreachable` (doctor reads that as
|
|
17
20
|
* none, the panel says it).
|
|
@@ -19,6 +22,7 @@
|
|
|
19
22
|
|
|
20
23
|
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
21
24
|
import { join } from "node:path";
|
|
25
|
+
import { recordReadable } from "./run-history.mjs";
|
|
22
26
|
import { SUGGEST_WINDOW_DAYS, SUGGEST_WINDOW_RUNS } from "./size-suggest.mjs";
|
|
23
27
|
|
|
24
28
|
/** The largest run record a size suggestion parses: 256 KiB. A larger one is skipped and counted. */
|
|
@@ -33,7 +37,8 @@ const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v)
|
|
|
33
37
|
* `{ records, skipped, unreachable }`: the parsed records (at most SUGGEST_WINDOW_RUNS per project, newest first by
|
|
34
38
|
* `endedAt`; a record with no string `project`, no readable `endedAt`, no object `resources` or no `size` is dropped,
|
|
35
39
|
* since no suggestion could read it), how many files were skipped for their size, and null or a reason when the
|
|
36
|
-
* directory itself could not be listed
|
|
40
|
+
* directory itself could not be listed; plus `newerSkipped`, how many records of a project and the window from a newer
|
|
41
|
+
* pi-dispatch were left out, only when there were any.
|
|
37
42
|
* `fs` is `{ readdirSync, statSync, readFileSync }`.
|
|
38
43
|
*/
|
|
39
44
|
export function readSizingRecords(logsDir, { nowMs, fs = { readdirSync, readFileSync, statSync } } = {}) {
|
|
@@ -46,6 +51,7 @@ export function readSizingRecords(logsDir, { nowMs, fs = { readdirSync, readFile
|
|
|
46
51
|
}
|
|
47
52
|
const byProject = new Map();
|
|
48
53
|
let skipped = 0;
|
|
54
|
+
let newerSkipped = 0;
|
|
49
55
|
for (const name of names) {
|
|
50
56
|
if (typeof name !== "string" || !name.endsWith(".json")) continue;
|
|
51
57
|
try {
|
|
@@ -64,6 +70,12 @@ export function readSizingRecords(logsDir, { nowMs, fs = { readdirSync, readFile
|
|
|
64
70
|
const record = JSON.parse(buf.toString("utf8"));
|
|
65
71
|
const at = typeof record?.endedAt === "string" ? Date.parse(record.endedAt) : NaN;
|
|
66
72
|
if (typeof record?.project !== "string" || !Number.isFinite(at)) continue;
|
|
73
|
+
// `project` and `endedAt` are in the stable core, `resources` and `size` are not: a newer record is counted on the
|
|
74
|
+
// core alone, before its measurements are looked at.
|
|
75
|
+
if (!recordReadable(record)) {
|
|
76
|
+
newerSkipped++;
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
67
79
|
if (!isObject(record.resources) || !isObject(record.size)) continue; // no measurements: never counts toward the 50
|
|
68
80
|
if (!byProject.has(record.project)) byProject.set(record.project, []);
|
|
69
81
|
byProject.get(record.project).push({ at, record });
|
|
@@ -76,5 +88,5 @@ export function readSizingRecords(logsDir, { nowMs, fs = { readdirSync, readFile
|
|
|
76
88
|
list.sort((a, b) => b.at - a.at);
|
|
77
89
|
for (const { record } of list.slice(0, SUGGEST_WINDOW_RUNS)) records.push(record);
|
|
78
90
|
}
|
|
79
|
-
return { records, skipped, unreachable: null };
|
|
91
|
+
return { records, skipped, unreachable: null, ...(newerSkipped > 0 ? { newerSkipped } : {}) };
|
|
80
92
|
}
|