@indigoai-us/hq-cli 5.342.1 → 5.342.3
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 +6 -0
- package/dist/commands/secrets-env-exit.d.ts +6 -0
- package/dist/commands/secrets-env-exit.js +28 -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/lib/flag-registry-refresh-wait.d.ts +7 -0
- package/dist/lib/flag-registry-refresh-wait.js +10 -0
- package/dist/lib/flag-registry-worker-source.generated.d.ts +1 -1
- package/dist/lib/flag-registry-worker-source.generated.js +1 -1
- package/dist/lib/flag-registry-worker.js +6 -1
- package/dist/lib/flag-registry.d.ts +12 -1
- package/dist/lib/flag-registry.js +125 -5
- package/dist/main.js +8 -2
- 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
|
@@ -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 { finishEnvExportsAndExit } from "./commands/secrets-env-exit.js";
|
|
10
|
+
import { getEntityUid, vaultApiFetch } from "./utils/vault-api.js";
|
|
11
|
+
import { HQ_API_KEY_PREFIX, resolveVaultCredentialForCapability, } from "./utils/resolve-vault-credential.js";
|
|
12
|
+
import { CACHED_SECRETS_SERVER_UNAVAILABLE_MESSAGE, isSecretLoadUnavailable, isUnavailableSecretLoadHttpStatus, SecretLoadUnavailableError, } from "./utils/secret-load-availability.js";
|
|
13
|
+
const MAX_BATCH_NAMES = 100;
|
|
14
|
+
const SECRET_LOAD_TIMEOUT_MS = 30_000;
|
|
15
|
+
/**
|
|
16
|
+
* A browser login is safe only when a person can answer it. This mirrors the
|
|
17
|
+
* repository's existing terminal convention in `canOfferTeamUpgrade`: both
|
|
18
|
+
* stdin and stderr must be TTYs, and CI is always non-interactive.
|
|
19
|
+
*/
|
|
20
|
+
export function secretsCredentialOptions() {
|
|
21
|
+
return {
|
|
22
|
+
interactive: process.stdin.isTTY === true &&
|
|
23
|
+
process.stderr.isTTY === true &&
|
|
24
|
+
!process.env.CI,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
export function scopeOpts(opts) {
|
|
28
|
+
if (opts.personal && opts.company) {
|
|
29
|
+
console.error(chalk.red("Error: --personal cannot be combined with --company."));
|
|
30
|
+
process.exit(1);
|
|
31
|
+
}
|
|
32
|
+
return { personal: !!opts.personal, companySlug: opts.company };
|
|
33
|
+
}
|
|
34
|
+
function shellSingleQuote(value) {
|
|
35
|
+
return "'" + value.replace(/'/g, "'\\''") + "'";
|
|
36
|
+
}
|
|
37
|
+
export function highSecuritySandboxOnlyMessage(secretName) {
|
|
38
|
+
const sandboxExample = secretName
|
|
39
|
+
? `hq secrets sandbox --only ${secretName} -- <command>`
|
|
40
|
+
: "hq secrets sandbox --only <secret> -- <command>";
|
|
41
|
+
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.`;
|
|
42
|
+
}
|
|
43
|
+
export function normalizeSecretTier(tier) {
|
|
44
|
+
return tier === "sensitive" || tier === "nuclear" ? tier : "standard";
|
|
45
|
+
}
|
|
46
|
+
export function normalizeScriptLockMode(mode) {
|
|
47
|
+
return mode === "enforced" ? "enforced" : "off";
|
|
48
|
+
}
|
|
49
|
+
function normalizeCacheTtlMs(metadata) {
|
|
50
|
+
// Controlled rows must never reach the offline cache even if a mixed-version
|
|
51
|
+
// or malformed response carries a positive TTL. The server is authoritative
|
|
52
|
+
// for access, but the client still has enough policy metadata to fail safe.
|
|
53
|
+
if (metadata.tier === "sensitive" ||
|
|
54
|
+
metadata.tier === "nuclear" ||
|
|
55
|
+
metadata.scriptLock?.mode === "enforced") {
|
|
56
|
+
return 0;
|
|
57
|
+
}
|
|
58
|
+
if (metadata.cacheTtlMs === undefined) {
|
|
59
|
+
return DEFAULT_SECRETS_CACHE_TTL_MS;
|
|
60
|
+
}
|
|
61
|
+
return typeof metadata.cacheTtlMs === "number" &&
|
|
62
|
+
Number.isFinite(metadata.cacheTtlMs) &&
|
|
63
|
+
metadata.cacheTtlMs > 0
|
|
64
|
+
? metadata.cacheTtlMs
|
|
65
|
+
: 0;
|
|
66
|
+
}
|
|
67
|
+
export function extractApiMessage(body, fallback) {
|
|
68
|
+
const message = typeof body.message === "string" ? body.message : undefined;
|
|
69
|
+
const error = typeof body.error === "string" ? body.error : undefined;
|
|
70
|
+
return message ?? error ?? fallback;
|
|
71
|
+
}
|
|
72
|
+
export async function buildSecretUsage(channel, scriptPath, scriptId, attestationLevel = "self-asserted-hash") {
|
|
73
|
+
if (scriptId && !scriptPath) {
|
|
74
|
+
throw new Error("--script-id requires --script");
|
|
75
|
+
}
|
|
76
|
+
if (!scriptPath) {
|
|
77
|
+
return { channel };
|
|
78
|
+
}
|
|
79
|
+
const resolvedPath = nodePath.resolve(scriptPath);
|
|
80
|
+
return {
|
|
81
|
+
channel,
|
|
82
|
+
script: {
|
|
83
|
+
scriptId: scriptId ?? resolvedPath,
|
|
84
|
+
path: resolvedPath,
|
|
85
|
+
sha256: await computeSha256(resolvedPath),
|
|
86
|
+
attestationLevel,
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
export function parseSecretNameList(input) {
|
|
91
|
+
const keys = input ?? [];
|
|
92
|
+
if (keys.length === 0) {
|
|
93
|
+
console.error(chalk.red("Error: --only requires at least one secret name."));
|
|
94
|
+
process.exit(1);
|
|
95
|
+
}
|
|
96
|
+
for (const key of keys) {
|
|
97
|
+
if (!SECRET_NAME_PATTERN.test(key)) {
|
|
98
|
+
console.error(chalk.red(`Invalid secret name '${key}': must match ^[A-Z][A-Z0-9_]*(/[A-Z][A-Z0-9_]+)*$`));
|
|
99
|
+
process.exit(1);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return keys;
|
|
103
|
+
}
|
|
104
|
+
// HQ-4H — load + decrypt secrets through the BATCH-LOAD endpoint
|
|
105
|
+
// (`POST /secrets/{companyUid}/load`) instead of one single-secret GET per key.
|
|
106
|
+
//
|
|
107
|
+
// Every single-secret GET that 404s makes vault-service emit an unattributed
|
|
108
|
+
// "Secret not found" Sentry *warning* — each `response(404, …)` is captured at
|
|
109
|
+
// the response boundary (hq-pro `_error-response.ts`). That capture is the
|
|
110
|
+
// right signal for `hq secrets get <name>` ("a caller genuinely needed THIS
|
|
111
|
+
// secret and it was missing"), but it is pure noise for a multi-key load where
|
|
112
|
+
// a missing / renamed / rotated key is a per-key outcome `exec`/`env` already
|
|
113
|
+
// report cleanly to the user. The batch-load endpoint returns 200 with a
|
|
114
|
+
// per-name `errors[]` array and never captures — the same path `hq run` uses,
|
|
115
|
+
// which is exactly why `hq run` never contributed to HQ-4H. Routing `exec` and
|
|
116
|
+
// `env` through it stops those callers from emitting the warning while keeping
|
|
117
|
+
// identical UX and error text.
|
|
118
|
+
//
|
|
119
|
+
// Every value use through exec/env/reveal is server-authorized when online,
|
|
120
|
+
// including when an encrypted disk-cache entry exists. Only explicitly opted-in
|
|
121
|
+
// exec/env callers may use principal-bound values during classified outages;
|
|
122
|
+
// install authorization and reveal remain online-only. This means a policy,
|
|
123
|
+
// ACL, tier, or script-approval change takes effect on the next online use.
|
|
124
|
+
// Requests are chunked at MAX_BATCH_NAMES and throw on the FIRST unresolved key
|
|
125
|
+
// with the same `Failed to fetch secret '<k>': <reason>` shape the per-key GET
|
|
126
|
+
// path used — never swallows a failure.
|
|
127
|
+
async function loadRevealedSecretsViaApiKey(token, keys) {
|
|
128
|
+
const resolved = new Map();
|
|
129
|
+
const requested = [...new Set(keys)];
|
|
130
|
+
const cacheScope = "__api_key__";
|
|
131
|
+
for (const name of requested) {
|
|
132
|
+
const res = await vaultApiFetch({
|
|
133
|
+
token,
|
|
134
|
+
path: "/v1/keys/secrets/fetch",
|
|
135
|
+
method: "POST",
|
|
136
|
+
body: { name },
|
|
137
|
+
signal: AbortSignal.timeout(SECRET_LOAD_TIMEOUT_MS),
|
|
138
|
+
});
|
|
139
|
+
const body = (await res.json().catch(() => ({})));
|
|
140
|
+
if (!res.ok) {
|
|
141
|
+
if (res.status === 404) {
|
|
142
|
+
throw new Error(`Failed to fetch secret '${name}': Secret not found`);
|
|
143
|
+
}
|
|
144
|
+
if (res.status === 403) {
|
|
145
|
+
const message = typeof body.error === "string"
|
|
146
|
+
? body.error
|
|
147
|
+
: typeof body.message === "string"
|
|
148
|
+
? body.message
|
|
149
|
+
: "No read permission";
|
|
150
|
+
if (body.highSecurity === true) {
|
|
151
|
+
throw new Error(highSecuritySandboxOnlyMessage(name));
|
|
152
|
+
}
|
|
153
|
+
throw new Error(`Failed to fetch secret '${name}': ${message}`);
|
|
154
|
+
}
|
|
155
|
+
if (res.status === 401) {
|
|
156
|
+
throw new Error(`Failed to fetch secret '${name}': Invalid or missing API key`);
|
|
157
|
+
}
|
|
158
|
+
throw new Error(`Failed to fetch secret '${name}': ${extractApiMessage(body, res.statusText)}`);
|
|
159
|
+
}
|
|
160
|
+
const secret = typeof body.secret === "object" && body.secret !== null
|
|
161
|
+
? body.secret
|
|
162
|
+
: null;
|
|
163
|
+
if (typeof secret?.value !== "string") {
|
|
164
|
+
throw new Error(`Failed to fetch secret '${name}': malformed fetch response`);
|
|
165
|
+
}
|
|
166
|
+
removeCacheEntry(cacheScope, name);
|
|
167
|
+
resolved.set(name, secret.value);
|
|
168
|
+
}
|
|
169
|
+
return resolved;
|
|
170
|
+
}
|
|
171
|
+
export async function loadRevealedSecrets(token, companyUid, keys, usage, options = {}) {
|
|
172
|
+
if (token.startsWith(HQ_API_KEY_PREFIX)) {
|
|
173
|
+
return loadRevealedSecretsViaApiKey(token, keys);
|
|
174
|
+
}
|
|
175
|
+
const resolved = new Map();
|
|
176
|
+
const requested = [...new Set(keys)];
|
|
177
|
+
const principalUid = cognitoCachePrincipalUid(token);
|
|
178
|
+
const cachedCandidates = new Set(principalUid
|
|
179
|
+
? requested.filter((name) => hasCacheEntry(companyUid, name, principalUid))
|
|
180
|
+
: []);
|
|
181
|
+
try {
|
|
182
|
+
// The batch endpoint is the only non-capturing authorization path that also
|
|
183
|
+
// evaluates the supplied usage metadata. It covers warm and cold names in
|
|
184
|
+
// bounded requests, then a warm name may use its locally authenticated
|
|
185
|
+
// plaintext only when the returned version and current policy TTL agree.
|
|
186
|
+
for (let i = 0; i < requested.length; i += MAX_BATCH_NAMES) {
|
|
187
|
+
const chunk = requested.slice(i, i + MAX_BATCH_NAMES);
|
|
188
|
+
const chunkNames = new Set(chunk);
|
|
189
|
+
const res = await vaultApiFetch({
|
|
190
|
+
token,
|
|
191
|
+
path: `/secrets/${encodeURIComponent(companyUid)}/load`,
|
|
192
|
+
method: "POST",
|
|
193
|
+
body: usage ? { names: chunk, usage } : { names: chunk },
|
|
194
|
+
signal: AbortSignal.timeout(SECRET_LOAD_TIMEOUT_MS),
|
|
195
|
+
});
|
|
196
|
+
if (!res.ok) {
|
|
197
|
+
const body = (await res.json().catch(() => ({})));
|
|
198
|
+
const message = extractApiMessage(body, res.statusText);
|
|
199
|
+
// High-security refusal surfaced at the batch level (rather than
|
|
200
|
+
// per-name): point the caller at the sandbox/proxy and never leak
|
|
201
|
+
// plaintext. A local script attestation is not an override.
|
|
202
|
+
if (body.code === "high_security_denied" ||
|
|
203
|
+
body.code === "high_security_sandbox_only" ||
|
|
204
|
+
body.highSecurity === true) {
|
|
205
|
+
throw new Error(highSecuritySandboxOnlyMessage());
|
|
206
|
+
}
|
|
207
|
+
if (isUnavailableSecretLoadHttpStatus(res.status)) {
|
|
208
|
+
throw new SecretLoadUnavailableError(`Failed to batch-load secrets: ${message}`);
|
|
209
|
+
}
|
|
210
|
+
if (res.status >= 400 &&
|
|
211
|
+
res.status < 500 &&
|
|
212
|
+
typeof body.code === "string") {
|
|
213
|
+
throw new Error(message);
|
|
214
|
+
}
|
|
215
|
+
if (res.status === 403) {
|
|
216
|
+
throw new Error(`Failed to fetch secret '${chunk[0]}': No read permission`);
|
|
217
|
+
}
|
|
218
|
+
throw new Error(`Failed to batch-load secrets: ${message}`);
|
|
219
|
+
}
|
|
220
|
+
const data = (await res.json());
|
|
221
|
+
if (!Array.isArray(data.secrets) || !Array.isArray(data.errors)) {
|
|
222
|
+
throw new Error("Invalid secret load response from vault");
|
|
223
|
+
}
|
|
224
|
+
const errorsByName = new Map();
|
|
225
|
+
const seenNames = new Set();
|
|
226
|
+
for (const rawSecret of data.secrets) {
|
|
227
|
+
if (!rawSecret || typeof rawSecret !== "object") {
|
|
228
|
+
throw new Error("Invalid secret load response from vault");
|
|
229
|
+
}
|
|
230
|
+
const s = rawSecret;
|
|
231
|
+
if (typeof s.name !== "string" ||
|
|
232
|
+
!chunkNames.has(s.name) ||
|
|
233
|
+
seenNames.has(s.name) ||
|
|
234
|
+
(s.value != null && typeof s.value !== "string")) {
|
|
235
|
+
throw new Error("Invalid secret load response from vault");
|
|
236
|
+
}
|
|
237
|
+
seenNames.add(s.name);
|
|
238
|
+
if (s.revokedAt != null) {
|
|
239
|
+
errorsByName.set(s.name, {
|
|
240
|
+
code: "revoked",
|
|
241
|
+
message: `Secret '${s.name}' has been revoked`,
|
|
242
|
+
});
|
|
243
|
+
removeCacheEntry(companyUid, s.name);
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
if (s.value == null) {
|
|
247
|
+
errorsByName.set(s.name, {
|
|
248
|
+
code: "not_returned",
|
|
249
|
+
message: `Secret '${s.name}' has no value (reveal may not be permitted).`,
|
|
250
|
+
});
|
|
251
|
+
removeCacheEntry(companyUid, s.name);
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
const cacheTtlMs = normalizeCacheTtlMs(s);
|
|
255
|
+
const cached = cachedCandidates.has(s.name) &&
|
|
256
|
+
cacheTtlMs > 0 &&
|
|
257
|
+
typeof s.version === "number" &&
|
|
258
|
+
Number.isSafeInteger(s.version) &&
|
|
259
|
+
s.version > 0
|
|
260
|
+
? readCache(companyUid, s.name, principalUid, s.version, cacheTtlMs)
|
|
261
|
+
: null;
|
|
262
|
+
if (cached !== null) {
|
|
263
|
+
resolved.set(s.name, cached);
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
if (cacheTtlMs > 0) {
|
|
267
|
+
if (typeof s.version === "number" &&
|
|
268
|
+
Number.isSafeInteger(s.version) &&
|
|
269
|
+
s.version > 0) {
|
|
270
|
+
if (principalUid) {
|
|
271
|
+
writeCache(companyUid, s.name, principalUid, s.value, cacheTtlMs, s.version);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
else if (principalUid) {
|
|
275
|
+
writeCache(companyUid, s.name, principalUid, s.value, cacheTtlMs);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
else {
|
|
279
|
+
removeCacheEntry(companyUid, s.name);
|
|
280
|
+
}
|
|
281
|
+
resolved.set(s.name, s.value);
|
|
282
|
+
}
|
|
283
|
+
for (const rawError of data.errors) {
|
|
284
|
+
if (!rawError || typeof rawError !== "object") {
|
|
285
|
+
throw new Error("Invalid secret load response from vault");
|
|
286
|
+
}
|
|
287
|
+
const e = rawError;
|
|
288
|
+
if (typeof e.name !== "string" ||
|
|
289
|
+
!chunkNames.has(e.name) ||
|
|
290
|
+
seenNames.has(e.name) ||
|
|
291
|
+
typeof e.code !== "string" ||
|
|
292
|
+
(e.message !== undefined && typeof e.message !== "string")) {
|
|
293
|
+
throw new Error("Invalid secret load response from vault");
|
|
294
|
+
}
|
|
295
|
+
seenNames.add(e.name);
|
|
296
|
+
errorsByName.set(e.name, { code: e.code, message: e.message });
|
|
297
|
+
resolved.delete(e.name);
|
|
298
|
+
removeCacheEntry(companyUid, e.name);
|
|
299
|
+
}
|
|
300
|
+
// Any requested key in this chunk the server did not return is a per-key
|
|
301
|
+
// failure — surface it with the same prefix the single-GET path used so
|
|
302
|
+
// callers (and scripts grepping stderr) see no behavior change.
|
|
303
|
+
let firstFailure = null;
|
|
304
|
+
for (const key of chunk) {
|
|
305
|
+
if (resolved.has(key))
|
|
306
|
+
continue;
|
|
307
|
+
removeCacheEntry(companyUid, key);
|
|
308
|
+
const err = errorsByName.get(key);
|
|
309
|
+
// Every caller of loadRevealedSecrets injects or prints the plaintext
|
|
310
|
+
// locally (`secrets get --reveal`, `secrets exec`, `secrets env`). Both
|
|
311
|
+
// high-security denial shapes therefore receive the same explicit
|
|
312
|
+
// sandbox/proxy guidance, even if the caller supplied `--script`.
|
|
313
|
+
if (err?.code === "high_security_denied" ||
|
|
314
|
+
err?.code === "high_security_sandbox_only") {
|
|
315
|
+
firstFailure ??= new Error(highSecuritySandboxOnlyMessage(key));
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
const reason = err?.code === "not_found"
|
|
319
|
+
? "Secret not found"
|
|
320
|
+
: err?.code === "forbidden"
|
|
321
|
+
? err.message ?? "No read permission"
|
|
322
|
+
: err?.message ?? err?.code ?? "not returned by vault";
|
|
323
|
+
firstFailure ??= new Error(`Failed to fetch secret '${key}': ${reason}`);
|
|
324
|
+
}
|
|
325
|
+
if (firstFailure) {
|
|
326
|
+
throw firstFailure;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
return resolved;
|
|
330
|
+
}
|
|
331
|
+
catch (err) {
|
|
332
|
+
if (isSecretLoadUnavailable(err) && options.allowOfflineFallback === true) {
|
|
333
|
+
const offline = new Map();
|
|
334
|
+
for (const key of requested) {
|
|
335
|
+
const cached = cachedCandidates.has(key)
|
|
336
|
+
? readCache(companyUid, key, principalUid)
|
|
337
|
+
: null;
|
|
338
|
+
if (cached === null) {
|
|
339
|
+
// Keep the same failure behavior for a cache miss, while leaving any
|
|
340
|
+
// still-valid entries untouched for a later retry.
|
|
341
|
+
throw err;
|
|
342
|
+
}
|
|
343
|
+
offline.set(key, cached);
|
|
344
|
+
}
|
|
345
|
+
console.error(CACHED_SECRETS_SERVER_UNAVAILABLE_MESSAGE);
|
|
346
|
+
return offline;
|
|
347
|
+
}
|
|
348
|
+
if (isSecretLoadUnavailable(err)) {
|
|
349
|
+
throw err;
|
|
350
|
+
}
|
|
351
|
+
// A transport failure, stale session, malformed response, or a later chunk
|
|
352
|
+
// failure is an explicit denial or unusable response. Evict the whole
|
|
353
|
+
// request before failing closed. Transient no-answer/5xx/429 failures above
|
|
354
|
+
// preserve entries and only use values still valid under their stored TTL.
|
|
355
|
+
for (const key of requested) {
|
|
356
|
+
removeCacheEntry(companyUid, key);
|
|
357
|
+
}
|
|
358
|
+
throw err;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
/**
|
|
362
|
+
* Emit `hq secrets env` output and terminate deterministically.
|
|
363
|
+
*
|
|
364
|
+
* The process MUST exit even when the consuming pipe has gone away. The
|
|
365
|
+
* orphaned-process leak (personal/projects/hq-secrets-process-leak) was caused
|
|
366
|
+
* by relying on `process.stdout.write(payload, () => process.exit(0))`: when the
|
|
367
|
+
* parent shell reading `source <(hq secrets env …)` is killed on timeout, the
|
|
368
|
+
* stdout pipe loses its reader, the write callback never fires, `process.exit(0)`
|
|
369
|
+
* is never reached, and — because `env` forces no other exit — the process parks
|
|
370
|
+
* in `epoll_pwait(-1)` forever, reparented to PID 1. Hundreds accumulated this
|
|
371
|
+
* way, exhausting swap and driving load into triple digits.
|
|
372
|
+
*
|
|
373
|
+
* Writing synchronously to fd 1 and then exiting unconditionally removes every
|
|
374
|
+
* dependency on the event loop draining and on the reader still being attached:
|
|
375
|
+
* - a live reader receives the export lines (synchronous write reaches the
|
|
376
|
+
* kernel pipe buffer before we exit, so nothing is truncated);
|
|
377
|
+
* - a dead reader surfaces as EPIPE, which we swallow — the secret is already
|
|
378
|
+
* resolved and there is nothing left to deliver, so a clean exit is correct;
|
|
379
|
+
* - EAGAIN (stdout in non-blocking mode with a momentarily full buffer) is
|
|
380
|
+
* retried a bounded number of times. `env` payloads are a few short export
|
|
381
|
+
* lines, far under the 64 KiB pipe buffer, so this effectively never spins.
|
|
382
|
+
*
|
|
383
|
+
* `exit` and `write` are injectable for tests; production uses the real ones.
|
|
384
|
+
*/
|
|
385
|
+
export function emitEnvExportsAndExit(payload, exit = process.exit, write = writeSync) {
|
|
386
|
+
const buf = Buffer.from(payload, "utf8");
|
|
387
|
+
const MAX_EAGAIN_RETRIES = 10_000;
|
|
388
|
+
let offset = 0;
|
|
389
|
+
let eagainRetries = 0;
|
|
390
|
+
while (offset < buf.length) {
|
|
391
|
+
try {
|
|
392
|
+
offset += write(1, buf, offset, buf.length - offset);
|
|
393
|
+
}
|
|
394
|
+
catch (err) {
|
|
395
|
+
const code = err.code;
|
|
396
|
+
if (code === "EAGAIN" && eagainRetries++ < MAX_EAGAIN_RETRIES)
|
|
397
|
+
continue;
|
|
398
|
+
// EPIPE (reader gone) or retries exhausted: the output cannot be
|
|
399
|
+
// delivered, but the process must never hang. Stop and exit cleanly.
|
|
400
|
+
break;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
return exit(0);
|
|
404
|
+
}
|
|
405
|
+
/**
|
|
406
|
+
* Shared `secrets env` action for Commander and the default-off lightweight
|
|
407
|
+
* entrypoint. The fast entrypoint still runs the same credential, usage-audit,
|
|
408
|
+
* server authorization, cache-fallback, redaction, and output code.
|
|
409
|
+
*/
|
|
410
|
+
export async function runSecretsEnvCommand(opts, parentScope, options = {}) {
|
|
411
|
+
let secretLoadStarted = false;
|
|
412
|
+
try {
|
|
413
|
+
const redact = process.stdout.isTTY;
|
|
414
|
+
if (redact) {
|
|
415
|
+
console.error(chalk.yellow("stdout is a terminal — values redacted. Use: source <(hq secrets env --only KEY1,KEY2)"));
|
|
416
|
+
}
|
|
417
|
+
const keys = parseSecretNameList(opts.only);
|
|
418
|
+
const cred = await resolveVaultCredentialForCapability("secrets:read", "secrets env", secretsCredentialOptions());
|
|
419
|
+
const companyUid = cred.kind === "api-key"
|
|
420
|
+
? "__api_key__"
|
|
421
|
+
: await getEntityUid(cred.token, scopeOpts(parentScope), {
|
|
422
|
+
allowOfflineScopeFallback: true,
|
|
423
|
+
});
|
|
424
|
+
const usage = cred.kind === "cognito"
|
|
425
|
+
? await buildSecretUsage("env", opts.script, opts.scriptId)
|
|
426
|
+
: undefined;
|
|
427
|
+
secretLoadStarted = true;
|
|
428
|
+
const revealed = await loadRevealedSecrets(cred.token, companyUid, keys, usage, { allowOfflineFallback: true });
|
|
429
|
+
let payload = "";
|
|
430
|
+
for (const key of keys) {
|
|
431
|
+
const value = revealed.get(key);
|
|
432
|
+
// loadRevealedSecrets throws on any unresolved key, so a miss here is
|
|
433
|
+
// unreachable — guard rather than print an `undefined` export line.
|
|
434
|
+
if (value === undefined) {
|
|
435
|
+
throw new Error(`Failed to fetch secret '${key}': not returned by vault`);
|
|
436
|
+
}
|
|
437
|
+
const out = redact ? "[REDACTED]" : value;
|
|
438
|
+
payload += `export ${key}=${shellSingleQuote(out)}\n`;
|
|
439
|
+
}
|
|
440
|
+
// Persist an already-running company snapshot before the synchronous
|
|
441
|
+
// export write exits the process. Personal exports do not wait.
|
|
442
|
+
await finishEnvExportsAndExit(payload, undefined, process.exit, undefined, !parentScope.personal, companyUid);
|
|
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
|
});
|