@indigoai-us/hq-cli 5.75.0 → 5.77.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 (49) hide show
  1. package/dist/commands/agents.js +8 -3
  2. package/dist/commands/files.d.ts +61 -0
  3. package/dist/commands/files.js +274 -0
  4. package/dist/commands/mcp-registration.d.ts +4 -5
  5. package/dist/commands/mcp-registration.js +5 -4
  6. package/dist/commands/outposts.d.ts +20 -4
  7. package/dist/commands/outposts.js +79 -10
  8. package/dist/commands/pack-install.d.ts +14 -17
  9. package/dist/commands/pack-install.js +53 -29
  10. package/dist/commands/pkg-install.js +3 -1
  11. package/dist/commands/run.d.ts +2 -0
  12. package/dist/commands/run.js +9 -3
  13. package/dist/commands/secrets.js +189 -87
  14. package/dist/run/hq-plugin.js +94 -31
  15. package/dist/utils/billing-gate.d.ts +15 -0
  16. package/dist/utils/billing-gate.js +35 -0
  17. package/dist/utils/sandbox-runner-client.d.ts +1 -0
  18. package/dist/utils/sandbox-runner-client.js +1 -0
  19. package/dist/utils/secrets-cache.d.ts +4 -5
  20. package/dist/utils/secrets-cache.js +5 -8
  21. package/package.json +3 -2
  22. package/pnpm-workspace.yaml +2 -0
  23. package/src/commands/agents.test.ts +41 -0
  24. package/src/commands/agents.ts +7 -3
  25. package/src/commands/files-recovery.test.ts +361 -0
  26. package/src/commands/files.ts +410 -0
  27. package/src/commands/mcp-registration.ts +9 -9
  28. package/src/commands/outposts.test.ts +155 -24
  29. package/src/commands/outposts.ts +199 -45
  30. package/src/commands/pack-install-secret-authorization.test.ts +115 -0
  31. package/src/commands/pack-install.test.ts +5 -1
  32. package/src/commands/pack-install.ts +67 -29
  33. package/src/commands/pkg-install.ts +3 -1
  34. package/src/commands/run.test.ts +45 -0
  35. package/src/commands/run.ts +20 -4
  36. package/src/commands/secrets.test.ts +366 -25
  37. package/src/commands/secrets.ts +222 -96
  38. package/src/run/hq-plugin.test.ts +186 -10
  39. package/src/run/hq-plugin.ts +102 -32
  40. package/src/utils/__fixtures__/scan-packages.generated-block.sh +23 -0
  41. package/src/utils/billing-gate.ts +46 -0
  42. package/src/utils/pack-contributions.test.ts +90 -31
  43. package/src/utils/sandbox-runner-client.test.ts +28 -0
  44. package/src/utils/sandbox-runner-client.ts +2 -0
  45. package/src/utils/secrets-cache.ts +5 -8
  46. package/test/commands/signals.test.ts +2 -2
  47. package/test/commands/sources.test.ts +2 -2
  48. package/test/helpers/vault-service-mock.ts +76 -17
  49. package/test/sources-signals/smoke.test.ts +2 -2
@@ -37,27 +37,55 @@ import {
37
37
  OUTPOST_PRICE_CENTS,
38
38
  confirmChargeOrExit,
39
39
  parseBillingPayload,
40
- surfaceBillingRequired,
40
+ surfaceBillingBlocked,
41
41
  type BillingErrorPayload,
42
42
  } from "../utils/billing-gate.js";
43
43
 
44
+ /**
45
+ * hq-pro's per-person cap envelope on a `409` provision block. Unlike every
46
+ * other `/outpost/*` failure this body carries NO `message`/`error` field —
47
+ * only the cap facts — so it has to be decoded structurally or the reason
48
+ * degrades to a bare `res.statusText` ("Conflict").
49
+ */
50
+ export interface OutpostCappedPayload {
51
+ limit: number;
52
+ outposts: OutpostSummary[];
53
+ }
54
+
55
+ /** Decode hq-pro's `{ capped, limit, outposts }` cap envelope, if present. */
56
+ export function parseCappedPayload(
57
+ body: unknown,
58
+ ): OutpostCappedPayload | undefined {
59
+ if (!body || typeof body !== "object") return undefined;
60
+ const b = body as { capped?: unknown; limit?: unknown; outposts?: unknown };
61
+ if (b.capped !== true) return undefined;
62
+ return {
63
+ limit: typeof b.limit === "number" ? b.limit : 0,
64
+ outposts: Array.isArray(b.outposts) ? (b.outposts as OutpostSummary[]) : [],
65
+ };
66
+ }
67
+
44
68
  /** A non-2xx from the `/outpost/*` control plane. Carries status + `step`. */
45
69
  export class OutpostHttpError extends Error {
46
70
  status: number;
47
71
  step?: string;
48
72
  /** hq-pro's billing envelope on a `402 billing_required` provision block. */
49
73
  billing?: BillingErrorPayload;
74
+ /** hq-pro's cap envelope on a `409` provision block. */
75
+ capped?: OutpostCappedPayload;
50
76
  constructor(
51
77
  status: number,
52
78
  message: string,
53
79
  step?: string,
54
80
  billing?: BillingErrorPayload,
81
+ capped?: OutpostCappedPayload,
55
82
  ) {
56
83
  super(message);
57
84
  this.name = "OutpostHttpError";
58
85
  this.status = status;
59
86
  this.step = step;
60
87
  this.billing = billing;
88
+ this.capped = capped;
61
89
  }
62
90
  }
63
91
 
@@ -105,6 +133,7 @@ export async function outpostRequest<T>(opts: {
105
133
  message,
106
134
  body.step,
107
135
  parseBillingPayload(body),
136
+ parseCappedPayload(body),
108
137
  );
109
138
  }
110
139
  return (await res.json()) as T;
@@ -113,9 +142,11 @@ export async function outpostRequest<T>(opts: {
113
142
  /**
114
143
  * Provision the caller's Outpost. Sends the cached Cognito refresh token so the
115
144
  * box can authenticate AS the caller (the same body the console's
116
- * `provisionMyOutpost` sends). Idempotent server-side: a caller already at their
117
- * per-person cap gets their existing box back rather than a duplicate. The
118
- * refresh token is sent over HTTPS and NEVER printed.
145
+ * `provisionMyOutpost` sends). No duplicate is ever created: a caller already at
146
+ * their per-person cap gets a `409` whose body lists their existing boxes
147
+ * thrown here as an `OutpostHttpError` carrying `capped` (hq-pro checks the cap
148
+ * BEFORE activation billing, so a capped call is never charged). The refresh
149
+ * token is sent over HTTPS and NEVER printed.
119
150
  */
120
151
  export async function provisionOutpost(
121
152
  token: string,
@@ -480,6 +511,31 @@ function ensureTrailingNewline(s: string): string {
480
511
  // Command registration
481
512
  // ---------------------------------------------------------------------------
482
513
 
514
+ /**
515
+ * Explain a `409` per-person cap. hq-pro checks the cap BEFORE activation
516
+ * billing, so nothing was charged — worth saying, since the caller just
517
+ * confirmed a recurring charge to get here.
518
+ */
519
+ function surfaceOutpostCapped(capped: OutpostCappedPayload): void {
520
+ const owned = capped.outposts.length;
521
+ console.error(
522
+ chalk.yellow(
523
+ `You're already at your Outpost limit (${owned} of ${capped.limit}). ` +
524
+ `No new box was provisioned and you have not been charged.`,
525
+ ),
526
+ );
527
+ for (const o of capped.outposts) {
528
+ const detail = [o.state, o.instanceName, o.region]
529
+ .filter(Boolean)
530
+ .join(" ");
531
+ console.error(` ${o.outpostId} ${detail}`);
532
+ }
533
+ console.error(chalk.dim("Inspect it: hq outposts status"));
534
+ console.error(
535
+ chalk.dim("Or tear it down first: hq outposts destroy --id <id> --yes"),
536
+ );
537
+ }
538
+
483
539
  function fail(err: unknown): never {
484
540
  if (err instanceof OutpostHttpError) {
485
541
  console.error(chalk.red(err.message));
@@ -513,8 +569,7 @@ export interface SelfDeployDependencies {
513
569
  ) => ReturnType<typeof spawnSync>;
514
570
  readTextFile: (file: string) => string;
515
571
  loadCachedTokens: () =>
516
- | { refreshToken?: string; idToken?: string }
517
- | undefined;
572
+ { refreshToken?: string; idToken?: string } | undefined;
518
573
  getUid: () => number | undefined;
519
574
  isStdinTty: () => boolean;
520
575
  confirm: () => Promise<boolean>;
@@ -595,10 +650,9 @@ function hqIdentityFromSession(session: {
595
650
  try {
596
651
  const payload = session.idToken.split(".")[1];
597
652
  if (!payload) return "your cached HQ session";
598
- const claims = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as Record<
599
- string,
600
- unknown
601
- >;
653
+ const claims = JSON.parse(
654
+ Buffer.from(payload, "base64url").toString("utf8"),
655
+ ) as Record<string, unknown>;
602
656
  for (const key of [
603
657
  "email",
604
658
  "preferred_username",
@@ -698,9 +752,10 @@ function runPrivileged(
698
752
  runChecked(deps, "sudo", [command, ...args], description, options);
699
753
  }
700
754
 
701
- function selfDeployPreflight(
702
- deps: SelfDeployDependencies,
703
- ): { refreshToken?: string; idToken?: string } {
755
+ function selfDeployPreflight(deps: SelfDeployDependencies): {
756
+ refreshToken?: string;
757
+ idToken?: string;
758
+ } {
704
759
  let osRelease: string;
705
760
  try {
706
761
  osRelease = deps.readTextFile("/etc/os-release");
@@ -740,7 +795,9 @@ function selfDeployPreflight(
740
795
 
741
796
  const session = deps.loadCachedTokens();
742
797
  if (!session?.refreshToken) {
743
- throw selfDeployError("no HQ login session was found. Run `hq login`, then re-run this command.");
798
+ throw selfDeployError(
799
+ "no HQ login session was found. Run `hq login`, then re-run this command.",
800
+ );
744
801
  }
745
802
  return session;
746
803
  }
@@ -763,7 +820,9 @@ async function selfDeployOutpost(
763
820
  ),
764
821
  );
765
822
  if (!deps.isStdinTty()) {
766
- throw new Error("Confirmation requires a TTY. Pass --yes to continue non-interactively.");
823
+ throw new Error(
824
+ "Confirmation requires a TTY. Pass --yes to continue non-interactively.",
825
+ );
767
826
  }
768
827
  if (!(await deps.confirm())) {
769
828
  throw new Error("Self-deploy cancelled.");
@@ -806,12 +865,7 @@ async function selfDeployOutpost(
806
865
  stdio: ["pipe", "ignore", "pipe"],
807
866
  },
808
867
  );
809
- runPrivileged(
810
- deps,
811
- "systemctl",
812
- ["daemon-reload"],
813
- "Reloading systemd",
814
- );
868
+ runPrivileged(deps, "systemctl", ["daemon-reload"], "Reloading systemd");
815
869
  runPrivileged(
816
870
  deps,
817
871
  "systemctl",
@@ -819,9 +873,19 @@ async function selfDeployOutpost(
819
873
  "Enabling outpost-sync.service",
820
874
  );
821
875
 
822
- console.log(chalk.green("This box is now a self-hosted HQ outpost and will sync all your company vaults continuously."));
823
- console.log(chalk.dim("Check it with: systemctl status outpost-sync.service"));
824
- console.log(chalk.dim("It is unregistered and unmanaged by hq-pro (no console entry or remote management)."));
876
+ console.log(
877
+ chalk.green(
878
+ "This box is now a self-hosted HQ outpost and will sync all your company vaults continuously.",
879
+ ),
880
+ );
881
+ console.log(
882
+ chalk.dim("Check it with: systemctl status outpost-sync.service"),
883
+ );
884
+ console.log(
885
+ chalk.dim(
886
+ "It is unregistered and unmanaged by hq-pro (no console entry or remote management).",
887
+ ),
888
+ );
825
889
  }
826
890
 
827
891
  // ---------------------------------------------------------------------------
@@ -940,7 +1004,9 @@ function runBestEffort(
940
1004
  return false;
941
1005
  }
942
1006
  if (!result || result.error || result.status !== 0) {
943
- console.warn(`replica-sync: ${description} did not complete cleanly — continuing.`);
1007
+ console.warn(
1008
+ `replica-sync: ${description} did not complete cleanly — continuing.`,
1009
+ );
944
1010
  return false;
945
1011
  }
946
1012
  return true;
@@ -961,7 +1027,17 @@ function authGitHubViaVault(deps: ReplicaSyncDependencies): void {
961
1027
  const authed = runBestEffort(
962
1028
  deps,
963
1029
  "hq",
964
- ["secrets", "--personal", "exec", "--only", "GITHUB_TOKEN", "--", "bash", "-c", ghLogin],
1030
+ [
1031
+ "secrets",
1032
+ "--personal",
1033
+ "exec",
1034
+ "--only",
1035
+ "GITHUB_TOKEN",
1036
+ "--",
1037
+ "bash",
1038
+ "-c",
1039
+ ghLogin,
1040
+ ],
965
1041
  "GitHub auth via vault",
966
1042
  );
967
1043
  if (authed) {
@@ -978,7 +1054,9 @@ function replicateRepos(deps: ReplicaSyncDependencies, hqRoot: string): void {
978
1054
  try {
979
1055
  manifestText = deps.readTextFile(manifestPath);
980
1056
  } catch {
981
- console.log("replica-sync: no personal/data/repos.yaml — skipping repo clone.");
1057
+ console.log(
1058
+ "replica-sync: no personal/data/repos.yaml — skipping repo clone.",
1059
+ );
982
1060
  return;
983
1061
  }
984
1062
 
@@ -990,7 +1068,9 @@ function replicateRepos(deps: ReplicaSyncDependencies, hqRoot: string): void {
990
1068
 
991
1069
  const missing = repos.filter(
992
1070
  (repo) =>
993
- !deps.pathExists(path.join(hqRoot, "repos", repo.visibility, repo.name, ".git")),
1071
+ !deps.pathExists(
1072
+ path.join(hqRoot, "repos", repo.visibility, repo.name, ".git"),
1073
+ ),
994
1074
  );
995
1075
  if (missing.length === 0) {
996
1076
  console.log("replica-sync: all repos already present.");
@@ -1025,7 +1105,9 @@ async function replicaSyncOutpost(
1025
1105
  // signed in has nothing to pull — exit cleanly so the timer stays green.
1026
1106
  const session = deps.loadCachedTokens();
1027
1107
  if (!session?.refreshToken) {
1028
- console.log("replica-sync: no HQ session on this box yet — nothing to replicate.");
1108
+ console.log(
1109
+ "replica-sync: no HQ session on this box yet — nothing to replicate.",
1110
+ );
1029
1111
  return;
1030
1112
  }
1031
1113
 
@@ -1041,7 +1123,15 @@ async function replicaSyncOutpost(
1041
1123
  runBestEffort(
1042
1124
  deps,
1043
1125
  "hq",
1044
- ["sync", "pull", "--personal", "--hq-root", hqRoot, "--on-conflict", "keep"],
1126
+ [
1127
+ "sync",
1128
+ "pull",
1129
+ "--personal",
1130
+ "--hq-root",
1131
+ hqRoot,
1132
+ "--on-conflict",
1133
+ "keep",
1134
+ ],
1045
1135
  "personal vault pull",
1046
1136
  );
1047
1137
 
@@ -1088,7 +1178,11 @@ export function registerOutpostsCommand(
1088
1178
  .description(
1089
1179
  "Replicate your full HQ onto this box (personal pull + core rescue + repos). Runs on a timer.",
1090
1180
  )
1091
- .option("--hq-root <path>", "HQ root to replicate into", deps.defaultHqRoot())
1181
+ .option(
1182
+ "--hq-root <path>",
1183
+ "HQ root to replicate into",
1184
+ deps.defaultHqRoot(),
1185
+ )
1092
1186
  .action(async (opts: { hqRoot?: string }) => {
1093
1187
  // Best-effort background job: never hard-fail the systemd unit on a
1094
1188
  // transient step. Swallow, log, and exit 0.
@@ -1105,7 +1199,10 @@ export function registerOutpostsCommand(
1105
1199
  .command("provision")
1106
1200
  .alias("create")
1107
1201
  .description("Provision a new Outpost ($80/month — requires --yes)")
1108
- .option("--runtime <runtime>", "Agent runtime: claude | codex (default claude)")
1202
+ .option(
1203
+ "--runtime <runtime>",
1204
+ "Agent runtime: claude | codex (default claude)",
1205
+ )
1109
1206
  .option("--disk <gb>", "Root disk size in GB (EC2 only)")
1110
1207
  .option(
1111
1208
  "--client-ip <ip>",
@@ -1114,20 +1211,43 @@ export function registerOutpostsCommand(
1114
1211
  .option("--yes", "Confirm the $80/month charge (required to provision)")
1115
1212
  .action(async function (
1116
1213
  this: Command,
1117
- opts: { runtime?: string; disk?: string; clientIp?: string; yes?: boolean },
1214
+ opts: {
1215
+ runtime?: string;
1216
+ disk?: string;
1217
+ clientIp?: string;
1218
+ yes?: boolean;
1219
+ },
1118
1220
  ) {
1119
1221
  try {
1222
+ // Reject an unrecognized runtime rather than resolving it to claude:
1223
+ // `--runtime codx` would otherwise hand back a silently Claude box.
1224
+ if (
1225
+ opts.runtime !== undefined &&
1226
+ opts.runtime !== "claude" &&
1227
+ opts.runtime !== "codex"
1228
+ ) {
1229
+ console.error(
1230
+ chalk.red(
1231
+ `Invalid --runtime '${opts.runtime}': must be 'claude' or 'codex'.`,
1232
+ ),
1233
+ );
1234
+ process.exit(1);
1235
+ }
1120
1236
  const agentRuntime =
1121
1237
  opts.runtime === "codex"
1122
1238
  ? "codex"
1123
- : opts.runtime
1239
+ : opts.runtime === "claude"
1124
1240
  ? "claude"
1125
1241
  : undefined;
1126
1242
  let diskSizeGb: number | undefined;
1127
1243
  if (opts.disk !== undefined) {
1128
1244
  diskSizeGb = Number(opts.disk);
1129
1245
  if (!Number.isFinite(diskSizeGb) || diskSizeGb <= 0) {
1130
- console.error(chalk.red(`Invalid --disk '${opts.disk}': must be a positive number of GB.`));
1246
+ console.error(
1247
+ chalk.red(
1248
+ `Invalid --disk '${opts.disk}': must be a positive number of GB.`,
1249
+ ),
1250
+ );
1131
1251
  process.exit(1);
1132
1252
  }
1133
1253
  }
@@ -1144,7 +1264,9 @@ export function registerOutpostsCommand(
1144
1264
  const refreshToken = loadCachedTokens()?.refreshToken;
1145
1265
  if (!refreshToken) {
1146
1266
  console.error(
1147
- chalk.red("No cached session found — run `hq login` first, then re-run."),
1267
+ chalk.red(
1268
+ "No cached session found — run `hq login` first, then re-run.",
1269
+ ),
1148
1270
  );
1149
1271
  process.exit(1);
1150
1272
  }
@@ -1166,7 +1288,17 @@ export function registerOutpostsCommand(
1166
1288
  err.status === 402 &&
1167
1289
  err.billing
1168
1290
  ) {
1169
- await surfaceBillingRequired(token, err.billing);
1291
+ await surfaceBillingBlocked(token, err.billing, err.message);
1292
+ process.exit(1);
1293
+ }
1294
+ // Already at the per-person cap → say so and name the box they own,
1295
+ // not a bare "Conflict".
1296
+ if (
1297
+ err instanceof OutpostHttpError &&
1298
+ err.status === 409 &&
1299
+ err.capped
1300
+ ) {
1301
+ surfaceOutpostCapped(err.capped);
1170
1302
  process.exit(1);
1171
1303
  }
1172
1304
  throw err;
@@ -1198,7 +1330,10 @@ export function registerOutpostsCommand(
1198
1330
  4,
1199
1331
  ...rows.map((r) => (r.instanceName ?? "").length),
1200
1332
  );
1201
- const regionW = Math.max(6, ...rows.map((r) => (r.region ?? "").length));
1333
+ const regionW = Math.max(
1334
+ 6,
1335
+ ...rows.map((r) => (r.region ?? "").length),
1336
+ );
1202
1337
  const rtW = Math.max(
1203
1338
  7,
1204
1339
  ...rows.map((r) => (r.agentRuntime ?? "").length),
@@ -1295,7 +1430,9 @@ export function registerOutpostsCommand(
1295
1430
  try {
1296
1431
  const command = joinCommandParts(commandParts);
1297
1432
  if (!command.trim()) {
1298
- console.error(chalk.red("No command given. Usage: hq outposts exec -- <command>"));
1433
+ console.error(
1434
+ chalk.red("No command given. Usage: hq outposts exec -- <command>"),
1435
+ );
1299
1436
  process.exit(1);
1300
1437
  }
1301
1438
  if (opts.async && opts.detach) {
@@ -1439,11 +1576,17 @@ export function registerOutpostsCommand(
1439
1576
  if (result.stderr) process.stderr.write(result.stderr);
1440
1577
  if (result.truncated) {
1441
1578
  console.error(
1442
- chalk.yellow("(output truncated by SSM's inline limit — redirect to a file on the box for full output)"),
1579
+ chalk.yellow(
1580
+ "(output truncated by SSM's inline limit — redirect to a file on the box for full output)",
1581
+ ),
1443
1582
  );
1444
1583
  }
1445
1584
  if (result.status !== "Success" && result.exitCode === null) {
1446
- console.error(chalk.yellow(`(command ended with SSM status: ${result.status})`));
1585
+ console.error(
1586
+ chalk.yellow(
1587
+ `(command ended with SSM status: ${result.status})`,
1588
+ ),
1589
+ );
1447
1590
  }
1448
1591
  }
1449
1592
  // Propagate the remote exit code so `hq outposts exec -- false` exits 1.
@@ -1458,7 +1601,9 @@ export function registerOutpostsCommand(
1458
1601
  const access = await getOutpostSshAccess(token, opts.id);
1459
1602
  const ssh = execViaSsh(access, remoteCommand);
1460
1603
  if (ssh.error) {
1461
- console.error(chalk.red(`Could not run the command over SSH: ${ssh.error}`));
1604
+ console.error(
1605
+ chalk.red(`Could not run the command over SSH: ${ssh.error}`),
1606
+ );
1462
1607
  process.exit(1);
1463
1608
  }
1464
1609
  if (opts.json) {
@@ -1553,7 +1698,9 @@ export function registerOutpostsCommand(
1553
1698
  const command = joinCommandParts(commandParts);
1554
1699
  if (!command.trim()) {
1555
1700
  console.error(
1556
- chalk.red("No command given. Usage: hq outposts exec-submit -- <command>"),
1701
+ chalk.red(
1702
+ "No command given. Usage: hq outposts exec-submit -- <command>",
1703
+ ),
1557
1704
  );
1558
1705
  process.exit(1);
1559
1706
  }
@@ -1602,7 +1749,10 @@ export function registerOutpostsCommand(
1602
1749
  outposts
1603
1750
  .command("exec-result")
1604
1751
  .description("Fetch the result of an asynchronous Outpost command")
1605
- .requiredOption("--command-id <commandId>", "Command id returned by exec-submit")
1752
+ .requiredOption(
1753
+ "--command-id <commandId>",
1754
+ "Command id returned by exec-submit",
1755
+ )
1606
1756
  .option("--id <outpostId>", "Outpost id (defaults to your primary box)")
1607
1757
  .option("--wait", "Poll until the command reaches a terminal state")
1608
1758
  .option("--json", "Emit raw JSON")
@@ -1705,7 +1855,11 @@ export function registerOutpostsCommand(
1705
1855
  try {
1706
1856
  const trimmed = code.trim();
1707
1857
  if (!trimmed) {
1708
- console.error(chalk.red("Provide the sign-in code: hq outposts login-code <code>"));
1858
+ console.error(
1859
+ chalk.red(
1860
+ "Provide the sign-in code: hq outposts login-code <code>",
1861
+ ),
1862
+ );
1709
1863
  process.exit(1);
1710
1864
  }
1711
1865
  const token = await ensureCognitoToken();
@@ -0,0 +1,115 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+
3
+ vi.mock('../utils/cognito-session.js', () => ({
4
+ ensureCognitoToken: vi.fn(async () => 'test-token'),
5
+ }));
6
+
7
+ vi.mock('../utils/vault-api.js', () => ({
8
+ getCompanyUid: vi.fn(async () => 'cmp_selected'),
9
+ vaultApiFetchPublic: vi.fn(),
10
+ }));
11
+
12
+ vi.mock('../utils/secrets-cache.js', () => ({
13
+ listSecretCacheScopes: vi.fn(() => []),
14
+ }));
15
+
16
+ vi.mock('./secrets.js', () => ({
17
+ loadRevealedSecrets: vi.fn(),
18
+ }));
19
+
20
+ import { ensureCognitoToken } from '../utils/cognito-session.js';
21
+ import { getCompanyUid } from '../utils/vault-api.js';
22
+ import { listSecretCacheScopes } from '../utils/secrets-cache.js';
23
+ import { loadRevealedSecrets } from './secrets.js';
24
+ import { makeInstallSecretResolver } from './pack-install.js';
25
+
26
+ describe('install-time MCP secret authorization', () => {
27
+ beforeEach(() => {
28
+ vi.clearAllMocks();
29
+ vi.mocked(ensureCognitoToken).mockResolvedValue('test-token');
30
+ vi.mocked(getCompanyUid).mockResolvedValue('cmp_selected');
31
+ vi.mocked(listSecretCacheScopes).mockReturnValue([]);
32
+ });
33
+
34
+ it('uses a fresh server response for one unambiguous cached scope', async () => {
35
+ vi.mocked(listSecretCacheScopes).mockReturnValue(['cmp_cached']);
36
+ vi.mocked(loadRevealedSecrets).mockResolvedValue(
37
+ new Map([['API_KEY', 'fresh-server-value']]),
38
+ );
39
+
40
+ const resolve = await makeInstallSecretResolver(['API_KEY']);
41
+
42
+ expect(loadRevealedSecrets).toHaveBeenCalledWith(
43
+ 'test-token',
44
+ 'cmp_cached',
45
+ ['API_KEY'],
46
+ );
47
+ expect(resolve('API_KEY')).toBe('fresh-server-value');
48
+ });
49
+
50
+ it('fails closed without reading a cached value when scopes are ambiguous', async () => {
51
+ vi.mocked(listSecretCacheScopes).mockReturnValue(['cmp_one', 'cmp_two']);
52
+
53
+ const resolve = await makeInstallSecretResolver(['API_KEY']);
54
+
55
+ expect(loadRevealedSecrets).not.toHaveBeenCalled();
56
+ expect(resolve('API_KEY')).toBeNull();
57
+ });
58
+
59
+ it('uses an explicit company to resolve and authorize the target scope', async () => {
60
+ vi.mocked(listSecretCacheScopes).mockReturnValue(['cmp_one', 'cmp_two']);
61
+ vi.mocked(loadRevealedSecrets).mockResolvedValue(
62
+ new Map([['API_KEY', 'selected-company-value']]),
63
+ );
64
+
65
+ const resolve = await makeInstallSecretResolver(['API_KEY'], 'indigo');
66
+
67
+ expect(getCompanyUid).toHaveBeenCalledWith('test-token', 'indigo');
68
+ expect(loadRevealedSecrets).toHaveBeenCalledWith(
69
+ 'test-token',
70
+ 'cmp_selected',
71
+ ['API_KEY'],
72
+ );
73
+ expect(resolve('API_KEY')).toBe('selected-company-value');
74
+ });
75
+
76
+ it.each([
77
+ ['forbidden', new Error('Forbidden')],
78
+ ['not found', new Error('Secret not found')],
79
+ ['offline', new TypeError('fetch failed: ENETUNREACH')],
80
+ ['timeout', new DOMException('The operation was aborted due to timeout', 'TimeoutError')],
81
+ ])('defers registration when online authorization is %s', async (_label, failure) => {
82
+ vi.mocked(listSecretCacheScopes).mockReturnValue(['cmp_cached']);
83
+ vi.mocked(loadRevealedSecrets).mockRejectedValue(failure);
84
+
85
+ const resolve = await makeInstallSecretResolver(['API_KEY']);
86
+
87
+ expect(resolve('API_KEY')).toBeNull();
88
+ });
89
+
90
+ it('does not reuse an earlier authorized value after the server revokes access', async () => {
91
+ vi.mocked(listSecretCacheScopes).mockReturnValue(['cmp_cached']);
92
+ vi.mocked(loadRevealedSecrets)
93
+ .mockResolvedValueOnce(new Map([['API_KEY', 'initial-authorized-value']]))
94
+ .mockRejectedValueOnce(new Error('No read permission'));
95
+
96
+ const initiallyAuthorized = await makeInstallSecretResolver(['API_KEY']);
97
+ const afterRevocation = await makeInstallSecretResolver(['API_KEY']);
98
+
99
+ expect(initiallyAuthorized('API_KEY')).toBe('initial-authorized-value');
100
+ expect(afterRevocation('API_KEY')).toBeNull();
101
+ expect(loadRevealedSecrets).toHaveBeenCalledTimes(2);
102
+ });
103
+
104
+ it('fails closed when no non-interactive HQ session is available', async () => {
105
+ vi.mocked(ensureCognitoToken).mockRejectedValue(
106
+ new Error('No valid HQ session'),
107
+ );
108
+ vi.mocked(listSecretCacheScopes).mockReturnValue(['cmp_cached']);
109
+
110
+ const resolve = await makeInstallSecretResolver(['API_KEY']);
111
+
112
+ expect(loadRevealedSecrets).not.toHaveBeenCalled();
113
+ expect(resolve('API_KEY')).toBeNull();
114
+ });
115
+ });
@@ -127,6 +127,10 @@ describe('pack-install: install path layout', () => {
127
127
  });
128
128
 
129
129
  // ---- 3. scan-packages.sh resolution -------------------------------------
130
+ // Spawns a real `bash` subprocess (spawnSync), which under the saturated
131
+ // full-suite parallel pool can take well past vitest's 5s default. The work
132
+ // is a trivial `touch`; the extra budget is for scheduling contention, not
133
+ // slow logic. Isolated, this runs in milliseconds.
130
134
  it('runScanPackages invokes core/scripts/scan-packages.sh when present', () => {
131
135
  const scriptDir = path.join(hqRoot, 'core', 'scripts');
132
136
  fs.mkdirSync(scriptDir, { recursive: true });
@@ -142,7 +146,7 @@ describe('pack-install: install path layout', () => {
142
146
  runScanPackages(hqRoot);
143
147
 
144
148
  expect(fs.existsSync(sentinel)).toBe(true);
145
- });
149
+ }, 30_000);
146
150
 
147
151
  it('runScanPackages skips with a dim warning when core/scripts/scan-packages.sh is absent', () => {
148
152
  // No script anywhere — runScanPackages must not throw, must not invoke