toolroll 0.9.70 → 0.9.72
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/CHANGELOG.md +99 -0
- package/dist/agent-fence.d.ts +7 -0
- package/dist/agent-fence.js +10 -2
- package/dist/approval-authority.d.ts +20 -0
- package/dist/approval-authority.js +67 -0
- package/dist/assignment.js +26 -4
- package/dist/browser/workspace.css +1 -1
- package/dist/builder.js +48 -6
- package/dist/chat-decide.js +17 -2
- package/dist/check-report.d.ts +109 -0
- package/dist/check-report.js +204 -0
- package/dist/claim.js +1 -1
- package/dist/commit-extract.d.ts +94 -0
- package/dist/commit-extract.js +436 -0
- package/dist/contracts/console-api.d.ts +1 -1
- package/dist/contracts/console-api.js +1 -0
- package/dist/contracts/flow.d.ts +16 -0
- package/dist/contracts/flow.js +3 -0
- package/dist/contracts/release-check.d.ts +335 -0
- package/dist/contracts/release-check.js +159 -0
- package/dist/contracts/release-terms.d.ts +60 -0
- package/dist/contracts/release-terms.js +70 -0
- package/dist/contracts/verification-receipt.d.ts +42 -8
- package/dist/contracts/verification-receipt.js +24 -7
- package/dist/contracts/verification-view.d.ts +1 -0
- package/dist/deploy-gate.d.ts +17 -0
- package/dist/deploy-gate.js +68 -0
- package/dist/dispatch.js +3 -10
- package/dist/dispose.js +4 -1
- package/dist/evidence-pack.d.ts +1 -1
- package/dist/evidence-pack.js +20 -2
- package/dist/evidence.d.ts +1 -0
- package/dist/evidence.js +2 -1
- package/dist/flow-engine.js +5 -1
- package/dist/flow-pull-request.d.ts +5 -2
- package/dist/flow-pull-request.js +14 -5
- package/dist/flow-schedule.js +25 -6
- package/dist/flows-ui.js +7 -1
- package/dist/invoke.d.ts +19 -1
- package/dist/invoke.js +49 -2
- package/dist/lead-status.js +5 -2
- package/dist/live-terminal.d.ts +10 -6
- package/dist/live-terminal.js +55 -14
- package/dist/nightly-check.d.ts +104 -0
- package/dist/nightly-check.js +362 -0
- package/dist/operate.js +39 -2
- package/dist/person-proof.d.ts +2 -0
- package/dist/person-proof.js +14 -0
- package/dist/plan-auto.js +4 -4
- package/dist/planner-source.d.ts +1 -1
- package/dist/proof.js +2 -0
- package/dist/publish.js +156 -144
- package/dist/pull-request-flow.d.ts +2 -0
- package/dist/pull-request-flow.js +24 -2
- package/dist/release-adapters.d.ts +8 -0
- package/dist/release-adapters.js +212 -15
- package/dist/release-authority.d.ts +112 -0
- package/dist/release-authority.js +126 -0
- package/dist/release-carry.d.ts +233 -0
- package/dist/release-carry.js +645 -0
- package/dist/release-cli.d.ts +3 -0
- package/dist/release-cli.js +124 -5
- package/dist/release-coverage.js +3 -0
- package/dist/release-recheck.d.ts +146 -0
- package/dist/release-recheck.js +657 -0
- package/dist/release-view.d.ts +34 -0
- package/dist/release-view.js +27 -0
- package/dist/release.d.ts +131 -1
- package/dist/release.js +562 -244
- package/dist/remote-main.d.ts +2 -0
- package/dist/remote-main.js +1 -1
- package/dist/result-completion.d.ts +6 -0
- package/dist/result-completion.js +15 -1
- package/dist/result-follow-ups.js +29 -1
- package/dist/sandbox-runtime.d.ts +59 -1
- package/dist/sandbox-runtime.js +159 -5
- package/dist/scope.d.ts +115 -5
- package/dist/scope.js +219 -7
- package/dist/server/render-tasks.d.ts +8 -0
- package/dist/server/render-tasks.js +50 -9
- package/dist/server/route-table.js +1 -0
- package/dist/server/task-views.js +4 -0
- package/dist/server/tasks.js +12 -0
- package/dist/store.d.ts +227 -5
- package/dist/store.js +727 -37
- package/dist/task-outcome-cli.js +7 -3
- package/dist/toolroll-update.js +2 -2
- package/dist/verification-evidence.d.ts +25 -1
- package/dist/verification-evidence.js +57 -2
- package/dist/work-index.js +3 -6
- package/package.json +1 -1
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The approved commit's files, written by Toolroll itself from a repository's object store (docs/plans/approval-policy.md
|
|
3
|
+
* §1.7): the release runs the deployment script from what this writes, never from the candidate's checkout.
|
|
4
|
+
*
|
|
5
|
+
* No conversion of any kind: `git cat-file --batch` hands back each object's stored bytes, and this code hashes the
|
|
6
|
+
* commit, every tree and every blob itself against the ids that name them and parses the trees itself, so no filter, smudge, textconv, export-subst, export-ignore or other attribute (from the repository's
|
|
7
|
+
* config, `info/attributes` or the tree's own `.gitattributes`) can change or add a byte. Before any read the
|
|
8
|
+
* repository's own config is refused if it names a command or includes another file (teardown-git.ts), and git runs
|
|
9
|
+
* with no system or global config, no replacement objects, no lazy fetch, no hooks and no fsmonitor. Only regular
|
|
10
|
+
* files are written (mode 644, or 755 when the tree says executable), each at a normalized path inside the folder;
|
|
11
|
+
* symlinks, submodules and any other entry refuse the whole extraction.
|
|
12
|
+
*/
|
|
13
|
+
import { spawnSync } from "node:child_process";
|
|
14
|
+
import { createHash } from "node:crypto";
|
|
15
|
+
import { existsSync, mkdirSync, realpathSync, writeFileSync } from "node:fs";
|
|
16
|
+
import { join, sep } from "node:path";
|
|
17
|
+
import { checkoutGitEnv, HARDENED_GIT_ARGS } from "./agent-fence.js";
|
|
18
|
+
import { commandSetting } from "./teardown-git.js";
|
|
19
|
+
const MAX_BYTES = 1024 * 1024 * 1024;
|
|
20
|
+
export const EXTRACTION_LIMITS = Object.freeze({ bytes: MAX_BYTES, files: 50_000, entries: 200_000, depth: 64 });
|
|
21
|
+
/** A tree path that stays where it says: relative, no empty, `.` or `..` part, no backslash or control character. */
|
|
22
|
+
export function normalizedTreePath(path) {
|
|
23
|
+
if (path === "" || path.startsWith("/") || /[\u0000-\u001f\\]/.test(path))
|
|
24
|
+
return false;
|
|
25
|
+
return path.split("/").every(part => part !== "" && part !== "." && part !== "..");
|
|
26
|
+
}
|
|
27
|
+
/** The object id Git gives `<type> <size>\0<bytes>` in this repository's object format. */
|
|
28
|
+
export const objectIdOf = (format, type, bytes) => createHash(format).update(`${type} ${bytes.length}\0`).update(bytes).digest("hex");
|
|
29
|
+
/** A tree object's entries, parsed from its verified bytes: `<mode> <name>\0<raw id>` each. Null when malformed. */
|
|
30
|
+
export function parseTree(bytes, idBytes) {
|
|
31
|
+
const entries = [];
|
|
32
|
+
let at = 0;
|
|
33
|
+
while (at < bytes.length) {
|
|
34
|
+
const space = bytes.indexOf(0x20, at), nul = space < 0 ? -1 : bytes.indexOf(0x00, space);
|
|
35
|
+
if (space < 0 || nul < 0 || nul + 1 + idBytes > bytes.length)
|
|
36
|
+
return null;
|
|
37
|
+
entries.push({ mode: bytes.subarray(at, space).toString("latin1"), name: bytes.subarray(space + 1, nul).toString("utf8"), id: bytes.subarray(nul + 1, nul + 1 + idBytes).toString("hex") });
|
|
38
|
+
at = nul + 1 + idBytes;
|
|
39
|
+
}
|
|
40
|
+
return entries;
|
|
41
|
+
}
|
|
42
|
+
/** Write commit `sha` of the repository whose Git directory is `gitDir` (a realpath Toolroll took from its own
|
|
43
|
+
* records) into `dest`, an empty folder this process made. Every object on the way — the commit, each tree, each
|
|
44
|
+
* blob — is hashed here and must be the id that names it: Git's own answer about an id is never taken on trust, so a
|
|
45
|
+
* corrupted or replaced object in a store a build could write to refuses the extraction. */
|
|
46
|
+
export function extractCommit(gitDir, sha, dest, limits = EXTRACTION_LIMITS) {
|
|
47
|
+
const refuse = (message) => ({ ok: false, message });
|
|
48
|
+
const env = { ...checkoutGitEnv(), GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null", GIT_NO_REPLACE_OBJECTS: "1", GIT_NO_LAZY_FETCH: "1", GIT_ATTR_NOSYSTEM: "1" };
|
|
49
|
+
const git = (args, input) => spawnSync("git", ["--git-dir", gitDir, "--no-replace-objects", ...HARDENED_GIT_ARGS, ...args], { env, input, maxBuffer: MAX_BYTES, timeout: 300_000 });
|
|
50
|
+
// The repository's own config, read without includes, must name no command and include nothing.
|
|
51
|
+
const config = git(["config", "--list", "--no-includes", "--show-scope", "-z"]);
|
|
52
|
+
if (config.status !== 0)
|
|
53
|
+
return refuse("The project's Git config couldn't be read.");
|
|
54
|
+
const listing = config.stdout.toString("utf8");
|
|
55
|
+
const command = commandSetting(listing);
|
|
56
|
+
if (command !== null)
|
|
57
|
+
return refuse(`The project's Git config sets ${command}, which can run a command.`);
|
|
58
|
+
const format = /(?:^|\0)local\0extensions\.objectformat\nsha256(?:\0|$)/i.test(listing) ? "sha256" : "sha1";
|
|
59
|
+
const hexLength = format === "sha1" ? 40 : 64;
|
|
60
|
+
if (!new RegExp(`^[0-9a-f]{${hexLength}}$`).test(sha))
|
|
61
|
+
return refuse(`${sha} isn't a full commit id.`);
|
|
62
|
+
/** Objects by id, each read raw (`cat-file --batch`: "<id> <type> <size>\n<bytes>\n") and hashed here. */
|
|
63
|
+
const read = (ids) => {
|
|
64
|
+
const batch = git(["cat-file", "--batch"], Buffer.from(ids.map(id => `${id}\n`).join(""), "utf8"));
|
|
65
|
+
if (batch.status !== 0)
|
|
66
|
+
return "the objects couldn't be read";
|
|
67
|
+
const out = batch.stdout, found = new Map();
|
|
68
|
+
let at = 0;
|
|
69
|
+
for (const id of ids) {
|
|
70
|
+
const newline = out.indexOf(0x0a, at);
|
|
71
|
+
if (newline < 0)
|
|
72
|
+
return "the objects couldn't be read";
|
|
73
|
+
const header = out.subarray(at, newline).toString("utf8").split(" ");
|
|
74
|
+
if (header.length !== 3 || header[0] !== id || !/^\d+$/.test(header[2]))
|
|
75
|
+
return `object ${id.slice(0, 12)} is missing or unreadable`;
|
|
76
|
+
const start = newline + 1, end = start + Number(header[2]);
|
|
77
|
+
if (end + 1 > out.length || out[end] !== 0x0a)
|
|
78
|
+
return `object ${id.slice(0, 12)} couldn't be read whole`;
|
|
79
|
+
const bytes = out.subarray(start, end);
|
|
80
|
+
if (objectIdOf(format, header[1], bytes) !== id)
|
|
81
|
+
return `object ${id.slice(0, 12)} doesn't hash to its id`;
|
|
82
|
+
found.set(id, { type: header[1], bytes });
|
|
83
|
+
at = end + 1;
|
|
84
|
+
}
|
|
85
|
+
return found;
|
|
86
|
+
};
|
|
87
|
+
const commit = read([sha]);
|
|
88
|
+
if (typeof commit === "string")
|
|
89
|
+
return refuse(`The approved commit couldn't be verified: ${commit}.`);
|
|
90
|
+
const commitObject = commit.get(sha);
|
|
91
|
+
if (commitObject.type !== "commit")
|
|
92
|
+
return refuse(`${sha.slice(0, 7)} isn't a commit.`);
|
|
93
|
+
const treeLine = new RegExp(`^tree ([0-9a-f]{${hexLength}})\n`).exec(commitObject.bytes.toString("utf8"));
|
|
94
|
+
if (treeLine === null)
|
|
95
|
+
return refuse("The approved commit names no tree.");
|
|
96
|
+
// Walk the trees from their verified bytes, level by level; collect every file.
|
|
97
|
+
const files = [];
|
|
98
|
+
let level = [{ id: treeLine[1], prefix: "" }];
|
|
99
|
+
let depth = 0, walked = 0;
|
|
100
|
+
while (level.length > 0) {
|
|
101
|
+
if (++depth > limits.depth)
|
|
102
|
+
return refuse(`The commit's folders nest deeper than ${limits.depth}.`);
|
|
103
|
+
const trees = read(level.map(one => one.id));
|
|
104
|
+
if (typeof trees === "string")
|
|
105
|
+
return refuse(`The approved commit couldn't be verified: ${trees}.`);
|
|
106
|
+
const next = [];
|
|
107
|
+
for (const { id, prefix } of level) {
|
|
108
|
+
const tree = trees.get(id);
|
|
109
|
+
if (tree.type !== "tree")
|
|
110
|
+
return refuse(`The approved commit's tree ${id.slice(0, 12)} isn't a tree.`);
|
|
111
|
+
const entries = parseTree(tree.bytes, hexLength / 2);
|
|
112
|
+
if (entries === null)
|
|
113
|
+
return refuse(`The approved commit's tree ${id.slice(0, 12)} couldn't be read.`);
|
|
114
|
+
walked += entries.length;
|
|
115
|
+
if (walked > limits.entries)
|
|
116
|
+
return refuse(`The commit's trees hold more than ${limits.entries} entries.`);
|
|
117
|
+
for (const entry of entries) {
|
|
118
|
+
const path = prefix === "" ? entry.name : `${prefix}/${entry.name}`;
|
|
119
|
+
if (entry.name.includes("/") || !normalizedTreePath(path))
|
|
120
|
+
return refuse(`The commit holds a path that isn't normalized (${JSON.stringify(path)}).`);
|
|
121
|
+
if (entry.mode === "40000")
|
|
122
|
+
next.push({ id: entry.id, prefix: path });
|
|
123
|
+
else if (entry.mode === "120000")
|
|
124
|
+
return refuse(`The commit holds a symlink (${path}); a deployment never runs one.`);
|
|
125
|
+
else if (entry.mode === "160000")
|
|
126
|
+
return refuse(`The commit holds a submodule (${path}); a deployment never runs one.`);
|
|
127
|
+
else if (entry.mode === "100644" || entry.mode === "100755") {
|
|
128
|
+
files.push({ mode: entry.mode, id: entry.id, path });
|
|
129
|
+
if (files.length > limits.files)
|
|
130
|
+
return refuse(`The commit holds more than ${limits.files} files.`);
|
|
131
|
+
}
|
|
132
|
+
else
|
|
133
|
+
return refuse(`The commit holds an entry that isn't a plain file (${path}).`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
level = next;
|
|
137
|
+
}
|
|
138
|
+
if (files.length === 0)
|
|
139
|
+
return refuse(`The commit ${sha.slice(0, 7)} holds no files.`);
|
|
140
|
+
const blobs = read([...new Set(files.map(one => one.id))]);
|
|
141
|
+
if (typeof blobs === "string")
|
|
142
|
+
return refuse(`The approved commit couldn't be verified: ${blobs}.`);
|
|
143
|
+
// What would be written, every path counted (a blob shared by many paths is written once per path).
|
|
144
|
+
let total = 0;
|
|
145
|
+
for (const file of files) {
|
|
146
|
+
total += blobs.get(file.id).bytes.length;
|
|
147
|
+
if (total > limits.bytes)
|
|
148
|
+
return refuse(`The commit's files come to more than ${limits.bytes} bytes.`);
|
|
149
|
+
}
|
|
150
|
+
const root = realpathSync(dest);
|
|
151
|
+
for (const file of files) {
|
|
152
|
+
const blob = blobs.get(file.id);
|
|
153
|
+
if (blob.type !== "blob")
|
|
154
|
+
return refuse(`${file.path} isn't a file in the commit.`);
|
|
155
|
+
const target = join(root, ...file.path.split("/"));
|
|
156
|
+
if (!target.startsWith(root + sep))
|
|
157
|
+
return refuse(`The path ${file.path} leaves the folder.`);
|
|
158
|
+
try {
|
|
159
|
+
mkdirSync(join(target, ".."), { recursive: true, mode: 0o755 });
|
|
160
|
+
// "wx": never over anything already there (no file twice, no file where a folder is).
|
|
161
|
+
writeFileSync(target, blob.bytes, { flag: "wx", mode: file.mode === "100755" ? 0o755 : 0o644 });
|
|
162
|
+
}
|
|
163
|
+
catch (error) {
|
|
164
|
+
return refuse(`The file ${file.path} couldn't be written: ${error.message}`);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return { ok: true, files: files.length };
|
|
168
|
+
}
|
|
169
|
+
/** A reader over the project's object store that hashes every object it hands back, or why it can't be one. */
|
|
170
|
+
function verifiedReader(gitDir) {
|
|
171
|
+
const refuse = (message) => ({ ok: false, message });
|
|
172
|
+
const env = { ...checkoutGitEnv(), GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null", GIT_NO_REPLACE_OBJECTS: "1", GIT_NO_LAZY_FETCH: "1", GIT_ATTR_NOSYSTEM: "1" };
|
|
173
|
+
const git = (args, input) => spawnSync("git", ["--git-dir", gitDir, "--no-replace-objects", ...HARDENED_GIT_ARGS, ...args], { env, input, maxBuffer: MAX_BYTES, timeout: 300_000 });
|
|
174
|
+
// History a build could have rewritten around the objects: a shallow boundary, grafts, or replacement refs. Each is
|
|
175
|
+
// refused outright, so no reader here ever has to decide whether to believe one.
|
|
176
|
+
if (existsSync(join(gitDir, "shallow")))
|
|
177
|
+
return refuse("The project's history is shallow, so the commits between the approval and this one can't all be read.");
|
|
178
|
+
if (existsSync(join(gitDir, "info", "grafts")))
|
|
179
|
+
return refuse("The project's Git directory has grafts, which rewrite history; they are refused.");
|
|
180
|
+
const replaced = git(["for-each-ref", "--format=%(refname)", "refs/replace/"]);
|
|
181
|
+
if (replaced.status !== 0)
|
|
182
|
+
return refuse("The project's refs couldn't be read.");
|
|
183
|
+
if (replaced.stdout.toString("utf8").trim() !== "")
|
|
184
|
+
return refuse("The project's Git directory has replacement refs, which swap objects; they are refused.");
|
|
185
|
+
const config = git(["config", "--list", "--no-includes", "--show-scope", "-z"]);
|
|
186
|
+
if (config.status !== 0)
|
|
187
|
+
return refuse("The project's Git config couldn't be read.");
|
|
188
|
+
const listing = config.stdout.toString("utf8");
|
|
189
|
+
const command = commandSetting(listing);
|
|
190
|
+
if (command !== null)
|
|
191
|
+
return refuse(`The project's Git config sets ${command}, which can run a command.`);
|
|
192
|
+
const format = /(?:^|\0)local\0extensions\.objectformat\nsha256(?:\0|$)/i.test(listing) ? "sha256" : "sha1";
|
|
193
|
+
const hexLength = format === "sha1" ? 40 : 64;
|
|
194
|
+
const read = (ids) => {
|
|
195
|
+
const found = new Map();
|
|
196
|
+
const wanted = [...new Set(ids)];
|
|
197
|
+
if (wanted.length === 0)
|
|
198
|
+
return found;
|
|
199
|
+
if (wanted.some(id => !new RegExp(`^[0-9a-f]{${hexLength}}$`).test(id)))
|
|
200
|
+
return "an object id isn't a full id";
|
|
201
|
+
const batch = git(["cat-file", "--batch"], Buffer.from(wanted.map(id => `${id}\n`).join(""), "utf8"));
|
|
202
|
+
if (batch.status !== 0)
|
|
203
|
+
return "the objects couldn't be read";
|
|
204
|
+
const out = batch.stdout;
|
|
205
|
+
let at = 0;
|
|
206
|
+
for (const id of wanted) {
|
|
207
|
+
const newline = out.indexOf(0x0a, at);
|
|
208
|
+
if (newline < 0)
|
|
209
|
+
return "the objects couldn't be read";
|
|
210
|
+
const header = out.subarray(at, newline).toString("utf8").split(" ");
|
|
211
|
+
if (header.length !== 3 || header[0] !== id || !/^\d+$/.test(header[2]))
|
|
212
|
+
return `object ${id.slice(0, 12)} is missing or unreadable`;
|
|
213
|
+
const start = newline + 1, end = start + Number(header[2]);
|
|
214
|
+
if (end + 1 > out.length || out[end] !== 0x0a)
|
|
215
|
+
return `object ${id.slice(0, 12)} couldn't be read whole`;
|
|
216
|
+
const bytes = out.subarray(start, end);
|
|
217
|
+
if (objectIdOf(format, header[1], bytes) !== id)
|
|
218
|
+
return `object ${id.slice(0, 12)} doesn't hash to its id`;
|
|
219
|
+
found.set(id, { type: header[1], bytes });
|
|
220
|
+
at = end + 1;
|
|
221
|
+
}
|
|
222
|
+
return found;
|
|
223
|
+
};
|
|
224
|
+
return { ok: true, value: { format, hexLength, read } };
|
|
225
|
+
}
|
|
226
|
+
/** Parse a commit from its verified bytes: tree, parents, message. Null when malformed. */
|
|
227
|
+
function parseCommit(id, bytes, hexLength) {
|
|
228
|
+
const text = bytes.toString("utf8");
|
|
229
|
+
const split = text.indexOf("\n\n");
|
|
230
|
+
const head = split < 0 ? text : text.slice(0, split);
|
|
231
|
+
const lines = head.split("\n");
|
|
232
|
+
const tree = new RegExp(`^tree ([0-9a-f]{${hexLength}})$`).exec(lines[0] ?? "");
|
|
233
|
+
if (tree === null)
|
|
234
|
+
return null;
|
|
235
|
+
const parents = [];
|
|
236
|
+
for (const line of lines.slice(1)) {
|
|
237
|
+
const parent = new RegExp(`^parent ([0-9a-f]{${hexLength}})$`).exec(line);
|
|
238
|
+
if (parent === null)
|
|
239
|
+
break;
|
|
240
|
+
parents.push(parent[1]);
|
|
241
|
+
}
|
|
242
|
+
return { id, tree: tree[1], parents, message: split < 0 ? "" : text.slice(split + 2) };
|
|
243
|
+
}
|
|
244
|
+
/** One commit, hashed here and parsed from its own bytes. */
|
|
245
|
+
export function readVerifiedCommit(gitDir, sha) {
|
|
246
|
+
const reader = verifiedReader(gitDir);
|
|
247
|
+
if (!reader.ok)
|
|
248
|
+
return reader;
|
|
249
|
+
return readCommitWith(reader.value, sha);
|
|
250
|
+
}
|
|
251
|
+
function readCommitWith(reader, sha) {
|
|
252
|
+
const got = reader.read([sha]);
|
|
253
|
+
if (typeof got === "string")
|
|
254
|
+
return { ok: false, message: `Commit ${sha.slice(0, 7)} couldn't be verified: ${got}.` };
|
|
255
|
+
const object = got.get(sha);
|
|
256
|
+
if (object.type !== "commit")
|
|
257
|
+
return { ok: false, message: `${sha.slice(0, 7)} isn't a commit.` };
|
|
258
|
+
const commit = parseCommit(sha, object.bytes, reader.hexLength);
|
|
259
|
+
return commit === null ? { ok: false, message: `Commit ${sha.slice(0, 7)} couldn't be read.` } : { ok: true, value: commit };
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* The commits from `from` back to `until` along first parents, newest first, `until` itself left out. Refuses when
|
|
263
|
+
* `until` isn't reached within `max` commits (it isn't an ancestor, or too much lies between), and hands back each
|
|
264
|
+
* commit's full parent list so a caller can refuse merges.
|
|
265
|
+
*/
|
|
266
|
+
export function verifiedFirstParentWalk(gitDir, from, until, max = 200) {
|
|
267
|
+
const reader = verifiedReader(gitDir);
|
|
268
|
+
if (!reader.ok)
|
|
269
|
+
return reader;
|
|
270
|
+
const commits = [];
|
|
271
|
+
let at = from;
|
|
272
|
+
while (at !== until) {
|
|
273
|
+
if (commits.length >= max)
|
|
274
|
+
return { ok: false, message: `${until.slice(0, 7)} isn't within ${max} commits before ${from.slice(0, 7)}.` };
|
|
275
|
+
const commit = readCommitWith(reader.value, at);
|
|
276
|
+
if (!commit.ok)
|
|
277
|
+
return commit;
|
|
278
|
+
commits.push(commit.value);
|
|
279
|
+
const parent = commit.value.parents[0];
|
|
280
|
+
if (parent === undefined)
|
|
281
|
+
return { ok: false, message: `${until.slice(0, 7)} isn't an ancestor of ${from.slice(0, 7)}.` };
|
|
282
|
+
at = parent;
|
|
283
|
+
}
|
|
284
|
+
return { ok: true, value: commits };
|
|
285
|
+
}
|
|
286
|
+
/** Every entry of a verified tree at its full path, symlinks and submodules included (a caller decides on them). An
|
|
287
|
+
* unnormalized path, or one a tree lists twice, refuses the whole read. */
|
|
288
|
+
export function verifiedTreeEntries(gitDir, tree, limits = EXTRACTION_LIMITS) {
|
|
289
|
+
const reader = verifiedReader(gitDir);
|
|
290
|
+
if (!reader.ok)
|
|
291
|
+
return reader;
|
|
292
|
+
return treeEntriesWith(reader.value, tree, limits);
|
|
293
|
+
}
|
|
294
|
+
function treeEntriesWith(reader, root, limits) {
|
|
295
|
+
const refuse = (message) => ({ ok: false, message });
|
|
296
|
+
const entries = [];
|
|
297
|
+
let level = [{ id: root, prefix: "" }];
|
|
298
|
+
let depth = 0, walked = 0;
|
|
299
|
+
const seen = new Set();
|
|
300
|
+
while (level.length > 0) {
|
|
301
|
+
if (++depth > limits.depth)
|
|
302
|
+
return refuse(`The commit's folders nest deeper than ${limits.depth}.`);
|
|
303
|
+
const trees = reader.read(level.map(one => one.id));
|
|
304
|
+
if (typeof trees === "string")
|
|
305
|
+
return refuse(`A tree couldn't be verified: ${trees}.`);
|
|
306
|
+
const next = [];
|
|
307
|
+
for (const { id, prefix } of level) {
|
|
308
|
+
const tree = trees.get(id);
|
|
309
|
+
if (tree.type !== "tree")
|
|
310
|
+
return refuse(`${id.slice(0, 12)} isn't a tree.`);
|
|
311
|
+
const parsed = parseTree(tree.bytes, reader.hexLength / 2);
|
|
312
|
+
if (parsed === null)
|
|
313
|
+
return refuse(`Tree ${id.slice(0, 12)} couldn't be read.`);
|
|
314
|
+
walked += parsed.length;
|
|
315
|
+
if (walked > limits.entries)
|
|
316
|
+
return refuse(`The trees hold more than ${limits.entries} entries.`);
|
|
317
|
+
for (const entry of parsed) {
|
|
318
|
+
const path = prefix === "" ? entry.name : `${prefix}/${entry.name}`;
|
|
319
|
+
if (entry.name.includes("/") || !normalizedTreePath(path))
|
|
320
|
+
return refuse(`A tree holds a path that isn't normalized (${JSON.stringify(path)}).`);
|
|
321
|
+
if (seen.has(path))
|
|
322
|
+
return refuse(`A tree lists ${path} twice.`);
|
|
323
|
+
seen.add(path);
|
|
324
|
+
if (entry.mode === "40000")
|
|
325
|
+
next.push({ id: entry.id, prefix: path });
|
|
326
|
+
else if (["100644", "100755", "120000", "160000"].includes(entry.mode))
|
|
327
|
+
entries.push({ path, mode: entry.mode, id: entry.id });
|
|
328
|
+
else
|
|
329
|
+
return refuse(`A tree holds an entry of an unknown kind (${path}).`);
|
|
330
|
+
if (entries.length > limits.files)
|
|
331
|
+
return refuse(`The tree holds more than ${limits.files} entries.`);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
level = next;
|
|
335
|
+
}
|
|
336
|
+
return { ok: true, value: entries.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)) };
|
|
337
|
+
}
|
|
338
|
+
/** Whether bytes look binary: a NUL in the first 8000 bytes, as Git judges. */
|
|
339
|
+
export const looksBinary = (bytes) => bytes.subarray(0, 8000).includes(0);
|
|
340
|
+
/** The lines added and deleted from `before` to `after`, by the shortest edit (Myers): each counted once, stopped at
|
|
341
|
+
* `cap` total edits (the counts then add up to more than `cap`, which every limit here refuses). */
|
|
342
|
+
export function lineChanges(before, after, cap = 100_000) {
|
|
343
|
+
const split = (bytes) => { const text = bytes.toString("utf8"); if (text === "")
|
|
344
|
+
return []; const lines = text.split("\n"); if (lines[lines.length - 1] === "")
|
|
345
|
+
lines.pop(); return lines; };
|
|
346
|
+
const a = split(before), b = split(after);
|
|
347
|
+
// Trim the common start and end first: most changes are small.
|
|
348
|
+
let start = 0;
|
|
349
|
+
while (start < a.length && start < b.length && a[start] === b[start])
|
|
350
|
+
start++;
|
|
351
|
+
let endA = a.length, endB = b.length;
|
|
352
|
+
while (endA > start && endB > start && a[endA - 1] === b[endB - 1]) {
|
|
353
|
+
endA--;
|
|
354
|
+
endB--;
|
|
355
|
+
}
|
|
356
|
+
const n = endA - start, m = endB - start;
|
|
357
|
+
if (n === 0 || m === 0)
|
|
358
|
+
return { added: m, deleted: n };
|
|
359
|
+
const max = Math.min(n + m, cap);
|
|
360
|
+
const v = new Int32Array(2 * max + 2);
|
|
361
|
+
const off = max + 1;
|
|
362
|
+
for (let d = 0; d <= max; d++) {
|
|
363
|
+
for (let k = -d; k <= d; k += 2) {
|
|
364
|
+
let x = k === -d || (k !== d && v[off + k - 1] < v[off + k + 1]) ? v[off + k + 1] : v[off + k - 1] + 1;
|
|
365
|
+
let y = x - k;
|
|
366
|
+
while (x < n && y < m && a[start + x] === b[start + y]) {
|
|
367
|
+
x++;
|
|
368
|
+
y++;
|
|
369
|
+
}
|
|
370
|
+
v[off + k] = x;
|
|
371
|
+
if (x >= n && y >= m) {
|
|
372
|
+
const common = (n + m - d) / 2;
|
|
373
|
+
return { added: m - common, deleted: n - common };
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
// Past the cap: report everything as changed, which is more than any limit allows.
|
|
378
|
+
return { added: m, deleted: n };
|
|
379
|
+
}
|
|
380
|
+
/**
|
|
381
|
+
* What differs between two verified trees, path by path (no rename pairing: a moved file is a deletion and an
|
|
382
|
+
* addition). Text files get their added and deleted lines; a binary side, a symlink or a submodule is marked so a caller
|
|
383
|
+
* can refuse it.
|
|
384
|
+
*/
|
|
385
|
+
export function verifiedTreeDiff(gitDir, beforeTree, afterTree) {
|
|
386
|
+
const reader = verifiedReader(gitDir);
|
|
387
|
+
if (!reader.ok)
|
|
388
|
+
return reader;
|
|
389
|
+
const before = treeEntriesWith(reader.value, beforeTree, EXTRACTION_LIMITS);
|
|
390
|
+
if (!before.ok)
|
|
391
|
+
return before;
|
|
392
|
+
const after = treeEntriesWith(reader.value, afterTree, EXTRACTION_LIMITS);
|
|
393
|
+
if (!after.ok)
|
|
394
|
+
return after;
|
|
395
|
+
const was = new Map(before.value.map(one => [one.path, one]));
|
|
396
|
+
const now = new Map(after.value.map(one => [one.path, one]));
|
|
397
|
+
const paths = [...new Set([...was.keys(), ...now.keys()])].sort();
|
|
398
|
+
const differing = paths.filter(path => { const a = was.get(path), b = now.get(path); return a === undefined || b === undefined || a.id !== b.id || a.mode !== b.mode; });
|
|
399
|
+
const blobIds = differing.flatMap(path => [was.get(path), now.get(path)]).filter((one) => one !== undefined && (one.mode === "100644" || one.mode === "100755")).map(one => one.id);
|
|
400
|
+
const blobs = reader.value.read(blobIds);
|
|
401
|
+
if (typeof blobs === "string")
|
|
402
|
+
return { ok: false, message: `A changed file couldn't be verified: ${blobs}.` };
|
|
403
|
+
const changes = [];
|
|
404
|
+
for (const path of differing) {
|
|
405
|
+
const a = was.get(path) ?? null, b = now.get(path) ?? null;
|
|
406
|
+
const bytesOf = (entry) => {
|
|
407
|
+
if (entry === null || (entry.mode !== "100644" && entry.mode !== "100755"))
|
|
408
|
+
return null;
|
|
409
|
+
const blob = blobs.get(entry.id);
|
|
410
|
+
return blob !== undefined && blob.type === "blob" ? blob.bytes : null;
|
|
411
|
+
};
|
|
412
|
+
const fileLike = (entry) => entry === null || entry.mode === "100644" || entry.mode === "100755";
|
|
413
|
+
const ab = bytesOf(a), bb = bytesOf(b);
|
|
414
|
+
if ((a !== null && fileLike(a) && ab === null) || (b !== null && fileLike(b) && bb === null))
|
|
415
|
+
return { ok: false, message: `${path} isn't a file in its commit.` };
|
|
416
|
+
const binary = !fileLike(a) || !fileLike(b) || (ab !== null && looksBinary(ab)) || (bb !== null && looksBinary(bb));
|
|
417
|
+
const counted = binary ? { added: 0, deleted: 0 } : lineChanges(ab ?? Buffer.alloc(0), bb ?? Buffer.alloc(0));
|
|
418
|
+
changes.push({ path, kind: a === null ? "added" : b === null ? "deleted" : "modified", before: a, after: b, ...counted, binary });
|
|
419
|
+
}
|
|
420
|
+
return { ok: true, value: changes };
|
|
421
|
+
}
|
|
422
|
+
/** The ids of verified blobs at these paths of a tree (absent paths left out): a digest's inputs, read without
|
|
423
|
+
* running anything. */
|
|
424
|
+
export function verifiedBlobs(gitDir, tree, paths) {
|
|
425
|
+
const reader = verifiedReader(gitDir);
|
|
426
|
+
if (!reader.ok)
|
|
427
|
+
return reader;
|
|
428
|
+
const entries = treeEntriesWith(reader.value, tree, EXTRACTION_LIMITS);
|
|
429
|
+
if (!entries.ok)
|
|
430
|
+
return entries;
|
|
431
|
+
const wanted = entries.value.filter(one => paths.includes(one.path) && (one.mode === "100644" || one.mode === "100755"));
|
|
432
|
+
const blobs = reader.value.read(wanted.map(one => one.id));
|
|
433
|
+
if (typeof blobs === "string")
|
|
434
|
+
return { ok: false, message: `A file couldn't be verified: ${blobs}.` };
|
|
435
|
+
return { ok: true, value: new Map(wanted.map(one => [one.path, { id: one.id, bytes: blobs.get(one.id).bytes }])) };
|
|
436
|
+
}
|
|
@@ -190,7 +190,7 @@ export declare const CONSOLE_FORMS: {
|
|
|
190
190
|
};
|
|
191
191
|
export type ConsoleFormName = keyof typeof CONSOLE_FORMS;
|
|
192
192
|
/** The console POSTs whose handlers read no field (sign-out not even the shared guard): their contract is the empty form. */
|
|
193
|
-
export declare const BODILESS_POSTS: readonly ["POST /settings/models/<unknown action>", "POST /settings/lead/on", "POST /r/<run>/draft-repair", "POST /ledger/checkpoint", "POST /settings/backups/now", "POST /mode/revoke", "POST /settings/telegram/retry", "POST /providers/<provider>/resume", "POST /logout", "POST /t/<task>/unhold", "POST /t/<task>/plan", "POST /t/<task>/retry-review"];
|
|
193
|
+
export declare const BODILESS_POSTS: readonly ["POST /settings/models/<unknown action>", "POST /settings/lead/on", "POST /r/<run>/draft-repair", "POST /ledger/checkpoint", "POST /settings/backups/now", "POST /mode/revoke", "POST /settings/telegram/retry", "POST /providers/<provider>/resume", "POST /logout", "POST /t/<task>/unhold", "POST /t/<task>/stop-carrying", "POST /t/<task>/plan", "POST /t/<task>/retry-review"];
|
|
194
194
|
/** The field names a console form's handler may read. */
|
|
195
195
|
export type FormFieldOf<N extends ConsoleFormName> = (typeof CONSOLE_FORMS)[N] extends FormContract<infer K> ? K : never;
|
|
196
196
|
/** `GET /ledger?format=json`: the chain's state, a page of entries (newest first) and the cursor for the next. */
|
package/dist/contracts/flow.d.ts
CHANGED
|
@@ -11023,6 +11023,14 @@ export declare const standingOrderSchema: z.ZodObject<{
|
|
|
11023
11023
|
profileJson: z.ZodString;
|
|
11024
11024
|
routeJson: z.ZodString;
|
|
11025
11025
|
}, z.core.$strict>>;
|
|
11026
|
+
check: z.ZodOptional<z.ZodObject<{
|
|
11027
|
+
kind: z.ZodLiteral<"main-full">;
|
|
11028
|
+
grant: z.ZodObject<{
|
|
11029
|
+
id: z.ZodInt;
|
|
11030
|
+
digest: z.ZodString;
|
|
11031
|
+
}, z.core.$strict>;
|
|
11032
|
+
access: z.ZodLiteral<"sandboxed">;
|
|
11033
|
+
}, z.core.$strict>>;
|
|
11026
11034
|
}, z.core.$strict>;
|
|
11027
11035
|
export type StandingOrder = z.infer<typeof standingOrderSchema>;
|
|
11028
11036
|
/** A trigger as it is saved (`configJson`), one schema per kind. */
|
|
@@ -11058,6 +11066,14 @@ export declare const triggerConfigSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
|
11058
11066
|
profileJson: z.ZodString;
|
|
11059
11067
|
routeJson: z.ZodString;
|
|
11060
11068
|
}, z.core.$strict>>;
|
|
11069
|
+
check: z.ZodOptional<z.ZodObject<{
|
|
11070
|
+
kind: z.ZodLiteral<"main-full">;
|
|
11071
|
+
grant: z.ZodObject<{
|
|
11072
|
+
id: z.ZodInt;
|
|
11073
|
+
digest: z.ZodString;
|
|
11074
|
+
}, z.core.$strict>;
|
|
11075
|
+
access: z.ZodLiteral<"sandboxed">;
|
|
11076
|
+
}, z.core.$strict>>;
|
|
11061
11077
|
}, z.core.$strict>>;
|
|
11062
11078
|
}, z.core.$strict>, z.ZodObject<{
|
|
11063
11079
|
zone: z.ZodNullable<z.ZodString>;
|
package/dist/contracts/flow.js
CHANGED
|
@@ -354,6 +354,9 @@ export const standingOrderSchema = z.strictObject({
|
|
|
354
354
|
/** The routine it came from: that routine's earlier tasks still count toward one-at-a-time and the ceiling. */
|
|
355
355
|
routine: z.int().nullable(),
|
|
356
356
|
approval: z.strictObject({ digest: z.string().min(1), by: z.string().nullable(), at: z.string().min(1), profileJson: z.string().min(1), routeJson: z.string().min(1) }).nullable(),
|
|
357
|
+
/** The nightly full check on main (release-once.md, part 3): each firing files a check-only task at main's head, its
|
|
358
|
+
* approval bound to the release check grant and the fenced check access. */
|
|
359
|
+
check: z.strictObject({ kind: z.literal("main-full"), grant: z.strictObject({ id: z.int(), digest: z.string().min(1) }), access: z.literal("sandboxed") }).optional(),
|
|
357
360
|
});
|
|
358
361
|
/** A trigger as it is saved (`configJson`), one schema per kind. */
|
|
359
362
|
export const triggerConfigSchema = z.discriminatedUnion("kind", [
|