@dsh-enhanced/assistant-verifier 0.1.30
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/LICENSE +21 -0
- package/README.md +82 -0
- package/cordis.patch.yml +9 -0
- package/lib/config.d.ts +30 -0
- package/lib/config.d.ts.map +1 -0
- package/lib/config.js +68 -0
- package/lib/config.js.map +1 -0
- package/lib/drivers.d.ts +128 -0
- package/lib/drivers.d.ts.map +1 -0
- package/lib/drivers.js +786 -0
- package/lib/drivers.js.map +1 -0
- package/lib/host.d.ts +49 -0
- package/lib/host.d.ts.map +1 -0
- package/lib/host.js +2 -0
- package/lib/host.js.map +1 -0
- package/lib/index.d.ts +13 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +12 -0
- package/lib/index.js.map +1 -0
- package/lib/service.d.ts +85 -0
- package/lib/service.d.ts.map +1 -0
- package/lib/service.js +574 -0
- package/lib/service.js.map +1 -0
- package/lib/store.d.ts +84 -0
- package/lib/store.d.ts.map +1 -0
- package/lib/store.js +386 -0
- package/lib/store.js.map +1 -0
- package/lib/version.d.ts +2 -0
- package/lib/version.d.ts.map +1 -0
- package/lib/version.js +2 -0
- package/lib/version.js.map +1 -0
- package/package.json +91 -0
package/lib/drivers.js
ADDED
|
@@ -0,0 +1,786 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { closeSync, constants, fstatSync, openSync, readSync, realpathSync, statSync } from 'node:fs';
|
|
4
|
+
import { chmod, lstat, mkdtemp, open, realpath, rm, writeFile } from 'node:fs/promises';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { extname, isAbsolute, join, normalize, relative, resolve, sep } from 'node:path';
|
|
7
|
+
import { acceptanceCanonicalJson } from '@dsh-enhanced/task-acceptance-contract';
|
|
8
|
+
const MAX_AUTHORITIES = 64;
|
|
9
|
+
const MAX_CONFIG_STRING = 16_384;
|
|
10
|
+
const MAX_EXECUTABLE_BYTES = 128 * 1024 * 1024;
|
|
11
|
+
const MAX_ARTIFACT_BYTES = 1_048_576;
|
|
12
|
+
const MAX_ISOLATED_TEST_SETS = 64;
|
|
13
|
+
const MAX_ISOLATED_CASES = 256;
|
|
14
|
+
const MAX_ISOLATED_TEST_CASES = 1_024;
|
|
15
|
+
const MAX_ISOLATED_TEST_BYTES = 65_536;
|
|
16
|
+
const IMAGE = /^sha256:[a-f0-9]{64}$/u;
|
|
17
|
+
const STABLE_REF = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$/u;
|
|
18
|
+
export class VerifierAuthorityError extends Error {
|
|
19
|
+
code;
|
|
20
|
+
constructor(code, message) {
|
|
21
|
+
super(message);
|
|
22
|
+
this.code = code;
|
|
23
|
+
this.name = 'VerifierAuthorityError';
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
function fail(message) { throw new VerifierAuthorityError('invalid-authority', message); }
|
|
27
|
+
function object(value, label) {
|
|
28
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value) || Object.getPrototypeOf(value) !== Object.prototype)
|
|
29
|
+
fail(`${label} must be an object`);
|
|
30
|
+
for (const key of Reflect.ownKeys(value)) {
|
|
31
|
+
if (typeof key !== 'string')
|
|
32
|
+
fail(`${label} has unsupported fields`);
|
|
33
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
34
|
+
if (descriptor === undefined || !descriptor.enumerable || !Object.hasOwn(descriptor, 'value'))
|
|
35
|
+
fail(`${label} has unsupported fields`);
|
|
36
|
+
}
|
|
37
|
+
return value;
|
|
38
|
+
}
|
|
39
|
+
function exactKeys(value, keys, label) {
|
|
40
|
+
const actual = Object.keys(value).sort();
|
|
41
|
+
const expected = [...keys].sort();
|
|
42
|
+
if (actual.length !== expected.length || actual.some((key, index) => key !== expected[index]))
|
|
43
|
+
fail(`${label} has unsupported fields`);
|
|
44
|
+
}
|
|
45
|
+
function allowedKeys(value, required, optional, label) {
|
|
46
|
+
for (const key of required)
|
|
47
|
+
if (!(key in value))
|
|
48
|
+
fail(`${label} is missing ${key}`);
|
|
49
|
+
const allowed = new Set([...required, ...optional]);
|
|
50
|
+
if (Object.keys(value).some(key => !allowed.has(key)))
|
|
51
|
+
fail(`${label} has unsupported fields`);
|
|
52
|
+
}
|
|
53
|
+
function string(value, label, allowEmpty = false) {
|
|
54
|
+
if (typeof value !== 'string' || value.length > MAX_CONFIG_STRING || (!allowEmpty && value.length === 0) || value.includes('\0'))
|
|
55
|
+
fail(`${label} must be a bounded string`);
|
|
56
|
+
return value;
|
|
57
|
+
}
|
|
58
|
+
function whole(value, label, maximum = 300_000) {
|
|
59
|
+
if (!Number.isSafeInteger(value) || value < 1 || value > maximum)
|
|
60
|
+
fail(`${label} must be a bounded positive integer`);
|
|
61
|
+
return value;
|
|
62
|
+
}
|
|
63
|
+
function digest(value) { return createHash('sha256').update(canonical(value)).digest('hex'); }
|
|
64
|
+
function canonical(value) {
|
|
65
|
+
if (value === null || typeof value === 'boolean' || typeof value === 'number' || typeof value === 'string')
|
|
66
|
+
return JSON.stringify(value);
|
|
67
|
+
if (Array.isArray(value))
|
|
68
|
+
return `[${value.map(canonical).join(',')}]`;
|
|
69
|
+
if (typeof value === 'object') {
|
|
70
|
+
const record = value;
|
|
71
|
+
return `{${Object.keys(record).sort().map(key => `${JSON.stringify(key)}:${canonical(record[key])}`).join(',')}}`;
|
|
72
|
+
}
|
|
73
|
+
fail('authority contains an unsupported value');
|
|
74
|
+
}
|
|
75
|
+
function freeze(value) {
|
|
76
|
+
if (value !== null && typeof value === 'object' && !Object.isFrozen(value)) {
|
|
77
|
+
for (const child of Object.values(value))
|
|
78
|
+
freeze(child);
|
|
79
|
+
Object.freeze(value);
|
|
80
|
+
}
|
|
81
|
+
return value;
|
|
82
|
+
}
|
|
83
|
+
function validPointer(value, label) {
|
|
84
|
+
const pointer = string(value, label, true);
|
|
85
|
+
if (pointer !== '' && (!pointer.startsWith('/') || /~(?:[^01]|$)/u.test(pointer)))
|
|
86
|
+
fail(`${label} must be an RFC6901 pointer`);
|
|
87
|
+
return pointer;
|
|
88
|
+
}
|
|
89
|
+
function safeUrl(value, label, allowHttpLoopback) {
|
|
90
|
+
const raw = string(value, label);
|
|
91
|
+
if (/\{[^}]*\}/u.test(raw))
|
|
92
|
+
fail(`${label} must not contain placeholders`);
|
|
93
|
+
let url;
|
|
94
|
+
try {
|
|
95
|
+
url = new URL(raw);
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
fail(`${label} must be an absolute URL`);
|
|
99
|
+
}
|
|
100
|
+
if (url.username !== '' || url.password !== '' || url.hash !== '')
|
|
101
|
+
fail(`${label} must not contain credentials or a fragment`);
|
|
102
|
+
if (url.protocol === 'https:')
|
|
103
|
+
return url.toString();
|
|
104
|
+
const loopback = url.hostname === '127.0.0.1' || url.hostname === 'localhost' || url.hostname === '[::1]';
|
|
105
|
+
if (url.protocol !== 'http:' || !allowHttpLoopback || !loopback)
|
|
106
|
+
fail(`${label} must use HTTPS or explicitly enabled loopback HTTP`);
|
|
107
|
+
return url.toString();
|
|
108
|
+
}
|
|
109
|
+
function readbackTemplate(value, allowHttpLoopback) {
|
|
110
|
+
const raw = string(value, 'readback urlTemplate');
|
|
111
|
+
const marker = '__dsh_acceptance_object_id__';
|
|
112
|
+
if ((raw.match(/\{id\}/gu) ?? []).length !== 1)
|
|
113
|
+
fail('readback urlTemplate must contain exactly one {id}');
|
|
114
|
+
if (/\{[^}]*\}/u.test(raw.replace('{id}', '')))
|
|
115
|
+
fail('readback urlTemplate must not contain other placeholders');
|
|
116
|
+
const parsed = new URL(safeUrl(raw.replace('{id}', marker), 'readback urlTemplate', allowHttpLoopback));
|
|
117
|
+
if (parsed.hostname.includes(marker) || parsed.search.includes(marker) || parsed.username.includes(marker)
|
|
118
|
+
|| parsed.password.includes(marker) || !parsed.pathname.split('/').includes(marker)) {
|
|
119
|
+
fail('readback urlTemplate must contain {id} as one path segment');
|
|
120
|
+
}
|
|
121
|
+
return parsed.toString().replace(marker, '{id}');
|
|
122
|
+
}
|
|
123
|
+
function shaFile(path) {
|
|
124
|
+
const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
125
|
+
try {
|
|
126
|
+
const stat = fstatSync(fd);
|
|
127
|
+
if (!stat.isFile() || stat.size < 0 || stat.size > MAX_EXECUTABLE_BYTES)
|
|
128
|
+
fail('runner executable must be a bounded regular file');
|
|
129
|
+
const bytes = Buffer.alloc(stat.size);
|
|
130
|
+
let offset = 0;
|
|
131
|
+
while (offset < bytes.length) {
|
|
132
|
+
const count = readSync(fd, bytes, offset, bytes.length - offset, offset);
|
|
133
|
+
if (count === 0)
|
|
134
|
+
break;
|
|
135
|
+
offset += count;
|
|
136
|
+
}
|
|
137
|
+
const after = fstatSync(fd);
|
|
138
|
+
if (offset !== stat.size || after.dev !== stat.dev || after.ino !== stat.ino || after.size !== stat.size)
|
|
139
|
+
fail('runner executable changed while hashing');
|
|
140
|
+
return createHash('sha256').update(bytes).digest('hex');
|
|
141
|
+
}
|
|
142
|
+
finally {
|
|
143
|
+
closeSync(fd);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
function canonicalDirectory(value, label) {
|
|
147
|
+
const path = string(value, label);
|
|
148
|
+
if (!isAbsolute(path) || normalize(path) !== path || resolve(path) !== path || path === sep)
|
|
149
|
+
fail(`${label} must be a canonical non-root absolute path`);
|
|
150
|
+
return path;
|
|
151
|
+
}
|
|
152
|
+
function canonicalExecutable(value, label) {
|
|
153
|
+
const configured = string(value, label);
|
|
154
|
+
if (!isAbsolute(configured) || normalize(configured) !== configured || resolve(configured) !== configured)
|
|
155
|
+
fail(`${label} must be a canonical absolute path`);
|
|
156
|
+
let resolved;
|
|
157
|
+
try {
|
|
158
|
+
resolved = realpathSync(configured);
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
fail(`${label} is unavailable`);
|
|
162
|
+
}
|
|
163
|
+
if (resolved !== configured)
|
|
164
|
+
fail(`${label} must not traverse a symlink`);
|
|
165
|
+
try {
|
|
166
|
+
const stat = statSync(resolved);
|
|
167
|
+
if (!stat.isFile() || (stat.mode & 0o111) === 0)
|
|
168
|
+
fail(`${label} must be an executable regular file`);
|
|
169
|
+
}
|
|
170
|
+
catch (error) {
|
|
171
|
+
if (error instanceof VerifierAuthorityError)
|
|
172
|
+
throw error;
|
|
173
|
+
fail(`${label} is unavailable`);
|
|
174
|
+
}
|
|
175
|
+
return resolved;
|
|
176
|
+
}
|
|
177
|
+
function strictArray(value, label, minimum, maximum) {
|
|
178
|
+
if (!Array.isArray(value) || Object.getPrototypeOf(value) !== Array.prototype || value.length < minimum || value.length > maximum || Object.getOwnPropertySymbols(value).length > 0)
|
|
179
|
+
fail(`${label} must be bounded`);
|
|
180
|
+
const keys = Object.getOwnPropertyNames(value).filter(key => key !== 'length');
|
|
181
|
+
if (keys.length !== value.length || keys.some((key, index) => key !== String(index) || !Object.hasOwn(Object.getOwnPropertyDescriptor(value, key), 'value')))
|
|
182
|
+
fail(`${label} has an unsafe shape`);
|
|
183
|
+
return value;
|
|
184
|
+
}
|
|
185
|
+
function isolatedTestSets(value) {
|
|
186
|
+
const sets = strictArray(value, 'isolated runner testSets', 1, MAX_ISOLATED_TEST_SETS);
|
|
187
|
+
const ids = new Set();
|
|
188
|
+
let cases = 0;
|
|
189
|
+
let bytes = 0;
|
|
190
|
+
const output = sets.map((entry, index) => {
|
|
191
|
+
const set = object(entry, `isolated runner testSets[${index}]`);
|
|
192
|
+
exactKeys(set, ['id', 'cases'], 'isolated runner test set');
|
|
193
|
+
const id = string(set.id, 'isolated runner test set id');
|
|
194
|
+
if (ids.has(id))
|
|
195
|
+
fail('isolated runner test set ids must be unique');
|
|
196
|
+
ids.add(id);
|
|
197
|
+
const casesInput = strictArray(set.cases, 'isolated runner test cases', 1, MAX_ISOLATED_CASES);
|
|
198
|
+
const parsed = casesInput.map((candidate, caseIndex) => {
|
|
199
|
+
cases += 1;
|
|
200
|
+
if (cases > MAX_ISOLATED_TEST_CASES)
|
|
201
|
+
fail('isolated runner test cases exceed total bound');
|
|
202
|
+
const item = object(candidate, `isolated runner testSets[${index}].cases[${caseIndex}]`);
|
|
203
|
+
exactKeys(item, ['stdin', 'expectedStdout', 'expectedExitCode'], 'isolated runner test case');
|
|
204
|
+
const stdin = string(item.stdin, 'isolated runner stdin', true);
|
|
205
|
+
const expectedStdout = string(item.expectedStdout, 'isolated runner expectedStdout', true);
|
|
206
|
+
bytes += Buffer.byteLength(stdin, 'utf8') + Buffer.byteLength(expectedStdout, 'utf8');
|
|
207
|
+
if (bytes > MAX_ISOLATED_TEST_BYTES)
|
|
208
|
+
fail('isolated runner test text exceeds total bound');
|
|
209
|
+
const expectedExitCode = item.expectedExitCode;
|
|
210
|
+
if (typeof expectedExitCode !== 'number' || !Number.isSafeInteger(expectedExitCode) || expectedExitCode < 0 || expectedExitCode > 255)
|
|
211
|
+
fail('isolated runner expectedExitCode is invalid');
|
|
212
|
+
return freeze({ stdin, expectedStdout, expectedExitCode });
|
|
213
|
+
});
|
|
214
|
+
return freeze({ id, cases: freeze(parsed) });
|
|
215
|
+
});
|
|
216
|
+
return freeze(output);
|
|
217
|
+
}
|
|
218
|
+
/** Validates Host-owned driver configuration, captures executable identity, and freezes it. */
|
|
219
|
+
export function createVerifierAuthorities(config) {
|
|
220
|
+
const root = object(config, 'verifier authorities');
|
|
221
|
+
exactKeys(root, ['authorities'], 'verifier authorities');
|
|
222
|
+
if (!Array.isArray(root.authorities) || root.authorities.length === 0 || root.authorities.length > MAX_AUTHORITIES)
|
|
223
|
+
fail('authorities must be a bounded non-empty array');
|
|
224
|
+
const identifiers = new Set();
|
|
225
|
+
const authorities = root.authorities.map((entry, index) => {
|
|
226
|
+
const item = object(entry, `authorities[${index}]`);
|
|
227
|
+
const kind = string(item.kind, `authorities[${index}].kind`);
|
|
228
|
+
const id = string(item.id, `authorities[${index}].id`);
|
|
229
|
+
if (identifiers.has(`${kind}\0${id}`))
|
|
230
|
+
fail('authority kind/id must be unique');
|
|
231
|
+
identifiers.add(`${kind}\0${id}`);
|
|
232
|
+
if (kind === 'runner') {
|
|
233
|
+
allowedKeys(item, ['kind', 'id', 'executable', 'fixedArgs', 'timeoutMs', 'maxOutputBytes'], ['environment'], `runner authority ${id}`);
|
|
234
|
+
const executable = string(item.executable, 'runner executable');
|
|
235
|
+
if (!isAbsolute(executable) || normalize(executable) !== executable)
|
|
236
|
+
fail('runner executable must be an absolute normalized path');
|
|
237
|
+
if (!Array.isArray(item.fixedArgs) || item.fixedArgs.length > 64)
|
|
238
|
+
fail('runner fixedArgs must be bounded');
|
|
239
|
+
const fixedArgs = item.fixedArgs.map((value, arg) => string(value, `runner fixedArgs[${arg}]`, true));
|
|
240
|
+
const environmentValue = item.environment === undefined ? {} : object(item.environment, 'runner environment');
|
|
241
|
+
const environment = {};
|
|
242
|
+
for (const [key, value] of Object.entries(environmentValue)) {
|
|
243
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(key))
|
|
244
|
+
fail('runner environment key is invalid');
|
|
245
|
+
environment[key] = string(value, `runner environment.${key}`, true);
|
|
246
|
+
}
|
|
247
|
+
const bare = { kind: 'runner', id, executable, fixedArgs, timeoutMs: whole(item.timeoutMs, 'runner timeoutMs'), maxOutputBytes: whole(item.maxOutputBytes, 'runner maxOutputBytes', 1_048_576), environment, executableDigest: shaFile(executable) };
|
|
248
|
+
return freeze({ ...bare, digest: digest(bare) });
|
|
249
|
+
}
|
|
250
|
+
if (kind === 'document') {
|
|
251
|
+
allowedKeys(item, ['kind', 'id', 'sources', 'timeoutMs', 'maxResponseBytes'], ['allowHttpLoopback'], `document authority ${id}`);
|
|
252
|
+
if (!Array.isArray(item.sources) || item.sources.length === 0 || item.sources.length > 64)
|
|
253
|
+
fail('document sources must be bounded and non-empty');
|
|
254
|
+
if (item.allowHttpLoopback !== undefined && typeof item.allowHttpLoopback !== 'boolean')
|
|
255
|
+
fail('document allowHttpLoopback must be boolean');
|
|
256
|
+
const allowHttpLoopback = item.allowHttpLoopback === true;
|
|
257
|
+
const sourceIds = new Set();
|
|
258
|
+
const sources = item.sources.map((source, sourceIndex) => {
|
|
259
|
+
const row = object(source, `document sources[${sourceIndex}]`);
|
|
260
|
+
exactKeys(row, ['id', 'url'], 'document source');
|
|
261
|
+
const sourceId = string(row.id, 'document source id');
|
|
262
|
+
if (sourceIds.has(sourceId))
|
|
263
|
+
fail('document source id must be unique');
|
|
264
|
+
sourceIds.add(sourceId);
|
|
265
|
+
return freeze({ id: sourceId, url: safeUrl(row.url, 'document source url', allowHttpLoopback) });
|
|
266
|
+
});
|
|
267
|
+
const bare = { kind: 'document', id, sources, timeoutMs: whole(item.timeoutMs, 'document timeoutMs'), maxResponseBytes: whole(item.maxResponseBytes, 'document maxResponseBytes', 1_048_576), allowHttpLoopback };
|
|
268
|
+
return freeze({ ...bare, digest: digest(bare) });
|
|
269
|
+
}
|
|
270
|
+
if (kind === 'readback') {
|
|
271
|
+
allowedKeys(item, ['kind', 'id', 'urlTemplate', 'objectIdPointer', 'timeoutMs', 'maxResponseBytes'], ['revisionPointer', 'allowHttpLoopback'], `readback authority ${id}`);
|
|
272
|
+
if (item.allowHttpLoopback !== undefined && typeof item.allowHttpLoopback !== 'boolean')
|
|
273
|
+
fail('readback allowHttpLoopback must be boolean');
|
|
274
|
+
const allowHttpLoopback = item.allowHttpLoopback === true;
|
|
275
|
+
const urlTemplate = readbackTemplate(item.urlTemplate, allowHttpLoopback);
|
|
276
|
+
const revisionPointer = item.revisionPointer === undefined ? undefined : validPointer(item.revisionPointer, 'readback revisionPointer');
|
|
277
|
+
const bare = { kind: 'readback', id, urlTemplate, objectIdPointer: validPointer(item.objectIdPointer, 'readback objectIdPointer'), ...(revisionPointer === undefined ? {} : { revisionPointer }), timeoutMs: whole(item.timeoutMs, 'readback timeoutMs'), maxResponseBytes: whole(item.maxResponseBytes, 'readback maxResponseBytes', 1_048_576), allowHttpLoopback };
|
|
278
|
+
return freeze({ ...bare, digest: digest(bare) });
|
|
279
|
+
}
|
|
280
|
+
if (kind === 'repository-readback') {
|
|
281
|
+
exactKeys(item, ['kind', 'id', 'grantId', 'grantRevision', 'repository', 'branch', 'baseBranch', 'requiredChecks', 'reviewerIds', 'minApprovals', 'timeoutMs', 'freshnessMs'], 'repository readback authority');
|
|
282
|
+
const checks = strictArray(item.requiredChecks, 'required checks', 1, 20).map(value => {
|
|
283
|
+
const check = object(value, 'required check');
|
|
284
|
+
exactKeys(check, ['name', 'appId'], 'required check');
|
|
285
|
+
const name = string(check.name, 'check name');
|
|
286
|
+
if (name.length > 256 || name.trim() !== name || /[\p{Cc}]/u.test(name))
|
|
287
|
+
fail('invalid check name');
|
|
288
|
+
return { name, appId: whole(check.appId, 'check app id', Number.MAX_SAFE_INTEGER) };
|
|
289
|
+
});
|
|
290
|
+
if (new Set(checks.map(check => JSON.stringify(check))).size !== checks.length)
|
|
291
|
+
fail('duplicate required check');
|
|
292
|
+
const reviewerIds = strictArray(item.reviewerIds, 'reviewer ids', 0, 30).map(value => whole(value, 'reviewer id', Number.MAX_SAFE_INTEGER));
|
|
293
|
+
if (new Set(reviewerIds).size !== reviewerIds.length || !Number.isSafeInteger(item.minApprovals) || item.minApprovals < 0 || item.minApprovals > reviewerIds.length)
|
|
294
|
+
fail('invalid required approvals');
|
|
295
|
+
const repository = string(item.repository, 'repository'), branch = string(item.branch, 'branch'), baseBranch = string(item.baseBranch, 'baseBranch');
|
|
296
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9-]*\/[A-Za-z0-9][A-Za-z0-9._-]*$/u.test(repository) || branch === baseBranch
|
|
297
|
+
|| `${repository}:${branch}`.length > 256 || [branch, baseBranch].some(value => value.length > 256 || value.trim() !== value || /[\p{Cc}]/u.test(value)))
|
|
298
|
+
fail('invalid repository target');
|
|
299
|
+
const bare = { kind: 'repository-readback', id, grantId: string(item.grantId, 'grantId'), grantRevision: whole(item.grantRevision, 'grant revision', Number.MAX_SAFE_INTEGER), repository, branch, baseBranch, requiredChecks: checks, reviewerIds, minApprovals: item.minApprovals, timeoutMs: whole(item.timeoutMs, 'repository timeout', 30_000), freshnessMs: whole(item.freshnessMs, 'repository freshness', 60_000) };
|
|
300
|
+
return freeze({ ...bare, digest: digest(bare) });
|
|
301
|
+
}
|
|
302
|
+
if (kind === 'isolated-runner') {
|
|
303
|
+
allowedKeys(item, ['kind', 'id', 'stateRoot', 'image', 'dockerPath', 'command', 'expiresAt', 'maxRuns', 'maxTotalDurationMs', 'maxDurationMs', 'maxOutputBytes', 'testSets'], [], `isolated runner authority ${id}`);
|
|
304
|
+
const image = string(item.image, 'isolated runner image');
|
|
305
|
+
if (!IMAGE.test(image))
|
|
306
|
+
fail('isolated runner image must be SHA-256 pinned');
|
|
307
|
+
const expiresAt = item.expiresAt;
|
|
308
|
+
if (typeof expiresAt !== 'number' || !Number.isSafeInteger(expiresAt) || expiresAt < 1)
|
|
309
|
+
fail('isolated runner expiresAt must be an absolute timestamp');
|
|
310
|
+
const maxTotalDurationMs = whole(item.maxTotalDurationMs, 'isolated runner maxTotalDurationMs', 86_400_000);
|
|
311
|
+
const maxDurationMs = whole(item.maxDurationMs, 'isolated runner maxDurationMs', 300_000);
|
|
312
|
+
if (maxDurationMs > maxTotalDurationMs)
|
|
313
|
+
fail('isolated runner maxDurationMs must not exceed maxTotalDurationMs');
|
|
314
|
+
const bare = { kind: 'isolated-runner', id, stateRoot: canonicalDirectory(item.stateRoot, 'isolated runner stateRoot'), image,
|
|
315
|
+
dockerPath: canonicalExecutable(item.dockerPath, 'isolated runner dockerPath'), command: string(item.command, 'isolated runner command'), expiresAt,
|
|
316
|
+
maxRuns: whole(item.maxRuns, 'isolated runner maxRuns', 10_000), maxTotalDurationMs, maxDurationMs,
|
|
317
|
+
maxOutputBytes: whole(item.maxOutputBytes, 'isolated runner maxOutputBytes', 262_144), testSets: isolatedTestSets(item.testSets) };
|
|
318
|
+
return freeze({ ...bare, digest: digest(bare) });
|
|
319
|
+
}
|
|
320
|
+
return fail(`unknown authority kind ${kind}`);
|
|
321
|
+
});
|
|
322
|
+
return freeze(authorities);
|
|
323
|
+
}
|
|
324
|
+
function unknown(criterionId, reason, artifactDigest) {
|
|
325
|
+
return freeze({ criterionId, status: 'unknown', reason, evidence: [], ...(artifactDigest === undefined ? {} : { artifactDigest }) });
|
|
326
|
+
}
|
|
327
|
+
function failed(criterionId, reason, evidence = [], artifactDigest) {
|
|
328
|
+
return freeze({ criterionId, status: 'failed', reason, evidence, ...(artifactDigest === undefined ? {} : { artifactDigest }) });
|
|
329
|
+
}
|
|
330
|
+
function passed(criterionId, evidence, artifactDigest) {
|
|
331
|
+
return freeze({ criterionId, status: 'passed', reason: 'verified', evidence, ...(artifactDigest === undefined ? {} : { artifactDigest }) });
|
|
332
|
+
}
|
|
333
|
+
function authorityFor(criterion, authorities) {
|
|
334
|
+
return authorities.find(authority => authority.id === criterion.authority.id && authority.digest === criterion.authority.digest);
|
|
335
|
+
}
|
|
336
|
+
async function artifact(workspace, path, signal) {
|
|
337
|
+
if (signal.aborted)
|
|
338
|
+
throw new Error('artifact-aborted');
|
|
339
|
+
if (!path || isAbsolute(path) || normalize(path) !== path || path.split(/[\\/]/u).includes('..'))
|
|
340
|
+
throw new Error('artifact-path-invalid');
|
|
341
|
+
const canonicalWorkspace = await realpath(workspace);
|
|
342
|
+
const candidate = resolve(canonicalWorkspace, path);
|
|
343
|
+
if (relative(canonicalWorkspace, candidate) === '' || !candidate.startsWith(canonicalWorkspace + sep))
|
|
344
|
+
throw new Error('artifact-path-escape');
|
|
345
|
+
const parts = relative(canonicalWorkspace, candidate).split(sep);
|
|
346
|
+
let current = canonicalWorkspace;
|
|
347
|
+
for (const part of parts) {
|
|
348
|
+
current = resolve(current, part);
|
|
349
|
+
if ((await lstat(current)).isSymbolicLink())
|
|
350
|
+
throw new Error('artifact-symlink');
|
|
351
|
+
}
|
|
352
|
+
const resolved = await realpath(candidate);
|
|
353
|
+
if (!resolved.startsWith(canonicalWorkspace + sep))
|
|
354
|
+
throw new Error('artifact-path-escape');
|
|
355
|
+
// lstat each segment after realpath so a symlink cannot enter the workspace.
|
|
356
|
+
current = canonicalWorkspace;
|
|
357
|
+
for (const part of parts) {
|
|
358
|
+
current = resolve(current, part);
|
|
359
|
+
const stat = await lstat(current);
|
|
360
|
+
if (stat.isSymbolicLink())
|
|
361
|
+
throw new Error('artifact-symlink');
|
|
362
|
+
}
|
|
363
|
+
const handle = await open(resolved, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
364
|
+
try {
|
|
365
|
+
const stat = await handle.stat();
|
|
366
|
+
if (!stat.isFile() || stat.size < 0 || stat.size > MAX_ARTIFACT_BYTES)
|
|
367
|
+
throw new Error('artifact-not-regular-or-oversize');
|
|
368
|
+
const bytes = Buffer.alloc(stat.size);
|
|
369
|
+
let offset = 0;
|
|
370
|
+
while (offset < bytes.length) {
|
|
371
|
+
if (signal.aborted)
|
|
372
|
+
throw new Error('artifact-aborted');
|
|
373
|
+
const reading = await handle.read(bytes, offset, bytes.length - offset, offset);
|
|
374
|
+
if (reading.bytesRead === 0)
|
|
375
|
+
break;
|
|
376
|
+
offset += reading.bytesRead;
|
|
377
|
+
}
|
|
378
|
+
const after = await handle.stat();
|
|
379
|
+
if (offset !== stat.size || after.dev !== stat.dev || after.ino !== stat.ino || after.size !== stat.size)
|
|
380
|
+
throw new Error('artifact-altered');
|
|
381
|
+
return { path: resolved, bytes, digest: createHash('sha256').update(bytes).digest('hex') };
|
|
382
|
+
}
|
|
383
|
+
finally {
|
|
384
|
+
await handle.close().catch(() => { });
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
async function snapshotArtifact(bytes, originalPath) {
|
|
388
|
+
const directory = await mkdtemp(join(tmpdir(), 'assistant-verifier-snapshot-'));
|
|
389
|
+
const path = join(directory, `artifact${extname(originalPath)}`);
|
|
390
|
+
try {
|
|
391
|
+
await writeFile(path, bytes, { flag: 'wx', mode: 0o400 });
|
|
392
|
+
await chmod(path, 0o400);
|
|
393
|
+
return { path, remove: async () => { await rm(directory, { recursive: true, force: true }); } };
|
|
394
|
+
}
|
|
395
|
+
catch (error) {
|
|
396
|
+
await rm(directory, { recursive: true, force: true }).catch(() => { });
|
|
397
|
+
throw error;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
function pointer(document, value) {
|
|
401
|
+
let current = document;
|
|
402
|
+
if (value === '')
|
|
403
|
+
return current;
|
|
404
|
+
for (const token of value.slice(1).split('/')) {
|
|
405
|
+
const key = token.replace(/~1/gu, '/').replace(/~0/gu, '~');
|
|
406
|
+
if (key === '__proto__' || key === 'constructor' || key === 'prototype' || typeof current !== 'object' || current === null || !Object.hasOwn(current, key))
|
|
407
|
+
throw new Error('json-pointer-missing');
|
|
408
|
+
current = current[key];
|
|
409
|
+
}
|
|
410
|
+
return current;
|
|
411
|
+
}
|
|
412
|
+
async function responseBytes(url, maximum, signal) {
|
|
413
|
+
const response = await fetch(url, { method: 'GET', redirect: 'error', signal, headers: { accept: 'application/json,text/plain' } });
|
|
414
|
+
if (!response.ok || response.body === null)
|
|
415
|
+
throw new Error('http-response-invalid');
|
|
416
|
+
const declared = response.headers.get('content-length');
|
|
417
|
+
if (declared !== null && Number(declared) > maximum) {
|
|
418
|
+
await response.body.cancel().catch(() => { });
|
|
419
|
+
throw new Error('http-body-oversize');
|
|
420
|
+
}
|
|
421
|
+
const reader = response.body.getReader();
|
|
422
|
+
const chunks = [];
|
|
423
|
+
let size = 0;
|
|
424
|
+
try {
|
|
425
|
+
while (true) {
|
|
426
|
+
const entry = await reader.read();
|
|
427
|
+
if (entry.done)
|
|
428
|
+
break;
|
|
429
|
+
size += entry.value.byteLength;
|
|
430
|
+
if (size > maximum)
|
|
431
|
+
throw new Error('http-body-oversize');
|
|
432
|
+
chunks.push(Buffer.from(entry.value));
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
finally {
|
|
436
|
+
await reader.cancel().catch(() => { });
|
|
437
|
+
}
|
|
438
|
+
return Buffer.concat(chunks, size);
|
|
439
|
+
}
|
|
440
|
+
function withDeadline(parent, duration) {
|
|
441
|
+
const controller = new AbortController();
|
|
442
|
+
const abort = () => controller.abort(parent.reason);
|
|
443
|
+
if (parent.aborted)
|
|
444
|
+
abort();
|
|
445
|
+
else
|
|
446
|
+
parent.addEventListener('abort', abort, { once: true });
|
|
447
|
+
const timer = setTimeout(() => controller.abort(new Error('verification-timeout')), duration);
|
|
448
|
+
timer.unref?.();
|
|
449
|
+
return { signal: controller.signal, close: () => { clearTimeout(timer); parent.removeEventListener('abort', abort); } };
|
|
450
|
+
}
|
|
451
|
+
async function run(authority, input, artifactPath, workspace, signal) {
|
|
452
|
+
return new Promise((resolvePromise, reject) => {
|
|
453
|
+
if (signal.aborted) {
|
|
454
|
+
resolvePromise({ code: null, stdout: Buffer.alloc(0), timedOut: true, oversize: false });
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
const grouped = process.platform !== 'win32';
|
|
458
|
+
const child = spawn(authority.executable, [...authority.fixedArgs, artifactPath], { cwd: workspace, env: { ...authority.environment }, shell: false, detached: grouped, stdio: ['pipe', 'pipe', 'ignore'] });
|
|
459
|
+
const output = [];
|
|
460
|
+
let size = 0;
|
|
461
|
+
let oversize = false;
|
|
462
|
+
let timedOut = false;
|
|
463
|
+
let settled = false;
|
|
464
|
+
const releasePipes = () => {
|
|
465
|
+
child.stdin?.destroy();
|
|
466
|
+
child.stdout?.destroy();
|
|
467
|
+
};
|
|
468
|
+
const terminate = (timeout = true) => {
|
|
469
|
+
if (timeout)
|
|
470
|
+
timedOut = true;
|
|
471
|
+
try {
|
|
472
|
+
if (grouped && child.pid !== undefined)
|
|
473
|
+
process.kill(-child.pid, 'SIGKILL');
|
|
474
|
+
else
|
|
475
|
+
child.kill('SIGKILL');
|
|
476
|
+
}
|
|
477
|
+
catch {
|
|
478
|
+
child.kill('SIGKILL');
|
|
479
|
+
}
|
|
480
|
+
};
|
|
481
|
+
const settle = (result) => {
|
|
482
|
+
if (settled)
|
|
483
|
+
return;
|
|
484
|
+
settled = true;
|
|
485
|
+
signal.removeEventListener('abort', onAbort);
|
|
486
|
+
releasePipes();
|
|
487
|
+
resolvePromise(result);
|
|
488
|
+
};
|
|
489
|
+
const onAbort = () => { terminate(); settle({ code: null, stdout: Buffer.concat(output), timedOut, oversize }); };
|
|
490
|
+
const finishError = (error) => { if (settled)
|
|
491
|
+
return; settled = true; signal.removeEventListener('abort', onAbort); terminate(); releasePipes(); reject(error); };
|
|
492
|
+
if (signal.aborted)
|
|
493
|
+
onAbort();
|
|
494
|
+
else
|
|
495
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
496
|
+
child.stdout.on('data', (chunk) => {
|
|
497
|
+
if (settled)
|
|
498
|
+
return;
|
|
499
|
+
size += chunk.length;
|
|
500
|
+
if (size > authority.maxOutputBytes) {
|
|
501
|
+
oversize = true;
|
|
502
|
+
terminate(false);
|
|
503
|
+
settle({ code: null, stdout: Buffer.concat(output), timedOut, oversize });
|
|
504
|
+
}
|
|
505
|
+
else
|
|
506
|
+
output.push(chunk);
|
|
507
|
+
});
|
|
508
|
+
child.once('error', error => finishError(error));
|
|
509
|
+
child.once('close', code => settle({ code, stdout: Buffer.concat(output), timedOut, oversize }));
|
|
510
|
+
child.stdin.once('error', error => { if (error.code !== 'EPIPE')
|
|
511
|
+
finishError(error); });
|
|
512
|
+
child.stdin.end(input, 'utf8');
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
async function verifyProcess(criterion, authority, workspace, signal) {
|
|
516
|
+
let initial;
|
|
517
|
+
try {
|
|
518
|
+
initial = await artifact(workspace, criterion.artifactPath, signal);
|
|
519
|
+
}
|
|
520
|
+
catch {
|
|
521
|
+
return unknown(criterion.id, 'artifact-unavailable');
|
|
522
|
+
}
|
|
523
|
+
try {
|
|
524
|
+
if (shaFile(authority.executable) !== authority.executableDigest)
|
|
525
|
+
return unknown(criterion.id, 'executable-identity-mismatch', initial.digest);
|
|
526
|
+
}
|
|
527
|
+
catch {
|
|
528
|
+
return unknown(criterion.id, 'executable-unavailable', initial.digest);
|
|
529
|
+
}
|
|
530
|
+
const deadline = withDeadline(signal, authority.timeoutMs);
|
|
531
|
+
let snapshot;
|
|
532
|
+
try {
|
|
533
|
+
// This is a one-artifact byte snapshot. The original workspace remains cwd, but imports resolved from the artifact path use the temporary snapshot location.
|
|
534
|
+
snapshot = await snapshotArtifact(initial.bytes, initial.path);
|
|
535
|
+
const result = await run(authority, criterion.stdin, snapshot.path, workspace, deadline.signal);
|
|
536
|
+
const final = await artifact(workspace, criterion.artifactPath, signal).catch(() => undefined);
|
|
537
|
+
if (final === undefined || final.digest !== initial.digest)
|
|
538
|
+
return unknown(criterion.id, 'artifact-altered', initial.digest);
|
|
539
|
+
if (deadline.signal.aborted)
|
|
540
|
+
return unknown(criterion.id, 'verification-aborted', initial.digest);
|
|
541
|
+
if (result.oversize)
|
|
542
|
+
return unknown(criterion.id, 'process-output-oversize', initial.digest);
|
|
543
|
+
if (result.timedOut)
|
|
544
|
+
return unknown(criterion.id, 'process-timeout', initial.digest);
|
|
545
|
+
if (result.code !== criterion.expectedExitCode)
|
|
546
|
+
return failed(criterion.id, 'unexpected-exit-code', [], initial.digest);
|
|
547
|
+
if (result.stdout.toString('utf8') !== criterion.expectedStdout)
|
|
548
|
+
return failed(criterion.id, 'unexpected-stdout', [], initial.digest);
|
|
549
|
+
return passed(criterion.id, [{ kind: 'process-output', ref: criterion.artifactPath, digest: createHash('sha256').update(result.stdout).digest('hex') }], initial.digest);
|
|
550
|
+
}
|
|
551
|
+
catch {
|
|
552
|
+
return unknown(criterion.id, signal.aborted ? 'verification-aborted' : 'process-io-failed', initial.digest);
|
|
553
|
+
}
|
|
554
|
+
finally {
|
|
555
|
+
deadline.close();
|
|
556
|
+
await snapshot?.remove();
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
async function verifyDocument(criterion, authority, workspace, signal) {
|
|
560
|
+
let target;
|
|
561
|
+
try {
|
|
562
|
+
target = await artifact(workspace, criterion.artifactPath, signal);
|
|
563
|
+
}
|
|
564
|
+
catch {
|
|
565
|
+
return unknown(criterion.id, 'artifact-unavailable');
|
|
566
|
+
}
|
|
567
|
+
const text = target.bytes.toString('utf8');
|
|
568
|
+
let outcome;
|
|
569
|
+
if (criterion.requiredText.some(required => !text.includes(required)))
|
|
570
|
+
outcome = failed(criterion.id, 'required-text-missing', [], target.digest);
|
|
571
|
+
const deadline = withDeadline(signal, authority.timeoutMs);
|
|
572
|
+
try {
|
|
573
|
+
const evidence = [];
|
|
574
|
+
for (const quote of criterion.quotes) {
|
|
575
|
+
if (outcome !== undefined)
|
|
576
|
+
break;
|
|
577
|
+
const source = authority.sources.find(item => item.id === quote.sourceId);
|
|
578
|
+
if (source === undefined || !text.includes(quote.quote) || !text.includes(source.url)) {
|
|
579
|
+
outcome = failed(criterion.id, 'citation-missing', evidence, target.digest);
|
|
580
|
+
break;
|
|
581
|
+
}
|
|
582
|
+
const body = await responseBytes(source.url, authority.maxResponseBytes, deadline.signal);
|
|
583
|
+
const observed = createHash('sha256').update(body).digest('hex');
|
|
584
|
+
if (observed !== quote.sourceSha256 || !body.toString('utf8').includes(quote.quote)) {
|
|
585
|
+
outcome = failed(criterion.id, 'citation-mismatch', evidence, target.digest);
|
|
586
|
+
break;
|
|
587
|
+
}
|
|
588
|
+
evidence.push({ kind: 'source', ref: source.url, digest: observed });
|
|
589
|
+
}
|
|
590
|
+
const final = await artifact(workspace, criterion.artifactPath, signal).catch(() => undefined);
|
|
591
|
+
if (final === undefined || final.digest !== target.digest)
|
|
592
|
+
return unknown(criterion.id, 'artifact-altered', target.digest);
|
|
593
|
+
if (deadline.signal.aborted)
|
|
594
|
+
return unknown(criterion.id, 'verification-aborted', target.digest);
|
|
595
|
+
return outcome ?? passed(criterion.id, evidence, target.digest);
|
|
596
|
+
}
|
|
597
|
+
catch {
|
|
598
|
+
return unknown(criterion.id, signal.aborted ? 'verification-aborted' : 'source-io-failed', target.digest);
|
|
599
|
+
}
|
|
600
|
+
finally {
|
|
601
|
+
deadline.close();
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
async function verifyReadback(criterion, authority, signal) {
|
|
605
|
+
const deadline = withDeadline(signal, authority.timeoutMs);
|
|
606
|
+
try {
|
|
607
|
+
const url = authority.urlTemplate.replace('{id}', encodeURIComponent(criterion.objectId).replace(/\./gu, '%2E'));
|
|
608
|
+
const body = await responseBytes(url, authority.maxResponseBytes, deadline.signal);
|
|
609
|
+
const document = JSON.parse(body.toString('utf8'));
|
|
610
|
+
acceptanceCanonicalJson(document);
|
|
611
|
+
if (pointer(document, authority.objectIdPointer) !== criterion.objectId)
|
|
612
|
+
return failed(criterion.id, 'readback-object-mismatch');
|
|
613
|
+
if (criterion.expectedRevision !== undefined && (authority.revisionPointer === undefined || pointer(document, authority.revisionPointer) !== criterion.expectedRevision))
|
|
614
|
+
return failed(criterion.id, 'readback-revision-mismatch');
|
|
615
|
+
for (const expected of criterion.expected)
|
|
616
|
+
if (acceptanceCanonicalJson(pointer(document, expected.pointer)) !== acceptanceCanonicalJson(expected.value))
|
|
617
|
+
return failed(criterion.id, 'readback-value-mismatch');
|
|
618
|
+
if (deadline.signal.aborted)
|
|
619
|
+
return unknown(criterion.id, 'verification-aborted');
|
|
620
|
+
return passed(criterion.id, [{ kind: 'readback', ref: url, digest: createHash('sha256').update(body).digest('hex') }]);
|
|
621
|
+
}
|
|
622
|
+
catch {
|
|
623
|
+
return unknown(criterion.id, signal.aborted ? 'verification-aborted' : 'readback-io-failed');
|
|
624
|
+
}
|
|
625
|
+
finally {
|
|
626
|
+
deadline.close();
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
function snapshotMatches(source, criterion) {
|
|
630
|
+
return source.path === criterion.artifactPath
|
|
631
|
+
&& typeof source.jobId === 'string' && STABLE_REF.test(source.jobId)
|
|
632
|
+
&& typeof source.requestDigest === 'string' && /^[a-f0-9]{64}$/u.test(source.requestDigest)
|
|
633
|
+
&& typeof source.content === 'string' && Buffer.byteLength(source.content, 'utf8') <= MAX_ARTIFACT_BYTES
|
|
634
|
+
&& typeof source.sha256 === 'string' && /^[a-f0-9]{64}$/u.test(source.sha256)
|
|
635
|
+
&& createHash('sha256').update(source.content, 'utf8').digest('hex') === source.sha256;
|
|
636
|
+
}
|
|
637
|
+
function isolatedKey(contract, criterion, source, authority, caseIndex) {
|
|
638
|
+
return createHash('sha256').update(acceptanceCanonicalJson([
|
|
639
|
+
contract.id, contract.digest, criterion.id, source.jobId, source.requestDigest, source.sha256,
|
|
640
|
+
authority.digest, criterion.testSetId, caseIndex,
|
|
641
|
+
])).digest('hex');
|
|
642
|
+
}
|
|
643
|
+
async function verifyIsolated(criterion, authority, contract, context, signal) {
|
|
644
|
+
if (context === undefined)
|
|
645
|
+
return unknown(criterion.id, 'isolated-context-unavailable');
|
|
646
|
+
if (authority.expiresAt <= Date.now())
|
|
647
|
+
return unknown(criterion.id, 'isolated-authority-expired');
|
|
648
|
+
const testSet = authority.testSets.find(entry => entry.id === criterion.testSetId);
|
|
649
|
+
if (testSet === undefined)
|
|
650
|
+
return unknown(criterion.id, 'isolated-test-set-unavailable');
|
|
651
|
+
let initial;
|
|
652
|
+
try {
|
|
653
|
+
initial = await context.readArtifact(contract, criterion.artifactPath);
|
|
654
|
+
}
|
|
655
|
+
catch {
|
|
656
|
+
return unknown(criterion.id, 'isolated-artifact-unavailable');
|
|
657
|
+
}
|
|
658
|
+
if (!snapshotMatches(initial, criterion))
|
|
659
|
+
return unknown(criterion.id, 'isolated-artifact-invalid');
|
|
660
|
+
const total = withDeadline(signal, Math.min(contract.bounds.maxDurationMs, authority.maxTotalDurationMs));
|
|
661
|
+
let outcome;
|
|
662
|
+
const runs = [];
|
|
663
|
+
try {
|
|
664
|
+
for (let index = 0; index < testSet.cases.length; index += 1) {
|
|
665
|
+
if (total.signal.aborted) {
|
|
666
|
+
outcome = unknown(criterion.id, 'verification-aborted', initial.sha256);
|
|
667
|
+
break;
|
|
668
|
+
}
|
|
669
|
+
const entry = testSet.cases[index];
|
|
670
|
+
const deadline = withDeadline(total.signal, authority.maxDurationMs);
|
|
671
|
+
const key = isolatedKey(contract, criterion, initial, authority, index);
|
|
672
|
+
let observed;
|
|
673
|
+
try {
|
|
674
|
+
observed = await context.run(authority, key, initial.content, entry.stdin, deadline.signal);
|
|
675
|
+
}
|
|
676
|
+
catch {
|
|
677
|
+
outcome = unknown(criterion.id, deadline.signal.aborted ? 'verification-aborted' : 'isolated-run-unavailable', initial.sha256);
|
|
678
|
+
deadline.close();
|
|
679
|
+
break;
|
|
680
|
+
}
|
|
681
|
+
deadline.close();
|
|
682
|
+
if (typeof observed.jobId !== 'string' || !STABLE_REF.test(observed.jobId) || (observed.status !== 'succeeded' && observed.status !== 'failed') || !observed.quiescent) {
|
|
683
|
+
outcome = unknown(criterion.id, 'isolated-run-unknown', initial.sha256);
|
|
684
|
+
break;
|
|
685
|
+
}
|
|
686
|
+
runs.push({ jobId: observed.jobId, key });
|
|
687
|
+
if (typeof observed.stdout !== 'string' || typeof observed.stderr !== 'string'
|
|
688
|
+
|| Buffer.byteLength(observed.stdout, 'utf8') > authority.maxOutputBytes || Buffer.byteLength(observed.stderr, 'utf8') > authority.maxOutputBytes) {
|
|
689
|
+
outcome = unknown(criterion.id, 'isolated-output-invalid', initial.sha256);
|
|
690
|
+
break;
|
|
691
|
+
}
|
|
692
|
+
if (!Number.isSafeInteger(observed.exitCode)) {
|
|
693
|
+
outcome = unknown(criterion.id, 'isolated-exit-unavailable', initial.sha256);
|
|
694
|
+
break;
|
|
695
|
+
}
|
|
696
|
+
if (observed.exitCode !== entry.expectedExitCode) {
|
|
697
|
+
outcome = failed(criterion.id, 'isolated-unexpected-exit-code', [], initial.sha256);
|
|
698
|
+
break;
|
|
699
|
+
}
|
|
700
|
+
if (observed.stdout !== entry.expectedStdout) {
|
|
701
|
+
outcome = failed(criterion.id, 'isolated-unexpected-stdout', [], initial.sha256);
|
|
702
|
+
break;
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
let final;
|
|
706
|
+
try {
|
|
707
|
+
final = await context.readArtifact(contract, criterion.artifactPath);
|
|
708
|
+
}
|
|
709
|
+
catch {
|
|
710
|
+
return unknown(criterion.id, 'isolated-artifact-unavailable', initial.sha256);
|
|
711
|
+
}
|
|
712
|
+
if (!snapshotMatches(final, criterion) || final.jobId !== initial.jobId || final.requestDigest !== initial.requestDigest || final.sha256 !== initial.sha256 || final.content !== initial.content) {
|
|
713
|
+
return unknown(criterion.id, 'isolated-artifact-altered', initial.sha256);
|
|
714
|
+
}
|
|
715
|
+
if (total.signal.aborted)
|
|
716
|
+
return unknown(criterion.id, 'verification-aborted', initial.sha256);
|
|
717
|
+
return outcome ?? passed(criterion.id, [{ kind: 'isolated-artifact', ref: initial.jobId, digest: initial.sha256 },
|
|
718
|
+
{ kind: 'isolated-verification-jobs', ref: authority.id, digest: digest(runs) }], initial.sha256);
|
|
719
|
+
}
|
|
720
|
+
finally {
|
|
721
|
+
total.close();
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
async function verifyRepository(criterion, authority, contract, context, signal) {
|
|
725
|
+
if (contract.task.kind !== 'goal-outcome' || criterion.objectId !== `${authority.repository}:${authority.branch}` || context === undefined)
|
|
726
|
+
return unknown(criterion.id, 'repository-context-unavailable');
|
|
727
|
+
const deadline = withDeadline(signal, authority.timeoutMs);
|
|
728
|
+
try {
|
|
729
|
+
const value = await context.read(contract, authority, deadline.signal);
|
|
730
|
+
const document = object(value, 'repository readback');
|
|
731
|
+
exactKeys(document, ['objectId', 'headOid', 'ci', 'review', 'pullRequest', 'ready'], 'repository readback');
|
|
732
|
+
if (deadline.signal.aborted || document.objectId !== criterion.objectId || typeof document.headOid !== 'string' || !/^[a-f0-9]{40}$/u.test(document.headOid)
|
|
733
|
+
|| !['passed', 'pending', 'failed'].includes(document.ci) || !['approved', 'pending', 'changes-requested'].includes(document.review)
|
|
734
|
+
|| !['open', 'closed', 'merged'].includes(document.pullRequest)
|
|
735
|
+
|| document.ready !== (document.ci === 'passed' && document.review === 'approved' && ['open', 'merged'].includes(document.pullRequest)))
|
|
736
|
+
return unknown(criterion.id, 'repository-readback-unconfirmed');
|
|
737
|
+
if (document.ci === 'pending' || document.review === 'pending')
|
|
738
|
+
return unknown(criterion.id, 'repository-requirements-pending');
|
|
739
|
+
if (criterion.expectedRevision !== undefined && criterion.expectedRevision !== document.headOid)
|
|
740
|
+
return failed(criterion.id, 'repository-head-mismatch');
|
|
741
|
+
const evidence = [{ kind: 'repository-readback', ref: criterion.objectId, digest: digest(document) }];
|
|
742
|
+
for (const expected of criterion.expected)
|
|
743
|
+
if (acceptanceCanonicalJson(pointer(document, expected.pointer)) !== acceptanceCanonicalJson(expected.value))
|
|
744
|
+
return failed(criterion.id, 'repository-requirements-not-met', evidence);
|
|
745
|
+
return passed(criterion.id, evidence);
|
|
746
|
+
}
|
|
747
|
+
catch {
|
|
748
|
+
return unknown(criterion.id, 'repository-readback-unavailable');
|
|
749
|
+
}
|
|
750
|
+
finally {
|
|
751
|
+
deadline.close();
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
/** Runs every immutable criterion once. It never retries task side effects. */
|
|
755
|
+
export async function verifyAcceptanceCriteria(contract, authorities, signal, context, repository) {
|
|
756
|
+
const deadline = withDeadline(signal, contract.bounds.maxDurationMs);
|
|
757
|
+
try {
|
|
758
|
+
const results = [];
|
|
759
|
+
for (const criterion of contract.criteria) {
|
|
760
|
+
if (deadline.signal.aborted) {
|
|
761
|
+
results.push(unknown(criterion.id, 'verification-aborted'));
|
|
762
|
+
continue;
|
|
763
|
+
}
|
|
764
|
+
const authority = authorityFor(criterion, authorities);
|
|
765
|
+
if (authority === undefined) {
|
|
766
|
+
results.push(unknown(criterion.id, 'authority-mismatch'));
|
|
767
|
+
continue;
|
|
768
|
+
}
|
|
769
|
+
if (criterion.kind === 'process-behavior')
|
|
770
|
+
results.push(authority.kind === 'runner' ? await verifyProcess(criterion, authority, contract.scope.workspace, deadline.signal) : unknown(criterion.id, 'authority-kind-mismatch'));
|
|
771
|
+
else if (criterion.kind === 'isolated-process-behavior')
|
|
772
|
+
results.push(authority.kind === 'isolated-runner' ? await verifyIsolated(criterion, authority, contract, context, deadline.signal) : unknown(criterion.id, 'authority-kind-mismatch'));
|
|
773
|
+
else if (criterion.kind === 'document-citations')
|
|
774
|
+
results.push(authority.kind === 'document' ? await verifyDocument(criterion, authority, contract.scope.workspace, deadline.signal) : unknown(criterion.id, 'authority-kind-mismatch'));
|
|
775
|
+
else if (authority.kind === 'repository-readback')
|
|
776
|
+
results.push(await verifyRepository(criterion, authority, contract, repository, deadline.signal));
|
|
777
|
+
else
|
|
778
|
+
results.push(authority.kind === 'readback' ? await verifyReadback(criterion, authority, deadline.signal) : unknown(criterion.id, 'authority-kind-mismatch'));
|
|
779
|
+
}
|
|
780
|
+
return freeze(results);
|
|
781
|
+
}
|
|
782
|
+
finally {
|
|
783
|
+
deadline.close();
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
//# sourceMappingURL=drivers.js.map
|