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.
- package/dist/bin/claim-push-bg.js +1 -1
- package/dist/bin/jpi-chat-read.js +1 -1
- package/dist/bin/jpi-chat.js +1 -1
- package/dist/bin/jpi-claim.js +974 -139
- package/dist/bin/jpi-contribute.js +6 -1
- package/dist/bin/jpi-dispatch.js +1144 -186
- package/dist/bin/jpi-hub.js +1 -1
- package/dist/bin/jpi-inbox.js +1 -1
- package/dist/bin/jpi-login.js +10 -5
- package/dist/bin/jpi-mcp-chat.js +1 -1
- package/dist/bin/jpi-mcp.js +983 -148
- package/dist/bin/jpi-refresh.js +1 -1
- package/dist/bin/jpi-run.js +1227 -218
- package/dist/src/chat-client.js +1 -1
- package/dist/src/chat-keystore.js +1 -1
- package/dist/src/github-auth.js +6 -1
- package/package.json +1 -1
package/dist/bin/jpi-run.js
CHANGED
|
@@ -293,7 +293,7 @@ var init_classify = __esm({
|
|
|
293
293
|
});
|
|
294
294
|
|
|
295
295
|
// ../../packages/containment/dist/env.js
|
|
296
|
-
import { homedir as
|
|
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 =
|
|
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"] =
|
|
340
|
-
env["XDG_CACHE_HOME"] =
|
|
341
|
-
env["XDG_DATA_HOME"] =
|
|
342
|
-
env["GIT_CONFIG_GLOBAL"] =
|
|
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"] =
|
|
348
|
-
env["npm_config_cache"] =
|
|
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"] =
|
|
353
|
-
env["GOMODCACHE"] =
|
|
354
|
-
env["GOCACHE"] =
|
|
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"] =
|
|
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
|
|
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
|
-
|
|
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
|
|
409
|
-
import { dirname as dirname2, isAbsolute, join as
|
|
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 =
|
|
459
|
-
const tmp =
|
|
460
|
-
for (const dir of [jail, tmp,
|
|
461
|
-
|
|
462
|
-
}
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
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
|
|
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
|
|
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,
|
|
597
|
+
sync(args, opts2) {
|
|
577
598
|
return syncResult(spawnSync3(DOCKER_BIN, argv(args), {
|
|
578
599
|
encoding: "utf8",
|
|
579
|
-
timeout:
|
|
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
|
|
661
|
-
import { chmodSync, copyFileSync, existsSync as
|
|
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 :
|
|
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 =
|
|
996
|
-
if (
|
|
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) =>
|
|
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(
|
|
1029
|
+
const dir = mkdtempSync(join7(tmpdir(), "th-proxy-"));
|
|
1009
1030
|
try {
|
|
1010
1031
|
for (const file of PROXY_FILES) {
|
|
1011
|
-
copyFileSync(
|
|
1032
|
+
copyFileSync(join7(source, file), join7(dir, file));
|
|
1012
1033
|
}
|
|
1013
1034
|
for (const file of PROXY_FILES) {
|
|
1014
|
-
chmodSync(
|
|
1035
|
+
chmodSync(join7(dir, file), 420);
|
|
1015
1036
|
}
|
|
1016
1037
|
chmodSync(dir, 493);
|
|
1017
1038
|
} catch (err) {
|
|
1018
1039
|
try {
|
|
1019
|
-
|
|
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
|
-
|
|
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
|
|
1443
|
-
import { join as
|
|
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 ??
|
|
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
|
-
|
|
1506
|
-
const docPath =
|
|
1507
|
-
|
|
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
|
|
1717
|
+
import { join as join9 } from "path";
|
|
1697
1718
|
function localJailPaths(scratchRoot) {
|
|
1698
1719
|
return {
|
|
1699
|
-
jail:
|
|
1700
|
-
tmp:
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
3117
|
+
return refuse3("no-outcome-recorded", "the patched run recorded no outcome");
|
|
2905
3118
|
}
|
|
2906
3119
|
if (baseline.status === "refused") {
|
|
2907
|
-
return
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
3171
|
+
return refuse3("test-command-origin-unattested", `the ${side} run ${why}`);
|
|
2959
3172
|
}
|
|
2960
3173
|
if (patched.testCommandSource === "developer-declared") {
|
|
2961
|
-
return
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 ??
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
3535
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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(
|
|
3647
|
-
file =
|
|
3648
|
-
|
|
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
|
-
|
|
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 {
|
|
3672
|
-
import {
|
|
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 (
|
|
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 =
|
|
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:
|
|
3721
|
-
GIT_OBJECT_DIRECTORY:
|
|
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
|
|
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 ??
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
4513
|
-
|
|
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 =
|
|
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
|
|
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) =>
|
|
5486
|
+
exists: (path) => existsSync6(path),
|
|
4760
5487
|
makePrivateDir: () => {
|
|
4761
|
-
const dir = mkdtempSync3(
|
|
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
|
-
|
|
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
|
|
5703
|
-
import { join as
|
|
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 :
|
|
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
|
|
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
|
|
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 =
|
|
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
|
|
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
|
|
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 (!
|
|
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
|
|
6355
|
-
const
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
6561
|
-
const runOut = (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
|
-
|
|
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
|
-
|
|
6680
|
-
const treeOid =
|
|
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
|
-
|
|
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 =
|
|
6971
|
-
const cloneDir =
|
|
6972
|
-
const scratch =
|
|
6973
|
-
|
|
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:
|
|
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
|
|
7395
|
-
import { mkdirSync as
|
|
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:
|
|
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
|
-
|
|
7738
|
-
|
|
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
|
|
8184
|
-
import {
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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 =
|
|
8570
|
-
if (!
|
|
9480
|
+
const file = join14(localDir, ".th-run.json");
|
|
9481
|
+
if (!existsSync8(file)) return {};
|
|
8571
9482
|
try {
|
|
8572
|
-
const parsed = JSON.parse(
|
|
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(
|
|
8684
|
-
|
|
8685
|
-
|
|
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
|
-
|
|
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
|
};
|