@theappagency/valinor 10.8.0 → 10.11.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 +13 -3
- package/lib/launcher.js +105 -13
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -18,15 +18,24 @@ The launcher is **trivial by design** and contains no Valinor functionality itse
|
|
|
18
18
|
version runs **fully offline** with no network I/O;
|
|
19
19
|
4. executes the cached CLI, passing through all arguments and the exit code.
|
|
20
20
|
|
|
21
|
-
No npm registry auth
|
|
22
|
-
|
|
21
|
+
No npm registry auth and no `.npmrc` — `npx` it anywhere, including CI. It runs on Node 22 or later, so a
|
|
22
|
+
GitHub runner's default Node works without a `setup-node` step.
|
|
23
|
+
|
|
24
|
+
**Authentication.** The downloads endpoint can require a credential. In GitHub Actions the launcher mints one
|
|
25
|
+
itself: give the job `permissions: id-token: write` and it requests a GitHub OIDC token for the audience
|
|
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
|
|
29
|
+
`pull_request` run from a fork cannot mint an OIDC token, so it needs `VALINOR_DOWNLOADS_TOKEN` once the
|
|
30
|
+
endpoint requires authentication.
|
|
23
31
|
|
|
24
32
|
A download that fails for a transient reason (a dropped connection, a stalled transfer, a timeout, or an
|
|
25
33
|
HTTP 408, 425, 429 or 5xx) is retried with jittered backoff: 3 attempts per file within a 2-minute budget by default. A 404
|
|
26
34
|
and a checksum mismatch are never retried. When `review-trigger-run` still cannot be fetched after the
|
|
27
35
|
retries, the launcher prints a warning and exits 0: the verb posts nothing when it cannot run, and the next
|
|
28
36
|
wake-up or the hourly sweep evaluates the PR again. Every other command exits 1.
|
|
29
|
-
**The launcher sends no telemetry.** Its only network activity is fetching the artifact above
|
|
37
|
+
**The launcher sends no telemetry.** Its only network activity is fetching the artifact above (and, in
|
|
38
|
+
GitHub Actions with `id-token: write`, minting the OIDC token it fetches with).
|
|
30
39
|
|
|
31
40
|
## Environment variables
|
|
32
41
|
|
|
@@ -39,6 +48,7 @@ wake-up or the hourly sweep evaluates the PR again. Every other command exits 1.
|
|
|
39
48
|
| `VALINOR_FETCH_ATTEMPTS` | Attempts per file for a transient failure: a whole number, at least `1` (default `3`). |
|
|
40
49
|
| `VALINOR_FETCH_BACKOFF_MS` | Retry backoff base in milliseconds (default `1000`); attempt *n* waits 50–100% of base × 2^(n−1). |
|
|
41
50
|
| `VALINOR_FETCH_TOTAL_TIMEOUT_MS` | The whole download's budget in milliseconds, across every attempt (default `120000`). |
|
|
51
|
+
| `VALINOR_DOWNLOADS_TOKEN` | The downloads credential outside GitHub Actions (sent as a bearer token; a minted Actions OIDC token takes precedence). Sent only over https, or to this machine. |
|
|
42
52
|
| `VALINOR_LAUNCHER_DEBUG` | Print full error causes (default output is the actionable message only). |
|
|
43
53
|
|
|
44
54
|
## Licensing
|
package/lib/launcher.js
CHANGED
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
// Design constraints (deliberate):
|
|
8
8
|
// • ZERO runtime dependencies — nothing to audit, nothing to resolve from any registry.
|
|
9
9
|
// • NO TELEMETRY — the launcher reports nothing anywhere; only the artifact fetch itself.
|
|
10
|
+
// • One credential per run, only when the endpoint asks for one: a GitHub Actions OIDC token minted
|
|
11
|
+
// for the downloads audience, else VALINOR_DOWNLOADS_TOKEN, else none (see downloadsAuthorization).
|
|
10
12
|
// • Fail closed on integrity: a checksum mismatch DELETES the download and refuses to run.
|
|
11
13
|
// • Offline-friendly: an exactly-pinned, already-cached version runs with NO network at all;
|
|
12
14
|
// only a dist-tag (`latest`) needs the manifest.
|
|
@@ -48,6 +50,14 @@ export const DEFAULT_DOWNLOADS_HOST = LEGACY_DOWNLOADS_HOST;
|
|
|
48
50
|
/** The production downloads endpoint (override with VALINOR_DOWNLOADS_URL — staging/fixtures/tests). */
|
|
49
51
|
export const DEFAULT_DOWNLOADS_URL = `https://${DEFAULT_DOWNLOADS_HOST}`;
|
|
50
52
|
|
|
53
|
+
/**
|
|
54
|
+
* The OIDC AUDIENCE the launcher requests when it mints a GitHub Actions token for the downloads endpoint —
|
|
55
|
+
* MIRRORS src/launcherBundle/resolveSha.ts (asserted by src/launcherBundle/downloadsHostMirror.test.ts). It is ONE
|
|
56
|
+
* fixed string whichever hostname the run fetches from: an audience is signed into the token before the endpoint
|
|
57
|
+
* sees it, so it cannot follow a host flip, and the endpoint accepts it on every hostname it serves.
|
|
58
|
+
*/
|
|
59
|
+
export const DOWNLOADS_OIDC_AUDIENCE = "https://downloads.theappagency.com";
|
|
60
|
+
|
|
51
61
|
const SEMVER_RE = /^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/;
|
|
52
62
|
|
|
53
63
|
/** The endpoint base URL, env-overridable, without a trailing slash. */
|
|
@@ -126,6 +136,78 @@ export function parseSha256Sums(text, filename) {
|
|
|
126
136
|
throw new Error(`SHA256SUMS carries no entry for ${filename} — refusing to run an unverifiable artifact`);
|
|
127
137
|
}
|
|
128
138
|
|
|
139
|
+
/** Whether a credential may be sent to `base`: https, or plain http to this machine only (a local fixture). */
|
|
140
|
+
function mayCarryCredential(base) {
|
|
141
|
+
try {
|
|
142
|
+
const url = new URL(base);
|
|
143
|
+
return url.protocol === "https:" || (url.protocol === "http:" && ["127.0.0.1", "localhost", "[::1]"].includes(url.hostname));
|
|
144
|
+
} catch {
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* The `Authorization` value for downloads requests, or null to send none. ONE credential, in this order:
|
|
151
|
+
* 1. a GitHub Actions OIDC token minted for {@link DOWNLOADS_OIDC_AUDIENCE} — available only when the job grants
|
|
152
|
+
* `permissions: id-token: write`, which is what puts ACTIONS_ID_TOKEN_REQUEST_URL/TOKEN in the environment;
|
|
153
|
+
* 2. VALINOR_DOWNLOADS_TOKEN — the non-Actions credential (a laptop, another CI);
|
|
154
|
+
* 3. nothing — anonymous, which the endpoint serves until it requires authentication.
|
|
155
|
+
* A failed mint is not fatal: it warns (never printing a token) and falls through, so a run the endpoint would
|
|
156
|
+
* serve anonymously still runs; when the endpoint does require auth, its 401 names the fix. A pull_request run
|
|
157
|
+
* from a fork cannot mint (GitHub withholds the request variables), so it lands on 2 or 3.
|
|
158
|
+
* `base` is the endpoint the credential is for: a plain-http endpoint that is not this machine gets none.
|
|
159
|
+
*/
|
|
160
|
+
export async function downloadsAuthorization(env = process.env, fetchImpl, policy = fetchPolicy(env), base = downloadsUrl(env)) {
|
|
161
|
+
if (!mayCarryCredential(base)) return null;
|
|
162
|
+
const fallback = env.VALINOR_DOWNLOADS_TOKEN ? `Bearer ${env.VALINOR_DOWNLOADS_TOKEN}` : null;
|
|
163
|
+
const requestUrl = env.ACTIONS_ID_TOKEN_REQUEST_URL;
|
|
164
|
+
const requestToken = env.ACTIONS_ID_TOKEN_REQUEST_TOKEN;
|
|
165
|
+
if (!requestUrl || !requestToken) return fallback;
|
|
166
|
+
try {
|
|
167
|
+
const url = new URL(requestUrl);
|
|
168
|
+
url.searchParams.set("audience", DOWNLOADS_OIDC_AUDIENCE);
|
|
169
|
+
const res = await (fetchImpl ?? fetch)(url, {
|
|
170
|
+
headers: { Authorization: `bearer ${requestToken}`, Accept: "application/json" },
|
|
171
|
+
signal: AbortSignal.timeout(policy.timeoutMs),
|
|
172
|
+
});
|
|
173
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
174
|
+
const value = (await res.json())?.value;
|
|
175
|
+
if (typeof value !== "string" || value === "") throw new Error("the response carried no token");
|
|
176
|
+
return `Bearer ${value}`;
|
|
177
|
+
} catch (e) {
|
|
178
|
+
const why = e?.name === "TimeoutError" ? `timed out after ${policy.timeoutMs}ms` : e instanceof Error ? e.message : String(e);
|
|
179
|
+
console.error(
|
|
180
|
+
`valinor launcher: warning — could not mint a GitHub Actions OIDC token for the downloads endpoint (${why}); ` +
|
|
181
|
+
(fallback ? "sending VALINOR_DOWNLOADS_TOKEN instead" : "continuing without a credential"),
|
|
182
|
+
);
|
|
183
|
+
return fallback;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Resolve the run's credential at most once, and only if a request is actually made (a cache hit mints nothing). */
|
|
188
|
+
function onceAuthorization(resolveAuthorization) {
|
|
189
|
+
let pending = null;
|
|
190
|
+
return () => (pending ??= resolveAuthorization());
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** 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) {
|
|
195
|
+
const fix =
|
|
196
|
+
"In GitHub Actions, add `permissions: id-token: write` to this job so the launcher can mint an OIDC token " +
|
|
197
|
+
"(`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
|
+
);
|
|
207
|
+
e.auth = true;
|
|
208
|
+
return e;
|
|
209
|
+
}
|
|
210
|
+
|
|
129
211
|
/**
|
|
130
212
|
* The per-ATTEMPT timeout (ms): a firewalled/blackholed endpoint that ACCEPTS a connection but never
|
|
131
213
|
* replies must fail LOUDLY in seconds, not hang forever. The signal covers the response BODY as well as
|
|
@@ -198,7 +280,10 @@ function describeFailure(e, timeoutMs, phase) {
|
|
|
198
280
|
* backoff wait — once a sibling download has already failed terminally, so the process never idles out
|
|
199
281
|
* the rest of the budget on a run that must fail anyway.
|
|
200
282
|
*/
|
|
201
|
-
async function fetchBody(url, read, fetchImpl, policy, cancel) {
|
|
283
|
+
async function fetchBody(url, read, fetchImpl, policy, cancel, authorization) {
|
|
284
|
+
// The credential is resolved BEFORE the budget starts: minting has its own timeout, and the budget is the download's.
|
|
285
|
+
const credential = authorization ? await authorization() : null;
|
|
286
|
+
const headers = credential ? { Authorization: credential } : undefined;
|
|
202
287
|
const deadline = Date.now() + policy.totalTimeoutMs;
|
|
203
288
|
let last;
|
|
204
289
|
let made = 0;
|
|
@@ -212,11 +297,16 @@ async function fetchBody(url, read, fetchImpl, policy, cancel) {
|
|
|
212
297
|
const signal = cancel ? AbortSignal.any([timeout, cancel]) : timeout;
|
|
213
298
|
let res;
|
|
214
299
|
try {
|
|
215
|
-
|
|
300
|
+
// A redirect to another origin drops the Authorization header (the fetch standard), so a credential never
|
|
301
|
+
// follows the legacy store redirect off this endpoint.
|
|
302
|
+
res = await (fetchImpl ?? fetch)(url, { redirect: "follow", signal, headers });
|
|
216
303
|
} catch (e) {
|
|
217
304
|
last = fetchError(`could not reach ${url} (${describeFailure(e, timeoutMs, "waiting for a response")})`, true, e);
|
|
218
305
|
}
|
|
219
|
-
if (res &&
|
|
306
|
+
if (res && (res.status === 401 || res.status === 403)) {
|
|
307
|
+
last = authRefusal(url, res.status, credential !== null);
|
|
308
|
+
await res.body?.cancel().catch(() => {});
|
|
309
|
+
} else if (res && !res.ok) {
|
|
220
310
|
last = fetchError(`${url} returned HTTP ${res.status}`, isTransientStatus(res.status));
|
|
221
311
|
await res.body?.cancel().catch(() => {});
|
|
222
312
|
} else if (res) {
|
|
@@ -243,8 +333,8 @@ async function fetchBody(url, read, fetchImpl, policy, cancel) {
|
|
|
243
333
|
}
|
|
244
334
|
|
|
245
335
|
/** Fetch + validate the endpoint manifest, retrying transient failures. `policy` is threaded from the caller's env. */
|
|
246
|
-
export async function fetchManifest(base, fetchImpl, policy = fetchPolicy()) {
|
|
247
|
-
return fetchBody(`${base}/manifest.json`, (res) => res.json(), fetchImpl, policy);
|
|
336
|
+
export async function fetchManifest(base, fetchImpl, policy = fetchPolicy(), authorization) {
|
|
337
|
+
return fetchBody(`${base}/manifest.json`, (res) => res.json(), fetchImpl, policy, undefined, authorization);
|
|
248
338
|
}
|
|
249
339
|
|
|
250
340
|
/**
|
|
@@ -255,7 +345,7 @@ export async function fetchManifest(base, fetchImpl, policy = fetchPolicy()) {
|
|
|
255
345
|
* a temp dir and atomically renamed into place, so a crashed install can never half-populate a
|
|
256
346
|
* version dir that a later run would trust.
|
|
257
347
|
*/
|
|
258
|
-
export async function ensureVersion(base, version, root, fetchImpl, policy = fetchPolicy()) {
|
|
348
|
+
export async function ensureVersion(base, version, root, fetchImpl, policy = fetchPolicy(), authorization) {
|
|
259
349
|
const versionDir = join(root, version);
|
|
260
350
|
if (existsSync(join(versionDir, ".complete"))) return versionDir; // cache hit — zero network.
|
|
261
351
|
|
|
@@ -268,8 +358,8 @@ export async function ensureVersion(base, version, root, fetchImpl, policy = fet
|
|
|
268
358
|
throw e;
|
|
269
359
|
});
|
|
270
360
|
const [tarball, sums] = await Promise.all([
|
|
271
|
-
settle(fetchBody(tarballUrl, async (res) => Buffer.from(await res.arrayBuffer()), fetchImpl, policy, siblings.signal)),
|
|
272
|
-
settle(fetchBody(`${base}/v/${version}/SHA256SUMS`, (res) => res.text(), fetchImpl, policy, siblings.signal)),
|
|
361
|
+
settle(fetchBody(tarballUrl, async (res) => Buffer.from(await res.arrayBuffer()), fetchImpl, policy, siblings.signal, authorization)),
|
|
362
|
+
settle(fetchBody(`${base}/v/${version}/SHA256SUMS`, (res) => res.text(), fetchImpl, policy, siblings.signal, authorization)),
|
|
273
363
|
]);
|
|
274
364
|
const expected = parseSha256Sums(sums, "valinor.tgz");
|
|
275
365
|
const actual = createHash("sha256").update(tarball).digest("hex");
|
|
@@ -334,6 +424,8 @@ export async function run(args, env = process.env) {
|
|
|
334
424
|
// Resolve the fetch policy ONCE from THIS call's env (an earlier bug read the ambient process.env, so a
|
|
335
425
|
// `run(args, env)` override never engaged) and thread it into every fetch.
|
|
336
426
|
const policy = fetchPolicy(env);
|
|
427
|
+
// One credential for the whole run, resolved lazily: a cache hit (or an offline pinned run) mints nothing.
|
|
428
|
+
const authorization = onceAuthorization(() => downloadsAuthorization(env, undefined, policy, base));
|
|
337
429
|
|
|
338
430
|
let version;
|
|
339
431
|
if (SEMVER_RE.test(requested)) {
|
|
@@ -341,20 +433,20 @@ export async function run(args, env = process.env) {
|
|
|
341
433
|
} else {
|
|
342
434
|
let manifest;
|
|
343
435
|
try {
|
|
344
|
-
manifest = await fetchManifest(base, undefined, policy);
|
|
436
|
+
manifest = await fetchManifest(base, undefined, policy, authorization);
|
|
345
437
|
} catch (e) {
|
|
346
|
-
throw withHint(e, `${e instanceof Error ? e.message : String(e)}\n${offlineHint(base, root)}`);
|
|
438
|
+
throw withHint(e, e?.auth === true ? e.message : `${e instanceof Error ? e.message : String(e)}\n${offlineHint(base, root)}`);
|
|
347
439
|
}
|
|
348
440
|
version = resolveVersion(manifest, requested);
|
|
349
441
|
}
|
|
350
442
|
|
|
351
443
|
let versionDir;
|
|
352
444
|
try {
|
|
353
|
-
versionDir = await ensureVersion(base, version, root, undefined, policy);
|
|
445
|
+
versionDir = await ensureVersion(base, version, root, undefined, policy, authorization);
|
|
354
446
|
} catch (e) {
|
|
355
447
|
const msg = e instanceof Error ? e.message : String(e);
|
|
356
|
-
// Integrity failures are terminal as-is; reachability failures get the offline hint.
|
|
357
|
-
throw withHint(e, msg.includes("SHA-256 MISMATCH") ? msg : `${msg}\n${offlineHint(base, root)}`);
|
|
448
|
+
// 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)}`);
|
|
358
450
|
}
|
|
359
451
|
return spawnCli(cachedBinPath(versionDir), args);
|
|
360
452
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@theappagency/valinor",
|
|
3
|
-
"version": "10.
|
|
3
|
+
"version": "10.11.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",
|