@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
@@ -1071,7 +1071,8 @@ function restoreSafely(realTarget: string, backupDir: string): boolean {
1071
1071
  //
1072
1072
  // SECRET SAFETY (the hard rule from the PRD authModel / US-002 AC):
1073
1073
  // - `${secret:NAME}` resolves ONLY here, at emit, via the injected
1074
- // {@link SecretResolver} (production = the AES-256-GCM `secrets-cache.ts`).
1074
+ // {@link SecretResolver}. Production injects values returned by a fresh
1075
+ // server authorization; the encrypted disk cache is never a value fallback.
1075
1076
  // - The RESOLVED value lands in `~/.claude.json` — a 0600 user-global file that
1076
1077
  // is NOT synced/committed — because the runtime needs the real header to work.
1077
1078
  // - The resolved value is REDACTED from every return value and never echoed; we
@@ -1105,11 +1106,10 @@ export interface McpManifest {
1105
1106
  /**
1106
1107
  * Resolve a `${secret:NAME}` reference to its plaintext value, or return `null`
1107
1108
  * when the secret is unavailable (un-minted / TTL-expired / no company context).
1108
- * Production binds this to `secrets-cache.ts` (`readCache(companyUid, NAME)`,
1109
- * AES-256-GCM, 0600, 5-min TTL); TESTS inject a pure map so they NEVER read the
1110
- * real encrypted cache. Returning `null` for a referenced secret is a hard error
1111
- * at emit (we refuse to write a broken header), distinct from a name with no
1112
- * reference at all.
1109
+ * Production binds this to an in-memory map populated by a fresh vault `/load`
1110
+ * authorization response; tests inject a pure map. Returning `null` for a
1111
+ * referenced secret is a hard error at emit (we refuse to write a broken
1112
+ * header), distinct from a name with no reference at all.
1113
1113
  */
1114
1114
  export type SecretResolver = (name: string) => string | null;
1115
1115
 
@@ -1146,9 +1146,9 @@ export function resolveSecretRefs(
1146
1146
  const resolved = resolve(name);
1147
1147
  if (resolved === null || resolved === undefined) {
1148
1148
  throw new McpManifestError(
1149
- `cannot resolve \${secret:${name}} at emit — the secret is not in the cache ` +
1150
- '(mint it via the pack onboarding / `hq run`, then re-install). HQ refuses to ' +
1151
- 'write a half-resolved header.',
1149
+ `cannot resolve \${secret:${name}} at emit — the secret was not available ` +
1150
+ 'after online vault authorization (check login, company scope, and access, ' +
1151
+ 'then re-install). HQ refuses to write a half-resolved header.',
1152
1152
  );
1153
1153
  }
1154
1154
  if (resolved.length > 0) secretSink.add(resolved);
@@ -45,8 +45,7 @@ vi.mock("@indigoai-us/hq-cloud", async (importOriginal) => {
45
45
  // The SSH fallback shells out to `ssh` via spawnSync — stub it so tests don't
46
46
  // touch a real box or need the ssh binary.
47
47
  vi.mock("node:child_process", async (importOriginal) => {
48
- const original =
49
- await importOriginal<typeof import("node:child_process")>();
48
+ const original = await importOriginal<typeof import("node:child_process")>();
50
49
  return { ...original, spawnSync: vi.fn() };
51
50
  });
52
51
 
@@ -173,7 +172,9 @@ describe("hq outposts codex-enable / login", () => {
173
172
  });
174
173
 
175
174
  it("login-code POSTs /outpost/login-code with the code in the body", async () => {
176
- fetchSpy.mockResolvedValueOnce(jsonResponse(200, { ok: true, userId: "u1" }));
175
+ fetchSpy.mockResolvedValueOnce(
176
+ jsonResponse(200, { ok: true, userId: "u1" }),
177
+ );
177
178
  await run(["outposts", "login-code", "ABC-123"]);
178
179
  const [url, init] = fetchSpy.mock.calls[0];
179
180
  expect(String(url)).toContain("/outpost/login-code");
@@ -182,7 +183,9 @@ describe("hq outposts codex-enable / login", () => {
182
183
  });
183
184
 
184
185
  it("login-code targets a specific box via --id (outpostId query)", async () => {
185
- fetchSpy.mockResolvedValueOnce(jsonResponse(200, { ok: true, userId: "u1" }));
186
+ fetchSpy.mockResolvedValueOnce(
187
+ jsonResponse(200, { ok: true, userId: "u1" }),
188
+ );
186
189
  await run(["outposts", "login-code", "CODE", "--id", "3"]);
187
190
  const [url] = fetchSpy.mock.calls[0];
188
191
  expect(String(url)).toContain("outpostId=3");
@@ -197,7 +200,11 @@ describe("hq outposts codex-enable / login", () => {
197
200
 
198
201
  it("login-code surfaces an awaiting-login not-found error", async () => {
199
202
  fetchSpy.mockResolvedValueOnce(
200
- jsonResponse(404, { error: true, step: "not-found", message: "no outpost" }),
203
+ jsonResponse(404, {
204
+ error: true,
205
+ step: "not-found",
206
+ message: "no outpost",
207
+ }),
201
208
  );
202
209
  await expect(run(["outposts", "login-code", "CODE"])).rejects.toThrow(
203
210
  "process.exit(1)",
@@ -237,9 +244,9 @@ describe("hq outposts destroy", () => {
237
244
  fetchSpy.mockResolvedValueOnce(
238
245
  jsonResponse(500, { error: "boom", step: "teardown" }),
239
246
  );
240
- await expect(
241
- run(["outposts", "destroy", "--yes"]),
242
- ).rejects.toThrow("process.exit(1)");
247
+ await expect(run(["outposts", "destroy", "--yes"])).rejects.toThrow(
248
+ "process.exit(1)",
249
+ );
243
250
  });
244
251
  });
245
252
 
@@ -267,6 +274,68 @@ describe("hq outposts provision (billing gate)", () => {
267
274
  expect(sent.agentRuntime).toBe("codex");
268
275
  });
269
276
 
277
+ it("rejects an unknown --runtime before the charge gate and makes NO API call", async () => {
278
+ const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
279
+ await expect(
280
+ run(["outposts", "provision", "--runtime", "codx", "--yes"]),
281
+ ).rejects.toThrow("process.exit(1)");
282
+ expect(fetchSpy).not.toHaveBeenCalled();
283
+ const printed = errSpy.mock.calls
284
+ .map((c) => c.map(String).join(" "))
285
+ .join("\n");
286
+ // A typo'd runtime must not silently resolve to claude — codex is the whole
287
+ // point of the flag, so a near-miss has to name itself.
288
+ expect(printed).toContain("codx");
289
+ expect(printed).toMatch(/claude/);
290
+ expect(printed).toMatch(/codex/);
291
+ });
292
+
293
+ it("accepts an explicit --runtime claude", async () => {
294
+ fetchSpy.mockResolvedValueOnce(jsonResponse(200, { ok: true }));
295
+ await run(["outposts", "provision", "--runtime", "claude", "--yes"]);
296
+ const sent = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string);
297
+ expect(sent.agentRuntime).toBe("claude");
298
+ });
299
+
300
+ it("explains the per-person cap on a 409 rather than printing a bare 'Conflict'", async () => {
301
+ const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
302
+ // hq-pro's cap body carries `capped`/`limit`/`outposts` and NO
303
+ // `message`/`error` field, so the generic decoder falls through to
304
+ // res.statusText — the operator sees only "Conflict".
305
+ fetchSpy.mockResolvedValueOnce(
306
+ jsonResponse(409, {
307
+ capped: true,
308
+ userId: "user_test_123",
309
+ limit: 1,
310
+ outposts: [
311
+ {
312
+ outpostId: "primary",
313
+ state: "ready",
314
+ instanceName: "outpost-user_test_123",
315
+ region: "us-east-1",
316
+ agentRuntime: "claude",
317
+ platform: "ec2",
318
+ createdAt: "2026-07-01T00:00:00.000Z",
319
+ },
320
+ ],
321
+ }),
322
+ );
323
+
324
+ await expect(run(["outposts", "provision", "--yes"])).rejects.toThrow(
325
+ "process.exit(1)",
326
+ );
327
+
328
+ const printed = errSpy.mock.calls
329
+ .map((c) => c.map(String).join(" "))
330
+ .join("\n");
331
+ expect(printed).not.toMatch(/^Conflict$/m);
332
+ expect(printed).toMatch(/limit/i);
333
+ // Name the box they already own, and reassure on the charge they were
334
+ // just warned about — the cap is checked BEFORE activation billing.
335
+ expect(printed).toContain("primary");
336
+ expect(printed).toMatch(/not been charged/i);
337
+ });
338
+
270
339
  it("mints a card-capture link on 402 billing_required and exits 1", async () => {
271
340
  fetchSpy
272
341
  .mockResolvedValueOnce(
@@ -286,9 +355,9 @@ describe("hq outposts provision (billing gate)", () => {
286
355
  jsonResponse(200, { url: "https://checkout.stripe.com/outpost" }),
287
356
  );
288
357
 
289
- await expect(
290
- run(["outposts", "provision", "--yes"]),
291
- ).rejects.toThrow("process.exit(1)");
358
+ await expect(run(["outposts", "provision", "--yes"])).rejects.toThrow(
359
+ "process.exit(1)",
360
+ );
292
361
 
293
362
  expect(String(fetchSpy.mock.calls[1][0])).toContain(
294
363
  "/v1/billing/checkout/person",
@@ -394,12 +463,13 @@ describe("hq outposts exec", () => {
394
463
  jsonResponse(409, {
395
464
  error: true,
396
465
  step: "not-ready",
397
- message: 'outpost is "provisioning", not ready — it must be running to exec',
466
+ message:
467
+ 'outpost is "provisioning", not ready — it must be running to exec',
398
468
  }),
399
469
  );
400
- await expect(
401
- run(["outposts", "exec", "echo", "hi"]),
402
- ).rejects.toThrow("process.exit(1)");
470
+ await expect(run(["outposts", "exec", "echo", "hi"])).rejects.toThrow(
471
+ "process.exit(1)",
472
+ );
403
473
  });
404
474
 
405
475
  it("emits raw JSON with --json", async () => {
@@ -523,11 +593,15 @@ describe("hq outposts exec — Lightsail SSH fallback", () => {
523
593
 
524
594
  it("does NOT fall back for a non-platform error (e.g. not-ready)", async () => {
525
595
  fetchSpy.mockResolvedValueOnce(
526
- jsonResponse(409, { error: true, step: "not-ready", message: "not ready" }),
596
+ jsonResponse(409, {
597
+ error: true,
598
+ step: "not-ready",
599
+ message: "not ready",
600
+ }),
601
+ );
602
+ await expect(run(["outposts", "exec", "echo", "hi"])).rejects.toThrow(
603
+ "process.exit(1)",
527
604
  );
528
- await expect(
529
- run(["outposts", "exec", "echo", "hi"]),
530
- ).rejects.toThrow("process.exit(1)");
531
605
  // Only the SSM call happened; no ssh-access mint, no ssh.
532
606
  expect(fetchSpy).toHaveBeenCalledTimes(1);
533
607
  expect(mockSpawnSync).not.toHaveBeenCalled();
@@ -711,7 +785,9 @@ describe("hq outposts asynchronous exec", () => {
711
785
  expect.stringContaining("/outpost/exec?outpostId=2"),
712
786
  expect.stringContaining("/outpost/exec?outpostId=2"),
713
787
  ]);
714
- expect(fetchSpy.mock.calls.map(([, init]) => JSON.parse(init?.body as string))).toEqual([
788
+ expect(
789
+ fetchSpy.mock.calls.map(([, init]) => JSON.parse(init?.body as string)),
790
+ ).toEqual([
715
791
  { mode: "stage" },
716
792
  { mode: "submit", command: "cd /tmp && run-worker" },
717
793
  { mode: "result", commandId: "cmd-1" },
@@ -738,7 +814,15 @@ describe("hq outposts asynchronous exec", () => {
738
814
  )
739
815
  .mockResolvedValueOnce(new Response(null, { status: 200 }));
740
816
 
741
- await run(["outposts", "exec-stage", "--id", "2", "--file", file, "--json"]);
817
+ await run([
818
+ "outposts",
819
+ "exec-stage",
820
+ "--id",
821
+ "2",
822
+ "--file",
823
+ file,
824
+ "--json",
825
+ ]);
742
826
 
743
827
  expect(fetchSpy).toHaveBeenCalledTimes(2);
744
828
  const [uploadUrl, uploadInit] = fetchSpy.mock.calls[1];
@@ -748,7 +832,9 @@ describe("hq outposts asynchronous exec", () => {
748
832
  "Content-Length": String(expectedBytes.byteLength),
749
833
  });
750
834
  expect(Buffer.from(uploadInit?.body as Uint8Array)).toEqual(expectedBytes);
751
- const printed = stdoutSpy.mock.calls.map((call) => String(call[0])).join("");
835
+ const printed = stdoutSpy.mock.calls
836
+ .map((call) => String(call[0]))
837
+ .join("");
752
838
  expect(JSON.parse(printed)).toEqual({
753
839
  key: "input-key",
754
840
  getUrl: "https://download.invalid/get",
@@ -828,6 +914,10 @@ describe("hq outposts asynchronous exec", () => {
828
914
  });
829
915
  });
830
916
 
917
+ // Spawns a real `bash` (execFileSync) to prove the round-trip end-to-end.
918
+ // Under the saturated full-suite parallel pool that spawn can exceed vitest's
919
+ // 5s default purely on scheduling contention; the extra budget covers that,
920
+ // not slow logic (isolated, this runs in milliseconds).
831
921
  it("preserves one command part verbatim and round-trips quoted bash argv", () => {
832
922
  const single = " printf 'already formed' ";
833
923
  expect(joinCommandParts([single])).toBe(single);
@@ -844,7 +934,7 @@ describe("hq outposts asynchronous exec", () => {
844
934
  expect(execFileSync("bash", ["-c", command], { encoding: "utf8" })).toBe(
845
935
  "marker\na b\nquote's\n",
846
936
  );
847
- });
937
+ }, 30_000);
848
938
 
849
939
  it("exec-result --wait retries a 429, polls until done, and prints the result contract", async () => {
850
940
  vi.useFakeTimers();
@@ -854,7 +944,11 @@ describe("hq outposts asynchronous exec", () => {
854
944
  .mockImplementation(() => true);
855
945
  fetchSpy
856
946
  .mockResolvedValueOnce(
857
- jsonResponse(429, { error: true, step: "rate", message: "slow down" }),
947
+ jsonResponse(429, {
948
+ error: true,
949
+ step: "rate",
950
+ message: "slow down",
951
+ }),
858
952
  )
859
953
  .mockResolvedValueOnce(
860
954
  jsonResponse(200, {