terminalhire 0.42.19 → 0.42.21

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.
@@ -293,7 +293,7 @@ var init_classify = __esm({
293
293
  });
294
294
 
295
295
  // ../../packages/containment/dist/env.js
296
- import { homedir as homedir3 } from "os";
296
+ import { homedir as homedir4 } from "os";
297
297
  import { posix } from "path";
298
298
  function realHomeCandidates(source) {
299
299
  const candidates = [];
@@ -302,7 +302,7 @@ function realHomeCandidates(source) {
302
302
  candidates.push(fromEnv);
303
303
  let fromOs;
304
304
  try {
305
- fromOs = homedir3();
305
+ fromOs = homedir4();
306
306
  } catch {
307
307
  fromOs = void 0;
308
308
  }
@@ -336,24 +336,24 @@ function scrubEnv(source, opts) {
336
336
  env["PATH"] = [...opts.toolPaths ?? [], ...BASE_PATH].join(":");
337
337
  env["HOME"] = jailHome;
338
338
  env["TMPDIR"] = tmpDir;
339
- env["XDG_CONFIG_HOME"] = join4(jailHome, ".config");
340
- env["XDG_CACHE_HOME"] = join4(jailHome, ".cache");
341
- env["XDG_DATA_HOME"] = join4(jailHome, ".local", "share");
342
- env["GIT_CONFIG_GLOBAL"] = join4(jailHome, ".gitconfig");
339
+ env["XDG_CONFIG_HOME"] = join5(jailHome, ".config");
340
+ env["XDG_CACHE_HOME"] = join5(jailHome, ".cache");
341
+ env["XDG_DATA_HOME"] = join5(jailHome, ".local", "share");
342
+ env["GIT_CONFIG_GLOBAL"] = join5(jailHome, ".gitconfig");
343
343
  env["GIT_CONFIG_SYSTEM"] = "/dev/null";
344
344
  env["GIT_TERMINAL_PROMPT"] = "0";
345
345
  env["GIT_ASKPASS"] = "/usr/bin/false";
346
346
  env["SSH_ASKPASS"] = "/usr/bin/false";
347
- env["npm_config_userconfig"] = join4(jailHome, ".npmrc");
348
- env["npm_config_cache"] = join4(jailHome, ".npm");
347
+ env["npm_config_userconfig"] = join5(jailHome, ".npmrc");
348
+ env["npm_config_cache"] = join5(jailHome, ".npm");
349
349
  env["npm_config_update_notifier"] = "false";
350
350
  env["npm_config_fund"] = "false";
351
351
  env["npm_config_audit"] = "false";
352
- env["GOPATH"] = join4(jailHome, "go");
353
- env["GOMODCACHE"] = join4(jailHome, "go", "pkg", "mod");
354
- env["GOCACHE"] = join4(jailHome, ".cache", "go-build");
352
+ env["GOPATH"] = join5(jailHome, "go");
353
+ env["GOMODCACHE"] = join5(jailHome, "go", "pkg", "mod");
354
+ env["GOCACHE"] = join5(jailHome, ".cache", "go-build");
355
355
  env["GOFLAGS"] = "-modcacherw";
356
- env["CARGO_HOME"] = join4(jailHome, ".cargo");
356
+ env["CARGO_HOME"] = join5(jailHome, ".cargo");
357
357
  const proxy = opts.proxyUrl ?? DEAD_PROXY;
358
358
  env["HTTP_PROXY"] = proxy;
359
359
  env["HTTPS_PROXY"] = proxy;
@@ -381,11 +381,11 @@ function auditEnv(env) {
381
381
  }
382
382
  return leaks;
383
383
  }
384
- var join4, ENV_ALLOWLIST, BASE_PATH, DEAD_PROXY, SandboxEnvError, FORBIDDEN_EXTRA;
384
+ var join5, ENV_ALLOWLIST, BASE_PATH, DEAD_PROXY, SandboxEnvError, FORBIDDEN_EXTRA;
385
385
  var init_env = __esm({
386
386
  "../../packages/containment/dist/env.js"() {
387
387
  "use strict";
388
- join4 = posix.join;
388
+ join5 = posix.join;
389
389
  ENV_ALLOWLIST = ["LANG", "LC_ALL", "LC_CTYPE", "TZ", "TERM"];
390
390
  BASE_PATH = ["/usr/bin", "/bin", "/usr/sbin", "/sbin"];
391
391
  DEAD_PROXY = "http://127.0.0.1:1";
@@ -405,8 +405,8 @@ var init_reap = __esm({
405
405
 
406
406
  // ../../packages/containment/dist/fence.js
407
407
  import { spawn, spawnSync as spawnSync2 } from "child_process";
408
- import { existsSync as existsSync3, mkdirSync as mkdirSync2, realpathSync, writeFileSync as writeFileSync2 } from "fs";
409
- import { dirname as dirname2, isAbsolute, join as join5, posix as posix2 } from "path";
408
+ import { existsSync as existsSync4, mkdirSync as mkdirSync4, realpathSync, writeFileSync as writeFileSync3 } from "fs";
409
+ import { dirname as dirname2, isAbsolute, join as join6, posix as posix2 } from "path";
410
410
  import { fileURLToPath as fileURLToPath2 } from "url";
411
411
  function canonical(path, label) {
412
412
  if (!isAbsolute(path)) {
@@ -455,15 +455,15 @@ function idOrNull(fn) {
455
455
  return typeof f === "function" ? f.call(process) : null;
456
456
  }
457
457
  function buildJail(root, guestUser) {
458
- const jail = join5(root, JAIL_SEGMENT);
459
- const tmp = join5(root, JAIL_TMP_SEGMENT);
460
- for (const dir of [jail, tmp, join5(jail, ".config"), join5(jail, ".cache"), join5(jail, ".npm")]) {
461
- mkdirSync2(dir, { recursive: true });
462
- }
463
- writeFileSync2(join5(jail, ".npmrc"), "", "utf8");
464
- writeFileSync2(join5(jail, JAIL_PASSWD_FILE), guestUser ? jailPasswd(guestUser.uid, guestUser.gid) : jailPasswd(), "utf8");
465
- writeFileSync2(join5(jail, JAIL_GROUP_FILE), guestUser ? jailGroup(guestUser.gid) : jailGroup(), "utf8");
466
- writeFileSync2(join5(jail, ".gitconfig"), '[user]\n name = sandbox\n email = sandbox@localhost\n[safe]\n directory = *\n[url "https://github.com/"]\n insteadOf = ssh://git@github.com/\n insteadOf = git@github.com:\n', "utf8");
458
+ const jail = join6(root, JAIL_SEGMENT);
459
+ const tmp = join6(root, JAIL_TMP_SEGMENT);
460
+ for (const dir of [jail, tmp, join6(jail, ".config"), join6(jail, ".cache"), join6(jail, ".npm")]) {
461
+ mkdirSync4(dir, { recursive: true });
462
+ }
463
+ writeFileSync3(join6(jail, ".npmrc"), "", "utf8");
464
+ writeFileSync3(join6(jail, JAIL_PASSWD_FILE), guestUser ? jailPasswd(guestUser.uid, guestUser.gid) : jailPasswd(), "utf8");
465
+ writeFileSync3(join6(jail, JAIL_GROUP_FILE), guestUser ? jailGroup(guestUser.gid) : jailGroup(), "utf8");
466
+ writeFileSync3(join6(jail, ".gitconfig"), '[user]\n name = sandbox\n email = sandbox@localhost\n[safe]\n directory = *\n[url "https://github.com/"]\n insteadOf = ssh://git@github.com/\n insteadOf = git@github.com:\n', "utf8");
467
467
  return { jail, tmp };
468
468
  }
469
469
  var FenceError, ContainmentError, ContainmentRefusalError, JAIL_PASSWD_FILE, JAIL_GROUP_FILE, GUEST_JAIL, FENCE_USER, JAIL_SEGMENT, JAIL_TMP_SEGMENT;
@@ -551,12 +551,33 @@ function scrubEndpointEnv(env) {
551
551
  }
552
552
  return out;
553
553
  }
554
- function remoteDockerClient(endpoint) {
554
+ function containerFlagsAt(args) {
555
+ if (args[0] === "run" || args[0] === "create")
556
+ return 1;
557
+ if (args[0] === "container" && (args[1] === "run" || args[1] === "create"))
558
+ return 2;
559
+ return -1;
560
+ }
561
+ function remoteDockerClient(endpoint, opts = {}) {
555
562
  if (endpoint.trim().length === 0) {
556
563
  throw new Error("remoteDockerClient: endpoint must be a non-empty docker host");
557
564
  }
558
565
  const target = endpoint.trim();
559
- const argv = (args) => ["--host", target, ...args];
566
+ const tlsFlags = [];
567
+ if (opts.tls) {
568
+ for (const key of ["caPath", "certPath", "keyPath"]) {
569
+ if (opts.tls[key].trim().length === 0) {
570
+ throw new Error(`remoteDockerClient: tls.${key} must name a file`);
571
+ }
572
+ }
573
+ tlsFlags.push("--tlsverify", "--tlscacert", opts.tls.caPath, "--tlscert", opts.tls.certPath, "--tlskey", opts.tls.keyPath);
574
+ }
575
+ const runArgs = opts.runArgs ?? [];
576
+ const argv = (args) => {
577
+ const at = runArgs.length > 0 ? containerFlagsAt(args) : -1;
578
+ const sub = at < 0 ? [...args] : [...args.slice(0, at), ...runArgs, ...args.slice(at)];
579
+ return ["--host", target, ...tlsFlags, ...sub];
580
+ };
560
581
  return {
561
582
  endpoint: target,
562
583
  // Always empty, and it is a fact about this client rather than about the
@@ -573,10 +594,10 @@ function remoteDockerClient(endpoint) {
573
594
  env: scrubEndpointEnv(process.env)
574
595
  });
575
596
  },
576
- sync(args, opts) {
597
+ sync(args, opts2) {
577
598
  return syncResult(spawnSync3(DOCKER_BIN, argv(args), {
578
599
  encoding: "utf8",
579
- timeout: opts?.timeoutMs,
600
+ timeout: opts2?.timeoutMs,
580
601
  env: scrubEndpointEnv(process.env)
581
602
  }));
582
603
  },
@@ -657,8 +678,8 @@ var init_egressProxy = __esm({
657
678
 
658
679
  // ../../packages/containment/dist/container.js
659
680
  import { fileURLToPath as fileURLToPath3 } from "url";
660
- import { dirname as dirname3, join as join6 } from "path";
661
- import { chmodSync, copyFileSync, existsSync as existsSync4, mkdtempSync, rmSync } from "fs";
681
+ import { dirname as dirname3, join as join7 } from "path";
682
+ import { chmodSync, copyFileSync, existsSync as existsSync5, mkdtempSync, rmSync as rmSync2 } from "fs";
662
683
  import { tmpdir } from "os";
663
684
  function scrubEnvPathsFor(containmentKind, host) {
664
685
  return containmentKind === "container" ? { jailHome: GUEST.jail, tmpDir: GUEST.tmp } : { jailHome: host.jail, tmpDir: host.tmp };
@@ -806,7 +827,7 @@ function guestIdentityMounts(spec) {
806
827
  return [];
807
828
  const domain = pathDomainOf(spec);
808
829
  const resolve2 = resolverFor(domain);
809
- const under = domain === "venue" ? venueJoin : join6;
830
+ const under = domain === "venue" ? venueJoin : join7;
810
831
  const jail = resolve2(spec.jail, "jail");
811
832
  const passwd = resolve2(under(jail, JAIL_PASSWD_FILE), "the jail passwd file");
812
833
  const group = resolve2(under(jail, JAIL_GROUP_FILE), "the jail group file");
@@ -992,8 +1013,8 @@ function dockerSync(d, args, timeoutMs = DOCKER_TIMEOUT_MS) {
992
1013
  };
993
1014
  }
994
1015
  function pickProxyDir(baseDir) {
995
- const scoped = join6(baseDir, "proxy");
996
- if (existsSync4(join6(scoped, "proxyEntry.js")))
1016
+ const scoped = join7(baseDir, "proxy");
1017
+ if (existsSync5(join7(scoped, "proxyEntry.js")))
997
1018
  return scoped;
998
1019
  return baseDir;
999
1020
  }
@@ -1001,22 +1022,22 @@ function resolveProxyCodeSource() {
1001
1022
  return pickProxyDir(dirname3(fileURLToPath3(import.meta.url)));
1002
1023
  }
1003
1024
  function stageProxyCode(source = resolveProxyCodeSource()) {
1004
- const missing = PROXY_FILES.map((f) => join6(source, f)).filter((p) => !existsSync4(p));
1025
+ const missing = PROXY_FILES.map((f) => join7(source, f)).filter((p) => !existsSync5(p));
1005
1026
  if (missing.length > 0) {
1006
1027
  throw new FenceError(`the egress proxy is missing from this install: ${missing.join(", ")} not found. Reinstall the CLI, or update the Claude Code plugin.`);
1007
1028
  }
1008
- const dir = mkdtempSync(join6(tmpdir(), "th-proxy-"));
1029
+ const dir = mkdtempSync(join7(tmpdir(), "th-proxy-"));
1009
1030
  try {
1010
1031
  for (const file of PROXY_FILES) {
1011
- copyFileSync(join6(source, file), join6(dir, file));
1032
+ copyFileSync(join7(source, file), join7(dir, file));
1012
1033
  }
1013
1034
  for (const file of PROXY_FILES) {
1014
- chmodSync(join6(dir, file), 420);
1035
+ chmodSync(join7(dir, file), 420);
1015
1036
  }
1016
1037
  chmodSync(dir, 493);
1017
1038
  } catch (err) {
1018
1039
  try {
1019
- rmSync(dir, { recursive: true, force: true });
1040
+ rmSync2(dir, { recursive: true, force: true });
1020
1041
  } catch {
1021
1042
  }
1022
1043
  throw err;
@@ -1025,7 +1046,7 @@ function stageProxyCode(source = resolveProxyCodeSource()) {
1025
1046
  dir,
1026
1047
  cleanup: () => {
1027
1048
  try {
1028
- rmSync(dir, { recursive: true, force: true });
1049
+ rmSync2(dir, { recursive: true, force: true });
1029
1050
  } catch {
1030
1051
  }
1031
1052
  }
@@ -1438,9 +1459,9 @@ var init_previewRegistry = __esm({
1438
1459
  });
1439
1460
 
1440
1461
  // ../../packages/envrun/dist/preview.js
1441
- import { randomBytes } from "crypto";
1442
- import { mkdirSync as mkdirSync3, writeFileSync as writeFileSync3 } from "fs";
1443
- import { join as join7 } from "path";
1462
+ import { randomBytes as randomBytes2 } from "crypto";
1463
+ import { mkdirSync as mkdirSync5, writeFileSync as writeFileSync4 } from "fs";
1464
+ import { join as join8 } from "path";
1444
1465
  function docker(client, args, timeoutMs = 6e4) {
1445
1466
  const res = client.sync([...args], { timeoutMs });
1446
1467
  return {
@@ -1498,13 +1519,13 @@ async function startPreview(req) {
1498
1519
  if (!LOOPBACK_BINDS.has(bindAddress) && req.authToken === void 0) {
1499
1520
  throw new PreviewError(`refusing to publish the preview on ${bindAddress} without an explicit authToken: a bind wider than loopback puts this run \u2014 the test output tail included \u2014 on the developer's local network`);
1500
1521
  }
1501
- const authToken = req.authToken ?? randomBytes(24).toString("base64url");
1522
+ const authToken = req.authToken ?? randomBytes2(24).toString("base64url");
1502
1523
  const envArgs = ["--env", `PREVIEW_AUTH_TOKEN=${authToken}`];
1503
1524
  const probeHost = WILDCARD_BINDS.has(bindAddress) ? "127.0.0.1" : bindAddress;
1504
1525
  const probeAuthority = probeHost.includes(":") ? `[${probeHost}]` : probeHost;
1505
- mkdirSync3(req.scratchDir, { recursive: true });
1506
- const docPath = join7(req.scratchDir, "preview-run.json");
1507
- writeFileSync3(docPath, JSON.stringify(req.document, null, 2), "utf8");
1526
+ mkdirSync5(req.scratchDir, { recursive: true });
1527
+ const docPath = join8(req.scratchDir, "preview-run.json");
1528
+ writeFileSync4(docPath, JSON.stringify(req.document, null, 2), "utf8");
1508
1529
  const teardown = () => {
1509
1530
  livePreviews.deregister(client, container);
1510
1531
  for (let i = 0; i < 3; i += 1) {
@@ -1693,11 +1714,11 @@ http
1693
1714
  });
1694
1715
 
1695
1716
  // ../../packages/envrun/dist/venue.js
1696
- import { join as join8 } from "path";
1717
+ import { join as join9 } from "path";
1697
1718
  function localJailPaths(scratchRoot) {
1698
1719
  return {
1699
- jail: join8(scratchRoot, JAIL_SEGMENT),
1700
- tmp: join8(scratchRoot, JAIL_TMP_SEGMENT)
1720
+ jail: join9(scratchRoot, JAIL_SEGMENT),
1721
+ tmp: join9(scratchRoot, JAIL_TMP_SEGMENT)
1701
1722
  };
1702
1723
  }
1703
1724
  function localVenue() {
@@ -2352,7 +2373,7 @@ var init_dsse = __esm({
2352
2373
  });
2353
2374
 
2354
2375
  // ../../packages/attest/dist/sealedbox.js
2355
- import { createCipheriv, createDecipheriv, diffieHellman, generateKeyPairSync as generateKeyPairSync2, hkdfSync, randomBytes as randomBytes2 } from "crypto";
2376
+ import { createCipheriv, createDecipheriv, diffieHellman, generateKeyPairSync as generateKeyPairSync2, hkdfSync, randomBytes as randomBytes3 } from "crypto";
2356
2377
  var init_sealedbox = __esm({
2357
2378
  "../../packages/attest/dist/sealedbox.js"() {
2358
2379
  "use strict";
@@ -2362,7 +2383,7 @@ var init_sealedbox = __esm({
2362
2383
  });
2363
2384
 
2364
2385
  // ../../packages/attest/dist/aead.js
2365
- import { createCipheriv as createCipheriv2, createDecipheriv as createDecipheriv2, randomBytes as randomBytes3 } from "crypto";
2386
+ import { createCipheriv as createCipheriv2, createDecipheriv as createDecipheriv2, randomBytes as randomBytes4 } from "crypto";
2366
2387
  var init_aead = __esm({
2367
2388
  "../../packages/attest/dist/aead.js"() {
2368
2389
  "use strict";
@@ -2557,6 +2578,197 @@ var init_gceIdentity = __esm({
2557
2578
  }
2558
2579
  });
2559
2580
 
2581
+ // ../../packages/attest/dist/confidentialSpace.js
2582
+ import { createHash as createHash2, createPublicKey as createPublicKey3, verify as cryptoVerify3 } from "crypto";
2583
+ function refuse2(reason, detail) {
2584
+ return { ok: false, reason, detail };
2585
+ }
2586
+ function isPlainObject(v) {
2587
+ return typeof v === "object" && v !== null && !Array.isArray(v);
2588
+ }
2589
+ function assertExpectationsComplete2(expectations) {
2590
+ const required = [
2591
+ ["audience", expectations.audience],
2592
+ ["nonce", expectations.nonce],
2593
+ ["instanceName", expectations.instanceName],
2594
+ ["projectId", expectations.projectId],
2595
+ ...(expectations.additionalNonces ?? []).map((n) => [
2596
+ "additionalNonces[]",
2597
+ n
2598
+ ]),
2599
+ ...Object.entries(expectations.containerEnv ?? {}).flatMap(([name, value]) => [
2600
+ ["containerEnv name", name],
2601
+ [`containerEnv.${name}`, value]
2602
+ ])
2603
+ ];
2604
+ for (const [name, value] of required) {
2605
+ if (value === "") {
2606
+ throw new TypeError(`verifyConfidentialSpaceToken: expectations.${name} is empty`);
2607
+ }
2608
+ }
2609
+ }
2610
+ function nonceList(v) {
2611
+ if (typeof v === "string")
2612
+ return [v];
2613
+ if (!Array.isArray(v) || v.length === 0)
2614
+ return null;
2615
+ if (!v.every((n) => typeof n === "string"))
2616
+ return null;
2617
+ return v;
2618
+ }
2619
+ async function verifyConfidentialSpaceToken(token, expectations, deps) {
2620
+ assertExpectationsComplete2(expectations);
2621
+ if (!Number.isFinite(deps.now)) {
2622
+ throw new TypeError("verifyConfidentialSpaceToken: deps.now is not a finite number");
2623
+ }
2624
+ if (expectations.allowedImageDigests.length === 0) {
2625
+ return refuse2("image-digest-allowlist-unset", "no workload image digest is allowlisted, so no attestation can be accepted");
2626
+ }
2627
+ if (token.length > MAX_TOKEN_LENGTH) {
2628
+ return refuse2("token-malformed", "longer than any attestation token");
2629
+ }
2630
+ const parts = token.split(".");
2631
+ if (parts.length !== 3) {
2632
+ return refuse2("token-malformed", "not a three-segment compact JWT");
2633
+ }
2634
+ const [headerB64, payloadB64, signatureB64] = parts;
2635
+ const header = decodeJsonSegment(headerB64);
2636
+ if (header === null) {
2637
+ return refuse2("token-malformed", "header segment is not base64url-encoded JSON");
2638
+ }
2639
+ if (header.alg !== "RS256") {
2640
+ return refuse2("algorithm-not-rs256", "token names an unsupported algorithm");
2641
+ }
2642
+ if (typeof header.kid !== "string" || header.kid === "") {
2643
+ return refuse2("unknown-key", "token header names no key id");
2644
+ }
2645
+ const jwks = await deps.fetchJwks();
2646
+ const jwk = jwks.keys.find((k) => k.kid === header.kid && k.kty === "RSA");
2647
+ if (jwk === void 0) {
2648
+ return refuse2("unknown-key", "token key id is not in the published key set");
2649
+ }
2650
+ const signature = strictBase64UrlDecode(signatureB64);
2651
+ if (signature === null) {
2652
+ return refuse2("token-malformed", "signature segment is not base64url");
2653
+ }
2654
+ let signatureValid = false;
2655
+ try {
2656
+ const publicKey = createPublicKey3({ key: jwk, format: "jwk" });
2657
+ signatureValid = cryptoVerify3("sha256", Buffer.from(`${headerB64}.${payloadB64}`, "utf8"), publicKey, signature);
2658
+ } catch {
2659
+ signatureValid = false;
2660
+ }
2661
+ if (!signatureValid) {
2662
+ return refuse2("signature-invalid", "signature does not verify under the named key");
2663
+ }
2664
+ const payload = decodeJsonSegment(payloadB64);
2665
+ if (payload === null) {
2666
+ return refuse2("token-malformed", "payload segment is not base64url-encoded JSON");
2667
+ }
2668
+ if (payload.iss !== CONFIDENTIAL_SPACE_ISSUER) {
2669
+ return refuse2("wrong-issuer", "issuer is not the Confidential Computing attestation service");
2670
+ }
2671
+ if (payload.aud !== expectations.audience) {
2672
+ return refuse2("wrong-audience", "audience is not the one this run expects");
2673
+ }
2674
+ const nowSeconds = deps.now / 1e3;
2675
+ const exp = payload.exp;
2676
+ if (typeof exp !== "number" || !Number.isFinite(exp) || exp <= nowSeconds) {
2677
+ return refuse2("expired", "exp is missing, malformed, or in the past");
2678
+ }
2679
+ const iat = payload.iat;
2680
+ if (typeof iat !== "number" || !Number.isFinite(iat) || iat > nowSeconds + IAT_FUTURE_SKEW_MS / 1e3) {
2681
+ return refuse2("iat-in-future", "iat is missing, malformed, or too far in the future");
2682
+ }
2683
+ const nonces = nonceList(payload.eat_nonce);
2684
+ if (nonces === null) {
2685
+ return refuse2("nonce-missing", "eat_nonce is absent or not a string or array of strings");
2686
+ }
2687
+ if (!nonces.includes(expectations.nonce)) {
2688
+ return refuse2("nonce-mismatch", "eat_nonce does not carry the nonce this run was dispatched with");
2689
+ }
2690
+ for (const extra of expectations.additionalNonces ?? []) {
2691
+ if (!nonces.includes(extra)) {
2692
+ return refuse2("nonce-mismatch", "eat_nonce does not carry every nonce this caller requires");
2693
+ }
2694
+ }
2695
+ if (payload.swname !== CONFIDENTIAL_SPACE_SWNAME) {
2696
+ return refuse2("swname-not-confidential-space", "the VM is not running a Confidential Space image");
2697
+ }
2698
+ if (payload.dbgstat !== CONFIDENTIAL_SPACE_DBGSTAT_PRODUCTION) {
2699
+ return refuse2("debug-enabled", "the image is not a production image with debug disabled");
2700
+ }
2701
+ if (payload.secboot !== true) {
2702
+ return refuse2("secure-boot-off", "Secure Boot is not reported as enabled");
2703
+ }
2704
+ if (typeof payload.hwmodel !== "string" || !payload.hwmodel.startsWith("GCP_")) {
2705
+ return refuse2("hwmodel-not-gcp", "hardware model is not a Google Cloud confidential model");
2706
+ }
2707
+ const hwmodel = payload.hwmodel;
2708
+ const submods = payload.submods;
2709
+ const gce = isPlainObject(submods) ? submods.gce : void 0;
2710
+ if (!isPlainObject(gce)) {
2711
+ return refuse2("submods-gce-missing", "no submods.gce block names the instance");
2712
+ }
2713
+ if (gce.project_id !== expectations.projectId) {
2714
+ return refuse2("project-mismatch", "token names an instance in a different project");
2715
+ }
2716
+ if (gce.instance_name !== expectations.instanceName) {
2717
+ return refuse2("instance-name-mismatch", "token names a different instance than this run booted");
2718
+ }
2719
+ if (typeof gce.instance_id !== "string" || gce.instance_id === "") {
2720
+ return refuse2("instance-id-missing", "instance id is absent or not a string");
2721
+ }
2722
+ const instanceId = gce.instance_id;
2723
+ if (typeof gce.zone !== "string" || !expectations.allowedZones.includes(gce.zone)) {
2724
+ return refuse2("zone-not-allowed", "zone is not one the pool is configured to use");
2725
+ }
2726
+ const zone = gce.zone;
2727
+ const container = isPlainObject(submods) ? submods.container : void 0;
2728
+ if (!isPlainObject(container)) {
2729
+ return refuse2("submods-container-missing", "no submods.container block names the workload");
2730
+ }
2731
+ if (typeof container.image_reference !== "string" || container.image_reference === "") {
2732
+ return refuse2("image-reference-missing", "the workload image reference is absent");
2733
+ }
2734
+ const imageReference = container.image_reference;
2735
+ if (typeof container.image_digest !== "string" || !expectations.allowedImageDigests.includes(container.image_digest)) {
2736
+ return refuse2("image-digest-not-allowed", "the workload image digest is not on the allowlist");
2737
+ }
2738
+ const env = container.env;
2739
+ for (const [name, value] of Object.entries(expectations.containerEnv ?? {})) {
2740
+ if (!isPlainObject(env) || !Object.prototype.hasOwnProperty.call(env, name) || env[name] !== value) {
2741
+ return refuse2("container-env-mismatch", `the signed container environment does not carry the ${name} this caller booted with`);
2742
+ }
2743
+ }
2744
+ return {
2745
+ ok: true,
2746
+ value: {
2747
+ instanceId,
2748
+ instanceName: expectations.instanceName,
2749
+ zone,
2750
+ projectId: expectations.projectId,
2751
+ imageDigest: container.image_digest,
2752
+ imageReference,
2753
+ hwmodel,
2754
+ iat,
2755
+ exp,
2756
+ tokenSha256: createHash2("sha256").update(token, "utf8").digest("hex")
2757
+ }
2758
+ };
2759
+ }
2760
+ var CONFIDENTIAL_SPACE_ISSUER, CONFIDENTIAL_SPACE_OIDC_DISCOVERY_URL, CONFIDENTIAL_SPACE_SWNAME, CONFIDENTIAL_SPACE_DBGSTAT_PRODUCTION;
2761
+ var init_confidentialSpace = __esm({
2762
+ "../../packages/attest/dist/confidentialSpace.js"() {
2763
+ "use strict";
2764
+ init_gceIdentity();
2765
+ CONFIDENTIAL_SPACE_ISSUER = "https://confidentialcomputing.googleapis.com";
2766
+ CONFIDENTIAL_SPACE_OIDC_DISCOVERY_URL = `${CONFIDENTIAL_SPACE_ISSUER}/.well-known/openid-configuration`;
2767
+ CONFIDENTIAL_SPACE_SWNAME = "CONFIDENTIAL_SPACE";
2768
+ CONFIDENTIAL_SPACE_DBGSTAT_PRODUCTION = "disabled-since-boot";
2769
+ }
2770
+ });
2771
+
2560
2772
  // ../../packages/attest/dist/venueInstanceName.js
2561
2773
  function venueInstanceName(runId) {
2562
2774
  return `th-run-${runId}`.replace(/_/g, "-").toLowerCase().slice(0, 62);
@@ -2591,6 +2803,7 @@ var init_dist2 = __esm({
2591
2803
  init_attestation();
2592
2804
  init_nonce();
2593
2805
  init_gceIdentity();
2806
+ init_confidentialSpace();
2594
2807
  init_venueInstanceName();
2595
2808
  init_verify();
2596
2809
  }
@@ -2841,7 +3054,7 @@ var init_result = __esm({
2841
3054
  });
2842
3055
 
2843
3056
  // ../../packages/envrun/dist/attestation.js
2844
- import { createHash as createHash2, randomBytes as randomBytes4 } from "crypto";
3057
+ import { createHash as createHash3, randomBytes as randomBytes5 } from "crypto";
2845
3058
  function contradicts(outcome, counts, exitCode) {
2846
3059
  const budget = OUTCOME_TO_BUDGET[outcome];
2847
3060
  if (budget === null)
@@ -2883,7 +3096,7 @@ function imageDigestOf(ref) {
2883
3096
  return at === -1 ? null : ref.slice(at + 1);
2884
3097
  }
2885
3098
  function sha256Hex(data) {
2886
- return createHash2("sha256").update(typeof data === "string" ? Buffer.from(data, "utf8") : data).digest("hex");
3099
+ return createHash3("sha256").update(typeof data === "string" ? Buffer.from(data, "utf8") : data).digest("hex");
2887
3100
  }
2888
3101
  function toTestRunResult(result, outputSha256) {
2889
3102
  return {
@@ -2895,50 +3108,50 @@ function toTestRunResult(result, outputSha256) {
2895
3108
  function toAcceptancePredicate(pair, opts = {}) {
2896
3109
  const { baseline, patched } = pair;
2897
3110
  if (baseline.runId === patched.runId) {
2898
- return refuse2("pair-is-the-same-run", `both sides are run ${patched.runId}. A baseline and a patched result that are the same execution prove nothing about the patch, and every field of the pair would be equal by construction.`);
3111
+ return refuse3("pair-is-the-same-run", `both sides are run ${patched.runId}. A baseline and a patched result that are the same execution prove nothing about the patch, and every field of the pair would be equal by construction.`);
2899
3112
  }
2900
3113
  if (patched.status === "refused") {
2901
- return refuse2("run-was-refused", `the patched run was refused before executing (${patched.boundaryRefusals.length} boundary refusal(s)), so there is no result to attest`);
3114
+ return refuse3("run-was-refused", `the patched run was refused before executing (${patched.boundaryRefusals.length} boundary refusal(s)), so there is no result to attest`);
2902
3115
  }
2903
3116
  if (patched.outcome === null) {
2904
- return refuse2("no-outcome-recorded", "the patched run recorded no outcome");
3117
+ return refuse3("no-outcome-recorded", "the patched run recorded no outcome");
2905
3118
  }
2906
3119
  if (baseline.status === "refused") {
2907
- return refuse2("baseline-was-refused", `the baseline run was refused before executing (${baseline.boundaryRefusals.length} boundary refusal(s)), so there is no "before" to compare against`);
3120
+ return refuse3("baseline-was-refused", `the baseline run was refused before executing (${baseline.boundaryRefusals.length} boundary refusal(s)), so there is no "before" to compare against`);
2908
3121
  }
2909
3122
  if (baseline.outcome === null) {
2910
- return refuse2("baseline-no-outcome", "the baseline run recorded no outcome");
3123
+ return refuse3("baseline-no-outcome", "the baseline run recorded no outcome");
2911
3124
  }
2912
3125
  if (!BASELINE_IS_A_VERDICT[baseline.outcome]) {
2913
- return refuse2("baseline-environment-failed", `the baseline ended ${baseline.outcome}, which is not an observed verdict about the unpatched tree. Signing it would let a baseline we could not read stand in for one that failed, and a broken baseline flatters every patch.`);
3126
+ return refuse3("baseline-environment-failed", `the baseline ended ${baseline.outcome}, which is not an observed verdict about the unpatched tree. Signing it would let a baseline we could not read stand in for one that failed, and a broken baseline flatters every patch.`);
2914
3127
  }
2915
3128
  const budget = OUTCOME_TO_BUDGET[patched.outcome];
2916
3129
  if (budget === null) {
2917
- return refuse2("our-environment-failed", `the patched run ended ${patched.outcome}: the test command could not be invoked, which is our environment failing and not a verdict about the work. No statement is emitted, because every possible value would be a claim we cannot support.`);
3130
+ return refuse3("our-environment-failed", `the patched run ended ${patched.outcome}: the test command could not be invoked, which is our environment failing and not a verdict about the work. No statement is emitted, because every possible value would be a claim we cannot support.`);
2918
3131
  }
2919
3132
  if (patched.testCommand === null) {
2920
- return refuse2("no-test-command", "no test command was recorded, so none can be attested");
3133
+ return refuse3("no-test-command", "no test command was recorded, so none can be attested");
2921
3134
  }
2922
3135
  if (baseline.testCommand !== patched.testCommand) {
2923
- return refuse2("pair-disagrees-on-harness", `baseline ran ${JSON.stringify(baseline.testCommand)} and patched ran ${JSON.stringify(patched.testCommand)}. Opposite verdicts from different harnesses say nothing about the patch.`);
3136
+ return refuse3("pair-disagrees-on-harness", `baseline ran ${JSON.stringify(baseline.testCommand)} and patched ran ${JSON.stringify(patched.testCommand)}. Opposite verdicts from different harnesses say nothing about the patch.`);
2924
3137
  }
2925
3138
  if (baseline.targetSha !== patched.targetSha) {
2926
- return refuse2("pair-disagrees-on-base", `baseline is at ${baseline.targetSha} and patched at ${patched.targetSha}`);
3139
+ return refuse3("pair-disagrees-on-base", `baseline is at ${baseline.targetSha} and patched at ${patched.targetSha}`);
2927
3140
  }
2928
3141
  if (patched.treeDigest === null || patched.treeDigest === "") {
2929
- return refuse2("missing-tree-digest", "the patched run recorded no tree digest to bind to");
3142
+ return refuse3("missing-tree-digest", "the patched run recorded no tree digest to bind to");
2930
3143
  }
2931
3144
  if (patched.patchSha256 === null || patched.patchSha256 === "") {
2932
- return refuse2("missing-patch-digest", "the patched run recorded no patch digest");
3145
+ return refuse3("missing-patch-digest", "the patched run recorded no patch digest");
2933
3146
  }
2934
3147
  if (baseline.treeDigest === null || baseline.treeDigest === "") {
2935
- return refuse2("missing-tree-digest", "the baseline recorded no tree digest, so the two halves cannot be shown to differ");
3148
+ return refuse3("missing-tree-digest", "the baseline recorded no tree digest, so the two halves cannot be shown to differ");
2936
3149
  }
2937
3150
  if (baseline.treeDigest === patched.treeDigest) {
2938
- return refuse2("pair-has-identical-tree", `both sides are tree ${String(patched.treeDigest).slice(0, 12)}\u2026, so whatever the two runs disagree about, it is not the patch`);
3151
+ return refuse3("pair-has-identical-tree", `both sides are tree ${String(patched.treeDigest).slice(0, 12)}\u2026, so whatever the two runs disagree about, it is not the patch`);
2939
3152
  }
2940
3153
  if (patched.baselinePatchSha256 !== null || baseline.baselinePatchSha256 !== null) {
2941
- return refuse2("patch-binding-incomplete", `a baseline patch shaped the ${patched.baselinePatchSha256 !== null ? "patched" : "baseline"} tree, so it cannot be reproduced from (base_commit_oid, patch_sha256) alone. Fold it into the binding before signing; do not sign a triple a verifier cannot follow.`);
3154
+ return refuse3("patch-binding-incomplete", `a baseline patch shaped the ${patched.baselinePatchSha256 !== null ? "patched" : "baseline"} tree, so it cannot be reproduced from (base_commit_oid, patch_sha256) alone. Fold it into the binding before signing; do not sign a triple a verifier cannot follow.`);
2942
3155
  }
2943
3156
  for (const [side, outcome, counts, exitCode] of [
2944
3157
  ["patched", patched.outcome, patched.counts, patched.exitCode],
@@ -2946,7 +3159,7 @@ function toAcceptancePredicate(pair, opts = {}) {
2946
3159
  ]) {
2947
3160
  const why = contradicts(outcome, counts, exitCode);
2948
3161
  if (why !== null) {
2949
- return refuse2("outcome-contradicts-counts", `the ${side} run is ${why}`);
3162
+ return refuse3("outcome-contradicts-counts", `the ${side} run is ${why}`);
2950
3163
  }
2951
3164
  }
2952
3165
  for (const [side, source] of [
@@ -2955,44 +3168,44 @@ function toAcceptancePredicate(pair, opts = {}) {
2955
3168
  ]) {
2956
3169
  const why = SOURCE_IS_SIGNABLE[source](patched.testCommand);
2957
3170
  if (why !== null)
2958
- return refuse2("test-command-origin-unattested", `the ${side} run ${why}`);
3171
+ return refuse3("test-command-origin-unattested", `the ${side} run ${why}`);
2959
3172
  }
2960
3173
  if (patched.testCommandSource === "developer-declared") {
2961
- return refuse2("test-command-origin-unattested", "the developer supplied the test command (unreachable \u2014 the source rules above cover it)");
3174
+ return refuse3("test-command-origin-unattested", "the developer supplied the test command (unreachable \u2014 the source rules above cover it)");
2962
3175
  }
2963
3176
  if (baseline.testCommandSource !== patched.testCommandSource) {
2964
- return refuse2("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.`);
3177
+ 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.`);
2965
3178
  }
2966
3179
  if (baseline.containerImage !== patched.containerImage) {
2967
- return refuse2("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.`);
3180
+ 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.`);
2968
3181
  }
2969
3182
  if (patched.containerImage === null || patched.containerImage === "") {
2970
- return refuse2("missing-container-image", "no container image was recorded, so the measurement would name a stand-in rather than the environment the suite actually ran in");
3183
+ return refuse3("missing-container-image", "no container image was recorded, so the measurement would name a stand-in rather than the environment the suite actually ran in");
2971
3184
  }
2972
3185
  if (patched.containerImageDigest == null || patched.containerImageDigest === "") {
2973
- return refuse2("missing-image-digest", "no image digest was recorded for the patched half, so the measurement would name the mutable tag rather than the bytes the suite actually ran in. A run without the digest is recordable but not attestable \u2014 the `missing-patch-digest` split.");
3186
+ return refuse3("missing-image-digest", "no image digest was recorded for the patched half, so the measurement would name the mutable tag rather than the bytes the suite actually ran in. A run without the digest is recordable but not attestable \u2014 the `missing-patch-digest` split.");
2974
3187
  }
2975
3188
  if (typeof patched.containerImageDigest !== "string" || !isCanonicalRepoDigest(patched.containerImageDigest)) {
2976
- return refuse2("malformed-image-digest", `the patched half carries ${JSON.stringify(patched.containerImageDigest).slice(0, 120)} where a RepoDigest (\`repo@sha256:<64 hex>\`) belongs. A tag or a stand-in string here would be signed as if it were content-addressed, which is the lie the digest axis exists to refuse.`);
3189
+ return refuse3("malformed-image-digest", `the patched half carries ${JSON.stringify(patched.containerImageDigest).slice(0, 120)} where a RepoDigest (\`repo@sha256:<64 hex>\`) belongs. A tag or a stand-in string here would be signed as if it were content-addressed, which is the lie the digest axis exists to refuse.`);
2977
3190
  }
2978
3191
  if (imageRepo(patched.containerImageDigest) !== imageRepo(patched.containerImage)) {
2979
- return refuse2("image-digest-repo-mismatch", `the patched half's digest names repository ${imageRepo(patched.containerImageDigest)} but the pair ran ${patched.containerImage}. An image can carry digests for several repositories; signing one the pair did not name would attribute these bytes to a different name.`);
3192
+ return refuse3("image-digest-repo-mismatch", `the patched half's digest names repository ${imageRepo(patched.containerImageDigest)} but the pair ran ${patched.containerImage}. An image can carry digests for several repositories; signing one the pair did not name would attribute these bytes to a different name.`);
2980
3193
  }
2981
3194
  if (baseline.containerImageDigest == null || baseline.containerImageDigest === "") {
2982
- return refuse2("missing-image-digest", "no image digest was recorded for the baseline half, so the measurement would name the mutable tag rather than the bytes the suite actually ran in. A run without the digest is recordable but not attestable \u2014 the `missing-patch-digest` split.");
3195
+ return refuse3("missing-image-digest", "no image digest was recorded for the baseline half, so the measurement would name the mutable tag rather than the bytes the suite actually ran in. A run without the digest is recordable but not attestable \u2014 the `missing-patch-digest` split.");
2983
3196
  }
2984
3197
  if (typeof baseline.containerImageDigest !== "string" || !isCanonicalRepoDigest(baseline.containerImageDigest)) {
2985
- return refuse2("malformed-image-digest", `the baseline half carries ${JSON.stringify(baseline.containerImageDigest).slice(0, 120)} where a RepoDigest (\`repo@sha256:<64 hex>\`) belongs. A tag or a stand-in string here would be signed as if it were content-addressed, which is the lie the digest axis exists to refuse.`);
3198
+ return refuse3("malformed-image-digest", `the baseline half carries ${JSON.stringify(baseline.containerImageDigest).slice(0, 120)} where a RepoDigest (\`repo@sha256:<64 hex>\`) belongs. A tag or a stand-in string here would be signed as if it were content-addressed, which is the lie the digest axis exists to refuse.`);
2986
3199
  }
2987
3200
  if (imageRepo(baseline.containerImageDigest) !== imageRepo(patched.containerImage)) {
2988
- return refuse2("image-digest-repo-mismatch", `the baseline half's digest names repository ${imageRepo(baseline.containerImageDigest)} but the pair ran ${patched.containerImage}. An image can carry digests for several repositories; signing one the pair did not name would attribute these bytes to a different name.`);
3201
+ return refuse3("image-digest-repo-mismatch", `the baseline half's digest names repository ${imageRepo(baseline.containerImageDigest)} but the pair ran ${patched.containerImage}. An image can carry digests for several repositories; signing one the pair did not name would attribute these bytes to a different name.`);
2989
3202
  }
2990
3203
  if (baseline.containerImageDigest !== patched.containerImageDigest) {
2991
- return refuse2("pair-disagrees-on-image-digest", `baseline ran image digest ${String(baseline.containerImageDigest)} and patched ${String(patched.containerImageDigest)} under one tag. The tag agreeing is the trap: a re-pointed tag is two environments wearing one name.`);
3204
+ return refuse3("pair-disagrees-on-image-digest", `baseline ran image digest ${String(baseline.containerImageDigest)} and patched ${String(patched.containerImageDigest)} under one tag. The tag agreeing is the trap: a re-pointed tag is two environments wearing one name.`);
2992
3205
  }
2993
3206
  const claimedDigest = imageDigestOf(patched.containerImage);
2994
3207
  if (claimedDigest !== null && claimedDigest !== imageDigestOf(patched.containerImageDigest)) {
2995
- return refuse2("image-digest-contradicts-image", `the pair ran ${patched.containerImage}, whose reference pins digest ${claimedDigest}, but the digest field says ${String(imageDigestOf(patched.containerImageDigest))}. A measurement must not sign one digest while the record names another.`);
3208
+ return refuse3("image-digest-contradicts-image", `the pair ran ${patched.containerImage}, whose reference pins digest ${claimedDigest}, but the digest field says ${String(imageDigestOf(patched.containerImageDigest))}. A measurement must not sign one digest while the record names another.`);
2996
3209
  }
2997
3210
  return {
2998
3211
  ok: true,
@@ -3010,7 +3223,7 @@ function toAcceptancePredicate(pair, opts = {}) {
3010
3223
  // RepoDigest (`repo@sha256:…`) carries the repo name and the content hash, and the
3011
3224
  // tag it drops is the part a registry can re-point (TERM-893).
3012
3225
  enclave_measurement: localMeasurement(patched.containerImageDigest),
3013
- nonce: opts.nonce ?? randomBytes4(16).toString("hex"),
3226
+ nonce: opts.nonce ?? randomBytes5(16).toString("hex"),
3014
3227
  run_policy: { max_attempts: opts.maxAttempts ?? 1, budget_outcome: budget }
3015
3228
  }
3016
3229
  };
@@ -3019,7 +3232,7 @@ function signRunStatement(predicate, privateKey, keyid) {
3019
3232
  const statement = createAcceptanceStatement(predicate);
3020
3233
  return { statement, envelope: signStatement(statement, privateKey, keyid) };
3021
3234
  }
3022
- var OUTCOME_TO_BUDGET, BASELINE_IS_A_VERDICT, CONTRADICTS_COUNTS, SOURCE_IS_SIGNABLE, ATTEST_REFUSAL_REASONS, refuse2, LOCAL_MEASUREMENT_PREFIX, REFERENCE_DOMAIN_COMPONENT, REFERENCE_DOMAIN_NAME, REFERENCE_IPV6, REFERENCE_DOMAIN, REFERENCE_PATH_COMPONENT, REPO_DIGEST_RE;
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;
3023
3236
  var init_attestation2 = __esm({
3024
3237
  "../../packages/envrun/dist/attestation.js"() {
3025
3238
  "use strict";
@@ -3123,7 +3336,7 @@ var init_attestation2 = __esm({
3123
3336
  "outcome-contradicts-counts",
3124
3337
  "test-command-origin-unattested"
3125
3338
  ];
3126
- refuse2 = (reason, detail) => ({
3339
+ refuse3 = (reason, detail) => ({
3127
3340
  ok: false,
3128
3341
  reason,
3129
3342
  detail
@@ -3506,6 +3719,73 @@ var init_boundary = __esm({
3506
3719
  });
3507
3720
 
3508
3721
  // ../../packages/envrun/dist/gcpPlacement.js
3722
+ function assertWorkloadImage(fn, value) {
3723
+ if (typeof value !== "string" || !WORKLOAD_IMAGE_REFERENCE.test(value)) {
3724
+ throw new GcpPlacementError(`${fn}: the workload image is missing or is not one plain image reference (${String(WORKLOAD_IMAGE_REFERENCE)}). It lands inside a comma-separated --metadata= list, so anything that could start a second key is refused.`);
3725
+ }
3726
+ }
3727
+ function venueImageFromEnv(env) {
3728
+ const image = env["VENUE_IMAGE"] ?? "";
3729
+ if (image === "" || image === "cos-stable")
3730
+ return { venueImage: "cos-stable" };
3731
+ if (image !== "confidential-space") {
3732
+ throw new GcpPlacementError("VENUE_IMAGE is set to something other than cos-stable or confidential-space. An unknown value is refused rather than booted as the default.");
3733
+ }
3734
+ const workloadImage = env["VENUE_WORKLOAD_IMAGE"] ?? "";
3735
+ if (workloadImage === "") {
3736
+ throw new GcpPlacementError("VENUE_IMAGE=confidential-space needs VENUE_WORKLOAD_IMAGE, the image reference the Confidential Space launcher runs. Nothing boots without one.");
3737
+ }
3738
+ assertWorkloadImage("venueImageFromEnv", workloadImage);
3739
+ return { venueImage: "confidential-space", workloadImage };
3740
+ }
3741
+ function bootImageFlags(p) {
3742
+ const image = p.venueImage ?? "cos-stable";
3743
+ if (image === "cos-stable")
3744
+ return ["--image-family=cos-stable", "--image-project=cos-cloud"];
3745
+ if (image !== "confidential-space") {
3746
+ throw new GcpPlacementError("gcpBootArgv: venueImage is not one of cos-stable, confidential-space. An unknown image is refused rather than booted as the default.");
3747
+ }
3748
+ const workloadImage = p.workloadImage;
3749
+ assertWorkloadImage("gcpBootArgv", workloadImage);
3750
+ const clientCert = p.clientCert;
3751
+ if (typeof clientCert !== "string" || !CLIENT_CERT_BASE64URL.test(clientCert)) {
3752
+ throw new GcpPlacementError(`gcpBootArgv: the client certificate is missing or is not one base64url value (${String(CLIENT_CERT_BASE64URL)}). It lands inside the --metadata value, so anything that could start a second key is refused.`);
3753
+ }
3754
+ if (p.runId.length < MIN_NONCE_BYTES) {
3755
+ throw new GcpPlacementError(`gcpBootArgv: a Confidential Space run id must be at least ${String(MIN_NONCE_BYTES)} characters, because it is the attestation token's nonce and the launcher refuses a shorter one. Refused here, before a VM is billed for a token it cannot mint.`);
3756
+ }
3757
+ const metadata = [
3758
+ `tee-image-reference=${workloadImage}`,
3759
+ "tee-container-log-redirect=true",
3760
+ 'tee-added-capabilities=["CAP_SYS_ADMIN","CAP_NET_ADMIN"]',
3761
+ "tee-cgroup-ns=true",
3762
+ `tee-env-TH_AUDIENCE=${expectedAudience(p.runId)}`,
3763
+ `tee-env-TH_NONCE=${p.runId}`,
3764
+ `tee-env-TH_CLIENT_CERT=${clientCert}`
3765
+ ];
3766
+ return [
3767
+ // The production family. The debug family reports `dbgstat: enabled`, which the
3768
+ // intake's verifier refuses, so a debug boot could never produce a run we record.
3769
+ "--image-family=confidential-space",
3770
+ "--image-project=confidential-space-images",
3771
+ "--confidential-compute-type=SEV",
3772
+ // The deploy-workloads page says N2D with SEV may set MIGRATE for live migration,
3773
+ // and every other machine type needs TERMINATE. The spec chose TERMINATE: a
3774
+ // maintenance event ends the VM instead of moving it, which for a VM that lives at
3775
+ // most GCP_MAX_RUN_DURATION_SECONDS costs one run. Whether GCE boots this exact
3776
+ // combination is phase 5's measurement.
3777
+ "--maintenance-policy=TERMINATE",
3778
+ `--metadata=^;^${metadata.join(";")}`,
3779
+ // What the standing firewall rule for IAP's range matches, so the tunnels to the
3780
+ // workload's dockerd and attest ports open on these VMs and no others.
3781
+ "--tags=th-cs-venue",
3782
+ // The family's 11 GB default leaves the workload a 7.7 GB root, and dockerd there
3783
+ // runs vfs, which copies every layer and every container's rootfs in full. Pulling
3784
+ // node:20-bookworm left 992 MB, and the next container create failed ENOSPC
3785
+ // (measured, TERM-1094). COS runs overlay2 on its own disk and keeps the default.
3786
+ "--boot-disk-size=50GB"
3787
+ ];
3788
+ }
3509
3789
  function assertInstanceIdentity(fn, id) {
3510
3790
  const fields = [
3511
3791
  ["vmName", id.vmName, GCE_INSTANCE_NAME],
@@ -3531,8 +3811,8 @@ function gcpBootArgv(p) {
3531
3811
  `--project=${p.project}`,
3532
3812
  `--zone=${p.zone}`,
3533
3813
  `--machine-type=${p.machineType}`,
3534
- "--image-family=cos-stable",
3535
- "--image-project=cos-cloud",
3814
+ // What the VM boots, and the one part of this argv the two images do not share.
3815
+ ...bootImageFlags(p),
3536
3816
  // ── THE OUTER REAP. This, not the `finally` below, is what bounds the bill. ──
3537
3817
  //
3538
3818
  // An in-process `finally` is a best effort, not a reap. SIGKILL, a crashed
@@ -3577,7 +3857,13 @@ function gcpBootArgv(p) {
3577
3857
  // expectation uses (TERM-832): two inline spellings of one value is how
3578
3858
  // the boot path and the verifier drift apart silently.
3579
3859
  `--service-account=${venueIdentityEmail(p.project)}`,
3580
- "--no-scopes",
3860
+ // A Confidential Space VM takes `cloud-platform` instead (TERM-1094): the launcher
3861
+ // pulls the workload image and mints the attestation token with this account, and
3862
+ // under `--no-scopes` it can do neither. What bounds a lifted token there is the
3863
+ // account's two grants — `confidentialcomputing.workloadUser` and a reader on the
3864
+ // one venue image repository — plus point 2 above, which is unchanged: the fence
3865
+ // still denies untrusted code the metadata server.
3866
+ ...p.venueImage === "confidential-space" ? ["--scopes=cloud-platform"] : ["--no-scopes"],
3581
3867
  "--shielded-secure-boot",
3582
3868
  "--shielded-vtpm",
3583
3869
  "--shielded-integrity-monitoring",
@@ -3610,7 +3896,7 @@ function gcpDeleteArgv(p) {
3610
3896
  "--quiet"
3611
3897
  ];
3612
3898
  }
3613
- var DEFAULT_GCP_PROJECT, DEFAULT_GCP_ZONE, DEFAULT_GCP_MACHINE_TYPE, GcpPlacementError, GCP_MAX_RUN_DURATION_SECONDS, GCP_MANAGED_LABEL_KEY, GCP_RUN_LABEL_KEY, GCP_LABEL_VALUE, GCE_INSTANCE_NAME, GCP_RESOURCE_ID;
3899
+ var DEFAULT_GCP_PROJECT, DEFAULT_GCP_ZONE, DEFAULT_GCP_MACHINE_TYPE, GcpPlacementError, GCP_MAX_RUN_DURATION_SECONDS, GCP_MANAGED_LABEL_KEY, GCP_RUN_LABEL_KEY, CONFIDENTIAL_SPACE_MACHINE_TYPE, WORKLOAD_IMAGE_REFERENCE, CLIENT_CERT_BASE64URL, MIN_NONCE_BYTES, GCP_LABEL_VALUE, GCE_INSTANCE_NAME, GCP_RESOURCE_ID;
3614
3900
  var init_gcpPlacement = __esm({
3615
3901
  "../../packages/envrun/dist/gcpPlacement.js"() {
3616
3902
  "use strict";
@@ -3627,6 +3913,10 @@ var init_gcpPlacement = __esm({
3627
3913
  GCP_MAX_RUN_DURATION_SECONDS = 3600;
3628
3914
  GCP_MANAGED_LABEL_KEY = "th-managed";
3629
3915
  GCP_RUN_LABEL_KEY = "th-run";
3916
+ CONFIDENTIAL_SPACE_MACHINE_TYPE = "n2d-standard-2";
3917
+ WORKLOAD_IMAGE_REFERENCE = /^[A-Za-z0-9][A-Za-z0-9._/:@-]{0,511}$/;
3918
+ CLIENT_CERT_BASE64URL = /^[A-Za-z0-9_-]{16,8192}$/;
3919
+ MIN_NONCE_BYTES = 10;
3630
3920
  GCP_LABEL_VALUE = /^[a-z0-9_-]{1,63}$/;
3631
3921
  GCE_INSTANCE_NAME = /^[a-z](?:[-a-z0-9]{0,61}[a-z0-9])?$/;
3632
3922
  GCP_RESOURCE_ID = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
@@ -3634,25 +3924,25 @@ var init_gcpPlacement = __esm({
3634
3924
  });
3635
3925
 
3636
3926
  // ../../packages/envrun/dist/emptyGitConfig.js
3637
- import { mkdtempSync as mkdtempSync2, rmSync as rmSync2, writeFileSync as writeFileSync4 } from "fs";
3927
+ import { mkdtempSync as mkdtempSync2, rmSync as rmSync3, writeFileSync as writeFileSync5 } from "fs";
3638
3928
  import { tmpdir as tmpdir2 } from "os";
3639
- import { join as join9 } from "path";
3929
+ import { join as join10 } from "path";
3640
3930
  function emptyGitConfig() {
3641
3931
  if (emptyGitConfigFile !== void 0)
3642
3932
  return emptyGitConfigFile;
3643
3933
  let dir;
3644
3934
  let file;
3645
3935
  try {
3646
- dir = mkdtempSync2(join9(tmpdir2(), "th-run-nogitconfig-"));
3647
- file = join9(dir, "config");
3648
- writeFileSync4(file, "", { mode: 384 });
3936
+ dir = mkdtempSync2(join10(tmpdir2(), "th-run-nogitconfig-"));
3937
+ file = join10(dir, "config");
3938
+ writeFileSync5(file, "", { mode: 384 });
3649
3939
  } catch (err) {
3650
3940
  throw new RunRefusalError("could not create the empty git config this run points GIT_CONFIG_GLOBAL and GIT_CONFIG_SYSTEM at, so git would read the configuration \u2014 and the credential helpers \u2014 on this machine instead. That is our environment failing, not your tests: check that the temp directory is writable.", { cause: err });
3651
3941
  }
3652
3942
  emptyGitConfigFile = file;
3653
3943
  process.once("exit", () => {
3654
3944
  try {
3655
- rmSync2(dir, { recursive: true, force: true });
3945
+ rmSync3(dir, { recursive: true, force: true });
3656
3946
  } catch {
3657
3947
  }
3658
3948
  });
@@ -3668,8 +3958,11 @@ var init_emptyGitConfig = __esm({
3668
3958
 
3669
3959
  // ../../packages/envrun/dist/hostedVenue.js
3670
3960
  import { spawn as spawn3, spawnSync as spawnSync5 } from "child_process";
3671
- import { chmodSync as chmodSync2, existsSync as existsSync5, mkdtempSync as mkdtempSync3, readFileSync as readFileSync3, rmSync as rmSync3 } from "fs";
3672
- import { join as join10 } from "path";
3961
+ import { createHash as createHash4, X509Certificate } from "crypto";
3962
+ import { chmodSync as chmodSync2, existsSync as existsSync6, mkdtempSync as mkdtempSync3, readFileSync as readFileSync4, rmSync as rmSync4, writeFileSync as writeFileSync6 } from "fs";
3963
+ import { request as httpsRequest } from "https";
3964
+ import { createServer } from "net";
3965
+ import { join as join11 } from "path";
3673
3966
  import { tmpdir as tmpdir3 } from "os";
3674
3967
  function credentialInGitConfig(text) {
3675
3968
  for (const match of text.matchAll(/\b([a-z][a-z0-9+.-]*):\/\/(\S+)/gi)) {
@@ -3706,19 +3999,19 @@ function decodeMaybe(url) {
3706
3999
  }
3707
4000
  function dispatchedGitBinary() {
3708
4001
  for (const candidate of DISPATCHED_GIT_CANDIDATES) {
3709
- if (existsSync5(candidate))
4002
+ if (existsSync6(candidate))
3710
4003
  return candidate;
3711
4004
  }
3712
4005
  return "git";
3713
4006
  }
3714
4007
  function dispatchedProbeEnv(cloneDir, base) {
3715
- const gitDir = join10(cloneDir, ".git");
4008
+ const gitDir = join11(cloneDir, ".git");
3716
4009
  return {
3717
4010
  ...base,
3718
4011
  GIT_DIR: gitDir,
3719
4012
  GIT_WORK_TREE: cloneDir,
3720
- GIT_INDEX_FILE: join10(gitDir, "index"),
3721
- GIT_OBJECT_DIRECTORY: join10(gitDir, "objects"),
4013
+ GIT_INDEX_FILE: join11(gitDir, "index"),
4014
+ GIT_OBJECT_DIRECTORY: join11(gitDir, "objects"),
3722
4015
  GIT_ALTERNATE_OBJECT_DIRECTORIES: "",
3723
4016
  GIT_COMMON_DIR: gitDir,
3724
4017
  GIT_NAMESPACE: "",
@@ -3870,6 +4163,89 @@ timed out after ${String(timeoutMs)}ms` : stderr,
3870
4163
  });
3871
4164
  });
3872
4165
  }
4166
+ function socketErrorReason(err) {
4167
+ if (err.message !== "")
4168
+ return err.message;
4169
+ const code = err.code;
4170
+ if (code !== void 0)
4171
+ return code;
4172
+ const inner = err.errors;
4173
+ if (Array.isArray(inner) && inner[0] instanceof Error && inner[0].message !== "") {
4174
+ return inner[0].message;
4175
+ }
4176
+ return err.name;
4177
+ }
4178
+ function fetchAttestationOverTls(req) {
4179
+ return new Promise((resolve2) => {
4180
+ let whole;
4181
+ const settle = (reading) => {
4182
+ if (whole !== void 0)
4183
+ clearTimeout(whole);
4184
+ resolve2(reading);
4185
+ };
4186
+ let cert;
4187
+ let key;
4188
+ try {
4189
+ cert = readFileSync4(req.certPath);
4190
+ key = readFileSync4(req.keyPath);
4191
+ } catch (err) {
4192
+ settle({ ok: false, reason: `the client key could not be read: ${describeErr(err)}` });
4193
+ return;
4194
+ }
4195
+ const call = httpsRequest({
4196
+ host: "localhost",
4197
+ port: req.port,
4198
+ path: "/attest",
4199
+ method: "GET",
4200
+ servername: "localhost",
4201
+ cert,
4202
+ key,
4203
+ rejectUnauthorized: false,
4204
+ agent: false,
4205
+ timeout: req.timeoutMs
4206
+ }, (res) => {
4207
+ const peer = res.socket.getPeerCertificate();
4208
+ let body = "";
4209
+ res.setEncoding("utf8");
4210
+ res.on("data", (chunk) => {
4211
+ body += chunk;
4212
+ if (body.length > MAX_TOKEN_LENGTH) {
4213
+ settle({ ok: false, reason: "the answer is longer than any attestation token" });
4214
+ res.destroy();
4215
+ }
4216
+ });
4217
+ res.on("end", () => {
4218
+ if (res.statusCode !== 200) {
4219
+ settle({
4220
+ ok: false,
4221
+ reason: `the attest endpoint answered HTTP ${String(res.statusCode)}`
4222
+ });
4223
+ return;
4224
+ }
4225
+ const raw = peer.raw;
4226
+ if (raw === void 0 || raw.length === 0) {
4227
+ settle({ ok: false, reason: "the attest endpoint presented no certificate" });
4228
+ return;
4229
+ }
4230
+ settle({ ok: true, token: body.trim(), peerCertDer: new Uint8Array(raw) });
4231
+ });
4232
+ res.on("error", (err) => {
4233
+ settle({ ok: false, reason: socketErrorReason(err) });
4234
+ });
4235
+ });
4236
+ whole = setTimeout(() => {
4237
+ settle({ ok: false, reason: `no complete answer within ${String(req.timeoutMs)}ms` });
4238
+ call.destroy();
4239
+ }, req.timeoutMs);
4240
+ call.on("timeout", () => {
4241
+ call.destroy(new Error(`no answer within ${String(req.timeoutMs)}ms`));
4242
+ });
4243
+ call.on("error", (err) => {
4244
+ settle({ ok: false, reason: socketErrorReason(err) });
4245
+ });
4246
+ call.end();
4247
+ });
4248
+ }
3873
4249
  function quoteForRemoteShell(arg) {
3874
4250
  return `'${arg.replace(/'/g, "'\\''")}'`;
3875
4251
  }
@@ -4204,25 +4580,370 @@ ${res.stderr}`);
4204
4580
  function manualDeleteCommand(vm, project, zone) {
4205
4581
  return `gcloud compute instances delete ${vm} --project=${project} --zone=${zone} --quiet`;
4206
4582
  }
4583
+ function clientCertArgv(keyPath, certPath) {
4584
+ return [
4585
+ "req",
4586
+ "-x509",
4587
+ "-newkey",
4588
+ "ec",
4589
+ "-pkeyopt",
4590
+ "ec_paramgen_curve:P-256",
4591
+ "-nodes",
4592
+ "-keyout",
4593
+ keyPath,
4594
+ "-out",
4595
+ certPath,
4596
+ "-subj",
4597
+ "/CN=terminalhire-worker",
4598
+ "-days",
4599
+ "1",
4600
+ "-addext",
4601
+ "extendedKeyUsage=clientAuth"
4602
+ ];
4603
+ }
4604
+ function iapPortTunnelArgv(vm, project, zone, remotePort, localPort) {
4605
+ return [
4606
+ "compute",
4607
+ "start-iap-tunnel",
4608
+ vm,
4609
+ String(remotePort),
4610
+ `--local-host-port=localhost:${String(localPort)}`,
4611
+ `--project=${project}`,
4612
+ `--zone=${zone}`,
4613
+ "--iap-tunnel-disable-connection-check"
4614
+ ];
4615
+ }
4616
+ function spkiSha256Hex(certDer) {
4617
+ const spki = new X509Certificate(certDer).publicKey.export({ type: "spki", format: "der" });
4618
+ return createHash4("sha256").update(spki).digest("hex");
4619
+ }
4620
+ function certMetadataValue(pem) {
4621
+ return new X509Certificate(pem).raw.toString("base64url");
4622
+ }
4623
+ function requireWorkloadDigest(reference) {
4624
+ const named = /@(sha256:[0-9a-f]{64})$/.exec(reference);
4625
+ if (named === null) {
4626
+ throw new GcpPlacementError("VENUE_WORKLOAD_IMAGE must name its image by digest (\u2026@sha256:<64 hex>) for a Confidential Space venue: the attestation is checked against that digest, and a tag names whatever was pushed last.");
4627
+ }
4628
+ return named[1];
4629
+ }
4630
+ function underStageRoot(path) {
4631
+ if (!path.startsWith(`${CS_STAGE_ROOT}/`)) {
4632
+ throw new Error(`${path} is outside ${CS_STAGE_ROOT}, the only directory the stage helpers mount`);
4633
+ }
4634
+ return path;
4635
+ }
4636
+ function userFlag(owner) {
4637
+ return `--user=${String(owner.uid)}:${String(owner.gid)}`;
4638
+ }
4639
+ function csStagePrepareArgv(stageBase, owner) {
4640
+ const dirs = Object.values(venueStagePaths(underStageRoot(stageBase))).map(quoteForRemoteShell);
4641
+ return [
4642
+ "run",
4643
+ "--rm",
4644
+ "--network=none",
4645
+ `--volume=${CS_STAGE_ROOT}:${CS_STAGE_ROOT}:rw`,
4646
+ "--",
4647
+ STAGE_HELPER_IMAGE,
4648
+ "sh",
4649
+ "-c",
4650
+ `mkdir -p ${dirs.join(" ")} && chown -R ${String(owner.uid)}:${String(owner.gid)} ` + quoteForRemoteShell(stageBase)
4651
+ ];
4652
+ }
4653
+ function csUntarArgv(dir, owner) {
4654
+ return [
4655
+ "run",
4656
+ "-i",
4657
+ "--rm",
4658
+ "--network=none",
4659
+ userFlag(owner),
4660
+ `--volume=${underStageRoot(dir)}:/dst:rw`,
4661
+ "--",
4662
+ STAGE_HELPER_IMAGE,
4663
+ "tar",
4664
+ "-xf",
4665
+ "-",
4666
+ "-C",
4667
+ "/dst"
4668
+ ];
4669
+ }
4670
+ function csUntarProxyArgv(dir, owner) {
4671
+ const d = quoteForRemoteShell(underStageRoot(dir));
4672
+ return [
4673
+ "run",
4674
+ "-i",
4675
+ "--rm",
4676
+ "--network=none",
4677
+ userFlag(owner),
4678
+ `--volume=${CS_STAGE_ROOT}:${CS_STAGE_ROOT}:rw`,
4679
+ "--",
4680
+ STAGE_HELPER_IMAGE,
4681
+ "sh",
4682
+ "-c",
4683
+ `mkdir -p ${d} && tar -C ${d} -xf - && chmod 0644 ${d}/*.js && chmod 0755 ${d}`
4684
+ ];
4685
+ }
4686
+ function csRemoveArgv(dir, owner) {
4687
+ return [
4688
+ "run",
4689
+ "--rm",
4690
+ "--network=none",
4691
+ userFlag(owner),
4692
+ `--volume=${CS_STAGE_ROOT}:${CS_STAGE_ROOT}:rw`,
4693
+ "--",
4694
+ STAGE_HELPER_IMAGE,
4695
+ "rm",
4696
+ "-rf",
4697
+ "--",
4698
+ underStageRoot(dir)
4699
+ ];
4700
+ }
4701
+ function sshStageTransport(vm, project, zone, env, io) {
4702
+ return {
4703
+ /**
4704
+ * `id -u` / `id -g` on the venue, as the account that untarred the tree.
4705
+ *
4706
+ * That account is the owner BY CONSTRUCTION — the untar runs as the ssh user,
4707
+ * so asking it who it is answers exactly the question the mount needs, without
4708
+ * a chown and without assuming a uid. One ssh round trip, once per run.
4709
+ *
4710
+ * Both ids come from ONE invocation. Two calls could straddle a reconnect and
4711
+ * pair a uid with another session's gid — a third wrong answer rather than a
4712
+ * half-right one, which is the reason `GuestUser` is a pair in the first place.
4713
+ */
4714
+ readOwner: () => {
4715
+ const res = io.exec(
4716
+ "gcloud",
4717
+ iapSshArgv(vm, project, zone, "id -u && id -g"),
4718
+ OWNER_PROBE_TIMEOUT_MS,
4719
+ // `env`, like every other gcloud call here. TERM-724 made the venue boot under the
4720
+ // SERVICE configuration rather than whoever is at the terminal, and a probe that
4721
+ // omitted this would ssh under the person's own credential — then report an owner
4722
+ // measured against a session the run does not use.
4723
+ env
4724
+ );
4725
+ if (!res.ok) {
4726
+ throw new HostedVenueError(`could not read the staged tree's owner on ${vm}: ${execDetail(res).slice(0, 300)}. Refusing rather than falling back to this machine\u2019s uid, which would hand the guest an id with no write access to its own clone.`);
4727
+ }
4728
+ const lines = res.stdout.split("\n").map((l) => l.trim()).filter((l) => l.length > 0);
4729
+ const ids2 = lines.slice(-2).map((l) => Number(l));
4730
+ if (ids2.length !== 2 || !ids2.every((n) => Number.isInteger(n) && n > 0)) {
4731
+ throw new HostedVenueError(`the venue ${vm} answered 'id -u && id -g' with ${JSON.stringify(res.stdout.slice(0, 120))}, which is not a uid/gid pair. Refusing rather than guessing an owner.`);
4732
+ }
4733
+ return { uid: ids2[0], gid: ids2[1] };
4734
+ },
4735
+ push: (from, to) => io.pushTree(from, "gcloud", iapUntarArgv(vm, project, zone, to), STAGE_PUSH_TIMEOUT_MS, env),
4736
+ pushProxy: (from, dir) => io.pushTree(from, "gcloud", iapUntarProxyArgv(vm, project, zone, dir), STAGE_PUSH_TIMEOUT_MS, env),
4737
+ removeProxy: (dir) => {
4738
+ io.exec("gcloud", iapSshArgv(vm, project, zone, `rm -rf ${quoteForRemoteShell(dir)}`), PROXY_CLEANUP_TIMEOUT_MS, env);
4739
+ }
4740
+ };
4741
+ }
4742
+ function dockerStageTransport(docker3, env, io) {
4743
+ const pushVia = (from, args) => {
4744
+ const [file, ...rest] = docker3.commandLine(args);
4745
+ if (file === void 0)
4746
+ throw new Error("the docker client returned an empty command line");
4747
+ return io.pushTree(from, file, rest, STAGE_PUSH_TIMEOUT_MS, env);
4748
+ };
4749
+ return {
4750
+ // Fixed rather than probed: the helpers unpack as this account, so it owns the tree.
4751
+ readOwner: () => CS_GUEST_USER,
4752
+ push: (from, to) => pushVia(from, csUntarArgv(to, CS_GUEST_USER)),
4753
+ pushProxy: (from, dir) => pushVia(from, csUntarProxyArgv(dir, CS_GUEST_USER)),
4754
+ removeProxy: (dir) => {
4755
+ docker3.sync(csRemoveArgv(dir, CS_GUEST_USER), { timeoutMs: PROXY_CLEANUP_TIMEOUT_MS });
4756
+ }
4757
+ };
4758
+ }
4759
+ function bothTunnels(a, b) {
4760
+ return {
4761
+ kill: (signal) => {
4762
+ let failed = false;
4763
+ let error;
4764
+ for (const t of [a, b]) {
4765
+ try {
4766
+ t.kill(signal);
4767
+ } catch (err) {
4768
+ if (!failed)
4769
+ error = err;
4770
+ failed = true;
4771
+ }
4772
+ }
4773
+ if (failed)
4774
+ throw error;
4775
+ },
4776
+ failure: () => a.failure() ?? b.failure()
4777
+ };
4778
+ }
4779
+ function makeClientTls(io, allocated, vm) {
4780
+ let dir;
4781
+ try {
4782
+ dir = io.makePrivateDir();
4783
+ } catch (err) {
4784
+ throw new HostedVenueError(`could not create a private directory for ${vm}'s client key: ${describeErr(err)}`, "ours");
4785
+ }
4786
+ allocated.onRollback(() => {
4787
+ try {
4788
+ io.removeTree(dir);
4789
+ } catch (err) {
4790
+ throw new HostedVenueError(`the directory ${dir} holding ${vm}'s client key could not be removed and is left behind: ${describeErr(err)}`);
4791
+ }
4792
+ });
4793
+ const certPath = join11(dir, "client.pem");
4794
+ const keyPath = join11(dir, "client-key.pem");
4795
+ const made = io.exec("openssl", clientCertArgv(keyPath, certPath), OPENSSL_TIMEOUT_MS, {});
4796
+ if (!made.ok) {
4797
+ throw new HostedVenueError(`could not make ${vm}'s client certificate with openssl: ${execDetail(made).slice(0, 300)}`, "ours");
4798
+ }
4799
+ let metadataValue;
4800
+ try {
4801
+ const pem = io.readTextIfPresent(certPath);
4802
+ if (pem === null)
4803
+ throw new Error(`openssl wrote nothing to ${certPath}`);
4804
+ metadataValue = certMetadataValue(pem);
4805
+ } catch (err) {
4806
+ throw new HostedVenueError(`could not read back ${vm}'s client certificate: ${describeErr(err)}`, "ours");
4807
+ }
4808
+ return { dir, certPath, keyPath, metadataValue };
4809
+ }
4810
+ async function reachConfidentialSpace(a) {
4811
+ const { io, vm } = a;
4812
+ let ports;
4813
+ try {
4814
+ ports = await io.freeLocalPorts(2);
4815
+ } catch (err) {
4816
+ throw new HostedVenueError(`could not find two free local ports for ${vm}'s forwards: ${describeErr(err)}`, "ours");
4817
+ }
4818
+ const [attestPort, dockerPort] = ports;
4819
+ if (attestPort === void 0 || dockerPort === void 0 || attestPort === dockerPort) {
4820
+ throw new HostedVenueError(`could not find two distinct free local ports for ${vm}'s forwards`, "ours");
4821
+ }
4822
+ const forward = (remote, local) => {
4823
+ const t = io.spawnTunnel("gcloud", iapPortTunnelArgv(vm, a.project, a.zone, remote, local), a.env);
4824
+ a.allocated.onRollback(() => {
4825
+ try {
4826
+ t.kill("SIGTERM");
4827
+ } catch {
4828
+ }
4829
+ });
4830
+ return t;
4831
+ };
4832
+ const tunnel = bothTunnels(forward(CS_ATTEST_PORT, attestPort), forward(CS_DOCKER_PORT, dockerPort));
4833
+ const deadline = io.now() + CS_READY_BUDGET_MS;
4834
+ let reading = { ok: false, reason: "no request was made" };
4835
+ while (io.now() < deadline) {
4836
+ const dead = tunnel.failure();
4837
+ if (dead !== null) {
4838
+ throw new HostedVenueError(`the IAP port forward to ${vm} could not be kept up \u2014 ${dead}`);
4839
+ }
4840
+ reading = await io.fetchAttestation({
4841
+ port: attestPort,
4842
+ certPath: a.tls.certPath,
4843
+ keyPath: a.tls.keyPath,
4844
+ timeoutMs: CS_ATTEST_TIMEOUT_MS
4845
+ });
4846
+ if (reading.ok)
4847
+ break;
4848
+ await io.sleep(CS_ATTEST_INTERVAL_MS);
4849
+ }
4850
+ if (!reading.ok) {
4851
+ throw new HostedVenueError(`${vm}'s attest endpoint did not answer within ${String(CS_READY_BUDGET_MS / 1e3)}s \u2014 last reason: ${reading.reason}`);
4852
+ }
4853
+ let spki;
4854
+ let venuePem;
4855
+ try {
4856
+ spki = spkiSha256Hex(reading.peerCertDer);
4857
+ venuePem = new X509Certificate(reading.peerCertDer).toString();
4858
+ } catch (err) {
4859
+ throw new HostedVenueError(`${vm}'s attest endpoint presented a certificate that does not parse: ${describeErr(err)}`);
4860
+ }
4861
+ let jwks;
4862
+ try {
4863
+ jwks = await io.fetchConfidentialSpaceJwks();
4864
+ } catch (err) {
4865
+ throw new HostedVenueError(`could not reach the Confidential Space key set to check ${vm}'s attestation \u2014 ` + describeErr(err), "ours", { cause: err });
4866
+ }
4867
+ const verdict = await verifyConfidentialSpaceToken(reading.token, {
4868
+ audience: expectedAudience(a.runId),
4869
+ nonce: a.runId,
4870
+ additionalNonces: [spki],
4871
+ instanceName: vm,
4872
+ allowedZones: [a.zone],
4873
+ projectId: a.project,
4874
+ allowedImageDigests: [a.imageDigest],
4875
+ containerEnv: { TH_CLIENT_CERT: a.tls.metadataValue }
4876
+ }, { fetchJwks: () => Promise.resolve(jwks), now: io.now() });
4877
+ if (!verdict.ok) {
4878
+ throw new HostedVenueError(`${vm} presented an attestation this lease refuses (${verdict.reason}): ${verdict.detail}`);
4879
+ }
4880
+ const caPath = join11(a.tls.dir, "venue.pem");
4881
+ try {
4882
+ io.writeFile(caPath, venuePem);
4883
+ } catch (err) {
4884
+ throw new HostedVenueError(`could not write ${vm}'s attested certificate to ${caPath}: ${describeErr(err)}`, "ours");
4885
+ }
4886
+ const dockerAt = `tcp://localhost:${String(dockerPort)}`;
4887
+ const docker3 = io.dockerOverTls(dockerAt, { caPath, certPath: a.tls.certPath, keyPath: a.tls.keyPath }, CS_RUN_ARGS);
4888
+ const daemon = io.classifyDaemon(docker3);
4889
+ if (!daemon.distinct) {
4890
+ throw new HostedVenueError(`refusing to hand out a hosted lease: ${describeVenueDaemon(daemon)}`);
4891
+ }
4892
+ const prepared = docker3.sync(csStagePrepareArgv(a.stageBase, CS_GUEST_USER), {
4893
+ timeoutMs: MKDIR_TIMEOUT_MS
4894
+ });
4895
+ if (prepared.error || prepared.status !== 0) {
4896
+ throw new HostedVenueError(`could not prepare the stage on ${vm}: ${dockerDetail(prepared).slice(0, 300)}`);
4897
+ }
4898
+ const pulled = docker3.sync(["pull", DEFAULT_CONTAINER_IMAGE], { timeoutMs: CS_PULL_TIMEOUT_MS });
4899
+ if (pulled.error || pulled.status !== 0) {
4900
+ throw new HostedVenueError(`could not pull ${DEFAULT_CONTAINER_IMAGE} on ${vm}: ${dockerDetail(pulled).slice(0, 300)}`);
4901
+ }
4902
+ return {
4903
+ docker: docker3,
4904
+ tunnel,
4905
+ socketDir: a.tls.dir,
4906
+ socketPath: dockerAt,
4907
+ venueDaemonId: daemon.venueDaemonId,
4908
+ venueIdentity: { kind: "confidential-space", token: reading.token, claims: verdict.value },
4909
+ transport: dockerStageTransport(docker3, a.env, io)
4910
+ };
4911
+ }
4207
4912
  function hostedVenue(opts = {}, io = defaultHostedVenueIo) {
4208
4913
  const project = opts.project ?? DEFAULT_GCP_PROJECT;
4209
4914
  const zone = opts.zone ?? DEFAULT_GCP_ZONE;
4210
4915
  const gcloudConfig = opts.gcloudConfig ?? VENUE_GCLOUD_CONFIG;
4211
4916
  const keyFile = resolveServiceAccountKeyFile(opts);
4917
+ const image = venueImageFromEnv(process.env);
4918
+ const csImageDigest = image.venueImage === "confidential-space" ? requireWorkloadDigest(image.workloadImage) : null;
4212
4919
  const env = venueGcloudEnv(gcloudConfig);
4213
4920
  return {
4214
4921
  kind: "hosted-pool",
4215
4922
  acquire: (runId) => acquireTransactionally(async (allocated) => {
4216
4923
  const vm = venueInstanceName(runId);
4217
4924
  const stageBase = `/tmp/th-stage-${runId}`;
4218
- const bootArgv = gcpBootArgv({
4925
+ const bootParams = (clientCert) => image.venueImage === "confidential-space" ? {
4219
4926
  vmName: vm,
4220
4927
  project,
4221
4928
  zone,
4222
4929
  runId,
4223
- machineType: opts.machineType ?? DEFAULT_GCP_MACHINE_TYPE
4224
- });
4930
+ machineType: opts.machineType ?? CONFIDENTIAL_SPACE_MACHINE_TYPE,
4931
+ venueImage: "confidential-space",
4932
+ workloadImage: image.workloadImage,
4933
+ clientCert
4934
+ } : {
4935
+ vmName: vm,
4936
+ project,
4937
+ zone,
4938
+ runId,
4939
+ machineType: opts.machineType ?? DEFAULT_GCP_MACHINE_TYPE,
4940
+ ...image
4941
+ };
4942
+ let bootArgv = gcpBootArgv(bootParams(CLIENT_CERT_STAND_IN));
4225
4943
  ensureVenueServiceCredentials(gcloudConfig, keyFile, io);
4944
+ const clientTls = csImageDigest === null ? null : { ...makeClientTls(io, allocated, vm), imageDigest: csImageDigest };
4945
+ if (clientTls !== null)
4946
+ bootArgv = gcpBootArgv(bootParams(clientTls.metadataValue));
4226
4947
  const boot = io.exec("gcloud", bootArgv, BOOT_TIMEOUT_MS, env);
4227
4948
  let instanceState = "unknown";
4228
4949
  allocated.onRollback(() => {
@@ -4238,6 +4959,20 @@ function hostedVenue(opts = {}, io = defaultHostedVenueIo) {
4238
4959
  throw new HostedVenueError(`could not boot ${vm}: ${verdict2.reason.slice(0, 400)}`, verdict2.source);
4239
4960
  }
4240
4961
  instanceState = "exists";
4962
+ if (clientTls !== null) {
4963
+ return finishAcquire(await reachConfidentialSpace({
4964
+ vm,
4965
+ project,
4966
+ zone,
4967
+ runId,
4968
+ stageBase,
4969
+ env,
4970
+ io,
4971
+ allocated,
4972
+ tls: clientTls,
4973
+ imageDigest: clientTls.imageDigest
4974
+ }));
4975
+ }
4241
4976
  const deadline = io.now() + SSH_READY_BUDGET_MS;
4242
4977
  let last = { retryable: true, reason: "no probe ran" };
4243
4978
  let identity = null;
@@ -4297,7 +5032,7 @@ function hostedVenue(opts = {}, io = defaultHostedVenueIo) {
4297
5032
  throw new HostedVenueError(`the tunnel socket directory ${socketDir} could not be removed and is left behind: ${describeErr(err)}`);
4298
5033
  }
4299
5034
  });
4300
- const socketPath = join10(socketDir, VENUE_SOCKET_NAME);
5035
+ const socketPath = join11(socketDir, VENUE_SOCKET_NAME);
4301
5036
  if (io.exists(socketPath)) {
4302
5037
  throw new HostedVenueError(`something already exists at ${socketPath}, inside a directory created seconds ago for this run alone. Refusing rather than clearing it: the tunnel would carry the whole run over a path we cannot account for`, "ours");
4303
5038
  }
@@ -4332,43 +5067,56 @@ function hostedVenue(opts = {}, io = defaultHostedVenueIo) {
4332
5067
  if (!prepared.ok) {
4333
5068
  throw new HostedVenueError(`could not prepare the stage on ${vm}: ${execDetail(prepared).slice(0, 300)}`);
4334
5069
  }
4335
- const stageVolumes = stageVolumeNames(vm);
4336
- for (const name of [stageVolumes.clone, stageVolumes.jail]) {
4337
- const created = docker3.sync(volumeCreateArgv(name, runId), {
4338
- timeoutMs: VOLUME_CREATE_TIMEOUT_MS
4339
- });
4340
- if (created.error || created.status !== 0) {
4341
- throw new HostedVenueError(`could not create the stage volume ${name} on ${vm}: ${dockerDetail(created).slice(0, 300)}`);
4342
- }
4343
- }
4344
- for (const name of [stageVolumes.clone, stageVolumes.jail]) {
4345
- const probe = docker3.sync(execProbeArgv(name), {
4346
- timeoutMs: EXEC_PROBE_TIMEOUT_MS
4347
- });
4348
- if (probe.error || probe.status !== 0) {
4349
- throw new HostedVenueError(`${vm} cannot execute a file written to its own Docker volume ${name}, so no install step with a native postinstall could run there. Refusing before staging anything: ${dockerDetail(probe).slice(0, 300)}`);
4350
- }
4351
- }
4352
- const after = io.classifyDaemon(docker3);
4353
- if (!after.distinct || after.venueDaemonId !== venueDaemonId) {
4354
- throw new HostedVenueError(`the daemon at ${socketPath} changed while the stage volumes were being created and proved: it now answers as ${after.distinct ? `daemon ${after.venueDaemonId}` : describeVenueDaemon(after)}, not ${venueDaemonId}. Refusing to hand out a lease whose proof was made elsewhere.`);
4355
- }
4356
- return makeLease({
4357
- runId,
4358
- vm,
4359
- project,
4360
- zone,
4361
- stageBase,
4362
- stageVolumes,
5070
+ return finishAcquire({
4363
5071
  docker: docker3,
4364
5072
  tunnel,
4365
5073
  socketDir,
4366
5074
  socketPath,
4367
5075
  venueDaemonId,
4368
5076
  venueIdentity,
4369
- env,
4370
- io
5077
+ transport: sshStageTransport(vm, project, zone, env, io)
4371
5078
  });
5079
+ function finishAcquire(r) {
5080
+ const { docker: docker4, socketPath: socketPath2, venueDaemonId: venueDaemonId2 } = r;
5081
+ const stageVolumes = stageVolumeNames(vm);
5082
+ for (const name of [stageVolumes.clone, stageVolumes.jail]) {
5083
+ const created = docker4.sync(volumeCreateArgv(name, runId), {
5084
+ timeoutMs: VOLUME_CREATE_TIMEOUT_MS
5085
+ });
5086
+ if (created.error || created.status !== 0) {
5087
+ throw new HostedVenueError(`could not create the stage volume ${name} on ${vm}: ${dockerDetail(created).slice(0, 300)}`);
5088
+ }
5089
+ }
5090
+ for (const name of [stageVolumes.clone, stageVolumes.jail]) {
5091
+ const probe = docker4.sync(execProbeArgv(name), {
5092
+ timeoutMs: EXEC_PROBE_TIMEOUT_MS
5093
+ });
5094
+ if (probe.error || probe.status !== 0) {
5095
+ throw new HostedVenueError(`${vm} cannot execute a file written to its own Docker volume ${name}, so no install step with a native postinstall could run there. Refusing before staging anything: ${dockerDetail(probe).slice(0, 300)}`);
5096
+ }
5097
+ }
5098
+ const after = io.classifyDaemon(docker4);
5099
+ if (!after.distinct || after.venueDaemonId !== venueDaemonId2) {
5100
+ throw new HostedVenueError(`the daemon at ${socketPath2} changed while the stage volumes were being created and proved: it now answers as ${after.distinct ? `daemon ${after.venueDaemonId}` : describeVenueDaemon(after)}, not ${venueDaemonId2}. Refusing to hand out a lease whose proof was made elsewhere.`);
5101
+ }
5102
+ return makeLease({
5103
+ runId,
5104
+ vm,
5105
+ project,
5106
+ zone,
5107
+ stageBase,
5108
+ stageVolumes,
5109
+ docker: docker4,
5110
+ tunnel: r.tunnel,
5111
+ socketDir: r.socketDir,
5112
+ socketPath: socketPath2,
5113
+ venueDaemonId: venueDaemonId2,
5114
+ venueIdentity: r.venueIdentity,
5115
+ transport: r.transport,
5116
+ env,
5117
+ io
5118
+ });
5119
+ }
4372
5120
  })
4373
5121
  };
4374
5122
  }
@@ -4427,28 +5175,7 @@ function makeLease(p) {
4427
5175
  return null;
4428
5176
  };
4429
5177
  let stagedOwner;
4430
- const readStagedOwner = () => {
4431
- const res = p.io.exec(
4432
- "gcloud",
4433
- iapSshArgv(p.vm, p.project, p.zone, "id -u && id -g"),
4434
- OWNER_PROBE_TIMEOUT_MS,
4435
- // `p.env`, like every other gcloud call here. TERM-724 made the venue boot under the
4436
- // SERVICE configuration rather than whoever is at the terminal, and a probe that
4437
- // omitted this would ssh under the person's own credential — then report an owner
4438
- // measured against a session the run does not use.
4439
- p.env
4440
- );
4441
- if (!res.ok) {
4442
- throw new HostedVenueError(`could not read the staged tree's owner on ${p.vm}: ${execDetail(res).slice(0, 300)}. Refusing rather than falling back to this machine\u2019s uid, which would hand the guest an id with no write access to its own clone.`);
4443
- }
4444
- const lines = res.stdout.split("\n").map((l) => l.trim()).filter((l) => l.length > 0);
4445
- const ids2 = lines.slice(-2).map((l) => Number(l));
4446
- if (ids2.length !== 2 || !ids2.every((n) => Number.isInteger(n) && n > 0)) {
4447
- throw new HostedVenueError(`the venue ${p.vm} answered 'id -u && id -g' with ${JSON.stringify(res.stdout.slice(0, 120))}, which is not a uid/gid pair. Refusing rather than guessing an owner.`);
4448
- }
4449
- return { uid: ids2[0], gid: ids2[1] };
4450
- };
4451
- stagedOwner = readStagedOwner();
5178
+ stagedOwner = p.transport.readOwner();
4452
5179
  return {
4453
5180
  kind: "hosted-pool",
4454
5181
  runId: p.runId,
@@ -4483,7 +5210,7 @@ function makeLease(p) {
4483
5210
  const dir = venueProxyDir(p.stageBase);
4484
5211
  const buffer = stageProxyCode();
4485
5212
  try {
4486
- const res = await p.io.pushTree(buffer.dir, "gcloud", iapUntarProxyArgv(p.vm, p.project, p.zone, dir), STAGE_PUSH_TIMEOUT_MS, p.env);
5213
+ const res = await p.transport.pushProxy(buffer.dir, dir);
4487
5214
  if (!res.ok) {
4488
5215
  throw new HostedVenueError(`could not stage the egress proxy onto ${p.vm}: ${execDetail(res).slice(0, 300)}`);
4489
5216
  }
@@ -4493,7 +5220,7 @@ function makeLease(p) {
4493
5220
  return {
4494
5221
  dir,
4495
5222
  cleanup: () => {
4496
- p.io.exec("gcloud", iapSshArgv(p.vm, p.project, p.zone, `rm -rf ${quoteForRemoteShell(dir)}`), PROXY_CLEANUP_TIMEOUT_MS, p.env);
5223
+ p.transport.removeProxy(dir);
4497
5224
  }
4498
5225
  };
4499
5226
  },
@@ -4501,7 +5228,7 @@ function makeLease(p) {
4501
5228
  check("staging the tree");
4502
5229
  const paths = venueStagePaths(p.stageBase);
4503
5230
  const push = async (from, to) => {
4504
- const res = await p.io.pushTree(from, "gcloud", iapUntarArgv(p.vm, p.project, p.zone, to), STAGE_PUSH_TIMEOUT_MS, p.env);
5231
+ const res = await p.transport.push(from, to);
4505
5232
  if (!res.ok) {
4506
5233
  throw new HostedVenueError(`could not stage ${from} onto ${p.vm}: ${execDetail(res).slice(0, 300)}`);
4507
5234
  }
@@ -4509,14 +5236,14 @@ function makeLease(p) {
4509
5236
  const { jail: localJail, tmp: localTmp } = localJailPaths(local.scratchRoot);
4510
5237
  const required = [
4511
5238
  localTmp,
4512
- join10(localJail, JAIL_PASSWD_FILE),
4513
- join10(localJail, JAIL_GROUP_FILE)
5239
+ join11(localJail, JAIL_PASSWD_FILE),
5240
+ join11(localJail, JAIL_GROUP_FILE)
4514
5241
  ];
4515
5242
  const missing = required.filter((path) => !p.io.exists(path));
4516
5243
  if (missing.length > 0) {
4517
5244
  throw new HostedVenueError(`refusing to stage ${local.scratchRoot} onto ${p.vm}: the jail at ${localJail} is incomplete \u2014 missing ${missing.join(", ")}. buildJail must run to completion before stage(), or the venue mounts a directory with no identity database.`);
4518
5245
  }
4519
- const gitConfigPath = join10(local.cloneDir, ".git", "config");
5246
+ const gitConfigPath = join11(local.cloneDir, ".git", "config");
4520
5247
  let gitConfig;
4521
5248
  try {
4522
5249
  gitConfig = p.io.readTextIfPresent(gitConfigPath);
@@ -4659,7 +5386,7 @@ function makeLease(p) {
4659
5386
  }
4660
5387
  };
4661
5388
  }
4662
- var SSH_READY_BUDGET_MS, SSH_PROBE_INTERVAL_MS, SSH_PROBE_TIMEOUT_MS, TUNNEL_BUDGET_MS, TUNNEL_POLL_INTERVAL_MS, GOOGLE_JWKS_URL, JWKS_FETCH_TIMEOUT_MS, CREDENTIAL_QUERY_PARAM, UNDECODABLE, STAGE_PUSH_TIMEOUT_MS, DISPATCHED_PROBE_TIMEOUT_MS, DISPATCHED_STATUS_ARGV, DISPATCHED_GIT_CANDIDATES, PROXY_CLEANUP_TIMEOUT_MS, OWNER_PROBE_TIMEOUT_MS, BOOT_TIMEOUT_MS, MKDIR_TIMEOUT_MS, DELETE_TIMEOUT_MS, LOCAL_GCLOUD_TIMEOUT_MS, SERVICE_ACCOUNT_ACTIVATE_TIMEOUT_MS, SOCKET_DIR_PREFIX, VENUE_SOCKET_NAME, HostedVenueError, VENUE_GCLOUD_CONFIG, SERVICE_ACCOUNT_SUFFIX, GCLOUD_PRINCIPAL_OVERRIDES, defaultHostedVenueIo, VENUE_SSH_USER, GCE_METADATA_IDENTITY_URL, COMPACT_JWT, STAGE_HELPER_IMAGE, VOLUME_CREATE_TIMEOUT_MS, EXEC_PROBE_TIMEOUT_MS, POPULATE_TIMEOUT_MS, STAGE_VOLUME_LABEL_KEY, STAGE_PROOF_PREFIX, IAP_NOT_READY, IAP_BACKEND_UNREACHABLE, IAP_DENIED, TERMINAL_GCP, INSTANCE_NOT_RUNNING, PREEMPTED, HOST_KEY_MISMATCH, SSH_KEY_NOT_READY, DAEMON_NOT_READY, SSH_NOT_ANSWERING;
5389
+ var SSH_READY_BUDGET_MS, SSH_PROBE_INTERVAL_MS, SSH_PROBE_TIMEOUT_MS, TUNNEL_BUDGET_MS, TUNNEL_POLL_INTERVAL_MS, GOOGLE_JWKS_URL, JWKS_FETCH_TIMEOUT_MS, CREDENTIAL_QUERY_PARAM, UNDECODABLE, STAGE_PUSH_TIMEOUT_MS, DISPATCHED_PROBE_TIMEOUT_MS, DISPATCHED_STATUS_ARGV, DISPATCHED_GIT_CANDIDATES, PROXY_CLEANUP_TIMEOUT_MS, OWNER_PROBE_TIMEOUT_MS, BOOT_TIMEOUT_MS, MKDIR_TIMEOUT_MS, DELETE_TIMEOUT_MS, LOCAL_GCLOUD_TIMEOUT_MS, SERVICE_ACCOUNT_ACTIVATE_TIMEOUT_MS, SOCKET_DIR_PREFIX, VENUE_SOCKET_NAME, HostedVenueError, VENUE_GCLOUD_CONFIG, SERVICE_ACCOUNT_SUFFIX, GCLOUD_PRINCIPAL_OVERRIDES, defaultHostedVenueIo, VENUE_SSH_USER, GCE_METADATA_IDENTITY_URL, COMPACT_JWT, STAGE_HELPER_IMAGE, VOLUME_CREATE_TIMEOUT_MS, EXEC_PROBE_TIMEOUT_MS, POPULATE_TIMEOUT_MS, STAGE_VOLUME_LABEL_KEY, STAGE_PROOF_PREFIX, IAP_NOT_READY, IAP_BACKEND_UNREACHABLE, IAP_DENIED, TERMINAL_GCP, INSTANCE_NOT_RUNNING, PREEMPTED, HOST_KEY_MISMATCH, SSH_KEY_NOT_READY, DAEMON_NOT_READY, SSH_NOT_ANSWERING, CS_ATTEST_PORT, CS_DOCKER_PORT, CS_READY_BUDGET_MS, CS_ATTEST_TIMEOUT_MS, CS_PULL_TIMEOUT_MS, CS_ATTEST_INTERVAL_MS, OPENSSL_TIMEOUT_MS, CS_RUN_ARGS, CS_GUEST_USER, CS_STAGE_ROOT, CLIENT_CERT_STAND_IN;
4663
5390
  var init_hostedVenue = __esm({
4664
5391
  "../../packages/envrun/dist/hostedVenue.js"() {
4665
5392
  "use strict";
@@ -4724,7 +5451,7 @@ var init_hostedVenue = __esm({
4724
5451
  pushTree: pushTreeWithTar,
4725
5452
  readTextIfPresent: (path) => {
4726
5453
  try {
4727
- return readFileSync3(path, "utf8");
5454
+ return readFileSync4(path, "utf8");
4728
5455
  } catch (err) {
4729
5456
  if (err.code === "ENOENT")
4730
5457
  return null;
@@ -4756,14 +5483,14 @@ var init_hostedVenue = __esm({
4756
5483
  },
4757
5484
  sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
4758
5485
  now: () => Date.now(),
4759
- exists: (path) => existsSync5(path),
5486
+ exists: (path) => existsSync6(path),
4760
5487
  makePrivateDir: () => {
4761
- const dir = mkdtempSync3(join10(tmpdir3(), SOCKET_DIR_PREFIX));
5488
+ const dir = mkdtempSync3(join11(tmpdir3(), SOCKET_DIR_PREFIX));
4762
5489
  chmodSync2(dir, 448);
4763
5490
  return dir;
4764
5491
  },
4765
5492
  removeTree: (path) => {
4766
- rmSync3(path, { recursive: true, force: true });
5493
+ rmSync4(path, { recursive: true, force: true });
4767
5494
  },
4768
5495
  dockerFor: (socketPath) => remoteDockerClient(`unix://${socketPath}`),
4769
5496
  classifyDaemon: (docker3) => classifyVenueDaemon(docker3),
@@ -4780,7 +5507,55 @@ var init_hostedVenue = __esm({
4780
5507
  throw new Error("JWKS fetch returned a body with no keys array");
4781
5508
  }
4782
5509
  return body;
4783
- }
5510
+ },
5511
+ fetchConfidentialSpaceJwks: async () => {
5512
+ const getJson = async (url, what) => {
5513
+ const res = await globalThis.fetch(url, {
5514
+ signal: AbortSignal.timeout(JWKS_FETCH_TIMEOUT_MS)
5515
+ });
5516
+ if (!res.ok) {
5517
+ throw new Error(`Confidential Space ${what} returned HTTP ${String(res.status)}`);
5518
+ }
5519
+ return res.json();
5520
+ };
5521
+ const discovery = await getJson(CONFIDENTIAL_SPACE_OIDC_DISCOVERY_URL, "discovery");
5522
+ const jwksUri = typeof discovery === "object" && discovery !== null ? discovery.jwks_uri : void 0;
5523
+ if (typeof jwksUri !== "string" || !jwksUri.startsWith("https://")) {
5524
+ throw new Error("Confidential Space discovery names no https jwks_uri");
5525
+ }
5526
+ const body = await getJson(jwksUri, "key set");
5527
+ if (typeof body !== "object" || body === null || !Array.isArray(body.keys)) {
5528
+ throw new Error("Confidential Space key set has no keys array");
5529
+ }
5530
+ return body;
5531
+ },
5532
+ freeLocalPorts: async (n) => {
5533
+ const servers = [];
5534
+ try {
5535
+ for (let i = 0; i < n; i += 1) {
5536
+ const server = createServer();
5537
+ servers.push(server);
5538
+ await new Promise((resolve2, reject) => {
5539
+ server.once("error", reject);
5540
+ server.listen(0, "localhost", () => {
5541
+ resolve2();
5542
+ });
5543
+ });
5544
+ }
5545
+ return servers.map((s) => s.address().port);
5546
+ } finally {
5547
+ await Promise.all(servers.map((s) => new Promise((resolve2) => {
5548
+ s.close(() => {
5549
+ resolve2();
5550
+ });
5551
+ })));
5552
+ }
5553
+ },
5554
+ writeFile: (path, text) => {
5555
+ writeFileSync6(path, text, { mode: 384, flag: "wx" });
5556
+ },
5557
+ fetchAttestation: (req) => fetchAttestationOverTls(req),
5558
+ dockerOverTls: (endpoint, tls, runArgs) => remoteDockerClient(endpoint, { tls, runArgs })
4784
5559
  };
4785
5560
  VENUE_SSH_USER = "th-runner";
4786
5561
  GCE_METADATA_IDENTITY_URL = "http://169.254.169.254/computeMetadata/v1/instance/service-accounts/default/identity";
@@ -4801,6 +5576,17 @@ var init_hostedVenue = __esm({
4801
5576
  SSH_KEY_NOT_READY = /Permission denied \(publickey/;
4802
5577
  DAEMON_NOT_READY = /Cannot connect to the Docker daemon/;
4803
5578
  SSH_NOT_ANSWERING = /Connection refused|Connection reset|Connection closed by|kex_exchange_identification|Operation timed out/;
5579
+ CS_ATTEST_PORT = 8443;
5580
+ CS_DOCKER_PORT = 2376;
5581
+ CS_READY_BUDGET_MS = 48e4;
5582
+ CS_ATTEST_TIMEOUT_MS = 15e3;
5583
+ CS_PULL_TIMEOUT_MS = 6e5;
5584
+ CS_ATTEST_INTERVAL_MS = 5e3;
5585
+ OPENSSL_TIMEOUT_MS = 3e4;
5586
+ CS_RUN_ARGS = ["--oom-score-adj", "1000"];
5587
+ CS_GUEST_USER = { uid: 1e3, gid: 1e3 };
5588
+ CS_STAGE_ROOT = "/tmp";
5589
+ CLIENT_CERT_STAND_IN = "stand-in-certificate";
4804
5590
  }
4805
5591
  });
4806
5592
 
@@ -5699,21 +6485,21 @@ var init_references = __esm({
5699
6485
  });
5700
6486
 
5701
6487
  // ../../packages/envspec/dist/repo.js
5702
- import { readdirSync as readdirSync2, readFileSync as readFileSync4, statSync as statSync2 } from "fs";
5703
- import { join as join11, relative, sep } from "path";
6488
+ import { readdirSync as readdirSync2, readFileSync as readFileSync5, statSync as statSync3 } from "fs";
6489
+ import { join as join12, relative, sep } from "path";
5704
6490
  function createRepoReader(repoPath) {
5705
- const resolveIn = (relativePath) => relativePath === "" ? repoPath : join11(repoPath, relativePath);
6491
+ const resolveIn = (relativePath) => relativePath === "" ? repoPath : join12(repoPath, relativePath);
5706
6492
  const toPosix = (absolute) => relative(repoPath, absolute).split(sep).join("/");
5707
6493
  const readText = (relativePath) => {
5708
6494
  try {
5709
- return readFileSync4(resolveIn(relativePath), "utf8");
6495
+ return readFileSync5(resolveIn(relativePath), "utf8");
5710
6496
  } catch {
5711
6497
  return null;
5712
6498
  }
5713
6499
  };
5714
6500
  const statOf = (relativePath) => {
5715
6501
  try {
5716
- return statSync2(resolveIn(relativePath));
6502
+ return statSync3(resolveIn(relativePath));
5717
6503
  } catch {
5718
6504
  return null;
5719
6505
  }
@@ -5732,7 +6518,7 @@ function createRepoReader(repoPath) {
5732
6518
  for (const name of names.slice().sort()) {
5733
6519
  if (SKIP_DIRECTORIES.has(name))
5734
6520
  continue;
5735
- const child = join11(dir, name);
6521
+ const child = join12(dir, name);
5736
6522
  const st = statOf(toPosix(child));
5737
6523
  if (st === null)
5738
6524
  continue;
@@ -6328,11 +7114,11 @@ var init_dist3 = __esm({
6328
7114
  });
6329
7115
 
6330
7116
  // ../../packages/envrun/dist/thrun.js
6331
- import { execFileSync, spawnSync as spawnSync6 } from "child_process";
6332
- import { existsSync as existsSync6, mkdirSync as mkdirSync4, mkdtempSync as mkdtempSync4, rmSync as rmSync4 } from "fs";
7117
+ 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";
6333
7119
  import { randomUUID } from "crypto";
6334
7120
  import { tmpdir as tmpdir4 } from "os";
6335
- import { join as join12 } from "path";
7121
+ import { join as join13 } from "path";
6336
7122
  function git(repoDir, args, allowNonZero = false) {
6337
7123
  const res = spawnSync6("git", [...args], {
6338
7124
  cwd: repoDir,
@@ -6346,13 +7132,16 @@ function git(repoDir, args, allowNonZero = false) {
6346
7132
  }
6347
7133
  return res.stdout ?? "";
6348
7134
  }
6349
- function collectWorkingDiff(repoDir) {
6350
- if (!existsSync6(join12(repoDir, ".git"))) {
7135
+ function collectWorkingDiff(repoDir, opts = {}) {
7136
+ if (!existsSync7(join13(repoDir, ".git"))) {
6351
7137
  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.`);
6352
7138
  }
6353
7139
  const headSha = git(repoDir, ["rev-parse", "HEAD"]).trim();
6354
- const tracked = git(repoDir, ["diff", "HEAD", "--binary"]);
6355
- const trackedChanged = git(repoDir, ["diff", "HEAD", "--name-only"]).split("\n").map((s) => s.trim()).filter((s) => s.length > 0);
7140
+ const diffBase = opts.base === void 0 ? headSha : assertDiffBase(repoDir, opts.base);
7141
+ const trackedArgs = opts.base === void 0 ? ["diff", "HEAD"] : ["diff", diffBase];
7142
+ const pathsEnd = opts.base === void 0 ? [] : ["--"];
7143
+ const tracked = git(repoDir, [...trackedArgs, "--binary", ...pathsEnd]);
7144
+ const trackedChanged = git(repoDir, [...trackedArgs, "--name-only", ...pathsEnd]).split("\n").map((s) => s.trim()).filter((s) => s.length > 0);
6356
7145
  const untracked = git(repoDir, ["ls-files", "--others", "--exclude-standard"]).split("\n").map((s) => s.trim()).filter((s) => s.length > 0);
6357
7146
  const parts = [];
6358
7147
  if (tracked.trim() !== "")
@@ -6362,7 +7151,17 @@ function collectWorkingDiff(repoDir) {
6362
7151
  if (one.trim() !== "")
6363
7152
  parts.push(one.replace(/\n*$/, "\n"));
6364
7153
  }
6365
- return { patch: parts.join(""), headSha, trackedChanged, untracked };
7154
+ return { patch: parts.join(""), headSha, diffBase, trackedChanged, untracked };
7155
+ }
7156
+ function assertDiffBase(repoDir, base) {
7157
+ if (!FULL_COMMIT_ID.test(base)) {
7158
+ throw new ThRunError(`the diff base must be a full 40-character commit id, got ${JSON.stringify(base)}.`);
7159
+ }
7160
+ const resolved = git(repoDir, ["rev-parse", "--verify", "--quiet", `${base}^{commit}`], true);
7161
+ if (resolved.trim() !== base) {
7162
+ throw new ThRunError(`the diff base ${base} is not a commit in ${repoDir}.`);
7163
+ }
7164
+ return base;
6366
7165
  }
6367
7166
  function isLocalPath(p) {
6368
7167
  return !UNC_PATH.test(p) && (p.startsWith("/") || WINDOWS_ABSOLUTE.test(p));
@@ -6482,14 +7281,14 @@ function credentialFreeHome() {
6482
7281
  return credentialFreeHomeDir;
6483
7282
  let made;
6484
7283
  try {
6485
- made = mkdtempSync4(join12(tmpdir4(), "th-run-nohome-"));
7284
+ made = mkdtempSync4(join13(tmpdir4(), "th-run-nohome-"));
6486
7285
  } catch (err) {
6487
7286
  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 });
6488
7287
  }
6489
7288
  credentialFreeHomeDir = made;
6490
7289
  process.once("exit", () => {
6491
7290
  try {
6492
- rmSync4(made, { recursive: true, force: true });
7291
+ rmSync5(made, { recursive: true, force: true });
6493
7292
  } catch {
6494
7293
  }
6495
7294
  });
@@ -6557,8 +7356,8 @@ function cloneTargetAtUnguarded(opts) {
6557
7356
  if (persisted !== null) {
6558
7357
  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.`);
6559
7358
  }
6560
- mkdirSync4(opts.dest, { recursive: true });
6561
- const runOut = (args) => execFileSync("git", [...gitConfigArgs(), ...args], {
7359
+ mkdirSync6(opts.dest, { recursive: true });
7360
+ const runOut = (args) => execFileSync2("git", [...gitConfigArgs(), ...args], {
6562
7361
  cwd: opts.dest,
6563
7362
  encoding: "utf8",
6564
7363
  stdio: "pipe",
@@ -6596,7 +7395,7 @@ function cloneTargetAtUnguarded(opts) {
6596
7395
  }
6597
7396
  function scrubCloneSource(dest, run2) {
6598
7397
  run2(["remote", "remove", "origin"]);
6599
- rmSync4(join12(dest, ".git", "FETCH_HEAD"), { force: true });
7398
+ rmSync5(join13(dest, ".git", "FETCH_HEAD"), { force: true });
6600
7399
  }
6601
7400
  function publishableTarget(url) {
6602
7401
  if (separatorInTarget(url) !== null)
@@ -6676,8 +7475,8 @@ function targetCarriesCredential(url) {
6676
7475
  }
6677
7476
  }
6678
7477
  function patchedTreeDigest(repoDir) {
6679
- execFileSync("git", ["-C", repoDir, "add", "-A", "-f"], { stdio: "pipe" });
6680
- const treeOid = execFileSync("git", ["-C", repoDir, "write-tree"], {
7478
+ execFileSync2("git", ["-C", repoDir, "add", "-A", "-f"], { stdio: "pipe" });
7479
+ const treeOid = execFileSync2("git", ["-C", repoDir, "write-tree"], {
6681
7480
  encoding: "utf8"
6682
7481
  }).trim();
6683
7482
  return sha256Hex(treeOid);
@@ -6934,8 +7733,9 @@ async function runVerification(req, ctx) {
6934
7733
  let diff = null;
6935
7734
  if (source.kind === "working-diff") {
6936
7735
  progress("collect", `reading the working diff from ${source.localRepoDir}`);
6937
- diff = collectWorkingDiff(source.localRepoDir);
6938
- progress("collect", `${String(diff.trackedChanged.length)} tracked, ${String(diff.untracked.length)} untracked, ${String(diff.patch.length)} bytes`);
7736
+ diff = collectWorkingDiff(source.localRepoDir, source.diffBase === void 0 ? {} : { base: source.diffBase });
7737
+ const from = source.diffBase === void 0 ? "" : `, from delivered baseline ${diff.diffBase.slice(0, 12)}`;
7738
+ progress("collect", `${String(diff.trackedChanged.length)} tracked, ${String(diff.untracked.length)} untracked, ${String(diff.patch.length)} bytes${from}`);
6939
7739
  } else {
6940
7740
  progress("collect", "dispatched run: the tree is the stored commit itself");
6941
7741
  }
@@ -6967,10 +7767,10 @@ async function runVerification(req, ctx) {
6967
7767
  venueIdentity: null
6968
7768
  };
6969
7769
  }
6970
- const stage = join12(req.scratchRoot, runId);
6971
- const cloneDir = join12(stage, "clone");
6972
- const scratch = join12(stage, "scratch");
6973
- mkdirSync4(scratch, { recursive: true });
7770
+ const stage = join13(req.scratchRoot, runId);
7771
+ const cloneDir = join13(stage, "clone");
7772
+ const scratch = join13(stage, "scratch");
7773
+ mkdirSync6(scratch, { recursive: true });
6974
7774
  assertSafeTargetSha(req.targetSha);
6975
7775
  progress("clone", `${publishableTarget(req.targetRepo)} @ ${req.targetSha.slice(0, 12)}`);
6976
7776
  cloneTargetAt({
@@ -7035,7 +7835,7 @@ async function runVerification(req, ctx) {
7035
7835
  const venuePaths = await lease.stage({
7036
7836
  cloneDir,
7037
7837
  scratchRoot: scratch,
7038
- previewDir: join12(stage, "preview"),
7838
+ previewDir: join13(stage, "preview"),
7039
7839
  // On a dispatched run the commit is the statement of what was tested, so
7040
7840
  // it rides with the tree and the venue seam refuses a tree that is not
7041
7841
  // that commit (design §6 item 4, TERM-892 — the guard lives in
@@ -7137,7 +7937,7 @@ async function runVerification(req, ctx) {
7137
7937
  await releaseWithoutThrowing(lease, progress);
7138
7938
  }
7139
7939
  }
7140
- var ThRunError, OUTPUT_TAIL_BYTES, ALLOWED_URL_SCHEMES, SCP_STYLE, URL_SCHEME, WINDOWS_ABSOLUTE, UNC_PATH, TRANSPORT_REASON, SHA_REASON, FULL_SHA, MIN_GIT_VERSION_FOR_END_OF_OPTIONS, CloneUnavailableError, GIT_ENV_ALLOWLIST, credentialFreeHomeDir, SSH_ISOLATION_ARGS, WITHHELD_SEGMENT, UNPARSEABLE_TARGET, FAILURE_LINE, REDACTED_TARGET_REPO, REDACTED_TARGET_SHA;
7940
+ var ThRunError, OUTPUT_TAIL_BYTES, FULL_COMMIT_ID, ALLOWED_URL_SCHEMES, SCP_STYLE, URL_SCHEME, WINDOWS_ABSOLUTE, UNC_PATH, TRANSPORT_REASON, SHA_REASON, FULL_SHA, MIN_GIT_VERSION_FOR_END_OF_OPTIONS, CloneUnavailableError, GIT_ENV_ALLOWLIST, credentialFreeHomeDir, SSH_ISOLATION_ARGS, WITHHELD_SEGMENT, UNPARSEABLE_TARGET, FAILURE_LINE, REDACTED_TARGET_REPO, REDACTED_TARGET_SHA;
7141
7941
  var init_thrun = __esm({
7142
7942
  "../../packages/envrun/dist/thrun.js"() {
7143
7943
  "use strict";
@@ -7155,6 +7955,7 @@ var init_thrun = __esm({
7155
7955
  ThRunError = class extends Error {
7156
7956
  };
7157
7957
  OUTPUT_TAIL_BYTES = 4e3;
7958
+ FULL_COMMIT_ID = /^[0-9a-f]{40}$/;
7158
7959
  ALLOWED_URL_SCHEMES = /* @__PURE__ */ new Set(["https", "http", "ssh", "git"]);
7159
7960
  SCP_STYLE = /^[A-Za-z0-9._~+-]+@[A-Za-z0-9._-]+:[^:]/;
7160
7961
  URL_SCHEME = /^([A-Za-z][A-Za-z0-9+.-]*):\/\//;
@@ -7391,8 +8192,8 @@ var init_dbplan = __esm({
7391
8192
 
7392
8193
  // ../../packages/envrun/dist/dbstack.js
7393
8194
  import { spawnSync as spawnSync7 } from "child_process";
7394
- import { randomBytes as randomBytes5 } from "crypto";
7395
- import { mkdirSync as mkdirSync5 } from "fs";
8195
+ import { randomBytes as randomBytes6 } from "crypto";
8196
+ import { mkdirSync as mkdirSync7 } from "fs";
7396
8197
  function installCommandFor(runner) {
7397
8198
  switch (runner) {
7398
8199
  case "sql":
@@ -7427,7 +8228,7 @@ function docker2(args, timeoutMs = DOCKER_TIMEOUT_MS2) {
7427
8228
  function generateCredentials(host) {
7428
8229
  return {
7429
8230
  user: "thverify",
7430
- password: randomBytes5(24).toString("base64url"),
8231
+ password: randomBytes6(24).toString("base64url"),
7431
8232
  database: "thverify",
7432
8233
  host,
7433
8234
  port: 5432
@@ -7734,8 +8535,8 @@ async function installLocalMigrationTooling(opts) {
7734
8535
  };
7735
8536
  }
7736
8537
  const { jail, tmp } = buildJail(opts.scratchRoot);
7737
- mkdirSync5(jail, { recursive: true });
7738
- mkdirSync5(tmp, { recursive: true });
8538
+ mkdirSync7(jail, { recursive: true });
8539
+ mkdirSync7(tmp, { recursive: true });
7739
8540
  const spec = {
7740
8541
  profile: "install",
7741
8542
  clone: opts.repoDir,
@@ -8013,6 +8814,7 @@ __export(dist_exports, {
8013
8814
  ALEMBIC_IMAGE: () => ALEMBIC_IMAGE,
8014
8815
  ATTEST_REFUSAL_REASONS: () => ATTEST_REFUSAL_REASONS,
8015
8816
  BOOKKEEPING_TABLES: () => BOOKKEEPING_TABLES,
8817
+ CONFIDENTIAL_SPACE_MACHINE_TYPE: () => CONFIDENTIAL_SPACE_MACHINE_TYPE,
8016
8818
  CONTAINMENT_UNAVAILABLE_PREFIX: () => CONTAINMENT_UNAVAILABLE_PREFIX,
8017
8819
  CloneUnavailableError: () => CloneUnavailableError,
8018
8820
  DAEMON_FACTS_FORMAT: () => DAEMON_FACTS_FORMAT,
@@ -8154,6 +8956,7 @@ __export(dist_exports, {
8154
8956
  toolingImageFor: () => toolingImageFor,
8155
8957
  unquoteDiffPath: () => unquoteDiffPath,
8156
8958
  venueGcloudEnv: () => venueGcloudEnv,
8959
+ venueImageFromEnv: () => venueImageFromEnv,
8157
8960
  venueStagePaths: () => venueStagePaths,
8158
8961
  verifyWorkingDiff: () => verifyWorkingDiff
8159
8962
  });
@@ -8180,10 +8983,11 @@ var init_dist4 = __esm({
8180
8983
  });
8181
8984
 
8182
8985
  // bin/jpi-run.js
8183
- import { existsSync as existsSync7, readFileSync as readFileSync5 } from "fs";
8184
- import { join as join13, resolve } from "path";
8986
+ import { existsSync as existsSync8, readFileSync as readFileSync6, realpathSync as realpathSync2 } from "fs";
8987
+ import { execFileSync as execFileSync3 } from "child_process";
8988
+ import { join as join14, resolve } from "path";
8185
8989
  import { tmpdir as tmpdir5 } from "os";
8186
- import { mkdtempSync as mkdtempSync5, rmSync as rmSync5 } from "fs";
8990
+ import { mkdtempSync as mkdtempSync5, rmSync as rmSync6 } from "fs";
8187
8991
 
8188
8992
  // bin/recall-check.js
8189
8993
  import {
@@ -8478,6 +9282,107 @@ function readPackageVersion() {
8478
9282
  return "0.1.1";
8479
9283
  }
8480
9284
 
9285
+ // src/claims.ts
9286
+ import {
9287
+ readFileSync as readFileSync3,
9288
+ writeFileSync as writeFileSync2,
9289
+ mkdirSync as mkdirSync3,
9290
+ renameSync as renameSync2,
9291
+ existsSync as existsSync3,
9292
+ rmSync,
9293
+ statSync as statSync2
9294
+ } from "fs";
9295
+ import { randomBytes } from "crypto";
9296
+ import { join as join4 } from "path";
9297
+ import { homedir as homedir3 } from "os";
9298
+
9299
+ // src/state-dir.ts
9300
+ import { closeSync as closeSync2, constants, fchmodSync, fstatSync, mkdirSync as mkdirSync2, openSync as openSync2 } from "fs";
9301
+
9302
+ // src/claims.ts
9303
+ var TERMINALHIRE_DIR = process.env.TERMINALHIRE_DIR || join4(homedir3(), ".terminalhire");
9304
+ var CLAIMS_FILE = join4(TERMINALHIRE_DIR, "claims.json");
9305
+ var LOCK_DIR = `${CLAIMS_FILE}.lock`;
9306
+ var LOCK_STALE_MS = Number(process.env.TERMINALHIRE_LOCK_STALE_MS) || 1e4;
9307
+ var LOCK_RETRY_MS = Number(process.env.TERMINALHIRE_LOCK_RETRY_MS) || 25;
9308
+ var LOCK_TIMEOUT_MS = Number(process.env.TERMINALHIRE_LOCK_TIMEOUT_MS) || 5e3;
9309
+ var CLAIM_STATES = Object.freeze([
9310
+ "claimed",
9311
+ // recorded, not started
9312
+ "working",
9313
+ // background executor running / work in progress
9314
+ "in-review",
9315
+ // gate ran / dev reviewing the diff
9316
+ "ready",
9317
+ // passed review, cleared to submit
9318
+ "submitted",
9319
+ // PR opened on the source platform (awaiting maintainer)
9320
+ "merged",
9321
+ // PR merged — accepted; counts toward the metric
9322
+ "abandoned"
9323
+ // released, or PR closed unmerged
9324
+ ]);
9325
+ var CLAIM_TURN_BY_STATE = Object.freeze({
9326
+ claimed: "you",
9327
+ working: "you",
9328
+ "in-review": "you",
9329
+ ready: "you",
9330
+ submitted: "poster",
9331
+ merged: "merged",
9332
+ abandoned: "closed"
9333
+ });
9334
+ var CLAIM_TURN_LABEL = Object.freeze({
9335
+ you: "waiting on you",
9336
+ poster: "waiting on poster",
9337
+ merged: "merged",
9338
+ closed: "closed"
9339
+ });
9340
+ var CLAIM_TURN_LABEL_WIDTH = Math.max(
9341
+ ...Object.values(CLAIM_TURN_LABEL).map((label) => label.length)
9342
+ );
9343
+ var WHITESPACE_CONTROLS = /[\t\n\v\f\r]+/g;
9344
+ var CONTROL_CHARS = /[\x00-\x1f\x7f-\x9f]/g;
9345
+ function defangText(s) {
9346
+ return typeof s === "string" ? s.replace(WHITESPACE_CONTROLS, " ").replace(CONTROL_CHARS, "") : s;
9347
+ }
9348
+ function finiteAmount(a) {
9349
+ if (typeof a === "number") return Number.isFinite(a) ? a : null;
9350
+ if (typeof a !== "string" || a.trim() === "") return null;
9351
+ const n = Number(a);
9352
+ return Number.isFinite(n) ? n : null;
9353
+ }
9354
+ function normalizeClaim(c) {
9355
+ return {
9356
+ ...c,
9357
+ kind: c.kind ?? "bounty",
9358
+ policy: c.policy ?? null,
9359
+ title: defangText(c.title),
9360
+ repoFullName: defangText(c.repoFullName),
9361
+ amountUSD: finiteAmount(c.amountUSD)
9362
+ };
9363
+ }
9364
+ function readClaims() {
9365
+ try {
9366
+ if (!existsSync3(CLAIMS_FILE)) return [];
9367
+ const data = JSON.parse(readFileSync3(CLAIMS_FILE, "utf8"));
9368
+ const claims = Array.isArray(data?.claims) ? data.claims : [];
9369
+ return claims.map(normalizeClaim);
9370
+ } catch {
9371
+ return [];
9372
+ }
9373
+ }
9374
+
9375
+ // src/delivered-baseline.ts
9376
+ import { execFileSync } from "child_process";
9377
+ function deliveredBaseline(worktree) {
9378
+ const roots = execFileSync("git", ["-C", worktree, "rev-list", "--max-parents=0", "HEAD"], {
9379
+ encoding: "utf8",
9380
+ stdio: ["ignore", "pipe", "pipe"]
9381
+ }).split("\n").map((s) => s.trim()).filter((s) => s.length > 0);
9382
+ const [only] = roots;
9383
+ return roots.length === 1 && only !== void 0 ? { kind: "baseline", sha: only } : { kind: "ambiguous", rootCount: roots.length };
9384
+ }
9385
+
8481
9386
  // bin/jpi-run.js
8482
9387
  var KEEP_MAX_SECONDS = Math.floor((2 ** 31 - 1) / 1e3);
8483
9388
  var USAGE = `terminalhire run \u2014 verify your working diff in a fresh container
@@ -8506,7 +9411,13 @@ Options:
8506
9411
  Defaults are read from .th-run.json in the local checkout when present, so a
8507
9412
  claim you run repeatedly needs no flags. Every flag overrides the file.
8508
9413
 
8509
- Needs Docker running. Needs network for the first clone of the target.`;
9414
+ Run from inside a delivered claim workspace, the claim ledger supplies --claim and
9415
+ --sha. The diff is then taken from the delivered baseline, so committed work is tested
9416
+ as \`claim submit\` would send it. A full-repository delivery is its own --target: the
9417
+ delivered commit is cloned out of the workspace by path, so a private repository needs
9418
+ no credential. A delivery of only the files you were granted needs --target.
9419
+
9420
+ Needs Docker running. Needs network for the first clone of a remote target.`;
8510
9421
  function parseArgs(argv) {
8511
9422
  const out = { flags: {}, bools: /* @__PURE__ */ new Set() };
8512
9423
  for (let i = 0; i < argv.length; i += 1) {
@@ -8529,7 +9440,7 @@ function parseArgs(argv) {
8529
9440
  function runScratchRoot() {
8530
9441
  let root;
8531
9442
  try {
8532
- root = mkdtempSync5(join13(tmpdir5(), "th-run-"));
9443
+ root = mkdtempSync5(join14(tmpdir5(), "th-run-"));
8533
9444
  } catch (err) {
8534
9445
  process.stderr.write(
8535
9446
  `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)})
@@ -8542,7 +9453,7 @@ function runScratchRoot() {
8542
9453
  if (cleaned) return;
8543
9454
  cleaned = true;
8544
9455
  try {
8545
- rmSync5(root, { recursive: true, force: true });
9456
+ rmSync6(root, { recursive: true, force: true });
8546
9457
  } catch {
8547
9458
  }
8548
9459
  };
@@ -8566,10 +9477,10 @@ async function loadEngine() {
8566
9477
  }
8567
9478
  }
8568
9479
  function readConfig(localDir) {
8569
- const file = join13(localDir, ".th-run.json");
8570
- if (!existsSync7(file)) return {};
9480
+ const file = join14(localDir, ".th-run.json");
9481
+ if (!existsSync8(file)) return {};
8571
9482
  try {
8572
- const parsed = JSON.parse(readFileSync5(file, "utf8"));
9483
+ const parsed = JSON.parse(readFileSync6(file, "utf8"));
8573
9484
  return parsed && typeof parsed === "object" ? parsed : {};
8574
9485
  } catch (err) {
8575
9486
  throw new Error(
@@ -8581,9 +9492,80 @@ function requireField(value, name, hint) {
8581
9492
  if (typeof value === "string" && value.trim() !== "") return value.trim();
8582
9493
  throw new Error(`terminalhire: run needs ${name}. ${hint}`);
8583
9494
  }
9495
+ var given = (value) => typeof value === "string" && value.trim() !== "";
9496
+ function realToplevel(dir) {
9497
+ try {
9498
+ const top = execFileSync3("git", ["-C", dir, "rev-parse", "--show-toplevel"], {
9499
+ encoding: "utf8",
9500
+ stdio: ["ignore", "pipe", "ignore"]
9501
+ }).trim();
9502
+ return top === "" ? null : realpathSync2(top);
9503
+ } catch {
9504
+ return null;
9505
+ }
9506
+ }
9507
+ function realpathOrNull(path) {
9508
+ try {
9509
+ return realpathSync2(path);
9510
+ } catch {
9511
+ return null;
9512
+ }
9513
+ }
9514
+ function resolveClaimContext({ localDir, flags, claims }) {
9515
+ const here = realToplevel(localDir);
9516
+ if (here === null) return null;
9517
+ const recordedHere = claims.filter(
9518
+ (c) => c.approval != null && typeof c.worktreePath === "string" && realpathOrNull(c.worktreePath) === here
9519
+ );
9520
+ const matches = given(flags.claim) ? recordedHere.filter((c) => c.id === flags.claim.trim()) : recordedHere;
9521
+ if (matches.length === 0) return null;
9522
+ if (matches.length > 1) {
9523
+ throw new Error(
9524
+ `terminalhire: ${String(matches.length)} claims are recorded against this workspace (${matches.map((c) => c.id).join(", ")}), so run cannot tell which one this is. Name it with --claim <id>.`
9525
+ );
9526
+ }
9527
+ const [claim] = matches;
9528
+ let sha = given(flags.sha) ? flags.sha.trim() : null;
9529
+ if (sha === null) {
9530
+ if (typeof claim.baseSha !== "string" || !/^[0-9a-f]{40}$/.test(claim.baseSha)) {
9531
+ throw new Error(
9532
+ `terminalhire: claim ${claim.id} has no recorded base commit: it was delivered before terminalhire kept one. Pass --sha <40-hex>, the commit the workspace was delivered from.`
9533
+ );
9534
+ }
9535
+ sha = claim.baseSha;
9536
+ }
9537
+ let baseline;
9538
+ try {
9539
+ baseline = deliveredBaseline(here);
9540
+ } catch (err) {
9541
+ throw new Error(
9542
+ `terminalhire: could not read the delivered baseline in ${here} (${String(err?.stderr || err?.message || err).trim()}).`
9543
+ );
9544
+ }
9545
+ if (baseline.kind !== "baseline") {
9546
+ throw new Error(
9547
+ `terminalhire: ${here} has ${String(baseline.rootCount)} root commits \u2014 cannot determine the delivered baseline to diff against. Re-fetch the slice into a fresh directory: terminalhire claim slice ${claim.id} --dir <path>`
9548
+ );
9549
+ }
9550
+ const diffBase = baseline.sha;
9551
+ const target = given(flags.target) ? flags.target.trim() : diffBase === sha ? here : null;
9552
+ if (target === null) {
9553
+ const sparse = typeof claim.baseSha === "string" && diffBase !== claim.baseSha;
9554
+ throw new Error(
9555
+ sparse ? "terminalhire: run needs --target. This delivery holds only the files you were granted, so a local run needs the clone URL of the full repository: --target <url>." : `terminalhire: run needs --target. --sha ${sha.slice(0, 12)} is not the commit this workspace was delivered as (${diffBase.slice(0, 12)}), so the workspace cannot stand in for the repository: pass --target <clone URL of the repository at that commit>.`
9556
+ );
9557
+ }
9558
+ return { claimId: claim.id, target, sha, diffBase, workspaceRoot: here };
9559
+ }
8584
9560
  async function once(engine, opts) {
8585
9561
  const { root } = opts.scratch;
8586
9562
  const started = Date.now();
9563
+ if (opts.deliveredCommit && !opts.json) {
9564
+ process.stderr.write(
9565
+ ` ${"tested".padEnd(8)} against the delivered commit ${opts.deliveredCommit.slice(0, 12)}; the poster-side run puts your change on top of the repository's current default branch, so its result can differ if that branch has moved since delivery.
9566
+ `
9567
+ );
9568
+ }
8587
9569
  const outcome = await engine.verifyWorkingDiff({
8588
9570
  claimId: opts.claimId,
8589
9571
  // `th run` is the developer's own loop, and this is the variant that says so: it tests
@@ -8591,7 +9573,10 @@ async function once(engine, opts) {
8591
9573
  source: {
8592
9574
  kind: "working-diff",
8593
9575
  localRepoDir: opts.localDir,
8594
- sliceFiles: opts.slice
9576
+ sliceFiles: opts.slice,
9577
+ // From a claim workspace, the delivered baseline: committed work is then tested,
9578
+ // exactly as `claim submit` would send it (TERM-1098). Absent means HEAD.
9579
+ ...opts.diffBase ? { diffBase: opts.diffBase } : {}
8595
9580
  },
8596
9581
  targetRepo: opts.target,
8597
9582
  targetSha: opts.sha,
@@ -8679,12 +9664,35 @@ async function run() {
8679
9664
  `terminalhire: --keep must be at most ${String(KEEP_MAX_SECONDS)} seconds, got ${JSON.stringify(String(keepRaw))}. Past that the timer overflows its int32 of milliseconds and the wait collapses to 1ms, so asking for a longer hold would give you no hold at all and the URL printed above it would be dead before anyone could open it. Refused rather than clamped, because a preview that died on the way to you looks exactly like one that worked.`
8680
9665
  );
8681
9666
  }
9667
+ const claimContext = resolveClaimContext({
9668
+ localDir,
9669
+ flags: { claim: pick("claim"), target: pick("target"), sha: pick("sha") },
9670
+ claims: readClaims()
9671
+ });
8682
9672
  const opts = {
8683
- claimId: requireField(pick("claim"), "--claim", "It is the id the result is filed under."),
8684
- target: requireField(pick("target"), "--target", "e.g. https://github.com/koajs/koa.git"),
8685
- sha: requireField(pick("sha"), "--sha", "The full 40-character commit your diff applies to."),
9673
+ claimId: requireField(
9674
+ claimContext?.claimId ?? pick("claim"),
9675
+ "--claim",
9676
+ "It is the id the result is filed under."
9677
+ ),
9678
+ // With a claim context the target is always set: `resolveClaimContext` refuses, and
9679
+ // says why, when it cannot name one.
9680
+ target: requireField(
9681
+ claimContext?.target ?? pick("target"),
9682
+ "--target",
9683
+ "e.g. https://github.com/koajs/koa.git"
9684
+ ),
9685
+ sha: requireField(
9686
+ claimContext?.sha ?? pick("sha"),
9687
+ "--sha",
9688
+ "The full 40-character commit your diff applies to."
9689
+ ),
9690
+ diffBase: claimContext?.diffBase ?? null,
9691
+ deliveredCommit: claimContext?.sha ?? null,
8686
9692
  slice,
8687
- localDir,
9693
+ // From a claim workspace, its root and not the directory run started in: the diff
9694
+ // collector reads `.git` there, and --watch watches the same tree (TERM-1098).
9695
+ localDir: claimContext?.workspaceRoot ?? localDir,
8688
9696
  preview: !parsed.bools.has("no-preview"),
8689
9697
  watch: parsed.bools.has("watch"),
8690
9698
  json: parsed.bools.has("json"),
@@ -8746,5 +9754,6 @@ if (process.argv[1] && process.argv[1].endsWith("jpi-run.js")) {
8746
9754
  }
8747
9755
  export {
8748
9756
  once,
9757
+ resolveClaimContext,
8749
9758
  run
8750
9759
  };