@theappagency/valinor 10.18.0 → 10.19.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.
Files changed (3) hide show
  1. package/README.md +21 -2
  2. package/lib/launcher.js +199 -30
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -24,11 +24,30 @@ GitHub runner's default Node works without a `setup-node` step.
24
24
  **Authentication.** The downloads endpoint can require a credential. In GitHub Actions the launcher mints one
25
25
  itself: give the job `permissions: id-token: write` and it requests a GitHub OIDC token for the audience
26
26
  `https://downloads.theappagency.com`, so the repository stores no secret. Elsewhere, set
27
- `VALINOR_DOWNLOADS_TOKEN`. With neither, the launcher sends no credential, which works for as long as the
28
- endpoint does not require one. An HTTP 401 or 403 is never retried; the error names the fix. A
27
+ `VALINOR_DOWNLOADS_TOKEN`. With neither, the launcher sends no credential, which works only from GitHub
28
+ Actions and allow-listed networks. An HTTP 401 or 403 is never retried; the error names the fix. A
29
29
  `pull_request` run from a fork cannot mint an OIDC token, so it needs `VALINOR_DOWNLOADS_TOKEN` once the
30
30
  endpoint requires authentication.
31
31
 
32
+ **Getting a downloads token.** Ask whoever runs your Valinor downloads endpoint for a personal token: each
33
+ person gets their own, so one person's access can be withdrawn without touching anyone else's. Keep it in
34
+ your secret store and export it as `VALINOR_DOWNLOADS_TOKEN` in the shell that runs Valinor; never commit
35
+ it.
36
+
37
+ **Which 403 you got.** A 403 is named by who sent it. "The endpoint verified the GitHub OIDC token, but this
38
+ repository's owner is not authorized" comes from the endpoint itself, for a CI token from an organization it
39
+ does not serve. "HTTP 403 from Cloudflare's firewall in front of the downloads endpoint" means the request
40
+ never reached the endpoint: without a credential, set `VALINOR_DOWNLOADS_TOKEN`; with one, retry from
41
+ another network and report the Ray ID the message gives.
42
+
43
+ **A failed download is a hard stop.** Valinor does not run, and nothing runs in its place: there is no
44
+ switch, variable or flag that skips the download, and no other cached, local or `npx` copy stands in.
45
+ A cache hit runs offline only after the kept tarball re-hashes to the SHA-256 recorded when it was first
46
+ verified; a cache entry that fails that check is refused, and the error says to delete it and run again
47
+ with network access. Installs live under `verified/` in the cache. An entry an older launcher wrote (which
48
+ kept no tarball) is never run, moved or deleted: the version is downloaded and verified once into
49
+ `verified/`. Concurrent runs never replace an install another run is using.
50
+
32
51
  A download that fails for a transient reason (a dropped connection, a stalled transfer, a timeout, or an
33
52
  HTTP 408, 425, 429 or 5xx) is retried with jittered backoff: 3 attempts per file within a 2-minute budget by default. A 404
34
53
  and a checksum mismatch are never retried. When `review-trigger-run` still cannot be fetched after the
package/lib/launcher.js CHANGED
@@ -10,8 +10,9 @@
10
10
  // • One credential per run, only when the endpoint asks for one: a GitHub Actions OIDC token minted
11
11
  // for the downloads audience, else VALINOR_DOWNLOADS_TOKEN, else none (see downloadsAuthorization).
12
12
  // • Fail closed on integrity: a checksum mismatch DELETES the download and refuses to run.
13
- // • Offline-friendly: an exactly-pinned, already-cached version runs with NO network at all;
14
- // only a dist-tag (`latest`) needs the manifest.
13
+ // • Offline-friendly: an exactly-pinned, already-cached version runs with NO network at all, once its
14
+ // kept tarball re-hashes to the digest recorded at install; only a dist-tag (`latest`) needs the manifest.
15
+ // • A failed download is a HARD STOP: nothing (no cached, local or npx copy) runs in the bundle's place.
15
16
  // • Loud, actionable degradation: an unreachable endpoint names the URL it tried AND the local
16
17
  // cache state (what versions could still run offline) — never a bare stack trace.
17
18
  import { execFileSync, spawnSync } from "node:child_process";
@@ -19,7 +20,7 @@ import { createHash, randomBytes } from "node:crypto";
19
20
  import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
20
21
  import { homedir } from "node:os";
21
22
  import { setTimeout as sleep } from "node:timers/promises";
22
- import { join } from "node:path";
23
+ import { dirname, join } from "node:path";
23
24
  import { fileURLToPath } from "node:url";
24
25
 
25
26
  /**
@@ -82,11 +83,15 @@ export function requestedVersion(env = process.env) {
82
83
  return own === "0.0.0" ? "latest" : own;
83
84
  }
84
85
 
85
- /** The versions present in the local cache (complete installs only), newest-ish last. Never throws. */
86
+ /**
87
+ * The versions present in the local cache (complete installs that kept their verified tarball, so they can run
88
+ * offline), newest-ish last. Never throws. It only feeds diagnostics: each one is still re-verified before it runs.
89
+ */
86
90
  export function cachedVersions(root) {
87
91
  try {
88
- return readdirSync(root)
89
- .filter((name) => SEMVER_RE.test(name) && existsSync(join(root, name, ".complete")))
92
+ const verified = join(root, VERIFIED_CACHE_DIR);
93
+ return readdirSync(verified)
94
+ .filter((name) => SEMVER_RE.test(name) && existsSync(join(verified, name, ".complete")) && existsSync(join(verified, name, "valinor.tgz")))
90
95
  .sort();
91
96
  } catch (e) {
92
97
  // ENOENT only = "no cache dir yet" — a genuinely empty cache. Anything else (EACCES, EIO, …)
@@ -190,20 +195,95 @@ function onceAuthorization(resolveAuthorization) {
190
195
  return () => (pending ??= resolveAuthorization());
191
196
  }
192
197
 
198
+ /**
199
+ * The first words of the downloads Worker's OWN 403 body (sites/downloads/src/worker.js `refuse`): the only 403 the
200
+ * endpoint itself sends, for a verified GitHub OIDC token whose repository owner is not on its allowlist.
201
+ */
202
+ const OWNER_REFUSAL_PREFIX = "forbidden: the GitHub OIDC token is valid";
203
+
204
+ /** The doc that says how an operator gets a downloads token: this package's own README, which ships with it. */
205
+ const DOWNLOADS_TOKEN_DOC = "the \"Authentication\" section of the @theappagency/valinor README";
206
+
207
+ /** Said after every refusal: a failed download is a hard stop, and nothing else runs in Valinor's place. */
208
+ const HARD_STOP = "Valinor did not run, and nothing runs in its place: no cached, local or npx copy is used instead.";
209
+
210
+ /** The title Cloudflare's own block page carries, near the top of its HTML. */
211
+ const CLOUDFLARE_BLOCK_PAGE_RE = /<title>[^<]*\|\s*Cloudflare\s*<\/title>/i;
212
+
213
+ /** At most this much of a refusal body is read: enough for the Worker's one-line answer and a block page's title. */
214
+ export const REFUSAL_BODY_PREFIX_BYTES = 4096;
215
+
216
+ /**
217
+ * Who answered a 403, from its headers and at most {@link REFUSAL_BODY_PREFIX_BYTES} of its body. `owner` is the
218
+ * Worker's own plain-text OIDC-owner refusal. `edge` is Cloudflare's firewall in front of it, and needs a
219
+ * Cloudflare-specific signal: a `cf-mitigated` header, or a `cf-ray` header on Cloudflare's HTML block page. The request
220
+ * never reached the Worker. Anything else (`other`, an HTML page from a corporate proxy included) is a 403 from
221
+ * something in front of the endpoint that is not named.
222
+ */
223
+ export function classify403(headers, body) {
224
+ const type = String(headers?.get?.("content-type") ?? "").toLowerCase();
225
+ const text = String(body ?? "");
226
+ if (type.startsWith("text/plain") && text.startsWith(OWNER_REFUSAL_PREFIX)) return "owner";
227
+ if (headers?.get?.("cf-mitigated")) return "edge";
228
+ if (headers?.get?.("cf-ray") && type.startsWith("text/html") && CLOUDFLARE_BLOCK_PAGE_RE.test(text)) return "edge";
229
+ return "other";
230
+ }
231
+
232
+ /**
233
+ * Read at most `max` bytes of a response body as text, then cancel the rest, so a large refusal page is never held
234
+ * in memory. A body that cannot be read yields what was read so far.
235
+ */
236
+ async function readBodyPrefix(res, max = REFUSAL_BODY_PREFIX_BYTES) {
237
+ const reader = res.body?.getReader?.();
238
+ if (!reader) return "";
239
+ const chunks = [];
240
+ let size = 0;
241
+ try {
242
+ while (size < max) {
243
+ const { done, value } = await reader.read();
244
+ if (done) break;
245
+ chunks.push(value);
246
+ size += value.byteLength;
247
+ }
248
+ } catch {
249
+ // keep what was read
250
+ } finally {
251
+ await reader.cancel().catch(() => {});
252
+ }
253
+ return new TextDecoder().decode(Buffer.concat(chunks).subarray(0, max));
254
+ }
255
+
193
256
  /** The refusal for a 401/403 — an auth answer is never an outage, so it names the fix, not the network. */
194
- function authRefusal(url, status, sentCredential) {
257
+ function authRefusal(url, status, sentCredential, headers, body) {
195
258
  const fix =
196
259
  "In GitHub Actions, add `permissions: id-token: write` to this job so the launcher can mint an OIDC token " +
197
260
  "(`valinor refresh-workflows --apply` adds it to Valinor's own workflows); elsewhere, set VALINOR_DOWNLOADS_TOKEN.";
198
- const e =
199
- status === 403
200
- ? fetchError(`${url} returned HTTP 403: the endpoint verified the credential, but this repository's owner is not authorized to download Valinor.`, false)
201
- : fetchError(
202
- sentCredential
203
- ? `${url} returned HTTP 401: the downloads endpoint refused the credential the launcher sent (expired or not valid for it). ${fix}`
204
- : `${url} returned HTTP 401: the downloads endpoint requires authentication and this run sent no credential. ${fix}`,
205
- false,
206
- );
261
+ let message;
262
+ if (status === 403) {
263
+ const who = classify403(headers, body);
264
+ const ray = headers?.get?.("cf-ray");
265
+ const rayNote = ray ? ` (Cloudflare Ray ID ${ray})` : "";
266
+ if (who === "owner") {
267
+ message = `${url} returned HTTP 403: the endpoint verified the GitHub OIDC token, but this repository's owner is not authorized to download Valinor.`;
268
+ } else if (!sentCredential) {
269
+ const where = who === "edge" ? `Cloudflare's firewall in front of the downloads endpoint${rayNote}` : "something between this machine and the downloads endpoint (a proxy or firewall)";
270
+ message =
271
+ `${url} returned HTTP 403 from ${where}; the endpoint itself never answers 403 to a request without a credential. ` +
272
+ "This request carried none, and a download without a credential is admitted only from GitHub Actions and allow-listed networks. " +
273
+ `Set VALINOR_DOWNLOADS_TOKEN to your own downloads token (how to get one: ${DOWNLOADS_TOKEN_DOC}); ` +
274
+ "in GitHub Actions, add `permissions: id-token: write` to the job instead.";
275
+ } else {
276
+ const where = who === "edge" ? `Cloudflare's firewall in front of the downloads endpoint${rayNote}` : "something between this machine and the downloads endpoint (a proxy or firewall)";
277
+ message =
278
+ `${url} returned HTTP 403 from ${where}, not from the endpoint, although the request carried a credential. ` +
279
+ `The credential was never checked. Retry from another network, or report the refusal${ray ? ` with Ray ID ${ray}` : ""}.`;
280
+ }
281
+ } else {
282
+ message = sentCredential
283
+ ? `${url} returned HTTP 401: the downloads endpoint refused the credential the launcher sent (expired or not valid for it). ${fix}`
284
+ : `${url} returned HTTP 401: the downloads endpoint requires authentication and this run sent no credential. ${fix}`;
285
+ }
286
+ const e = fetchError(`${message}\n${HARD_STOP}`, false);
207
287
  e.auth = true;
208
288
  return e;
209
289
  }
@@ -304,7 +384,10 @@ async function fetchBody(url, read, fetchImpl, policy, cancel, authorization) {
304
384
  last = fetchError(`could not reach ${url} (${describeFailure(e, timeoutMs, "waiting for a response")})`, true, e);
305
385
  }
306
386
  if (res && (res.status === 401 || res.status === 403)) {
307
- last = authRefusal(url, res.status, credential !== null);
387
+ // A 403's body says WHO refused (the Worker's one-line answer, or a block page's title), so a bounded prefix of
388
+ // it is read, unless a `cf-mitigated` header already says so; every other body is cancelled unread.
389
+ const body = res.status === 403 && !res.headers.get("cf-mitigated") ? await readBodyPrefix(res) : "";
390
+ last = authRefusal(url, res.status, credential !== null, res.headers, body);
308
391
  await res.body?.cancel().catch(() => {});
309
392
  } else if (res && !res.ok) {
310
393
  last = fetchError(`${url} returned HTTP ${res.status}`, isTransientStatus(res.status));
@@ -337,18 +420,96 @@ export async function fetchManifest(base, fetchImpl, policy = fetchPolicy(), aut
337
420
  return fetchBody(`${base}/manifest.json`, (res) => res.json(), fetchImpl, policy, undefined, authorization);
338
421
  }
339
422
 
423
+ /** A cached version that failed its check: terminal, never retried, and never given the offline hint. */
424
+ function cacheRefusal(versionDir, why) {
425
+ const e = new Error(
426
+ `the cached Valinor at ${versionDir} failed verification: ${why}. Refusing to run it. ` +
427
+ `Delete ${versionDir} and run again with network access, so the launcher downloads and verifies it afresh.\n${HARD_STOP}`,
428
+ );
429
+ e.transient = false;
430
+ e.integrity = true;
431
+ return e;
432
+ }
433
+
434
+ const DIGEST_RE = /^[0-9a-f]{64}$/;
435
+
436
+ /**
437
+ * Check a cached version before it runs. The cache keeps the VERIFIED tarball beside the extracted package, and
438
+ * `.complete` records the SHA-256 that SHA256SUMS gave for it at install. A hit re-hashes the kept tarball against
439
+ * that digest on every run, so a `.complete` marker on its own is never enough: a bare or malformed marker, or a
440
+ * tarball whose bytes are not the recorded ones (or a missing one), is refused. Returns { state: "absent" } (no
441
+ * marker: an ordinary cache miss) or { state: "verified" }, and throws {@link cacheRefusal} otherwise. This check
442
+ * cannot stop someone who writes a matching tarball AND digest into the cache, but anyone able to do that can run
443
+ * `node` on any file already; it closes the bare-marker shortcut.
444
+ */
445
+ export function verifyCachedVersion(versionDir) {
446
+ const marker = join(versionDir, ".complete");
447
+ if (!existsSync(marker)) return { state: "absent" };
448
+ let recorded;
449
+ try {
450
+ recorded = readFileSync(marker, "utf8").trim();
451
+ } catch (e) {
452
+ throw cacheRefusal(versionDir, `its .complete marker could not be read (${e?.code ?? e})`);
453
+ }
454
+ if (!DIGEST_RE.test(recorded)) throw cacheRefusal(versionDir, "its .complete marker records no valid SHA-256 digest");
455
+ const kept = join(versionDir, "valinor.tgz");
456
+ if (!existsSync(kept)) throw cacheRefusal(versionDir, "it has no kept tarball to re-hash");
457
+ let actual;
458
+ try {
459
+ actual = createHash("sha256").update(readFileSync(kept)).digest("hex");
460
+ } catch (e) {
461
+ throw cacheRefusal(versionDir, `its kept tarball could not be read (${e?.code ?? e})`);
462
+ }
463
+ if (actual !== recorded) throw cacheRefusal(versionDir, `its kept tarball hashes to ${actual}, not the ${recorded} recorded when it was verified`);
464
+ return { state: "verified" };
465
+ }
466
+
467
+ /**
468
+ * Where this launcher keeps its installs: `<cache root>/verified/<version>/`, apart from the `<cache root>/<version>/`
469
+ * entries older launchers wrote (which kept no tarball, so cannot be re-verified). An older entry is never read, moved
470
+ * or deleted here — an older launcher may be running from it — so it is simply not a hit: the version is downloaded
471
+ * and verified once into this directory. Not a semver name, so an older launcher's cache listing never sees it.
472
+ */
473
+ export const VERIFIED_CACHE_DIR = "verified";
474
+
475
+ /** The directory this launcher installs `version` into (see {@link VERIFIED_CACHE_DIR}). */
476
+ export function versionCacheDir(root, version) {
477
+ return join(root, VERIFIED_CACHE_DIR, version);
478
+ }
479
+
340
480
  /**
341
481
  * Ensure `version` is present + verified in the cache; returns the cache dir for that version.
342
- * Cache HIT (a completed prior install) is served with NO network I/O. On a miss: download the
343
- * tarball AND its SHA256SUMS, recompute the digest, and FAIL CLOSED on mismatch (the partial
344
- * download is deleted; nothing executable is left behind). The verified tarball is extracted into
345
- * a temp dir and atomically renamed into place, so a crashed install can never half-populate a
346
- * version dir that a later run would trust.
482
+ * Cache HIT (a completed prior install whose kept tarball still matches its recorded digest, see
483
+ * {@link verifyCachedVersion}) is served with NO network I/O. On a miss: download the tarball AND its
484
+ * SHA256SUMS, recompute the digest, and FAIL CLOSED on mismatch (the partial download is deleted; nothing
485
+ * executable is left behind). The verified tarball is kept and extracted into a fresh staging dir that is
486
+ * renamed into place. A rename never replaces an existing install, so a version dir another process is running
487
+ * from is never moved or deleted: a run that loses the race uses the winner's install once it verifies.
347
488
  */
348
489
  export async function ensureVersion(base, version, root, fetchImpl, policy = fetchPolicy(), authorization) {
349
- const versionDir = join(root, version);
350
- if (existsSync(join(versionDir, ".complete"))) return versionDir; // cache hit — zero network.
490
+ const versionDir = versionCacheDir(root, version);
491
+ if (verifyCachedVersion(versionDir).state === "verified") return versionDir; // cache hit — zero network.
492
+ const legacyDir = join(root, version);
493
+ if (!existsSync(join(legacyDir, ".complete"))) return installVersion(base, version, versionDir, fetchImpl, policy, authorization);
494
+ try {
495
+ return await installVersion(base, version, versionDir, fetchImpl, policy, authorization);
496
+ } catch (e) {
497
+ if (e?.auth === true || e?.integrity === true || String(e?.message).includes("SHA-256 MISMATCH")) throw e;
498
+ const msg = e instanceof Error ? e.message : String(e);
499
+ const wrapped = new Error(
500
+ `the cached Valinor at ${legacyDir} was written by an older launcher that did not keep the verified tarball, so it is never ` +
501
+ `run; this version is downloaded and verified once into ${versionDir}, and that download failed: ${msg}\n` +
502
+ `Run again with network access (outside GitHub Actions, with VALINOR_DOWNLOADS_TOKEN set).\n${HARD_STOP}`,
503
+ { cause: e },
504
+ );
505
+ wrapped.transient = e?.transient === true;
506
+ wrapped.integrity = true;
507
+ throw wrapped;
508
+ }
509
+ }
351
510
 
511
+ /** Download, verify and install `version` into `versionDir`. */
512
+ async function installVersion(base, version, versionDir, fetchImpl, policy, authorization) {
352
513
  const tarballUrl = `${base}/v/${version}/valinor.tgz`;
353
514
  // Either file failing for good fails the install, so it cancels the other's retry loop at once.
354
515
  const siblings = new AbortController();
@@ -370,20 +531,28 @@ export async function ensureVersion(base, version, root, fetchImpl, policy = fet
370
531
  );
371
532
  }
372
533
 
373
- const stagingDir = join(root, `.tmp-${version}-${randomBytes(6).toString("hex")}`);
374
- mkdirSync(stagingDir, { recursive: true });
534
+ const parent = dirname(versionDir);
535
+ mkdirSync(parent, { recursive: true });
536
+ const stagingDir = join(parent, `.tmp-${version}-${randomBytes(6).toString("hex")}`);
537
+ mkdirSync(stagingDir);
375
538
  try {
376
539
  const tgzPath = join(stagingDir, "valinor.tgz");
377
540
  writeFileSync(tgzPath, tarball);
378
541
  // `tar` ships with every supported platform (incl. Windows 10+'s bsdtar); using it (rather than
379
- // npm) keeps the install registry-free and auth-free by construction.
542
+ // npm) keeps the install registry-free and auth-free by construction. The tarball stays: every later
543
+ // cache hit re-hashes it against the digest recorded below.
380
544
  execFileSync("tar", ["-xzf", tgzPath, "-C", stagingDir]);
381
- rmSync(tgzPath);
382
545
  writeFileSync(join(stagingDir, ".complete"), `${expected}\n`);
383
546
  try {
547
+ // A directory rename never replaces a non-empty directory, so an install another process is running from
548
+ // is never moved or deleted under it.
384
549
  renameSync(stagingDir, versionDir);
385
550
  } catch (e) {
386
- if (existsSync(join(versionDir, ".complete"))) return versionDir; // lost a concurrent race — theirs is verified too.
551
+ // Lost a concurrent race: theirs is used only if it verifies like any other cache hit.
552
+ if (verifyCachedVersion(versionDir).state === "verified") {
553
+ rmSync(stagingDir, { recursive: true, force: true });
554
+ return versionDir;
555
+ }
387
556
  throw e;
388
557
  }
389
558
  } catch (e) {
@@ -446,7 +615,7 @@ export async function run(args, env = process.env) {
446
615
  } catch (e) {
447
616
  const msg = e instanceof Error ? e.message : String(e);
448
617
  // Integrity and authentication failures are terminal as-is; reachability failures get the offline hint.
449
- throw withHint(e, msg.includes("SHA-256 MISMATCH") || e?.auth === true ? msg : `${msg}\n${offlineHint(base, root)}`);
618
+ throw withHint(e, msg.includes("SHA-256 MISMATCH") || e?.auth === true || e?.integrity === true ? msg : `${msg}\n${offlineHint(base, root)}`);
450
619
  }
451
620
  return spawnCli(cachedBinPath(versionDir), args);
452
621
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theappagency/valinor",
3
- "version": "10.18.0",
3
+ "version": "10.19.0",
4
4
  "description": "Public launcher for the Valinor CLI — fetches, SHA-256-verifies, caches, and runs the proprietary Valinor bundle from downloads.valinorci.com. Zero setup, zero secrets, zero npm auth.",
5
5
  "repository": {
6
6
  "type": "git",