@edgehero/pi-dispatch 4.3.0 → 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 +94 -4
- 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 +199 -30
- 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 +1663 -198
- package/src/dollar-budget.mjs +212 -3
- package/src/endpoint-status.mjs +100 -0
- package/src/env-allowlist.mjs +103 -9
- package/src/env-file.mjs +6 -4
- package/src/file-expect.mjs +47 -0
- package/src/forgejo-auth.mjs +4 -0
- package/src/forgejo-host.mjs +127 -31
- package/src/forgejo-prompt.mjs +5 -4
- package/src/forges.mjs +73 -15
- 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 +740 -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 +1554 -167
- 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 +296 -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 +782 -40
- 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,338 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Immutable, run-addressed fork sources (issue #648). The canonical session moves on every promotion,
|
|
3
|
+
* so looking up an old run through its session key would silently fork a newer conversation instead.
|
|
4
|
+
* These private artifacts have their own bounded retention; no index maps jobs to arbitrary paths.
|
|
5
|
+
* Workspace points are captured ONLY while the caller has quiesced the container. A transcript alone
|
|
6
|
+
* never establishes that the files at an earlier entry are available.
|
|
7
|
+
*/
|
|
8
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
9
|
+
import * as fs from "node:fs";
|
|
10
|
+
import { hostname } from "node:os";
|
|
11
|
+
import { join, resolve } from "node:path";
|
|
12
|
+
import { DEFAULT_CHECKPOINT_LIMITS, makeWorkspaceCheckpointer, retryableStoreError } from "./workspace-checkpoint.mjs";
|
|
13
|
+
|
|
14
|
+
export const FORK_SOURCE_LIMITS = Object.freeze({ maxBytes: 8 * 1024 * 1024, maxStoreBytes: 256 * 1024 * 1024, maxSources: 1024, maxPoints: 64, ttlMs: 7 * 86400_000 });
|
|
15
|
+
const ID = /^[A-Za-z0-9._:-]{1,200}$/;
|
|
16
|
+
const ENTRY = /^[A-Za-z0-9_-]{1,128}$/;
|
|
17
|
+
const HEX = /^[0-9a-f]{64}$/;
|
|
18
|
+
const KEY = /^[0-9a-f]{32}$/;
|
|
19
|
+
const SHA = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
|
|
20
|
+
const TOKEN = /^[A-Za-z0-9][A-Za-z0-9._:@/+\-]{0,127}$/;
|
|
21
|
+
const HOST = /^[A-Za-z0-9._-]{1,64}$/;
|
|
22
|
+
const POSITIONS = new Set(["before", "at"]);
|
|
23
|
+
const ENTRY_TYPES = new Set(["message", "thinking_level_change", "model_change", "usage", "compaction", "branch_summary", "custom", "label", "session_info", "custom_message", "context_edit"]);
|
|
24
|
+
const ROLES = new Set(["user", "assistant", "toolResult"]);
|
|
25
|
+
const digest = (bytes) => createHash("sha256").update(bytes).digest("hex");
|
|
26
|
+
export const forkSourceKey = (runId) => digest(`fork-source\0${runId}`).slice(0, 32);
|
|
27
|
+
const pointKey = (entryId, position) => digest(`${entryId}\0${position}`);
|
|
28
|
+
const refused = (suffix) => ({ refused: `fork-source-${suffix}` });
|
|
29
|
+
|
|
30
|
+
function regular(path, max) {
|
|
31
|
+
const fd = fs.openSync(path, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK);
|
|
32
|
+
try {
|
|
33
|
+
const st = fs.fstatSync(fd);
|
|
34
|
+
if (!st.isFile() || st.size > max) throw new Error("not-bounded-file");
|
|
35
|
+
const out = Buffer.alloc(st.size);
|
|
36
|
+
let offset = 0;
|
|
37
|
+
while (offset < out.length) {
|
|
38
|
+
const n = fs.readSync(fd, out, offset, out.length - offset, offset);
|
|
39
|
+
if (!n) throw new Error("short-file");
|
|
40
|
+
offset += n;
|
|
41
|
+
}
|
|
42
|
+
const after = fs.fstatSync(fd);
|
|
43
|
+
if (st.size !== after.size || st.mtimeMs !== after.mtimeMs) throw new Error("changed-file");
|
|
44
|
+
return out;
|
|
45
|
+
} finally { fs.closeSync(fd); }
|
|
46
|
+
}
|
|
47
|
+
function directory(path, create = false) {
|
|
48
|
+
if (create) {
|
|
49
|
+
try { if (!fs.lstatSync(path).isDirectory()) throw new Error("not-directory"); }
|
|
50
|
+
catch (e) { if (e?.code !== "ENOENT") throw e; }
|
|
51
|
+
fs.mkdirSync(path, { recursive: true, mode: 0o700 });
|
|
52
|
+
}
|
|
53
|
+
if (!fs.lstatSync(path).isDirectory()) throw new Error("not-directory");
|
|
54
|
+
}
|
|
55
|
+
function durable(path, bytes) {
|
|
56
|
+
const fd = fs.openSync(path, "wx", 0o600);
|
|
57
|
+
try { fs.writeFileSync(fd, bytes); fs.fsyncSync(fd); } finally { fs.closeSync(fd); }
|
|
58
|
+
}
|
|
59
|
+
function syncDir(path) {
|
|
60
|
+
const fd = fs.openSync(path, "r");
|
|
61
|
+
try { fs.fsyncSync(fd); } finally { fs.closeSync(fd); }
|
|
62
|
+
}
|
|
63
|
+
function parseEntries(bytes) {
|
|
64
|
+
const lines = bytes.toString("utf8").split("\n").filter(Boolean);
|
|
65
|
+
const header = JSON.parse(lines.shift() ?? "null");
|
|
66
|
+
if (header?.type !== "session") throw new Error("header");
|
|
67
|
+
const entries = [];
|
|
68
|
+
const seen = new Set();
|
|
69
|
+
for (const line of lines) {
|
|
70
|
+
const e = JSON.parse(line);
|
|
71
|
+
if (!ENTRY.test(e.id ?? "") || seen.has(e.id) || (e.parentId !== null && !seen.has(e.parentId))) throw new Error("tree");
|
|
72
|
+
seen.add(e.id);
|
|
73
|
+
entries.push({ id: e.id, parentId: e.parentId, type: ENTRY_TYPES.has(e.type) ? e.type : "unknown", ...(e.type === "message" ? { role: ROLES.has(e.message?.role) ? e.message.role : "unknown" } : {}) });
|
|
74
|
+
}
|
|
75
|
+
return entries;
|
|
76
|
+
}
|
|
77
|
+
/** Last complete tree path, with every tool call answered, is a usable workspace boundary. */
|
|
78
|
+
export function forkPointEntry(bytes) {
|
|
79
|
+
try {
|
|
80
|
+
const raw = bytes.toString("utf8").split("\n").filter(Boolean).map((line) => JSON.parse(line));
|
|
81
|
+
const entries = raw.slice(1);
|
|
82
|
+
parseEntries(bytes);
|
|
83
|
+
const byId = new Map(entries.map((e) => [e.id, e]));
|
|
84
|
+
const path = [];
|
|
85
|
+
for (let e = entries.at(-1); e; e = byId.get(e.parentId)) path.unshift(e);
|
|
86
|
+
const pending = new Set();
|
|
87
|
+
for (const e of path) {
|
|
88
|
+
if (e.type !== "message") continue;
|
|
89
|
+
if (e.message?.role === "assistant") for (const c of e.message.content ?? []) if (c.type === "toolCall") pending.add(c.id);
|
|
90
|
+
if (e.message?.role === "toolResult") pending.delete(e.message.toolCallId);
|
|
91
|
+
}
|
|
92
|
+
return pending.size === 0 ? path.at(-1)?.id ?? null : null;
|
|
93
|
+
} catch { return null; }
|
|
94
|
+
}
|
|
95
|
+
function validManifest(m) {
|
|
96
|
+
const fields = ["v", "runId", "sessionKey", "baseSha", "venue", "piVersion", "attempt", "host", "hostQueue", "at", "bytes", "transcriptSha256", "sourceDigest"];
|
|
97
|
+
return m && Object.keys(m).length === fields.length && fields.every((k) => Object.hasOwn(m, k)) && m.v === 1 && ID.test(m.runId) && KEY.test(m.sessionKey) && SHA.test(m.baseSha) && TOKEN.test(m.venue) && /^\d+\.\d+\.\d+$/.test(m.piVersion) && Number.isSafeInteger(m.attempt) && m.attempt >= 0 && HOST.test(m.host) && (m.hostQueue === null || /^pi-jobs@[A-Za-z0-9._-]{1,64}$/.test(m.hostQueue)) && Number.isSafeInteger(m.at) && m.at >= 0 && Number.isSafeInteger(m.bytes) && m.bytes >= 0 && HEX.test(m.transcriptSha256) && HEX.test(m.sourceDigest);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The sweep's age of a source from its manifest, of ANY version (issue #761): `{ runId, at }` when the manifest carries
|
|
102
|
+
* an integer `v` of 1 or more, a run id that names this very directory (`forkSourceKey(runId) === key`) and a publish
|
|
103
|
+
* time that is a safe integer of 0 or more; null otherwise. For the sweep alone, and lenient on purpose: judged by
|
|
104
|
+
* `validManifest`, a source a newer release published reads as no manifest, so it would be aged by its earlier
|
|
105
|
+
* `origin.json` (removed while young) or by the folder's mtime, and with no run id its workspace archive would never be
|
|
106
|
+
* removed. `read` stays strict, so a newer source is still never served here.
|
|
107
|
+
*/
|
|
108
|
+
function sweepAge(m, key) {
|
|
109
|
+
if (!m || typeof m !== "object" || Array.isArray(m)) return null;
|
|
110
|
+
if (!Number.isSafeInteger(m.v) || m.v < 1) return null;
|
|
111
|
+
if (typeof m.runId !== "string" || !ID.test(m.runId) || forkSourceKey(m.runId) !== key) return null;
|
|
112
|
+
if (!Number.isSafeInteger(m.at) || m.at < 0) return null;
|
|
113
|
+
return { runId: m.runId, at: m.at };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function createForkStore({ dir, now = Date.now, limits = {}, hostName = hostname(), hostQueue = null, checkpointer = null, checkpointLimits = {}, readRegular = regular } = {}) {
|
|
117
|
+
const L = { ...FORK_SOURCE_LIMITS, ...limits };
|
|
118
|
+
const root = typeof dir === "string" && dir ? resolve(dir) : null;
|
|
119
|
+
const host = HOST.test(hostName) ? hostName : "unknown";
|
|
120
|
+
const archive = root ? checkpointer ?? makeWorkspaceCheckpointer({ storeDir: join(root, ".workspaces"), now, hostName: host, keepHistory: true, limits: { ttlMs: L.ttlMs, snapshotTimeoutMs: 5_000, ...checkpointLimits } }) : null;
|
|
121
|
+
const pathOf = (runId) => join(root, forkSourceKey(runId));
|
|
122
|
+
const pointsOf = (runId) => join(pathOf(runId), "points");
|
|
123
|
+
let archiveBusy = false;
|
|
124
|
+
function dirs(runId, create = false) {
|
|
125
|
+
if (!root || !ID.test(runId ?? "")) throw new Error("bad-args");
|
|
126
|
+
directory(root, create);
|
|
127
|
+
directory(pathOf(runId), create);
|
|
128
|
+
}
|
|
129
|
+
function usage() {
|
|
130
|
+
let bytes = 0, sources = 0;
|
|
131
|
+
for (const name of fs.readdirSync(root)) {
|
|
132
|
+
if (!KEY.test(name) || !fs.lstatSync(join(root, name)).isDirectory()) continue;
|
|
133
|
+
sources++;
|
|
134
|
+
try { bytes += fs.lstatSync(join(root, name, "transcript.jsonl")).size; } catch {}
|
|
135
|
+
}
|
|
136
|
+
return { bytes, sources };
|
|
137
|
+
}
|
|
138
|
+
function publish({ runId, sessionKey, transcriptPath, baseSha, venue, piVersion, attempt = 0 } = {}) {
|
|
139
|
+
if (!root) return refused("unconfigured");
|
|
140
|
+
let lock;
|
|
141
|
+
try {
|
|
142
|
+
directory(root, true);
|
|
143
|
+
const before = usage();
|
|
144
|
+
if (!ID.test(runId ?? "") || !KEY.test(sessionKey ?? "") || !SHA.test(baseSha ?? "") || !TOKEN.test(venue ?? "") || !/^\d+\.\d+\.\d+$/.test(piVersion ?? "") || !Number.isSafeInteger(attempt) || attempt < 0) return refused("invalid");
|
|
145
|
+
const existed = fs.existsSync(pathOf(runId));
|
|
146
|
+
if (!existed && before.sources >= L.maxSources) return refused("capacity");
|
|
147
|
+
dirs(runId, true);
|
|
148
|
+
if (fs.existsSync(join(pathOf(runId), "unsafe-base"))) return { refused: "fork-unsafe-extensions" };
|
|
149
|
+
lock = fs.openSync(join(pathOf(runId), "publish.lock"), "wx", 0o600);
|
|
150
|
+
const bytes = readRegular(transcriptPath, L.maxBytes);
|
|
151
|
+
parseEntries(bytes);
|
|
152
|
+
const old = read({ runId });
|
|
153
|
+
if (old.ok) return old.manifest.transcriptSha256 === digest(bytes) ? { ok: true, sourceDigest: old.manifest.sourceDigest, manifest: old.manifest } : refused("immutable");
|
|
154
|
+
if (fs.existsSync(join(pathOf(runId), "manifest.json"))) return refused("corrupt");
|
|
155
|
+
if (before.bytes + bytes.length > L.maxStoreBytes) return refused("capacity");
|
|
156
|
+
const body = { v: 1, runId, sessionKey, baseSha, venue, piVersion, attempt, host, hostQueue, at: now(), bytes: bytes.length, transcriptSha256: digest(bytes) };
|
|
157
|
+
const manifest = { ...body, sourceDigest: digest(JSON.stringify(body)) };
|
|
158
|
+
if (!validManifest(manifest)) return refused("invalid");
|
|
159
|
+
// The manifest rename is the commit point. A crash before it leaves no readable source.
|
|
160
|
+
fs.rmSync(join(pathOf(runId), "transcript.jsonl"), { force: true });
|
|
161
|
+
durable(join(pathOf(runId), "transcript.jsonl"), bytes);
|
|
162
|
+
const incoming = join(pathOf(runId), `manifest.${randomBytes(8).toString("hex")}.incoming`);
|
|
163
|
+
durable(incoming, JSON.stringify(manifest));
|
|
164
|
+
fs.renameSync(incoming, join(pathOf(runId), "manifest.json"));
|
|
165
|
+
syncDir(pathOf(runId));
|
|
166
|
+
return { ok: true, sourceDigest: manifest.sourceDigest, manifest };
|
|
167
|
+
} catch (e) { return refused(e?.code === "EEXIST" ? "busy" : "unreadable"); }
|
|
168
|
+
finally { if (lock !== undefined) { fs.closeSync(lock); fs.rmSync(join(pathOf(runId), "publish.lock"), { force: true }); } }
|
|
169
|
+
}
|
|
170
|
+
function read({ runId, sourceDigest, entryId, position = "before" } = {}) {
|
|
171
|
+
if (!root) return refused("unconfigured");
|
|
172
|
+
if (!ID.test(runId ?? "") || (sourceDigest !== undefined && !HEX.test(sourceDigest))) return refused("invalid");
|
|
173
|
+
try {
|
|
174
|
+
dirs(runId);
|
|
175
|
+
if (fs.existsSync(join(pathOf(runId), "unsafe-base"))) return { refused: "fork-unsafe-extensions" };
|
|
176
|
+
const manifest = JSON.parse(readRegular(join(pathOf(runId), "manifest.json"), 8192));
|
|
177
|
+
if (!validManifest(manifest) || manifest.runId !== runId) return refused("corrupt");
|
|
178
|
+
const { sourceDigest: expected, ...body } = manifest;
|
|
179
|
+
if (digest(JSON.stringify(body)) !== expected) return refused("corrupt");
|
|
180
|
+
if (sourceDigest !== undefined && sourceDigest !== expected) return refused("changed");
|
|
181
|
+
if (now() - manifest.at > L.ttlMs || manifest.at > now()) return refused("expired");
|
|
182
|
+
const transcriptPath = join(pathOf(runId), "transcript.jsonl");
|
|
183
|
+
const bytes = readRegular(transcriptPath, L.maxBytes);
|
|
184
|
+
if (bytes.length !== manifest.bytes || digest(bytes) !== manifest.transcriptSha256) return refused("corrupt");
|
|
185
|
+
const entries = parseEntries(bytes);
|
|
186
|
+
const firstUser = entries.find((e) => e.type === "message" && e.role === "user");
|
|
187
|
+
// Load at most the point cap once. A long transcript must not turn preview into two
|
|
188
|
+
// filesystem opens per entry (an 8 MiB transcript can hold thousands of metadata entries).
|
|
189
|
+
const points = new Map();
|
|
190
|
+
try {
|
|
191
|
+
directory(pointsOf(runId));
|
|
192
|
+
const names = fs.readdirSync(pointsOf(runId)).filter((n) => /^[0-9a-f]{64}\.json$/.test(n));
|
|
193
|
+
if (names.length > L.maxPoints) return refused("corrupt");
|
|
194
|
+
for (const name of names) {
|
|
195
|
+
const p = JSON.parse(readRegular(join(pointsOf(runId), name), 8192));
|
|
196
|
+
if (ENTRY.test(p.entryId ?? "") && p.position === "at" && name === `${pointKey(p.entryId, p.position)}.json`) {
|
|
197
|
+
const valid = pointRecord(runId, p.entryId, p.position);
|
|
198
|
+
if (valid) points.set(p.entryId, valid);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
} catch (e) {
|
|
202
|
+
if (retryableStoreError(e)) return { unavailable: e.code };
|
|
203
|
+
if (e?.code !== "ENOENT") return refused("checkpoint-unreadable");
|
|
204
|
+
}
|
|
205
|
+
const pointFor = (e, p) => e.id === firstUser?.id && p === "before" ? { base: true } : p === "before" && e.parentId ? points.get(e.parentId) : p === "at" ? points.get(e.id) : null;
|
|
206
|
+
for (const e of entries) e.positions = [...POSITIONS].filter((p) => pointFor(e, p));
|
|
207
|
+
const out = { ok: true, manifest, sourceDigest: expected, transcriptPath, entries };
|
|
208
|
+
if (entryId === undefined) return out;
|
|
209
|
+
if (!ENTRY.test(entryId) || !POSITIONS.has(position)) return refused("invalid-entry");
|
|
210
|
+
if (!entries.some((e) => e.id === entryId)) return refused("entry-missing");
|
|
211
|
+
const point = pointFor(entries.find((e) => e.id === entryId), position);
|
|
212
|
+
return point ? { ...out, point } : refused("checkpoint-missing");
|
|
213
|
+
} catch (e) { return retryableStoreError(e) ? { unavailable: e.code } : refused(e?.code === "ENOENT" ? "missing" : "unreadable"); }
|
|
214
|
+
}
|
|
215
|
+
function pointRecord(runId, entryId, position) {
|
|
216
|
+
try {
|
|
217
|
+
directory(pointsOf(runId));
|
|
218
|
+
const p = JSON.parse(readRegular(join(pointsOf(runId), `${pointKey(entryId, position)}.json`), 8192));
|
|
219
|
+
if (Object.keys(p).length !== 6 || p.entryId !== entryId || p.position !== position || !Number.isSafeInteger(p.seq) || p.seq < 1 || !HEX.test(p.transcriptSha256) || !Number.isSafeInteger(p.bytes) || p.bytes < 0 || !SHA.test(p.baseSha)) return null;
|
|
220
|
+
return p;
|
|
221
|
+
} catch (error) { if (retryableStoreError(error)) throw error; return null; }
|
|
222
|
+
}
|
|
223
|
+
async function primeBase(args = {}) {
|
|
224
|
+
if (!root || !ID.test(args.runId ?? "")) return refused("invalid");
|
|
225
|
+
try {
|
|
226
|
+
directory(root, true);
|
|
227
|
+
if (!fs.existsSync(pathOf(args.runId)) && usage().sources >= L.maxSources) return refused("capacity");
|
|
228
|
+
dirs(args.runId, true);
|
|
229
|
+
const originPath = join(pathOf(args.runId), "origin.json");
|
|
230
|
+
try { durable(originPath, JSON.stringify({ runId: args.runId, at: now() })); }
|
|
231
|
+
catch (e) { if (e?.code !== "EEXIST") throw e; }
|
|
232
|
+
} catch { return refused("unreadable"); }
|
|
233
|
+
if (archiveBusy) return refused("busy");
|
|
234
|
+
archiveBusy = true;
|
|
235
|
+
try {
|
|
236
|
+
const maxStore = checkpointLimits.maxStoreBytes ?? DEFAULT_CHECKPOINT_LIMITS.maxStoreBytes;
|
|
237
|
+
const maxBase = checkpointLimits.maxBaseBytes ?? DEFAULT_CHECKPOINT_LIMITS.maxBaseBytes;
|
|
238
|
+
const before = await archive.usage?.();
|
|
239
|
+
if (before?.bytes !== undefined && before.bytes + maxBase > maxStore) return refused("capacity");
|
|
240
|
+
const primed = await archive.primeBase({ ...args, objectFormat: args.objectFormat ?? (args.baseSha?.length === 64 ? "sha256" : "sha1") });
|
|
241
|
+
const after = primed?.ok ? await archive.usage?.() : null;
|
|
242
|
+
if (after?.bytes > maxStore) {
|
|
243
|
+
await archive.remove?.(args.runId);
|
|
244
|
+
return refused("capacity");
|
|
245
|
+
}
|
|
246
|
+
if (primed?.baseHasExtensions) {
|
|
247
|
+
try { durable(join(pathOf(args.runId), "unsafe-base"), "1\n"); } catch {}
|
|
248
|
+
return { refused: "fork-unsafe-extensions" };
|
|
249
|
+
}
|
|
250
|
+
return primed;
|
|
251
|
+
} catch { return refused("unreadable"); }
|
|
252
|
+
finally { archiveBusy = false; }
|
|
253
|
+
}
|
|
254
|
+
async function capturePoint({ runId, entryId, position = "at", transcriptPath, offset, ...args } = {}) {
|
|
255
|
+
if (!root || !ID.test(runId ?? "") || (entryId !== undefined && !ENTRY.test(entryId)) || !POSITIONS.has(position) || !Number.isSafeInteger(offset) || offset <= 0 || offset > L.maxBytes || !["frozen", "dead"].includes(args.quiesced)) return refused("invalid");
|
|
256
|
+
if (archiveBusy) return refused("busy");
|
|
257
|
+
archiveBusy = true;
|
|
258
|
+
try {
|
|
259
|
+
dirs(runId);
|
|
260
|
+
directory(pointsOf(runId), true);
|
|
261
|
+
const bytes = readRegular(transcriptPath, L.maxBytes).subarray(0, offset);
|
|
262
|
+
if (bytes.length !== offset) return refused("checkpoint-mismatch");
|
|
263
|
+
entryId ??= forkPointEntry(bytes);
|
|
264
|
+
if (!ENTRY.test(entryId ?? "")) return refused("checkpoint-unsafe");
|
|
265
|
+
if (pointRecord(runId, entryId, position)) return { ok: true, unchanged: true };
|
|
266
|
+
const names = fs.readdirSync(pointsOf(runId)).filter((n) => /^[0-9a-f]{64}\.json$/.test(n));
|
|
267
|
+
if (names.length >= L.maxPoints) return refused("point-capacity");
|
|
268
|
+
const entries = parseEntries(bytes);
|
|
269
|
+
if (entries.at(-1)?.id !== entryId || forkPointEntry(bytes) !== entryId || position !== "at") return refused("checkpoint-mismatch");
|
|
270
|
+
const current = await archive.readManifest(runId);
|
|
271
|
+
const seq = current.ok ? current.manifest.seq + 1 : 1;
|
|
272
|
+
if (seq > L.maxPoints) return refused("point-capacity");
|
|
273
|
+
const saved = await archive.snapshot({ ...args, runId, seq, transcript: { srcFile: transcriptPath, offset } });
|
|
274
|
+
if (!saved.ok) return saved;
|
|
275
|
+
if (saved.manifest.workspace.partial || saved.manifest.workspace.ignoredDropped) return refused("checkpoint-partial");
|
|
276
|
+
const point = { entryId, position, seq, transcriptSha256: saved.manifest.transcript.sha256, bytes: offset, baseSha: args.baseSha };
|
|
277
|
+
if (point.transcriptSha256 !== digest(bytes)) return refused("checkpoint-mismatch");
|
|
278
|
+
durable(join(pointsOf(runId), `${pointKey(entryId, position)}.json`), JSON.stringify(point));
|
|
279
|
+
syncDir(pointsOf(runId));
|
|
280
|
+
return { ok: true, point };
|
|
281
|
+
} catch { return refused("checkpoint-unreadable"); }
|
|
282
|
+
finally { archiveBusy = false; }
|
|
283
|
+
}
|
|
284
|
+
async function readPoint(options) {
|
|
285
|
+
try {
|
|
286
|
+
const source = read(options);
|
|
287
|
+
if (!source.ok) return source;
|
|
288
|
+
if (archive.readBase) {
|
|
289
|
+
const base = await archive.readBase(options.runId);
|
|
290
|
+
if (base?.unavailable) return base;
|
|
291
|
+
if (!base?.ok || base.baseSha !== source.manifest.baseSha) return refused("base-missing");
|
|
292
|
+
}
|
|
293
|
+
if (source.point?.base) return source;
|
|
294
|
+
if (!source.point) return refused("checkpoint-missing");
|
|
295
|
+
const saved = await archive.readManifest(options.runId, { seq: source.point.seq });
|
|
296
|
+
if (saved?.unavailable) return saved;
|
|
297
|
+
if (saved?.reason === "internal-error") return { unavailable: "checkpoint-unreadable" };
|
|
298
|
+
if (!saved.ok || saved.manifest.workspace.partial || saved.manifest.workspace.ignoredDropped || saved.manifest.transcript.sha256 !== source.point.transcriptSha256 || saved.manifest.transcript.bytes !== source.point.bytes || saved.manifest.workspace.baseSha !== source.manifest.baseSha) return refused("checkpoint-mismatch");
|
|
299
|
+
// Bind the archived prefix to THIS immutable source, not merely to an entry id repeated elsewhere.
|
|
300
|
+
const prefix = readRegular(source.transcriptPath, L.maxBytes).subarray(0, source.point.bytes);
|
|
301
|
+
if (digest(prefix) !== source.point.transcriptSha256 || forkPointEntry(prefix) !== source.point.entryId) return refused("checkpoint-mismatch");
|
|
302
|
+
return { ...source, checkpoint: saved.manifest };
|
|
303
|
+
} catch (error) {
|
|
304
|
+
return retryableStoreError(error) ? { unavailable: error.code } : { unavailable: "checkpoint-unreadable" };
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
async function restorePoint({ intoWorkspace, ...options }) {
|
|
308
|
+
const source = await readPoint(options);
|
|
309
|
+
if (!source.ok || source.point.base) return source;
|
|
310
|
+
const restored = await archive.restore({ runId: options.runId, manifest: source.checkpoint, intoWorkspace });
|
|
311
|
+
return restored.refused || restored.skipped || restored.unavailable ? restored : { ok: true, ...restored };
|
|
312
|
+
}
|
|
313
|
+
async function sweep() {
|
|
314
|
+
if (!root) return { removed: 0 };
|
|
315
|
+
let removed = 0;
|
|
316
|
+
try {
|
|
317
|
+
directory(root);
|
|
318
|
+
for (const key of fs.readdirSync(root)) {
|
|
319
|
+
if (!KEY.test(key) || !fs.lstatSync(join(root, key)).isDirectory()) continue;
|
|
320
|
+
let runId, at;
|
|
321
|
+
try { const age = sweepAge(JSON.parse(readRegular(join(root, key, "manifest.json"), 8192)), key); if (age) ({ runId, at } = age); } catch {}
|
|
322
|
+
if (at === undefined) {
|
|
323
|
+
try {
|
|
324
|
+
const origin = JSON.parse(readRegular(join(root, key, "origin.json"), 4096));
|
|
325
|
+
if (Object.keys(origin).length === 2 && ID.test(origin.runId ?? "") && forkSourceKey(origin.runId) === key && Number.isSafeInteger(origin.at) && origin.at >= 0) { runId = origin.runId; at = origin.at; }
|
|
326
|
+
} catch {}
|
|
327
|
+
}
|
|
328
|
+
if (at === undefined) at = fs.lstatSync(join(root, key)).mtimeMs;
|
|
329
|
+
if (now() - at <= L.ttlMs) continue;
|
|
330
|
+
if (runId) await archive.remove(runId);
|
|
331
|
+
fs.rmSync(join(root, key), { recursive: true, force: true });
|
|
332
|
+
removed++;
|
|
333
|
+
}
|
|
334
|
+
return { removed };
|
|
335
|
+
} catch { return refused("sweep-failed"); }
|
|
336
|
+
}
|
|
337
|
+
return { publish, read, primeBase, capturePoint, readPoint, restorePoint, sweep, baseSourceUrl: (runId) => archive?.baseSourceUrl(runId) };
|
|
338
|
+
}
|
package/src/get-token.mjs
CHANGED
|
@@ -120,7 +120,7 @@ export async function makeGitHubAuth(cfg, deps = {}) {
|
|
|
120
120
|
// for `{ metadata: "read" }`, so the token it holds for that one question cannot write anything
|
|
121
121
|
// even if leaked. Job mints never pass this and keep the installation's full grant -- narrowing
|
|
122
122
|
// is the caller's statement of intent, not a default this mint could guess.
|
|
123
|
-
const permissions = job?.permissions;
|
|
123
|
+
const permissions = job?.purpose === "answer-draft" ? { contents: "read", issues: "read", pull_requests: "read", metadata: "read" } : job?.permissions;
|
|
124
124
|
let minted;
|
|
125
125
|
try {
|
|
126
126
|
const appAuth = createAppAuth({ ...auth, request: mintRequest });
|
package/src/git-dirty.mjs
CHANGED
|
@@ -1,30 +1,135 @@
|
|
|
1
1
|
import { execFileSync } from "node:child_process";
|
|
2
|
-
import { existsSync } from "node:fs";
|
|
3
|
-
import {
|
|
2
|
+
import { closeSync, constants, existsSync, fstatSync, mkdirSync, mkdtempSync, openSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { isAbsolute, join } from "node:path";
|
|
4
5
|
import { GIT_READ_FLAGS } from "./git-hardening.mjs";
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* Report a folder's git working-tree state: `true` = dirty, `false` = clean, `null` = not a usable
|
|
8
|
-
* git repository. Reads the WORKING TREE via `git status --porcelain
|
|
9
|
-
*
|
|
10
|
-
*
|
|
9
|
+
* git repository. Reads the WORKING TREE via `git status --porcelain`, distinct from flow-gate.mjs's
|
|
10
|
+
* object-store read, which reads committed content at a pinned SHA; the two are kept separate on purpose.
|
|
11
|
+
* `exec` and `fs` are injectable for tests.
|
|
11
12
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
13
|
+
* THE FOLDER'S OWN GIT CONFIG IS NEVER READ BY THE STATUS. The folder is the path a local job mounts at
|
|
14
|
+
* /workspace with write access, so the agent can write its `.git/config` and `.git/info/attributes`, and
|
|
15
|
+
* `git status` hashes every stat-dirty file through the clean filter those name: a command, run ON THE HOST
|
|
16
|
+
* by the next `pi-dispatch run` or console start. `-c` cannot neutralise a filter whose driver name is not
|
|
17
|
+
* known in advance, and git has no switch that skips a repository's local config. So the status runs
|
|
18
|
+
* against a scratch git directory this function writes itself: HEAD at the folder's commit, the folder's
|
|
19
|
+
* objects reached through `objects/info/alternates`, a copy of its index with every assume-unchanged and
|
|
20
|
+
* skip-worktree bit cleared (an agent could set them to hide a change), and a config
|
|
21
|
+
* holding only the `core` settings that change how a file is compared (each value checked to be a plain
|
|
22
|
+
* word). Its `info/exclude` is not used either. A filter the working tree's `.gitattributes` names then has no
|
|
23
|
+
* driver here and is not run, unless the operator's own global config defines one by that name: then the operator's
|
|
24
|
+
* command runs, on content the agent chose (LFS needs exactly that).
|
|
25
|
+
*
|
|
26
|
+
* The operator's own global and system config still apply: this runs as the operator (the CLI, the
|
|
27
|
+
* console), that config is theirs and outside the container, and an LFS repository needs its filter.
|
|
28
|
+
*
|
|
29
|
+
* The paths and the commit come from `rev-parse`, hardened, which reads config but refreshes no index and
|
|
30
|
+
* so runs no filter and no fsmonitor. An unborn HEAD is `null`: every caller refuses that folder first.
|
|
18
31
|
*/
|
|
19
|
-
export function gitDirty(folder, { exec = execFileSync } = {}) {
|
|
32
|
+
export function gitDirty(folder, { exec = execFileSync, fs = defaultFs } = {}) {
|
|
33
|
+
let scratch = null;
|
|
20
34
|
try {
|
|
21
|
-
const
|
|
22
|
-
|
|
35
|
+
const read = (args) => String(exec("git", [...GIT_READ_FLAGS, "-C", folder, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }));
|
|
36
|
+
const [gitDir, commonDir, format, head] = read(["rev-parse", "--path-format=absolute", "--git-dir", "--git-common-dir", "--show-object-format", "--verify", "HEAD^{commit}"]).split("\n");
|
|
37
|
+
if (!isAbsolute(gitDir ?? "") || !isAbsolute(commonDir ?? "") || !/^(sha1|sha256)$/.test(format ?? "") || !/^[0-9a-f]{40}([0-9a-f]{24})?$/.test(head ?? "")) return null;
|
|
38
|
+
const core = compareSettings(read);
|
|
39
|
+
scratch = fs.mkdtempSync(join(tmpdir(), "pi-dispatch-git-read-"));
|
|
40
|
+
fs.mkdirSync(join(scratch, "refs"));
|
|
41
|
+
fs.mkdirSync(join(scratch, "objects", "info"), { recursive: true });
|
|
42
|
+
fs.mkdirSync(join(scratch, "info"));
|
|
43
|
+
fs.writeFileSync(join(scratch, "HEAD"), `${head}\n`);
|
|
44
|
+
fs.writeFileSync(join(scratch, "objects", "info", "alternates"), `${join(commonDir, "objects")}\n`);
|
|
45
|
+
const sha256 = format === "sha256";
|
|
46
|
+
const lines = ["[core]", `\trepositoryformatversion = ${sha256 ? 1 : 0}`, "\tbare = false", ...core.map(([k, v]) => `\t${k} = ${v}`)];
|
|
47
|
+
if (sha256) lines.push("[extensions]", "\tobjectformat = sha256");
|
|
48
|
+
fs.writeFileSync(join(scratch, "config"), `${lines.join("\n")}\n`);
|
|
49
|
+
// `info/exclude` is NOT copied: it lives in the agent-writable `.git`, and `*` there would hide the operator's
|
|
50
|
+
// untracked work. A personal exclude list then reads as dirty, which is the safe side (`--force` is the way past).
|
|
51
|
+
copyRegular(fs, join(gitDir, "index"), join(scratch, "index"));
|
|
52
|
+
// A split index keeps its shared part beside it; without it the copy cannot be read and the answer is null.
|
|
53
|
+
for (const name of safeList(fs, gitDir)) if (/^sharedindex\.[0-9a-f]{40,64}$/.test(name)) copyRegular(fs, join(gitDir, name), join(scratch, name));
|
|
54
|
+
const env = { ...withoutGitEnv(process.env), GIT_DIR: scratch, GIT_WORK_TREE: folder, GIT_INDEX_FILE: join(scratch, "index"), GIT_OPTIONAL_LOCKS: "0", GIT_TERMINAL_PROMPT: "0" };
|
|
55
|
+
const scratchGit = (args, input) => exec("git", [...GIT_READ_FLAGS, ...args], { encoding: "utf8", env, cwd: scratch, ...(input === undefined ? { stdio: ["ignore", "pipe", "ignore"] } : { input, stdio: ["pipe", "pipe", "ignore"] }) });
|
|
56
|
+
// The index is agent-written too, and an assume-unchanged or skip-worktree bit hides every later change to that
|
|
57
|
+
// path from `status`. Both are cleared in the COPY (one flag per call: given together, git applies only one). A
|
|
58
|
+
// sparse checkout's skipped paths then read as deleted, so dirty: the safe side again.
|
|
59
|
+
const flagged = String(scratchGit(["ls-files", "-v", "-z"]))
|
|
60
|
+
.split("\0")
|
|
61
|
+
.filter((e) => e.length > 2 && e[1] === " " && (e[0] === "S" || (e[0] >= "a" && e[0] <= "z")))
|
|
62
|
+
.map((e) => e.slice(2));
|
|
63
|
+
if (flagged.length > 0) {
|
|
64
|
+
const list = `${flagged.join("\0")}\0`;
|
|
65
|
+
scratchGit(["update-index", "-z", "--no-skip-worktree", "--stdin"], list);
|
|
66
|
+
scratchGit(["update-index", "-z", "--no-assume-unchanged", "--stdin"], list);
|
|
67
|
+
}
|
|
68
|
+
const out = scratchGit(["-c", "core.untrackedCache=false", "-c", "status.submoduleSummary=false", "status", "--porcelain", "--ignore-submodules=all"]);
|
|
69
|
+
return String(out).trim().length > 0;
|
|
23
70
|
} catch {
|
|
24
71
|
return null;
|
|
72
|
+
} finally {
|
|
73
|
+
if (scratch) {
|
|
74
|
+
try {
|
|
75
|
+
fs.rmSync(scratch, { recursive: true, force: true });
|
|
76
|
+
} catch {}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const defaultFs = { mkdtempSync, mkdirSync, writeFileSync, rmSync, openSync, fstatSync, closeSync, readFileSync, readdirSync };
|
|
82
|
+
|
|
83
|
+
/** The `core` settings that change how a working-tree file is compared, never one that names a command. */
|
|
84
|
+
const COMPARE_KEYS = ["autocrlf", "eol", "ignorecase", "precomposeunicode", "filemode", "symlinks", "checkstat", "trustctime"];
|
|
85
|
+
|
|
86
|
+
/** The folder's values for COMPARE_KEYS, each a plain word, read with `git config` (a read: no filter, no hook). */
|
|
87
|
+
function compareSettings(read) {
|
|
88
|
+
let out = "";
|
|
89
|
+
try {
|
|
90
|
+
out = read(["config", "--get-regexp", `^core\\.(${COMPARE_KEYS.join("|")})$`]);
|
|
91
|
+
} catch {
|
|
92
|
+
return []; // exit 1: none set
|
|
93
|
+
}
|
|
94
|
+
const seen = new Map();
|
|
95
|
+
for (const line of out.split("\n")) {
|
|
96
|
+
const m = /^core\.([a-z]+) ([A-Za-z0-9]{1,16})$/.exec(line.trim());
|
|
97
|
+
if (m && COMPARE_KEYS.includes(m[1])) seen.set(m[1], m[2]);
|
|
98
|
+
}
|
|
99
|
+
return [...seen];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Copies `from` when it is a regular file of at most 512 MiB; anything else is skipped. Opened O_NOFOLLOW and judged by
|
|
104
|
+
* the descriptor, so a link swapped in after a check can never be read through.
|
|
105
|
+
*/
|
|
106
|
+
function copyRegular(fs, from, to) {
|
|
107
|
+
let fd = null;
|
|
108
|
+
try {
|
|
109
|
+
fd = fs.openSync(from, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
110
|
+
const st = fs.fstatSync(fd);
|
|
111
|
+
if (!st.isFile() || st.size > 512 * 1024 * 1024) return;
|
|
112
|
+
fs.writeFileSync(to, fs.readFileSync(fd));
|
|
113
|
+
} catch {
|
|
114
|
+
// absent, a link, or unreadable: the copy is skipped and git answers from what it has
|
|
115
|
+
} finally {
|
|
116
|
+
if (fd !== null) fs.closeSync(fd);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function safeList(fs, dir) {
|
|
121
|
+
try {
|
|
122
|
+
return fs.readdirSync(dir);
|
|
123
|
+
} catch {
|
|
124
|
+
return [];
|
|
25
125
|
}
|
|
26
126
|
}
|
|
27
127
|
|
|
128
|
+
/** The environment without any GIT_* variable, so the caller's own GIT_DIR or index cannot redirect the read. */
|
|
129
|
+
function withoutGitEnv(env) {
|
|
130
|
+
return Object.fromEntries(Object.entries(env).filter(([k]) => !k.startsWith("GIT_")));
|
|
131
|
+
}
|
|
132
|
+
|
|
28
133
|
/**
|
|
29
134
|
* Why `folder` cannot be a local job's folder, as one sentence with its fix, or null when it can (issue #524).
|
|
30
135
|
*
|
package/src/git-hardening.mjs
CHANGED
|
@@ -31,3 +31,88 @@ export const GIT_SAFE_CONFIG = Object.freeze(["-c", "core.hooksPath=/dev/null",
|
|
|
31
31
|
* convenience of this one.
|
|
32
32
|
*/
|
|
33
33
|
export const GIT_READ_FLAGS = Object.freeze([...GIT_SAFE_CONFIG, "--no-pager"]);
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The flags for every git spawned against the WORKSPACE CHECKPOINT STORE (issue #645). Unlike the two
|
|
37
|
+
* constants above, which guard a read of a repository the worker cloned, the store is written while a
|
|
38
|
+
* paid job is paused, from a tree the agent fully controls, so the prefix pins every knob that could make
|
|
39
|
+
* git run a program, sign, prompt, or do background work in the middle of that window:
|
|
40
|
+
*
|
|
41
|
+
* - `protocol.ext.allow=never` and an empty `credential.helper`: the store only ever talks `file://`, and
|
|
42
|
+
* nothing it does may reach a helper or an `ext::` command.
|
|
43
|
+
* - `commit.gpgSign`/`tag.gpgSign=false`: `gpg.program` is a command, and a signing default in some
|
|
44
|
+
* config would run it on the host.
|
|
45
|
+
* - `gc.auto=0`, `maintenance.auto=false`: an auto-gc forked mid-snapshot holds the store's packs while
|
|
46
|
+
* the checkpointer repacks on its own schedule; two writers on one object store is the race to avoid.
|
|
47
|
+
* - `core.protectNTFS`/`core.protectHFS=true`: git's own dotgit folding, on whatever the host is, so the
|
|
48
|
+
* store refuses the same names `checkpoint-paths.mjs` does.
|
|
49
|
+
* - `transfer.fsckObjects`/`fetch.fsckObjects=true`: the base fetch is the one place objects arrive from
|
|
50
|
+
* outside; a malformed tree there (a `..` entry, a `.GIT`) is refused before it is ever stored.
|
|
51
|
+
* - `core.untrackedCache=false`: the untracked cache is written into the index of whatever work tree git
|
|
52
|
+
* was pointed at; the one command given a work tree (`check-ignore`) must write nothing there.
|
|
53
|
+
*/
|
|
54
|
+
export const STORE_GIT_FLAGS = Object.freeze([
|
|
55
|
+
...GIT_SAFE_CONFIG,
|
|
56
|
+
"-c",
|
|
57
|
+
"protocol.ext.allow=never",
|
|
58
|
+
"-c",
|
|
59
|
+
"credential.helper=",
|
|
60
|
+
"-c",
|
|
61
|
+
"commit.gpgSign=false",
|
|
62
|
+
"-c",
|
|
63
|
+
"tag.gpgSign=false",
|
|
64
|
+
"-c",
|
|
65
|
+
"gc.auto=0",
|
|
66
|
+
"-c",
|
|
67
|
+
"maintenance.auto=false",
|
|
68
|
+
"-c",
|
|
69
|
+
"core.protectNTFS=true",
|
|
70
|
+
"-c",
|
|
71
|
+
"core.protectHFS=true",
|
|
72
|
+
"-c",
|
|
73
|
+
"transfer.fsckObjects=true",
|
|
74
|
+
"-c",
|
|
75
|
+
"fetch.fsckObjects=true",
|
|
76
|
+
"-c",
|
|
77
|
+
"core.untrackedCache=false",
|
|
78
|
+
"--no-pager",
|
|
79
|
+
]);
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The environment for a store git, built FROM NOTHING rather than from `process.env`. A spread would carry
|
|
83
|
+
* whatever the worker's own environment holds into git: `GIT_DIR`/`GIT_WORK_TREE` from a developer shell,
|
|
84
|
+
* `GIT_SSH_COMMAND`, `GIT_EXTERNAL_DIFF`, `GIT_CONFIG_PARAMETERS` (every one of them a way to run a
|
|
85
|
+
* program or read another repository), plus every secret the worker holds. A deny list would have to name
|
|
86
|
+
* all of those and every one git adds later; an allow list of exactly what git needs cannot drift that way.
|
|
87
|
+
*
|
|
88
|
+
* `HOME` and `XDG_CONFIG_HOME` both point at an empty per-run directory, so neither `~/.gitconfig` nor
|
|
89
|
+
* `$XDG_CONFIG_HOME/git/{config,attributes,ignore}` can exist; `GIT_CONFIG_GLOBAL=/dev/null` says the same
|
|
90
|
+
* for the global file, and the two NOSYSTEM switches for the system ones. `GIT_OPTIONAL_LOCKS=0` keeps a
|
|
91
|
+
* status-like read from writing an index; `GIT_NO_REPLACE_OBJECTS` stops a `refs/replace` ref swapping the
|
|
92
|
+
* object a commit names; `GIT_PROTOCOL_FROM_USER=0` turns every "user"-policy protocol off by default (the
|
|
93
|
+
* two `file://` fetches re-enable `file` explicitly, at their call sites). `LC_ALL=C` keeps any message we
|
|
94
|
+
* match on in one language.
|
|
95
|
+
*/
|
|
96
|
+
// env-internal PATH: the worker's own search path, passed through so the store git finds its helpers (`git-upload-pack`).
|
|
97
|
+
export function storeGitEnv({ gitDir, home, path = process.env.PATH }) {
|
|
98
|
+
// Empty and relative PATH entries are dropped: both resolve against the CWD, so a `git-upload-pack`
|
|
99
|
+
// (or any helper git execs) found there would be whatever the directory git runs in happens to hold.
|
|
100
|
+
const safePath = String(path ?? "")
|
|
101
|
+
.split(":")
|
|
102
|
+
.filter((entry) => entry.startsWith("/"))
|
|
103
|
+
.join(":");
|
|
104
|
+
return {
|
|
105
|
+
PATH: safePath || "/usr/bin:/bin",
|
|
106
|
+
HOME: home,
|
|
107
|
+
XDG_CONFIG_HOME: home,
|
|
108
|
+
GIT_DIR: gitDir,
|
|
109
|
+
GIT_CONFIG_NOSYSTEM: "1",
|
|
110
|
+
GIT_CONFIG_GLOBAL: "/dev/null",
|
|
111
|
+
GIT_ATTR_NOSYSTEM: "1",
|
|
112
|
+
GIT_TERMINAL_PROMPT: "0",
|
|
113
|
+
GIT_OPTIONAL_LOCKS: "0",
|
|
114
|
+
GIT_NO_REPLACE_OBJECTS: "1",
|
|
115
|
+
GIT_PROTOCOL_FROM_USER: "0",
|
|
116
|
+
LC_ALL: "C",
|
|
117
|
+
};
|
|
118
|
+
}
|