@patronage/software-factory 1.0.0-alpha.36 → 1.0.0-alpha.38

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.
@@ -193,8 +193,19 @@ const openJevCache = ({ directory = defaultJevCacheDirectory(process.cwd()), end
193
193
  };
194
194
  //#endregion
195
195
  //#region src/hq-access.ts
196
- const CF_ACCESS_CLIENT_ID_ENV = "CF-Access-Client-Id";
197
- const CF_ACCESS_CLIENT_SECRET_ENV = "CF-Access-Client-Secret";
196
+ /**
197
+ * The environment variables that carry the HQ Cloudflare Access service token.
198
+ * Both names are shell identifiers, so `/bin/sh` passes them through on every
199
+ * platform, dash included (#1286, #1311). These are the only names read.
200
+ */
201
+ const CF_ACCESS_CLIENT_ID_ENV = "CF_ACCESS_CLIENT_ID";
202
+ const CF_ACCESS_CLIENT_SECRET_ENV = "CF_ACCESS_CLIENT_SECRET";
203
+ /**
204
+ * The request headers Cloudflare Access reads the token from. They are the
205
+ * wire contract and do not follow the environment variable names.
206
+ */
207
+ const CF_ACCESS_CLIENT_ID_HEADER = "CF-Access-Client-Id";
208
+ const CF_ACCESS_CLIENT_SECRET_HEADER = "CF-Access-Client-Secret";
198
209
  /**
199
210
  * True in a hosted runner, where secret-manager resolution must not be
200
211
  * tried (#312). Lives here — a dependency-free leaf both
@@ -211,8 +222,8 @@ const buildCloudflareAccessRequestInit = (token, init) => ({
211
222
  ...init,
212
223
  headers: {
213
224
  ...init.headers,
214
- [CF_ACCESS_CLIENT_ID_ENV]: token.clientId,
215
- [CF_ACCESS_CLIENT_SECRET_ENV]: token.clientSecret
225
+ [CF_ACCESS_CLIENT_ID_HEADER]: token.clientId,
226
+ [CF_ACCESS_CLIENT_SECRET_HEADER]: token.clientSecret
216
227
  },
217
228
  redirect: "error"
218
229
  });
@@ -221,9 +232,11 @@ const buildCloudflareAccessRequestInit = (token, init) => ({
221
232
  /**
222
233
  * Resolving the HQ Access credentials, and saying *why* when that fails.
223
234
  *
224
- * A deliberate command (`psf hq:flush`, and doctor's remote checks in #394) is
225
- * the one place allowed to ask the secret manager for the HQ Access token. It
226
- * used to keep a value-or-nothing answer, so a missing binary, a sandbox that
235
+ * A deliberate command (`psf hq:flush`, and doctor's remote checks in #394)
236
+ * asks the secret manager for the HQ Access token, and since #1313 so does an
237
+ * HQ emission on an operator machine, once per process
238
+ * (`resolveHqCredentialsOncePerProcess`). This used to be a
239
+ * value-or-nothing answer, so a missing binary, a sandbox that
227
240
  * cannot reach the keychain, and a mistyped reference all collapsed into one
228
241
  * generic "credentials are unavailable" line. A sandboxed lane read that as
229
242
  * environmental noise while every one of its proofs stayed spooled.
@@ -247,15 +260,16 @@ const spawnFailure = (error) => {
247
260
  if (code === "EACCES" || code === "EPERM") return "resolver-blocked";
248
261
  return "resolver-timeout";
249
262
  };
250
- const probe = (binary, reference, capture) => {
263
+ const probe = (binary, reference, capture, timeoutMs) => {
251
264
  const result = spawnSync(binary, ["read", reference], {
252
265
  encoding: "utf-8",
266
+ killSignal: "SIGKILL",
253
267
  stdio: [
254
268
  "ignore",
255
269
  capture ? "pipe" : "ignore",
256
270
  "ignore"
257
271
  ],
258
- timeout: SECRET_PROBE_TIMEOUT_MS
272
+ timeout: timeoutMs
259
273
  });
260
274
  if (result.error) return { status: spawnFailure(result.error) };
261
275
  if (result.signal) return { status: "resolver-timeout" };
@@ -276,20 +290,26 @@ const probe = (binary, reference, capture) => {
276
290
  * *informative* one: a machine without `op-fast` that has `op` installed and
277
291
  * denied must report the denial, not the missing binary.
278
292
  */
279
- const resolveThrough = (reference, capture) => {
293
+ const resolveThrough = (reference, capture, timeoutMs = SECRET_PROBE_TIMEOUT_MS) => {
280
294
  let failure = "resolver-missing";
281
295
  for (const binary of SECRET_RESOLVER_BINARIES) {
282
- const outcome = probe(binary, reference, capture);
296
+ const outcome = probe(binary, reference, capture, timeoutMs);
283
297
  if (outcome.status === "resolved") return outcome;
284
298
  if (failure === "resolver-missing") failure = outcome.status;
285
299
  }
286
300
  return { status: failure };
287
301
  };
288
- const defaultSecretReferenceResolver = (reference) => resolveThrough(reference, true);
289
302
  /**
290
- * Environment first (the #312 emit-time path), then the operator's configured
291
- * references. The first reference that fails ends the resolution: a second
292
- * probe would add nothing but another chance to hang.
303
+ * The capturing resolver with its own probe ceiling. Production uses
304
+ * `defaultSecretReferenceResolver`; a test passes a short ceiling to prove the
305
+ * probe really ends there.
306
+ */
307
+ const secretReferenceResolverWithin = (timeoutMs) => (reference) => resolveThrough(reference, true, timeoutMs);
308
+ const defaultSecretReferenceResolver = secretReferenceResolverWithin(SECRET_PROBE_TIMEOUT_MS);
309
+ /**
310
+ * Environment first, then the operator's configured references. The first
311
+ * reference that fails ends the resolution: a second probe would add nothing
312
+ * but another chance to hang.
293
313
  */
294
314
  const resolveHqCredentials = ({ env, references, resolve = defaultSecretReferenceResolver }) => {
295
315
  const clientId = env[CF_ACCESS_CLIENT_ID_ENV];
@@ -354,7 +374,7 @@ const hqIngestCredentialReferencesSchema = z.object({
354
374
  const factoryUserConfigSchema = z.object({
355
375
  githubApp: githubAppConfigSchema.optional(),
356
376
  hqAllowedOrigins: z.array(hqAllowedOriginSchema).describe("Operator-controlled exact HTTPS origins authorized to receive HQ credential-bearing requests; an absent or empty list authorizes none.").optional(),
357
- hqIngestCredentials: hqIngestCredentialReferencesSchema.describe("Operator-controlled secret-manager references for the HQ ingest Cloudflare Access service token. References only — the factory never reads, prints, exports, or caches the values.").optional(),
377
+ hqIngestCredentials: hqIngestCredentialReferencesSchema.describe("Operator-controlled secret-manager references for the HQ ingest Cloudflare Access service token. References only: the factory resolves them in memory when it sends HQ evidence, and never prints, exports, or writes the values.").optional(),
358
378
  schemaVersion: z.literal(FACTORY_USER_CONFIG_SCHEMA_VERSION)
359
379
  });
360
380
  function defaultUserConfigPath(env = process.env) {
@@ -368,14 +388,19 @@ function defaultUserConfigPath(env = process.env) {
368
388
  *
369
389
  * A request that carries the token goes only to an HTTPS origin with no
370
390
  * embedded credentials that the operator listed, exactly, in
371
- * `hqAllowedOrigins` in the operator user config. Committed repository data
372
- * and the environment can name an origin; only the operator can authorize one
373
- * (ADR 0015). HQ ingest and the Jev client both call this module, so the two
374
- * cannot drift apart: one protocol rule, one reader, one parser.
391
+ * `hqAllowedOrigins` in the operator user config or in
392
+ * `FACTORY_HQ_ALLOWED_ORIGINS` in the process environment. Those two lists
393
+ * are unioned. Committed repository data can name an origin; it cannot
394
+ * authorize one (ADR 0015). The environment variable is operator
395
+ * configuration, read only from the process environment passed in, never
396
+ * from the profile or any repository file. HQ ingest and the Jev client both
397
+ * call this module, so the two cannot drift apart: one protocol rule, one
398
+ * resolver.
375
399
  *
376
- * The reader is bounded. A config path that is a FIFO, a huge file, or a disk
377
- * that hangs answers "no origin is authorized" within the caller's deadline
378
- * instead of holding a gate or a lint run open.
400
+ * The file reader is bounded. A config path that is a FIFO, a huge file, or a
401
+ * disk that hangs contributes no config origin within the caller's deadline
402
+ * instead of holding a gate or a lint run open. The environment list does not
403
+ * touch the filesystem, so it still applies when the file does not answer.
379
404
  */
380
405
  /** Largest operator config or profile file the HQ readers will open. */
381
406
  const MAX_HQ_CONFIG_BYTES = 1024 * 1024;
@@ -447,20 +472,59 @@ const hqAllowedOriginsFromConfigText = (text) => {
447
472
  return [];
448
473
  }
449
474
  };
475
+ /** Operator allowlist supplied to a machine that has no user config file. */
476
+ const FACTORY_HQ_ALLOWED_ORIGINS_ENV = "FACTORY_HQ_ALLOWED_ORIGINS";
477
+ /**
478
+ * Exact HTTPS origins from `FACTORY_HQ_ALLOWED_ORIGINS` in `env`.
479
+ * Comma-separated. A segment that is not an exact origin is ignored.
480
+ * This reads that one property and nothing else: no file, no profile.
481
+ */
482
+ const hqAllowedOriginsFromEnv = (env) => {
483
+ let raw;
484
+ try {
485
+ raw = env[FACTORY_HQ_ALLOWED_ORIGINS_ENV];
486
+ } catch {
487
+ return [];
488
+ }
489
+ if (typeof raw !== "string" || raw.trim() === "") return [];
490
+ const origins = [];
491
+ for (const segment of raw.split(",")) {
492
+ const candidate = segment.trim();
493
+ if (candidate === "") continue;
494
+ const endpoint = validatedHqOrigin(candidate);
495
+ if (endpoint !== void 0 && candidate === endpoint.origin) origins.push(endpoint.origin);
496
+ }
497
+ return origins;
498
+ };
499
+ /**
500
+ * Operator-authorized HQ origins. `configOrigins` (the user config's
501
+ * `hqAllowedOrigins`) is unioned with `FACTORY_HQ_ALLOWED_ORIGINS` from
502
+ * `env`. The environment value never replaces the config list, and neither
503
+ * list is read from the profile or any repository file.
504
+ */
505
+ const resolveHqAllowedOrigins = (env, configOrigins) => {
506
+ const fromEnv = hqAllowedOriginsFromEnv(env);
507
+ if (fromEnv.length === 0) return [...configOrigins];
508
+ const allowed = [...configOrigins];
509
+ for (const origin of fromEnv) if (!allowed.includes(origin)) allowed.push(origin);
510
+ return allowed;
511
+ };
450
512
  /**
451
513
  * The operator's `hqAllowedOrigins`, read from the operator user config within
452
- * `deadline` (a `performance.now()` time). A missing, unreadable, oversized or
453
- * invalid file, or one that does not answer in time, authorizes nothing.
514
+ * `deadline` (a `performance.now()` time), unioned with
515
+ * `FACTORY_HQ_ALLOWED_ORIGINS`. A missing, unreadable, oversized or invalid
516
+ * file, or one that does not answer in time, contributes no config origin.
517
+ * The environment list still applies.
454
518
  */
455
519
  const loadHqAllowedOrigins = async (env, deadline) => {
456
- let configPath;
520
+ let configOrigins = [];
457
521
  try {
458
- configPath = defaultUserConfigPath(env);
522
+ const contents = await readBoundedTextFile(defaultUserConfigPath(env), deadline);
523
+ if (contents !== void 0) configOrigins = hqAllowedOriginsFromConfigText(contents);
459
524
  } catch {
460
- return [];
525
+ configOrigins = [];
461
526
  }
462
- const contents = await readBoundedTextFile(configPath, deadline);
463
- return contents === void 0 ? [] : hqAllowedOriginsFromConfigText(contents);
527
+ return resolveHqAllowedOrigins(env, configOrigins);
464
528
  };
465
529
  /**
466
530
  * The matching rule: the URL's origin must equal one listed origin exactly.
@@ -481,8 +545,8 @@ const isHqOriginAuthorized = (allowedOrigins, endpoint) => allowedOrigins.includ
481
545
  * anything else happens, by the same code HQ ingest uses
482
546
  * (`hq-origin-policy.ts`, ADR 0015): it must be an `https:` origin with no
483
547
  * embedded credentials, listed exactly in the operator's `hqAllowedOrigins`.
484
- * Only then are credentials read: `resolveHqCredentials` reads the hyphenated
485
- * `CF-Access-Client-Id` / `CF-Access-Client-Secret` pair, and
548
+ * Only then are credentials read: `resolveHqCredentials` reads the
549
+ * `CF_ACCESS_CLIENT_ID` / `CF_ACCESS_CLIENT_SECRET` pair, and
486
550
  * `buildCloudflareAccessRequestInit` attaches them to a request that refuses
487
551
  * redirects so the credentials cannot leave the authorized origin.
488
552
  *
@@ -647,11 +711,8 @@ const exchange = async (input) => {
647
711
  throw new JevRequestError(0, "exchange_failed", "The HQ Jev exchange failed before a response could be read. No upstream text is reported: it can quote the request.");
648
712
  }
649
713
  };
650
- /**
651
- * The whole remedy, in one sentence. The variable names are literally
652
- * hyphenated, so a plain `export` does not set them.
653
- */
654
- const MISSING_CREDENTIALS_REASON = `no HQ credentials: ${CF_ACCESS_CLIENT_ID_ENV} and ${CF_ACCESS_CLIENT_SECRET_ENV} are not both in the environment. The names are literally hyphenated, so a plain \`export\` does not set them — prefix the command: \`env '${CF_ACCESS_CLIENT_ID_ENV}=…' '${CF_ACCESS_CLIENT_SECRET_ENV}=…' <the command> …\`.`;
714
+ /** The whole remedy, in one sentence. */
715
+ const MISSING_CREDENTIALS_REASON = `no HQ credentials: ${CF_ACCESS_CLIENT_ID_ENV} and ${CF_ACCESS_CLIENT_SECRET_ENV} are not both in the environment. Set both, or prefix the command: \`env '${CF_ACCESS_CLIENT_ID_ENV}=…' '${CF_ACCESS_CLIENT_SECRET_ENV}=…' <the command> …\`.`;
655
716
  /** Where the operator config was looked for, for a message that names it. */
656
717
  const operatorConfigLocation = (env) => {
657
718
  try {