@indigoai-us/hq-cli 5.342.1 → 5.342.2

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/CHANGELOG.md CHANGED
@@ -2,6 +2,9 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.342.2] — 2026-10-04
6
+ - Load the shared `secrets env` action without importing the full secrets command module on the enabled fast route.
7
+
5
8
  ## [5.342.1] — 2026-10-04
6
9
  - `hq lanes` now continues envelope numbering beyond 999 and records unexpected reconcile admission failures as a distinct queued reason with error details.
7
10
  - Reconcile skips configured Codex homes absent on the current host and reports a note; other filesystem errors remain visible as store errors. `--dry-run` no longer compresses rollouts or advances the retention schedule.
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=secrets-public-types.test-d.d.ts.map
@@ -0,0 +1,18 @@
1
+ const scriptUsage = {
2
+ scriptId: "fixture-script",
3
+ path: "scripts/example.sh",
4
+ sha256: "0".repeat(64),
5
+ attestationLevel: "verified",
6
+ };
7
+ const metadata = {
8
+ tier: "standard",
9
+ cacheTtlMs: 1_000,
10
+ };
11
+ const loadedSecret = {
12
+ ...metadata,
13
+ name: "FIXTURE_NAME",
14
+ value: "fixture-only",
15
+ };
16
+ void [scriptUsage, metadata, loadedSecret];
17
+ export {};
18
+ //# sourceMappingURL=secrets-public-types.test-d.js.map
@@ -1,5 +1,7 @@
1
1
  import type { Command } from "commander";
2
- import { writeSync } from "node:fs";
2
+ export { buildSecretUsage, emitEnvExportsAndExit, loadRevealedSecrets, parseSecretNameList, runSecretsEnvCommand, } from "../secrets-env-action.js";
3
+ export type { SecretLoadResponse, SecretScriptLockMode, SecretTier, SecretUsage, SecretUsageChannel } from "../secrets-env-action.js";
4
+ export type { SecretScriptUsage, SecretMetadata, SecretLoadSuccessRow } from "../secrets-env-action.js";
3
5
  import { vaultApiFetch, getCompanyUid, getEntityUid } from "../utils/vault-api.js";
4
6
  export type { VaultApiOptions } from "../utils/vault-api.js";
5
7
  export { vaultApiFetch, getCompanyUid, getEntityUid };
@@ -10,42 +12,6 @@ export { vaultApiFetch, getCompanyUid, getEntityUid };
10
12
  */
11
13
  export declare const HQ_PRO_RESOURCE_SURFACES: readonly ["api", "bot_invite", "bot_join_now", "calendar_auto_schedule", "calendar_preferences", "cli_secrets_set", "console_secrets_form", "deploy_precheck", "external_connection", "factory_install", "integrations_connect", "oauth_callback", "plan_limit_check", "secrets_input_link", "web"];
12
14
  export declare const CLI_SECRETS_SET_SURFACE = "cli_secrets_set";
13
- export type SecretTier = "standard" | "sensitive" | "nuclear";
14
- export type SecretScriptLockMode = "off" | "enforced";
15
- export type SecretUsageChannel = "run" | "exec" | "env" | "sandbox" | "reveal" | "submit-link";
16
- export interface SecretScriptUsage {
17
- scriptId: string;
18
- path: string;
19
- sha256: string;
20
- attestationLevel: string;
21
- }
22
- export interface SecretUsage {
23
- channel: SecretUsageChannel;
24
- reason?: string;
25
- script?: SecretScriptUsage;
26
- }
27
- export interface SecretMetadata {
28
- tier?: SecretTier;
29
- scriptLock?: {
30
- mode?: SecretScriptLockMode;
31
- requiredAttestation?: string;
32
- };
33
- cacheTtlMs?: number;
34
- revokedAt?: string | null;
35
- }
36
- export interface SecretLoadSuccessRow extends SecretMetadata {
37
- name: string;
38
- value?: string;
39
- version?: number;
40
- }
41
- export interface SecretLoadResponse {
42
- secrets: SecretLoadSuccessRow[];
43
- errors: Array<{
44
- name: string;
45
- code: string;
46
- message?: string;
47
- }>;
48
- }
49
15
  export interface SecretInjectionRecipe {
50
16
  header: string;
51
17
  scheme: "raw" | "bearer";
@@ -60,48 +26,5 @@ export declare function parseDestinationUrl(raw: string): {
60
26
  };
61
27
  export declare function collectSecretNames(value: string, previous?: string[]): string[];
62
28
  export declare function scrubSandboxOutput(text: string, secretNames?: string[]): string;
63
- export declare function loadRevealedSecrets(token: string, companyUid: string, keys: string[], usage?: SecretUsage, options?: {
64
- allowOfflineFallback?: boolean;
65
- }): Promise<Map<string, string>>;
66
- /**
67
- * Emit `hq secrets env` output and terminate deterministically.
68
- *
69
- * The process MUST exit even when the consuming pipe has gone away. The
70
- * orphaned-process leak (personal/projects/hq-secrets-process-leak) was caused
71
- * by relying on `process.stdout.write(payload, () => process.exit(0))`: when the
72
- * parent shell reading `source <(hq secrets env …)` is killed on timeout, the
73
- * stdout pipe loses its reader, the write callback never fires, `process.exit(0)`
74
- * is never reached, and — because `env` forces no other exit — the process parks
75
- * in `epoll_pwait(-1)` forever, reparented to PID 1. Hundreds accumulated this
76
- * way, exhausting swap and driving load into triple digits.
77
- *
78
- * Writing synchronously to fd 1 and then exiting unconditionally removes every
79
- * dependency on the event loop draining and on the reader still being attached:
80
- * - a live reader receives the export lines (synchronous write reaches the
81
- * kernel pipe buffer before we exit, so nothing is truncated);
82
- * - a dead reader surfaces as EPIPE, which we swallow — the secret is already
83
- * resolved and there is nothing left to deliver, so a clean exit is correct;
84
- * - EAGAIN (stdout in non-blocking mode with a momentarily full buffer) is
85
- * retried a bounded number of times. `env` payloads are a few short export
86
- * lines, far under the 64 KiB pipe buffer, so this effectively never spins.
87
- *
88
- * `exit` and `write` are injectable for tests; production uses the real ones.
89
- */
90
- export declare function emitEnvExportsAndExit(payload: string, exit?: (code: number) => never, write?: typeof writeSync): never;
91
- /**
92
- * Shared `secrets env` action for Commander and the default-off lightweight
93
- * entrypoint. The fast entrypoint still runs the same credential, usage-audit,
94
- * server authorization, cache-fallback, redaction, and output code.
95
- */
96
- export declare function runSecretsEnvCommand(opts: {
97
- only: string[];
98
- script?: string;
99
- scriptId?: string;
100
- }, parentScope: {
101
- company?: string;
102
- personal?: boolean;
103
- }, options?: {
104
- fallbackOnAuthFailure?: boolean;
105
- }): Promise<"handled" | "fallback">;
106
29
  export declare function registerSecretsCommand(program: Command): void;
107
30
  //# sourceMappingURL=secrets.d.ts.map
@@ -1,16 +1,13 @@
1
+ import { buildSecretUsage, extractApiMessage, highSecuritySandboxOnlyMessage, loadRevealedSecrets, normalizeScriptLockMode, normalizeSecretTier, parseSecretNameList, runSecretsEnvCommand, scopeOpts, secretsCredentialOptions, } from "../secrets-env-action.js";
2
+ export { buildSecretUsage, emitEnvExportsAndExit, loadRevealedSecrets, parseSecretNameList, runSecretsEnvCommand, } from "../secrets-env-action.js";
1
3
  import chalk from "chalk";
2
- import { writeSync } from "node:fs";
3
- import * as nodePath from "node:path";
4
- import { cognitoCachePrincipalUid, ensureCognitoToken } from "../utils/cognito-session.js";
5
- import { isAuthError } from "../utils/auth-error.js";
6
- import { DEFAULT_SECRETS_CACHE_TTL_MS, hasCacheEntry, readCache, writeCache, removeCacheEntry, clearAllCache, } from "../utils/secrets-cache.js";
7
- import { computeSha256 } from "../utils/integrity.js";
4
+ import { ensureCognitoToken } from "../utils/cognito-session.js";
5
+ import { removeCacheEntry, clearAllCache } from "../utils/secrets-cache.js";
8
6
  import { isPlanGateError } from "../utils/plan-gate-error.js";
9
7
  import { SECRET_NAME_PATTERN, GROUP_ID_PATTERN, EMAIL_PATTERN } from "./_patterns.js";
10
8
  import { describeSecretsScope, formatSecretSaved, formatSecretsListEmpty, formatSecretsListHeader, } from "./secrets-scope.js";
11
9
  import { vaultApiFetch, getCompanyUid, getEntityUid, looksLikeCompanyUid, } from "../utils/vault-api.js";
12
- import { HQ_API_KEY_PREFIX, formatApiKeyCapabilityDenial, requireApiKeyCapability, resolveVaultCredentialForCapability, } from "../utils/resolve-vault-credential.js";
13
- import { CACHED_SECRETS_SERVER_UNAVAILABLE_MESSAGE, isSecretLoadUnavailable, isUnavailableSecretLoadHttpStatus, SecretLoadUnavailableError, } from "../utils/secret-load-availability.js";
10
+ import { formatApiKeyCapabilityDenial, requireApiKeyCapability, resolveVaultCredentialForCapability, } from "../utils/resolve-vault-credential.js";
14
11
  export { vaultApiFetch, getCompanyUid, getEntityUid };
15
12
  /**
16
13
  * Mirrors `RESOURCE_SURFACES` in hq-pro
@@ -45,34 +42,12 @@ async function requireCognitoTokenForSecrets(commandLabel, capability = null) {
45
42
  requireApiKeyCapability(capability, commandLabel, { routeAvailable: false });
46
43
  return ensureCognitoToken(secretsCredentialOptions());
47
44
  }
48
- /**
49
- * A browser login is safe only when a person can answer it. This mirrors the
50
- * repository's existing terminal convention in `canOfferTeamUpgrade`: both
51
- * stdin and stderr must be TTYs, and CI is always non-interactive.
52
- */
53
- function secretsCredentialOptions() {
54
- return {
55
- interactive: process.stdin.isTTY === true &&
56
- process.stderr.isTTY === true &&
57
- !process.env.CI,
58
- };
59
- }
60
- function scopeOpts(opts) {
61
- if (opts.personal && opts.company) {
62
- console.error(chalk.red("Error: --personal cannot be combined with --company."));
63
- process.exit(1);
64
- }
65
- return { personal: !!opts.personal, companySlug: opts.company };
66
- }
67
45
  function rejectIfPersonal(opts, action) {
68
46
  if (opts.personal) {
69
47
  console.error(chalk.red(`Error: ${action} is not supported with --personal.`));
70
48
  process.exit(1);
71
49
  }
72
50
  }
73
- function shellSingleQuote(value) {
74
- return "'" + value.replace(/'/g, "'\\''") + "'";
75
- }
76
51
  // API Gateway v2 URL-decodes `%2F` before route matching, so a full-name
77
52
  // `encodeURIComponent` would collapse structural `/` separators into a single
78
53
  // segment the `/name/{proxy+}` route can't match.
@@ -245,17 +220,6 @@ function promptSecretInteractively() {
245
220
  process.stdin.resume();
246
221
  });
247
222
  }
248
- // Maximum names per batch-load request. Mirrors hq-pro's MAX_BATCH_NAMES so a
249
- // large --only list is chunked client-side rather than 400'd whole by the
250
- // server (the legacy per-key GET path had no such cap).
251
- const MAX_BATCH_NAMES = 100;
252
- const SECRET_LOAD_TIMEOUT_MS = 30_000;
253
- function highSecuritySandboxOnlyMessage(secretName) {
254
- const sandboxExample = secretName
255
- ? `hq secrets sandbox --only ${secretName} -- <command>`
256
- : "hq secrets sandbox --only <secret> -- <command>";
257
- return `High-security secrets can run only in the sandbox (use \`${sandboxExample}\`) or through the HQ secret proxy. Local injection and script locks (\`--script\`) are not supported.`;
258
- }
259
223
  function parseSecretAclPrincipal(principal) {
260
224
  const p = principal.trim();
261
225
  if (p === "@all") {
@@ -352,53 +316,6 @@ export function parseDestinationUrl(raw) {
352
316
  }
353
317
  return { ok: true, url: `https://${parsed.hostname}`, hostname: parsed.hostname };
354
318
  }
355
- function normalizeSecretTier(tier) {
356
- return tier === "sensitive" || tier === "nuclear" ? tier : "standard";
357
- }
358
- function normalizeScriptLockMode(mode) {
359
- return mode === "enforced" ? "enforced" : "off";
360
- }
361
- function normalizeCacheTtlMs(metadata) {
362
- // Controlled rows must never reach the offline cache even if a mixed-version
363
- // or malformed response carries a positive TTL. The server is authoritative
364
- // for access, but the client still has enough policy metadata to fail safe.
365
- if (metadata.tier === "sensitive" ||
366
- metadata.tier === "nuclear" ||
367
- metadata.scriptLock?.mode === "enforced") {
368
- return 0;
369
- }
370
- if (metadata.cacheTtlMs === undefined) {
371
- return DEFAULT_SECRETS_CACHE_TTL_MS;
372
- }
373
- return typeof metadata.cacheTtlMs === "number" &&
374
- Number.isFinite(metadata.cacheTtlMs) &&
375
- metadata.cacheTtlMs > 0
376
- ? metadata.cacheTtlMs
377
- : 0;
378
- }
379
- function extractApiMessage(body, fallback) {
380
- const message = typeof body.message === "string" ? body.message : undefined;
381
- const error = typeof body.error === "string" ? body.error : undefined;
382
- return message ?? error ?? fallback;
383
- }
384
- async function buildSecretUsage(channel, scriptPath, scriptId, attestationLevel = "self-asserted-hash") {
385
- if (scriptId && !scriptPath) {
386
- throw new Error("--script-id requires --script");
387
- }
388
- if (!scriptPath) {
389
- return { channel };
390
- }
391
- const resolvedPath = nodePath.resolve(scriptPath);
392
- return {
393
- channel,
394
- script: {
395
- scriptId: scriptId ?? resolvedPath,
396
- path: resolvedPath,
397
- sha256: await computeSha256(resolvedPath),
398
- attestationLevel,
399
- },
400
- };
401
- }
402
319
  // Commander collector for `--only`: accumulates across REPEATED flags instead
403
320
  // of the last one silently winning, and each value may still be
404
321
  // comma-separated. So `--only A,B`, `--only A --only B`, and
@@ -407,20 +324,6 @@ export function collectSecretNames(value, previous) {
407
324
  const parsed = value.split(",").map((k) => k.trim()).filter(Boolean);
408
325
  return (previous ?? []).concat(parsed);
409
326
  }
410
- function parseSecretNameList(input) {
411
- const keys = input ?? [];
412
- if (keys.length === 0) {
413
- console.error(chalk.red("Error: --only requires at least one secret name."));
414
- process.exit(1);
415
- }
416
- for (const key of keys) {
417
- if (!SECRET_NAME_PATTERN.test(key)) {
418
- console.error(chalk.red(`Invalid secret name '${key}': must match ^[A-Z][A-Z0-9_]*(/[A-Z][A-Z0-9_]+)*$`));
419
- process.exit(1);
420
- }
421
- }
422
- return keys;
423
- }
424
327
  function mergeScopeOpts(parent, child) {
425
328
  return {
426
329
  company: child.company ?? parent.company,
@@ -520,362 +423,6 @@ function renderPolicyScripts(scripts) {
520
423
  ].join(" "));
521
424
  }
522
425
  }
523
- // HQ-4H — load + decrypt secrets through the BATCH-LOAD endpoint
524
- // (`POST /secrets/{companyUid}/load`) instead of one single-secret GET per key.
525
- //
526
- // Every single-secret GET that 404s makes vault-service emit an unattributed
527
- // "Secret not found" Sentry *warning* — each `response(404, …)` is captured at
528
- // the response boundary (hq-pro `_error-response.ts`). That capture is the
529
- // right signal for `hq secrets get <name>` ("a caller genuinely needed THIS
530
- // secret and it was missing"), but it is pure noise for a multi-key load where
531
- // a missing / renamed / rotated key is a per-key outcome `exec`/`env` already
532
- // report cleanly to the user. The batch-load endpoint returns 200 with a
533
- // per-name `errors[]` array and never captures — the same path `hq run` uses,
534
- // which is exactly why `hq run` never contributed to HQ-4H. Routing `exec` and
535
- // `env` through it stops those callers from emitting the warning while keeping
536
- // identical UX and error text.
537
- //
538
- // Every value use through exec/env/reveal is server-authorized when online,
539
- // including when an encrypted disk-cache entry exists. Only explicitly opted-in
540
- // exec/env callers may use principal-bound values during classified outages;
541
- // install authorization and reveal remain online-only. This means a policy,
542
- // ACL, tier, or script-approval change takes effect on the next online use.
543
- // Requests are chunked at MAX_BATCH_NAMES and throw on the FIRST unresolved key
544
- // with the same `Failed to fetch secret '<k>': <reason>` shape the per-key GET
545
- // path used — never swallows a failure.
546
- async function loadRevealedSecretsViaApiKey(token, keys) {
547
- const resolved = new Map();
548
- const requested = [...new Set(keys)];
549
- const cacheScope = "__api_key__";
550
- for (const name of requested) {
551
- const res = await vaultApiFetch({
552
- token,
553
- path: "/v1/keys/secrets/fetch",
554
- method: "POST",
555
- body: { name },
556
- signal: AbortSignal.timeout(SECRET_LOAD_TIMEOUT_MS),
557
- });
558
- const body = (await res.json().catch(() => ({})));
559
- if (!res.ok) {
560
- if (res.status === 404) {
561
- throw new Error(`Failed to fetch secret '${name}': Secret not found`);
562
- }
563
- if (res.status === 403) {
564
- const message = typeof body.error === "string"
565
- ? body.error
566
- : typeof body.message === "string"
567
- ? body.message
568
- : "No read permission";
569
- if (body.highSecurity === true) {
570
- throw new Error(highSecuritySandboxOnlyMessage(name));
571
- }
572
- throw new Error(`Failed to fetch secret '${name}': ${message}`);
573
- }
574
- if (res.status === 401) {
575
- throw new Error(`Failed to fetch secret '${name}': Invalid or missing API key`);
576
- }
577
- throw new Error(`Failed to fetch secret '${name}': ${extractApiMessage(body, res.statusText)}`);
578
- }
579
- const secret = typeof body.secret === "object" && body.secret !== null
580
- ? body.secret
581
- : null;
582
- if (typeof secret?.value !== "string") {
583
- throw new Error(`Failed to fetch secret '${name}': malformed fetch response`);
584
- }
585
- removeCacheEntry(cacheScope, name);
586
- resolved.set(name, secret.value);
587
- }
588
- return resolved;
589
- }
590
- export async function loadRevealedSecrets(token, companyUid, keys, usage, options = {}) {
591
- if (token.startsWith(HQ_API_KEY_PREFIX)) {
592
- return loadRevealedSecretsViaApiKey(token, keys);
593
- }
594
- const resolved = new Map();
595
- const requested = [...new Set(keys)];
596
- const principalUid = cognitoCachePrincipalUid(token);
597
- const cachedCandidates = new Set(principalUid
598
- ? requested.filter((name) => hasCacheEntry(companyUid, name, principalUid))
599
- : []);
600
- try {
601
- // The batch endpoint is the only non-capturing authorization path that also
602
- // evaluates the supplied usage metadata. It covers warm and cold names in
603
- // bounded requests, then a warm name may use its locally authenticated
604
- // plaintext only when the returned version and current policy TTL agree.
605
- for (let i = 0; i < requested.length; i += MAX_BATCH_NAMES) {
606
- const chunk = requested.slice(i, i + MAX_BATCH_NAMES);
607
- const chunkNames = new Set(chunk);
608
- const res = await vaultApiFetch({
609
- token,
610
- path: `/secrets/${encodeURIComponent(companyUid)}/load`,
611
- method: "POST",
612
- body: usage ? { names: chunk, usage } : { names: chunk },
613
- signal: AbortSignal.timeout(SECRET_LOAD_TIMEOUT_MS),
614
- });
615
- if (!res.ok) {
616
- const body = (await res.json().catch(() => ({})));
617
- const message = extractApiMessage(body, res.statusText);
618
- // High-security refusal surfaced at the batch level (rather than
619
- // per-name): point the caller at the sandbox/proxy and never leak
620
- // plaintext. A local script attestation is not an override.
621
- if (body.code === "high_security_denied" ||
622
- body.code === "high_security_sandbox_only" ||
623
- body.highSecurity === true) {
624
- throw new Error(highSecuritySandboxOnlyMessage());
625
- }
626
- if (isUnavailableSecretLoadHttpStatus(res.status)) {
627
- throw new SecretLoadUnavailableError(`Failed to batch-load secrets: ${message}`);
628
- }
629
- if (res.status >= 400 &&
630
- res.status < 500 &&
631
- typeof body.code === "string") {
632
- throw new Error(message);
633
- }
634
- if (res.status === 403) {
635
- throw new Error(`Failed to fetch secret '${chunk[0]}': No read permission`);
636
- }
637
- throw new Error(`Failed to batch-load secrets: ${message}`);
638
- }
639
- const data = (await res.json());
640
- if (!Array.isArray(data.secrets) || !Array.isArray(data.errors)) {
641
- throw new Error("Invalid secret load response from vault");
642
- }
643
- const errorsByName = new Map();
644
- const seenNames = new Set();
645
- for (const rawSecret of data.secrets) {
646
- if (!rawSecret || typeof rawSecret !== "object") {
647
- throw new Error("Invalid secret load response from vault");
648
- }
649
- const s = rawSecret;
650
- if (typeof s.name !== "string" ||
651
- !chunkNames.has(s.name) ||
652
- seenNames.has(s.name) ||
653
- (s.value != null && typeof s.value !== "string")) {
654
- throw new Error("Invalid secret load response from vault");
655
- }
656
- seenNames.add(s.name);
657
- if (s.revokedAt != null) {
658
- errorsByName.set(s.name, {
659
- code: "revoked",
660
- message: `Secret '${s.name}' has been revoked`,
661
- });
662
- removeCacheEntry(companyUid, s.name);
663
- continue;
664
- }
665
- if (s.value == null) {
666
- errorsByName.set(s.name, {
667
- code: "not_returned",
668
- message: `Secret '${s.name}' has no value (reveal may not be permitted).`,
669
- });
670
- removeCacheEntry(companyUid, s.name);
671
- continue;
672
- }
673
- const cacheTtlMs = normalizeCacheTtlMs(s);
674
- const cached = cachedCandidates.has(s.name) &&
675
- cacheTtlMs > 0 &&
676
- typeof s.version === "number" &&
677
- Number.isSafeInteger(s.version) &&
678
- s.version > 0
679
- ? readCache(companyUid, s.name, principalUid, s.version, cacheTtlMs)
680
- : null;
681
- if (cached !== null) {
682
- resolved.set(s.name, cached);
683
- continue;
684
- }
685
- if (cacheTtlMs > 0) {
686
- if (typeof s.version === "number" &&
687
- Number.isSafeInteger(s.version) &&
688
- s.version > 0) {
689
- if (principalUid) {
690
- writeCache(companyUid, s.name, principalUid, s.value, cacheTtlMs, s.version);
691
- }
692
- }
693
- else if (principalUid) {
694
- writeCache(companyUid, s.name, principalUid, s.value, cacheTtlMs);
695
- }
696
- }
697
- else {
698
- removeCacheEntry(companyUid, s.name);
699
- }
700
- resolved.set(s.name, s.value);
701
- }
702
- for (const rawError of data.errors) {
703
- if (!rawError || typeof rawError !== "object") {
704
- throw new Error("Invalid secret load response from vault");
705
- }
706
- const e = rawError;
707
- if (typeof e.name !== "string" ||
708
- !chunkNames.has(e.name) ||
709
- seenNames.has(e.name) ||
710
- typeof e.code !== "string" ||
711
- (e.message !== undefined && typeof e.message !== "string")) {
712
- throw new Error("Invalid secret load response from vault");
713
- }
714
- seenNames.add(e.name);
715
- errorsByName.set(e.name, { code: e.code, message: e.message });
716
- resolved.delete(e.name);
717
- removeCacheEntry(companyUid, e.name);
718
- }
719
- // Any requested key in this chunk the server did not return is a per-key
720
- // failure — surface it with the same prefix the single-GET path used so
721
- // callers (and scripts grepping stderr) see no behavior change.
722
- let firstFailure = null;
723
- for (const key of chunk) {
724
- if (resolved.has(key))
725
- continue;
726
- removeCacheEntry(companyUid, key);
727
- const err = errorsByName.get(key);
728
- // Every caller of loadRevealedSecrets injects or prints the plaintext
729
- // locally (`secrets get --reveal`, `secrets exec`, `secrets env`). Both
730
- // high-security denial shapes therefore receive the same explicit
731
- // sandbox/proxy guidance, even if the caller supplied `--script`.
732
- if (err?.code === "high_security_denied" ||
733
- err?.code === "high_security_sandbox_only") {
734
- firstFailure ??= new Error(highSecuritySandboxOnlyMessage(key));
735
- continue;
736
- }
737
- const reason = err?.code === "not_found"
738
- ? "Secret not found"
739
- : err?.code === "forbidden"
740
- ? err.message ?? "No read permission"
741
- : err?.message ?? err?.code ?? "not returned by vault";
742
- firstFailure ??= new Error(`Failed to fetch secret '${key}': ${reason}`);
743
- }
744
- if (firstFailure) {
745
- throw firstFailure;
746
- }
747
- }
748
- return resolved;
749
- }
750
- catch (err) {
751
- if (isSecretLoadUnavailable(err) && options.allowOfflineFallback === true) {
752
- const offline = new Map();
753
- for (const key of requested) {
754
- const cached = cachedCandidates.has(key)
755
- ? readCache(companyUid, key, principalUid)
756
- : null;
757
- if (cached === null) {
758
- // Keep the same failure behavior for a cache miss, while leaving any
759
- // still-valid entries untouched for a later retry.
760
- throw err;
761
- }
762
- offline.set(key, cached);
763
- }
764
- console.error(CACHED_SECRETS_SERVER_UNAVAILABLE_MESSAGE);
765
- return offline;
766
- }
767
- if (isSecretLoadUnavailable(err)) {
768
- throw err;
769
- }
770
- // A transport failure, stale session, malformed response, or a later chunk
771
- // failure is an explicit denial or unusable response. Evict the whole
772
- // request before failing closed. Transient no-answer/5xx/429 failures above
773
- // preserve entries and only use values still valid under their stored TTL.
774
- for (const key of requested) {
775
- removeCacheEntry(companyUid, key);
776
- }
777
- throw err;
778
- }
779
- }
780
- /**
781
- * Emit `hq secrets env` output and terminate deterministically.
782
- *
783
- * The process MUST exit even when the consuming pipe has gone away. The
784
- * orphaned-process leak (personal/projects/hq-secrets-process-leak) was caused
785
- * by relying on `process.stdout.write(payload, () => process.exit(0))`: when the
786
- * parent shell reading `source <(hq secrets env …)` is killed on timeout, the
787
- * stdout pipe loses its reader, the write callback never fires, `process.exit(0)`
788
- * is never reached, and — because `env` forces no other exit — the process parks
789
- * in `epoll_pwait(-1)` forever, reparented to PID 1. Hundreds accumulated this
790
- * way, exhausting swap and driving load into triple digits.
791
- *
792
- * Writing synchronously to fd 1 and then exiting unconditionally removes every
793
- * dependency on the event loop draining and on the reader still being attached:
794
- * - a live reader receives the export lines (synchronous write reaches the
795
- * kernel pipe buffer before we exit, so nothing is truncated);
796
- * - a dead reader surfaces as EPIPE, which we swallow — the secret is already
797
- * resolved and there is nothing left to deliver, so a clean exit is correct;
798
- * - EAGAIN (stdout in non-blocking mode with a momentarily full buffer) is
799
- * retried a bounded number of times. `env` payloads are a few short export
800
- * lines, far under the 64 KiB pipe buffer, so this effectively never spins.
801
- *
802
- * `exit` and `write` are injectable for tests; production uses the real ones.
803
- */
804
- export function emitEnvExportsAndExit(payload, exit = process.exit, write = writeSync) {
805
- const buf = Buffer.from(payload, "utf8");
806
- const MAX_EAGAIN_RETRIES = 10_000;
807
- let offset = 0;
808
- let eagainRetries = 0;
809
- while (offset < buf.length) {
810
- try {
811
- offset += write(1, buf, offset, buf.length - offset);
812
- }
813
- catch (err) {
814
- const code = err.code;
815
- if (code === "EAGAIN" && eagainRetries++ < MAX_EAGAIN_RETRIES)
816
- continue;
817
- // EPIPE (reader gone) or retries exhausted: the output cannot be
818
- // delivered, but the process must never hang. Stop and exit cleanly.
819
- break;
820
- }
821
- }
822
- return exit(0);
823
- }
824
- /**
825
- * Shared `secrets env` action for Commander and the default-off lightweight
826
- * entrypoint. The fast entrypoint still runs the same credential, usage-audit,
827
- * server authorization, cache-fallback, redaction, and output code.
828
- */
829
- export async function runSecretsEnvCommand(opts, parentScope, options = {}) {
830
- let secretLoadStarted = false;
831
- try {
832
- const redact = process.stdout.isTTY;
833
- if (redact) {
834
- console.error(chalk.yellow("stdout is a terminal — values redacted. Use: source <(hq secrets env --only KEY1,KEY2)"));
835
- }
836
- const keys = parseSecretNameList(opts.only);
837
- const cred = await resolveVaultCredentialForCapability("secrets:read", "secrets env", secretsCredentialOptions());
838
- const companyUid = cred.kind === "api-key"
839
- ? "__api_key__"
840
- : await getEntityUid(cred.token, scopeOpts(parentScope), {
841
- allowOfflineScopeFallback: true,
842
- });
843
- const usage = cred.kind === "cognito"
844
- ? await buildSecretUsage("env", opts.script, opts.scriptId)
845
- : undefined;
846
- secretLoadStarted = true;
847
- const revealed = await loadRevealedSecrets(cred.token, companyUid, keys, usage, { allowOfflineFallback: true });
848
- let payload = "";
849
- for (const key of keys) {
850
- const value = revealed.get(key);
851
- // loadRevealedSecrets throws on any unresolved key, so a miss here is
852
- // unreachable — guard rather than print an `undefined` export line.
853
- if (value === undefined) {
854
- throw new Error(`Failed to fetch secret '${key}': not returned by vault`);
855
- }
856
- const out = redact ? "[REDACTED]" : value;
857
- payload += `export ${key}=${shellSingleQuote(out)}\n`;
858
- }
859
- // Write synchronously and exit unconditionally — never depend on the
860
- // event loop draining or the stdout write callback firing. See
861
- // emitEnvExportsAndExit for the full rationale (orphaned-process leak).
862
- emitEnvExportsAndExit(payload);
863
- return "handled";
864
- }
865
- catch (err) {
866
- if (options.fallbackOnAuthFailure &&
867
- !secretLoadStarted &&
868
- (isAuthError(err) ||
869
- (err instanceof Error &&
870
- (/^No valid HQ session\b/.test(err.message) ||
871
- /^HQ_API_KEY must start with /.test(err.message))))) {
872
- return "fallback";
873
- }
874
- console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
875
- process.exit(1);
876
- return "handled";
877
- }
878
- }
879
426
  export function registerSecretsCommand(program) {
880
427
  const secrets = program
881
428
  .command("secrets")
@@ -0,0 +1,103 @@
1
+ import { writeSync } from "node:fs";
2
+ /**
3
+ * A browser login is safe only when a person can answer it. This mirrors the
4
+ * repository's existing terminal convention in `canOfferTeamUpgrade`: both
5
+ * stdin and stderr must be TTYs, and CI is always non-interactive.
6
+ */
7
+ export declare function secretsCredentialOptions(): {
8
+ interactive: boolean;
9
+ };
10
+ export interface SecretsScopeOpts {
11
+ company?: string;
12
+ personal?: boolean;
13
+ }
14
+ export declare function scopeOpts(opts: SecretsScopeOpts): {
15
+ personal: boolean;
16
+ companySlug: string | undefined;
17
+ };
18
+ export type SecretTier = "standard" | "sensitive" | "nuclear";
19
+ export type SecretScriptLockMode = "off" | "enforced";
20
+ export type SecretUsageChannel = "run" | "exec" | "env" | "sandbox" | "reveal" | "submit-link";
21
+ export interface SecretScriptUsage {
22
+ scriptId: string;
23
+ path: string;
24
+ sha256: string;
25
+ attestationLevel: string;
26
+ }
27
+ export interface SecretUsage {
28
+ channel: SecretUsageChannel;
29
+ reason?: string;
30
+ script?: SecretScriptUsage;
31
+ }
32
+ export interface SecretMetadata {
33
+ tier?: SecretTier;
34
+ scriptLock?: {
35
+ mode?: SecretScriptLockMode;
36
+ requiredAttestation?: string;
37
+ };
38
+ cacheTtlMs?: number;
39
+ revokedAt?: string | null;
40
+ }
41
+ export interface SecretLoadSuccessRow extends SecretMetadata {
42
+ name: string;
43
+ value?: string;
44
+ version?: number;
45
+ }
46
+ export interface SecretLoadResponse {
47
+ secrets: SecretLoadSuccessRow[];
48
+ errors: Array<{
49
+ name: string;
50
+ code: string;
51
+ message?: string;
52
+ }>;
53
+ }
54
+ export declare function highSecuritySandboxOnlyMessage(secretName?: string): string;
55
+ export declare function normalizeSecretTier(tier?: string): SecretTier;
56
+ export declare function normalizeScriptLockMode(mode?: string): SecretScriptLockMode;
57
+ export declare function extractApiMessage(body: Record<string, unknown>, fallback: string): string;
58
+ export declare function buildSecretUsage(channel: SecretUsageChannel, scriptPath?: string, scriptId?: string, attestationLevel?: string): Promise<SecretUsage>;
59
+ export declare function parseSecretNameList(input: string[] | undefined): string[];
60
+ export declare function loadRevealedSecrets(token: string, companyUid: string, keys: string[], usage?: SecretUsage, options?: {
61
+ allowOfflineFallback?: boolean;
62
+ }): Promise<Map<string, string>>;
63
+ /**
64
+ * Emit `hq secrets env` output and terminate deterministically.
65
+ *
66
+ * The process MUST exit even when the consuming pipe has gone away. The
67
+ * orphaned-process leak (personal/projects/hq-secrets-process-leak) was caused
68
+ * by relying on `process.stdout.write(payload, () => process.exit(0))`: when the
69
+ * parent shell reading `source <(hq secrets env …)` is killed on timeout, the
70
+ * stdout pipe loses its reader, the write callback never fires, `process.exit(0)`
71
+ * is never reached, and — because `env` forces no other exit — the process parks
72
+ * in `epoll_pwait(-1)` forever, reparented to PID 1. Hundreds accumulated this
73
+ * way, exhausting swap and driving load into triple digits.
74
+ *
75
+ * Writing synchronously to fd 1 and then exiting unconditionally removes every
76
+ * dependency on the event loop draining and on the reader still being attached:
77
+ * - a live reader receives the export lines (synchronous write reaches the
78
+ * kernel pipe buffer before we exit, so nothing is truncated);
79
+ * - a dead reader surfaces as EPIPE, which we swallow — the secret is already
80
+ * resolved and there is nothing left to deliver, so a clean exit is correct;
81
+ * - EAGAIN (stdout in non-blocking mode with a momentarily full buffer) is
82
+ * retried a bounded number of times. `env` payloads are a few short export
83
+ * lines, far under the 64 KiB pipe buffer, so this effectively never spins.
84
+ *
85
+ * `exit` and `write` are injectable for tests; production uses the real ones.
86
+ */
87
+ export declare function emitEnvExportsAndExit(payload: string, exit?: (code: number) => never, write?: typeof writeSync): never;
88
+ /**
89
+ * Shared `secrets env` action for Commander and the default-off lightweight
90
+ * entrypoint. The fast entrypoint still runs the same credential, usage-audit,
91
+ * server authorization, cache-fallback, redaction, and output code.
92
+ */
93
+ export declare function runSecretsEnvCommand(opts: {
94
+ only: string[];
95
+ script?: string;
96
+ scriptId?: string;
97
+ }, parentScope: {
98
+ company?: string;
99
+ personal?: boolean;
100
+ }, options?: {
101
+ fallbackOnAuthFailure?: boolean;
102
+ }): Promise<"handled" | "fallback">;
103
+ //# sourceMappingURL=secrets-env-action.d.ts.map
@@ -0,0 +1,459 @@
1
+ import chalk from "chalk";
2
+ import { writeSync } from "node:fs";
3
+ import * as nodePath from "node:path";
4
+ import { cognitoCachePrincipalUid } from "./utils/cognito-session.js";
5
+ import { isAuthError } from "./utils/auth-error.js";
6
+ import { DEFAULT_SECRETS_CACHE_TTL_MS, hasCacheEntry, readCache, removeCacheEntry, writeCache, } from "./utils/secrets-cache.js";
7
+ import { computeSha256 } from "./utils/integrity.js";
8
+ import { SECRET_NAME_PATTERN } from "./commands/_patterns.js";
9
+ import { getEntityUid, vaultApiFetch } from "./utils/vault-api.js";
10
+ import { HQ_API_KEY_PREFIX, resolveVaultCredentialForCapability, } from "./utils/resolve-vault-credential.js";
11
+ import { CACHED_SECRETS_SERVER_UNAVAILABLE_MESSAGE, isSecretLoadUnavailable, isUnavailableSecretLoadHttpStatus, SecretLoadUnavailableError, } from "./utils/secret-load-availability.js";
12
+ const MAX_BATCH_NAMES = 100;
13
+ const SECRET_LOAD_TIMEOUT_MS = 30_000;
14
+ /**
15
+ * A browser login is safe only when a person can answer it. This mirrors the
16
+ * repository's existing terminal convention in `canOfferTeamUpgrade`: both
17
+ * stdin and stderr must be TTYs, and CI is always non-interactive.
18
+ */
19
+ export function secretsCredentialOptions() {
20
+ return {
21
+ interactive: process.stdin.isTTY === true &&
22
+ process.stderr.isTTY === true &&
23
+ !process.env.CI,
24
+ };
25
+ }
26
+ export function scopeOpts(opts) {
27
+ if (opts.personal && opts.company) {
28
+ console.error(chalk.red("Error: --personal cannot be combined with --company."));
29
+ process.exit(1);
30
+ }
31
+ return { personal: !!opts.personal, companySlug: opts.company };
32
+ }
33
+ function shellSingleQuote(value) {
34
+ return "'" + value.replace(/'/g, "'\\''") + "'";
35
+ }
36
+ export function highSecuritySandboxOnlyMessage(secretName) {
37
+ const sandboxExample = secretName
38
+ ? `hq secrets sandbox --only ${secretName} -- <command>`
39
+ : "hq secrets sandbox --only <secret> -- <command>";
40
+ return `High-security secrets can run only in the sandbox (use \`${sandboxExample}\`) or through the HQ secret proxy. Local injection and script locks (\`--script\`) are not supported.`;
41
+ }
42
+ export function normalizeSecretTier(tier) {
43
+ return tier === "sensitive" || tier === "nuclear" ? tier : "standard";
44
+ }
45
+ export function normalizeScriptLockMode(mode) {
46
+ return mode === "enforced" ? "enforced" : "off";
47
+ }
48
+ function normalizeCacheTtlMs(metadata) {
49
+ // Controlled rows must never reach the offline cache even if a mixed-version
50
+ // or malformed response carries a positive TTL. The server is authoritative
51
+ // for access, but the client still has enough policy metadata to fail safe.
52
+ if (metadata.tier === "sensitive" ||
53
+ metadata.tier === "nuclear" ||
54
+ metadata.scriptLock?.mode === "enforced") {
55
+ return 0;
56
+ }
57
+ if (metadata.cacheTtlMs === undefined) {
58
+ return DEFAULT_SECRETS_CACHE_TTL_MS;
59
+ }
60
+ return typeof metadata.cacheTtlMs === "number" &&
61
+ Number.isFinite(metadata.cacheTtlMs) &&
62
+ metadata.cacheTtlMs > 0
63
+ ? metadata.cacheTtlMs
64
+ : 0;
65
+ }
66
+ export function extractApiMessage(body, fallback) {
67
+ const message = typeof body.message === "string" ? body.message : undefined;
68
+ const error = typeof body.error === "string" ? body.error : undefined;
69
+ return message ?? error ?? fallback;
70
+ }
71
+ export async function buildSecretUsage(channel, scriptPath, scriptId, attestationLevel = "self-asserted-hash") {
72
+ if (scriptId && !scriptPath) {
73
+ throw new Error("--script-id requires --script");
74
+ }
75
+ if (!scriptPath) {
76
+ return { channel };
77
+ }
78
+ const resolvedPath = nodePath.resolve(scriptPath);
79
+ return {
80
+ channel,
81
+ script: {
82
+ scriptId: scriptId ?? resolvedPath,
83
+ path: resolvedPath,
84
+ sha256: await computeSha256(resolvedPath),
85
+ attestationLevel,
86
+ },
87
+ };
88
+ }
89
+ export function parseSecretNameList(input) {
90
+ const keys = input ?? [];
91
+ if (keys.length === 0) {
92
+ console.error(chalk.red("Error: --only requires at least one secret name."));
93
+ process.exit(1);
94
+ }
95
+ for (const key of keys) {
96
+ if (!SECRET_NAME_PATTERN.test(key)) {
97
+ console.error(chalk.red(`Invalid secret name '${key}': must match ^[A-Z][A-Z0-9_]*(/[A-Z][A-Z0-9_]+)*$`));
98
+ process.exit(1);
99
+ }
100
+ }
101
+ return keys;
102
+ }
103
+ // HQ-4H — load + decrypt secrets through the BATCH-LOAD endpoint
104
+ // (`POST /secrets/{companyUid}/load`) instead of one single-secret GET per key.
105
+ //
106
+ // Every single-secret GET that 404s makes vault-service emit an unattributed
107
+ // "Secret not found" Sentry *warning* — each `response(404, …)` is captured at
108
+ // the response boundary (hq-pro `_error-response.ts`). That capture is the
109
+ // right signal for `hq secrets get <name>` ("a caller genuinely needed THIS
110
+ // secret and it was missing"), but it is pure noise for a multi-key load where
111
+ // a missing / renamed / rotated key is a per-key outcome `exec`/`env` already
112
+ // report cleanly to the user. The batch-load endpoint returns 200 with a
113
+ // per-name `errors[]` array and never captures — the same path `hq run` uses,
114
+ // which is exactly why `hq run` never contributed to HQ-4H. Routing `exec` and
115
+ // `env` through it stops those callers from emitting the warning while keeping
116
+ // identical UX and error text.
117
+ //
118
+ // Every value use through exec/env/reveal is server-authorized when online,
119
+ // including when an encrypted disk-cache entry exists. Only explicitly opted-in
120
+ // exec/env callers may use principal-bound values during classified outages;
121
+ // install authorization and reveal remain online-only. This means a policy,
122
+ // ACL, tier, or script-approval change takes effect on the next online use.
123
+ // Requests are chunked at MAX_BATCH_NAMES and throw on the FIRST unresolved key
124
+ // with the same `Failed to fetch secret '<k>': <reason>` shape the per-key GET
125
+ // path used — never swallows a failure.
126
+ async function loadRevealedSecretsViaApiKey(token, keys) {
127
+ const resolved = new Map();
128
+ const requested = [...new Set(keys)];
129
+ const cacheScope = "__api_key__";
130
+ for (const name of requested) {
131
+ const res = await vaultApiFetch({
132
+ token,
133
+ path: "/v1/keys/secrets/fetch",
134
+ method: "POST",
135
+ body: { name },
136
+ signal: AbortSignal.timeout(SECRET_LOAD_TIMEOUT_MS),
137
+ });
138
+ const body = (await res.json().catch(() => ({})));
139
+ if (!res.ok) {
140
+ if (res.status === 404) {
141
+ throw new Error(`Failed to fetch secret '${name}': Secret not found`);
142
+ }
143
+ if (res.status === 403) {
144
+ const message = typeof body.error === "string"
145
+ ? body.error
146
+ : typeof body.message === "string"
147
+ ? body.message
148
+ : "No read permission";
149
+ if (body.highSecurity === true) {
150
+ throw new Error(highSecuritySandboxOnlyMessage(name));
151
+ }
152
+ throw new Error(`Failed to fetch secret '${name}': ${message}`);
153
+ }
154
+ if (res.status === 401) {
155
+ throw new Error(`Failed to fetch secret '${name}': Invalid or missing API key`);
156
+ }
157
+ throw new Error(`Failed to fetch secret '${name}': ${extractApiMessage(body, res.statusText)}`);
158
+ }
159
+ const secret = typeof body.secret === "object" && body.secret !== null
160
+ ? body.secret
161
+ : null;
162
+ if (typeof secret?.value !== "string") {
163
+ throw new Error(`Failed to fetch secret '${name}': malformed fetch response`);
164
+ }
165
+ removeCacheEntry(cacheScope, name);
166
+ resolved.set(name, secret.value);
167
+ }
168
+ return resolved;
169
+ }
170
+ export async function loadRevealedSecrets(token, companyUid, keys, usage, options = {}) {
171
+ if (token.startsWith(HQ_API_KEY_PREFIX)) {
172
+ return loadRevealedSecretsViaApiKey(token, keys);
173
+ }
174
+ const resolved = new Map();
175
+ const requested = [...new Set(keys)];
176
+ const principalUid = cognitoCachePrincipalUid(token);
177
+ const cachedCandidates = new Set(principalUid
178
+ ? requested.filter((name) => hasCacheEntry(companyUid, name, principalUid))
179
+ : []);
180
+ try {
181
+ // The batch endpoint is the only non-capturing authorization path that also
182
+ // evaluates the supplied usage metadata. It covers warm and cold names in
183
+ // bounded requests, then a warm name may use its locally authenticated
184
+ // plaintext only when the returned version and current policy TTL agree.
185
+ for (let i = 0; i < requested.length; i += MAX_BATCH_NAMES) {
186
+ const chunk = requested.slice(i, i + MAX_BATCH_NAMES);
187
+ const chunkNames = new Set(chunk);
188
+ const res = await vaultApiFetch({
189
+ token,
190
+ path: `/secrets/${encodeURIComponent(companyUid)}/load`,
191
+ method: "POST",
192
+ body: usage ? { names: chunk, usage } : { names: chunk },
193
+ signal: AbortSignal.timeout(SECRET_LOAD_TIMEOUT_MS),
194
+ });
195
+ if (!res.ok) {
196
+ const body = (await res.json().catch(() => ({})));
197
+ const message = extractApiMessage(body, res.statusText);
198
+ // High-security refusal surfaced at the batch level (rather than
199
+ // per-name): point the caller at the sandbox/proxy and never leak
200
+ // plaintext. A local script attestation is not an override.
201
+ if (body.code === "high_security_denied" ||
202
+ body.code === "high_security_sandbox_only" ||
203
+ body.highSecurity === true) {
204
+ throw new Error(highSecuritySandboxOnlyMessage());
205
+ }
206
+ if (isUnavailableSecretLoadHttpStatus(res.status)) {
207
+ throw new SecretLoadUnavailableError(`Failed to batch-load secrets: ${message}`);
208
+ }
209
+ if (res.status >= 400 &&
210
+ res.status < 500 &&
211
+ typeof body.code === "string") {
212
+ throw new Error(message);
213
+ }
214
+ if (res.status === 403) {
215
+ throw new Error(`Failed to fetch secret '${chunk[0]}': No read permission`);
216
+ }
217
+ throw new Error(`Failed to batch-load secrets: ${message}`);
218
+ }
219
+ const data = (await res.json());
220
+ if (!Array.isArray(data.secrets) || !Array.isArray(data.errors)) {
221
+ throw new Error("Invalid secret load response from vault");
222
+ }
223
+ const errorsByName = new Map();
224
+ const seenNames = new Set();
225
+ for (const rawSecret of data.secrets) {
226
+ if (!rawSecret || typeof rawSecret !== "object") {
227
+ throw new Error("Invalid secret load response from vault");
228
+ }
229
+ const s = rawSecret;
230
+ if (typeof s.name !== "string" ||
231
+ !chunkNames.has(s.name) ||
232
+ seenNames.has(s.name) ||
233
+ (s.value != null && typeof s.value !== "string")) {
234
+ throw new Error("Invalid secret load response from vault");
235
+ }
236
+ seenNames.add(s.name);
237
+ if (s.revokedAt != null) {
238
+ errorsByName.set(s.name, {
239
+ code: "revoked",
240
+ message: `Secret '${s.name}' has been revoked`,
241
+ });
242
+ removeCacheEntry(companyUid, s.name);
243
+ continue;
244
+ }
245
+ if (s.value == null) {
246
+ errorsByName.set(s.name, {
247
+ code: "not_returned",
248
+ message: `Secret '${s.name}' has no value (reveal may not be permitted).`,
249
+ });
250
+ removeCacheEntry(companyUid, s.name);
251
+ continue;
252
+ }
253
+ const cacheTtlMs = normalizeCacheTtlMs(s);
254
+ const cached = cachedCandidates.has(s.name) &&
255
+ cacheTtlMs > 0 &&
256
+ typeof s.version === "number" &&
257
+ Number.isSafeInteger(s.version) &&
258
+ s.version > 0
259
+ ? readCache(companyUid, s.name, principalUid, s.version, cacheTtlMs)
260
+ : null;
261
+ if (cached !== null) {
262
+ resolved.set(s.name, cached);
263
+ continue;
264
+ }
265
+ if (cacheTtlMs > 0) {
266
+ if (typeof s.version === "number" &&
267
+ Number.isSafeInteger(s.version) &&
268
+ s.version > 0) {
269
+ if (principalUid) {
270
+ writeCache(companyUid, s.name, principalUid, s.value, cacheTtlMs, s.version);
271
+ }
272
+ }
273
+ else if (principalUid) {
274
+ writeCache(companyUid, s.name, principalUid, s.value, cacheTtlMs);
275
+ }
276
+ }
277
+ else {
278
+ removeCacheEntry(companyUid, s.name);
279
+ }
280
+ resolved.set(s.name, s.value);
281
+ }
282
+ for (const rawError of data.errors) {
283
+ if (!rawError || typeof rawError !== "object") {
284
+ throw new Error("Invalid secret load response from vault");
285
+ }
286
+ const e = rawError;
287
+ if (typeof e.name !== "string" ||
288
+ !chunkNames.has(e.name) ||
289
+ seenNames.has(e.name) ||
290
+ typeof e.code !== "string" ||
291
+ (e.message !== undefined && typeof e.message !== "string")) {
292
+ throw new Error("Invalid secret load response from vault");
293
+ }
294
+ seenNames.add(e.name);
295
+ errorsByName.set(e.name, { code: e.code, message: e.message });
296
+ resolved.delete(e.name);
297
+ removeCacheEntry(companyUid, e.name);
298
+ }
299
+ // Any requested key in this chunk the server did not return is a per-key
300
+ // failure — surface it with the same prefix the single-GET path used so
301
+ // callers (and scripts grepping stderr) see no behavior change.
302
+ let firstFailure = null;
303
+ for (const key of chunk) {
304
+ if (resolved.has(key))
305
+ continue;
306
+ removeCacheEntry(companyUid, key);
307
+ const err = errorsByName.get(key);
308
+ // Every caller of loadRevealedSecrets injects or prints the plaintext
309
+ // locally (`secrets get --reveal`, `secrets exec`, `secrets env`). Both
310
+ // high-security denial shapes therefore receive the same explicit
311
+ // sandbox/proxy guidance, even if the caller supplied `--script`.
312
+ if (err?.code === "high_security_denied" ||
313
+ err?.code === "high_security_sandbox_only") {
314
+ firstFailure ??= new Error(highSecuritySandboxOnlyMessage(key));
315
+ continue;
316
+ }
317
+ const reason = err?.code === "not_found"
318
+ ? "Secret not found"
319
+ : err?.code === "forbidden"
320
+ ? err.message ?? "No read permission"
321
+ : err?.message ?? err?.code ?? "not returned by vault";
322
+ firstFailure ??= new Error(`Failed to fetch secret '${key}': ${reason}`);
323
+ }
324
+ if (firstFailure) {
325
+ throw firstFailure;
326
+ }
327
+ }
328
+ return resolved;
329
+ }
330
+ catch (err) {
331
+ if (isSecretLoadUnavailable(err) && options.allowOfflineFallback === true) {
332
+ const offline = new Map();
333
+ for (const key of requested) {
334
+ const cached = cachedCandidates.has(key)
335
+ ? readCache(companyUid, key, principalUid)
336
+ : null;
337
+ if (cached === null) {
338
+ // Keep the same failure behavior for a cache miss, while leaving any
339
+ // still-valid entries untouched for a later retry.
340
+ throw err;
341
+ }
342
+ offline.set(key, cached);
343
+ }
344
+ console.error(CACHED_SECRETS_SERVER_UNAVAILABLE_MESSAGE);
345
+ return offline;
346
+ }
347
+ if (isSecretLoadUnavailable(err)) {
348
+ throw err;
349
+ }
350
+ // A transport failure, stale session, malformed response, or a later chunk
351
+ // failure is an explicit denial or unusable response. Evict the whole
352
+ // request before failing closed. Transient no-answer/5xx/429 failures above
353
+ // preserve entries and only use values still valid under their stored TTL.
354
+ for (const key of requested) {
355
+ removeCacheEntry(companyUid, key);
356
+ }
357
+ throw err;
358
+ }
359
+ }
360
+ /**
361
+ * Emit `hq secrets env` output and terminate deterministically.
362
+ *
363
+ * The process MUST exit even when the consuming pipe has gone away. The
364
+ * orphaned-process leak (personal/projects/hq-secrets-process-leak) was caused
365
+ * by relying on `process.stdout.write(payload, () => process.exit(0))`: when the
366
+ * parent shell reading `source <(hq secrets env …)` is killed on timeout, the
367
+ * stdout pipe loses its reader, the write callback never fires, `process.exit(0)`
368
+ * is never reached, and — because `env` forces no other exit — the process parks
369
+ * in `epoll_pwait(-1)` forever, reparented to PID 1. Hundreds accumulated this
370
+ * way, exhausting swap and driving load into triple digits.
371
+ *
372
+ * Writing synchronously to fd 1 and then exiting unconditionally removes every
373
+ * dependency on the event loop draining and on the reader still being attached:
374
+ * - a live reader receives the export lines (synchronous write reaches the
375
+ * kernel pipe buffer before we exit, so nothing is truncated);
376
+ * - a dead reader surfaces as EPIPE, which we swallow — the secret is already
377
+ * resolved and there is nothing left to deliver, so a clean exit is correct;
378
+ * - EAGAIN (stdout in non-blocking mode with a momentarily full buffer) is
379
+ * retried a bounded number of times. `env` payloads are a few short export
380
+ * lines, far under the 64 KiB pipe buffer, so this effectively never spins.
381
+ *
382
+ * `exit` and `write` are injectable for tests; production uses the real ones.
383
+ */
384
+ export function emitEnvExportsAndExit(payload, exit = process.exit, write = writeSync) {
385
+ const buf = Buffer.from(payload, "utf8");
386
+ const MAX_EAGAIN_RETRIES = 10_000;
387
+ let offset = 0;
388
+ let eagainRetries = 0;
389
+ while (offset < buf.length) {
390
+ try {
391
+ offset += write(1, buf, offset, buf.length - offset);
392
+ }
393
+ catch (err) {
394
+ const code = err.code;
395
+ if (code === "EAGAIN" && eagainRetries++ < MAX_EAGAIN_RETRIES)
396
+ continue;
397
+ // EPIPE (reader gone) or retries exhausted: the output cannot be
398
+ // delivered, but the process must never hang. Stop and exit cleanly.
399
+ break;
400
+ }
401
+ }
402
+ return exit(0);
403
+ }
404
+ /**
405
+ * Shared `secrets env` action for Commander and the default-off lightweight
406
+ * entrypoint. The fast entrypoint still runs the same credential, usage-audit,
407
+ * server authorization, cache-fallback, redaction, and output code.
408
+ */
409
+ export async function runSecretsEnvCommand(opts, parentScope, options = {}) {
410
+ let secretLoadStarted = false;
411
+ try {
412
+ const redact = process.stdout.isTTY;
413
+ if (redact) {
414
+ console.error(chalk.yellow("stdout is a terminal — values redacted. Use: source <(hq secrets env --only KEY1,KEY2)"));
415
+ }
416
+ const keys = parseSecretNameList(opts.only);
417
+ const cred = await resolveVaultCredentialForCapability("secrets:read", "secrets env", secretsCredentialOptions());
418
+ const companyUid = cred.kind === "api-key"
419
+ ? "__api_key__"
420
+ : await getEntityUid(cred.token, scopeOpts(parentScope), {
421
+ allowOfflineScopeFallback: true,
422
+ });
423
+ const usage = cred.kind === "cognito"
424
+ ? await buildSecretUsage("env", opts.script, opts.scriptId)
425
+ : undefined;
426
+ secretLoadStarted = true;
427
+ const revealed = await loadRevealedSecrets(cred.token, companyUid, keys, usage, { allowOfflineFallback: true });
428
+ let payload = "";
429
+ for (const key of keys) {
430
+ const value = revealed.get(key);
431
+ // loadRevealedSecrets throws on any unresolved key, so a miss here is
432
+ // unreachable — guard rather than print an `undefined` export line.
433
+ if (value === undefined) {
434
+ throw new Error(`Failed to fetch secret '${key}': not returned by vault`);
435
+ }
436
+ const out = redact ? "[REDACTED]" : value;
437
+ payload += `export ${key}=${shellSingleQuote(out)}\n`;
438
+ }
439
+ // Write synchronously and exit unconditionally — never depend on the
440
+ // event loop draining or the stdout write callback firing. See
441
+ // emitEnvExportsAndExit for the full rationale (orphaned-process leak).
442
+ emitEnvExportsAndExit(payload);
443
+ return "handled";
444
+ }
445
+ catch (err) {
446
+ if (options.fallbackOnAuthFailure &&
447
+ !secretLoadStarted &&
448
+ (isAuthError(err) ||
449
+ (err instanceof Error &&
450
+ (/^No valid HQ session\b/.test(err.message) ||
451
+ /^HQ_API_KEY must start with /.test(err.message))))) {
452
+ return "fallback";
453
+ }
454
+ console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
455
+ process.exit(1);
456
+ return "handled";
457
+ }
458
+ }
459
+ //# sourceMappingURL=secrets-env-action.js.map
@@ -8,7 +8,7 @@ export async function runSecretsEnvFastPath(request) {
8
8
  ]);
9
9
  adoptAgentIdentityEnv(agentKitPaths(os.homedir(), process.env), process.env);
10
10
  }
11
- const { runSecretsEnvCommand } = await import("./commands/secrets.js");
11
+ const { runSecretsEnvCommand } = await import("./secrets-env-action.js");
12
12
  return runSecretsEnvCommand(request.options, request.scope, {
13
13
  fallbackOnAuthFailure: true,
14
14
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.342.1",
3
+ "version": "5.342.2",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {