forgedock 1.48.1 → 1.50.0
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/bin/engine-cli.mjs +49 -19
- package/bin/forgedock.mjs +1 -1
- package/bin/lean/batch.mjs +9 -0
- package/bin/lean/batch.test.mjs +174 -2
- package/bin/lean/conformance.mjs +38 -9
- package/bin/lean/conformance.test.mjs +33 -1
- package/bin/lean/effects.mjs +16 -5
- package/bin/lean/execution.mjs +66 -20
- package/bin/lean/execution.test.mjs +133 -1
- package/bin/lean/heavy.mjs +14 -4
- package/bin/lean/heavy.test.mjs +55 -1
- package/bin/lean/lanecheck.mjs +44 -12
- package/bin/lean/lanecheck.test.mjs +23 -0
- package/bin/lean/lean.mjs +87 -24
- package/bin/lean/lean.test.mjs +90 -7
- package/bin/lean/live-execution.mjs +136 -0
- package/bin/lean/live-execution.test.mjs +83 -0
- package/bin/lean/orchestrate.mjs +77 -20
- package/bin/lean/prompts.mjs +15 -2
- package/bin/lean/review-trigger.mjs +25 -0
- package/bin/lean/review-trigger.test.mjs +28 -1
- package/bin/lean/sandbox-provider.contract.test.mjs +21 -0
- package/bin/lean/sandbox-remote.mjs +6 -2
- package/bin/lean/schedd.mjs +20 -3
- package/bin/lean/schedd.test.mjs +20 -3
- package/commands/orchestrate.md +2 -1
- package/docs/CONFIG.md +3 -1
- package/package.json +1 -1
- package/scripts/plan-validate-live.mjs +24 -7
package/bin/lean/execution.mjs
CHANGED
|
@@ -32,6 +32,9 @@
|
|
|
32
32
|
* `openExecutionBudget` enforces the hard caps (`budget_minutes`/`budget_usd` per batch, `day_budget_*` per host UTC day,
|
|
33
33
|
* from the host-day store `execution-budget.json` in the sched dir): pure arithmetic, no LLM.
|
|
34
34
|
*
|
|
35
|
+
* #5112: `execution.capacity_fallback_after` (positive integer, `capacityFallbackAfter`) is how many `sandbox.capacity` deferrals of one
|
|
36
|
+
* issue make `fallback_local` run it on the host (orchestrate `CAPACITY_FALLBACK_AFTER` when unset), gated by `hostHeadroom`.
|
|
37
|
+
*
|
|
35
38
|
* Hybrid (#4256): `placeLane` places each lane from facts (lane kind, validation weight, host headroom, verify.mode);
|
|
36
39
|
* orchestrate runs a remote-placed lane locally when `max_remote` or the execution budget is exhausted.
|
|
37
40
|
*
|
|
@@ -113,9 +116,12 @@ export function parseExecution(sec = {}) {
|
|
|
113
116
|
let endpoint = null;
|
|
114
117
|
try { const u = new URL(str(s.endpoint)); if (u.protocol === "https:" || u.protocol === "http:") endpoint = str(s.endpoint); } catch { endpoint = null; }
|
|
115
118
|
const tokenEnv = ENV_RE.test(str(s.token_env)) ? str(s.token_env) : null;
|
|
119
|
+
const tokenFile = str(s.token_file) || null; // #5131: explicit pool/remote token file
|
|
116
120
|
const cacheStore = str(s.cache_store) || null;
|
|
117
121
|
const allowActions = str(s.allow_actions).toLowerCase() === "true";
|
|
118
122
|
const fallbackLocal = str(s.fallback_local).toLowerCase() === "true";
|
|
123
|
+
const cfa = Number(str(s.capacity_fallback_after));
|
|
124
|
+
const capacityFallbackAfter = Number.isInteger(cfa) && cfa > 0 ? cfa : null; // #5112: unset: orchestrate's CAPACITY_FALLBACK_AFTER
|
|
119
125
|
const heavyRaw = str(s.heavy).toLowerCase();
|
|
120
126
|
const heavy = heavyRaw === "true" ? true : heavyRaw === "false" ? false : null;
|
|
121
127
|
const cap = (v) => { const t = str(v), x = Number(t); return t !== "" && Number.isFinite(x) && x >= 0 ? x : null; };
|
|
@@ -133,7 +139,7 @@ export function parseExecution(sec = {}) {
|
|
|
133
139
|
const uw = Number(str(s.unreachable_window_min));
|
|
134
140
|
const accountRotator = str(s.account_rotator) === "tokenbaton" ? "tokenbaton" : null; // #5094: rotator.mjs reads its per-account quota state
|
|
135
141
|
const unreachableWindowMin = str(s.unreachable_window_min) !== "" && Number.isFinite(uw) && uw >= 0 ? uw : null; // unset: providerLanes defaults to 5
|
|
136
|
-
return { mode, maxRemote, provider, image, secrets, budget, ...(hostCapabilities.length ? { hostCapabilities } : {}), ...(hostCapabilityPatterns.length ? { hostCapabilityPatterns } : {}), ...(endpoint ? { endpoint } : {}), ...(tokenEnv ? { tokenEnv } : {}), ...(cacheStore ? { cacheStore } : {}), ...(allowActions ? { allowActions } : {}), ...(fallbackLocal ? { fallbackLocal } : {}), ...(heavy !== null ? { heavy } : {}), ...(unreachableWindowMin !== null ? { unreachableWindowMin } : {}), ...(Object.keys(relay).length ? { relay } : {}), ...(warmSandboxes ? { warmSandboxes } : {}), ...(accountRotator ? { accountRotator } : {}) };
|
|
142
|
+
return { mode, maxRemote, provider, image, secrets, budget, ...(hostCapabilities.length ? { hostCapabilities } : {}), ...(hostCapabilityPatterns.length ? { hostCapabilityPatterns } : {}), ...(endpoint ? { endpoint } : {}), ...(tokenEnv ? { tokenEnv } : {}), ...(tokenFile ? { tokenFile } : {}), ...(cacheStore ? { cacheStore } : {}), ...(allowActions ? { allowActions } : {}), ...(fallbackLocal ? { fallbackLocal } : {}), ...(capacityFallbackAfter !== null ? { capacityFallbackAfter } : {}), ...(heavy !== null ? { heavy } : {}), ...(unreachableWindowMin !== null ? { unreachableWindowMin } : {}), ...(Object.keys(relay).length ? { relay } : {}), ...(warmSandboxes ? { warmSandboxes } : {}), ...(accountRotator ? { accountRotator } : {}) };
|
|
137
143
|
}
|
|
138
144
|
|
|
139
145
|
const round1 = (x) => Math.round(x * 10) / 10;
|
|
@@ -480,18 +486,49 @@ export function containerProvider({ batch, repo, image, gitDir, cacheDir = `${ho
|
|
|
480
486
|
|
|
481
487
|
const typedError = (code, message) => Object.assign(new Error(message), { code });
|
|
482
488
|
|
|
489
|
+
/** #5131: an endpoint as `scheme://host:port` (default ports filled, no path or trailing slash); null when unparsable. */
|
|
490
|
+
const endpointKey = (u) => { try { const x = new URL(String(u)); return `${x.protocol}//${x.hostname}:${x.port || (x.protocol === "https:" ? "443" : "80")}`; } catch { return null; } };
|
|
491
|
+
|
|
492
|
+
/** #5131: the trimmed contents of a token file (`~/` is the home dir); null when unset, unreadable or empty. */
|
|
493
|
+
const readTokenFile = (file) => {
|
|
494
|
+
if (!file) return null;
|
|
495
|
+
try { return readFileSync(String(file).startsWith("~/") ? join(homedir(), String(file).slice(2)) : String(file), "utf8").trim() || null; } catch { return null; }
|
|
496
|
+
};
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* #5131: the one pool/remote credential resolver, shared by both providers, `remoteTransportFor` and `preflight.pool_auth`:
|
|
500
|
+
* `token_env` from the env, then the keychain; then `execution.token_file`; then the local router's `forgedock_token_file`
|
|
501
|
+
* (`router`, poolRouterConfig output): always for `pool`, for `remote` only when its endpoint is unset or the router's.
|
|
502
|
+
* Returns `{token, source}`; `source` names where it came from (env name, `token_file`, `forgedock_token_file`), never the value.
|
|
503
|
+
*/
|
|
504
|
+
export function poolCredential(exec = {}, { env = process.env, router = null, keychain } = {}) {
|
|
505
|
+
if (exec?.tokenEnv) {
|
|
506
|
+
const v = resolveCredential(exec.tokenEnv, { env, ...(keychain ? { keychain } : {}) });
|
|
507
|
+
if (v) return { token: v, source: exec.tokenEnv };
|
|
508
|
+
}
|
|
509
|
+
const f = readTokenFile(exec?.tokenFile);
|
|
510
|
+
if (f) return { token: f, source: "token_file" };
|
|
511
|
+
const routerOk = Boolean(router?.token) && ((exec?.provider ?? "pool") === "pool" || !exec?.endpoint || endpointKey(exec.endpoint) === endpointKey(router.endpoint));
|
|
512
|
+
return routerOk ? { token: router.token, source: "forgedock_token_file" } : { token: null, source: null };
|
|
513
|
+
}
|
|
514
|
+
|
|
483
515
|
/**
|
|
484
516
|
* The remote transport for forge.yaml `execution` settings: `opts.transport` when injected, else `httpTransport` on
|
|
485
|
-
* `exec.endpoint` with the credential `exec.tokenEnv`
|
|
517
|
+
* `exec.endpoint` with the credential `exec.tokenEnv` from `credentials`, or by default poolCredential (#5131: env,
|
|
518
|
+
* keychain, `token_file`, the matching local router's token). `fetchFn` is a test seam. Throws `execution.provider.misconfigured`
|
|
486
519
|
* without an endpoint, `execution.provider.actions_refused` for a GitHub Actions endpoint without `allow_actions`.
|
|
487
520
|
*/
|
|
488
|
-
export function remoteTransportFor(exec = {}, { transport = null, env = process.env, credentials =
|
|
521
|
+
export function remoteTransportFor(exec = {}, { transport = null, env = process.env, credentials = null, router, fetchFn } = {}) {
|
|
489
522
|
if (transport) return transport;
|
|
490
523
|
if (!exec?.endpoint) throw typedError("execution.provider.misconfigured", "execution.provider remote needs execution.endpoint");
|
|
491
524
|
if (isActionsEndpoint(exec.endpoint) && exec.allowActions !== true) {
|
|
492
525
|
throw typedError("execution.provider.actions_refused", "execution.endpoint is a GitHub Actions API; set execution.allow_actions: true to spend Actions minutes");
|
|
493
526
|
}
|
|
494
|
-
|
|
527
|
+
// #5144: a getter, so the bearer is resolved on every request (a token fixed or rotated mid-batch is picked up), not once here.
|
|
528
|
+
const rt = credentials ? null : (router !== undefined ? router : poolRouterConfig({ env }));
|
|
529
|
+
const token = credentials ? () => (exec.tokenEnv ? credentials(exec.tokenEnv) : null)
|
|
530
|
+
: () => poolCredential({ provider: "remote", ...exec }, { env, router: rt }).token;
|
|
531
|
+
return httpTransport({ endpoint: exec.endpoint, token, ...(fetchFn ? { fetchFn } : {}) });
|
|
495
532
|
}
|
|
496
533
|
|
|
497
534
|
/**
|
|
@@ -510,26 +547,35 @@ export function poolRouterConfig({ env = process.env, path = env.COMPUTESHARE_CO
|
|
|
510
547
|
export function providerByName(name, opts) {
|
|
511
548
|
if (name === "local") return localProvider(opts);
|
|
512
549
|
if (name === "container") return containerProvider(opts);
|
|
513
|
-
if (name === "remote") {
|
|
514
|
-
const exec = opts?.exec ?? {};
|
|
515
|
-
const env = opts?.env ?? process.env;
|
|
516
|
-
return remoteProvider({ batch: opts.batch, repo: opts.repo, settings: { ...exec, ...(typeof opts?.prefetch === "function" ? { prefetch: opts.prefetch } : {}) }, env, transport: remoteTransportFor(exec, { transport: opts?.transport, env }), ...(opts?.now ? { now: opts.now } : {}) });
|
|
517
|
-
}
|
|
518
|
-
if (name === "pool") {
|
|
519
|
-
// #4546: `remote` with the endpoint and token defaulted to the local router, and the repo's forge.yaml sent along.
|
|
550
|
+
if (name === "remote" || name === "pool") {
|
|
520
551
|
const env = opts?.env ?? process.env;
|
|
521
552
|
const exec = opts?.exec ?? {};
|
|
522
|
-
const
|
|
523
|
-
|
|
553
|
+
const pool = name === "pool";
|
|
554
|
+
// #4546: `pool` is `remote` with the endpoint and token defaulted to the local router, and the repo's forge.yaml sent along.
|
|
555
|
+
// #5131: `remote` reads the router config too, for the token fallback when its endpoint is the local router's.
|
|
556
|
+
const router = opts?.transport || (pool && exec.endpoint) ? null : poolRouterConfig({ env, ...(opts?.poolConfigPath ? { path: opts.poolConfigPath } : {}) });
|
|
557
|
+
if (pool && !opts?.transport && !exec.endpoint && !router) throw typedError("execution.provider.misconfigured", "execution.provider pool needs a local router config (forgedock pool router) or execution.endpoint");
|
|
524
558
|
let forgeYaml = null;
|
|
525
|
-
try { forgeYaml = opts?.cwd ? readFileSync(join(opts.cwd, "forge.yaml"), "utf8") : null; } catch { forgeYaml = null; }
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
const
|
|
530
|
-
|
|
559
|
+
if (pool) { try { forgeYaml = opts?.cwd ? readFileSync(join(opts.cwd, "forge.yaml"), "utf8") : null; } catch { forgeYaml = null; } }
|
|
560
|
+
const endpoint = exec.endpoint ?? (pool ? router?.endpoint : undefined);
|
|
561
|
+
// #5144: the router's token file is re-read per call (a rotated or newly written token), its endpoint stays as at construction.
|
|
562
|
+
const routerEligible = !(opts?.transport || (pool && exec.endpoint));
|
|
563
|
+
const routerNow = () => {
|
|
564
|
+
if (!routerEligible) return null;
|
|
565
|
+
const c = poolRouterConfig({ env, ...(opts?.poolConfigPath ? { path: opts.poolConfigPath } : {}) });
|
|
566
|
+
return c ? { endpoint: router?.endpoint ?? c.endpoint, token: c.token } : router;
|
|
567
|
+
};
|
|
568
|
+
const resolve = () => poolCredential({ ...exec, provider: name, ...(endpoint ? { endpoint } : {}) }, { env, router: routerNow() });
|
|
569
|
+
// A token read from a file is resolved under a fixed name so remoteProvider redacts it like any `token_env` credential.
|
|
570
|
+
// #5144: set even when no token exists yet, so one that appears later is still resolved per call (and redacted).
|
|
571
|
+
const fixed = pool ? "FORGEDOCK_POOL_TOKEN" : "FORGEDOCK_REMOTE_TOKEN";
|
|
572
|
+
const tokenEnv = exec.tokenEnv ?? fixed;
|
|
573
|
+
// #4685/#5131: an empty env/keychain lookup of `token_env` falls back to `token_file`, then the (matching) router's token file.
|
|
574
|
+
const credentials = (n) => (n === tokenEnv ? resolve().token : resolveCredential(n, { env })); // per call, like resolveCredential
|
|
575
|
+
const settings = { ...exec, ...(endpoint ? { endpoint } : {}), ...(tokenEnv ? { tokenEnv } : {}), ...(forgeYaml ? { forgeYaml } : {}),
|
|
531
576
|
...(typeof opts?.prefetch === "function" ? { prefetch: opts.prefetch } : {}) }; // #4778: dispatch-time relay pre-fetch
|
|
532
|
-
return remoteProvider({ batch: opts.batch, repo: opts.repo, settings, name: "pool"
|
|
577
|
+
return remoteProvider({ batch: opts.batch, repo: opts.repo, settings, ...(pool ? { name: "pool" } : {}), env, credentials,
|
|
578
|
+
transport: remoteTransportFor(settings, { transport: opts?.transport, env, credentials, ...(opts?.fetchFn ? { fetchFn: opts.fetchFn } : {}) }), ...(opts?.now ? { now: opts.now } : {}) });
|
|
533
579
|
}
|
|
534
580
|
const e = new Error(`unknown execution.provider ${JSON.stringify(name)} (known: ${SANDBOX_PROVIDERS.join(", ")})`);
|
|
535
581
|
e.code = "execution.provider.unknown";
|
|
@@ -7,7 +7,7 @@ import { mkdirSync, writeFileSync, existsSync, readFileSync } from "node:fs";
|
|
|
7
7
|
import { join } from "node:path";
|
|
8
8
|
import { tmpDir } from "../tests/_tmp.mjs";
|
|
9
9
|
import { renderContract } from "./render.mjs";
|
|
10
|
-
import { parseExecution, SANDBOX_PROVIDERS, detectNeeds, hostCapabilityBlock, placementText, contractHostNeeds, placement, placeLane, validationWeight, hostHeadroom, scopedEnv, providerLanes, remoteHoldId, usageLimitInTail, isTransportError, isCloneFailure, containerProvider, providerByName, openExecutionBudget } from "./execution.mjs";
|
|
10
|
+
import { parseExecution, SANDBOX_PROVIDERS, detectNeeds, hostCapabilityBlock, placementText, contractHostNeeds, placement, placeLane, validationWeight, hostHeadroom, scopedEnv, providerLanes, remoteHoldId, usageLimitInTail, isTransportError, isCloneFailure, containerProvider, providerByName, openExecutionBudget, poolCredential, remoteTransportFor } from "./execution.mjs";
|
|
11
11
|
import { makeLedger, countLiveLanes } from "./ledger.mjs";
|
|
12
12
|
import { openBatch } from "./batch.mjs";
|
|
13
13
|
import { orchestrate, formatTable, formatSandboxSummary } from "./orchestrate.mjs";
|
|
@@ -399,6 +399,27 @@ describe("runBatch wiring (#4253)", () => {
|
|
|
399
399
|
assert.deepEqual(prov.calls, []);
|
|
400
400
|
}
|
|
401
401
|
});
|
|
402
|
+
|
|
403
|
+
it("#5138: a live --set-placement local makes the next lane of a remote batch a detached host lane", async () => {
|
|
404
|
+
const work = tmpDir("exec-cli-live-");
|
|
405
|
+
const stub = join(work, "stub.mjs");
|
|
406
|
+
writeFileSync(stub, `import { writeFileSync } from "node:fs"; const a = process.argv.slice(2); writeFileSync(a[a.indexOf("--result-file") + 1], JSON.stringify({ terminalReason: "merged", pr: 9 }));\n`);
|
|
407
|
+
const root = join(work, "batches");
|
|
408
|
+
let recorded = null;
|
|
409
|
+
const prov = fakeProvider({ finish: (n) => {
|
|
410
|
+
const b = openBatch(root, "blive");
|
|
411
|
+
recorded = b.readExecution();
|
|
412
|
+
if (n === 1) b.setExecution({ mode: "local" }); // the operator's --set-placement before #2 is admitted
|
|
413
|
+
return { terminalReason: "merged", pr: 100 + n, cost_usd: 0 };
|
|
414
|
+
} });
|
|
415
|
+
const res = await quiet(() => orchestrateFromCli(["1", "2", "--repo", "o/r", "--no-prefilter", "--parallel", "1"], base(work, { batchId: "blive", laneScript: stub,
|
|
416
|
+
execution: parseExecution({ mode: "remote", max_remote: "1" }), sandboxProvider: prov,
|
|
417
|
+
sandboxLedger: (n) => makeLedger({ root: join(work, "ledger"), batch: "blive", lane: `sandbox-${n}`, owner: "t", issue: n }) })));
|
|
418
|
+
assert.deepEqual(recorded, { provider: true });
|
|
419
|
+
assert.deepEqual(res.rows.map((r) => [r.issue, r.outcome, r.pr, r.placement?.where]), [[1, "merged", 101, "remote"], [2, "merged", 9, "local"]]);
|
|
420
|
+
assert.match(res.rows[1].placement.reason, /live placement/);
|
|
421
|
+
assert.deepEqual(prov.calls.filter((c) => c[0] === "start").map((c) => c[1]), [1]);
|
|
422
|
+
});
|
|
402
423
|
});
|
|
403
424
|
|
|
404
425
|
describe("execution budget and sandbox cost (#4255)", () => {
|
|
@@ -716,6 +737,10 @@ describe("#4547: fallback_local and agent liveness", () => {
|
|
|
716
737
|
assert.equal(parseExecution({ mode: "remote", max_remote: "2", fallback_local: "true" }).fallbackLocal, true);
|
|
717
738
|
assert.equal(parseExecution({ mode: "remote", fallback_local: "yes" }).fallbackLocal, undefined);
|
|
718
739
|
});
|
|
740
|
+
it("#5112 parseExecution: capacity_fallback_after is a positive integer, absent otherwise", () => {
|
|
741
|
+
assert.equal(parseExecution({ mode: "remote", fallback_local: "true", capacity_fallback_after: "5" }).capacityFallbackAfter, 5);
|
|
742
|
+
for (const v of [undefined, "", "0", "-1", "2.5", "x"]) assert.equal("capacityFallbackAfter" in parseExecution({ mode: "remote", capacity_fallback_after: v }), false, String(v));
|
|
743
|
+
});
|
|
719
744
|
it("transport capacity carries free and liveness; none valid is unknown", async () => {
|
|
720
745
|
const { httpTransport } = await import("./sandbox-remote.mjs");
|
|
721
746
|
const cap = async (data, ok = true) => httpTransport({ endpoint: "http://pool.test", token: "t", fetchFn: async () => ({ ok, status: ok ? 200 : 500, headers: new Headers(), text: async () => JSON.stringify(data) }) }).capacity();
|
|
@@ -901,3 +926,110 @@ describe("#5095: launch 5xx and a pool usage limit are deferred, never needs-hum
|
|
|
901
926
|
assert.equal(plain.terminalReason, "engine-error");
|
|
902
927
|
});
|
|
903
928
|
});
|
|
929
|
+
|
|
930
|
+
describe("remote/pool credential resolver (#5131)", () => {
|
|
931
|
+
/** A router config dir: router.json listening on 127.0.0.1:7710 and the sibling forgedock.token. */
|
|
932
|
+
const routerDir = () => {
|
|
933
|
+
const d = tmpDir("router-5131-");
|
|
934
|
+
writeFileSync(join(d, "router.json"), JSON.stringify({ listen: { host: "127.0.0.1", port: 7710 } }));
|
|
935
|
+
writeFileSync(join(d, "forgedock.token"), "router-tok\n");
|
|
936
|
+
return d;
|
|
937
|
+
};
|
|
938
|
+
const recorder = () => {
|
|
939
|
+
const seen = [];
|
|
940
|
+
const fetchFn = async (url, init = {}) => { seen.push({ url: String(url), auth: init.headers?.authorization ?? null }); return new Response("{}", { status: 200 }); };
|
|
941
|
+
return { seen, fetchFn };
|
|
942
|
+
};
|
|
943
|
+
const remoteVia = (exec, { fetchFn, dir }) => providerByName("remote", { batch: { id: "b5131" }, repo: "o/r", exec: parseExecution({ mode: "remote", max_remote: "1", provider: "remote", ...exec }),
|
|
944
|
+
env: {}, poolConfigPath: join(dir, "router.json"), fetchFn });
|
|
945
|
+
|
|
946
|
+
it("5131: providerByName remote with no env/keychain token sends the router token file's bearer only to the router's endpoint", async () => {
|
|
947
|
+
const dir = routerDir();
|
|
948
|
+
const hit = recorder();
|
|
949
|
+
await remoteVia({ endpoint: "http://127.0.0.1:7710/" }, { fetchFn: hit.fetchFn, dir }).capacity();
|
|
950
|
+
assert.ok(hit.seen.length >= 1);
|
|
951
|
+
assert.equal(hit.seen[0].auth, "Bearer router-tok");
|
|
952
|
+
const miss = recorder();
|
|
953
|
+
await remoteVia({ endpoint: "https://runner.example.com" }, { fetchFn: miss.fetchFn, dir }).capacity();
|
|
954
|
+
assert.ok(miss.seen.length >= 1);
|
|
955
|
+
assert.equal(miss.seen[0].auth, null, "the local router token never goes to another endpoint");
|
|
956
|
+
});
|
|
957
|
+
|
|
958
|
+
it("5131: poolCredential order is token_env (env, keychain), token_file, then the matching router token; source never holds the value", () => {
|
|
959
|
+
const dir = routerDir();
|
|
960
|
+
const file = join(dir, "explicit.token");
|
|
961
|
+
writeFileSync(file, " file-tok \n");
|
|
962
|
+
const router = { endpoint: "http://127.0.0.1:7710", token: "router-tok" };
|
|
963
|
+
const keychain = () => null;
|
|
964
|
+
assert.deepEqual(poolCredential({ provider: "remote", tokenEnv: "COMPUTESHARE_TOKEN", endpoint: "http://127.0.0.1:7710" }, { env: { COMPUTESHARE_TOKEN: "env-tok" }, router, keychain }), { token: "env-tok", source: "COMPUTESHARE_TOKEN" });
|
|
965
|
+
assert.deepEqual(poolCredential({ provider: "remote", tokenEnv: "COMPUTESHARE_TOKEN", endpoint: "http://127.0.0.1:7710" }, { env: {}, router, keychain: () => "kc-tok" }), { token: "kc-tok", source: "COMPUTESHARE_TOKEN" });
|
|
966
|
+
assert.deepEqual(poolCredential({ provider: "remote", tokenEnv: "COMPUTESHARE_TOKEN", endpoint: "http://127.0.0.1:7710/" }, { env: {}, router, keychain }), { token: "router-tok", source: "forgedock_token_file" });
|
|
967
|
+
assert.deepEqual(poolCredential({ provider: "remote", tokenEnv: "COMPUTESHARE_TOKEN", tokenFile: file, endpoint: "https://x.example" }, { env: {}, router, keychain }), { token: "file-tok", source: "token_file" });
|
|
968
|
+
assert.deepEqual(poolCredential({ provider: "remote", tokenEnv: "COMPUTESHARE_TOKEN", endpoint: "https://x.example" }, { env: {}, router, keychain }), { token: null, source: null });
|
|
969
|
+
assert.deepEqual(poolCredential({ provider: "pool", endpoint: "https://x.example" }, { env: {}, router, keychain }), { token: "router-tok", source: "forgedock_token_file" });
|
|
970
|
+
assert.deepEqual(poolCredential({ provider: "remote", tokenFile: join(dir, "missing.token") }, { env: {}, router: null }), { token: null, source: null });
|
|
971
|
+
});
|
|
972
|
+
|
|
973
|
+
it("5131: execution.token_file is parsed and sent as the bearer to any endpoint", async () => {
|
|
974
|
+
const dir = routerDir();
|
|
975
|
+
const file = join(dir, "explicit.token");
|
|
976
|
+
writeFileSync(file, "file-tok\n");
|
|
977
|
+
assert.equal(parseExecution({ token_file: file }).tokenFile, file);
|
|
978
|
+
assert.equal(parseExecution({ token_file: "" }).tokenFile, undefined);
|
|
979
|
+
const hit = recorder();
|
|
980
|
+
await remoteVia({ endpoint: "https://runner.example.com", token_file: file }, { fetchFn: hit.fetchFn, dir }).capacity();
|
|
981
|
+
assert.equal(hit.seen[0].auth, "Bearer file-tok");
|
|
982
|
+
});
|
|
983
|
+
|
|
984
|
+
it("5144: a token that appears or rotates after the provider is built is the next request's bearer", async () => {
|
|
985
|
+
const dir = tmpDir("router-5144-");
|
|
986
|
+
const tokenFile = join(dir, "forgedock.token");
|
|
987
|
+
writeFileSync(join(dir, "router.json"), JSON.stringify({ listen: { host: "127.0.0.1", port: 7710 } }));
|
|
988
|
+
writeFileSync(tokenFile, "\n"); // router config with no token yet
|
|
989
|
+
const hit = recorder();
|
|
990
|
+
const env = {};
|
|
991
|
+
const mk = (name, exec) => providerByName(name, { batch: { id: "b5144" }, repo: "o/r", exec: parseExecution({ mode: "remote", max_remote: "1", provider: name, ...exec }),
|
|
992
|
+
env, poolConfigPath: join(dir, "router.json"), fetchFn: hit.fetchFn });
|
|
993
|
+
for (const [name, exec] of [["remote", { endpoint: "http://127.0.0.1:7710" }], ["pool", {}]]) {
|
|
994
|
+
writeFileSync(tokenFile, "\n");
|
|
995
|
+
hit.seen.length = 0;
|
|
996
|
+
const p = mk(name, exec);
|
|
997
|
+
await p.capacity();
|
|
998
|
+
assert.equal(hit.seen.at(-1).auth, null, `${name}: no token at construction`);
|
|
999
|
+
writeFileSync(tokenFile, "router-new\n");
|
|
1000
|
+
await p.capacity();
|
|
1001
|
+
assert.equal(hit.seen.at(-1).auth, "Bearer router-new", `${name}: a token written later is sent`);
|
|
1002
|
+
writeFileSync(tokenFile, "router-rotated\n");
|
|
1003
|
+
await p.capacity();
|
|
1004
|
+
assert.equal(hit.seen.at(-1).auth, "Bearer router-rotated", `${name}: a rotated token is sent`);
|
|
1005
|
+
}
|
|
1006
|
+
});
|
|
1007
|
+
|
|
1008
|
+
it("5144: an env token_env that is set after construction is sent; remoteTransportFor default path resolves per call", async () => {
|
|
1009
|
+
const dir = routerDir();
|
|
1010
|
+
const hit = recorder();
|
|
1011
|
+
const penv = {};
|
|
1012
|
+
const q = providerByName("remote", { batch: { id: "b5144e" }, repo: "o/r", exec: parseExecution({ mode: "remote", max_remote: "1", provider: "remote", endpoint: "https://runner.example.com", token_env: "COMPUTESHARE_TOKEN" }),
|
|
1013
|
+
env: penv, poolConfigPath: join(dir, "router.json"), fetchFn: hit.fetchFn });
|
|
1014
|
+
await q.capacity();
|
|
1015
|
+
assert.equal(hit.seen.at(-1).auth, null);
|
|
1016
|
+
penv.COMPUTESHARE_TOKEN = "env-late";
|
|
1017
|
+
await q.capacity();
|
|
1018
|
+
assert.equal(hit.seen.at(-1).auth, "Bearer env-late");
|
|
1019
|
+
const tenv = {};
|
|
1020
|
+
const t = remoteTransportFor({ endpoint: "https://runner.example.com", tokenEnv: "COMPUTESHARE_TOKEN" }, { env: tenv, router: null, fetchFn: hit.fetchFn });
|
|
1021
|
+
await t.capacity();
|
|
1022
|
+
assert.equal(hit.seen.at(-1).auth, null);
|
|
1023
|
+
tenv.COMPUTESHARE_TOKEN = "env-late-2";
|
|
1024
|
+
await t.capacity();
|
|
1025
|
+
assert.equal(hit.seen.at(-1).auth, "Bearer env-late-2");
|
|
1026
|
+
});
|
|
1027
|
+
|
|
1028
|
+
it("5131: remoteTransportFor's default credentials (gc) fall back to the matching router token", async () => {
|
|
1029
|
+
const hit = recorder();
|
|
1030
|
+
const t = remoteTransportFor({ endpoint: "http://127.0.0.1:7710" }, { env: {}, router: { endpoint: "http://127.0.0.1:7710", token: "router-tok" }, fetchFn: hit.fetchFn });
|
|
1031
|
+
await t.capacity();
|
|
1032
|
+
assert.equal(hit.seen[0].auth, "Bearer router-tok");
|
|
1033
|
+
assert.throws(() => remoteTransportFor({}, { env: {}, router: null }), (e) => e.code === "execution.provider.misconfigured");
|
|
1034
|
+
});
|
|
1035
|
+
});
|
package/bin/lean/heavy.mjs
CHANGED
|
@@ -266,10 +266,16 @@ export function heavyShimScript(name, real, { node = process.execPath, script =
|
|
|
266
266
|
return `${SHIM_HEADER}export FORGE_HEAVY_SHIM_DIR="$(cd "$(dirname "$0")" && pwd)"\nexec ${q(node)} ${q(script)} run --name ${q(name)} -- ${q(real)} "$@"\n`;
|
|
267
267
|
}
|
|
268
268
|
|
|
269
|
-
/**
|
|
269
|
+
/**
|
|
270
|
+
* True when `text` is a shell-style script that looks `name` up on PATH itself (a pass-through wrapper, #4680). The lookup
|
|
271
|
+
* target may be the literal name, or (#5148) a variable/substitution when the script derives that name from `$0`
|
|
272
|
+
* (a `${0##…}` strip or `basename "$0"`), the shape of a wrapper symlinked under several command names.
|
|
273
|
+
*/
|
|
270
274
|
export function isPassThroughWrapper(text, name) {
|
|
271
275
|
const t = String(text ?? "");
|
|
272
|
-
|
|
276
|
+
if (!t.startsWith("#!") || !/\b(command\s+-v|which|type\s+-p)\b/.test(t)) return false;
|
|
277
|
+
if (t.includes(name)) return true;
|
|
278
|
+
return /(?:command\s+-v|which|type\s+-p)\s+(?:-\s+)?["']?\$/.test(t) && /\$\{0##\*\/\}|basename\s+(?:--\s+)?["']?\$\{?0\b/.test(t);
|
|
273
279
|
}
|
|
274
280
|
const head = (p) => { try { return readFileSync(p, "utf8").slice(0, 4096); } catch { return ""; } };
|
|
275
281
|
|
|
@@ -332,6 +338,8 @@ export function findShimLoops(path, { names = HEAVY_BINS, find = which, read = h
|
|
|
332
338
|
}
|
|
333
339
|
|
|
334
340
|
export const SHIM_LOOP_MAX = 4;
|
|
341
|
+
/** #5148: hard bound on shim entries (FORGE_HEAVY_DEPTH) whatever the wrapper shape; far above legitimate `npm run` nesting. */
|
|
342
|
+
export const SHIM_DEPTH_MAX = 32;
|
|
335
343
|
/**
|
|
336
344
|
* Re-entry guard (#4680): FORGE_HEAVY_CHAIN lists `name:cmd` per shim run. A repeat of the same name+cmd where cmd is a
|
|
337
345
|
* pass-through wrapper means it sent the call back to the shim; a repeat of a real binary is a legitimate nested call
|
|
@@ -342,15 +350,17 @@ export function guardChain(name, cmd, env, read = head) {
|
|
|
342
350
|
const chain = String(env.FORGE_HEAVY_CHAIN ?? "").split("|").filter(Boolean);
|
|
343
351
|
const dirs = chain.map((e) => dirname(e.slice(e.indexOf(":") + 1)));
|
|
344
352
|
const loop = (why) => ({ error: `env.shim_loop: ${name} ${why} (PATH dirs: ${[env.FORGE_HEAVY_SHIM_DIR, ...dirs].filter(Boolean).join(", ") || "none"})` });
|
|
353
|
+
const depth = Math.max(0, Number.parseInt(env.FORGE_HEAVY_DEPTH, 10) || 0);
|
|
354
|
+
if (depth >= SHIM_DEPTH_MAX) return loop(`re-entered the heavy shim ${depth} levels deep (max ${SHIM_DEPTH_MAX})`);
|
|
345
355
|
const repeat = chain.includes(`${name}:${cmd}`);
|
|
346
|
-
if (repeat && !isPassThroughWrapper(read(cmd), name)) return { cmd, env };
|
|
356
|
+
if (repeat && !isPassThroughWrapper(read(cmd), name)) return { cmd, env: { ...env, FORGE_HEAVY_DEPTH: String(depth + 1) } };
|
|
347
357
|
if (chain.length >= SHIM_LOOP_MAX) return loop(`re-entered the heavy shim more than ${SHIM_LOOP_MAX} times`);
|
|
348
358
|
if (repeat) {
|
|
349
359
|
const next = which(name, env.PATH ?? "", [env.FORGE_HEAVY_SHIM_DIR, ...dirs].filter(Boolean));
|
|
350
360
|
if (!next) return loop(`re-entered via ${cmd} and no other ${name} is on PATH`);
|
|
351
361
|
cmd = next;
|
|
352
362
|
}
|
|
353
|
-
return { cmd, env: { ...env, FORGE_HEAVY_CHAIN: [...chain, `${name}:${cmd}`].join("|") } };
|
|
363
|
+
return { cmd, env: { ...env, FORGE_HEAVY_DEPTH: String(depth + 1), FORGE_HEAVY_CHAIN: [...chain, `${name}:${cmd}`].join("|") } };
|
|
354
364
|
}
|
|
355
365
|
|
|
356
366
|
/**
|
package/bin/lean/heavy.test.mjs
CHANGED
|
@@ -6,7 +6,7 @@ import { spawnSync } from "node:child_process";
|
|
|
6
6
|
import { writeFileSync, readFileSync, mkdirSync, chmodSync, existsSync } from "node:fs";
|
|
7
7
|
import { join } from "node:path";
|
|
8
8
|
import { tmpDir } from "../tests/_tmp.mjs";
|
|
9
|
-
import { guardChain, isPassThroughWrapper, SHIM_LOOP_MAX, classifyStep, heavyConfig, openHeavy, acquireHeavy, writeHeavyShims, heavyShimScript, sumWaits, runWrapped, HEAVY_BINS, isHeavyProcess } from "./heavy.mjs";
|
|
9
|
+
import { guardChain, isPassThroughWrapper, SHIM_LOOP_MAX, SHIM_DEPTH_MAX, classifyStep, heavyConfig, openHeavy, acquireHeavy, writeHeavyShims, heavyShimScript, sumWaits, runWrapped, HEAVY_BINS, isHeavyProcess } from "./heavy.mjs";
|
|
10
10
|
import { checkAdmission, ADMISSION_DEFAULTS, localLaneCap } from "./admission.mjs";
|
|
11
11
|
import { formatAlertLines, formatWaitSummary } from "./orchestrate.mjs";
|
|
12
12
|
import { readHeavy, readAdmission, liveLaneCap } from "../engine-cli.mjs";
|
|
@@ -347,3 +347,57 @@ describe("validation steps and the local lane cap (#4777)", () => {
|
|
|
347
347
|
assert.equal(liveLaneCap(null, { cfg: readAdmission(d), host: { cores: () => 16, memAvailableMb: () => 64000 } })(), 2);
|
|
348
348
|
});
|
|
349
349
|
});
|
|
350
|
+
|
|
351
|
+
describe("#5148 dynamic-name pass-through wrapper", () => {
|
|
352
|
+
const WRAP = (log) => `#!/bin/bash\nname=\${0##*/}\necho w >> "${log}"\n[ "$(wc -l < "${log}")" -gt 12 ] && exit 99\nself_dir=$(cd "\${0%/*}" && pwd)\nreal=$(PATH=$(printf '%s' "$PATH" | tr ':' '\\n' | grep -vxF "$self_dir" | paste -sd:) command -v "$name")\nexec "$real" "$@"\n`;
|
|
353
|
+
const fixture = () => {
|
|
354
|
+
const d = tmpDir("heavy-5148-"), wrap = join(d, "wrap"), real = join(d, "real"), bin = join(d, "bin"), log = join(d, "log");
|
|
355
|
+
for (const x of [wrap, real, bin]) mkdirSync(x, { recursive: true });
|
|
356
|
+
writeFileSync(log, "");
|
|
357
|
+
writeFileSync(join(wrap, "docker"), WRAP(log));
|
|
358
|
+
writeFileSync(join(real, "docker"), `#!/bin/sh\necho r >> ${log}\necho real-docker\n`);
|
|
359
|
+
for (const x of [wrap, real]) chmodSync(join(x, "docker"), 0o755);
|
|
360
|
+
return { d, wrap, real, bin, log };
|
|
361
|
+
};
|
|
362
|
+
const lines = (log) => readFileSync(log, "utf8").split("\n").filter(Boolean);
|
|
363
|
+
|
|
364
|
+
it("#5148: a ${0##*/} pass-through wrapper on PATH does not recurse through the heavy shim", async () => {
|
|
365
|
+
const { wrap, real, bin, log } = fixture();
|
|
366
|
+
assert.ok(isPassThroughWrapper(readFileSync(join(wrap, "docker"), "utf8"), "docker"));
|
|
367
|
+
assert.ok(isPassThroughWrapper('#!/bin/sh\nexec "$(command -v "$(basename "$0")")" "$@"', "docker"));
|
|
368
|
+
assert.ok(!isPassThroughWrapper("#!/bin/sh\necho hi\n", "docker"));
|
|
369
|
+
assert.ok(!isPassThroughWrapper('#!/bin/sh\nx=git\nexec "$(command -v "$x")" "$@"', "docker"), "dynamic lookup without a $0-derived name is not a wrapper");
|
|
370
|
+
assert.deepEqual(await writeHeavyShims(bin, { path: `${wrap}:${real}`, warn: () => {} }), ["docker"]);
|
|
371
|
+
assert.match(readFileSync(join(bin, "docker"), "utf8"), new RegExp(`-- '${real}/docker' "\\$@"`));
|
|
372
|
+
const env = { ...process.env, PATH: `${bin}:${wrap}:${real}:/usr/bin:/bin`, FORGE_HEAVY_DISABLED: "1" };
|
|
373
|
+
const r = spawnSync(join(bin, "docker"), ["ps"], { env, encoding: "utf8", timeout: 20000 });
|
|
374
|
+
assert.equal(r.status, 0, r.stderr);
|
|
375
|
+
assert.match(r.stdout, /real-docker/);
|
|
376
|
+
assert.deepEqual(lines(log), ["r"], "the wrapper never ran; the real binary ran once");
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
it("#5148: with the shim baking the wrapper, the call reaches the real binary within SHIM_LOOP_MAX levels", () => {
|
|
380
|
+
const { wrap, real, bin, log } = fixture();
|
|
381
|
+
writeFileSync(join(bin, "docker"), heavyShimScript("docker", join(wrap, "docker"))); chmodSync(join(bin, "docker"), 0o755);
|
|
382
|
+
const env = { ...process.env, PATH: `${bin}:${wrap}:${real}:/usr/bin:/bin`, FORGE_HEAVY_DISABLED: "1" };
|
|
383
|
+
const r = spawnSync(join(bin, "docker"), ["ps"], { env, encoding: "utf8", timeout: 20000 });
|
|
384
|
+
assert.equal(r.status, 0, r.stderr);
|
|
385
|
+
assert.ok(lines(log).length <= SHIM_LOOP_MAX + 1, lines(log).join(","));
|
|
386
|
+
assert.equal(lines(log).at(-1), "r");
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
it("#5148: guardChain stops at SHIM_DEPTH_MAX even for a repeat it judges legitimate, and counts each entry", () => {
|
|
390
|
+
const env = { PATH: "/s:/r", FORGE_HEAVY_SHIM_DIR: "/s", FORGE_HEAVY_CHAIN: "npm:/r/npm", FORGE_HEAVY_DEPTH: String(SHIM_DEPTH_MAX) };
|
|
391
|
+
assert.match(guardChain("npm", "/r/npm", env, () => "").error, /^env\.shim_loop: npm .*levels deep/);
|
|
392
|
+
const ok = guardChain("npm", "/r/npm", { ...env, FORGE_HEAVY_DEPTH: String(SHIM_DEPTH_MAX - 1) }, () => "");
|
|
393
|
+
assert.equal(ok.error, undefined); assert.equal(ok.env.FORGE_HEAVY_DEPTH, String(SHIM_DEPTH_MAX));
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
it("#5148: under systemd-run TasksMax the shimmed call finishes without hitting the limit", { skip: spawnSync("systemd-run", ["--user", "--scope", "-q", "true"], { timeout: 10000 }).status !== 0 && "no systemd --user manager" }, () => {
|
|
397
|
+
const { wrap, real, bin, log } = fixture();
|
|
398
|
+
writeFileSync(join(bin, "docker"), heavyShimScript("docker", join(wrap, "docker"))); chmodSync(join(bin, "docker"), 0o755);
|
|
399
|
+
const r = spawnSync("systemd-run", ["--user", "--scope", "-q", "-p", "TasksMax=64", "env", `PATH=${bin}:${wrap}:${real}:/usr/bin:/bin`, "FORGE_HEAVY_DISABLED=1", join(bin, "docker"), "ps"], { encoding: "utf8", timeout: 30000 });
|
|
400
|
+
assert.equal(r.status, 0, r.stderr);
|
|
401
|
+
assert.ok(lines(log).length < 13);
|
|
402
|
+
});
|
|
403
|
+
});
|
package/bin/lean/lanecheck.mjs
CHANGED
|
@@ -31,20 +31,49 @@ const C = PREFLIGHT_CODES;
|
|
|
31
31
|
/** A remote or hybrid execution is configured (the only case the pool checks apply to). */
|
|
32
32
|
export const usesPool = (exec) => Boolean(exec) && (exec.mode === "remote" || exec.mode === "hybrid") && ["pool", "remote"].includes(exec.provider ?? "pool");
|
|
33
33
|
|
|
34
|
-
/**
|
|
35
|
-
|
|
34
|
+
/** #5131: `a` and `b` name the same `scheme://host:port` (default ports filled, path and trailing slash ignored). */
|
|
35
|
+
const sameEndpoint = (a, b) => {
|
|
36
|
+
const key = (u) => { try { const x = new URL(String(u)); return `${x.protocol}//${x.hostname}:${x.port || (x.protocol === "https:" ? "443" : "80")}`; } catch { return null; } };
|
|
37
|
+
return key(a) !== null && key(a) === key(b);
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The injectable stand-in for execution.mjs `poolCredential` when no `poolToken` is given (tests, older callers): `credential`
|
|
42
|
+
* on `token_env` (default COMPUTESHARE_TOKEN), then the router token, for `remote` only when its endpoint is unset or the router's.
|
|
43
|
+
*/
|
|
44
|
+
export const legacyPoolToken = (credential = () => null) => (exec, router) => {
|
|
45
|
+
const name = exec?.tokenEnv ?? "COMPUTESHARE_TOKEN";
|
|
46
|
+
const v = credential(name);
|
|
47
|
+
if (v) return { token: v, source: name };
|
|
48
|
+
const ok = Boolean(router?.token) && ((exec?.provider ?? "pool") === "pool" || !exec?.endpoint || sameEndpoint(exec.endpoint, router.endpoint));
|
|
49
|
+
return ok ? { token: router.token, source: "forgedock_token_file" } : { token: null, source: null };
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/** The sources a resolver tries for `exec` (names only, never values), for a FAIL detail. */
|
|
53
|
+
const sourcesTried = (exec, router) => [
|
|
54
|
+
`env/keychain ${exec.tokenEnv ?? (exec.provider === "remote" ? "(no token_env)" : "COMPUTESHARE_TOKEN")}`,
|
|
55
|
+
...(exec.tokenFile ? [`token_file ${exec.tokenFile}`] : []),
|
|
56
|
+
!router ? "no router config (COMPUTESHARE_CONFIG / ~/.config/computeshare/router.json)"
|
|
57
|
+
: (exec.provider ?? "pool") === "remote" && exec.endpoint && !sameEndpoint(exec.endpoint, router.endpoint) ? `the router's forgedock_token_file (not used: endpoint ${exec.endpoint} is not the local router ${router.endpoint})`
|
|
58
|
+
: "the router's forgedock_token_file",
|
|
59
|
+
].join(", ");
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The pool token resolves through `poolToken(exec, router) -> {token, source}` (#5131: hostLaneIo wires execution.mjs
|
|
63
|
+
* poolCredential with the dispatch env, so preflight and lane dispatch resolve the same token), else `credential`/`router`
|
|
64
|
+
* (legacyPoolToken), and the router does not answer 401/403 to an authenticated ping. Details name sources, never values.
|
|
65
|
+
*/
|
|
66
|
+
export async function checkPoolAuth({ exec, router = null, credential = () => null, ping = null, poolToken = null }) {
|
|
36
67
|
const code = C.PREFLIGHT_POOL_AUTH;
|
|
37
68
|
if (!usesPool(exec)) return pass(code, "skipped: no remote or hybrid execution", "remote");
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
const token = fromEnv || router?.token || null;
|
|
41
|
-
if (!token) return fail(code, `pool token does not resolve: tried env/keychain ${name}, ${router ? "the router's forgedock_token_file" : "no router config (COMPUTESHARE_CONFIG / ~/.config/computeshare/router.json)"}`, "remote");
|
|
69
|
+
const { token, source } = (poolToken ?? legacyPoolToken(credential))(exec, router) ?? {};
|
|
70
|
+
if (!token) return fail(code, `pool token does not resolve: tried ${sourcesTried(exec, router)}`, "remote");
|
|
42
71
|
const endpoint = exec.endpoint ?? router?.endpoint ?? null;
|
|
43
|
-
if (!endpoint || typeof ping !== "function") return pass(code, `token resolved from ${
|
|
72
|
+
if (!endpoint || typeof ping !== "function") return pass(code, `token resolved from ${source}`, "remote");
|
|
44
73
|
let r = null;
|
|
45
74
|
try { r = await ping(endpoint, token); } catch { r = null; }
|
|
46
|
-
if ([401, 403].includes(r?.status)) return fail(code, `router ${endpoint} answered HTTP ${r.status} to the token from ${
|
|
47
|
-
return pass(code, `token resolved from ${
|
|
75
|
+
if ([401, 403].includes(r?.status)) return fail(code, `router ${endpoint} answered HTTP ${r.status} to the token from ${source}`, "remote");
|
|
76
|
+
return pass(code, `token resolved from ${source}${r ? "" : "; router unreachable (not an auth failure)"}`, "remote");
|
|
48
77
|
}
|
|
49
78
|
|
|
50
79
|
/** Every online pool agent runs the CLI's version. `agents` is the router's `GET /agents` list (each spreads versionOf: version, image). */
|
|
@@ -99,9 +128,11 @@ export async function runLaneChecks({ exec = null, env = process.env, cliVersion
|
|
|
99
128
|
let router = null;
|
|
100
129
|
try { router = io.router?.() ?? null; } catch { router = null; }
|
|
101
130
|
const ping = io.ping ?? null;
|
|
102
|
-
|
|
131
|
+
// #5131: one resolver for the auth check and the agents read: io.poolToken (poolCredential), else the injected credential/router.
|
|
132
|
+
const poolToken = io.poolToken ?? legacyPoolToken(io.credential ?? (() => null));
|
|
133
|
+
const agentsOf = async () => { try { return (await io.agents?.(exec?.endpoint ?? router?.endpoint, poolToken(exec, router)?.token ?? null)) ?? null; } catch { return null; } };
|
|
103
134
|
return Promise.all([
|
|
104
|
-
safe(C.PREFLIGHT_POOL_AUTH, "remote", () => checkPoolAuth({ exec, router,
|
|
135
|
+
safe(C.PREFLIGHT_POOL_AUTH, "remote", () => checkPoolAuth({ exec, router, poolToken, ping })),
|
|
105
136
|
safe(C.PREFLIGHT_POOL_VERSION, "remote", async () => checkPoolVersion({ exec, agents: usesPool(exec) ? await agentsOf() : null, cliVersion: cliV })),
|
|
106
137
|
safe(C.PREFLIGHT_PATH_LOOP, "all", () => checkPathLoop({ env: { ...env, PATH: env.PATH ?? "" }, ...(io.loops ? { loops: io.loops } : {}) })),
|
|
107
138
|
safe(C.PREFLIGHT_GH_FIELDS, "all", () => (io.ghExec ? checkGhFields({ exec: io.ghExec }) : pass(C.PREFLIGHT_GH_FIELDS, "skipped: no gh", "all"))),
|
|
@@ -113,7 +144,7 @@ export const formatLaneChecks = (checks) => checks.map((c) => `${c.ok ? "PASS" :
|
|
|
113
144
|
|
|
114
145
|
/** Real-host io for the daemon, the batch and `doctor --lanes`. gh output is cached per process (it cannot change under a running engine). */
|
|
115
146
|
export async function hostLaneIo({ env = process.env } = {}) {
|
|
116
|
-
const { poolRouterConfig } = await import("./execution.mjs");
|
|
147
|
+
const { poolCredential, poolRouterConfig } = await import("./execution.mjs");
|
|
117
148
|
const { resolveCredential } = await import("./credentials.mjs");
|
|
118
149
|
const { execFile } = await import("node:child_process");
|
|
119
150
|
const call = (url, token, timeoutMs = 5000) => fetch(url, { headers: token ? { authorization: `Bearer ${token}` } : {}, signal: AbortSignal.timeout(timeoutMs) });
|
|
@@ -122,6 +153,7 @@ export async function hostLaneIo({ env = process.env } = {}) {
|
|
|
122
153
|
return {
|
|
123
154
|
router: () => poolRouterConfig({ env }),
|
|
124
155
|
credential: (n) => resolveCredential(n, { env }),
|
|
156
|
+
poolToken: (exec, router) => poolCredential(exec, { env, router }), // #5131: the dispatch resolver on the dispatch env
|
|
125
157
|
ping: async (endpoint, token) => ({ status: (await call(`${endpoint}/status`, token)).status }),
|
|
126
158
|
agents: async (endpoint, token) => { const r = await call(`${endpoint}/agents`, token); return r.ok ? (await r.json())?.agents ?? null : null; },
|
|
127
159
|
ghExec: (args) => { const k = args.join(" "); if (!memo.has(k)) memo.set(k, run(args)); return memo.get(k); },
|
|
@@ -31,6 +31,29 @@ describe("pool_auth", () => {
|
|
|
31
31
|
});
|
|
32
32
|
});
|
|
33
33
|
|
|
34
|
+
describe("pool_auth shared resolver (#5131)", () => {
|
|
35
|
+
const remote = { mode: "remote", provider: "remote", endpoint: "https://runner.example.com", tokenEnv: "COMPUTESHARE_TOKEN" };
|
|
36
|
+
const router = { endpoint: "http://127.0.0.1:7710", token: "router-tok" };
|
|
37
|
+
it("5131: FAILs remote on a non-matching endpoint although a router token exists, naming sources never the value", async () => {
|
|
38
|
+
const a = await checkPoolAuth({ exec: remote, router, credential: () => null });
|
|
39
|
+
assert.equal(a.ok, false); assert.equal(a.code, "preflight.pool_auth"); assert.equal(a.scope, "remote");
|
|
40
|
+
assert.match(a.detail, /COMPUTESHARE_TOKEN/); assert.match(a.detail, /not the local router/); assert.doesNotMatch(a.detail, /router-tok/);
|
|
41
|
+
const viaIo = await checkPoolAuth({ exec: remote, router, poolToken: () => ({ token: null, source: null }) });
|
|
42
|
+
assert.equal(viaIo.ok, false);
|
|
43
|
+
assert.equal((await checkPoolAuth({ exec: { ...remote, endpoint: "http://127.0.0.1:7710/" }, router, credential: () => null })).ok, true, "matching endpoint: router token applies");
|
|
44
|
+
});
|
|
45
|
+
it("5131: runLaneChecks uses io.poolToken for the auth check and the agents read, and FAILs a rejected token", async () => {
|
|
46
|
+
const calls = [];
|
|
47
|
+
const io = { router: () => router, poolToken: (exec, r) => { calls.push([exec.provider, r?.endpoint]); return { token: "tok", source: "token_file" }; },
|
|
48
|
+
ping: async (_e, t) => ({ status: t === "tok" ? 401 : 200 }), agents: async (_e, t) => { calls.push(["agents", t]); return []; } };
|
|
49
|
+
const checks = await runLaneChecks({ exec: remote, env: { PATH: "" }, io: { ...io, loops: () => [] } });
|
|
50
|
+
const auth = checks.find((c) => c.code === "preflight.pool_auth");
|
|
51
|
+
assert.equal(auth.ok, false); assert.match(auth.detail, /HTTP 401/); assert.match(auth.detail, /token_file/); assert.doesNotMatch(auth.detail, /\btok\b/);
|
|
52
|
+
assert.ok(calls.some((c) => c[0] === "agents" && c[1] === "tok"));
|
|
53
|
+
assert.ok(calls.some((c) => c[0] === "remote" && c[1] === router.endpoint));
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
34
57
|
describe("pool_version", () => {
|
|
35
58
|
it("reports the agent, image version, CLI version and upgrade command", () => {
|
|
36
59
|
const c = checkPoolVersion({ exec: pool, cliVersion: "1.39.0", agents: [{ name: "a1", ok: true, version: "1.37.0" }, { name: "a2", ok: true, version: "1.39.0" }] });
|