@indigoai-us/hq-cli 5.56.0 → 5.57.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.
@@ -150,8 +150,13 @@ function printMeetingTable(meetings: MeetingListItem[]): void {
150
150
  return;
151
151
  }
152
152
 
153
+ // Titles can come back null/undefined from the API even though the type says
154
+ // string; fall back to a placeholder so width calc + rendering never crash on
155
+ // `undefined.length`.
156
+ const displayTitle = (m: MeetingListItem): string => m.title ?? "(untitled)";
157
+
153
158
  const ID_W = 8;
154
- const TITLE_W = Math.min(40, Math.max(10, ...meetings.map((m) => m.title.length)));
159
+ const TITLE_W = Math.min(40, Math.max(10, ...meetings.map((m) => displayTitle(m).length)));
155
160
  const DATE_W = 16;
156
161
  const DUR_W = 8;
157
162
  const STATUS_W = 12;
@@ -174,7 +179,8 @@ function printMeetingTable(meetings: MeetingListItem[]): void {
174
179
 
175
180
  for (const m of meetings) {
176
181
  const id = m.meetingId.slice(0, 8);
177
- const title = m.title.length > TITLE_W ? m.title.slice(0, TITLE_W - 1) + "…" : m.title;
182
+ const fullTitle = displayTitle(m);
183
+ const title = fullTitle.length > TITLE_W ? fullTitle.slice(0, TITLE_W - 1) + "…" : fullTitle;
178
184
  const date = new Date(m.startTime).toLocaleDateString("en-US", {
179
185
  month: "short",
180
186
  day: "numeric",
@@ -56,10 +56,16 @@ vi.mock("node:child_process", () => ({
56
56
  }));
57
57
 
58
58
  import { Command } from "commander";
59
- import { registerSecretsCommand, loadRevealedSecrets } from "./secrets.js";
59
+ import {
60
+ registerSecretsCommand,
61
+ loadRevealedSecrets,
62
+ type SecretUsageChannel,
63
+ } from "./secrets.js";
60
64
  import { spawn } from "node:child_process";
65
+ import { ensureCognitoToken } from "../utils/cognito-session.js";
61
66
  import { getEntityUid, vaultApiFetch } from "../utils/vault-api.js";
62
67
  import { readCache, writeCache } from "../utils/secrets-cache.js";
68
+ import { SandboxRunnerClient } from "../utils/sandbox-runner-client.js";
63
69
 
64
70
  let logSpy: MockInstance<typeof console.log>;
65
71
  let errSpy: MockInstance<typeof console.error>;
@@ -95,6 +101,193 @@ function jsonRes(body: unknown, status = 200): Response {
95
101
  });
96
102
  }
97
103
 
104
+ describe("secrets sandbox", () => {
105
+ let stdoutSpy: MockInstance<typeof process.stdout.write>;
106
+ let stderrSpy: MockInstance<typeof process.stderr.write>;
107
+ let startJobSpy: MockInstance<SandboxRunnerClient["startJob"]>;
108
+ let pollJobSpy: MockInstance<SandboxRunnerClient["pollJob"]>;
109
+ let getJobSpy: MockInstance<SandboxRunnerClient["getJob"]>;
110
+
111
+ beforeEach(() => {
112
+ stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
113
+ stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
114
+ startJobSpy = vi
115
+ .spyOn(SandboxRunnerClient.prototype, "startJob")
116
+ .mockResolvedValue({ jobId: "job_123", status: "queued" });
117
+ pollJobSpy = vi
118
+ .spyOn(SandboxRunnerClient.prototype, "pollJob")
119
+ .mockResolvedValue({
120
+ jobId: "job_123",
121
+ status: "succeeded",
122
+ stdout: "done\n",
123
+ });
124
+ getJobSpy = vi.spyOn(SandboxRunnerClient.prototype, "getJob");
125
+ });
126
+
127
+ it("registers the sandbox channel value", () => {
128
+ const channel: SecretUsageChannel = "sandbox";
129
+ expect(channel).toBe("sandbox");
130
+ });
131
+
132
+ it("parses --company, --only, and the -- <skill> separator", async () => {
133
+ const program = buildProgram();
134
+ await program.parseAsync([
135
+ "node",
136
+ "hq",
137
+ "secrets",
138
+ "sandbox",
139
+ "--company",
140
+ "acme",
141
+ "--only",
142
+ "API_KEY,OTHER_KEY",
143
+ "--",
144
+ "pull-triple-whale",
145
+ "--days",
146
+ "7",
147
+ ]);
148
+
149
+ expect(startJobSpy).toHaveBeenCalledWith("test-token", {
150
+ skillId: "pull-triple-whale",
151
+ companyUid: "prs_alice",
152
+ args: { argv: ["--days", "7"] },
153
+ companySlug: "acme",
154
+ only: ["API_KEY", "OTHER_KEY"],
155
+ usage: { channel: "sandbox" },
156
+ });
157
+ expect(stdoutSpy).toHaveBeenCalledWith("done\n");
158
+ });
159
+
160
+ it("resolves identity before posting the runner job", async () => {
161
+ const program = buildProgram();
162
+ await program.parseAsync([
163
+ "node",
164
+ "hq",
165
+ "secrets",
166
+ "--company",
167
+ "parent-co",
168
+ "sandbox",
169
+ "--",
170
+ "my-skill",
171
+ ]);
172
+
173
+ expect(ensureCognitoToken).toHaveBeenCalled();
174
+ expect(getEntityUid).toHaveBeenCalledWith("test-token", {
175
+ personal: false,
176
+ companySlug: "parent-co",
177
+ });
178
+ expect(startJobSpy).toHaveBeenCalledWith(
179
+ "test-token",
180
+ expect.objectContaining({
181
+ skillId: "my-skill",
182
+ companyUid: "prs_alice",
183
+ companySlug: "parent-co",
184
+ usage: { channel: "sandbox" },
185
+ }),
186
+ );
187
+ });
188
+
189
+ it("can attach script attestation metadata to sandbox usage", async () => {
190
+ const scriptPath = join(tempDir, "sandbox-script.sh");
191
+ const scriptBody = "#!/usr/bin/env bash\necho sandbox\n";
192
+ writeFileSync(scriptPath, scriptBody);
193
+ const expectedSha = createHash("sha256").update(scriptBody).digest("hex");
194
+
195
+ const program = buildProgram();
196
+ await program.parseAsync([
197
+ "node",
198
+ "hq",
199
+ "secrets",
200
+ "sandbox",
201
+ "--script",
202
+ scriptPath,
203
+ "--",
204
+ "my-skill",
205
+ ]);
206
+
207
+ expect(startJobSpy).toHaveBeenCalledWith(
208
+ "test-token",
209
+ expect.objectContaining({
210
+ usage: {
211
+ channel: "sandbox",
212
+ script: {
213
+ scriptId: scriptPath,
214
+ path: scriptPath,
215
+ sha256: expectedSha,
216
+ attestationLevel: "self-asserted-hash",
217
+ },
218
+ },
219
+ }),
220
+ );
221
+ });
222
+
223
+ it("never batch-loads or prints a plaintext secret value locally", async () => {
224
+ pollJobSpy.mockResolvedValueOnce({
225
+ jobId: "job_123",
226
+ status: "succeeded",
227
+ stdout: "token sk-test-secret-value\nAPI_KEY=sk-another-secret\n",
228
+ stderr: "debug sk-stderr-secret\n",
229
+ logsTail: "tail sk-log-secret\n",
230
+ });
231
+
232
+ const program = buildProgram();
233
+ await program.parseAsync([
234
+ "node",
235
+ "hq",
236
+ "secrets",
237
+ "sandbox",
238
+ "--only",
239
+ "API_KEY",
240
+ "--",
241
+ "my-skill",
242
+ ]);
243
+
244
+ expect(vaultApiFetch).not.toHaveBeenCalledWith(
245
+ expect.objectContaining({ path: expect.stringContaining("/load") }),
246
+ );
247
+ expect(vaultApiFetch).not.toHaveBeenCalledWith(
248
+ expect.objectContaining({ path: expect.stringContaining("/name/") }),
249
+ );
250
+ const rendered = [
251
+ ...stdoutSpy.mock.calls.flat().map(String),
252
+ ...stderrSpy.mock.calls.flat().map(String),
253
+ ...logSpy.mock.calls.flat().map(String),
254
+ ...errSpy.mock.calls.flat().map(String),
255
+ ].join("\n");
256
+ expect(rendered).not.toContain("sk-test-secret-value");
257
+ expect(rendered).not.toContain("sk-another-secret");
258
+ expect(rendered).not.toContain("sk-stderr-secret");
259
+ expect(rendered).not.toContain("sk-log-secret");
260
+ expect(rendered).toContain("[REDACTED]");
261
+ });
262
+
263
+ it("renders scrubbed stdout, stderr, and log tails", async () => {
264
+ pollJobSpy.mockResolvedValueOnce({
265
+ jobId: "job_123",
266
+ status: "succeeded",
267
+ stdout: "ok\nAPI_KEY=sk-stdout-secret\n",
268
+ stderr: "warn sk-stderr-secret\n",
269
+ logsTail: "tail sk-log-secret\n",
270
+ });
271
+
272
+ const program = buildProgram();
273
+ await program.parseAsync([
274
+ "node",
275
+ "hq",
276
+ "secrets",
277
+ "sandbox",
278
+ "--only",
279
+ "API_KEY",
280
+ "--",
281
+ "my-skill",
282
+ ]);
283
+
284
+ expect(stdoutSpy).toHaveBeenCalledWith("ok\nAPI_KEY=[REDACTED]\n");
285
+ expect(stderrSpy).toHaveBeenCalledWith("warn [REDACTED]\n");
286
+ expect(stderrSpy).toHaveBeenCalledWith("tail [REDACTED]\n");
287
+ expect(getJobSpy).not.toHaveBeenCalled();
288
+ });
289
+ });
290
+
98
291
  // HQ-4H: `hq secrets exists` — HEAD existence probe with shell-chaining exit
99
292
  // codes (0=present, 1=absent, 2=error). process.exit is spied so the command's
100
293
  // terminal exit doesn't kill the runner; we assert the code it requested.
@@ -24,6 +24,10 @@ import {
24
24
  getCompanyUid,
25
25
  getEntityUid,
26
26
  } from "../utils/vault-api.js";
27
+ import {
28
+ SandboxRunnerClient,
29
+ type SandboxRunnerJob,
30
+ } from "../utils/sandbox-runner-client.js";
27
31
  export type { VaultApiOptions } from "../utils/vault-api.js";
28
32
  export { vaultApiFetch, getCompanyUid, getEntityUid };
29
33
 
@@ -172,6 +176,7 @@ export type SecretUsageChannel =
172
176
  | "run"
173
177
  | "exec"
174
178
  | "env"
179
+ | "sandbox"
175
180
  | "reveal"
176
181
  | "submit-link";
177
182
 
@@ -318,6 +323,62 @@ async function buildSecretUsage(
318
323
  };
319
324
  }
320
325
 
326
+ function parseSecretNameList(input: string): string[] {
327
+ const keys = input.split(",").map((k) => k.trim()).filter(Boolean);
328
+ if (keys.length === 0) {
329
+ console.error(chalk.red("Error: --only requires at least one secret name."));
330
+ process.exit(1);
331
+ }
332
+ for (const key of keys) {
333
+ if (!SECRET_NAME_PATTERN.test(key)) {
334
+ console.error(chalk.red(`Invalid secret name '${key}': must match ^[A-Z][A-Z0-9_]*(/[A-Z][A-Z0-9_]+)*$`));
335
+ process.exit(1);
336
+ }
337
+ }
338
+ return keys;
339
+ }
340
+
341
+ function mergeScopeOpts(
342
+ parent: SecretsScopeOpts,
343
+ child: SecretsScopeOpts,
344
+ ): SecretsScopeOpts {
345
+ return {
346
+ company: child.company ?? parent.company,
347
+ personal: child.personal ?? parent.personal,
348
+ };
349
+ }
350
+
351
+ export function scrubSandboxOutput(text: string, secretNames: string[] = []): string {
352
+ let scrubbed = text;
353
+ for (const name of secretNames) {
354
+ const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
355
+ scrubbed = scrubbed.replace(
356
+ new RegExp(`\\b(${escaped})\\s*=\\s*([^\\s'"\\n]+|'[^'\\n]*'|"[^"\\n]*")`, "g"),
357
+ "$1=[REDACTED]",
358
+ );
359
+ scrubbed = scrubbed.replace(
360
+ new RegExp(`\\b(${escaped})\\s*:\\s*([^\\s'"\\n]+|'[^'\\n]*'|"[^"\\n]*")`, "g"),
361
+ "$1: [REDACTED]",
362
+ );
363
+ }
364
+ return scrubbed
365
+ .replace(/\bsk-[A-Za-z0-9_-]{8,}\b/g, "[REDACTED]")
366
+ .replace(/\bgh[pousr]_[A-Za-z0-9_]{16,}\b/g, "[REDACTED]")
367
+ .replace(/\b[A-Za-z0-9+/]{40,}={0,2}\b/g, "[REDACTED]");
368
+ }
369
+
370
+ function renderSandboxJobResult(job: SandboxRunnerJob, secretNames: string[]): void {
371
+ if (job.stdout) {
372
+ process.stdout.write(scrubSandboxOutput(job.stdout, secretNames));
373
+ }
374
+ if (job.stderr) {
375
+ process.stderr.write(scrubSandboxOutput(job.stderr, secretNames));
376
+ }
377
+ if (job.logsTail) {
378
+ process.stderr.write(scrubSandboxOutput(job.logsTail, secretNames));
379
+ }
380
+ }
381
+
321
382
  function normalizePolicyRecord(
322
383
  secretPath: string,
323
384
  data: SecretPolicyResponse,
@@ -1186,6 +1247,65 @@ export function registerSecretsCommand(program: Command): void {
1186
1247
  }
1187
1248
  });
1188
1249
 
1250
+ secrets
1251
+ .command("sandbox")
1252
+ .description("Run a skill in the hosted sandbox with secrets injected server-side")
1253
+ .option("--company <slug>", "Company slug (resolves to companyUid)")
1254
+ .option(
1255
+ "--personal",
1256
+ "Operate on the caller's personal vault (no sharing)",
1257
+ )
1258
+ .option("--only <keys>", "Comma-separated list of secret names the skill may use")
1259
+ .option("--script <path>", "Attach local script identity for script-locked secrets")
1260
+ .allowUnknownOption(true)
1261
+ .action(async (opts: { company?: string; personal?: boolean; only?: string; script?: string }, cmd: Command) => {
1262
+ try {
1263
+ const dashIndex = process.argv.indexOf("--");
1264
+ const rawArgs = dashIndex !== -1 ? process.argv.slice(dashIndex + 1) : cmd.args;
1265
+ if (rawArgs.length === 0) {
1266
+ console.error(chalk.red("Error: no skill specified. Usage: hq secrets sandbox [--company X] [--only KEY1,KEY2] -- <skill> [args...]"));
1267
+ process.exit(1);
1268
+ }
1269
+
1270
+ const [skillId, ...skillArgs] = rawArgs;
1271
+ const keys = opts.only ? parseSecretNameList(opts.only) : [];
1272
+ const token = await ensureCognitoToken();
1273
+ const scope = scopeOpts(mergeScopeOpts(secrets.opts(), opts));
1274
+ const companyUid = await getEntityUid(token, scope);
1275
+ const usage = await buildSecretUsage("sandbox", opts.script);
1276
+ const client = new SandboxRunnerClient();
1277
+ const started = await client.startJob(token, {
1278
+ skillId,
1279
+ companyUid,
1280
+ args: skillArgs.length > 0 ? { argv: skillArgs } : undefined,
1281
+ companySlug: scope.companySlug,
1282
+ only: keys.length > 0 ? keys : undefined,
1283
+ usage,
1284
+ });
1285
+ const job =
1286
+ started.status === "succeeded" || started.status === "failed"
1287
+ ? await client.getJob(token, started.jobId)
1288
+ : await client.pollJob(token, started.jobId);
1289
+
1290
+ renderSandboxJobResult(job, keys);
1291
+ if (job.status === "failed") {
1292
+ if (job.error) {
1293
+ console.error(chalk.red("Sandbox job failed:"), scrubSandboxOutput(job.error, keys));
1294
+ }
1295
+ process.exit(job.exitCode && job.exitCode > 0 ? job.exitCode : 1);
1296
+ }
1297
+ if (job.exitCode && job.exitCode !== 0) {
1298
+ process.exit(job.exitCode);
1299
+ }
1300
+ } catch (err) {
1301
+ console.error(
1302
+ chalk.red("Error:"),
1303
+ err instanceof Error ? scrubSandboxOutput(err.message) : scrubSandboxOutput(String(err)),
1304
+ );
1305
+ process.exit(1);
1306
+ }
1307
+ });
1308
+
1189
1309
  secrets
1190
1310
  .command("exec")
1191
1311
  .description("Run a command with secrets injected as env vars")
@@ -1208,18 +1328,7 @@ export function registerSecretsCommand(program: Command): void {
1208
1328
  process.exit(1);
1209
1329
  }
1210
1330
 
1211
- const keys = _opts.only.split(",").map((k) => k.trim()).filter(Boolean);
1212
- if (keys.length === 0) {
1213
- console.error(chalk.red("Error: --only requires at least one secret name."));
1214
- process.exit(1);
1215
- }
1216
-
1217
- for (const key of keys) {
1218
- if (!SECRET_NAME_PATTERN.test(key)) {
1219
- console.error(chalk.red(`Invalid secret name '${key}': must match ^[A-Z][A-Z0-9_]*(/[A-Z][A-Z0-9_]+)*$`));
1220
- process.exit(1);
1221
- }
1222
- }
1331
+ const keys = parseSecretNameList(_opts.only);
1223
1332
 
1224
1333
  const token = await ensureCognitoToken();
1225
1334
  const companyUid = await getEntityUid(
@@ -1287,18 +1396,7 @@ export function registerSecretsCommand(program: Command): void {
1287
1396
  );
1288
1397
  }
1289
1398
 
1290
- const keys = opts.only.split(",").map((k) => k.trim()).filter(Boolean);
1291
- if (keys.length === 0) {
1292
- console.error(chalk.red("Error: --only requires at least one secret name."));
1293
- process.exit(1);
1294
- }
1295
-
1296
- for (const key of keys) {
1297
- if (!SECRET_NAME_PATTERN.test(key)) {
1298
- console.error(chalk.red(`Invalid secret name '${key}': must match ^[A-Z][A-Z0-9_]*(/[A-Z][A-Z0-9_]+)*$`));
1299
- process.exit(1);
1300
- }
1301
- }
1399
+ const keys = parseSecretNameList(opts.only);
1302
1400
 
1303
1401
  const token = await ensureCognitoToken();
1304
1402
  const companyUid = await getEntityUid(
@@ -0,0 +1,125 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { SandboxRunnerClient } from "./sandbox-runner-client.js";
3
+
4
+ function jsonRes(body: unknown, status = 200): Response {
5
+ return new Response(JSON.stringify(body), {
6
+ status,
7
+ headers: { "Content-Type": "application/json" },
8
+ });
9
+ }
10
+
11
+ describe("SandboxRunnerClient", () => {
12
+ it("posts jobs to the configured runner URL with the caller JWT", async () => {
13
+ const fetchImpl = vi.fn<typeof fetch>(async () =>
14
+ jsonRes({ jobId: "job_1", status: "queued" }),
15
+ );
16
+ const client = new SandboxRunnerClient({
17
+ baseUrl: "https://runner.example/",
18
+ fetchImpl,
19
+ });
20
+
21
+ const result = await client.startJob("jwt-token", {
22
+ skillId: "my-skill",
23
+ companyUid: "cmp_123",
24
+ args: { argv: ["--x"] },
25
+ only: ["API_KEY"],
26
+ usage: { channel: "sandbox" },
27
+ });
28
+
29
+ expect(result).toEqual({ jobId: "job_1", status: "queued" });
30
+ expect(fetchImpl).toHaveBeenCalledWith("https://runner.example/jobs", {
31
+ method: "POST",
32
+ headers: {
33
+ Authorization: "Bearer jwt-token",
34
+ "Content-Type": "application/json",
35
+ },
36
+ body: JSON.stringify({
37
+ skillId: "my-skill",
38
+ companyUid: "cmp_123",
39
+ args: { argv: ["--x"] },
40
+ only: ["API_KEY"],
41
+ usage: { channel: "sandbox" },
42
+ }),
43
+ });
44
+ });
45
+
46
+ it("polls queued and running states until succeeded", async () => {
47
+ const fetchImpl = vi
48
+ .fn<typeof fetch>()
49
+ .mockResolvedValueOnce(jsonRes({ jobId: "job_1", status: "queued" }))
50
+ .mockResolvedValueOnce(jsonRes({ jobId: "job_1", status: "running" }))
51
+ .mockResolvedValueOnce(
52
+ jsonRes({ jobId: "job_1", status: "succeeded", stdout: "ok\n" }),
53
+ );
54
+ const client = new SandboxRunnerClient({
55
+ baseUrl: "https://runner.example",
56
+ fetchImpl,
57
+ });
58
+
59
+ const result = await client.pollJob("jwt-token", "job_1", {
60
+ intervalMs: 0,
61
+ });
62
+
63
+ expect(result).toEqual({
64
+ jobId: "job_1",
65
+ status: "succeeded",
66
+ stdout: "ok\n",
67
+ stderr: undefined,
68
+ logsTail: undefined,
69
+ exitCode: undefined,
70
+ error: undefined,
71
+ });
72
+ expect(fetchImpl).toHaveBeenCalledTimes(3);
73
+ expect(fetchImpl).toHaveBeenNthCalledWith(
74
+ 1,
75
+ "https://runner.example/jobs/job_1",
76
+ { headers: { Authorization: "Bearer jwt-token" } },
77
+ );
78
+ });
79
+
80
+ it("returns failed terminal jobs with error details", async () => {
81
+ const fetchImpl = vi.fn<typeof fetch>(async () =>
82
+ jsonRes({
83
+ jobId: "job_1",
84
+ status: "failed",
85
+ stderr: "boom\n",
86
+ exitCode: 2,
87
+ error: "command failed",
88
+ }),
89
+ );
90
+ const client = new SandboxRunnerClient({
91
+ baseUrl: "https://runner.example",
92
+ fetchImpl,
93
+ });
94
+
95
+ await expect(
96
+ client.pollJob("jwt-token", "job_1", { intervalMs: 0 }),
97
+ ).resolves.toMatchObject({
98
+ jobId: "job_1",
99
+ status: "failed",
100
+ stderr: "boom\n",
101
+ exitCode: 2,
102
+ error: "command failed",
103
+ });
104
+ });
105
+
106
+ it("uses the requested job id when the live status response omits it", async () => {
107
+ const fetchImpl = vi.fn<typeof fetch>(async () =>
108
+ jsonRes({ status: "succeeded", logsTail: "ok\n" }),
109
+ );
110
+ const client = new SandboxRunnerClient({
111
+ baseUrl: "https://runner.example",
112
+ fetchImpl,
113
+ });
114
+
115
+ await expect(client.getJob("jwt-token", "job_live")).resolves.toEqual({
116
+ jobId: "job_live",
117
+ status: "succeeded",
118
+ stdout: undefined,
119
+ stderr: undefined,
120
+ logsTail: "ok\n",
121
+ exitCode: undefined,
122
+ error: undefined,
123
+ });
124
+ });
125
+ });