@edgehero/pi-dispatch 0.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 +160 -0
- package/deploy/com.pi-dispatch.worker.plist +66 -0
- package/deploy/nssm-install.cmd +59 -0
- package/deploy/receiver.service +36 -0
- package/deploy/worker-env-wrapper.cmd +50 -0
- package/deploy/worker-env-wrapper.sh +63 -0
- package/deploy/worker.service +55 -0
- package/package.json +83 -0
- package/src/azure-auth.mjs +61 -0
- package/src/azure-host.mjs +236 -0
- package/src/azure-identity.mjs +63 -0
- package/src/azure-prompt.mjs +118 -0
- package/src/branch.mjs +80 -0
- package/src/budget.mjs +179 -0
- package/src/cli.mjs +208 -0
- package/src/config.mjs +329 -0
- package/src/connection.mjs +40 -0
- package/src/cron.mjs +94 -0
- package/src/docker-run.mjs +119 -0
- package/src/doctor.mjs +1127 -0
- package/src/env-allowlist.mjs +198 -0
- package/src/env-file.mjs +153 -0
- package/src/exit-code.mjs +32 -0
- package/src/flow-gate.mjs +82 -0
- package/src/forgejo-auth.mjs +77 -0
- package/src/forgejo-host.mjs +172 -0
- package/src/forgejo-identity.mjs +74 -0
- package/src/forgejo-prompt.mjs +123 -0
- package/src/forges.mjs +148 -0
- package/src/get-token.mjs +226 -0
- package/src/git-dirty.mjs +16 -0
- package/src/github-app-setup.mjs +517 -0
- package/src/github-host.mjs +159 -0
- package/src/github-prompt.mjs +286 -0
- package/src/gitlab-auth.mjs +72 -0
- package/src/gitlab-host.mjs +200 -0
- package/src/gitlab-identity.mjs +61 -0
- package/src/gitlab-prompt.mjs +123 -0
- package/src/identity.mjs +57 -0
- package/src/image-preflight.mjs +180 -0
- package/src/import-pi.mjs +451 -0
- package/src/index.mjs +177 -0
- package/src/init.mjs +77 -0
- package/src/job-id.mjs +100 -0
- package/src/materialize.mjs +138 -0
- package/src/outbox.mjs +179 -0
- package/src/packages.mjs +188 -0
- package/src/pause-windows.mjs +218 -0
- package/src/prepare-github.mjs +260 -0
- package/src/prepare-local.mjs +76 -0
- package/src/prepare.mjs +199 -0
- package/src/pricing.mjs +168 -0
- package/src/processor.mjs +360 -0
- package/src/queue.mjs +152 -0
- package/src/run-container.mjs +133 -0
- package/src/run-history.mjs +534 -0
- package/src/runtime-settings.mjs +188 -0
- package/src/sandbox-cli.mjs +156 -0
- package/src/sandbox-store.mjs +269 -0
- package/src/sandbox.mjs +171 -0
- package/src/scheduler-stall-guard.mjs +67 -0
- package/src/schedules.mjs +62 -0
- package/src/service.mjs +677 -0
- package/src/session-key.mjs +108 -0
- package/src/session-store.mjs +249 -0
- package/src/start.mjs +502 -0
- package/src/subscriptions.mjs +208 -0
- package/src/triggers.mjs +491 -0
- package/src/up.mjs +315 -0
package/src/packages.mjs
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Staged pi packages (issue #58): pinned third-party pi packages that live INSIDE the operator's global
|
|
3
|
+
* overlay at `<PI_GLOBAL_PI_DIR>/packages/<dir>/`. That overlay is already mounted `/opt/pi-global:ro`
|
|
4
|
+
* into every job container (REQ-GLOBAL-PI-OVERLAY), so staged packages need no new mount and no new
|
|
5
|
+
* trust boundary -- they ride the one the overlay already established.
|
|
6
|
+
*
|
|
7
|
+
* A staged package is a SELF-CONTAINED directory (its `package.json` plus its own `node_modules/`).
|
|
8
|
+
* pi's package resolver treats any spec that is not `npm:`/`git:`/a URL as a LOCAL path: it resolves the
|
|
9
|
+
* directory in place -- no install, no network, no writes -- and a `pi` manifest there contributes
|
|
10
|
+
* extensions, skills, prompts and themes. That is precisely what lets a job container load them with
|
|
11
|
+
* network egress denied, which is the whole point of staging at all.
|
|
12
|
+
*
|
|
13
|
+
* Versions are EXACT, never a range (CONST-PI-VERSION-PINNED): a floating range turns a silent upstream
|
|
14
|
+
* minor into every queued job becoming a no-op with NO signal -- the queue still reports success, the
|
|
15
|
+
* worst failure class available. Pinning converts that into an operator-visible edit of a version string.
|
|
16
|
+
*
|
|
17
|
+
* Two directions, two error policies:
|
|
18
|
+
* - `parsePackagesFile` reads the OPERATOR's `pi-packages.json` before anything is staged. Pure and
|
|
19
|
+
* fs-free (mirrors triggers.mjs), fail-loud `configError` naming the offending package.
|
|
20
|
+
* - `readStageManifest` reads the file the STAGER wrote, at job-wiring time. It NEVER throws: a corrupt
|
|
21
|
+
* manifest must degrade to "no staged packages", not crash the worker mid-queue.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
25
|
+
import { join } from "node:path";
|
|
26
|
+
import { configError } from "./config.mjs";
|
|
27
|
+
// ENTRY_NAME_RE / ADMIN_RE are import-pi's -- imported rather than re-declared so the staged dir charset
|
|
28
|
+
// and the admin block cannot drift between the stager and this validator (doctor.mjs sets the precedent
|
|
29
|
+
// of importing from import-pi.mjs).
|
|
30
|
+
import { ADMIN_RE, ENTRY_NAME_RE } from "./import-pi.mjs";
|
|
31
|
+
// The container-side mount point is docker-run.mjs's fact -- IMPORTED, never re-typed, so the mount and the
|
|
32
|
+
// packages root below cannot drift apart while both test suites stay green. docker-run.mjs is dependency-free
|
|
33
|
+
// (it builds an argv array and nothing else), so this costs no cycle and no weight in the admin's bundle.
|
|
34
|
+
import { CONTAINER_GLOBAL_PI_DIR } from "./docker-run.mjs";
|
|
35
|
+
|
|
36
|
+
/** Staged packages live under `<globalPiDir>/packages/` -- a subdir of the overlay, not a new mount. */
|
|
37
|
+
export const PACKAGES_SUBDIR = "packages";
|
|
38
|
+
/** The stager's receipt, written alongside the staged dirs: what was staged, at which exact version. */
|
|
39
|
+
export const STAGE_MANIFEST = "packages.json";
|
|
40
|
+
/** Where the staged packages land inside the job container -- the overlay mount plus the one subdir. */
|
|
41
|
+
export const CONTAINER_PACKAGES_ROOT = `${CONTAINER_GLOBAL_PI_DIR}/${PACKAGES_SUBDIR}`;
|
|
42
|
+
|
|
43
|
+
/** An exact semver -- prerelease and build metadata allowed, ranges/tags/wildcards are not. */
|
|
44
|
+
export const EXACT_VERSION_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
45
|
+
/** An npm package name: lowercase, optionally `@scope/`-prefixed. */
|
|
46
|
+
export const NPM_NAME_RE = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/;
|
|
47
|
+
|
|
48
|
+
/** npm's hard limit on a package name; a longer one could never have been published. */
|
|
49
|
+
const MAX_NAME_LENGTH = 214;
|
|
50
|
+
/** The staged dir name doubles as a path segment in the container, so it stays short and flat. */
|
|
51
|
+
const MAX_DIR_LENGTH = 64;
|
|
52
|
+
|
|
53
|
+
function isNonEmptyString(value) {
|
|
54
|
+
return typeof value === "string" && value.trim() !== "";
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The default staged dir for a package name: `@scope/name` -> `scope__name`. A flat, slash-free segment,
|
|
59
|
+
* because the staged dir is one path component under `packages/` -- keeping the scope in the name (rather
|
|
60
|
+
* than nesting a `scope/` dir) keeps `<packages>/<dir>` a single validated segment on both host and
|
|
61
|
+
* container.
|
|
62
|
+
*/
|
|
63
|
+
export function stagedDirName(name) {
|
|
64
|
+
return String(name).replace(/^@/, "").replace(/\//g, "__");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Parse, validate, and normalize the operator's packages file TEXT. Returns `[{ name, version, dir }]`
|
|
69
|
+
* with unknown fields dropped. Throws `configError` (fail-loud) naming the offending package. `path` is
|
|
70
|
+
* for error messages only -- this function touches no filesystem.
|
|
71
|
+
*/
|
|
72
|
+
export function parsePackagesFile(text, path) {
|
|
73
|
+
let parsed;
|
|
74
|
+
try {
|
|
75
|
+
parsed = JSON.parse(text);
|
|
76
|
+
} catch (error) {
|
|
77
|
+
throw configError(`packages file is not valid JSON: ${path} (${error.message})`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const entries = parsed?.packages;
|
|
81
|
+
if (!Array.isArray(entries)) {
|
|
82
|
+
throw configError(`packages file must have a "packages" array: ${path}`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const seenDirs = new Map();
|
|
86
|
+
return entries.map((entry, index) => normalizePackage(entry, index, path, seenDirs));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function normalizePackage(entry, index, path, seenDirs) {
|
|
90
|
+
const at = `package at index ${index}`;
|
|
91
|
+
|
|
92
|
+
if (entry === null || typeof entry !== "object" || Array.isArray(entry)) {
|
|
93
|
+
throw configError(`${at}: must be an object: ${path}`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const { name, version } = entry;
|
|
97
|
+
if (!isNonEmptyString(name)) {
|
|
98
|
+
throw configError(`${at}: "name" must be a non-empty string: ${path}`);
|
|
99
|
+
}
|
|
100
|
+
if (name.length > MAX_NAME_LENGTH) {
|
|
101
|
+
throw configError(`package "${name.slice(0, 40)}...": name exceeds npm's ${MAX_NAME_LENGTH}-character limit: ${path}`);
|
|
102
|
+
}
|
|
103
|
+
if (!NPM_NAME_RE.test(name)) {
|
|
104
|
+
throw configError(`package "${name}": name must be a valid npm package name (lowercase, optionally "@scope/"-prefixed): ${path}`);
|
|
105
|
+
}
|
|
106
|
+
// The admin package can enqueue paid jobs -- staging it into an overlay that every job container reads
|
|
107
|
+
// is the same recursion vector import-pi already blocks for the admin extension.
|
|
108
|
+
if (ADMIN_RE.test(name)) {
|
|
109
|
+
throw configError(`package "${name}": refused -- the pi-dispatch admin package must never be staged into a job overlay (it can enqueue paid jobs: a recursion vector): ${path}`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (!isNonEmptyString(version)) {
|
|
113
|
+
throw configError(`package "${name}": "version" must be a non-empty string (an exact version, e.g. "1.4.2"): ${path}`);
|
|
114
|
+
}
|
|
115
|
+
// Exact versions only (CONST-PI-VERSION-PINNED). Stated in full because the refusal looks pedantic
|
|
116
|
+
// until you know the failure mode it prevents.
|
|
117
|
+
if (!EXACT_VERSION_RE.test(version)) {
|
|
118
|
+
throw configError(
|
|
119
|
+
`package "${name}": version must be an EXACT version like "1.4.2", got ${JSON.stringify(version)} -- ` +
|
|
120
|
+
`a floating range turns a silent upstream minor into every queued job becoming a no-op with no signal, ` +
|
|
121
|
+
`and the queue still reports success (CONST-PI-VERSION-PINNED): ${path}`,
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (entry.dir !== undefined && !isNonEmptyString(entry.dir)) {
|
|
126
|
+
throw configError(`package "${name}": "dir" must be a non-empty string when present: ${path}`);
|
|
127
|
+
}
|
|
128
|
+
const dir = entry.dir ?? stagedDirName(name);
|
|
129
|
+
// Length first: the charset regex also caps at 64, but "too long" and "bad characters" are different
|
|
130
|
+
// operator mistakes and deserve different fixes.
|
|
131
|
+
if (dir.length > MAX_DIR_LENGTH) {
|
|
132
|
+
throw configError(`package "${name}": dir ${JSON.stringify(dir)} exceeds ${MAX_DIR_LENGTH} characters -- set a shorter explicit "dir": ${path}`);
|
|
133
|
+
}
|
|
134
|
+
if (!ENTRY_NAME_RE.test(dir)) {
|
|
135
|
+
throw configError(`package "${name}": dir ${JSON.stringify(dir)} is not a safe directory name (letters, digits, "._-", no slashes, no "..") -- set an explicit "dir": ${path}`);
|
|
136
|
+
}
|
|
137
|
+
if (ADMIN_RE.test(dir)) {
|
|
138
|
+
throw configError(`package "${name}": dir ${JSON.stringify(dir)} is refused -- the admin name is blocked in a job overlay: ${path}`);
|
|
139
|
+
}
|
|
140
|
+
if (seenDirs.has(dir)) {
|
|
141
|
+
throw configError(`package "${name}": dir "${dir}" is already used by "${seenDirs.get(dir)}" -- two packages cannot share one staged dir; set an explicit "dir" on one of them: ${path}`);
|
|
142
|
+
}
|
|
143
|
+
seenDirs.set(dir, name);
|
|
144
|
+
|
|
145
|
+
return { name, version, dir };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Read the stager's manifest from `<globalPiDir>/packages/packages.json`. Returns `{ stagedAt, packages }`
|
|
150
|
+
* or `null` when there is nothing usable there. NEVER throws -- this runs on the job path, where a corrupt
|
|
151
|
+
* or half-written manifest must mean "no staged packages" rather than a crashed worker.
|
|
152
|
+
*
|
|
153
|
+
* Entries are re-validated on the way in (the file is a host artifact that an operator may have hand-edited
|
|
154
|
+
* between stage time and job time): a `dir` that is not a plain segment would otherwise flow straight into
|
|
155
|
+
* a container path.
|
|
156
|
+
*/
|
|
157
|
+
export function readStageManifest({ globalPiDir, readFile = readFileSync, fileExists = existsSync } = {}) {
|
|
158
|
+
if (!globalPiDir) return null;
|
|
159
|
+
const path = join(globalPiDir, PACKAGES_SUBDIR, STAGE_MANIFEST);
|
|
160
|
+
try {
|
|
161
|
+
if (!fileExists(path)) return null;
|
|
162
|
+
const parsed = JSON.parse(readFile(path, "utf8"));
|
|
163
|
+
if (parsed === null || typeof parsed !== "object" || !Array.isArray(parsed.packages)) return null;
|
|
164
|
+
|
|
165
|
+
const packages = [];
|
|
166
|
+
for (const entry of parsed.packages) {
|
|
167
|
+
if (entry === null || typeof entry !== "object") return null;
|
|
168
|
+
const { name, version, dir } = entry;
|
|
169
|
+
if (!isNonEmptyString(name) || !isNonEmptyString(version) || !isNonEmptyString(dir)) return null;
|
|
170
|
+
if (!ENTRY_NAME_RE.test(dir) || dir.length > MAX_DIR_LENGTH) return null;
|
|
171
|
+
packages.push({ name, version, dir });
|
|
172
|
+
}
|
|
173
|
+
return { stagedAt: typeof parsed.stagedAt === "string" ? parsed.stagedAt : null, packages };
|
|
174
|
+
} catch {
|
|
175
|
+
return null; // missing, unreadable, or garbage -- all mean "nothing staged"
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* The CONTAINER paths of the staged packages, in manifest order -- what gets handed to pi as local package
|
|
181
|
+
* specs. Built with template literals and never `path.join`: the worker may run on Windows, where `join`
|
|
182
|
+
* yields backslashes, and these are paths INSIDE a Linux container.
|
|
183
|
+
*/
|
|
184
|
+
export function containerPackagePaths(manifest) {
|
|
185
|
+
const packages = manifest?.packages;
|
|
186
|
+
if (!Array.isArray(packages)) return [];
|
|
187
|
+
return packages.filter((p) => isNonEmptyString(p?.dir)).map((p) => `${CONTAINER_PACKAGES_ROOT}/${p.dir}`);
|
|
188
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scoped pause windows (REQ-SCOPED-PAUSE-WINDOWS): hold a folder/repo's runs "between certain times" and
|
|
3
|
+
* resume automatically after. One `pause-windows.json` of `{ scope, from, to, tz?, days?, dateFrom?, dateTo? }`
|
|
4
|
+
* entries; the worker defers a job whose scope is inside an active window via BullMQ's own delayed set
|
|
5
|
+
* (DES-SCOPED-PAUSE-VIA-MOVE-TO-DELAYED). Deferred, never dropped — a paused github issue job runs after the
|
|
6
|
+
* window, not lost.
|
|
7
|
+
*
|
|
8
|
+
* This module is pure and fs-injectable (mirrors triggers.mjs/schedules.mjs): `parsePauseWindows` validates
|
|
9
|
+
* the file TEXT fail-loud, `loadPauseWindows` layers the one fs read on top, and `pauseUntilMs` is the
|
|
10
|
+
* timezone-aware predicate the processor gate consumes. Timezones are handled with the built-in `Intl`
|
|
11
|
+
* (no dependency, DST-correct via a one-pass offset correction).
|
|
12
|
+
*
|
|
13
|
+
* Custom: pause windows validated inline per triggers.mjs/config.mjs precedent; zod not in deps
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { existsSync as fsExistsSync, readFileSync as fsReadFileSync } from "node:fs";
|
|
17
|
+
import { configError } from "./config.mjs";
|
|
18
|
+
|
|
19
|
+
// Sunday-first to match JS getUTCDay() and the Intl weekday index used in zonedParts().
|
|
20
|
+
const DAYS = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"];
|
|
21
|
+
const DAY_SET = new Set(DAYS);
|
|
22
|
+
|
|
23
|
+
function isNonEmptyString(value) {
|
|
24
|
+
return typeof value === "string" && value.trim() !== "";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** "HH:MM" -> minutes since midnight in [0,1439]; throws `configError` on anything malformed. */
|
|
28
|
+
function parseHHMM(value, label, at, path) {
|
|
29
|
+
const m = isNonEmptyString(value) ? /^([0-9]{1,2}):([0-9]{2})$/.exec(value.trim()) : null;
|
|
30
|
+
if (!m) throw configError(`${at}: ${label} must be "HH:MM": ${JSON.stringify(value)} (${path})`);
|
|
31
|
+
const h = Number(m[1]);
|
|
32
|
+
const min = Number(m[2]);
|
|
33
|
+
if (h > 23 || min > 59) throw configError(`${at}: ${label} out of range 00:00-23:59: ${JSON.stringify(value)} (${path})`);
|
|
34
|
+
return h * 60 + min;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Validate a "YYYY-MM-DD" calendar date; throws `configError` when malformed. */
|
|
38
|
+
function assertDate(value, label, at, path) {
|
|
39
|
+
const m = /^([0-9]{4})-([0-9]{2})-([0-9]{2})$/.exec(String(value).trim());
|
|
40
|
+
const mo = m && Number(m[2]);
|
|
41
|
+
const d = m && Number(m[3]);
|
|
42
|
+
if (!m || mo < 1 || mo > 12 || d < 1 || d > 31) {
|
|
43
|
+
throw configError(`${at}: ${label} must be "YYYY-MM-DD": ${JSON.stringify(value)} (${path})`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Confirm an IANA zone by constructing a formatter (throws for an unknown zone); returns the zone. */
|
|
48
|
+
function validateTz(tz, at, path) {
|
|
49
|
+
try {
|
|
50
|
+
new Intl.DateTimeFormat("en-US", { timeZone: tz });
|
|
51
|
+
return tz;
|
|
52
|
+
} catch {
|
|
53
|
+
throw configError(`${at}: tz is not a valid IANA timezone: ${JSON.stringify(tz)} (${path})`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Parse, validate, and normalize the pause-windows file text. Returns an array of normalized windows
|
|
59
|
+
* (unknown fields dropped; `fromMin`/`toMin` precomputed). Throws `configError` (fail-loud) on any malformed
|
|
60
|
+
* entry. `path` is for messages only.
|
|
61
|
+
*/
|
|
62
|
+
export function parsePauseWindows(text, path) {
|
|
63
|
+
let parsed;
|
|
64
|
+
try {
|
|
65
|
+
parsed = JSON.parse(text);
|
|
66
|
+
} catch (error) {
|
|
67
|
+
throw configError(`pause-windows file is not valid JSON: ${path} (${error.message})`);
|
|
68
|
+
}
|
|
69
|
+
const windows = parsed?.windows;
|
|
70
|
+
if (!Array.isArray(windows)) {
|
|
71
|
+
throw configError(`pause-windows file must have a "windows" array: ${path}`);
|
|
72
|
+
}
|
|
73
|
+
return windows.map((w, index) => normalizeWindow(w, index, path));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function normalizeWindow(w, index, path) {
|
|
77
|
+
const at = `pause window at index ${index}`;
|
|
78
|
+
if (w === null || typeof w !== "object") throw configError(`${at}: must be an object: ${path}`);
|
|
79
|
+
if (!isNonEmptyString(w.scope)) throw configError(`${at}: scope must be a non-empty string: ${path}`);
|
|
80
|
+
|
|
81
|
+
const fromMin = parseHHMM(w.from, "from", at, path);
|
|
82
|
+
const toMin = parseHHMM(w.to, "to", at, path);
|
|
83
|
+
// A from==to window would pause 24h forever and busy-defer; refuse it — remove the trigger instead.
|
|
84
|
+
if (fromMin === toMin) {
|
|
85
|
+
throw configError(`${at}: from and to must differ (a 24h pause is not expressible; remove the trigger): ${path}`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const tz = w.tz === undefined ? "UTC" : validateTz(w.tz, at, path);
|
|
89
|
+
|
|
90
|
+
let days = null;
|
|
91
|
+
if (w.days !== undefined) {
|
|
92
|
+
if (!Array.isArray(w.days) || w.days.length === 0) {
|
|
93
|
+
throw configError(`${at}: days must be a non-empty array of weekday names (mon..sun): ${path}`);
|
|
94
|
+
}
|
|
95
|
+
days = w.days.map((d) => {
|
|
96
|
+
const key = String(d).trim().toLowerCase().slice(0, 3);
|
|
97
|
+
if (!DAY_SET.has(key)) throw configError(`${at}: unknown weekday ${JSON.stringify(d)} (use mon..sun): ${path}`);
|
|
98
|
+
return key;
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
let dateFrom = null;
|
|
103
|
+
let dateTo = null;
|
|
104
|
+
if (w.dateFrom !== undefined) { assertDate(w.dateFrom, "dateFrom", at, path); dateFrom = String(w.dateFrom).trim(); }
|
|
105
|
+
if (w.dateTo !== undefined) { assertDate(w.dateTo, "dateTo", at, path); dateTo = String(w.dateTo).trim(); }
|
|
106
|
+
if (dateFrom && dateTo && dateFrom > dateTo) throw configError(`${at}: dateFrom must be <= dateTo: ${path}`);
|
|
107
|
+
|
|
108
|
+
const norm = { scope: w.scope.trim(), from: w.from.trim(), to: w.to.trim(), fromMin, toMin, tz };
|
|
109
|
+
if (days) norm.days = days;
|
|
110
|
+
if (dateFrom) norm.dateFrom = dateFrom;
|
|
111
|
+
if (dateTo) norm.dateTo = dateTo;
|
|
112
|
+
return norm;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Load and validate the pause-windows file named by `config.pauseWindowsFile`. Returns `[]` when the file is
|
|
117
|
+
* unset (the feature is disabled — a valid deployment). `readFileSync`/`existsSync` are injectable for tests.
|
|
118
|
+
*/
|
|
119
|
+
export function loadPauseWindows(config, { readFileSync = fsReadFileSync, existsSync = fsExistsSync } = {}) {
|
|
120
|
+
const path = config.pauseWindowsFile;
|
|
121
|
+
if (path === null || path === undefined) return [];
|
|
122
|
+
if (!existsSync(path)) throw configError(`pause-windows file does not exist: ${path}`);
|
|
123
|
+
return parsePauseWindows(readFileSync(path, "utf8"), path);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ── the timezone-aware predicate ────────────────────────────────────────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
const WEEKDAY_INDEX = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 };
|
|
129
|
+
|
|
130
|
+
/** The wall-clock parts of instant `ms` in `tz`: `{ y, mo, d, hh, mi, dow }` (dow 0=Sun..6=Sat). */
|
|
131
|
+
function zonedParts(tz, ms) {
|
|
132
|
+
const fmt = new Intl.DateTimeFormat("en-US", {
|
|
133
|
+
timeZone: tz, hour12: false,
|
|
134
|
+
year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", weekday: "short",
|
|
135
|
+
});
|
|
136
|
+
const p = {};
|
|
137
|
+
for (const part of fmt.formatToParts(new Date(ms))) p[part.type] = part.value;
|
|
138
|
+
let hh = Number(p.hour);
|
|
139
|
+
if (hh === 24) hh = 0; // some ICU builds render midnight as "24"
|
|
140
|
+
return { y: Number(p.year), mo: Number(p.month), d: Number(p.day), hh, mi: Number(p.minute), dow: WEEKDAY_INDEX[p.weekday] };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Epoch ms for a wall-clock time `(y,mo,d,hh,mi)` in `tz`. One-pass offset correction: format the naive-UTC
|
|
145
|
+
* guess back in `tz`, diff to recover the zone offset, subtract. Correct except the ~1h DST-transition seam,
|
|
146
|
+
* which is immaterial for a pause boundary.
|
|
147
|
+
*/
|
|
148
|
+
function wallToMs(tz, y, mo, d, hh, mi) {
|
|
149
|
+
const guess = Date.UTC(y, mo - 1, d, hh, mi);
|
|
150
|
+
const shown = zonedParts(tz, guess);
|
|
151
|
+
const shownAsUtc = Date.UTC(shown.y, shown.mo - 1, shown.d, shown.hh, shown.mi);
|
|
152
|
+
return guess - (shownAsUtc - guess);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** The date `delta` days from `parts`' date, computed at local noon to dodge DST edges. Returns zoned parts. */
|
|
156
|
+
function shiftDay(tz, parts, delta) {
|
|
157
|
+
const noon = wallToMs(tz, parts.y, parts.mo, parts.d, 12, 0);
|
|
158
|
+
return zonedParts(tz, noon + delta * 86400000);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function ymd(parts) {
|
|
162
|
+
return `${String(parts.y).padStart(4, "0")}-${String(parts.mo).padStart(2, "0")}-${String(parts.d).padStart(2, "0")}`;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Whether a window occurrence STARTING on `parts`' day is allowed by its `days` and `dateFrom`/`dateTo`. */
|
|
166
|
+
function qualifies(w, parts) {
|
|
167
|
+
if (w.days && !w.days.includes(DAYS[parts.dow])) return false;
|
|
168
|
+
const date = ymd(parts);
|
|
169
|
+
if (w.dateFrom && date < w.dateFrom) return false;
|
|
170
|
+
if (w.dateTo && date > w.dateTo) return false;
|
|
171
|
+
return true;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** The end epoch ms of the window occurrence active at `nowMs`, or null if `now` is not inside `w`. Exported
|
|
175
|
+
* so the admin panel can mark a single window "currently paused · resumes in …" without a synthetic job. */
|
|
176
|
+
export function windowEndAt(w, nowMs) {
|
|
177
|
+
const p = zonedParts(w.tz, nowMs);
|
|
178
|
+
const nowMin = p.hh * 60 + p.mi;
|
|
179
|
+
const endAt = (dateParts) => wallToMs(w.tz, dateParts.y, dateParts.mo, dateParts.d, Math.floor(w.toMin / 60), w.toMin % 60);
|
|
180
|
+
|
|
181
|
+
if (w.fromMin < w.toMin) {
|
|
182
|
+
// Same-day window: [from, to) today, starting today.
|
|
183
|
+
if (nowMin >= w.fromMin && nowMin < w.toMin && qualifies(w, p)) return endAt(p);
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
// Overnight window (from > to): started tonight (>= from, today qualifies) -> ends tomorrow at `to`,
|
|
187
|
+
// or started last night (< to, yesterday qualified) -> ends today at `to`.
|
|
188
|
+
if (nowMin >= w.fromMin && qualifies(w, p)) return endAt(shiftDay(w.tz, p, 1));
|
|
189
|
+
if (nowMin < w.toMin && qualifies(w, shiftDay(w.tz, p, -1))) return endAt(p);
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* The scope key a window matches against: the folder for a local job, the repo for any forge-backed one.
|
|
195
|
+
* Keyed on local rather than on a list of forges, so a new forge is scoped by its `repo` automatically --
|
|
196
|
+
* an enumeration that forgot one would make that forge's jobs unpausable, which is a silent failure.
|
|
197
|
+
*/
|
|
198
|
+
export function scopeOf(job) {
|
|
199
|
+
return job?.kind === "local" ? job?.folder : job?.repo;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* When, in epoch ms, the current pause for this job's scope ends — or `null` when the job is not paused. A
|
|
204
|
+
* job is paused if `now` falls inside any scope-matching window (`scope === "*"` matches every scope); the
|
|
205
|
+
* latest end among active windows is returned so a single deferral clears them all (re-checked on wake).
|
|
206
|
+
*/
|
|
207
|
+
export function pauseUntilMs(windows, job, nowMs) {
|
|
208
|
+
if (!Array.isArray(windows) || windows.length === 0) return null;
|
|
209
|
+
const scope = scopeOf(job);
|
|
210
|
+
if (!isNonEmptyString(scope)) return null;
|
|
211
|
+
let end = null;
|
|
212
|
+
for (const w of windows) {
|
|
213
|
+
if (w.scope !== "*" && w.scope !== scope) continue;
|
|
214
|
+
const e = windowEndAt(w, nowMs);
|
|
215
|
+
if (e !== null && (end === null || e > end)) end = e;
|
|
216
|
+
}
|
|
217
|
+
return end;
|
|
218
|
+
}
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* prepare-github.mjs — build the read-only /job inputs for a GitHub-triggered job by cloning the
|
|
3
|
+
* serviced repo AT A PINNED SHA onto the host, then materialising its .pi/ and writing the prompt.
|
|
4
|
+
*
|
|
5
|
+
* This is the highest-risk host operation in the worker: it places attacker-influenced repository
|
|
6
|
+
* bytes on the host filesystem before any container exists. Two properties make it safe, and both
|
|
7
|
+
* are structural, not content filters:
|
|
8
|
+
*
|
|
9
|
+
* 1. THE CLONE RUNS NO REPO-SUPPLIED CODE. Every git invocation carries
|
|
10
|
+
* `-c core.hooksPath=/dev/null -c core.fsmonitor=false -c protocol.ext.allow=never
|
|
11
|
+
* -c credential.helper=` and `--no-pager`, and the fetch is `--no-recurse-submodules`. Hooks,
|
|
12
|
+
* the fsmonitor daemon, ext-protocol transports, credential helpers, and submodules are the
|
|
13
|
+
* paths by which a repo runs code during clone/checkout; all are disabled. `.pi/` is then read
|
|
14
|
+
* by object id via materializePiDir (`git cat-file`), never through the working tree, so no
|
|
15
|
+
* symlink/filter/hook runs there either. Per CONST-ISOLATION-CONTAINER-PER-JOB the agent runs
|
|
16
|
+
* in the container; the host only assembles the box's inputs and must not itself execute repo
|
|
17
|
+
* code.
|
|
18
|
+
*
|
|
19
|
+
* 2. THE TOKEN NEVER LANDS ANYWHERE THE AGENT OR THE REPO CAN READ IT. The per-job credential
|
|
20
|
+
* reaches git ONLY through a GIT_ASKPASS helper's env var (GIT_ASKPASS_TOKEN), never in argv, a
|
|
21
|
+
* remote URL, or the persisted `.git/config` (the remote is the tokenless
|
|
22
|
+
* `https://github.com/<owner>/<name>.git`, with no `x-access-token@` and no `url.*.insteadOf`).
|
|
23
|
+
* The askpass script itself holds no token — it prints the env var — and lives in a host temp
|
|
24
|
+
* dir OUTSIDE jobDir and workspace/, removed in a `finally`. jobDir is mounted `/job` :ro and IS
|
|
25
|
+
* agent-readable, so nothing token-bearing is written under it. See CONST-TOKEN-SCOPED-PER-JOB
|
|
26
|
+
* and no-token-in-agent-reachable-file.
|
|
27
|
+
*
|
|
28
|
+
* The /job inputs written here are exactly INT-CONTAINER-JOB-INPUTS: `prompt.md` (the user prompt,
|
|
29
|
+
* issue text as DATA), `event.json` (the INT-WEBHOOK-PAYLOAD-SUBSET body fields only — never a
|
|
30
|
+
* header, never the `X-Hub-Signature-256` signature, never the token; on issue_comment jobs the
|
|
31
|
+
* named subset includes the invoking `comment` body fields, and the one non-webhook addition is
|
|
32
|
+
* `matched` — the filter's decision record of which triggers.json entry fired, by raw file index,
|
|
33
|
+
* harness-computed rather than taken from any payload), and `pi/` (materialised persona/skills).
|
|
34
|
+
* Neither the token nor any issue text is logged.
|
|
35
|
+
*
|
|
36
|
+
* A determinate gone-SHA (the default branch advanced past the resolved tip before the fetch) is a
|
|
37
|
+
* POLICY outcome, returned as `{ outcome: "policy", reason: "sha-gone" }` — NOT thrown, so the
|
|
38
|
+
* processor does not retry a job that can never fetch that commit. Every other fetch failure
|
|
39
|
+
* (DNS/connection/timeout/5xx/auth) throws InfraRetry and is retried. The discipline mirrors
|
|
40
|
+
* github-host.mjs's 404-vs-other split: only the determinate class becomes policy.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
import { execFile } from "node:child_process";
|
|
44
|
+
import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
45
|
+
import { tmpdir } from "node:os";
|
|
46
|
+
import { join } from "node:path";
|
|
47
|
+
import { promisify } from "node:util";
|
|
48
|
+
import { buildGithubPrompt } from "./github-prompt.mjs";
|
|
49
|
+
import { materializePiDir } from "./materialize.mjs";
|
|
50
|
+
import { InfraRetry } from "./processor.mjs";
|
|
51
|
+
|
|
52
|
+
const exec = promisify(execFile);
|
|
53
|
+
|
|
54
|
+
// The -c flags that make a clone/checkout run no repo-supplied code, applied to EVERY git invocation
|
|
55
|
+
// through `hardened()` below so they cannot be omitted at a call site. Matches materialize.mjs's bar
|
|
56
|
+
// (hooksPath, fsmonitor, --no-pager) plus the clone-specific transport/credential locks.
|
|
57
|
+
const HARDEN_FLAGS = [
|
|
58
|
+
"-c",
|
|
59
|
+
"core.hooksPath=/dev/null",
|
|
60
|
+
"-c",
|
|
61
|
+
"core.fsmonitor=false",
|
|
62
|
+
"-c",
|
|
63
|
+
"protocol.ext.allow=never",
|
|
64
|
+
"-c",
|
|
65
|
+
"credential.helper=",
|
|
66
|
+
"--no-pager",
|
|
67
|
+
];
|
|
68
|
+
|
|
69
|
+
/** Prepend the hardening flags to a bare git subcommand's args. */
|
|
70
|
+
function hardened(args) {
|
|
71
|
+
return [...HARDEN_FLAGS, ...args];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// stderr fragments that mean the resolved SHA is provably gone from the remote: the default branch
|
|
75
|
+
// advanced and git can no longer fetch/realise that commit. These are DETERMINATE, so they map to a
|
|
76
|
+
// policy refusal, never a retry. Any other failure stays retryable (see fetch catch below).
|
|
77
|
+
const SHA_GONE_MARKERS = [
|
|
78
|
+
"couldn't find remote ref",
|
|
79
|
+
"not our ref",
|
|
80
|
+
"unadvertised object",
|
|
81
|
+
"did not send all necessary objects",
|
|
82
|
+
"reference is not a tree",
|
|
83
|
+
];
|
|
84
|
+
|
|
85
|
+
/** True only when a git error's output names one of the determinate gone-SHA conditions. */
|
|
86
|
+
function isShaGone(error) {
|
|
87
|
+
const text = `${error?.stderr ?? ""}\n${error?.message ?? ""}`.toLowerCase();
|
|
88
|
+
return SHA_GONE_MARKERS.some((marker) => text.includes(marker));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Prepare a GITHUB job's read-only /job inputs. Clones `job.repo` at the caller-resolved default-
|
|
93
|
+
* branch SHA into `jobDir/workspace`, materialises `.pi/` into `jobDir/pi`, and writes `prompt.md`
|
|
94
|
+
* and `event.json`. Mirrors prepare-local's jobDir-in shape and return object.
|
|
95
|
+
*
|
|
96
|
+
* Collaborators are injected so tests run offline against fakes and never touch a real network/git:
|
|
97
|
+
* - `git(cwd, args, { env })` transport for a single git invocation; default runs the real
|
|
98
|
+
* binary via execFile (no shell). Args arrive already hardened.
|
|
99
|
+
* - `resolveDefaultBranchSha(repo, token) => { sha }` fresh default-branch tip (github-host).
|
|
100
|
+
* - `materialize({ gitDir, sha, destDir }) => string[]` the .pi/ materialiser (git cat-file).
|
|
101
|
+
* - `writeFile` / `mkdir` fs writes for the job inputs (default sync fs).
|
|
102
|
+
*
|
|
103
|
+
* On a determinate gone-SHA returns `{ outcome: "policy", reason: "sha-gone" }`. On success returns
|
|
104
|
+
* `{ workspace, jobDir, sha, materialised }`.
|
|
105
|
+
*/
|
|
106
|
+
export async function prepareGithubWorkspace(
|
|
107
|
+
job,
|
|
108
|
+
token,
|
|
109
|
+
{
|
|
110
|
+
jobDir,
|
|
111
|
+
git = defaultGit,
|
|
112
|
+
resolveDefaultBranchSha,
|
|
113
|
+
materialize = materializePiDir,
|
|
114
|
+
writeFile = writeFileSync,
|
|
115
|
+
mkdir = mkdirSync,
|
|
116
|
+
// How this forge names a clone URL, and how it phrases the agent's envelope. Injected rather than
|
|
117
|
+
// forked into a second copy of this function: everything else here -- the askpass helper, the
|
|
118
|
+
// hardening flags, the gone-SHA markers, the pinned detached checkout, the :ro event.json -- is
|
|
119
|
+
// git and this project, not GitHub, and a second copy is a second place to fix a clone bug.
|
|
120
|
+
remoteUrlFor = (j) => `https://github.com/${String(j.repo).split("/")[0]}/${String(j.repo).split("/")[1]}.git`,
|
|
121
|
+
buildPrompt = buildGithubPrompt,
|
|
122
|
+
// REQ-RESUMABLE-SESSION. Both default to inert, so an unwired caller prepares what it always did.
|
|
123
|
+
resolvePullRequestHead = null,
|
|
124
|
+
resolveSession = () => null,
|
|
125
|
+
piVersion = null,
|
|
126
|
+
} = {},
|
|
127
|
+
) {
|
|
128
|
+
// Fresh API resolve of the commit to clone at — never a webhook field, never the triggering branch.
|
|
129
|
+
const { sha } = await resolveDefaultBranchSha(job, token);
|
|
130
|
+
|
|
131
|
+
const workspace = join(jobDir, "workspace");
|
|
132
|
+
mkdir(workspace, { recursive: true });
|
|
133
|
+
|
|
134
|
+
// The remote URL is TOKENLESS; the credential comes from the askpass helper's env, never from here.
|
|
135
|
+
const remoteUrl = remoteUrlFor(job);
|
|
136
|
+
|
|
137
|
+
const askpass = createAskpassHelper();
|
|
138
|
+
try {
|
|
139
|
+
await git(workspace, hardened(["init"]));
|
|
140
|
+
await git(workspace, hardened(["remote", "add", "origin", remoteUrl]));
|
|
141
|
+
|
|
142
|
+
// The token reaches git ONLY here, via GIT_ASKPASS_TOKEN read by the helper — never argv/URL.
|
|
143
|
+
const netEnv = {
|
|
144
|
+
...process.env,
|
|
145
|
+
GIT_TERMINAL_PROMPT: "0",
|
|
146
|
+
GIT_ASKPASS: askpass.path,
|
|
147
|
+
GIT_ASKPASS_TOKEN: token,
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
try {
|
|
151
|
+
await git(
|
|
152
|
+
workspace,
|
|
153
|
+
hardened(["fetch", "--depth=1", "--no-tags", "--no-recurse-submodules", "origin", sha]),
|
|
154
|
+
{ env: netEnv },
|
|
155
|
+
);
|
|
156
|
+
await git(workspace, hardened(["checkout", "--detach", "FETCH_HEAD"]));
|
|
157
|
+
} catch (error) {
|
|
158
|
+
// Determinate gone-SHA => policy, no retry. Anything else (DNS/connection/timeout/5xx/auth)
|
|
159
|
+
// => retryable infra. The cause carries git's output for debugging; it is token-free by
|
|
160
|
+
// construction (tokenless remote URL + askpass), and BullMQ persists only message+stack.
|
|
161
|
+
if (isShaGone(error)) {
|
|
162
|
+
return { outcome: "policy", reason: "sha-gone" };
|
|
163
|
+
}
|
|
164
|
+
throw new InfraRetry(`prepare-github: fetch failed for ${job.repo}`, { cause: error });
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// .pi/ is read by object id from the pinned SHA — symlink/submodule/exec safe, no working tree.
|
|
168
|
+
const materialised = await materialize({ gitDir: workspace, sha, destDir: jobDir });
|
|
169
|
+
|
|
170
|
+
// Which transcript, if any, this job continues. The head ref for a pull/merge-request target is
|
|
171
|
+
// resolved from the FORGE, not the payload -- an issue_comment on a PR carries no head at all, and
|
|
172
|
+
// head.repo.full_name in a webhook body is attacker-supplied. Both this lookup and the store itself
|
|
173
|
+
// degrade to a cold start rather than failing the job: a cache miss must never cost a run.
|
|
174
|
+
let resolved = {};
|
|
175
|
+
if (job.resume === true && job.target?.type === "pull_request" && resolvePullRequestHead) {
|
|
176
|
+
try {
|
|
177
|
+
resolved = (await resolvePullRequestHead(job, token)) ?? {};
|
|
178
|
+
} catch {
|
|
179
|
+
resolved = {};
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
const session = job.resume === true ? resolveSession(job, { jobDir, resolved, piVersion }) : null;
|
|
183
|
+
|
|
184
|
+
// Issue text is DATA: it enters the USER prompt (buildGithubPrompt), never a system prompt.
|
|
185
|
+
writeFile(
|
|
186
|
+
join(jobDir, "prompt.md"),
|
|
187
|
+
// `replica`/`replicas` (REQ-REPLICA-RUNS) are host-assigned integers off job.data, so they are safe
|
|
188
|
+
// to interpolate, and they are what makes this job's branch differ from its sibling's. This is the
|
|
189
|
+
// SHARED forge preparer, so the gitlab/forgejo/azure builders receive the two keys and destructure
|
|
190
|
+
// them away -- harmless, and always undefined while replicas are github-only.
|
|
191
|
+
buildPrompt({ flow: job.flow, target: job.target, comment: job.trigger?.comment, resumed: session?.resume === true, replica: job.replica, replicas: job.replicas }),
|
|
192
|
+
{ mode: 0o444 },
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
// The INT-WEBHOOK-PAYLOAD-SUBSET body fields ONLY — no header, no signature, no token. `matched`
|
|
196
|
+
// is the single harness-computed addition: the filter's decision record, not a webhook field.
|
|
197
|
+
//
|
|
198
|
+
// `replica`/`replicas` are DELIBERATELY ABSENT, and that is a choice rather than an oversight. This
|
|
199
|
+
// literal is the webhook's own body plus one decision record; an execution knob is not a fact about
|
|
200
|
+
// the delivery. The agent already learns its index from the prompt and `PI_JOB_ID` already ends
|
|
201
|
+
// `-r2`, so nothing here needs it -- and INT-CONTAINER-JOB-INPUTS and INT-WEBHOOK-PAYLOAD-SUBSET stay
|
|
202
|
+
// untouched, which is worth more than the convenience.
|
|
203
|
+
const subset = {
|
|
204
|
+
event: job.trigger?.event,
|
|
205
|
+
action: job.trigger?.action,
|
|
206
|
+
delivery: job.trigger?.deliveryId,
|
|
207
|
+
repository: { full_name: job.repo },
|
|
208
|
+
...(job.target?.type === "pull_request" ? { pull_request: prEventBody(job.target) } : { issue: { number: job.target?.number, title: job.target?.title, body: job.target?.body } }),
|
|
209
|
+
...(job.trigger?.comment ? { comment: { body: job.trigger.comment.body, author_association: job.trigger.comment.author_association } } : {}),
|
|
210
|
+
sender: { id: job.trigger?.sender?.id },
|
|
211
|
+
...(job.trigger?.matched ? { matched: job.trigger.matched } : {}),
|
|
212
|
+
};
|
|
213
|
+
writeFile(join(jobDir, "event.json"), JSON.stringify(subset, null, 2), { mode: 0o444 });
|
|
214
|
+
|
|
215
|
+
return { workspace, jobDir, sha, materialised, ...(session ? { session } : {}) };
|
|
216
|
+
} finally {
|
|
217
|
+
// The askpass machinery must not outlive the prepare, and must never be agent-reachable.
|
|
218
|
+
rmSync(askpass.dir, { recursive: true, force: true });
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* The `pull_request` body for event.json, from the job's PR target. `head`/`base` are attacker-controlled
|
|
224
|
+
* fork DATA carried for the flow's own `gh` use; they are surfaced here, never used as a clone ref. Absent
|
|
225
|
+
* when the target lacks them (a comment-triggered PR job resolves them from the number via `gh`).
|
|
226
|
+
*/
|
|
227
|
+
function prEventBody(target) {
|
|
228
|
+
const body = { number: target.number, title: target.title, body: target.body };
|
|
229
|
+
if (target.head) body.head = target.head;
|
|
230
|
+
if (target.base) body.base = target.base;
|
|
231
|
+
return body;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Write the GIT_ASKPASS helper to a fresh host temp dir OUTSIDE jobDir/workspace. The script holds no
|
|
236
|
+
* token: it prints GIT_ASKPASS_TOKEN, which git sets in the child's env only for the network fetch.
|
|
237
|
+
* Returns `{ dir, path }`; the caller removes `dir` in a finally.
|
|
238
|
+
*/
|
|
239
|
+
function createAskpassHelper() {
|
|
240
|
+
const dir = mkdtempSync(join(tmpdir(), "pi-askpass-"));
|
|
241
|
+
const path = join(dir, "askpass.sh");
|
|
242
|
+
writeFileSync(path, "#!/bin/sh\nprintf '%s' \"$GIT_ASKPASS_TOKEN\"\n", { mode: 0o700 });
|
|
243
|
+
chmodSync(path, 0o700);
|
|
244
|
+
return { dir, path };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Transport for one git invocation. `args` arrive already hardened by `hardened()`. Runs the real
|
|
249
|
+
* binary via execFile (array args, NO shell); `cwd` is the workspace and `env` is passed only for the
|
|
250
|
+
* network fetch (the askpass env), so local calls inherit the ambient environment.
|
|
251
|
+
*/
|
|
252
|
+
async function defaultGit(cwd, args, { env } = {}) {
|
|
253
|
+
const { stdout } = await exec("git", args, {
|
|
254
|
+
cwd,
|
|
255
|
+
encoding: "utf8",
|
|
256
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
257
|
+
env,
|
|
258
|
+
});
|
|
259
|
+
return stdout;
|
|
260
|
+
}
|