terminalhire 0.42.21 → 0.42.23
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/dist/bin/jpi-claim.js +696 -181
- package/dist/bin/jpi-dispatch.js +819 -296
- package/dist/bin/jpi-mcp.js +703 -188
- package/dist/bin/jpi-run.js +600 -77
- package/dist/bin/proxy/egressProxy.js +25 -0
- package/package.json +1 -1
package/dist/bin/jpi-run.js
CHANGED
|
@@ -45,7 +45,7 @@ ${facts.stderr}`))
|
|
|
45
45
|
return null;
|
|
46
46
|
}
|
|
47
47
|
function isCommandUnavailable(facts, countsRead) {
|
|
48
|
-
if (facts.exitCode === 127 || facts.exitCode === 126)
|
|
48
|
+
if (facts.exitCode === 127 || facts.exitCode === 126 || facts.exitCode === 125)
|
|
49
49
|
return true;
|
|
50
50
|
if (countsRead)
|
|
51
51
|
return false;
|
|
@@ -53,6 +53,22 @@ function isCommandUnavailable(facts, countsRead) {
|
|
|
53
53
|
return false;
|
|
54
54
|
return facts.stdout.trim().length === 0 && EXEC_FAILURE.test(facts.stderr);
|
|
55
55
|
}
|
|
56
|
+
function isOfflineBuildGap(facts, counts) {
|
|
57
|
+
if (facts.runtime !== "jvm")
|
|
58
|
+
return null;
|
|
59
|
+
if (counts !== null && counts.tests_failed > 0)
|
|
60
|
+
return null;
|
|
61
|
+
if (facts.exitCode === 0 || facts.exitCode === null)
|
|
62
|
+
return null;
|
|
63
|
+
const out = `${facts.stdout}
|
|
64
|
+
${facts.stderr}`;
|
|
65
|
+
for (const pattern of OFFLINE_BUILD_GAP) {
|
|
66
|
+
const hit = pattern.exec(out);
|
|
67
|
+
if (hit)
|
|
68
|
+
return hit[0];
|
|
69
|
+
}
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
56
72
|
function classifyVerification(facts) {
|
|
57
73
|
if (facts.timedOut) {
|
|
58
74
|
return {
|
|
@@ -77,6 +93,14 @@ function classifyVerification(facts) {
|
|
|
77
93
|
reason: `the test command could not be invoked (exit ${String(facts.exitCode)}). This is an environment failure on our side \u2014 a missing interpreter, a binary outside PATH, or a dependency installed somewhere the command cannot see it. The repo has not been judged.`
|
|
78
94
|
};
|
|
79
95
|
}
|
|
96
|
+
const gap = isOfflineBuildGap(facts, counts);
|
|
97
|
+
if (gap !== null) {
|
|
98
|
+
return {
|
|
99
|
+
outcome: "test-command-unavailable",
|
|
100
|
+
counts,
|
|
101
|
+
reason: `the build stopped on something our install step did not fetch (${gap}). The test step has no network, so this is a gap in our environment, not a failure in the repo. The repo has not been judged.`
|
|
102
|
+
};
|
|
103
|
+
}
|
|
80
104
|
if (counts !== null) {
|
|
81
105
|
if (counts.tests_failed > 0) {
|
|
82
106
|
return {
|
|
@@ -131,10 +155,19 @@ function isGreen(outcome) {
|
|
|
131
155
|
function isOurFault(outcome) {
|
|
132
156
|
return outcome === "test-command-unavailable" || outcome === "counts-unparsed";
|
|
133
157
|
}
|
|
134
|
-
var int, withSuiteFailures, READERS, SUPPORTED_RUNNERS, COVERAGE_TABLE, EXEC_FAILURE, SUITE_REPORTED_FAILURE;
|
|
158
|
+
var VERIFICATION_OUTCOMES, int, withSuiteFailures, READERS, SUPPORTED_RUNNERS, COVERAGE_TABLE, EXEC_FAILURE, SUITE_REPORTED_FAILURE, OFFLINE_BUILD_GAP;
|
|
135
159
|
var init_classify = __esm({
|
|
136
160
|
"../../packages/envrun/dist/classify.js"() {
|
|
137
161
|
"use strict";
|
|
162
|
+
VERIFICATION_OUTCOMES = [
|
|
163
|
+
"completed",
|
|
164
|
+
"tests-failed",
|
|
165
|
+
"no-tests-observed",
|
|
166
|
+
"counts-unparsed",
|
|
167
|
+
"test-command-unavailable",
|
|
168
|
+
"environment-exhausted",
|
|
169
|
+
"budget-exceeded"
|
|
170
|
+
];
|
|
138
171
|
int = (m, i = 1) => m ? Number(m[i]) : 0;
|
|
139
172
|
withSuiteFailures = (counts, suiteLine) => {
|
|
140
173
|
if (counts.tests_failed > 0 || !suiteLine)
|
|
@@ -234,6 +267,51 @@ var init_classify = __esm({
|
|
|
234
267
|
return { tests_passed: int(passed), tests_failed: int(failed) + int(errors) };
|
|
235
268
|
}
|
|
236
269
|
},
|
|
270
|
+
{
|
|
271
|
+
// Maven surefire's per-module summary (TERM-1122), measured against surefire
|
|
272
|
+
// 3.2.5 through `th run`:
|
|
273
|
+
//
|
|
274
|
+
// [INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0
|
|
275
|
+
//
|
|
276
|
+
// The per-CLASS lines carry the same counts followed by `, Time elapsed: …`, so
|
|
277
|
+
// the end anchor after `Skipped` is what keeps them out; counting both would
|
|
278
|
+
// double every test. One summary prints per module, so they are SUMMED. An
|
|
279
|
+
// error is a test that could not run to an assertion, counted as failed so a
|
|
280
|
+
// green verdict is impossible while one is outstanding, as pytest's are.
|
|
281
|
+
runner: "maven surefire",
|
|
282
|
+
read: (out) => {
|
|
283
|
+
const lines = [
|
|
284
|
+
...out.matchAll(/^(?:\[(?:INFO|WARNING|ERROR)\] )?Tests run: (\d+), Failures: (\d+), Errors: (\d+), Skipped: (\d+)(?:, Flakes: \d+)?\s*$/gm)
|
|
285
|
+
];
|
|
286
|
+
if (lines.length === 0)
|
|
287
|
+
return null;
|
|
288
|
+
let passed = 0;
|
|
289
|
+
let failed = 0;
|
|
290
|
+
for (const m of lines) {
|
|
291
|
+
const [run2, failures, errors, skipped] = [m[1], m[2], m[3], m[4]].map(Number);
|
|
292
|
+
failed += failures + errors;
|
|
293
|
+
passed += Math.max(0, run2 - failures - errors - skipped);
|
|
294
|
+
}
|
|
295
|
+
return { tests_passed: passed, tests_failed: failed };
|
|
296
|
+
}
|
|
297
|
+
},
|
|
298
|
+
{
|
|
299
|
+
// Gradle prints no test counts when every test passes, so envrun installs an
|
|
300
|
+
// init script that prints one line per Test task (`gradleTestSummary.ts`,
|
|
301
|
+
// TERM-1122). The marker is ours; its format is fixed there.
|
|
302
|
+
runner: "gradle",
|
|
303
|
+
read: (out) => {
|
|
304
|
+
const lines = [
|
|
305
|
+
...out.matchAll(/^terminalhire-gradle-tests: task=\S+ total=(\d+) passed=(\d+) failed=(\d+) skipped=(\d+)\s*$/gm)
|
|
306
|
+
];
|
|
307
|
+
if (lines.length === 0)
|
|
308
|
+
return null;
|
|
309
|
+
return {
|
|
310
|
+
tests_passed: lines.reduce((sum, m) => sum + Number(m[2]), 0),
|
|
311
|
+
tests_failed: lines.reduce((sum, m) => sum + Number(m[3]), 0)
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
},
|
|
237
315
|
{
|
|
238
316
|
// mocha: ` 440 passing (1s)` and ` 2 failing`
|
|
239
317
|
runner: "mocha",
|
|
@@ -268,6 +346,79 @@ var init_classify = __esm({
|
|
|
268
346
|
const p = int(passed);
|
|
269
347
|
return { tests_passed: p, tests_failed: Math.max(0, t - p) };
|
|
270
348
|
}
|
|
349
|
+
},
|
|
350
|
+
{
|
|
351
|
+
/**
|
|
352
|
+
* BARE TAP — assertion lines with no plan and no summary comment (TERM-1138).
|
|
353
|
+
*
|
|
354
|
+
* LAST ON PURPOSE. It is the loosest reader here, so anything that a more specific
|
|
355
|
+
* one can read must reach that one first: `node --test` and node-tap both emit `ok`
|
|
356
|
+
* lines AND a closing summary, and their readers are ordered above for that reason.
|
|
357
|
+
* Moving this up would have it answer for both.
|
|
358
|
+
*
|
|
359
|
+
* A hand-rolled harness prints neither summary — it emits its assertions and stops.
|
|
360
|
+
* That is what morebetterltd/wheelhouse does: thirty `ok N - …` lines, exit 0, and
|
|
361
|
+
* before this every one of those runs reported `counts-unparsed`, which the claim
|
|
362
|
+
* page renders as "RESULT UNREADABLE — THE SUITE EXITED 0" over a suite that passed.
|
|
363
|
+
*
|
|
364
|
+
* ON THE MISSING PLAN LINE. TAP requires `1..N` so a reader can tell a finished
|
|
365
|
+
* stream from a truncated one, and there is none here, so this cannot prove the
|
|
366
|
+
* suite reached its end. That is a real limit and it is NOT a reason to refuse,
|
|
367
|
+
* because it holds this format to a standard no reader in this file meets — the
|
|
368
|
+
* jest reader greps a line out of stdout that a repository could `echo` without
|
|
369
|
+
* running anything. These are format adapters, not a trust boundary; the boundary
|
|
370
|
+
* is that the command is the repository's own or is refused a signature.
|
|
371
|
+
*
|
|
372
|
+
* CONTIGUOUS FROM 1, which buys self-consistency rather than security:
|
|
373
|
+
*
|
|
374
|
+
* - starting at 1 rejects a TRUNCATION. `testOutputTail` keeps the last lines, so
|
|
375
|
+
* a stream beginning `ok 47` is the middle of a longer run and counting it would
|
|
376
|
+
* report three tests for a suite that ran fifty.
|
|
377
|
+
* - no gaps rejects prose that happens to carry the word, and a stream interleaved
|
|
378
|
+
* with another's.
|
|
379
|
+
* - anchored with no leading space rejects TAP SUBTESTS, which indent. Counting
|
|
380
|
+
* those would double them against their parent.
|
|
381
|
+
*/
|
|
382
|
+
runner: "bare TAP",
|
|
383
|
+
read: (out) => {
|
|
384
|
+
let n = 0;
|
|
385
|
+
let failed = 0;
|
|
386
|
+
let passed = 0;
|
|
387
|
+
let plan = null;
|
|
388
|
+
for (const line of out.split("\n")) {
|
|
389
|
+
if (/^Bail out!/.test(line))
|
|
390
|
+
return null;
|
|
391
|
+
const p = /^(\d+)\.\.(\d+)\s*$/.exec(line);
|
|
392
|
+
if (p) {
|
|
393
|
+
const declared = Number(p[2]) - Number(p[1]) + 1;
|
|
394
|
+
if (plan !== null && plan !== declared)
|
|
395
|
+
return null;
|
|
396
|
+
plan = declared;
|
|
397
|
+
continue;
|
|
398
|
+
}
|
|
399
|
+
const m = /^(not )?ok (\d+)(?:\s|$)/.exec(line);
|
|
400
|
+
if (!m)
|
|
401
|
+
continue;
|
|
402
|
+
if (Number(m[2]) !== n + 1)
|
|
403
|
+
return null;
|
|
404
|
+
n += 1;
|
|
405
|
+
const directive = /#\s*(skip|todo)\b/i.exec(line);
|
|
406
|
+
const kind = directive?.[1]?.toLowerCase();
|
|
407
|
+
if (kind === "skip")
|
|
408
|
+
continue;
|
|
409
|
+
if (m[1] !== void 0) {
|
|
410
|
+
if (kind !== "todo")
|
|
411
|
+
failed += 1;
|
|
412
|
+
continue;
|
|
413
|
+
}
|
|
414
|
+
passed += 1;
|
|
415
|
+
}
|
|
416
|
+
if (n === 0)
|
|
417
|
+
return null;
|
|
418
|
+
if (plan !== null && plan !== n)
|
|
419
|
+
return null;
|
|
420
|
+
return { tests_passed: passed, tests_failed: failed };
|
|
421
|
+
}
|
|
271
422
|
}
|
|
272
423
|
];
|
|
273
424
|
SUPPORTED_RUNNERS = READERS.map((r) => r.runner);
|
|
@@ -289,6 +440,21 @@ var init_classify = __esm({
|
|
|
289
440
|
"\\d+\\s+examples?,\\s+(?!0\\b)\\d+\\s+failures?"
|
|
290
441
|
// rspec's summary line
|
|
291
442
|
].join("|"), "m");
|
|
443
|
+
OFFLINE_BUILD_GAP = [
|
|
444
|
+
// Maven, `-o`: `Cannot access central (…) in offline mode and the artifact … has
|
|
445
|
+
// not been downloaded from it before.`
|
|
446
|
+
/in offline mode and the artifact \S+ has not been downloaded from it before/,
|
|
447
|
+
// Maven without `-o`, as a CI-derived `mvn test` runs: it tries the network.
|
|
448
|
+
/Could not transfer artifact \S+ from\/to /,
|
|
449
|
+
/Plugin \S+ or one of its dependencies could not be resolved/,
|
|
450
|
+
// Gradle, `--offline`.
|
|
451
|
+
/No cached version (?:listing )?(?:of|for) \S+ available for offline mode/,
|
|
452
|
+
// Gradle without `--offline`, and the wrapper fetching its own distribution.
|
|
453
|
+
/Could not GET 'https?:\/\//,
|
|
454
|
+
/^Downloading https:\/\/services\.gradle\.org\/distributions\//m,
|
|
455
|
+
/No matching toolchains found for requested specification/,
|
|
456
|
+
/Cannot find a Java installation on your machine matching/
|
|
457
|
+
];
|
|
292
458
|
}
|
|
293
459
|
});
|
|
294
460
|
|
|
@@ -354,6 +520,12 @@ function scrubEnv(source, opts) {
|
|
|
354
520
|
env["GOCACHE"] = join5(jailHome, ".cache", "go-build");
|
|
355
521
|
env["GOFLAGS"] = "-modcacherw";
|
|
356
522
|
env["CARGO_HOME"] = join5(jailHome, ".cargo");
|
|
523
|
+
env["MAVEN_OPTS"] = `-Dmaven.repo.local=${join5(jailHome, ".m2", "repository")}`;
|
|
524
|
+
env["GRADLE_USER_HOME"] = join5(jailHome, ".gradle");
|
|
525
|
+
const bundle = join5(jailHome, ".bundle");
|
|
526
|
+
env["GEM_HOME"] = bundle;
|
|
527
|
+
env["BUNDLE_PATH"] = bundle;
|
|
528
|
+
env["BUNDLE_APP_CONFIG"] = bundle;
|
|
357
529
|
const proxy = opts.proxyUrl ?? DEAD_PROXY;
|
|
358
530
|
env["HTTP_PROXY"] = proxy;
|
|
359
531
|
env["HTTPS_PROXY"] = proxy;
|
|
@@ -654,6 +826,31 @@ var init_egressProxy = __esm({
|
|
|
654
826
|
"static.crates.io",
|
|
655
827
|
"index.crates.io",
|
|
656
828
|
"crates.io",
|
|
829
|
+
// TERM-1139. `ruby` has had an image since the mapping existed and `bundle install`
|
|
830
|
+
// as its derived command, and this host was missing — so every ruby repo could start
|
|
831
|
+
// and could never install. Install is the ONLY step with a network grant at all, so a
|
|
832
|
+
// denial here is terminal, not a slow path.
|
|
833
|
+
//
|
|
834
|
+
// One entry, not two: `hostAllowed` is dot-anchored SUFFIX matching, so this covers
|
|
835
|
+
// `index.rubygems.org` as well. Listing that separately would advertise the grant as
|
|
836
|
+
// narrower than it is, which the `github.com` note below rejects for the same reason.
|
|
837
|
+
"rubygems.org",
|
|
838
|
+
// TERM-1139, added with the `dotnet` image mapping. `dotnet restore` fetches from
|
|
839
|
+
// NuGet; the coupling test refuses a mapped runtime whose registry is unreachable, and
|
|
840
|
+
// it caught this one the moment the image landed.
|
|
841
|
+
"api.nuget.org",
|
|
842
|
+
// TERM-1122, added with the `jvm` image mapping, each from a MEASURED denial
|
|
843
|
+
// through `th run` (the proxy's 403 in the build's own output), not a guess:
|
|
844
|
+
// - Maven Central, where both `mvn` and Gradle's `mavenCentral()` resolve.
|
|
845
|
+
// - `services.gradle.org`, where a Gradle wrapper fetches its distribution.
|
|
846
|
+
// - `release-assets.githubusercontent.com`, where that fetch LANDS. Measured
|
|
847
|
+
// chain: services.gradle.org 307 -> github.com 302 -> this host 200. The
|
|
848
|
+
// `github.com` note below keeps `githubusercontent.com` denied until a real
|
|
849
|
+
// install needs it; this is that install. Only this one host: raw blobs and
|
|
850
|
+
// `objects.githubusercontent.com` stay denied.
|
|
851
|
+
"repo.maven.apache.org",
|
|
852
|
+
"services.gradle.org",
|
|
853
|
+
"release-assets.githubusercontent.com",
|
|
657
854
|
// Git dependencies. Approved by Eric 2026-07-22 after a live run measured the
|
|
658
855
|
// chokepoint working under load (20 events, 16 allowed / 4 denied by host).
|
|
659
856
|
//
|
|
@@ -945,6 +1142,9 @@ function containerArgs(spec, env, opts) {
|
|
|
945
1142
|
args.push(`--env=${k}=${url}`);
|
|
946
1143
|
}
|
|
947
1144
|
args.push("--env=NO_PROXY=", "--env=no_proxy=");
|
|
1145
|
+
const host = opts.net.proxyHost;
|
|
1146
|
+
const port = String(opts.net.proxyPort);
|
|
1147
|
+
args.push(`--env=JAVA_TOOL_OPTIONS=-Dhttp.proxyHost=${host} -Dhttp.proxyPort=${port} -Dhttps.proxyHost=${host} -Dhttps.proxyPort=${port} -Daether.connector.http.useSystemProperties=true`);
|
|
948
1148
|
}
|
|
949
1149
|
args.push("--");
|
|
950
1150
|
args.push(validateImage(opts.image ?? DEFAULT_CONTAINER_IMAGE));
|
|
@@ -1277,7 +1477,10 @@ var init_container = __esm({
|
|
|
1277
1477
|
"http_proxy",
|
|
1278
1478
|
"https_proxy",
|
|
1279
1479
|
"NO_PROXY",
|
|
1280
|
-
"no_proxy"
|
|
1480
|
+
"no_proxy",
|
|
1481
|
+
// Carries the JVM's copy of the proxy (below), so an inherited value is dropped
|
|
1482
|
+
// for the same reason as the rest.
|
|
1483
|
+
"JAVA_TOOL_OPTIONS"
|
|
1281
1484
|
]);
|
|
1282
1485
|
WINDOWS_DRIVE_ROOT = /^[A-Za-z]:[\\/]/;
|
|
1283
1486
|
IMAGE_HOST = "(?:[a-zA-Z0-9-]+(?:\\.[a-zA-Z0-9-]+)+(?::[0-9]+)?|localhost(?::[0-9]+)?)";
|
|
@@ -1327,11 +1530,17 @@ var init_dist = __esm({
|
|
|
1327
1530
|
function censusTotal(c) {
|
|
1328
1531
|
return c.containers.length + c.volumes.length + c.networks.length;
|
|
1329
1532
|
}
|
|
1330
|
-
function
|
|
1533
|
+
function query(docker3, args) {
|
|
1331
1534
|
const res = docker3.sync([...args], { timeoutMs: 15e3 });
|
|
1332
|
-
if (res.error || res.status !== 0)
|
|
1333
|
-
|
|
1334
|
-
|
|
1535
|
+
if (res.error || res.status !== 0) {
|
|
1536
|
+
const why = res.error?.message ?? (res.stderr.trim() || `exit ${String(res.status)}`);
|
|
1537
|
+
return { ids: [], failure: `docker ${args.slice(0, 2).join(" ")}: ${why}` };
|
|
1538
|
+
}
|
|
1539
|
+
const ids2 = res.stdout.split("\n").map((s) => s.trim()).filter((s) => s.length > 0);
|
|
1540
|
+
return { ids: ids2, failure: null };
|
|
1541
|
+
}
|
|
1542
|
+
function ids(docker3, args) {
|
|
1543
|
+
return query(docker3, args).ids;
|
|
1335
1544
|
}
|
|
1336
1545
|
function census(docker3, label) {
|
|
1337
1546
|
const filter = `label=${label}`;
|
|
@@ -1341,25 +1550,67 @@ function census(docker3, label) {
|
|
|
1341
1550
|
networks: ids(docker3, ["network", "ls", "-q", "--filter", filter])
|
|
1342
1551
|
};
|
|
1343
1552
|
}
|
|
1553
|
+
function censusReport(docker3, label) {
|
|
1554
|
+
const filter = `label=${label}`;
|
|
1555
|
+
const failures = [];
|
|
1556
|
+
const ask = (args) => {
|
|
1557
|
+
const q = query(docker3, args);
|
|
1558
|
+
if (q.failure !== null)
|
|
1559
|
+
failures.push(q.failure);
|
|
1560
|
+
return q.ids;
|
|
1561
|
+
};
|
|
1562
|
+
const taken = {
|
|
1563
|
+
containers: ask(["ps", "-aq", "--filter", filter]),
|
|
1564
|
+
volumes: ask(["volume", "ls", "-q", "--filter", filter]),
|
|
1565
|
+
networks: ask(["network", "ls", "-q", "--filter", filter])
|
|
1566
|
+
};
|
|
1567
|
+
return failures.length === 0 ? { observed: true, census: taken, unobservedReason: null } : { observed: false, census: taken, unobservedReason: failures.join("; ") };
|
|
1568
|
+
}
|
|
1344
1569
|
function localCensus(label) {
|
|
1345
1570
|
return census(localDockerClient(), label);
|
|
1346
1571
|
}
|
|
1347
|
-
function judgeLeaks(peak, after) {
|
|
1572
|
+
function judgeLeaks(peak, after, observation) {
|
|
1348
1573
|
const labelObserved = peak.containers.length > 0;
|
|
1574
|
+
if (!observation.observed) {
|
|
1575
|
+
return {
|
|
1576
|
+
labelObserved,
|
|
1577
|
+
reaped: false,
|
|
1578
|
+
observed: false,
|
|
1579
|
+
clean: false,
|
|
1580
|
+
state: "unobserved",
|
|
1581
|
+
peak,
|
|
1582
|
+
after,
|
|
1583
|
+
note: `UNOBSERVED, not clean: we could not look at what survived teardown (${observation.unobservedReason ?? "no reason given"}), so nothing is known about leaks on this run, in either direction.`
|
|
1584
|
+
};
|
|
1585
|
+
}
|
|
1349
1586
|
const reaped = censusTotal(after) === 0;
|
|
1350
1587
|
let note;
|
|
1588
|
+
let state;
|
|
1351
1589
|
if (!labelObserved && reaped) {
|
|
1590
|
+
state = "inconclusive";
|
|
1352
1591
|
note = "INCONCLUSIVE, not clean: nothing labelled was ever seen alive, so an empty final census is equally consistent with the label never being applied. The control failed, so the denial proves nothing.";
|
|
1353
1592
|
} else if (!labelObserved) {
|
|
1593
|
+
state = "leak";
|
|
1354
1594
|
note = "no labelled container was observed alive AND objects remain \u2014 the label wiring is wrong.";
|
|
1355
1595
|
} else if (!reaped) {
|
|
1596
|
+
state = "leak";
|
|
1356
1597
|
note = `LEAK: ${String(censusTotal(after))} labelled object(s) survived teardown (containers=${String(after.containers.length)} volumes=${String(after.volumes.length)} networks=${String(after.networks.length)}).`;
|
|
1357
1598
|
} else {
|
|
1599
|
+
state = "clean";
|
|
1358
1600
|
note = `clean: peak ${String(peak.containers.length)} labelled container(s) observed alive, 0 labelled objects remain after teardown.`;
|
|
1359
1601
|
}
|
|
1360
|
-
return {
|
|
1602
|
+
return {
|
|
1603
|
+
labelObserved,
|
|
1604
|
+
reaped,
|
|
1605
|
+
observed: true,
|
|
1606
|
+
clean: labelObserved && reaped,
|
|
1607
|
+
state,
|
|
1608
|
+
peak,
|
|
1609
|
+
after,
|
|
1610
|
+
note
|
|
1611
|
+
};
|
|
1361
1612
|
}
|
|
1362
|
-
var RUN_LABEL_KEY, LabelWatch;
|
|
1613
|
+
var RUN_LABEL_KEY, LabelWatch, LEAK_STATES;
|
|
1363
1614
|
var init_labels = __esm({
|
|
1364
1615
|
"../../packages/envrun/dist/labels.js"() {
|
|
1365
1616
|
"use strict";
|
|
@@ -1414,6 +1665,7 @@ var init_labels = __esm({
|
|
|
1414
1665
|
return this.#samples;
|
|
1415
1666
|
}
|
|
1416
1667
|
};
|
|
1668
|
+
LEAK_STATES = ["clean", "leak", "inconclusive", "unobserved"];
|
|
1417
1669
|
}
|
|
1418
1670
|
});
|
|
1419
1671
|
|
|
@@ -1898,7 +2150,7 @@ function acquireLocalLease(runId) {
|
|
|
1898
2150
|
observed: false,
|
|
1899
2151
|
census: { containers: [], volumes: [], networks: [] },
|
|
1900
2152
|
unobservedReason: RELEASED_LEASE_CENSUS_REASON
|
|
1901
|
-
} :
|
|
2153
|
+
} : censusReport(docker3, label)),
|
|
1902
2154
|
publishPreview: (req) => startPreview({ ...req, docker: docker3 }),
|
|
1903
2155
|
release: () => {
|
|
1904
2156
|
if (released) {
|
|
@@ -2018,6 +2270,17 @@ caused by: ${frame}`;
|
|
|
2018
2270
|
}
|
|
2019
2271
|
return frames === 0 ? null : out;
|
|
2020
2272
|
}
|
|
2273
|
+
function imageVariantFor(spec) {
|
|
2274
|
+
if (spec.runtime !== "jvm")
|
|
2275
|
+
return void 0;
|
|
2276
|
+
const callsGradle = /(?:^|[\s;&|(])gradle(?=\s|$)/;
|
|
2277
|
+
return [spec.installCommand, spec.testCommand].some((c) => c != null && callsGradle.test(c)) ? "gradle" : void 0;
|
|
2278
|
+
}
|
|
2279
|
+
function shapeFor(runtime, variant) {
|
|
2280
|
+
if (runtime === "jvm" && variant === "gradle")
|
|
2281
|
+
return JVM_GRADLE_IMAGE;
|
|
2282
|
+
return RUNTIME_IMAGES[runtime];
|
|
2283
|
+
}
|
|
2021
2284
|
function atLeast(a, b) {
|
|
2022
2285
|
const left = a.split(".").map(Number);
|
|
2023
2286
|
const right = b.split(".").map(Number);
|
|
@@ -2030,19 +2293,19 @@ function atLeast(a, b) {
|
|
|
2030
2293
|
}
|
|
2031
2294
|
return true;
|
|
2032
2295
|
}
|
|
2033
|
-
function imageForRuntime(runtime, override, version) {
|
|
2296
|
+
function imageForRuntime(runtime, override, version, variant) {
|
|
2034
2297
|
if (override)
|
|
2035
2298
|
return override;
|
|
2036
|
-
const shape =
|
|
2299
|
+
const shape = shapeFor(runtime, variant);
|
|
2037
2300
|
if (!shape) {
|
|
2038
|
-
throw new RunRefusalError(`no container image is mapped for runtime ${JSON.stringify(runtime)}. Refusing to run it in the Node image: a
|
|
2301
|
+
throw new RunRefusalError(`no container image is mapped for runtime ${JSON.stringify(runtime)}. Refusing to run it in the Node image: a bare "command not found" exits 127, which classifyVerification already owns as ours \u2014 but a test script that RUNS and fails inside on the missing tool exits with its own status and prints to stdout, and that falls through to tests-failed \u2014 a false red blamed on the repo.`);
|
|
2039
2302
|
}
|
|
2040
2303
|
if (version === void 0 || version === null)
|
|
2041
2304
|
return unversionedImage(shape);
|
|
2042
2305
|
if (!TAG_VERSION.test(version)) {
|
|
2043
2306
|
throw new RunRefusalError(`runtime version ${JSON.stringify(version)} is not a bare version, so no image tag can be built from it. Refusing rather than booting the default: the repo asked for a version, and supplying a different one silently is what TERM-643 fixed.`);
|
|
2044
2307
|
}
|
|
2045
|
-
return `${shape.repository}:${version}${shape.suffix}`;
|
|
2308
|
+
return `${shape.repository}:${shape.prefix ?? ""}${version}${shape.suffix}`;
|
|
2046
2309
|
}
|
|
2047
2310
|
function setManifestProbe(probe) {
|
|
2048
2311
|
const previous = manifestProbe;
|
|
@@ -2055,10 +2318,10 @@ function imageDefinitelyAbsent(image) {
|
|
|
2055
2318
|
return false;
|
|
2056
2319
|
return /manifest unknown|no such manifest/i.test(res.output);
|
|
2057
2320
|
}
|
|
2058
|
-
function resolvePublishedImage(image, runtime, version) {
|
|
2321
|
+
function resolvePublishedImage(image, runtime, version, variant) {
|
|
2059
2322
|
if (!imageDefinitelyAbsent(image))
|
|
2060
2323
|
return image;
|
|
2061
|
-
const shape =
|
|
2324
|
+
const shape = shapeFor(runtime, variant);
|
|
2062
2325
|
if (shape && shape.declaredIsFloor && atLeast(shape.defaultVersion, version)) {
|
|
2063
2326
|
return unversionedImage(shape);
|
|
2064
2327
|
}
|
|
@@ -2066,18 +2329,19 @@ function resolvePublishedImage(image, runtime, version) {
|
|
|
2066
2329
|
}
|
|
2067
2330
|
function resolveImageForSpec(spec, override) {
|
|
2068
2331
|
const declared = spec.runtimeVersion;
|
|
2069
|
-
const
|
|
2332
|
+
const variant = imageVariantFor(spec);
|
|
2333
|
+
const image = imageForRuntime(spec.runtime, override, declared, variant);
|
|
2070
2334
|
const constructed = (() => {
|
|
2071
2335
|
if (declared === null)
|
|
2072
2336
|
return null;
|
|
2073
2337
|
try {
|
|
2074
|
-
return imageForRuntime(spec.runtime, void 0, declared);
|
|
2338
|
+
return imageForRuntime(spec.runtime, void 0, declared, variant);
|
|
2075
2339
|
} catch {
|
|
2076
2340
|
return null;
|
|
2077
2341
|
}
|
|
2078
2342
|
})();
|
|
2079
2343
|
if (constructed !== null && image === constructed && declared !== null) {
|
|
2080
|
-
return resolvePublishedImage(image, spec.runtime, declared);
|
|
2344
|
+
return resolvePublishedImage(image, spec.runtime, declared, variant);
|
|
2081
2345
|
}
|
|
2082
2346
|
return image;
|
|
2083
2347
|
}
|
|
@@ -2104,12 +2368,22 @@ function assertVenueOwnerDeclared(lease) {
|
|
|
2104
2368
|
return;
|
|
2105
2369
|
throw new RunRefusalError("this venue did not say which account owns the tree it staged, so the guest would run under this machine's uid and could not write its own clone. We refuse rather than report that permission error as the repo's tests failing.");
|
|
2106
2370
|
}
|
|
2371
|
+
function refuseUnbuildableSpec(spec) {
|
|
2372
|
+
if (spec.runtime !== "jvm" || spec.installCommand !== null || spec.testCommand !== null)
|
|
2373
|
+
return;
|
|
2374
|
+
const why = spec.unresolved.find((r) => r.kind === "no-test-command")?.detail;
|
|
2375
|
+
if (why === void 0) {
|
|
2376
|
+
throw new EnvRunError("internal: a jvm spec with no test command carries no no-test-command reason");
|
|
2377
|
+
}
|
|
2378
|
+
throw new RunRefusalError(`refusing to run this repository: ${why}.`);
|
|
2379
|
+
}
|
|
2107
2380
|
async function runEnvironmentSpec(req) {
|
|
2108
2381
|
const startedAt = Date.now();
|
|
2109
2382
|
const containment = req.lease.containment;
|
|
2110
2383
|
if (containment.kind !== "container") {
|
|
2111
2384
|
throw new EnvRunError(`phase 2 requires the container tier, got ${containment.kind}. Refusing: a container phase that silently ran under seatbelt would make every container claim vacuous.`);
|
|
2112
2385
|
}
|
|
2386
|
+
refuseUnbuildableSpec(req.spec);
|
|
2113
2387
|
assertVenueOwnerDeclared(req.lease);
|
|
2114
2388
|
const image = resolveImageForSpec(req.spec, req.image);
|
|
2115
2389
|
const { jail, tmp } = req;
|
|
@@ -2152,9 +2426,10 @@ async function runEnvironmentSpec(req) {
|
|
|
2152
2426
|
installOk: false
|
|
2153
2427
|
};
|
|
2154
2428
|
} else if (req.spec.testCommand === null) {
|
|
2429
|
+
const why = req.spec.unresolved.find((r) => r.kind === "no-test-command")?.detail;
|
|
2155
2430
|
result = {
|
|
2156
2431
|
outcome: "no-tests-observed",
|
|
2157
|
-
note: "the spec derived no test command, so nothing was executed: nothing failed and nothing ran. Certain, not inferred \u2014 no command was ever invoked.",
|
|
2432
|
+
note: "the spec derived no test command, so nothing was executed: nothing failed and nothing ran. Certain, not inferred \u2014 no command was ever invoked." + (why === void 0 ? "" : ` Why: ${why}.`),
|
|
2158
2433
|
installOk: true
|
|
2159
2434
|
};
|
|
2160
2435
|
} else {
|
|
@@ -2177,7 +2452,7 @@ async function runEnvironmentSpec(req) {
|
|
|
2177
2452
|
labels,
|
|
2178
2453
|
timeoutMs: req.testTimeoutMs ?? 9e5
|
|
2179
2454
|
});
|
|
2180
|
-
const verdict = classifyVerification(toExecution(test));
|
|
2455
|
+
const verdict = classifyVerification({ ...toExecution(test), runtime: req.spec.runtime });
|
|
2181
2456
|
result = { outcome: verdict.outcome, note: verdict.reason, installOk: true };
|
|
2182
2457
|
}
|
|
2183
2458
|
} finally {
|
|
@@ -2191,6 +2466,10 @@ async function runEnvironmentSpec(req) {
|
|
|
2191
2466
|
volumes: [],
|
|
2192
2467
|
networks: []
|
|
2193
2468
|
};
|
|
2469
|
+
const observation = afterReport ?? {
|
|
2470
|
+
observed: false,
|
|
2471
|
+
unobservedReason: "the run carried no label, so there was nothing to count by"
|
|
2472
|
+
};
|
|
2194
2473
|
return {
|
|
2195
2474
|
outcome: result.outcome,
|
|
2196
2475
|
tier: "container",
|
|
@@ -2199,7 +2478,7 @@ async function runEnvironmentSpec(req) {
|
|
|
2199
2478
|
test,
|
|
2200
2479
|
installOk: result.installOk,
|
|
2201
2480
|
counts: test ? readCounts(test.stdout, test.stderr) : null,
|
|
2202
|
-
leaks: judgeLeaks(peak, after),
|
|
2481
|
+
leaks: judgeLeaks(peak, after, observation),
|
|
2203
2482
|
note: result.note,
|
|
2204
2483
|
wallMs: Date.now() - startedAt
|
|
2205
2484
|
};
|
|
@@ -2249,7 +2528,7 @@ async function runStep(containment, r) {
|
|
|
2249
2528
|
wallMs: Date.now() - startedAt
|
|
2250
2529
|
};
|
|
2251
2530
|
}
|
|
2252
|
-
var EnvRunError, RunRefusalError, MAX_CAUSE_FRAMES, CHAIN_UNREADABLE, CHAIN_TOO_DEEP, RUNTIME_IMAGES, unversionedImage, TAG_VERSION, dockerManifestProbe, manifestProbe, MISSING_IMAGE_SHAPE;
|
|
2531
|
+
var EnvRunError, RunRefusalError, MAX_CAUSE_FRAMES, CHAIN_UNREADABLE, CHAIN_TOO_DEEP, RUNTIME_IMAGES, JVM_GRADLE_IMAGE, unversionedImage, TAG_VERSION, dockerManifestProbe, manifestProbe, MISSING_IMAGE_SHAPE;
|
|
2253
2532
|
var init_execute = __esm({
|
|
2254
2533
|
"../../packages/envrun/dist/execute.js"() {
|
|
2255
2534
|
"use strict";
|
|
@@ -2286,9 +2565,66 @@ var init_execute = __esm({
|
|
|
2286
2565
|
},
|
|
2287
2566
|
go: { repository: "golang", suffix: "-bookworm", defaultVersion: "1.23", declaredIsFloor: true },
|
|
2288
2567
|
ruby: { repository: "ruby", suffix: "-bookworm", defaultVersion: "3.3", declaredIsFloor: false },
|
|
2289
|
-
rust: { repository: "rust", suffix: "-bookworm", defaultVersion: "1", declaredIsFloor: true }
|
|
2568
|
+
rust: { repository: "rust", suffix: "-bookworm", defaultVersion: "1", declaredIsFloor: true },
|
|
2569
|
+
/**
|
|
2570
|
+
* TERM-1139. `dotnet` was unmapped alongside `jvm`, and the recorded reason was that
|
|
2571
|
+
* neither had "a single obvious base image (gradle vs maven, sdk vs runtime)". That
|
|
2572
|
+
* reason holds for jvm and it does NOT hold here, which is why only this half moved.
|
|
2573
|
+
*
|
|
2574
|
+
* "sdk vs runtime" answers itself: the derived commands are `dotnet restore` and
|
|
2575
|
+
* `dotnet test`, and neither exists on the runtime image. There is no second build
|
|
2576
|
+
* tool competing for the slot the way maven and gradle compete for jvm's — and since
|
|
2577
|
+
* no official image carries both of those, jvm genuinely cannot be served by one key.
|
|
2578
|
+
*
|
|
2579
|
+
* MEASURED, not chosen by tag: `mcr.microsoft.com/dotnet/sdk:8.0` carries `git` and
|
|
2580
|
+
* `dotnet` (881MB). `git` is the property the whole `image-tooling-live` test exists
|
|
2581
|
+
* for, and `eclipse-temurin:21-jdk` was rejected here for lacking it.
|
|
2582
|
+
*
|
|
2583
|
+
* The tag shape fits without touching the builder: the SDK publishes a tag per
|
|
2584
|
+
* version, so `repository:VERSION` is a real image at every version envspec can
|
|
2585
|
+
* derive, with no suffix. That is NOT true of the maven images, where the leading
|
|
2586
|
+
* tag component is maven's version and Java's sits in the suffix.
|
|
2587
|
+
*
|
|
2588
|
+
* `declaredIsFloor` is true on the same reasoning as go and rust: an SDK builds
|
|
2589
|
+
* older target frameworks, so booting 8.0 for a repo asking 6.0 is the supported
|
|
2590
|
+
* case rather than a substitution. A repo asking for something NEWER than our
|
|
2591
|
+
* default still refuses, which is where TERM-643 put that line.
|
|
2592
|
+
*/
|
|
2593
|
+
dotnet: {
|
|
2594
|
+
repository: "mcr.microsoft.com/dotnet/sdk",
|
|
2595
|
+
suffix: "",
|
|
2596
|
+
defaultVersion: "8.0",
|
|
2597
|
+
declaredIsFloor: true
|
|
2598
|
+
},
|
|
2599
|
+
/**
|
|
2600
|
+
* TERM-1122. Maven's image, and the one a Gradle WRAPPER repo gets too: the wrapper
|
|
2601
|
+
* downloads its own Gradle, so it needs a JDK and nothing else. MEASURED
|
|
2602
|
+
* 2026-09-18: `maven:3.9-eclipse-temurin-21` carries `git`, `mvn` and JDK 21 on
|
|
2603
|
+
* Ubuntu 24.04, which is why this is not `eclipse-temurin` (no `git`, rejected
|
|
2604
|
+
* under TERM-1139). The JDK sits AFTER maven's own version in the tag, hence
|
|
2605
|
+
* `prefix`, and the major is all envspec derives (`javaMajor`).
|
|
2606
|
+
*
|
|
2607
|
+
* `declaredIsFloor`: a JDK builds for older `--release` targets, so a repo asking
|
|
2608
|
+
* for 16, which has no image, runs on 21. A Gradle toolchain is stricter than a
|
|
2609
|
+
* floor and asks for its exact JDK; Gradle's refusal on the wrong one reads as
|
|
2610
|
+
* ours (`classify.ts`, `isOfflineBuildGap`), never as the repo's.
|
|
2611
|
+
*/
|
|
2612
|
+
jvm: {
|
|
2613
|
+
repository: "maven",
|
|
2614
|
+
prefix: "3.9-eclipse-temurin-",
|
|
2615
|
+
suffix: "",
|
|
2616
|
+
defaultVersion: "21",
|
|
2617
|
+
declaredIsFloor: true
|
|
2618
|
+
}
|
|
2619
|
+
};
|
|
2620
|
+
JVM_GRADLE_IMAGE = {
|
|
2621
|
+
repository: "gradle",
|
|
2622
|
+
prefix: "jdk",
|
|
2623
|
+
suffix: "",
|
|
2624
|
+
defaultVersion: "21",
|
|
2625
|
+
declaredIsFloor: true
|
|
2290
2626
|
};
|
|
2291
|
-
unversionedImage = (shape) => `${shape.repository}:${shape.defaultVersion}${shape.suffix}`;
|
|
2627
|
+
unversionedImage = (shape) => `${shape.repository}:${shape.prefix ?? ""}${shape.defaultVersion}${shape.suffix}`;
|
|
2292
2628
|
TAG_VERSION = /^\d+(?:\.\d+){0,2}$/;
|
|
2293
2629
|
dockerManifestProbe = (image) => {
|
|
2294
2630
|
const res = spawnSync4("docker", ["manifest", "inspect", image], {
|
|
@@ -2980,14 +3316,31 @@ function answerDidItPass(r) {
|
|
|
2980
3316
|
const passed = r.status === "verified" && r.outcome !== null && isGreen(r.outcome);
|
|
2981
3317
|
return { passed, summary: renderVerdictLine(r), lookAt: r.preview?.url ?? null };
|
|
2982
3318
|
}
|
|
2983
|
-
|
|
3319
|
+
function exitCodeFor(r) {
|
|
3320
|
+
if (r.status === "refused")
|
|
3321
|
+
return 2;
|
|
3322
|
+
if (r.outcome === null)
|
|
3323
|
+
return 2;
|
|
3324
|
+
return OUTCOME_EXIT_CODES[r.outcome];
|
|
3325
|
+
}
|
|
3326
|
+
var RUN_TEST_COMMAND_SOURCES, RUN_IMAGE_SOURCES, RUN_RESULT_SCHEMA, RUN_RESULT_FIELDS, RENDER_NONE, FIELD_VIEWS, OUTCOME_EXIT_CODES;
|
|
2984
3327
|
var init_result = __esm({
|
|
2985
3328
|
"../../packages/envrun/dist/result.js"() {
|
|
2986
3329
|
"use strict";
|
|
2987
3330
|
init_dist2();
|
|
2988
3331
|
init_classify();
|
|
2989
3332
|
init_venueDescriptor();
|
|
2990
|
-
RUN_TEST_COMMAND_SOURCES = [
|
|
3333
|
+
RUN_TEST_COMMAND_SOURCES = [
|
|
3334
|
+
...TEST_COMMAND_SOURCES,
|
|
3335
|
+
"developer-declared",
|
|
3336
|
+
"operator-declared"
|
|
3337
|
+
];
|
|
3338
|
+
RUN_IMAGE_SOURCES = [
|
|
3339
|
+
"detected",
|
|
3340
|
+
"none",
|
|
3341
|
+
"operator-declared",
|
|
3342
|
+
"developer-declared"
|
|
3343
|
+
];
|
|
2991
3344
|
RUN_RESULT_SCHEMA = "terminalhire.verification-run/1";
|
|
2992
3345
|
RUN_RESULT_FIELDS = [
|
|
2993
3346
|
"schema",
|
|
@@ -3012,7 +3365,9 @@ var init_result = __esm({
|
|
|
3012
3365
|
"preview",
|
|
3013
3366
|
"containerImage",
|
|
3014
3367
|
"containerImageDigest",
|
|
3368
|
+
"imageSource",
|
|
3015
3369
|
"leaksClean",
|
|
3370
|
+
"leakState",
|
|
3016
3371
|
"venue"
|
|
3017
3372
|
];
|
|
3018
3373
|
RENDER_NONE = null;
|
|
@@ -3044,12 +3399,42 @@ var init_result = __esm({
|
|
|
3044
3399
|
preview: (r) => r.preview === null ? null : `preview ${r.preview.url}`,
|
|
3045
3400
|
containerImage: (r) => r.containerImage === null ? null : `image ${r.containerImage}`,
|
|
3046
3401
|
containerImageDigest: (r) => r.containerImageDigest === null ? null : `image digest ${r.containerImageDigest}`,
|
|
3047
|
-
|
|
3402
|
+
// Shown only when a human chose the environment. `detected` is the ordinary case and
|
|
3403
|
+
// saying so on every run would train the reader to skip the line that matters.
|
|
3404
|
+
imageSource: (r) => r.imageSource === "detected" || r.imageSource === "none" ? null : `image source ${r.imageSource} (not signed)`,
|
|
3405
|
+
// Printed through `leakState` below, which knows WHY a false is false. Rendering both would
|
|
3406
|
+
// print the leak warning on a run where we merely could not look (TERM-1144).
|
|
3407
|
+
leaksClean: RENDER_NONE,
|
|
3408
|
+
// Silent when clean and when refused, as the boolean's line was. The two not-a-leak states
|
|
3409
|
+
// get their own sentence, because "we could not check" read as "we found a leak" is the
|
|
3410
|
+
// exact confusion this field exists to end.
|
|
3411
|
+
leakState: (r) => {
|
|
3412
|
+
switch (r.leakState) {
|
|
3413
|
+
case null:
|
|
3414
|
+
case "clean":
|
|
3415
|
+
return null;
|
|
3416
|
+
case "leak":
|
|
3417
|
+
return "WARNING labelled Docker objects survived teardown";
|
|
3418
|
+
case "inconclusive":
|
|
3419
|
+
return "leak check inconclusive \u2014 no labelled container was seen while the run was live, so finding nothing afterwards proves nothing";
|
|
3420
|
+
case "unobserved":
|
|
3421
|
+
return "leak check not taken \u2014 we could not check what was left after teardown";
|
|
3422
|
+
}
|
|
3423
|
+
},
|
|
3048
3424
|
// Absent on most runs, so it prints only when there is something to say. Silence
|
|
3049
3425
|
// here is the honest rendering of "no venue answered": a placeholder line would
|
|
3050
3426
|
// invite a reader to treat an unanswered probe as a described venue.
|
|
3051
3427
|
venue: (r) => r.venue === null ? null : renderVenueLine(r.venue)
|
|
3052
3428
|
};
|
|
3429
|
+
OUTCOME_EXIT_CODES = {
|
|
3430
|
+
completed: 0,
|
|
3431
|
+
"tests-failed": 1,
|
|
3432
|
+
"no-tests-observed": 1,
|
|
3433
|
+
"budget-exceeded": 1,
|
|
3434
|
+
"counts-unparsed": 2,
|
|
3435
|
+
"test-command-unavailable": 2,
|
|
3436
|
+
"environment-exhausted": 2
|
|
3437
|
+
};
|
|
3053
3438
|
}
|
|
3054
3439
|
});
|
|
3055
3440
|
|
|
@@ -3170,12 +3555,23 @@ function toAcceptancePredicate(pair, opts = {}) {
|
|
|
3170
3555
|
if (why !== null)
|
|
3171
3556
|
return refuse3("test-command-origin-unattested", `the ${side} run ${why}`);
|
|
3172
3557
|
}
|
|
3173
|
-
if (patched.testCommandSource === "developer-declared") {
|
|
3174
|
-
return refuse3("test-command-origin-unattested", "the developer supplied the test command (unreachable \u2014 the source rules above cover it)
|
|
3558
|
+
if (patched.testCommandSource === "developer-declared" || patched.testCommandSource === "operator-declared") {
|
|
3559
|
+
return refuse3("test-command-origin-unattested", `${patched.testCommandSource === "developer-declared" ? "the developer" : "an operator"} supplied the test command (unreachable \u2014 the source rules above cover it)`);
|
|
3175
3560
|
}
|
|
3176
3561
|
if (baseline.testCommandSource !== patched.testCommandSource) {
|
|
3177
3562
|
return refuse3("test-command-origin-unattested", `the pair disagrees on where the command came from: baseline ${baseline.testCommandSource}, patched ${patched.testCommandSource}. One signed value cannot describe both.`);
|
|
3178
3563
|
}
|
|
3564
|
+
for (const [side, source, image] of [
|
|
3565
|
+
["patched", patched.imageSource, patched.containerImage],
|
|
3566
|
+
["baseline", baseline.imageSource, baseline.containerImage]
|
|
3567
|
+
]) {
|
|
3568
|
+
const why = IMAGE_SOURCE_IS_SIGNABLE[source](image);
|
|
3569
|
+
if (why !== null)
|
|
3570
|
+
return refuse3("image-origin-unattested", `the ${side} run ${why}`);
|
|
3571
|
+
}
|
|
3572
|
+
if (baseline.imageSource !== patched.imageSource) {
|
|
3573
|
+
return refuse3("image-origin-unattested", `the pair disagrees on where the image came from: baseline ${baseline.imageSource}, patched ${patched.imageSource}. The environment is part of what the statement says was held constant.`);
|
|
3574
|
+
}
|
|
3179
3575
|
if (baseline.containerImage !== patched.containerImage) {
|
|
3180
3576
|
return refuse3("pair-disagrees-on-image", `baseline ran in ${String(baseline.containerImage)} and patched in ${String(patched.containerImage)}. The signed measurement names one image, so a pair from two environments would attribute to the patch whatever the image changed.`);
|
|
3181
3577
|
}
|
|
@@ -3232,7 +3628,7 @@ function signRunStatement(predicate, privateKey, keyid) {
|
|
|
3232
3628
|
const statement = createAcceptanceStatement(predicate);
|
|
3233
3629
|
return { statement, envelope: signStatement(statement, privateKey, keyid) };
|
|
3234
3630
|
}
|
|
3235
|
-
var OUTCOME_TO_BUDGET, BASELINE_IS_A_VERDICT, CONTRADICTS_COUNTS, SOURCE_IS_SIGNABLE, ATTEST_REFUSAL_REASONS, refuse3, LOCAL_MEASUREMENT_PREFIX, REFERENCE_DOMAIN_COMPONENT, REFERENCE_DOMAIN_NAME, REFERENCE_IPV6, REFERENCE_DOMAIN, REFERENCE_PATH_COMPONENT, REPO_DIGEST_RE;
|
|
3631
|
+
var OUTCOME_TO_BUDGET, BASELINE_IS_A_VERDICT, CONTRADICTS_COUNTS, SOURCE_IS_SIGNABLE, IMAGE_SOURCE_IS_SIGNABLE, ATTEST_REFUSAL_REASONS, refuse3, LOCAL_MEASUREMENT_PREFIX, REFERENCE_DOMAIN_COMPONENT, REFERENCE_DOMAIN_NAME, REFERENCE_IPV6, REFERENCE_DOMAIN, REFERENCE_PATH_COMPONENT, REPO_DIGEST_RE;
|
|
3236
3632
|
var init_attestation2 = __esm({
|
|
3237
3633
|
"../../packages/envrun/dist/attestation.js"() {
|
|
3238
3634
|
"use strict";
|
|
@@ -3288,11 +3684,25 @@ var init_attestation2 = __esm({
|
|
|
3288
3684
|
// The party under verification chose it, and no signed member can say so. Per ADR-005
|
|
3289
3685
|
// Decision 2, emit nothing rather than pick the closest word.
|
|
3290
3686
|
"developer-declared": () => "had its test command supplied by the developer whose work is under verification, and the signed vocabulary has no member for that. `founder-declared` would attribute the choice to the other party; `detected` would claim the repo chose it. Both are false.",
|
|
3687
|
+
// US, out of band, per posting — the twin of `IMAGE_SOURCE_IS_SIGNABLE['operator-declared']`
|
|
3688
|
+
// and refusing for the same reason. `founder-declared` is the nearest signed member and it
|
|
3689
|
+
// is still false: the poster did not choose this, we did, and signing our own choice under
|
|
3690
|
+
// the counterparty's name is the specific false attribution the `developer-declared` entry
|
|
3691
|
+
// above exists to refuse. Decision 2 — emit nothing rather than pick the closest word.
|
|
3692
|
+
"operator-declared": () => "had its test command chosen by an operator out of band, and the signed vocabulary has no member for that. Signing it would present a hand-picked command as one the repository chose for itself.",
|
|
3291
3693
|
// `none` means nobody chose a command, so a command beside it is a contradiction. Shadowed
|
|
3292
3694
|
// today — `thrun.ts` sets `none` only when the derived command is null, and `no-test-command`
|
|
3293
3695
|
// fires first — but that coupling runs through `runEnvironmentSpec` and lives in no type.
|
|
3294
3696
|
none: (command) => command === null ? null : `records its command source as \`none\` \u2014 nobody chose one \u2014 beside the command ${JSON.stringify(command)}`
|
|
3295
3697
|
};
|
|
3698
|
+
IMAGE_SOURCE_IS_SIGNABLE = {
|
|
3699
|
+
detected: () => null,
|
|
3700
|
+
"operator-declared": () => "had its container image chosen by an operator out of band, and the signed vocabulary has no member for that. Signing it would present a hand-picked environment as the one the runtime mapping selected.",
|
|
3701
|
+
"developer-declared": () => "had its container image supplied by the developer whose work is under verification. A chosen interpreter turns a red suite green as surely as a chosen test command.",
|
|
3702
|
+
// `none` means no image was booted, so an image name beside it is a contradiction — the
|
|
3703
|
+
// `SOURCE_IS_SIGNABLE.none` argument, applied to the other field.
|
|
3704
|
+
none: (image) => image === null ? null : `records its image source as \`none\` \u2014 nothing was booted \u2014 beside the image ${JSON.stringify(image)}`
|
|
3705
|
+
};
|
|
3296
3706
|
ATTEST_REFUSAL_REASONS = [
|
|
3297
3707
|
"our-environment-failed",
|
|
3298
3708
|
"run-was-refused",
|
|
@@ -3334,7 +3744,11 @@ var init_attestation2 = __esm({
|
|
|
3334
3744
|
// The signed triple must be reproducible from what it names — see the guard.
|
|
3335
3745
|
"patch-binding-incomplete",
|
|
3336
3746
|
"outcome-contradicts-counts",
|
|
3337
|
-
"test-command-origin-unattested"
|
|
3747
|
+
"test-command-origin-unattested",
|
|
3748
|
+
// The environment half of the pair above. `pair-disagrees-on-image` compares image NAMES;
|
|
3749
|
+
// this one asks who CHOSE the name, which no comparison of the two halves can reveal when
|
|
3750
|
+
// both were handed the same override.
|
|
3751
|
+
"image-origin-unattested"
|
|
3338
3752
|
];
|
|
3339
3753
|
refuse3 = (reason, detail) => ({
|
|
3340
3754
|
ok: false,
|
|
@@ -3838,13 +4252,20 @@ function gcpBootArgv(p) {
|
|
|
3838
4252
|
// steal". Attaching an account removes that, so the safety now rests on two things,
|
|
3839
4253
|
// and this change MUST land together with proof #2 — never ahead of it:
|
|
3840
4254
|
//
|
|
3841
|
-
// 1. `--no-scopes`
|
|
3842
|
-
// No scopes → the OAuth access-token endpoint serves nothing
|
|
3843
|
-
// TERM-706)
|
|
3844
|
-
//
|
|
3845
|
-
//
|
|
3846
|
-
//
|
|
3847
|
-
//
|
|
4255
|
+
// 1. `--no-scopes` and a NARROW grant set bound the BLAST RADIUS of a lifted
|
|
4256
|
+
// token. No scopes → the OAuth access-token endpoint serves nothing
|
|
4257
|
+
// (measured, TERM-706). This said "ZERO project roles" until 2026-09-18, and
|
|
4258
|
+
// that was never true of the account we ship: measured live, `venue-identity@`
|
|
4259
|
+
// holds `roles/confidentialcomputing.workloadUser` on the project and
|
|
4260
|
+
// `roles/artifactregistry.reader` on the `venue-images` repository alone —
|
|
4261
|
+
// the same two grants the Confidential Space note below already named, so this
|
|
4262
|
+
// file contradicted itself. Neither reaches a second repository, a bucket or
|
|
4263
|
+
// another VM, which is the property "zero roles" was reaching for. But the
|
|
4264
|
+
// token is NOT valueless: it is the attestation evidence itself, so a lifted
|
|
4265
|
+
// token still authorizes a false claim about WHERE a run happened (the
|
|
4266
|
+
// steal-a-true-token attack in the item-2 spike). `--no-scopes` stays because
|
|
4267
|
+
// it removes lateral GCP access; it does not make losing the identity token
|
|
4268
|
+
// cheap.
|
|
3848
4269
|
// 2. The untrusted code cannot reach the metadata server to lift the token at all.
|
|
3849
4270
|
// The test step runs `--network=none`; the install step runs on an `--internal`
|
|
3850
4271
|
// network whose only egress is a CONNECT-only, registry-allowlisted proxy that
|
|
@@ -5324,11 +5745,7 @@ function makeLease(p) {
|
|
|
5324
5745
|
}
|
|
5325
5746
|
try {
|
|
5326
5747
|
check("counting what the run left behind");
|
|
5327
|
-
return Promise.resolve(
|
|
5328
|
-
observed: true,
|
|
5329
|
-
census: census(p.docker, label),
|
|
5330
|
-
unobservedReason: null
|
|
5331
|
-
});
|
|
5748
|
+
return Promise.resolve(censusReport(p.docker, label));
|
|
5332
5749
|
} catch (err) {
|
|
5333
5750
|
return Promise.resolve({
|
|
5334
5751
|
observed: false,
|
|
@@ -5596,7 +6013,7 @@ function localDockerPlacement() {
|
|
|
5596
6013
|
kind: "local-docker",
|
|
5597
6014
|
refusal: null,
|
|
5598
6015
|
venue: () => localVenue(),
|
|
5599
|
-
imageFor: (runtime, override, version) => imageForRuntime(runtime, override, version)
|
|
6016
|
+
imageFor: (runtime, override, version, variant) => imageForRuntime(runtime, override, version, variant)
|
|
5600
6017
|
};
|
|
5601
6018
|
}
|
|
5602
6019
|
function hostedPoolPlacement() {
|
|
@@ -5604,7 +6021,7 @@ function hostedPoolPlacement() {
|
|
|
5604
6021
|
kind: "hosted-pool",
|
|
5605
6022
|
refusal: null,
|
|
5606
6023
|
venue: () => hostedVenue(),
|
|
5607
|
-
imageFor: (runtime, override, version) => imageForRuntime(runtime, override, version)
|
|
6024
|
+
imageFor: (runtime, override, version, variant) => imageForRuntime(runtime, override, version, variant)
|
|
5608
6025
|
};
|
|
5609
6026
|
}
|
|
5610
6027
|
function placementFor(kind) {
|
|
@@ -6169,6 +6586,9 @@ function detectRuntime(repo) {
|
|
|
6169
6586
|
if (repo.listNames("").some((name) => /\.(?:csproj|fsproj|sln)$/.test(name))) {
|
|
6170
6587
|
candidates.push("dotnet");
|
|
6171
6588
|
}
|
|
6589
|
+
if (candidates.length === 0 && repo.listFiles("").some((f) => f.endsWith(".java"))) {
|
|
6590
|
+
candidates.push("jvm");
|
|
6591
|
+
}
|
|
6172
6592
|
return { runtime: candidates[0] ?? "unknown", candidates };
|
|
6173
6593
|
}
|
|
6174
6594
|
function readJsonObject(repo, path) {
|
|
@@ -6316,6 +6736,11 @@ function basePythonInstallCommand(repo) {
|
|
|
6316
6736
|
}
|
|
6317
6737
|
return null;
|
|
6318
6738
|
}
|
|
6739
|
+
function rehearse(command) {
|
|
6740
|
+
const copy = '"$R/.terminalhire-rehearsal"';
|
|
6741
|
+
const home = '"$R/.terminalhire-rehearsal-home"';
|
|
6742
|
+
return `{ R="$HOME"; rm -rf ${copy} ${home}; mkdir -p ${home} && cp -R . ${copy} && (cd ${copy} && HOME=${home} GRADLE_USER_HOME="$R/.gradle" JAVA_TOOL_OPTIONS="$JAVA_TOOL_OPTIONS -Duser.home=$R/.terminalhire-rehearsal-home" ${command}); rm -rf ${copy} ${home}; true; }`;
|
|
6743
|
+
}
|
|
6319
6744
|
function otherRuntimeCommands(repo, runtime) {
|
|
6320
6745
|
switch (runtime) {
|
|
6321
6746
|
case "go":
|
|
@@ -6343,10 +6768,21 @@ function otherRuntimeCommands(repo, runtime) {
|
|
|
6343
6768
|
case "dotnet":
|
|
6344
6769
|
return { install: "dotnet restore", test: "dotnet test" };
|
|
6345
6770
|
case "jvm": {
|
|
6346
|
-
if (repo.exists(
|
|
6347
|
-
return { install:
|
|
6771
|
+
if (!JVM_BUILD_FILES.some((file) => repo.exists(file)))
|
|
6772
|
+
return { install: null, test: null };
|
|
6773
|
+
if (repo.exists("pom.xml")) {
|
|
6774
|
+
return {
|
|
6775
|
+
install: `mvn -B dependency:go-offline && ${rehearse('mvn -B test -Dmaven.test.failure.ignore=true -Dmaven.repo.local="$R/.m2/repository"')}`,
|
|
6776
|
+
test: "mvn -B -o test"
|
|
6777
|
+
};
|
|
6778
|
+
}
|
|
6348
6779
|
const wrapper = repo.exists("gradlew") ? "./gradlew" : "gradle";
|
|
6349
|
-
return {
|
|
6780
|
+
return {
|
|
6781
|
+
install: `${wrapper} dependencies && ${rehearse(`${wrapper} test --continue`)}`,
|
|
6782
|
+
// `--no-build-cache`: the rehearsal above can fill Gradle's build cache, and a
|
|
6783
|
+
// test task restored FROM-CACHE runs nothing and prints no count.
|
|
6784
|
+
test: `${wrapper} test --offline --no-build-cache`
|
|
6785
|
+
};
|
|
6350
6786
|
}
|
|
6351
6787
|
default:
|
|
6352
6788
|
return { install: null, test: null };
|
|
@@ -6378,6 +6814,15 @@ function searchRuntimeVersion(repo, runtime) {
|
|
|
6378
6814
|
push(".ruby-version", repo.readText(".ruby-version"));
|
|
6379
6815
|
} else if (runtime === "dotnet") {
|
|
6380
6816
|
push("global.json sdk.version", readStringField(readObjectField(readJsonObject(repo, "global.json"), "sdk"), "version"));
|
|
6817
|
+
} else if (runtime === "jvm") {
|
|
6818
|
+
const pom = repo.readText("pom.xml");
|
|
6819
|
+
const gradle = repo.readText("build.gradle") ?? repo.readText("build.gradle.kts");
|
|
6820
|
+
push("build.gradle toolchain languageVersion", javaMajor(matchFirst(gradle, /JavaLanguageVersion\.of\(\s*["']?(\d+)["']?\s*\)/) ?? matchFirst(gradle, /jvmToolchain\(\s*(\d+)\s*\)/)));
|
|
6821
|
+
push(".java-version", javaMajor(repo.readText(".java-version")));
|
|
6822
|
+
push("pom.xml maven.compiler.release", javaMajor(matchFirst(pom, /<maven\.compiler\.release>\s*([^<\s]+)\s*</)));
|
|
6823
|
+
push("pom.xml java.version", javaMajor(matchFirst(pom, /<java\.version>\s*([^<\s]+)\s*</)));
|
|
6824
|
+
push("pom.xml maven.compiler.source", javaMajor(matchFirst(pom, /<maven\.compiler\.source>\s*([^<\s]+)\s*</)));
|
|
6825
|
+
push("build.gradle sourceCompatibility", javaMajor(matchFirst(gradle, /sourceCompatibility\s*=\s*JavaVersion\.VERSION_([\d_]+)/)?.replace("_", ".") ?? matchFirst(gradle, /sourceCompatibility\s*=\s*["']?([\d.]+)["']?/)));
|
|
6381
6826
|
}
|
|
6382
6827
|
let rangeFound = null;
|
|
6383
6828
|
for (const source of sources) {
|
|
@@ -6391,13 +6836,21 @@ function searchRuntimeVersion(repo, runtime) {
|
|
|
6391
6836
|
}
|
|
6392
6837
|
return { version: null, searched: sources.map((s) => s.label), rangeFound };
|
|
6393
6838
|
}
|
|
6839
|
+
function javaMajor(text) {
|
|
6840
|
+
if (text === null)
|
|
6841
|
+
return null;
|
|
6842
|
+
const match = /^(?:1\.(\d+)|(\d+))(?:[._][\d_.]*)?$/.exec(text.trim());
|
|
6843
|
+
if (match === null)
|
|
6844
|
+
return null;
|
|
6845
|
+
return match[1] ?? match[2] ?? null;
|
|
6846
|
+
}
|
|
6394
6847
|
function matchFirst(text, pattern) {
|
|
6395
6848
|
if (text === null)
|
|
6396
6849
|
return null;
|
|
6397
6850
|
const match = pattern.exec(text);
|
|
6398
6851
|
return match === null ? null : match[1];
|
|
6399
6852
|
}
|
|
6400
|
-
var RUNTIME_MANIFESTS, MANIFEST_FILENAMES, NPM_PLACEHOLDER_TEST, REQUIREMENT_SPECIFIER, EXACT_VERSION;
|
|
6853
|
+
var RUNTIME_MANIFESTS, MANIFEST_FILENAMES, JVM_BUILD_FILES, NPM_PLACEHOLDER_TEST, REQUIREMENT_SPECIFIER, EXACT_VERSION;
|
|
6401
6854
|
var init_manifest2 = __esm({
|
|
6402
6855
|
"../../packages/envspec/dist/manifest.js"() {
|
|
6403
6856
|
"use strict";
|
|
@@ -6411,6 +6864,7 @@ var init_manifest2 = __esm({
|
|
|
6411
6864
|
{ runtime: "dotnet", files: [] }
|
|
6412
6865
|
];
|
|
6413
6866
|
MANIFEST_FILENAMES = RUNTIME_MANIFESTS.flatMap((m) => m.files).concat(["*.csproj", "*.fsproj", "*.sln"]).sort();
|
|
6867
|
+
JVM_BUILD_FILES = ["build.gradle", "build.gradle.kts", "pom.xml"];
|
|
6414
6868
|
NPM_PLACEHOLDER_TEST = /^echo\s+["']?Error:\s*no test specified["']?\s*&&\s*exit\s+1$/;
|
|
6415
6869
|
REQUIREMENT_SPECIFIER = /^[A-Za-z0-9._-]+(\[[A-Za-z0-9._,-]+\])?([<>=!~]=?[A-Za-z0-9._*+-]+(,[<>=!~]=?[A-Za-z0-9._*+-]+)*)?$/;
|
|
6416
6870
|
EXACT_VERSION = /^v?(\d+(?:\.\d+){0,2})$/;
|
|
@@ -6807,7 +7261,7 @@ function deriveFromReader(repo) {
|
|
|
6807
7261
|
searched: MANIFEST_FILENAMES,
|
|
6808
7262
|
detail: `no ecosystem manifest was found at the repo root (looked for ${MANIFEST_FILENAMES.join(", ")}), so the language runtime could not be identified`
|
|
6809
7263
|
});
|
|
6810
|
-
} else {
|
|
7264
|
+
} else if (runtime !== "jvm" || JVM_BUILD_FILES.some((file) => repo.exists(file))) {
|
|
6811
7265
|
derivedFrom.add("manifest");
|
|
6812
7266
|
}
|
|
6813
7267
|
if (detection.candidates.length > 1) {
|
|
@@ -6971,12 +7425,13 @@ function deriveRuntimeVersion(repo, runtime, job) {
|
|
|
6971
7425
|
const setup = SETUP_ACTIONS[runtime];
|
|
6972
7426
|
const searched = [];
|
|
6973
7427
|
let rangeFound = null;
|
|
7428
|
+
const pin = (raw) => runtime === "jvm" ? javaMajor(raw) : exactVersion(raw);
|
|
6974
7429
|
if (job !== null && setup !== void 0) {
|
|
6975
7430
|
const direct = findActionInput(job, setup.action, setup.input);
|
|
6976
7431
|
if (direct !== null) {
|
|
6977
7432
|
searched.push(`${job.file} ${setup.action} ${setup.input}`);
|
|
6978
7433
|
const reduced = reduceExpression(direct.command, job.singletons);
|
|
6979
|
-
const exact =
|
|
7434
|
+
const exact = pin(reduced);
|
|
6980
7435
|
if (exact !== null)
|
|
6981
7436
|
return { value: exact, searched, rangeFound: null };
|
|
6982
7437
|
if (reduced !== null)
|
|
@@ -6985,7 +7440,7 @@ function deriveRuntimeVersion(repo, runtime, job) {
|
|
|
6985
7440
|
const viaFile = findActionInput(job, setup.action, setup.fileInput);
|
|
6986
7441
|
if (viaFile !== null) {
|
|
6987
7442
|
searched.push(`${job.file} ${setup.action} ${setup.fileInput} -> ${viaFile.command}`);
|
|
6988
|
-
const exact =
|
|
7443
|
+
const exact = pin(repo.readText(viaFile.command));
|
|
6989
7444
|
if (exact !== null)
|
|
6990
7445
|
return { value: exact, searched, rangeFound: null };
|
|
6991
7446
|
}
|
|
@@ -7010,6 +7465,8 @@ function testSearchPaths(runtime, workflowFiles) {
|
|
|
7010
7465
|
paths.push("**/*_test.go");
|
|
7011
7466
|
if (runtime === "ruby")
|
|
7012
7467
|
paths.push("Gemfile", "Rakefile");
|
|
7468
|
+
if (runtime === "jvm")
|
|
7469
|
+
paths.push(...JVM_BUILD_FILES);
|
|
7013
7470
|
if (runtime === "unknown")
|
|
7014
7471
|
paths.push(...MANIFEST_FILENAMES);
|
|
7015
7472
|
return [...new Set(paths)].sort();
|
|
@@ -7027,6 +7484,9 @@ function describeTestSearch(repo, runtime, workflowFiles) {
|
|
|
7027
7484
|
if (runtime === "python") {
|
|
7028
7485
|
return `${ciPart}, and nothing in the repo names a test runner \u2014 no \`[tool.pytest.ini_options]\`, no \`pytest.ini\`, and no pytest dependency in \`pyproject.toml\` or a requirements file`;
|
|
7029
7486
|
}
|
|
7487
|
+
if (runtime === "jvm" && !JVM_BUILD_FILES.some((file) => repo.exists(file))) {
|
|
7488
|
+
return `${ciPart}, and the repo has Java sources but no build file (${JVM_BUILD_FILES.join(", ")}), so there is no build to run tests with. Compiling the sources with \`javac\` alone would run no tests`;
|
|
7489
|
+
}
|
|
7030
7490
|
return `${ciPart}, and no ${runtime} test convention applies to this tree`;
|
|
7031
7491
|
}
|
|
7032
7492
|
function installSearchPaths(runtime, workflowFiles) {
|
|
@@ -7035,6 +7495,8 @@ function installSearchPaths(runtime, workflowFiles) {
|
|
|
7035
7495
|
paths.push("package-lock.json", "pnpm-lock.yaml", "yarn.lock");
|
|
7036
7496
|
if (runtime === "python")
|
|
7037
7497
|
paths.push("poetry.lock", "pyproject.toml", "requirements.txt", "uv.lock");
|
|
7498
|
+
if (runtime === "jvm")
|
|
7499
|
+
paths.push(...JVM_BUILD_FILES);
|
|
7038
7500
|
if (runtime === "unknown")
|
|
7039
7501
|
paths.push(...MANIFEST_FILENAMES);
|
|
7040
7502
|
return [...new Set(paths)].sort();
|
|
@@ -7113,12 +7575,41 @@ var init_dist3 = __esm({
|
|
|
7113
7575
|
}
|
|
7114
7576
|
});
|
|
7115
7577
|
|
|
7578
|
+
// ../../packages/envrun/dist/gradleTestSummary.js
|
|
7579
|
+
import { mkdirSync as mkdirSync6, writeFileSync as writeFileSync7 } from "fs";
|
|
7580
|
+
import { join as join13 } from "path";
|
|
7581
|
+
function stageGradleTestSummary(jailHome) {
|
|
7582
|
+
const dir = join13(jailHome, ".gradle", "init.d");
|
|
7583
|
+
mkdirSync6(dir, { recursive: true });
|
|
7584
|
+
writeFileSync7(join13(dir, "terminalhire-test-summary.gradle"), GRADLE_TEST_SUMMARY_INIT);
|
|
7585
|
+
}
|
|
7586
|
+
var GRADLE_TEST_MARKER, GRADLE_TEST_SUMMARY_INIT;
|
|
7587
|
+
var init_gradleTestSummary = __esm({
|
|
7588
|
+
"../../packages/envrun/dist/gradleTestSummary.js"() {
|
|
7589
|
+
"use strict";
|
|
7590
|
+
GRADLE_TEST_MARKER = "terminalhire-gradle-tests:";
|
|
7591
|
+
GRADLE_TEST_SUMMARY_INIT = `// Written by terminalhire (TERM-1122): prints each Test task's counts, because
|
|
7592
|
+
// Gradle prints none when every test passes.
|
|
7593
|
+
allprojects {
|
|
7594
|
+
tasks.withType(Test).configureEach { task ->
|
|
7595
|
+
def label = task.path
|
|
7596
|
+
task.afterSuite { desc, result ->
|
|
7597
|
+
if (desc.parent == null) {
|
|
7598
|
+
println "${GRADLE_TEST_MARKER} task=\${label} total=\${result.testCount} passed=\${result.successfulTestCount} failed=\${result.failedTestCount} skipped=\${result.skippedTestCount}"
|
|
7599
|
+
}
|
|
7600
|
+
}
|
|
7601
|
+
}
|
|
7602
|
+
}
|
|
7603
|
+
`;
|
|
7604
|
+
}
|
|
7605
|
+
});
|
|
7606
|
+
|
|
7116
7607
|
// ../../packages/envrun/dist/thrun.js
|
|
7117
7608
|
import { execFileSync as execFileSync2, spawnSync as spawnSync6 } from "child_process";
|
|
7118
|
-
import { existsSync as existsSync7, mkdirSync as
|
|
7609
|
+
import { existsSync as existsSync7, mkdirSync as mkdirSync7, mkdtempSync as mkdtempSync4, rmSync as rmSync5 } from "fs";
|
|
7119
7610
|
import { randomUUID } from "crypto";
|
|
7120
7611
|
import { tmpdir as tmpdir4 } from "os";
|
|
7121
|
-
import { join as
|
|
7612
|
+
import { join as join14 } from "path";
|
|
7122
7613
|
function git(repoDir, args, allowNonZero = false) {
|
|
7123
7614
|
const res = spawnSync6("git", [...args], {
|
|
7124
7615
|
cwd: repoDir,
|
|
@@ -7133,7 +7624,7 @@ function git(repoDir, args, allowNonZero = false) {
|
|
|
7133
7624
|
return res.stdout ?? "";
|
|
7134
7625
|
}
|
|
7135
7626
|
function collectWorkingDiff(repoDir, opts = {}) {
|
|
7136
|
-
if (!existsSync7(
|
|
7627
|
+
if (!existsSync7(join14(repoDir, ".git"))) {
|
|
7137
7628
|
throw new ThRunError(`${repoDir} is not a git checkout (no .git). \`th run\` ships the working diff, so it needs a repository to read one from.`);
|
|
7138
7629
|
}
|
|
7139
7630
|
const headSha = git(repoDir, ["rev-parse", "HEAD"]).trim();
|
|
@@ -7281,7 +7772,7 @@ function credentialFreeHome() {
|
|
|
7281
7772
|
return credentialFreeHomeDir;
|
|
7282
7773
|
let made;
|
|
7283
7774
|
try {
|
|
7284
|
-
made = mkdtempSync4(
|
|
7775
|
+
made = mkdtempSync4(join14(tmpdir4(), "th-run-nohome-"));
|
|
7285
7776
|
} catch (err) {
|
|
7286
7777
|
throw new RunRefusalError("could not create the empty directory this clone uses as its home, so the clone would read the credentials on this machine instead. That is our environment failing, not your tests: check that the temp directory is writable.", { cause: err });
|
|
7287
7778
|
}
|
|
@@ -7356,7 +7847,7 @@ function cloneTargetAtUnguarded(opts) {
|
|
|
7356
7847
|
if (persisted !== null) {
|
|
7357
7848
|
throw new RunRefusalError(`refusing to clone from a URL carrying ${persisted}: \`git remote add\` writes the source verbatim into .git/config, which is mounted where the repo\u2019s own test command runs. Fetch with the credential out of band so it is never written to disk \u2014 this runner takes one as an HTTP header, which is never persisted.`);
|
|
7358
7849
|
}
|
|
7359
|
-
|
|
7850
|
+
mkdirSync7(opts.dest, { recursive: true });
|
|
7360
7851
|
const runOut = (args) => execFileSync2("git", [...gitConfigArgs(), ...args], {
|
|
7361
7852
|
cwd: opts.dest,
|
|
7362
7853
|
encoding: "utf8",
|
|
@@ -7395,7 +7886,7 @@ function cloneTargetAtUnguarded(opts) {
|
|
|
7395
7886
|
}
|
|
7396
7887
|
function scrubCloneSource(dest, run2) {
|
|
7397
7888
|
run2(["remote", "remove", "origin"]);
|
|
7398
|
-
rmSync5(
|
|
7889
|
+
rmSync5(join14(dest, ".git", "FETCH_HEAD"), { force: true });
|
|
7399
7890
|
}
|
|
7400
7891
|
function publishableTarget(url) {
|
|
7401
7892
|
if (separatorInTarget(url) !== null)
|
|
@@ -7534,12 +8025,18 @@ function refusedRun(fields) {
|
|
|
7534
8025
|
treeDigest: null,
|
|
7535
8026
|
baselinePatchSha256: null,
|
|
7536
8027
|
testCommandSource: "none",
|
|
8028
|
+
// Nothing was booted, so nobody chose an image. Same reasoning as `containerImage: null`
|
|
8029
|
+
// below: it must not read as an image we picked and cannot name.
|
|
8030
|
+
imageSource: "none",
|
|
7537
8031
|
boundaryRefusals: fields.boundaryRefusals,
|
|
7538
8032
|
touchedPaths: fields.touchedPaths,
|
|
7539
8033
|
preview: null,
|
|
7540
8034
|
containerImage: null,
|
|
7541
8035
|
containerImageDigest: null,
|
|
7542
8036
|
leaksClean: null,
|
|
8037
|
+
// Null for the same reason, and never `unobserved`: that word says we tried to take a
|
|
8038
|
+
// census and could not, and here there was nothing to take one of (TERM-1144).
|
|
8039
|
+
leakState: null,
|
|
7543
8040
|
// A refused run never held a lease, so there is no venue to describe. Same
|
|
7544
8041
|
// reasoning as `leaksClean` above: null because nothing happened, and it must
|
|
7545
8042
|
// not read as a venue we looked at and could not name.
|
|
@@ -7767,10 +8264,10 @@ async function runVerification(req, ctx) {
|
|
|
7767
8264
|
venueIdentity: null
|
|
7768
8265
|
};
|
|
7769
8266
|
}
|
|
7770
|
-
const stage =
|
|
7771
|
-
const cloneDir =
|
|
7772
|
-
const scratch =
|
|
7773
|
-
|
|
8267
|
+
const stage = join14(req.scratchRoot, runId);
|
|
8268
|
+
const cloneDir = join14(stage, "clone");
|
|
8269
|
+
const scratch = join14(stage, "scratch");
|
|
8270
|
+
mkdirSync7(scratch, { recursive: true });
|
|
7774
8271
|
assertSafeTargetSha(req.targetSha);
|
|
7775
8272
|
progress("clone", `${publishableTarget(req.targetRepo)} @ ${req.targetSha.slice(0, 12)}`);
|
|
7776
8273
|
cloneTargetAt({
|
|
@@ -7797,7 +8294,8 @@ async function runVerification(req, ctx) {
|
|
|
7797
8294
|
const derived = deriveEnvironmentSpec(cloneDir);
|
|
7798
8295
|
const spec = req.testCommandOverride === void 0 ? derived : { ...derived, testCommand: req.testCommandOverride };
|
|
7799
8296
|
progress("derive", `runtime=${spec.runtime} install=${String(spec.installCommand)} test=${String(spec.testCommand)}`);
|
|
7800
|
-
|
|
8297
|
+
refuseUnbuildableSpec(spec);
|
|
8298
|
+
const image = placement.imageFor(spec.runtime, req.image, spec.runtimeVersion, imageVariantFor(spec));
|
|
7801
8299
|
const resolved = await resolveLease(placement, runId);
|
|
7802
8300
|
if (!resolved.ok) {
|
|
7803
8301
|
return {
|
|
@@ -7832,10 +8330,12 @@ async function runVerification(req, ctx) {
|
|
|
7832
8330
|
const lease = resolved.lease;
|
|
7833
8331
|
try {
|
|
7834
8332
|
buildJail(scratch);
|
|
8333
|
+
if (spec.runtime === "jvm")
|
|
8334
|
+
stageGradleTestSummary(localJailPaths(scratch).jail);
|
|
7835
8335
|
const venuePaths = await lease.stage({
|
|
7836
8336
|
cloneDir,
|
|
7837
8337
|
scratchRoot: scratch,
|
|
7838
|
-
previewDir:
|
|
8338
|
+
previewDir: join14(stage, "preview"),
|
|
7839
8339
|
// On a dispatched run the commit is the statement of what was tested, so
|
|
7840
8340
|
// it rides with the tree and the venue seam refuses a tree that is not
|
|
7841
8341
|
// that commit (design §6 item 4, TERM-892 — the guard lives in
|
|
@@ -7882,7 +8382,7 @@ async function runVerification(req, ctx) {
|
|
|
7882
8382
|
// that `founder-declared` (as this did for one review round) signs the counterparty's
|
|
7883
8383
|
// name onto the developer's choice, which is worse than laundering it as `detected`:
|
|
7884
8384
|
// it is a specific false attribution inside a field a reviewer trusts.
|
|
7885
|
-
testCommandSource: req.testCommandOverride !== void 0 ? req.testCommandOverrideOrigin === "founder" ? "founder-declared" : "developer-declared" : derived.testCommand === null ? "none" : "detected",
|
|
8385
|
+
testCommandSource: req.testCommandOverride !== void 0 ? req.testCommandOverrideOrigin === "founder" ? "founder-declared" : req.testCommandOverrideOrigin === "operator" ? "operator-declared" : "developer-declared" : derived.testCommand === null ? "none" : "detected",
|
|
7886
8386
|
boundaryRefusals: [],
|
|
7887
8387
|
touchedPaths: pre.touchedPaths,
|
|
7888
8388
|
preview: null,
|
|
@@ -7892,7 +8392,14 @@ async function runVerification(req, ctx) {
|
|
|
7892
8392
|
// that measures one; a run without it is recordable but not attestable —
|
|
7893
8393
|
// `toAcceptancePredicate` refuses `missing-image-digest` (TERM-893).
|
|
7894
8394
|
containerImageDigest: null,
|
|
8395
|
+
// The image half of the provenance pair above. `detected` means the runtime mapping
|
|
8396
|
+
// chose the environment; an override names the human. Defaulting an unstated origin to
|
|
8397
|
+
// `developer-declared` is deliberate and matches `testCommandSource`: `th run --image`
|
|
8398
|
+
// runs on the developer's machine, judging the developer's work, so attributing it to
|
|
8399
|
+
// the operator would sign our name onto their choice.
|
|
8400
|
+
imageSource: req.image === void 0 ? "detected" : req.imageOrigin === "operator" ? "operator-declared" : "developer-declared",
|
|
7895
8401
|
leaksClean: verdict.leaks.clean,
|
|
8402
|
+
leakState: verdict.leaks.state,
|
|
7896
8403
|
// Built from the LEASE, over the client that ran the steps — never from
|
|
7897
8404
|
// `req.placement`, which is a request. `venueDescriptor.ts` carries the
|
|
7898
8405
|
// reasoning and the #735 failure that makes the distinction load-bearing.
|
|
@@ -7950,6 +8457,7 @@ var init_thrun = __esm({
|
|
|
7950
8457
|
init_execute();
|
|
7951
8458
|
init_placement();
|
|
7952
8459
|
init_venue();
|
|
8460
|
+
init_gradleTestSummary();
|
|
7953
8461
|
init_venueDescriptor();
|
|
7954
8462
|
init_result();
|
|
7955
8463
|
ThRunError = class extends Error {
|
|
@@ -8193,7 +8701,7 @@ var init_dbplan = __esm({
|
|
|
8193
8701
|
// ../../packages/envrun/dist/dbstack.js
|
|
8194
8702
|
import { spawnSync as spawnSync7 } from "child_process";
|
|
8195
8703
|
import { randomBytes as randomBytes6 } from "crypto";
|
|
8196
|
-
import { mkdirSync as
|
|
8704
|
+
import { mkdirSync as mkdirSync8 } from "fs";
|
|
8197
8705
|
function installCommandFor(runner) {
|
|
8198
8706
|
switch (runner) {
|
|
8199
8707
|
case "sql":
|
|
@@ -8424,7 +8932,7 @@ function startDatabase(req) {
|
|
|
8424
8932
|
if (!ready.ok) {
|
|
8425
8933
|
throw new DbStackError(`INFRASTRUCTURE/INCONCLUSIVE: Postgres never accepted queries at 127.0.0.1:${String(creds.port)} within the readiness window: ${ready.detail}`);
|
|
8426
8934
|
}
|
|
8427
|
-
const
|
|
8935
|
+
const query2 = (sql) => {
|
|
8428
8936
|
const res = docker2([
|
|
8429
8937
|
"exec",
|
|
8430
8938
|
`--env=PGPASSWORD=${creds.password}`,
|
|
@@ -8472,7 +8980,7 @@ function startDatabase(req) {
|
|
|
8472
8980
|
credentials: creds,
|
|
8473
8981
|
image,
|
|
8474
8982
|
readyMs: ready.ms,
|
|
8475
|
-
query,
|
|
8983
|
+
query: query2,
|
|
8476
8984
|
runOnNetwork,
|
|
8477
8985
|
teardown
|
|
8478
8986
|
};
|
|
@@ -8535,8 +9043,8 @@ async function installLocalMigrationTooling(opts) {
|
|
|
8535
9043
|
};
|
|
8536
9044
|
}
|
|
8537
9045
|
const { jail, tmp } = buildJail(opts.scratchRoot);
|
|
8538
|
-
|
|
8539
|
-
|
|
9046
|
+
mkdirSync8(jail, { recursive: true });
|
|
9047
|
+
mkdirSync8(tmp, { recursive: true });
|
|
8540
9048
|
const spec = {
|
|
8541
9049
|
profile: "install",
|
|
8542
9050
|
clone: opts.repoDir,
|
|
@@ -8831,6 +9339,7 @@ __export(dist_exports, {
|
|
|
8831
9339
|
GOOGLE_JWKS_URL: () => GOOGLE_JWKS_URL,
|
|
8832
9340
|
HostedVenueError: () => HostedVenueError,
|
|
8833
9341
|
JWKS_FETCH_TIMEOUT_MS: () => JWKS_FETCH_TIMEOUT_MS,
|
|
9342
|
+
LEAK_STATES: () => LEAK_STATES,
|
|
8834
9343
|
LOCAL_MEASUREMENT_PREFIX: () => LOCAL_MEASUREMENT_PREFIX,
|
|
8835
9344
|
LabelWatch: () => LabelWatch,
|
|
8836
9345
|
MIN_GIT_VERSION_FOR_END_OF_OPTIONS: () => MIN_GIT_VERSION_FOR_END_OF_OPTIONS,
|
|
@@ -8847,6 +9356,7 @@ __export(dist_exports, {
|
|
|
8847
9356
|
REDACTED_TARGET_SHA: () => REDACTED_TARGET_SHA,
|
|
8848
9357
|
RELEASED_LEASE_CENSUS_REASON: () => RELEASED_LEASE_CENSUS_REASON,
|
|
8849
9358
|
REPO_DIGEST_RE: () => REPO_DIGEST_RE,
|
|
9359
|
+
RUN_IMAGE_SOURCES: () => RUN_IMAGE_SOURCES,
|
|
8850
9360
|
RUN_LABEL_KEY: () => RUN_LABEL_KEY,
|
|
8851
9361
|
RUN_RESULT_FIELDS: () => RUN_RESULT_FIELDS,
|
|
8852
9362
|
RUN_RESULT_SCHEMA: () => RUN_RESULT_SCHEMA,
|
|
@@ -8863,6 +9373,7 @@ __export(dist_exports, {
|
|
|
8863
9373
|
VENUE_GCLOUD_CONFIG: () => VENUE_GCLOUD_CONFIG,
|
|
8864
9374
|
VENUE_SSH_USER: () => VENUE_SSH_USER,
|
|
8865
9375
|
VENV_DIR: () => VENV_DIR,
|
|
9376
|
+
VERIFICATION_OUTCOMES: () => VERIFICATION_OUTCOMES,
|
|
8866
9377
|
VenueRollbackError: () => VenueRollbackError,
|
|
8867
9378
|
acquireTransactionally: () => acquireTransactionally,
|
|
8868
9379
|
alembicChainPosition: () => alembicChainPosition,
|
|
@@ -8876,6 +9387,7 @@ __export(dist_exports, {
|
|
|
8876
9387
|
assertVenueOwnerDeclared: () => assertVenueOwnerDeclared,
|
|
8877
9388
|
bookkeepingFor: () => bookkeepingFor,
|
|
8878
9389
|
census: () => census,
|
|
9390
|
+
censusReport: () => censusReport,
|
|
8879
9391
|
censusTotal: () => censusTotal,
|
|
8880
9392
|
classifyBootFailure: () => classifyBootFailure,
|
|
8881
9393
|
classifyProbeFailure: () => classifyProbeFailure,
|
|
@@ -8895,6 +9407,7 @@ __export(dist_exports, {
|
|
|
8895
9407
|
detectRunner: () => detectRunner,
|
|
8896
9408
|
endOfOptionsUnsupported: () => endOfOptionsUnsupported,
|
|
8897
9409
|
ensureVenueServiceCredentials: () => ensureVenueServiceCredentials,
|
|
9410
|
+
exitCodeFor: () => exitCodeFor,
|
|
8898
9411
|
failureSourceOf: () => failureSourceOf,
|
|
8899
9412
|
findRunRefusal: () => findRunRefusal,
|
|
8900
9413
|
gcpBootArgv: () => gcpBootArgv,
|
|
@@ -8909,6 +9422,7 @@ __export(dist_exports, {
|
|
|
8909
9422
|
identityProbeCommand: () => identityProbeCommand,
|
|
8910
9423
|
imageForRuntime: () => imageForRuntime,
|
|
8911
9424
|
imageRepo: () => imageRepo,
|
|
9425
|
+
imageVariantFor: () => imageVariantFor,
|
|
8912
9426
|
installCommandFor: () => installCommandFor,
|
|
8913
9427
|
installLocalMigrationTooling: () => installLocalMigrationTooling,
|
|
8914
9428
|
isBookkeepingTable: () => isBookkeepingTable,
|
|
@@ -8938,6 +9452,7 @@ __export(dist_exports, {
|
|
|
8938
9452
|
readSchema: () => readSchema,
|
|
8939
9453
|
recordedApplied: () => recordedApplied,
|
|
8940
9454
|
refuseSshTransport: () => refuseSshTransport,
|
|
9455
|
+
refuseUnbuildableSpec: () => refuseUnbuildableSpec,
|
|
8941
9456
|
renderRunReport: () => renderRunReport,
|
|
8942
9457
|
renderVenueLine: () => renderVenueLine,
|
|
8943
9458
|
renderVerdictLine: () => renderVerdictLine,
|
|
@@ -8985,7 +9500,7 @@ var init_dist4 = __esm({
|
|
|
8985
9500
|
// bin/jpi-run.js
|
|
8986
9501
|
import { existsSync as existsSync8, readFileSync as readFileSync6, realpathSync as realpathSync2 } from "fs";
|
|
8987
9502
|
import { execFileSync as execFileSync3 } from "child_process";
|
|
8988
|
-
import { join as
|
|
9503
|
+
import { join as join15, resolve } from "path";
|
|
8989
9504
|
import { tmpdir as tmpdir5 } from "os";
|
|
8990
9505
|
import { mkdtempSync as mkdtempSync5, rmSync as rmSync6 } from "fs";
|
|
8991
9506
|
|
|
@@ -9406,6 +9921,9 @@ Options:
|
|
|
9406
9921
|
--keep <seconds> Hold the preview open this long, at most ${String(KEEP_MAX_SECONDS)}
|
|
9407
9922
|
(default: until Ctrl-C).
|
|
9408
9923
|
--test-command <cmd> Disclosed override of the derived test command.
|
|
9924
|
+
--image <ref> Disclosed override of the container image. Use when no image is
|
|
9925
|
+
mapped for the repo's runtime. Recorded as developer-declared
|
|
9926
|
+
provenance, which the attestation refuses to sign.
|
|
9409
9927
|
--help
|
|
9410
9928
|
|
|
9411
9929
|
Defaults are read from .th-run.json in the local checkout when present, so a
|
|
@@ -9440,7 +9958,7 @@ function parseArgs(argv) {
|
|
|
9440
9958
|
function runScratchRoot() {
|
|
9441
9959
|
let root;
|
|
9442
9960
|
try {
|
|
9443
|
-
root = mkdtempSync5(
|
|
9961
|
+
root = mkdtempSync5(join15(tmpdir5(), "th-run-"));
|
|
9444
9962
|
} catch (err) {
|
|
9445
9963
|
process.stderr.write(
|
|
9446
9964
|
`terminalhire: could not create the temporary directory this run works in, so nothing was cloned and nothing was executed. That is our environment failing, not your tests: check that the temp directory is writable. (${String(err?.message ?? err)})
|
|
@@ -9477,7 +9995,7 @@ async function loadEngine() {
|
|
|
9477
9995
|
}
|
|
9478
9996
|
}
|
|
9479
9997
|
function readConfig(localDir) {
|
|
9480
|
-
const file =
|
|
9998
|
+
const file = join15(localDir, ".th-run.json");
|
|
9481
9999
|
if (!existsSync8(file)) return {};
|
|
9482
10000
|
try {
|
|
9483
10001
|
const parsed = JSON.parse(readFileSync6(file, "utf8"));
|
|
@@ -9584,6 +10102,11 @@ async function once(engine, opts) {
|
|
|
9584
10102
|
preview: opts.preview,
|
|
9585
10103
|
...opts.placement ? { placement: opts.placement } : {},
|
|
9586
10104
|
...opts.testCommand ? { testCommandOverride: opts.testCommand } : {},
|
|
10105
|
+
// No `imageOrigin` beside it, deliberately. This flag runs on the DEVELOPER's machine,
|
|
10106
|
+
// judging the developer's work, so it takes `thrun`'s default of `developer` — the same
|
|
10107
|
+
// reasoning that makes `--test-command` developer-declared. An operator override is a
|
|
10108
|
+
// server-side caller, not this flag.
|
|
10109
|
+
...opts.image ? { image: opts.image } : {},
|
|
9587
10110
|
onProgress: (stage, detail) => {
|
|
9588
10111
|
if (!opts.json) process.stderr.write(` ${stage.padEnd(8)} ${detail}
|
|
9589
10112
|
`);
|
|
@@ -9629,8 +10152,7 @@ Expiry and revocation are not built yet (TERM-350 phase 5); Ctrl-C is the only s
|
|
|
9629
10152
|
process.stderr.write(` total ${String(Date.now() - started)}ms
|
|
9630
10153
|
`);
|
|
9631
10154
|
}
|
|
9632
|
-
|
|
9633
|
-
return result.outcome === "completed" ? 0 : 1;
|
|
10155
|
+
return engine.exitCodeFor(result);
|
|
9634
10156
|
}
|
|
9635
10157
|
async function run() {
|
|
9636
10158
|
const argv = process.argv.slice(2);
|
|
@@ -9697,6 +10219,7 @@ async function run() {
|
|
|
9697
10219
|
watch: parsed.bools.has("watch"),
|
|
9698
10220
|
json: parsed.bools.has("json"),
|
|
9699
10221
|
testCommand: pick("test-command") ?? null,
|
|
10222
|
+
image: pick("image") ?? null,
|
|
9700
10223
|
keepSeconds,
|
|
9701
10224
|
scratch: runScratchRoot()
|
|
9702
10225
|
};
|