@indigoai-us/hq-cli 5.75.0 → 5.76.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/dist/commands/mcp-registration.d.ts +4 -5
  2. package/dist/commands/mcp-registration.js +5 -4
  3. package/dist/commands/outposts.d.ts +20 -4
  4. package/dist/commands/outposts.js +77 -8
  5. package/dist/commands/pack-install.d.ts +14 -17
  6. package/dist/commands/pack-install.js +53 -29
  7. package/dist/commands/pkg-install.js +3 -1
  8. package/dist/commands/run.d.ts +2 -0
  9. package/dist/commands/run.js +9 -3
  10. package/dist/commands/secrets.js +189 -87
  11. package/dist/run/hq-plugin.js +94 -31
  12. package/dist/utils/sandbox-runner-client.d.ts +1 -0
  13. package/dist/utils/sandbox-runner-client.js +1 -0
  14. package/dist/utils/secrets-cache.d.ts +4 -5
  15. package/dist/utils/secrets-cache.js +5 -8
  16. package/package.json +3 -2
  17. package/pnpm-workspace.yaml +2 -0
  18. package/src/commands/mcp-registration.ts +9 -9
  19. package/src/commands/outposts.test.ts +118 -24
  20. package/src/commands/outposts.ts +197 -43
  21. package/src/commands/pack-install-secret-authorization.test.ts +115 -0
  22. package/src/commands/pack-install.test.ts +5 -1
  23. package/src/commands/pack-install.ts +67 -29
  24. package/src/commands/pkg-install.ts +3 -1
  25. package/src/commands/run.test.ts +45 -0
  26. package/src/commands/run.ts +20 -4
  27. package/src/commands/secrets.test.ts +366 -25
  28. package/src/commands/secrets.ts +222 -96
  29. package/src/run/hq-plugin.test.ts +186 -10
  30. package/src/run/hq-plugin.ts +102 -32
  31. package/src/utils/__fixtures__/scan-packages.generated-block.sh +23 -0
  32. package/src/utils/pack-contributions.test.ts +90 -31
  33. package/src/utils/sandbox-runner-client.test.ts +28 -0
  34. package/src/utils/sandbox-runner-client.ts +2 -0
  35. package/src/utils/secrets-cache.ts +5 -8
  36. package/test/commands/signals.test.ts +2 -2
  37. package/test/commands/sources.test.ts +2 -2
  38. package/test/helpers/vault-service-mock.ts +76 -17
  39. package/test/sources-signals/smoke.test.ts +2 -2
@@ -429,11 +429,10 @@ export interface McpManifest {
429
429
  /**
430
430
  * Resolve a `${secret:NAME}` reference to its plaintext value, or return `null`
431
431
  * when the secret is unavailable (un-minted / TTL-expired / no company context).
432
- * Production binds this to `secrets-cache.ts` (`readCache(companyUid, NAME)`,
433
- * AES-256-GCM, 0600, 5-min TTL); TESTS inject a pure map so they NEVER read the
434
- * real encrypted cache. Returning `null` for a referenced secret is a hard error
435
- * at emit (we refuse to write a broken header), distinct from a name with no
436
- * reference at all.
432
+ * Production binds this to an in-memory map populated by a fresh vault `/load`
433
+ * authorization response; tests inject a pure map. Returning `null` for a
434
+ * referenced secret is a hard error at emit (we refuse to write a broken
435
+ * header), distinct from a name with no reference at all.
437
436
  */
438
437
  export type SecretResolver = (name: string) => string | null;
439
438
  /** True iff `value` contains at least one `${secret:NAME}` reference. */
@@ -848,7 +848,8 @@ function restoreSafely(realTarget, backupDir) {
848
848
  //
849
849
  // SECRET SAFETY (the hard rule from the PRD authModel / US-002 AC):
850
850
  // - `${secret:NAME}` resolves ONLY here, at emit, via the injected
851
- // {@link SecretResolver} (production = the AES-256-GCM `secrets-cache.ts`).
851
+ // {@link SecretResolver}. Production injects values returned by a fresh
852
+ // server authorization; the encrypted disk cache is never a value fallback.
852
853
  // - The RESOLVED value lands in `~/.claude.json` — a 0600 user-global file that
853
854
  // is NOT synced/committed — because the runtime needs the real header to work.
854
855
  // - The resolved value is REDACTED from every return value and never echoed; we
@@ -885,9 +886,9 @@ export function resolveSecretRefs(value, resolve, secretSink) {
885
886
  return value.replace(SECRET_REF_RE, (_match, name) => {
886
887
  const resolved = resolve(name);
887
888
  if (resolved === null || resolved === undefined) {
888
- throw new McpManifestError(`cannot resolve \${secret:${name}} at emit — the secret is not in the cache ` +
889
- '(mint it via the pack onboarding / `hq run`, then re-install). HQ refuses to ' +
890
- 'write a half-resolved header.');
889
+ throw new McpManifestError(`cannot resolve \${secret:${name}} at emit — the secret was not available ` +
890
+ 'after online vault authorization (check login, company scope, and access, ' +
891
+ 'then re-install). HQ refuses to write a half-resolved header.');
891
892
  }
892
893
  if (resolved.length > 0)
893
894
  secretSink.add(resolved);
@@ -23,13 +23,27 @@
23
23
  import { Command } from "commander";
24
24
  import { spawnSync } from "node:child_process";
25
25
  import { type BillingErrorPayload } from "../utils/billing-gate.js";
26
+ /**
27
+ * hq-pro's per-person cap envelope on a `409` provision block. Unlike every
28
+ * other `/outpost/*` failure this body carries NO `message`/`error` field —
29
+ * only the cap facts — so it has to be decoded structurally or the reason
30
+ * degrades to a bare `res.statusText` ("Conflict").
31
+ */
32
+ export interface OutpostCappedPayload {
33
+ limit: number;
34
+ outposts: OutpostSummary[];
35
+ }
36
+ /** Decode hq-pro's `{ capped, limit, outposts }` cap envelope, if present. */
37
+ export declare function parseCappedPayload(body: unknown): OutpostCappedPayload | undefined;
26
38
  /** A non-2xx from the `/outpost/*` control plane. Carries status + `step`. */
27
39
  export declare class OutpostHttpError extends Error {
28
40
  status: number;
29
41
  step?: string;
30
42
  /** hq-pro's billing envelope on a `402 billing_required` provision block. */
31
43
  billing?: BillingErrorPayload;
32
- constructor(status: number, message: string, step?: string, billing?: BillingErrorPayload);
44
+ /** hq-pro's cap envelope on a `409` provision block. */
45
+ capped?: OutpostCappedPayload;
46
+ constructor(status: number, message: string, step?: string, billing?: BillingErrorPayload, capped?: OutpostCappedPayload);
33
47
  }
34
48
  /** Row summary from `GET /outpost/list`. */
35
49
  export interface OutpostSummary {
@@ -59,9 +73,11 @@ export declare function outpostRequest<T>(opts: {
59
73
  /**
60
74
  * Provision the caller's Outpost. Sends the cached Cognito refresh token so the
61
75
  * box can authenticate AS the caller (the same body the console's
62
- * `provisionMyOutpost` sends). Idempotent server-side: a caller already at their
63
- * per-person cap gets their existing box back rather than a duplicate. The
64
- * refresh token is sent over HTTPS and NEVER printed.
76
+ * `provisionMyOutpost` sends). No duplicate is ever created: a caller already at
77
+ * their per-person cap gets a `409` whose body lists their existing boxes
78
+ * thrown here as an `OutpostHttpError` carrying `capped` (hq-pro checks the cap
79
+ * BEFORE activation billing, so a capped call is never charged). The refresh
80
+ * token is sent over HTTPS and NEVER printed.
65
81
  */
66
82
  export declare function provisionOutpost(token: string, input: {
67
83
  refreshToken: string;
@@ -32,18 +32,33 @@ import { loadCachedTokens } from "@indigoai-us/hq-cloud";
32
32
  import { ensureCognitoToken } from "../utils/cognito-session.js";
33
33
  import { vaultApiFetch } from "../utils/vault-api.js";
34
34
  import { OUTPOST_PRICE_CENTS, confirmChargeOrExit, parseBillingPayload, surfaceBillingRequired, } from "../utils/billing-gate.js";
35
+ /** Decode hq-pro's `{ capped, limit, outposts }` cap envelope, if present. */
36
+ export function parseCappedPayload(body) {
37
+ if (!body || typeof body !== "object")
38
+ return undefined;
39
+ const b = body;
40
+ if (b.capped !== true)
41
+ return undefined;
42
+ return {
43
+ limit: typeof b.limit === "number" ? b.limit : 0,
44
+ outposts: Array.isArray(b.outposts) ? b.outposts : [],
45
+ };
46
+ }
35
47
  /** A non-2xx from the `/outpost/*` control plane. Carries status + `step`. */
36
48
  export class OutpostHttpError extends Error {
37
49
  status;
38
50
  step;
39
51
  /** hq-pro's billing envelope on a `402 billing_required` provision block. */
40
52
  billing;
41
- constructor(status, message, step, billing) {
53
+ /** hq-pro's cap envelope on a `409` provision block. */
54
+ capped;
55
+ constructor(status, message, step, billing, capped) {
42
56
  super(message);
43
57
  this.name = "OutpostHttpError";
44
58
  this.status = status;
45
59
  this.step = step;
46
60
  this.billing = billing;
61
+ this.capped = capped;
47
62
  }
48
63
  }
49
64
  /**
@@ -62,16 +77,18 @@ export async function outpostRequest(opts) {
62
77
  : typeof body.error === "string"
63
78
  ? body.error
64
79
  : res.statusText;
65
- throw new OutpostHttpError(res.status, message, body.step, parseBillingPayload(body));
80
+ throw new OutpostHttpError(res.status, message, body.step, parseBillingPayload(body), parseCappedPayload(body));
66
81
  }
67
82
  return (await res.json());
68
83
  }
69
84
  /**
70
85
  * Provision the caller's Outpost. Sends the cached Cognito refresh token so the
71
86
  * box can authenticate AS the caller (the same body the console's
72
- * `provisionMyOutpost` sends). Idempotent server-side: a caller already at their
73
- * per-person cap gets their existing box back rather than a duplicate. The
74
- * refresh token is sent over HTTPS and NEVER printed.
87
+ * `provisionMyOutpost` sends). No duplicate is ever created: a caller already at
88
+ * their per-person cap gets a `409` whose body lists their existing boxes
89
+ * thrown here as an `OutpostHttpError` carrying `capped` (hq-pro checks the cap
90
+ * BEFORE activation billing, so a capped call is never charged). The refresh
91
+ * token is sent over HTTPS and NEVER printed.
75
92
  */
76
93
  export async function provisionOutpost(token, input) {
77
94
  return outpostRequest({
@@ -300,6 +317,24 @@ function ensureTrailingNewline(s) {
300
317
  // ---------------------------------------------------------------------------
301
318
  // Command registration
302
319
  // ---------------------------------------------------------------------------
320
+ /**
321
+ * Explain a `409` per-person cap. hq-pro checks the cap BEFORE activation
322
+ * billing, so nothing was charged — worth saying, since the caller just
323
+ * confirmed a recurring charge to get here.
324
+ */
325
+ function surfaceOutpostCapped(capped) {
326
+ const owned = capped.outposts.length;
327
+ console.error(chalk.yellow(`You're already at your Outpost limit (${owned} of ${capped.limit}). ` +
328
+ `No new box was provisioned and you have not been charged.`));
329
+ for (const o of capped.outposts) {
330
+ const detail = [o.state, o.instanceName, o.region]
331
+ .filter(Boolean)
332
+ .join(" ");
333
+ console.error(` ${o.outpostId} ${detail}`);
334
+ }
335
+ console.error(chalk.dim("Inspect it: hq outposts status"));
336
+ console.error(chalk.dim("Or tear it down first: hq outposts destroy --id <id> --yes"));
337
+ }
303
338
  function fail(err) {
304
339
  if (err instanceof OutpostHttpError) {
305
340
  console.error(chalk.red(err.message));
@@ -615,7 +650,17 @@ function authGitHubViaVault(deps) {
615
650
  "unset GITHUB_TOKEN",
616
651
  'printf "%s" "$TOKEN" | gh auth login --with-token',
617
652
  ].join("\n");
618
- const authed = runBestEffort(deps, "hq", ["secrets", "--personal", "exec", "--only", "GITHUB_TOKEN", "--", "bash", "-c", ghLogin], "GitHub auth via vault");
653
+ const authed = runBestEffort(deps, "hq", [
654
+ "secrets",
655
+ "--personal",
656
+ "exec",
657
+ "--only",
658
+ "GITHUB_TOKEN",
659
+ "--",
660
+ "bash",
661
+ "-c",
662
+ ghLogin,
663
+ ], "GitHub auth via vault");
619
664
  if (authed) {
620
665
  // Let plain `git clone https://github.com/...` reuse gh's token so private
621
666
  // repos don't prompt for a username/password.
@@ -670,7 +715,15 @@ async function replicaSyncOutpost(opts, deps) {
670
715
  console.warn("replica-sync: auth refresh failed — skipping this cycle (token likely expired).");
671
716
  return;
672
717
  }
673
- runBestEffort(deps, "hq", ["sync", "pull", "--personal", "--hq-root", hqRoot, "--on-conflict", "keep"], "personal vault pull");
718
+ runBestEffort(deps, "hq", [
719
+ "sync",
720
+ "pull",
721
+ "--personal",
722
+ "--hq-root",
723
+ hqRoot,
724
+ "--on-conflict",
725
+ "keep",
726
+ ], "personal vault pull");
674
727
  // rescue lays down the core kernel and expects companies/ to exist.
675
728
  deps.mkdirp(path.join(hqRoot, "companies"));
676
729
  runBestEffort(deps, "hq", ["rescue", "--hq-root", hqRoot, "--yes"], "HQ kernel rescue");
@@ -722,9 +775,17 @@ export function registerOutpostsCommand(program, selfDeployOverrides = {}) {
722
775
  .option("--yes", "Confirm the $80/month charge (required to provision)")
723
776
  .action(async function (opts) {
724
777
  try {
778
+ // Reject an unrecognized runtime rather than resolving it to claude:
779
+ // `--runtime codx` would otherwise hand back a silently Claude box.
780
+ if (opts.runtime !== undefined &&
781
+ opts.runtime !== "claude" &&
782
+ opts.runtime !== "codex") {
783
+ console.error(chalk.red(`Invalid --runtime '${opts.runtime}': must be 'claude' or 'codex'.`));
784
+ process.exit(1);
785
+ }
725
786
  const agentRuntime = opts.runtime === "codex"
726
787
  ? "codex"
727
- : opts.runtime
788
+ : opts.runtime === "claude"
728
789
  ? "claude"
729
790
  : undefined;
730
791
  let diskSizeGb;
@@ -767,6 +828,14 @@ export function registerOutpostsCommand(program, selfDeployOverrides = {}) {
767
828
  await surfaceBillingRequired(token, err.billing);
768
829
  process.exit(1);
769
830
  }
831
+ // Already at the per-person cap → say so and name the box they own,
832
+ // not a bare "Conflict".
833
+ if (err instanceof OutpostHttpError &&
834
+ err.status === 409 &&
835
+ err.capped) {
836
+ surfaceOutpostCapped(err.capped);
837
+ process.exit(1);
838
+ }
770
839
  throw err;
771
840
  }
772
841
  }
@@ -305,26 +305,21 @@ export declare function renderMcpServerLine(payloadDir: string, item: string): s
305
305
  */
306
306
  export declare function confirmMcp(pkg: PackManifest, payloadDir: string, allowMcp: boolean): Promise<boolean>;
307
307
  /**
308
- * Build the install-time {@link SecretResolver} bound to the HQ vault's local
309
- * secrets-cache (`~/.hq/secrets-cache/<scope>/<NAME>`, AES-256-GCM, 0600, TTL'd).
308
+ * Build an install-time {@link SecretResolver} from a fresh server authorization
309
+ * decision. The encrypted cache may identify one unambiguous scope, but its
310
+ * plaintext is never trusted or read to register an MCP server.
310
311
  *
311
- * Active-company resolution at install time is INDIRECT by design: `hq install`
312
- * has no `--company` flag and runs OFFLINE (no token), so we cannot resolve a
313
- * single active company UID the way `hq run` / `hq secrets` do (via
314
- * `getEntityUid` over the network). Instead we probe EVERY cached scope
315
- * (`cmp_*`/`prs_*` whichever has minted secrets locally) for the requested
316
- * name and return the first hit. This naturally resolves to whichever company
317
- * context just provisioned the secret (e.g. the one `/connect-shopify` minted
318
- * `VYG_API_KEY` under), without guessing, and works whether the vault scoped the
319
- * key under a company or person entity.
312
+ * With `--company`, the caller-selected company is resolved normally. Without
313
+ * it, exactly one cache scope may identify the intended company/person context;
314
+ * multiple scopes are ambiguous and fail closed. The requested names are then
315
+ * loaded through the same server-authorized endpoint as exec/env/run. Offline,
316
+ * expired-session, forbidden, not-found, script-lock, and malformed-response
317
+ * outcomes all produce an empty resolver, so MCP registration is deferred.
320
318
  *
321
- * On MISS across all scopes (no cache, expired TTL, or key never minted) it
322
- * returns `null` which is exactly what {@link registerMcpServers}'s
323
- * unresolvable-secret path keys off to defer that server gracefully. With no
324
- * cached scopes at all (`listSecretCacheScopes()` → `[]`) it simply returns
325
- * `null` for every name, the desired graceful-deferral behavior.
319
+ * The returned synchronous resolver contains only values from that fresh
320
+ * authorization response. No cached value is a fallback.
326
321
  */
327
- export declare function makeInstallSecretResolver(): SecretResolver;
322
+ export declare function makeInstallSecretResolver(secretNames: string[], company?: string): Promise<SecretResolver>;
328
323
  /**
329
324
  * Install the fetched payload to `<hqRoot>/core/packages/<pkg.name>/` (HQ
330
325
  * v12+ layout). The HQ template (`hq-core` / `hq-core-staging`) ships
@@ -381,6 +376,8 @@ export declare function runScanPackages(hqRoot: string, opts?: {
381
376
  }): void;
382
377
  export interface InstallPackOptions {
383
378
  allowHooks?: boolean;
379
+ /** Company context for online authorization of install-time MCP secret refs. */
380
+ company?: string;
384
381
  /**
385
382
  * US-010 install-time MCP trust prompt bypass (CI/ambient-trust). Mirrors
386
383
  * `allowHooks`: when set, `confirmMcp` skips the prompt and prints a yellow
@@ -49,9 +49,11 @@ import { findHqRoot } from '../utils/manifest.js';
49
49
  import { readHqVersion, routeContribution, listInstalledPacks } from '../utils/pack-contributions.js';
50
50
  import { CONTRIBUTION_TABLE, payloadFor } from '../utils/contribution-table.js';
51
51
  import { safeExtractTarball } from './safe-extract.js';
52
- import { vaultApiFetchPublic } from '../utils/vault-api.js';
52
+ import { getCompanyUid, vaultApiFetchPublic } from '../utils/vault-api.js';
53
+ import { ensureCognitoToken } from '../utils/cognito-session.js';
53
54
  import { redactSecrets, SECRET_REDACTION, registerMcpServers, McpManifestError, } from './mcp-registration.js';
54
- import { readCache, listSecretCacheScopes } from '../utils/secrets-cache.js';
55
+ import { listSecretCacheScopes } from '../utils/secrets-cache.js';
56
+ import { loadRevealedSecrets } from './secrets.js';
55
57
  const PACK_UPDATE_CACHE_TTL_MS = 12 * 60 * 60 * 1000;
56
58
  const PACK_UPDATE_FETCH_TIMEOUT_MS = 3_000;
57
59
  const gitLsRemoteMemo = new Map();
@@ -1349,34 +1351,56 @@ function evalConditional(expr) {
1349
1351
  // key) still SUCCEEDS, skipping only the server whose secret is not yet present.
1350
1352
  // ---------------------------------------------------------------------------
1351
1353
  /**
1352
- * Build the install-time {@link SecretResolver} bound to the HQ vault's local
1353
- * secrets-cache (`~/.hq/secrets-cache/<scope>/<NAME>`, AES-256-GCM, 0600, TTL'd).
1354
+ * Build an install-time {@link SecretResolver} from a fresh server authorization
1355
+ * decision. The encrypted cache may identify one unambiguous scope, but its
1356
+ * plaintext is never trusted or read to register an MCP server.
1354
1357
  *
1355
- * Active-company resolution at install time is INDIRECT by design: `hq install`
1356
- * has no `--company` flag and runs OFFLINE (no token), so we cannot resolve a
1357
- * single active company UID the way `hq run` / `hq secrets` do (via
1358
- * `getEntityUid` over the network). Instead we probe EVERY cached scope
1359
- * (`cmp_*`/`prs_*` whichever has minted secrets locally) for the requested
1360
- * name and return the first hit. This naturally resolves to whichever company
1361
- * context just provisioned the secret (e.g. the one `/connect-shopify` minted
1362
- * `VYG_API_KEY` under), without guessing, and works whether the vault scoped the
1363
- * key under a company or person entity.
1358
+ * With `--company`, the caller-selected company is resolved normally. Without
1359
+ * it, exactly one cache scope may identify the intended company/person context;
1360
+ * multiple scopes are ambiguous and fail closed. The requested names are then
1361
+ * loaded through the same server-authorized endpoint as exec/env/run. Offline,
1362
+ * expired-session, forbidden, not-found, script-lock, and malformed-response
1363
+ * outcomes all produce an empty resolver, so MCP registration is deferred.
1364
1364
  *
1365
- * On MISS across all scopes (no cache, expired TTL, or key never minted) it
1366
- * returns `null` which is exactly what {@link registerMcpServers}'s
1367
- * unresolvable-secret path keys off to defer that server gracefully. With no
1368
- * cached scopes at all (`listSecretCacheScopes()` → `[]`) it simply returns
1369
- * `null` for every name, the desired graceful-deferral behavior.
1365
+ * The returned synchronous resolver contains only values from that fresh
1366
+ * authorization response. No cached value is a fallback.
1370
1367
  */
1371
- export function makeInstallSecretResolver() {
1372
- return (name) => {
1373
- for (const scope of listSecretCacheScopes()) {
1374
- const value = readCache(scope, name);
1375
- if (value !== null)
1376
- return value;
1368
+ export async function makeInstallSecretResolver(secretNames, company) {
1369
+ const names = [...new Set(secretNames)];
1370
+ if (names.length === 0)
1371
+ return () => null;
1372
+ try {
1373
+ const token = await ensureCognitoToken({ interactive: false });
1374
+ let scopeUid;
1375
+ if (company) {
1376
+ scopeUid = await getCompanyUid(token, company);
1377
1377
  }
1378
- return null;
1379
- };
1378
+ else {
1379
+ const scopes = listSecretCacheScopes();
1380
+ if (scopes.length !== 1)
1381
+ return () => null;
1382
+ [scopeUid] = scopes;
1383
+ }
1384
+ const authorized = await loadRevealedSecrets(token, scopeUid, names);
1385
+ return (name) => authorized.get(name) ?? null;
1386
+ }
1387
+ catch {
1388
+ return () => null;
1389
+ }
1390
+ }
1391
+ const INSTALL_SECRET_REF_RE = /\$\{secret:([A-Z][A-Z0-9_]*(?:\/[A-Z][A-Z0-9_]+)*)\}/g;
1392
+ function collectManifestSecretNames(manifest) {
1393
+ const names = new Set();
1394
+ for (const value of [
1395
+ ...Object.values(manifest.headers ?? {}),
1396
+ ...Object.values(manifest.env ?? {}),
1397
+ ]) {
1398
+ INSTALL_SECRET_REF_RE.lastIndex = 0;
1399
+ for (let match = INSTALL_SECRET_REF_RE.exec(value); match; match = INSTALL_SECRET_REF_RE.exec(value)) {
1400
+ names.add(match[1]);
1401
+ }
1402
+ }
1403
+ return [...names];
1380
1404
  }
1381
1405
  /**
1382
1406
  * Load + shape-validate + parse a pack's per-server MCP manifest from disk.
@@ -1424,13 +1448,13 @@ function extractDeferredSecretName(message) {
1424
1448
  * - ANY OTHER error → RE-THROW (abort install). Only the
1425
1449
  * unresolvable-secret case is swallowed.
1426
1450
  */
1427
- function wireMcpServers(pkg, destDir) {
1428
- const resolveSecret = makeInstallSecretResolver();
1451
+ async function wireMcpServers(pkg, destDir, company) {
1429
1452
  const loadManifest = (name) => loadMcpManifestFrom(destDir, name);
1430
1453
  const registered = [];
1431
1454
  const skipped = [];
1432
1455
  for (const name of pkg.contributes.mcp ?? []) {
1433
1456
  try {
1457
+ const resolveSecret = await makeInstallSecretResolver(collectManifestSecretNames(loadManifest(name)), company);
1434
1458
  // Per-server call: registerMcpServers throws on the FIRST unresolvable
1435
1459
  // secret, so calling it one name at a time lets us catch + continue.
1436
1460
  registerMcpServers(pkg.name, [name], { loadManifest, resolveSecret });
@@ -1676,7 +1700,7 @@ export async function installPack(source, opts = {}) {
1676
1700
  // Per-server secret-deferral keeps a fresh install (key not yet minted) at
1677
1701
  // exit 0, deferring only the unresolvable server (see wireMcpServers).
1678
1702
  if (Array.isArray(pkg.contributes.mcp) && pkg.contributes.mcp.length > 0) {
1679
- const { registered, skipped } = wireMcpServers(pkg, destDir);
1703
+ const { registered, skipped } = await wireMcpServers(pkg, destDir, opts.company);
1680
1704
  // One-line summary (server NAMES only — never resolved secret VALUES).
1681
1705
  say(chalk.dim(` MCP servers: registered [${registered.join(', ')}]; ` +
1682
1706
  `skipped [${skipped.join(', ')}].`));
@@ -29,7 +29,7 @@ export function registerPackageInstallCommand(parent) {
29
29
  .command('install <source>')
30
30
  .description('Install a package. Sources: bare slug (registry, Cognito-gated), ' +
31
31
  '@scope/name[@ver] (npm pack), git URL[#ref], or local path.')
32
- .option('--company <co>', 'Scope the package to a specific company (registry flow only)')
32
+ .option('--company <co>', 'Scope package secret authorization to a specific company')
33
33
  .option('--allow-hooks', 'Skip the hooks confirmation prompt (content-pack flow)')
34
34
  .option('--allow-mcp', 'Skip the MCP server confirmation prompt (content-pack flow)')
35
35
  .option('--branch', 'Follow a ref instead of SHA-pinning (git content-pack flow)')
@@ -37,6 +37,7 @@ export function registerPackageInstallCommand(parent) {
37
37
  try {
38
38
  if (sourceMatchesPackPattern(source)) {
39
39
  await installPack(source, {
40
+ company: opts.company,
40
41
  allowHooks: opts.allowHooks,
41
42
  allowMcp: opts.allowMcp,
42
43
  followBranch: opts.branch,
@@ -48,6 +49,7 @@ export function registerPackageInstallCommand(parent) {
48
49
  // would fail at registry setup. Resolve it through the marketplace
49
50
  // listings transport — the live install path — instead.
50
51
  await installPack(`${MARKETPLACE_PREFIX}${source}`, {
52
+ company: opts.company,
51
53
  allowHooks: opts.allowHooks,
52
54
  allowMcp: opts.allowMcp,
53
55
  followBranch: opts.branch,
@@ -1,3 +1,5 @@
1
1
  import { Command } from 'commander';
2
+ import type { SecretUsage } from './secrets.js';
3
+ export declare function buildRunUsage(scriptPath?: string, scriptId?: string): Promise<SecretUsage | undefined>;
2
4
  export declare function registerRunCommand(program: Command): void;
3
5
  //# sourceMappingURL=run.d.ts.map
@@ -7,7 +7,11 @@ import { computeSha256 } from '../utils/integrity.js';
7
7
  import { vaultApiFetch, getCompanyUid } from '../utils/vault-api.js';
8
8
  import { discoverSchemas } from '../run/discover-schemas.js';
9
9
  import { installHqPlugin, prewarmHqSecrets } from '../run/hq-plugin.js';
10
- async function buildRunUsage(scriptPath) {
10
+ const SECRET_LOAD_TIMEOUT_MS = 30_000;
11
+ export async function buildRunUsage(scriptPath, scriptId) {
12
+ if (scriptId && !scriptPath) {
13
+ throw new Error('--script-id requires --script');
14
+ }
11
15
  if (!scriptPath) {
12
16
  return undefined;
13
17
  }
@@ -15,7 +19,7 @@ async function buildRunUsage(scriptPath) {
15
19
  return {
16
20
  channel: 'run',
17
21
  script: {
18
- scriptId: resolvedPath,
22
+ scriptId: scriptId ?? resolvedPath,
19
23
  path: resolvedPath,
20
24
  sha256: await computeSha256(resolvedPath),
21
25
  attestationLevel: 'self-asserted-hash',
@@ -29,6 +33,7 @@ export function registerRunCommand(program) {
29
33
  .option('--company <slug>', 'Company slug (overrides @hqCompany in schema)')
30
34
  .option('--schema <path>', 'Explicit schema path (skips walk-up discovery)')
31
35
  .option('--script <path>', 'Attach local script identity for script-locked secrets')
36
+ .option('--script-id <id>', 'Stable script identifier approved by policy')
32
37
  .option('--check', 'Resolve schema and validate vars without executing the command')
33
38
  .allowUnknownOption(true)
34
39
  .action(async (opts) => {
@@ -68,13 +73,14 @@ export function registerRunCommand(program) {
68
73
  }
69
74
  const token = await ensureCognitoToken();
70
75
  const uid = await getCompanyUid(token, slug);
71
- const usage = await buildRunUsage(opts.script);
76
+ const usage = await buildRunUsage(opts.script, opts.scriptId);
72
77
  const fetchBatch = async (companyUid, names, requestUsage) => {
73
78
  const res = await vaultApiFetch({
74
79
  token,
75
80
  path: `/secrets/${encodeURIComponent(companyUid)}/load`,
76
81
  method: 'POST',
77
82
  body: requestUsage ? { names, usage: requestUsage } : { names },
83
+ signal: AbortSignal.timeout(SECRET_LOAD_TIMEOUT_MS),
78
84
  });
79
85
  if (!res.ok) {
80
86
  const body = await res.json().catch(() => ({}));