@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 +3 -0
- package/dist/commands/secrets-public-types.test-d.d.ts +2 -0
- package/dist/commands/secrets-public-types.test-d.js +18 -0
- package/dist/commands/secrets.d.ts +3 -80
- package/dist/commands/secrets.js +5 -458
- package/dist/secrets-env-action.d.ts +103 -0
- package/dist/secrets-env-action.js +459 -0
- package/dist/secrets-env-fast-path.js +1 -1
- package/package.json +1 -1
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,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
|
-
|
|
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
|
package/dist/commands/secrets.js
CHANGED
|
@@ -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 {
|
|
3
|
-
import
|
|
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 {
|
|
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("./
|
|
11
|
+
const { runSecretsEnvCommand } = await import("./secrets-env-action.js");
|
|
12
12
|
return runSecretsEnvCommand(request.options, request.scope, {
|
|
13
13
|
fallbackOnAuthFailure: true,
|
|
14
14
|
});
|