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.
@@ -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 ids(docker3, args) {
1533
+ function query(docker3, args) {
1331
1534
  const res = docker3.sync([...args], { timeoutMs: 15e3 });
1332
- if (res.error || res.status !== 0)
1333
- return [];
1334
- return res.stdout.split("\n").map((s) => s.trim()).filter((s) => s.length > 0);
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 { labelObserved, reaped, clean: labelObserved && reaped, peak, after, note };
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
- } : { observed: true, census: census(docker3, label), unobservedReason: null }),
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 = RUNTIME_IMAGES[runtime];
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 missing interpreter surfaces as "command not found", which the classifier reads as tests-failed \u2014 a false red blamed on the repo.`);
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 = RUNTIME_IMAGES[runtime];
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 image = imageForRuntime(spec.runtime, override, declared);
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
- var RUN_TEST_COMMAND_SOURCES, RUN_RESULT_SCHEMA, RUN_RESULT_FIELDS, RENDER_NONE, FIELD_VIEWS;
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 = [...TEST_COMMAND_SOURCES, "developer-declared"];
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
- leaksClean: (r) => r.leaksClean === null ? null : r.leaksClean ? null : "WARNING labelled Docker objects survived teardown",
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` + ZERO project roles bound the BLAST RADIUS of a lifted token.
3842
- // No scopes → the OAuth access-token endpoint serves nothing (measured,
3843
- // TERM-706); zero roles → the token authorizes no GCP API. But it is NOT
3844
- // valueless: it is the attestation evidence itself, so a lifted token still
3845
- // authorizes a false claim about WHERE a run happened (the steal-a-true-token
3846
- // attack in the item-2 spike). `--no-scopes` stays because it removes lateral
3847
- // GCP access; it does not make losing the identity token cheap.
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("pom.xml"))
6347
- return { install: "mvn -B dependency:go-offline", test: "mvn -B test" };
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 { install: `${wrapper} dependencies`, test: `${wrapper} test` };
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 = exactVersion(reduced);
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 = exactVersion(repo.readText(viaFile.command));
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 mkdirSync6, mkdtempSync as mkdtempSync4, rmSync as rmSync5 } from "fs";
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 join13 } from "path";
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(join13(repoDir, ".git"))) {
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(join13(tmpdir4(), "th-run-nohome-"));
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
- mkdirSync6(opts.dest, { recursive: true });
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(join13(dest, ".git", "FETCH_HEAD"), { force: true });
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 = join13(req.scratchRoot, runId);
7771
- const cloneDir = join13(stage, "clone");
7772
- const scratch = join13(stage, "scratch");
7773
- mkdirSync6(scratch, { recursive: true });
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
- const image = placement.imageFor(spec.runtime, req.image, spec.runtimeVersion);
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: join13(stage, "preview"),
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 mkdirSync7 } from "fs";
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 query = (sql) => {
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
- mkdirSync7(jail, { recursive: true });
8539
- mkdirSync7(tmp, { recursive: true });
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 join14, resolve } from "path";
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(join14(tmpdir5(), "th-run-"));
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 = join14(localDir, ".th-run.json");
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
- if (result.status === "refused") return 2;
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
  };