@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.
- package/CHANGELOG.md +27 -0
- package/dist/commands/groups.d.ts +4 -0
- package/dist/commands/groups.js +13 -8
- package/dist/commands/meetings.js +9 -4
- 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/groups.test.ts +44 -0
- package/src/commands/groups.ts +13 -6
- package/src/commands/meetings.test.ts +46 -0
- package/src/commands/meetings.ts +8 -2
- 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
package/src/commands/meetings.ts
CHANGED
|
@@ -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.
|
|
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
|
|
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 {
|
|
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
|
+
});
|