@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.
- package/dist/commands/mcp-registration.d.ts +4 -5
- package/dist/commands/mcp-registration.js +5 -4
- package/dist/commands/outposts.d.ts +20 -4
- package/dist/commands/outposts.js +77 -8
- package/dist/commands/pack-install.d.ts +14 -17
- package/dist/commands/pack-install.js +53 -29
- package/dist/commands/pkg-install.js +3 -1
- package/dist/commands/run.d.ts +2 -0
- package/dist/commands/run.js +9 -3
- package/dist/commands/secrets.js +189 -87
- package/dist/run/hq-plugin.js +94 -31
- package/dist/utils/sandbox-runner-client.d.ts +1 -0
- package/dist/utils/sandbox-runner-client.js +1 -0
- package/dist/utils/secrets-cache.d.ts +4 -5
- package/dist/utils/secrets-cache.js +5 -8
- package/package.json +3 -2
- package/pnpm-workspace.yaml +2 -0
- package/src/commands/mcp-registration.ts +9 -9
- package/src/commands/outposts.test.ts +118 -24
- package/src/commands/outposts.ts +197 -43
- package/src/commands/pack-install-secret-authorization.test.ts +115 -0
- package/src/commands/pack-install.test.ts +5 -1
- package/src/commands/pack-install.ts +67 -29
- package/src/commands/pkg-install.ts +3 -1
- package/src/commands/run.test.ts +45 -0
- package/src/commands/run.ts +20 -4
- package/src/commands/secrets.test.ts +366 -25
- package/src/commands/secrets.ts +222 -96
- package/src/run/hq-plugin.test.ts +186 -10
- package/src/run/hq-plugin.ts +102 -32
- package/src/utils/__fixtures__/scan-packages.generated-block.sh +23 -0
- package/src/utils/pack-contributions.test.ts +90 -31
- package/src/utils/sandbox-runner-client.test.ts +28 -0
- package/src/utils/sandbox-runner-client.ts +2 -0
- package/src/utils/secrets-cache.ts +5 -8
- package/test/commands/signals.test.ts +2 -2
- package/test/commands/sources.test.ts +2 -2
- package/test/helpers/vault-service-mock.ts +76 -17
- 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}
|
|
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
|
|
1109
|
-
*
|
|
1110
|
-
*
|
|
1111
|
-
*
|
|
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
|
|
1150
|
-
'
|
|
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(
|
|
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(
|
|
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, {
|
|
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
|
-
|
|
242
|
-
)
|
|
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
|
-
|
|
291
|
-
)
|
|
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:
|
|
466
|
+
message:
|
|
467
|
+
'outpost is "provisioning", not ready — it must be running to exec',
|
|
398
468
|
}),
|
|
399
469
|
);
|
|
400
|
-
await expect(
|
|
401
|
-
|
|
402
|
-
)
|
|
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, {
|
|
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(
|
|
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([
|
|
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
|
|
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, {
|
|
947
|
+
jsonResponse(429, {
|
|
948
|
+
error: true,
|
|
949
|
+
step: "rate",
|
|
950
|
+
message: "slow down",
|
|
951
|
+
}),
|
|
858
952
|
)
|
|
859
953
|
.mockResolvedValueOnce(
|
|
860
954
|
jsonResponse(200, {
|