@theappagency/valinor 10.22.0 → 10.24.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/README.md CHANGED
@@ -29,6 +29,17 @@ Actions and allow-listed networks. An HTTP 401 or 403 is never retried; the erro
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
+ **Dependabot and fork runs.** Every bundle-fetching step Valinor renders passes
33
+ `VALINOR_DOWNLOADS_TOKEN: ${{ secrets.VALINOR_DOWNLOADS_TOKEN }}` in its own `env:` (never the job's, where a
34
+ Dependabot pull request's install scripts would see it), so a run that cannot mint still authenticates. Only the
35
+ download reads it: the rendered step unsets it once the download has its credential, and the launcher runs the CLI
36
+ without it, so nothing the CLI runs can read it. A
37
+ Dependabot run reads Dependabot secrets, not Actions secrets: add `VALINOR_DOWNLOADS_TOKEN` under Settings >
38
+ Secrets and variables > Dependabot. When a run with no credential is refused, the error says it is a Dependabot
39
+ run or a fork pull request and names that secret. A fork pull request gets no secrets at all, so it fails closed
40
+ rather than skip the check. The rendered verify step stops on a 401 or 403 instead of falling back to `npx`, which
41
+ would ask the same endpoint and be refused the same way; an endpoint it cannot reach still falls back.
42
+
32
43
  **Getting a downloads token.** Ask whoever runs your Valinor downloads endpoint for a personal token: each
33
44
  person gets their own, so one person's access can be withdrawn without touching anyone else's. Keep it in
34
45
  your secret store and export it as `VALINOR_DOWNLOADS_TOKEN` in the shell that runs Valinor; never commit
package/lib/launcher.js CHANGED
@@ -253,8 +253,36 @@ async function readBodyPrefix(res, max = REFUSAL_BODY_PREFIX_BYTES) {
253
253
  return new TextDecoder().decode(Buffer.concat(chunks).subarray(0, max));
254
254
  }
255
255
 
256
+ /**
257
+ * Which secretless GitHub Actions run this is, or null: `dependabot` (the actor is Dependabot, whose runs read
258
+ * Dependabot secrets, not Actions secrets) or `fork` (a pull_request event whose head repository is not this one,
259
+ * which GitHub gives no repository secrets). Read from the runner's own variables and event file; a file it cannot
260
+ * read is not a fork.
261
+ */
262
+ export function secretlessRun(env = process.env) {
263
+ if (env.GITHUB_ACTOR === "dependabot[bot]") return "dependabot";
264
+ if (env.GITHUB_EVENT_NAME !== "pull_request" || !env.GITHUB_EVENT_PATH || !env.GITHUB_REPOSITORY) return null;
265
+ try {
266
+ const head = JSON.parse(readFileSync(env.GITHUB_EVENT_PATH, "utf8"))?.pull_request?.head?.repo?.full_name;
267
+ return typeof head === "string" && head !== "" && head.toLowerCase() !== env.GITHUB_REPOSITORY.toLowerCase() ? "fork" : null;
268
+ } catch {
269
+ return null;
270
+ }
271
+ }
272
+
273
+ /** Said after a refusal of a request that carried no credential, on a run {@link secretlessRun} names. */
274
+ const SECRETLESS_RUN_FIX = {
275
+ dependabot:
276
+ "This is a Dependabot run: it reads Dependabot secrets, not Actions secrets. Add VALINOR_DOWNLOADS_TOKEN as a Dependabot secret " +
277
+ "(Settings > Secrets and variables > Dependabot); Valinor's rendered steps pass it to the download.",
278
+ fork:
279
+ "This is a pull request from a fork: GitHub gives it no OIDC token and no Actions secrets, so it cannot download Valinor and fails here " +
280
+ "rather than skip the check. A Dependabot pull request needs VALINOR_DOWNLOADS_TOKEN as a Dependabot secret; a contributor's fork " +
281
+ "pull request passes once its branch runs from this repository.",
282
+ };
283
+
256
284
  /** The refusal for a 401/403 — an auth answer is never an outage, so it names the fix, not the network. */
257
- function authRefusal(url, status, sentCredential, headers, body) {
285
+ function authRefusal(url, status, sentCredential, headers, body, secretless = null) {
258
286
  const fix =
259
287
  "In GitHub Actions, add `permissions: id-token: write` to this job so the launcher can mint an OIDC token " +
260
288
  "(`valinor refresh-workflows --apply` adds it to Valinor's own workflows); elsewhere, set VALINOR_DOWNLOADS_TOKEN.";
@@ -283,6 +311,7 @@ function authRefusal(url, status, sentCredential, headers, body) {
283
311
  ? `${url} returned HTTP 401: the downloads endpoint refused the credential the launcher sent (expired or not valid for it). ${fix}`
284
312
  : `${url} returned HTTP 401: the downloads endpoint requires authentication and this run sent no credential. ${fix}`;
285
313
  }
314
+ if (!sentCredential && secretless) message = `${message} ${SECRETLESS_RUN_FIX[secretless]}`;
286
315
  const e = fetchError(`${message}\n${HARD_STOP}`, false);
287
316
  e.auth = true;
288
317
  return e;
@@ -329,6 +358,8 @@ export function fetchPolicy(env = process.env) {
329
358
  attempts: positiveInteger(env.VALINOR_FETCH_ATTEMPTS, 3),
330
359
  backoffMs: positiveNumber(env.VALINOR_FETCH_BACKOFF_MS, 1_000),
331
360
  totalTimeoutMs: positiveNumber(env.VALINOR_FETCH_TOTAL_TIMEOUT_MS, 120_000),
361
+ // Not a timing: which secretless run this is, so a refusal names the secret that run can read (authRefusal).
362
+ secretless: secretlessRun(env),
332
363
  };
333
364
  }
334
365
 
@@ -387,7 +418,7 @@ async function fetchBody(url, read, fetchImpl, policy, cancel, authorization) {
387
418
  // 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
419
  // it is read, unless a `cf-mitigated` header already says so; every other body is cancelled unread.
389
420
  const body = res.status === 403 && !res.headers.get("cf-mitigated") ? await readBodyPrefix(res) : "";
390
- last = authRefusal(url, res.status, credential !== null, res.headers, body);
421
+ last = authRefusal(url, res.status, credential !== null, res.headers, body, policy.secretless ?? null);
391
422
  await res.body?.cancel().catch(() => {});
392
423
  } else if (res && !res.ok) {
393
424
  last = fetchError(`${url} returned HTTP ${res.status}`, isTransientStatus(res.status));
@@ -571,8 +602,19 @@ export function cachedBinPath(versionDir) {
571
602
  }
572
603
 
573
604
  /** Spawn the cached CLI with argv passed through; returns its exit code (stdio inherited). */
605
+ /**
606
+ * The environment the CLI runs with: this process's, minus VALINOR_DOWNLOADS_TOKEN. The token is the download's
607
+ * credential, and the download is done; the CLI and everything it runs (a scanner from `node_modules/.bin`, which a pull
608
+ * request's install script could have replaced) never see it.
609
+ */
610
+ export function cliChildEnv(env = process.env) {
611
+ const rest = { ...env };
612
+ delete rest.VALINOR_DOWNLOADS_TOKEN;
613
+ return rest;
614
+ }
615
+
574
616
  export function spawnCli(binPath, args) {
575
- const result = spawnSync(process.execPath, [binPath, ...args], { stdio: "inherit" });
617
+ const result = spawnSync(process.execPath, [binPath, ...args], { stdio: "inherit", env: cliChildEnv() });
576
618
  if (result.error) throw result.error;
577
619
  if (result.signal) {
578
620
  process.kill(process.pid, result.signal); // propagate the signal-death faithfully (Ctrl-C etc.).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theappagency/valinor",
3
- "version": "10.22.0",
3
+ "version": "10.24.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",