@indigoai-us/hq-cli 5.55.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.
- package/CHANGELOG.md +27 -0
- package/dist/commands/cloud.d.ts +9 -0
- package/dist/commands/cloud.js +47 -9
- package/dist/commands/groups.d.ts +4 -0
- package/dist/commands/groups.js +13 -8
- package/dist/commands/meetings.js +19 -8
- package/dist/commands/secrets.d.ts +2 -1
- package/dist/commands/secrets.js +98 -24
- package/dist/utils/sandbox-runner-client.d.ts +39 -0
- package/dist/utils/sandbox-runner-client.js +113 -0
- package/package.json +1 -1
- package/src/commands/cloud.push-all.test.ts +29 -0
- package/src/commands/cloud.scope-excluded-warning.test.ts +22 -0
- package/src/commands/cloud.ts +60 -11
- package/src/commands/groups.test.ts +44 -0
- package/src/commands/groups.ts +13 -6
- package/src/commands/meetings.test.ts +125 -0
- package/src/commands/meetings.ts +24 -5
- package/src/commands/secrets.test.ts +194 -1
- package/src/commands/secrets.ts +122 -24
- package/src/utils/sandbox-runner-client.test.ts +125 -0
- package/src/utils/sandbox-runner-client.ts +175 -0
|
@@ -56,10 +56,16 @@ vi.mock("node:child_process", () => ({
|
|
|
56
56
|
}));
|
|
57
57
|
|
|
58
58
|
import { Command } from "commander";
|
|
59
|
-
import {
|
|
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.
|
package/src/commands/secrets.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
+
});
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
export type SandboxRunnerState = "queued" | "running" | "succeeded" | "failed";
|
|
2
|
+
|
|
3
|
+
export interface SandboxRunnerStartRequest {
|
|
4
|
+
skillId: string;
|
|
5
|
+
companyUid: string;
|
|
6
|
+
args?: Record<string, unknown>;
|
|
7
|
+
companySlug?: string;
|
|
8
|
+
only?: string[];
|
|
9
|
+
usage?: unknown;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface SandboxRunnerStartResponse {
|
|
13
|
+
jobId: string;
|
|
14
|
+
status: SandboxRunnerState;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface SandboxRunnerJob {
|
|
18
|
+
jobId: string;
|
|
19
|
+
status: SandboxRunnerState;
|
|
20
|
+
stdout?: string;
|
|
21
|
+
stderr?: string;
|
|
22
|
+
logsTail?: string;
|
|
23
|
+
exitCode?: number;
|
|
24
|
+
error?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface SandboxRunnerClientOptions {
|
|
28
|
+
baseUrl?: string;
|
|
29
|
+
fetchImpl?: typeof fetch;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface SandboxRunnerPollOptions {
|
|
33
|
+
intervalMs?: number;
|
|
34
|
+
maxPolls?: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const DEFAULT_SANDBOX_RUNNER_URL = "https://hqapi.getindigo.ai/sandbox";
|
|
38
|
+
|
|
39
|
+
function normalizeBaseUrl(baseUrl: string): string {
|
|
40
|
+
return baseUrl.replace(/\/+$/, "");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function getSandboxRunnerBaseUrl(): string {
|
|
44
|
+
return normalizeBaseUrl(
|
|
45
|
+
process.env.HQ_SANDBOX_RUNNER_URL ?? DEFAULT_SANDBOX_RUNNER_URL,
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function isSandboxRunnerState(value: unknown): value is SandboxRunnerState {
|
|
50
|
+
return value === "queued" || value === "running" || value === "succeeded" || value === "failed";
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function parseJsonResponse(res: Response): Promise<Record<string, unknown>> {
|
|
54
|
+
return (await res.json().catch(() => ({}))) as Record<string, unknown>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function requireString(body: Record<string, unknown>, key: string): string {
|
|
58
|
+
const value = body[key];
|
|
59
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
60
|
+
throw new Error(`Sandbox Runner returned an invalid '${key}'.`);
|
|
61
|
+
}
|
|
62
|
+
return value;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function normalizeJob(
|
|
66
|
+
body: Record<string, unknown>,
|
|
67
|
+
jobIdFallback?: string,
|
|
68
|
+
): SandboxRunnerJob {
|
|
69
|
+
const status = body.status;
|
|
70
|
+
if (!isSandboxRunnerState(status)) {
|
|
71
|
+
throw new Error("Sandbox Runner returned an invalid job status.");
|
|
72
|
+
}
|
|
73
|
+
const output =
|
|
74
|
+
typeof body.stdout === "string"
|
|
75
|
+
? body.stdout
|
|
76
|
+
: typeof body.output === "string"
|
|
77
|
+
? body.output
|
|
78
|
+
: undefined;
|
|
79
|
+
return {
|
|
80
|
+
jobId:
|
|
81
|
+
typeof body.jobId === "string" && body.jobId.length > 0
|
|
82
|
+
? body.jobId
|
|
83
|
+
: jobIdFallback ?? requireString(body, "jobId"),
|
|
84
|
+
status,
|
|
85
|
+
stdout: output,
|
|
86
|
+
stderr: typeof body.stderr === "string" ? body.stderr : undefined,
|
|
87
|
+
logsTail: typeof body.logsTail === "string" ? body.logsTail : undefined,
|
|
88
|
+
exitCode: typeof body.exitCode === "number" ? body.exitCode : undefined,
|
|
89
|
+
error: typeof body.error === "string" ? body.error : undefined,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function delay(ms: number): Promise<void> {
|
|
94
|
+
if (ms <= 0) return Promise.resolve();
|
|
95
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export class SandboxRunnerClient {
|
|
99
|
+
private readonly baseUrl: string;
|
|
100
|
+
private readonly fetchImpl: typeof fetch;
|
|
101
|
+
|
|
102
|
+
constructor(options: SandboxRunnerClientOptions = {}) {
|
|
103
|
+
this.baseUrl = normalizeBaseUrl(options.baseUrl ?? getSandboxRunnerBaseUrl());
|
|
104
|
+
this.fetchImpl = options.fetchImpl ?? fetch;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async startJob(
|
|
108
|
+
token: string,
|
|
109
|
+
request: SandboxRunnerStartRequest,
|
|
110
|
+
): Promise<SandboxRunnerStartResponse> {
|
|
111
|
+
const res = await this.fetchImpl(`${this.baseUrl}/jobs`, {
|
|
112
|
+
method: "POST",
|
|
113
|
+
headers: {
|
|
114
|
+
Authorization: `Bearer ${token}`,
|
|
115
|
+
"Content-Type": "application/json",
|
|
116
|
+
},
|
|
117
|
+
body: JSON.stringify(request),
|
|
118
|
+
});
|
|
119
|
+
const body = await parseJsonResponse(res);
|
|
120
|
+
if (!res.ok) {
|
|
121
|
+
const message =
|
|
122
|
+
typeof body.message === "string"
|
|
123
|
+
? body.message
|
|
124
|
+
: typeof body.error === "string"
|
|
125
|
+
? body.error
|
|
126
|
+
: res.statusText;
|
|
127
|
+
throw new Error(`Sandbox Runner rejected job: ${message}`);
|
|
128
|
+
}
|
|
129
|
+
const status = body.status;
|
|
130
|
+
if (!isSandboxRunnerState(status)) {
|
|
131
|
+
throw new Error("Sandbox Runner returned an invalid start status.");
|
|
132
|
+
}
|
|
133
|
+
return {
|
|
134
|
+
jobId: requireString(body, "jobId"),
|
|
135
|
+
status,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async getJob(token: string, jobId: string): Promise<SandboxRunnerJob> {
|
|
140
|
+
const res = await this.fetchImpl(
|
|
141
|
+
`${this.baseUrl}/jobs/${encodeURIComponent(jobId)}`,
|
|
142
|
+
{
|
|
143
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
144
|
+
},
|
|
145
|
+
);
|
|
146
|
+
const body = await parseJsonResponse(res);
|
|
147
|
+
if (!res.ok) {
|
|
148
|
+
const message =
|
|
149
|
+
typeof body.message === "string"
|
|
150
|
+
? body.message
|
|
151
|
+
: typeof body.error === "string"
|
|
152
|
+
? body.error
|
|
153
|
+
: res.statusText;
|
|
154
|
+
throw new Error(`Sandbox Runner job lookup failed: ${message}`);
|
|
155
|
+
}
|
|
156
|
+
return normalizeJob(body, jobId);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async pollJob(
|
|
160
|
+
token: string,
|
|
161
|
+
jobId: string,
|
|
162
|
+
options: SandboxRunnerPollOptions = {},
|
|
163
|
+
): Promise<SandboxRunnerJob> {
|
|
164
|
+
const intervalMs = options.intervalMs ?? 1000;
|
|
165
|
+
const maxPolls = options.maxPolls ?? 300;
|
|
166
|
+
for (let attempt = 0; attempt < maxPolls; attempt += 1) {
|
|
167
|
+
const job = await this.getJob(token, jobId);
|
|
168
|
+
if (job.status === "succeeded" || job.status === "failed") {
|
|
169
|
+
return job;
|
|
170
|
+
}
|
|
171
|
+
await delay(intervalMs);
|
|
172
|
+
}
|
|
173
|
+
throw new Error(`Sandbox Runner job '${jobId}' did not finish before the poll limit.`);
|
|
174
|
+
}
|
|
175
|
+
}
|