@edgehero/pi-dispatch 0.1.2 → 0.2.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/package.json +1 -1
- package/src/azure-prompt.mjs +14 -8
- package/src/cli.mjs +2 -2
- package/src/copy-tree.mjs +215 -0
- package/src/doctor.mjs +242 -8
- package/src/forgejo-prompt.mjs +14 -8
- package/src/github-app-setup.mjs +7 -2
- package/src/github-prompt.mjs +97 -17
- package/src/gitlab-prompt.mjs +14 -8
- package/src/host-pi.mjs +478 -0
- package/src/import-pi.mjs +299 -139
- package/src/materialize.mjs +262 -40
- package/src/outbox.mjs +5 -0
- package/src/packages.mjs +95 -2
- package/src/prepare-github.mjs +23 -4
- package/src/prepare-local.mjs +6 -1
- package/src/prepare.mjs +73 -14
- package/src/processor.mjs +34 -4
- package/src/queue.mjs +16 -2
- package/src/run-container.mjs +7 -2
- package/src/schedules.mjs +16 -1
- package/src/session-store.mjs +6 -0
- package/src/start.mjs +49 -9
- package/src/triggers.mjs +177 -8
package/src/materialize.mjs
CHANGED
|
@@ -11,46 +11,141 @@ const exec = promisify(execFile);
|
|
|
11
11
|
* read-only `/job/pi/` directory the container mounts.
|
|
12
12
|
*
|
|
13
13
|
* This is security-critical: the content becomes the agent's SYSTEM PROMPT, and the repo is only
|
|
14
|
-
* trusted at maintainer level.
|
|
14
|
+
* trusted at maintainer level. Four properties, the first three PROVEN with a hostile fixture:
|
|
15
15
|
*
|
|
16
16
|
* 1. NO SYMLINK FOLLOWING. A `.pi/APPEND_SYSTEM.md` symlinked to the worker's `.env` or
|
|
17
17
|
* `/etc/passwd` must never pull a host file into the prompt. We enumerate with `git ls-tree`
|
|
18
18
|
* and REJECT any entry that is not a regular blob (mode 100644): symlinks are 120000,
|
|
19
19
|
* submodules 160000. We never touch the working tree, so there is no link to follow.
|
|
20
|
-
* 2. NO PATH TRAVERSAL. Every output path is
|
|
21
|
-
*
|
|
20
|
+
* 2. NO PATH TRAVERSAL. Every output path is rebuilt from SEGMENTS THAT EACH MATCHED AN ANCHORED,
|
|
21
|
+
* SEPARATOR-FREE CHARSET -- never from git's own path string. See classifyPiPath.
|
|
22
22
|
* 3. NO EXECUTION. `git cat-file blob <oid>` dumps raw bytes by object id -- no working-tree
|
|
23
23
|
* checkout, no smudge/clean filters, no hooks, no diff drivers. Nothing in the repo runs.
|
|
24
|
+
* 4. BOUNDED. A repo skill is now materialised WHOLE (issue #60), so the file count and byte total
|
|
25
|
+
* are repo-controlled where they used to be one file per skill. Every cap is decided from the
|
|
26
|
+
* single `git ls-tree -r -l -z` listing, BEFORE the first `cat-file` and BEFORE the first
|
|
27
|
+
* write -- CONST-BUDGET-BEFORE-TOKENS' ordering, one layer down. A breach REFUSES the job.
|
|
24
28
|
*
|
|
25
29
|
* The SHA is an input, resolved by the caller from a fresh default-branch API call -- NEVER a
|
|
26
30
|
* webhook field, and NEVER the triggering (possibly fork) branch.
|
|
31
|
+
*
|
|
32
|
+
* WHY NOT `git cat-file --batch` (one process instead of N): it does preserve the by-oid,
|
|
33
|
+
* no-working-tree property, so that is not the objection. `promisify(execFile)` cannot drive it --
|
|
34
|
+
* it needs `spawn` plus a hand-rolled, binary-safe parser for the `<oid> <type> <size>\n<bytes>\n`
|
|
35
|
+
* framing, with partial-chunk handling and missing-object lines, which is ~60 lines of new bug
|
|
36
|
+
* surface at the one place in this codebase whose whole argument is "simple enough to prove". It
|
|
37
|
+
* would also replace a PER-FILE buffer bound with a shared one, undoing what makes maxFileBytes
|
|
38
|
+
* able to keep execFile's maxBuffer unreachable. The caps make the spawn cost a non-issue: 256
|
|
39
|
+
* files is a second or two, on a path that just did a network clone. Revisit if maxFiles ever rises
|
|
40
|
+
* past ~1000, or a profiler shows prepare dominated by spawns.
|
|
41
|
+
* WHY NOT `git archive | tar -x`: it would reconstruct output paths from the ARCHIVE's own strings,
|
|
42
|
+
* which is precisely the property property 2 exists to refuse.
|
|
27
43
|
*/
|
|
28
44
|
|
|
29
45
|
const PI_DIR = ".pi";
|
|
30
46
|
const APPEND_SYSTEM = `${PI_DIR}/APPEND_SYSTEM.md`;
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
47
|
+
const SKILLS_PREFIX = `${PI_DIR}/skills/`;
|
|
48
|
+
const SKILL_FILE = "SKILL.md";
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The bounds on what one repo may put into one job's `/job/pi`. Frozen and exported so the tests
|
|
52
|
+
* name the same numbers the code enforces rather than restating them.
|
|
53
|
+
*
|
|
54
|
+
* maxFileBytes does double duty: at 1 MiB it makes the 16 MiB execFile maxBuffer on the `cat-file`
|
|
55
|
+
* path UNREACHABLE, which converts an opaque RangeError (retried as infrastructure, forever, on a
|
|
56
|
+
* repo that will overrun it every time) into a named policy refusal.
|
|
57
|
+
*/
|
|
58
|
+
export const PI_LIMITS = Object.freeze({
|
|
59
|
+
maxFiles: 256, // ~50 typical skills; also bounds cat-file spawns per job
|
|
60
|
+
maxFilesPerSkill: 64, // SKILL.md + references/ + scripts/ + assets/ runs to a handful
|
|
61
|
+
maxFileBytes: 1 << 20, // 1 MiB: ~250k words of markdown. Larger is a dataset, not a skill.
|
|
62
|
+
maxTotalBytes: 8 << 20, // 8 MiB: trivial beside the clone, and it bounds host disk under retention
|
|
63
|
+
maxTailSegments: 4, // references/x.md is 2, scripts/lib/util.sh is 3
|
|
64
|
+
maxOutRelChars: 200, // Windows MAX_PATH is 260 and jobDir is an OS temp path (~40-70)
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The bound on the LISTING itself, and it is separate from PI_LIMITS on purpose: the caps above are
|
|
69
|
+
* computed FROM this buffer, so they cannot bound it. ~4000 records fit.
|
|
70
|
+
*/
|
|
71
|
+
const LS_TREE_MAX_BYTES = 1 << 20;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* ONE path segment BELOW the skill name: 1-64 chars, starts AND ends alphanumeric, with `.`, `_`
|
|
75
|
+
* and `-` allowed inside.
|
|
76
|
+
*
|
|
77
|
+
* The leading-alnum rule IS the traversal guard, and it needs no separate `!== ".."` test: `.` and
|
|
78
|
+
* `..` both LEAD with a dot, so neither can match. The trailing-alnum rule bars a trailing dot,
|
|
79
|
+
* which Windows silently strips (`foo.` opens `foo`) -- and this worker is cross-platform.
|
|
80
|
+
*
|
|
81
|
+
* Case-INSENSITIVE, unlike SKILL_NAME_RE, because `SKILL.md` and `README.md` are the whole point of
|
|
82
|
+
* issue #60. DELIBERATELY NO `u` FLAG: `/[a-z]/iu` additionally matches U+212A KELVIN SIGN and
|
|
83
|
+
* U+017F LATIN SMALL LETTER LONG S, so adding one would silently widen a security charset by two
|
|
84
|
+
* invisible codepoints. Verified by running it, not assumed.
|
|
85
|
+
*
|
|
86
|
+
* Same shape as import-pi.mjs's ENTRY_NAME_RE by design, and deliberately NOT imported from it:
|
|
87
|
+
* that module pulls in execFile and the npm package stager, and this one is on the security-critical
|
|
88
|
+
* read path. The duplication is pinned by a drift test in materialize.test.mjs.
|
|
89
|
+
*/
|
|
90
|
+
export const PI_SEGMENT_RE = /^[a-z0-9](?:[a-z0-9_.-]{0,62}[a-z0-9])?$/i;
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Windows reserved device names. `CON`, `NUL` and friends pass the charset above, and on Windows
|
|
94
|
+
* `writeFileSync(".../CON")` writes to a DEVICE: no file appears, no error is raised, and the skill
|
|
95
|
+
* silently arrives incomplete -- the exact silent-drop class issue #60 exists to remove. Matched on
|
|
96
|
+
* the part before the first dot, case-insensitively, because `con.md` is the device too.
|
|
97
|
+
*/
|
|
98
|
+
const WINDOWS_DEVICE_NAMES = new Set([
|
|
99
|
+
"con", "prn", "aux", "nul",
|
|
100
|
+
"com1", "com2", "com3", "com4", "com5", "com6", "com7", "com8", "com9",
|
|
101
|
+
"lpt1", "lpt2", "lpt3", "lpt4", "lpt5", "lpt6", "lpt7", "lpt8", "lpt9",
|
|
102
|
+
]);
|
|
103
|
+
|
|
104
|
+
function isWindowsDeviceName(segment) {
|
|
105
|
+
const stem = segment.split(".")[0].toLowerCase();
|
|
106
|
+
return WINDOWS_DEVICE_NAMES.has(stem);
|
|
107
|
+
}
|
|
37
108
|
|
|
38
109
|
/**
|
|
39
110
|
* Classify a git tree path into the destination we will WRITE, or null to reject.
|
|
40
111
|
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
112
|
+
* The output path is REBUILT from validated pieces, never taken from git's string. gitshow-research
|
|
113
|
+
* proved `git ls-tree` can emit path strings containing literal `../` segments (git does not
|
|
114
|
+
* sanitise tree-entry names), so deriving the output from git's string is unsafe even behind a
|
|
115
|
+
* containment check.
|
|
116
|
+
*
|
|
117
|
+
* Splitting on "/" first is what makes the rebuild safe, and it is stronger than the single regex it
|
|
118
|
+
* replaced: any OTHER separator (a backslash, a NUL, a CR) survives INSIDE a piece and is then
|
|
119
|
+
* refused by the anchored charset, and a literal `..` piece is refused by the leading-alnum rule.
|
|
120
|
+
* Only the validated pieces reach the join.
|
|
121
|
+
*
|
|
122
|
+
* Returns `{ outRel, skill }`; `skill` is null for the persona, which is a fixed path with no name
|
|
123
|
+
* and no subtree and so stays an EXACT match. Widening `.pi/*` would admit `settings.json`, and
|
|
124
|
+
* keeping a serviced repo's settings out of the runner is a constitutional property
|
|
125
|
+
* (INT-SDK-SESSION-OPTIONS: the runner uses SettingsManager.inMemory deliberately).
|
|
46
126
|
*/
|
|
47
127
|
export function classifyPiPath(path) {
|
|
48
|
-
if (path === APPEND_SYSTEM) return { outRel: "pi/APPEND_SYSTEM.md" };
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
|
|
128
|
+
if (path === APPEND_SYSTEM) return { outRel: "pi/APPEND_SYSTEM.md", skill: null };
|
|
129
|
+
if (!path.startsWith(SKILLS_PREFIX)) return null;
|
|
130
|
+
|
|
131
|
+
const segments = path.slice(SKILLS_PREFIX.length).split("/");
|
|
132
|
+
// Fewer than 2 means a blob AT `.pi/skills/<name>` rather than inside a skill directory.
|
|
133
|
+
if (segments.length < 2 || segments.length > 1 + PI_LIMITS.maxTailSegments) return null;
|
|
134
|
+
|
|
135
|
+
const [name, ...tail] = segments;
|
|
136
|
+
// The skill DIRECTORY name stays lowercase-only: SKILL_NAME_RE is the single source of truth
|
|
137
|
+
// (issue #92), shared with flow-gate.mjs, the outbox chain gate and the admin setup wizard. A
|
|
138
|
+
// name this module accepted but the gate rejected would be a skill that materialises and can
|
|
139
|
+
// never be chained.
|
|
140
|
+
if (!SKILL_NAME_RE.test(name)) return null;
|
|
141
|
+
for (const seg of tail) {
|
|
142
|
+
if (!PI_SEGMENT_RE.test(seg)) return null;
|
|
143
|
+
if (isWindowsDeviceName(seg)) return null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const outRel = ["pi", "skills", name, ...tail].join("/");
|
|
147
|
+
if (outRel.length > PI_LIMITS.maxOutRelChars) return null;
|
|
148
|
+
return { outRel, skill: name };
|
|
54
149
|
}
|
|
55
150
|
|
|
56
151
|
/** Back-compat predicate used by callers/tests that only care whether a path is accepted. */
|
|
@@ -59,26 +154,123 @@ export function isAllowedPiPath(path) {
|
|
|
59
154
|
}
|
|
60
155
|
|
|
61
156
|
/**
|
|
62
|
-
* Parse `git ls-tree -r -z` output into entries, keeping ONLY regular blobs (100644) at allowed
|
|
63
|
-
* paths, each carrying its
|
|
64
|
-
* executables (100755), and anything outside the allowlist are dropped here -- the single
|
|
65
|
-
* point for the reject-by-mode rule.
|
|
157
|
+
* Parse `git ls-tree -r -l -z` output into entries, keeping ONLY regular blobs (100644) at allowed
|
|
158
|
+
* paths, each carrying its rebuilt output path and its blob size. Symlinks (120000), submodules
|
|
159
|
+
* (160000), executables (100755), and anything outside the allowlist are dropped here -- the single
|
|
160
|
+
* choke point for the reject-by-mode rule.
|
|
161
|
+
*
|
|
162
|
+
* `100755` STAYS REJECTED, and it is a decision rather than an oversight. `/job` is mounted `:ro`
|
|
163
|
+
* and every file lands `0444`, so accepting the mode and writing `0444` anyway would accept what the
|
|
164
|
+
* repo asked for and silently strip it -- a no-op wearing a fix's clothes. Writing `0555` instead
|
|
165
|
+
* would grant "this path is execve-able" to repo bytes the worker placed there, which is not the
|
|
166
|
+
* worker's job. So a skill's scripts arrive non-executable and are invoked as `bash script.sh`; the
|
|
167
|
+
* failure is LOUD (Permission denied) and the agent recovers from it, which is the opposite of the
|
|
168
|
+
* silent class issue #60 is about. Keeping this gate byte-identical also keeps flow-gate.mjs's
|
|
169
|
+
* mirrored check honest and DES-AI-TRIGGER-FLOW-GATE's citation of it true.
|
|
170
|
+
*
|
|
171
|
+
* Returns `{ entries, skipped }`. `skipped` counts every enumerated record we did not keep.
|
|
66
172
|
*/
|
|
67
173
|
export function selectEntries(lsTreeZ) {
|
|
68
174
|
const entries = [];
|
|
175
|
+
let skipped = 0;
|
|
69
176
|
for (const record of lsTreeZ.split("\0")) {
|
|
70
177
|
if (!record) continue;
|
|
71
|
-
// "<mode> <type> <oid>\t<path>"
|
|
178
|
+
// "<mode> <type> <oid> <size>\t<path>" -- the size column is space-padded, right-justified to
|
|
179
|
+
// a minimum width of 7 (git-ls-tree(1)), which the existing \s+ split already collapses.
|
|
72
180
|
const tab = record.indexOf("\t");
|
|
73
181
|
if (tab === -1) continue;
|
|
74
|
-
const [mode, type, oid] = record.slice(0, tab).split(/\s+/);
|
|
182
|
+
const [mode, type, oid, sizeField] = record.slice(0, tab).split(/\s+/);
|
|
75
183
|
const path = record.slice(tab + 1);
|
|
76
|
-
if (mode !== "100644" || type !== "blob")
|
|
184
|
+
if (mode !== "100644" || type !== "blob") {
|
|
185
|
+
skipped++;
|
|
186
|
+
continue; // rejects symlink/submodule/exec
|
|
187
|
+
}
|
|
188
|
+
// Read the size AFTER the mode gate, never before: `-l` prints "-" for a tree or a commit, and
|
|
189
|
+
// those are exactly what the gate above has already dropped.
|
|
190
|
+
//
|
|
191
|
+
// A size we cannot read THROWS rather than defaulting to 0 or skipping. If `-l` were ever
|
|
192
|
+
// dropped from the argv, every sizeField would be `undefined`; skipping would then materialise
|
|
193
|
+
// NOTHING and report success, and defaulting to 0 would leave every byte cap unenforced. Both
|
|
194
|
+
// are silent. The message carries mode/type/oid and never the attacker-chosen path.
|
|
195
|
+
const size = Number(sizeField);
|
|
196
|
+
if (!Number.isSafeInteger(size) || size < 0) {
|
|
197
|
+
throw new Error(`git ls-tree: unreadable object size (is -l still in the argv?) for ${type} ${mode} ${oid}`);
|
|
198
|
+
}
|
|
77
199
|
const classified = classifyPiPath(path);
|
|
78
|
-
if (!classified)
|
|
79
|
-
|
|
200
|
+
if (!classified) {
|
|
201
|
+
skipped++;
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
entries.push({ oid, path, size, outRel: classified.outRel, skill: classified.skill });
|
|
205
|
+
}
|
|
206
|
+
return { entries, skipped };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Drop every entry under a `.pi/skills/<name>/` that declares no SKILL.md ANYWHERE beneath it.
|
|
211
|
+
*
|
|
212
|
+
* Verified against the pinned pi: loadSkillsFromDirInternal registers a skill only where a literal
|
|
213
|
+
* `SKILL.md` exists, and loose `.md` files load only at the skills ROOT (`includeRootFiles`). So a
|
|
214
|
+
* `.pi/skills/notes/` holding only `a.md` contributes nothing an agent can ever reference -- and
|
|
215
|
+
* materialising it would hand anyone with merge access a data-dump channel into the job container
|
|
216
|
+
* that never has to look like a skill.
|
|
217
|
+
*
|
|
218
|
+
* The test is "SKILL.md anywhere under <name>/", NOT "at <name>/SKILL.md", because pi keeps
|
|
219
|
+
* RECURSING while a directory has no SKILL.md: `.pi/skills/group/sub/SKILL.md` is a genuine,
|
|
220
|
+
* loadable skill. A root-only rule would re-create this issue's own bug one level down.
|
|
221
|
+
*/
|
|
222
|
+
export function keepOnlyDeclaredSkills(entries) {
|
|
223
|
+
const declared = new Set();
|
|
224
|
+
for (const e of entries) {
|
|
225
|
+
if (e.skill !== null && e.outRel.endsWith(`/${SKILL_FILE}`)) declared.add(e.skill);
|
|
226
|
+
}
|
|
227
|
+
const kept = entries.filter((e) => e.skill === null || declared.has(e.skill));
|
|
228
|
+
return { kept, skipped: entries.length - kept.length };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Decide every cap from the listing alone. Returns a reason string, or null to proceed.
|
|
233
|
+
*
|
|
234
|
+
* Reason tokens carry a `pi-` prefix on purpose: INT-RUN-HISTORY-FILE-CONTRACT's nested
|
|
235
|
+
* `session.reason` enum already contains a bare `too-large`, and two enums in one record sharing a
|
|
236
|
+
* token is how a reader misattributes a refusal.
|
|
237
|
+
*/
|
|
238
|
+
export function checkLimits(entries) {
|
|
239
|
+
if (entries.length > PI_LIMITS.maxFiles) return "pi-too-many-files";
|
|
240
|
+
|
|
241
|
+
const perSkill = new Map();
|
|
242
|
+
let total = 0;
|
|
243
|
+
for (const e of entries) {
|
|
244
|
+
if (e.size > PI_LIMITS.maxFileBytes) return "pi-file-too-large";
|
|
245
|
+
total += e.size;
|
|
246
|
+
if (total > PI_LIMITS.maxTotalBytes) return "pi-too-large";
|
|
247
|
+
if (e.skill !== null) {
|
|
248
|
+
const n = (perSkill.get(e.skill) ?? 0) + 1;
|
|
249
|
+
if (n > PI_LIMITS.maxFilesPerSkill) return "pi-too-many-files";
|
|
250
|
+
perSkill.set(e.skill, n);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// A case-insensitive host (APFS, NTFS) collapses `references/README.md` and
|
|
255
|
+
// `references/readme.md` -- two distinct git blobs -- onto ONE file. The second write would open
|
|
256
|
+
// an existing 0444 file O_WRONLY and EACCES, surfacing as an opaque throw that a retry cannot
|
|
257
|
+
// fix. Unreachable before issue #60 (one file per skill, and the name charset was lowercase-only)
|
|
258
|
+
// and reachable now, so it is refused here rather than discovered there. The same collapse
|
|
259
|
+
// applies between a FILE and a DIRECTORY prefix (`foo/Bar` beside `foo/bar/x.md`), which is why
|
|
260
|
+
// the ancestor prefixes are collected too.
|
|
261
|
+
const files = new Set();
|
|
262
|
+
const dirs = new Set();
|
|
263
|
+
for (const e of entries) {
|
|
264
|
+
const lower = e.outRel.toLowerCase();
|
|
265
|
+
if (files.has(lower)) return "pi-path-collision";
|
|
266
|
+
files.add(lower);
|
|
267
|
+
const parts = lower.split("/");
|
|
268
|
+
for (let i = 1; i < parts.length; i++) dirs.add(parts.slice(0, i).join("/"));
|
|
80
269
|
}
|
|
81
|
-
|
|
270
|
+
for (const d of dirs) {
|
|
271
|
+
if (files.has(d)) return "pi-path-collision";
|
|
272
|
+
}
|
|
273
|
+
return null;
|
|
82
274
|
}
|
|
83
275
|
|
|
84
276
|
/**
|
|
@@ -97,30 +289,60 @@ function safeJoin(root, ...segments) {
|
|
|
97
289
|
|
|
98
290
|
/**
|
|
99
291
|
* Materialise `.pi/` at `sha` from the clone at `gitDir` into `destDir` (which becomes /job/pi).
|
|
100
|
-
*
|
|
292
|
+
*
|
|
293
|
+
* Returns `{ written, skipped }` -- `written` being the relative paths written, under `pi/` -- or
|
|
294
|
+
* `{ outcome: "policy", reason }` when a cap is breached. The refusal is a determinate POLICY result
|
|
295
|
+
* and therefore RETURNS rather than throws (CONST-RETRY-INFRA-ONLY): the same tree at the same sha
|
|
296
|
+
* breaches the same cap on every retry, so throwing would clone twice and land the job in the failed
|
|
297
|
+
* set with a message nobody maps to "prune your skill directory". Truncating instead was rejected
|
|
298
|
+
* outright -- a truncated skill IS the bug this change fixes, and which files survived would be
|
|
299
|
+
* decided by git's tree order, which no operator can predict.
|
|
300
|
+
*
|
|
301
|
+
* NOTHING IS WRITTEN before every cap has passed, so a refused job leaves no partial /job/pi behind.
|
|
101
302
|
*
|
|
102
303
|
* `git` is injected for tests; defaults to a thin wrapper over the real binary.
|
|
103
304
|
*/
|
|
104
305
|
export async function materializePiDir({ gitDir, sha, destDir, git = defaultGit }) {
|
|
105
|
-
|
|
106
|
-
|
|
306
|
+
let lsTreeZ;
|
|
307
|
+
try {
|
|
308
|
+
// `-l` adds the blob size, which is what lets every cap be decided here rather than after N
|
|
309
|
+
// reads. maxBuffer is scoped down from the default 16 MiB: this is a LISTING, and a `.pi/`
|
|
310
|
+
// whose listing alone overruns a megabyte is already past maxFiles by two orders of magnitude.
|
|
311
|
+
lsTreeZ = await git(gitDir, ["ls-tree", "-r", "-l", "-z", sha, `${PI_DIR}/`], { maxBuffer: LS_TREE_MAX_BYTES });
|
|
312
|
+
} catch (error) {
|
|
313
|
+
// A listing too large for the buffer is DETERMINATE -- the same tree overruns it every time --
|
|
314
|
+
// so it becomes the same policy refusal a counted breach gets, rather than an opaque RangeError
|
|
315
|
+
// retried as infrastructure. Narrow on purpose: every other git failure (a bad sha, a missing
|
|
316
|
+
// binary, an unreadable object store) still throws and is still retried.
|
|
317
|
+
if (error?.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER") {
|
|
318
|
+
return { outcome: "policy", reason: "pi-too-many-files" };
|
|
319
|
+
}
|
|
320
|
+
throw error;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const selected = selectEntries(lsTreeZ);
|
|
324
|
+
const declared = keepOnlyDeclaredSkills(selected.entries);
|
|
325
|
+
const skipped = selected.skipped + declared.skipped;
|
|
326
|
+
|
|
327
|
+
const breach = checkLimits(declared.kept);
|
|
328
|
+
if (breach) return { outcome: "policy", reason: breach };
|
|
107
329
|
|
|
108
330
|
const written = [];
|
|
109
|
-
for (const { oid, outRel } of
|
|
331
|
+
for (const { oid, outRel } of declared.kept) {
|
|
110
332
|
const content = await git(gitDir, ["cat-file", "blob", oid], { raw: true });
|
|
111
|
-
// outRel is
|
|
112
|
-
//
|
|
113
|
-
//
|
|
333
|
+
// outRel is REBUILT from validated segments, never the raw git path -- so it cannot contain
|
|
334
|
+
// traversal. safeJoin re-checks containment as defence in depth, and splits the posix outRel
|
|
335
|
+
// into host path segments so it is correct on Windows too.
|
|
114
336
|
const out = safeJoin(destDir, ...outRel.split("/"));
|
|
115
337
|
mkdirSync(dirname(out), { recursive: true });
|
|
116
338
|
writeFileSync(out, content, { mode: 0o444 });
|
|
117
339
|
// Report posix-style: this names a CONTAINER path (/job/pi/...), stable across host OSes.
|
|
118
340
|
written.push(outRel);
|
|
119
341
|
}
|
|
120
|
-
return written;
|
|
342
|
+
return { written, skipped };
|
|
121
343
|
}
|
|
122
344
|
|
|
123
|
-
async function defaultGit(gitDir, args, { raw = false } = {}) {
|
|
345
|
+
async function defaultGit(gitDir, args, { raw = false, maxBuffer = 16 * 1024 * 1024 } = {}) {
|
|
124
346
|
// -c protecting against a hostile repo config: no hooks, no external filters, no pager.
|
|
125
347
|
const hardened = [
|
|
126
348
|
"-c",
|
|
@@ -134,7 +356,7 @@ async function defaultGit(gitDir, args, { raw = false } = {}) {
|
|
|
134
356
|
];
|
|
135
357
|
const { stdout } = await exec("git", hardened, {
|
|
136
358
|
encoding: raw ? "buffer" : "utf8",
|
|
137
|
-
maxBuffer
|
|
359
|
+
maxBuffer,
|
|
138
360
|
});
|
|
139
361
|
return stdout;
|
|
140
362
|
}
|
package/src/outbox.mjs
CHANGED
|
@@ -157,6 +157,11 @@ export function makeCollectChain({ queue, enqueue = enqueueLocalJob, readFlowGat
|
|
|
157
157
|
// (INT-OUTBOX-CONTRACT's explicit-property-reads rule). Undefined stays undefined, so a parent with
|
|
158
158
|
// no image chains a child whose data is byte-identical to today's.
|
|
159
159
|
image: job.data?.image,
|
|
160
|
+
// The parent's injected skills follow the child, off validated JOB DATA and never off the
|
|
161
|
+
// request file (REQ-PER-TRIGGER-SKILLS). Same reason `image` does: a chained child runs the
|
|
162
|
+
// same operator's flows and, without them, would look up a skill that is not there, write a
|
|
163
|
+
// plausible report and exit 0. It is NOT part of chainedJobId, for the reason stated below.
|
|
164
|
+
skillsDir: job.data?.skillsDir,
|
|
160
165
|
chainDepth: childDepth,
|
|
161
166
|
parentJobId: job.id,
|
|
162
167
|
// chainedJobId deliberately does NOT take the image: the child's identity is (parent, flow, task).
|
package/src/packages.mjs
CHANGED
|
@@ -14,9 +14,12 @@
|
|
|
14
14
|
* minor into every queued job becoming a no-op with NO signal -- the queue still reports success, the
|
|
15
15
|
* worst failure class available. Pinning converts that into an operator-visible edit of a version string.
|
|
16
16
|
*
|
|
17
|
-
*
|
|
17
|
+
* Three directions, three error policies:
|
|
18
18
|
* - `parsePackagesFile` reads the OPERATOR's `pi-packages.json` before anything is staged. Pure and
|
|
19
19
|
* fs-free (mirrors triggers.mjs), fail-loud `configError` naming the offending package.
|
|
20
|
+
* - `mergeHostPackages` folds in what host-pi.mjs discovered in the operator's own pi setup (issue #102).
|
|
21
|
+
* Declared entries win; a discovered one that fails validation is DROPPED with a reason rather than
|
|
22
|
+
* taking the declared set down with it.
|
|
20
23
|
* - `readStageManifest` reads the file the STAGER wrote, at job-wiring time. It NEVER throws: a corrupt
|
|
21
24
|
* manifest must degrade to "no staged packages", not crash the worker mid-queue.
|
|
22
25
|
*/
|
|
@@ -45,6 +48,23 @@ export const EXACT_VERSION_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za
|
|
|
45
48
|
/** An npm package name: lowercase, optionally `@scope/`-prefixed. */
|
|
46
49
|
export const NPM_NAME_RE = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/;
|
|
47
50
|
|
|
51
|
+
/**
|
|
52
|
+
* The pi resource kinds a package may contribute by convention dir, when it carries no `pi` manifest.
|
|
53
|
+
* Lives here rather than in import-pi.mjs because host-pi.mjs needs the same list to decide whether a
|
|
54
|
+
* package the operator installed is a pi package at all, and a third module importing from import-pi.mjs
|
|
55
|
+
* would add an edge to the import-pi <-> packages cycle that only survives because both sides use their
|
|
56
|
+
* bindings at call time.
|
|
57
|
+
*
|
|
58
|
+
* This list is pi's, not ours: at the 0.80.7 pin `collectPackageResources` falls through to exactly these
|
|
59
|
+
* four directory names when `readPiManifest` returns null, so a package with no `pi` key and a `skills/`
|
|
60
|
+
* dir IS a pi package. See host-pi.mjs's PINNED_PI_NEEDLES for the assertion that keeps that true.
|
|
61
|
+
*/
|
|
62
|
+
export const RESOURCE_DIRS = ["extensions", "skills", "prompts", "themes"];
|
|
63
|
+
|
|
64
|
+
/** Where a staged entry came from: the operator's file, or discovery of their pi setup. */
|
|
65
|
+
export const FROM_DECLARED = "pi-packages";
|
|
66
|
+
export const FROM_HOST = "host";
|
|
67
|
+
|
|
48
68
|
/** npm's hard limit on a package name; a longer one could never have been published. */
|
|
49
69
|
const MAX_NAME_LENGTH = 214;
|
|
50
70
|
/** The staged dir name doubles as a path segment in the container, so it stays short and flat. */
|
|
@@ -145,6 +165,74 @@ function normalizePackage(entry, index, path, seenDirs) {
|
|
|
145
165
|
return { name, version, dir };
|
|
146
166
|
}
|
|
147
167
|
|
|
168
|
+
/** The `path` a discovered candidate reports as its origin, and the suffix stripped off its error text. */
|
|
169
|
+
const DISCOVERED_AT = "your pi setup";
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Normalize ONE candidate discovered in the host's pi setup, returning `{ entry }` or `{ reason }` instead
|
|
173
|
+
* of throwing (issue #102).
|
|
174
|
+
*
|
|
175
|
+
* Deliberately a try/catch around the SAME `normalizePackage` the declared path uses. Every rule that
|
|
176
|
+
* guards a declared entry -- the admin block, the exact-version rule, the name charset, the dir length and
|
|
177
|
+
* the dir-collision check -- therefore reaches discovery by reuse rather than by a second implementation.
|
|
178
|
+
* A parallel implementation would eventually drift, and the rule it would drift away from is the one that
|
|
179
|
+
* keeps the admin package (which can enqueue paid jobs) out of every job container.
|
|
180
|
+
*
|
|
181
|
+
* Why a reason and not a throw: a declared pin is a promise the operator made, so failing it refuses the
|
|
182
|
+
* whole stage. A discovered package is an inference WE made, and taking the operator's declared set down
|
|
183
|
+
* because of it would be the wrong trade. A named skip is not a silent one.
|
|
184
|
+
*/
|
|
185
|
+
export function normalizeDiscoveredPackage(candidate, seenDirs = new Map()) {
|
|
186
|
+
try {
|
|
187
|
+
return { entry: normalizePackage(candidate, 0, DISCOVERED_AT, seenDirs) };
|
|
188
|
+
} catch (error) {
|
|
189
|
+
const suffix = `: ${DISCOVERED_AT}`;
|
|
190
|
+
const message = String(error?.message ?? error);
|
|
191
|
+
return { reason: message.endsWith(suffix) ? message.slice(0, -suffix.length) : message };
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Merge the packages the operator DECLARED in `pi-packages.json` with the ones discovered in their pi setup.
|
|
197
|
+
* Returns `{ entries, overrides, dropped }` (issue #102).
|
|
198
|
+
*
|
|
199
|
+
* A declared entry WINS, matched by name, and keeps its position: that is what lets an operator pin an
|
|
200
|
+
* older version than their host happens to run, which is the whole reason `pi-packages.json` survives as a
|
|
201
|
+
* layer rather than being replaced by discovery. When the two disagree on the version, the shadowed host
|
|
202
|
+
* version is reported in `overrides` so the printed list can say so out loud -- an operator who pinned 2.0.1
|
|
203
|
+
* while running 2.3.0 should learn it here, not from a flow behaving differently in a job than it does
|
|
204
|
+
* interactively.
|
|
205
|
+
*
|
|
206
|
+
* Collisions are resolved the same direction: `seenDirs` is seeded with the declared dirs BEFORE any
|
|
207
|
+
* candidate is normalized, so `normalizePackage`'s own duplicate-dir refusal fires on the discovered side
|
|
208
|
+
* and the declared entry survives untouched.
|
|
209
|
+
*/
|
|
210
|
+
export function mergeHostPackages(declared = [], discovered = []) {
|
|
211
|
+
const entries = declared.map((entry) => ({ ...entry, from: FROM_DECLARED }));
|
|
212
|
+
const seenDirs = new Map(entries.map((entry) => [entry.dir, entry.name]));
|
|
213
|
+
const declaredByName = new Map(entries.map((entry) => [entry.name, entry]));
|
|
214
|
+
const overrides = [];
|
|
215
|
+
const dropped = [];
|
|
216
|
+
|
|
217
|
+
for (const candidate of discovered) {
|
|
218
|
+
const shadowing = declaredByName.get(candidate?.name);
|
|
219
|
+
if (shadowing) {
|
|
220
|
+
if (shadowing.version !== candidate.version) {
|
|
221
|
+
overrides.push({ name: shadowing.name, declared: shadowing.version, host: candidate.version });
|
|
222
|
+
}
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
const result = normalizeDiscoveredPackage(candidate, seenDirs);
|
|
226
|
+
if (result.reason) {
|
|
227
|
+
dropped.push({ name: candidate?.name ?? "(unnamed)", reason: result.reason });
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
entries.push({ ...result.entry, from: FROM_HOST });
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return { entries, overrides, dropped };
|
|
234
|
+
}
|
|
235
|
+
|
|
148
236
|
/**
|
|
149
237
|
* Read the stager's manifest from `<globalPiDir>/packages/packages.json`. Returns `{ stagedAt, packages }`
|
|
150
238
|
* or `null` when there is nothing usable there. NEVER throws -- this runs on the job path, where a corrupt
|
|
@@ -153,6 +241,11 @@ function normalizePackage(entry, index, path, seenDirs) {
|
|
|
153
241
|
* Entries are re-validated on the way in (the file is a host artifact that an operator may have hand-edited
|
|
154
242
|
* between stage time and job time): a `dir` that is not a plain segment would otherwise flow straight into
|
|
155
243
|
* a container path.
|
|
244
|
+
*
|
|
245
|
+
* `from` is a CLOSED enum with a default, never a pass-through (issue #102): anything that is not exactly
|
|
246
|
+
* "host" reads as "pi-packages", so a hand-edited receipt cannot inject a string that reaches a printed
|
|
247
|
+
* doctor line. A receipt written before #102 has no `from` at all and correctly reads as declared. Every
|
|
248
|
+
* other unknown key is still dropped, which is what keeps an older worker safe against a newer receipt.
|
|
156
249
|
*/
|
|
157
250
|
export function readStageManifest({ globalPiDir, readFile = readFileSync, fileExists = existsSync } = {}) {
|
|
158
251
|
if (!globalPiDir) return null;
|
|
@@ -168,7 +261,7 @@ export function readStageManifest({ globalPiDir, readFile = readFileSync, fileEx
|
|
|
168
261
|
const { name, version, dir } = entry;
|
|
169
262
|
if (!isNonEmptyString(name) || !isNonEmptyString(version) || !isNonEmptyString(dir)) return null;
|
|
170
263
|
if (!ENTRY_NAME_RE.test(dir) || dir.length > MAX_DIR_LENGTH) return null;
|
|
171
|
-
packages.push({ name, version, dir });
|
|
264
|
+
packages.push({ name, version, dir, from: entry.from === FROM_HOST ? FROM_HOST : FROM_DECLARED });
|
|
172
265
|
}
|
|
173
266
|
return { stagedAt: typeof parsed.stagedAt === "string" ? parsed.stagedAt : null, packages };
|
|
174
267
|
} catch {
|
package/src/prepare-github.mjs
CHANGED
|
@@ -97,10 +97,13 @@ function isShaGone(error) {
|
|
|
97
97
|
* - `git(cwd, args, { env })` transport for a single git invocation; default runs the real
|
|
98
98
|
* binary via execFile (no shell). Args arrive already hardened.
|
|
99
99
|
* - `resolveDefaultBranchSha(repo, token) => { sha }` fresh default-branch tip (github-host).
|
|
100
|
-
* - `materialize({ gitDir, sha, destDir })
|
|
100
|
+
* - `materialize({ gitDir, sha, destDir })` the .pi/ materialiser (git cat-file). Returns
|
|
101
|
+
* `{ written, skipped }`, or `{ outcome: "policy", reason }`
|
|
102
|
+
* when the repo's .pi/ breaches a size cap (issue #60).
|
|
101
103
|
* - `writeFile` / `mkdir` fs writes for the job inputs (default sync fs).
|
|
102
104
|
*
|
|
103
|
-
* On a determinate gone-SHA returns `{ outcome: "policy", reason: "sha-gone" }
|
|
105
|
+
* On a determinate gone-SHA returns `{ outcome: "policy", reason: "sha-gone" }`, and likewise on a
|
|
106
|
+
* materialiser cap breach (`pi-too-large` and friends). On success returns
|
|
104
107
|
* `{ workspace, jobDir, sha, materialised }`.
|
|
105
108
|
*/
|
|
106
109
|
export async function prepareGithubWorkspace(
|
|
@@ -165,7 +168,12 @@ export async function prepareGithubWorkspace(
|
|
|
165
168
|
}
|
|
166
169
|
|
|
167
170
|
// .pi/ is read by object id from the pinned SHA — symlink/submodule/exec safe, no working tree.
|
|
168
|
-
const
|
|
171
|
+
const pi = await materialize({ gitDir: workspace, sha, destDir: jobDir });
|
|
172
|
+
// A repo whose .pi/ breaches a materialiser cap (issue #60) is a determinate refusal, returned
|
|
173
|
+
// BEFORE the session resolve and before prompt.md/event.json are written, so a refused job leaves
|
|
174
|
+
// no /job inputs behind at all. The processor's existing policy branch reserves no budget for it.
|
|
175
|
+
if (pi?.outcome === "policy") return pi;
|
|
176
|
+
const materialised = pi.written;
|
|
169
177
|
|
|
170
178
|
// Which transcript, if any, this job continues. The head ref for a pull/merge-request target is
|
|
171
179
|
// resolved from the FORGE, not the payload -- an issue_comment on a PR carries no head at all, and
|
|
@@ -188,7 +196,9 @@ export async function prepareGithubWorkspace(
|
|
|
188
196
|
// to interpolate, and they are what makes this job's branch differ from its sibling's. This is the
|
|
189
197
|
// SHARED forge preparer, so the gitlab/forgejo/azure builders receive the two keys and destructure
|
|
190
198
|
// them away -- harmless, and always undefined while replicas are github-only.
|
|
191
|
-
|
|
199
|
+
// `review` rides beside `comment` and, like `replica`/`replicas`, is destructured away by the
|
|
200
|
+
// gitlab/forgejo/azure builders -- harmless, and always undefined while reviews are github-only.
|
|
201
|
+
buildPrompt({ flow: job.flow, target: job.target, comment: job.trigger?.comment, resumed: session?.resume === true, replica: job.replica, replicas: job.replicas, review: job.trigger?.review, instructions: job.instructions }),
|
|
192
202
|
{ mode: 0o444 },
|
|
193
203
|
);
|
|
194
204
|
|
|
@@ -207,6 +217,15 @@ export async function prepareGithubWorkspace(
|
|
|
207
217
|
repository: { full_name: job.repo },
|
|
208
218
|
...(job.target?.type === "pull_request" ? { pull_request: prEventBody(job.target) } : { issue: { number: job.target?.number, title: job.target?.title, body: job.target?.body } }),
|
|
209
219
|
...(job.trigger?.comment ? { comment: { body: job.trigger.comment.body, author_association: job.trigger.comment.author_association } } : {}),
|
|
220
|
+
// The invoking review, review-triggered jobs only (issue #66). Written as an explicit literal
|
|
221
|
+
// rather than a spread, the same discipline as `comment` above: this object is the subset's
|
|
222
|
+
// named fields, not whatever happened to reach the queue. Conditional for the same reason too --
|
|
223
|
+
// a PR job without a review must stay byte-identical to the one this file wrote before #66.
|
|
224
|
+
//
|
|
225
|
+
// `action` above is `submitted` (GitHub's word) while `matched.action` below is
|
|
226
|
+
// `review_submitted` (the triggers.json word). They differ on purpose; INT-CONTAINER-JOB-INPUTS
|
|
227
|
+
// says which is which.
|
|
228
|
+
...(job.trigger?.review ? { review: { id: job.trigger.review.id, body: job.trigger.review.body, state: job.trigger.review.state, author_association: job.trigger.review.author_association } } : {}),
|
|
210
229
|
sender: { id: job.trigger?.sender?.id },
|
|
211
230
|
...(job.trigger?.matched ? { matched: job.trigger.matched } : {}),
|
|
212
231
|
};
|
package/src/prepare-local.mjs
CHANGED
|
@@ -44,7 +44,12 @@ export async function prepareLocalWorkspace({ folder, task, jobDir, git = defaul
|
|
|
44
44
|
const outboxDir = join(jobDir, "outbox");
|
|
45
45
|
mkdirSync(outboxDir, { recursive: true });
|
|
46
46
|
// Instructions from HEAD, via the symlink-safe git materialiser, into /job/pi (mounted :ro).
|
|
47
|
-
const
|
|
47
|
+
const pi = await materializePiDir({ gitDir: folder, sha, destDir: jobDir });
|
|
48
|
+
// A .pi/ over a materialiser cap (issue #60) refuses the job determinately, before prompt.md and
|
|
49
|
+
// event.json exist. Returned rather than thrown: the same tree breaches the same cap on every
|
|
50
|
+
// retry (CONST-RETRY-INFRA-ONLY), and the processor's policy branch spends nothing on it.
|
|
51
|
+
if (pi?.outcome === "policy") return pi;
|
|
52
|
+
const written = pi.written;
|
|
48
53
|
|
|
49
54
|
// The task the operator asked for. Plain data below the instructions.
|
|
50
55
|
writeFileSync(join(jobDir, "prompt.md"), String(task ?? ""), { mode: 0o444 });
|