@indigoai-us/hq-cli 5.56.0 → 5.58.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 +91 -24
- package/dist/utils/sandbox-runner-client.d.ts +34 -0
- package/dist/utils/sandbox-runner-client.js +106 -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 +200 -1
- package/src/commands/secrets.ts +116 -24
- package/src/utils/sandbox-runner-client.test.ts +123 -0
- package/src/utils/sandbox-runner-client.ts +162 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,33 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.55.1]
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- **`hq meetings list` no longer crashes when a meeting has no title.** The list
|
|
10
|
+
renderer read `title.length` for column sizing and row truncation, assuming
|
|
11
|
+
every meeting has a string title. The API can return a meeting whose title is
|
|
12
|
+
null/undefined, so the command printed the `Meetings (N)` header and then died
|
|
13
|
+
with `Cannot read properties of undefined (reading 'length')`. Missing titles
|
|
14
|
+
now render as `(untitled)` and the command completes normally. (#164)
|
|
15
|
+
- **`hq meetings get <short-id>` never sends a truncated id to the by-id
|
|
16
|
+
endpoint.** A short (8-char) id prefix is now resolved to the full meeting id
|
|
17
|
+
before the lookup, so short-id `get` no longer fails or targets the wrong
|
|
18
|
+
meeting. (#163)
|
|
19
|
+
- **`hq sync push` no longer reports a false-green success while silently
|
|
20
|
+
dropping files.** When a push excluded files that fell outside the caller's
|
|
21
|
+
granted write prefixes (hit by members holding only a company-wide write grant
|
|
22
|
+
on an older client), the summary now surfaces the scope-excluded files loudly
|
|
23
|
+
instead of reporting a clean success. (#162)
|
|
24
|
+
|
|
25
|
+
## [5.55.0]
|
|
26
|
+
|
|
27
|
+
### Added
|
|
28
|
+
|
|
29
|
+
- **`hq reindex --from-hook` / `--lock-timeout`** to bound the op-lock wait so a
|
|
30
|
+
hook-triggered reindex no-waits instead of blocking. (#160)
|
|
31
|
+
|
|
5
32
|
## [5.54.0]
|
|
6
33
|
|
|
7
34
|
### Added
|
|
@@ -1,3 +1,7 @@
|
|
|
1
1
|
import { Command } from "commander";
|
|
2
|
+
export declare function detectPrincipalType(principal: string): {
|
|
3
|
+
granteeType: "email" | "person";
|
|
4
|
+
granteeId: string;
|
|
5
|
+
} | null;
|
|
2
6
|
export declare function registerGroupsCommand(program: Command): void;
|
|
3
7
|
//# sourceMappingURL=groups.d.ts.map
|
package/dist/commands/groups.js
CHANGED
|
@@ -1,18 +1,23 @@
|
|
|
1
1
|
|
|
2
|
-
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="
|
|
2
|
+
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="caec6a8e-2b28-583a-acb3-1e70ab6c390c")}catch(e){}}();
|
|
3
3
|
import chalk from "chalk";
|
|
4
4
|
import { ensureCognitoToken } from "../utils/cognito-session.js";
|
|
5
5
|
import { vaultApiFetch, getCompanyUid } from "./secrets.js";
|
|
6
6
|
import { GROUP_ID_PATTERN } from "./_patterns.js";
|
|
7
7
|
const EMAIL_PATTERN = /^[^\s]+@[^\s]+$/;
|
|
8
8
|
const PERSON_UID_PATTERN = /^prs_[A-Za-z0-9_-]+$/;
|
|
9
|
-
|
|
9
|
+
const AGENT_UID_PATTERN = /^agt_[A-Za-z0-9_-]+$/;
|
|
10
|
+
const INVALID_PRINCIPAL_HINT = "must be an email address, a personUid (prs_…), or an agentUid (agt_…)";
|
|
11
|
+
export function detectPrincipalType(principal) {
|
|
10
12
|
if (EMAIL_PATTERN.test(principal)) {
|
|
11
13
|
// Server normalizes email again; we normalize here so local validation /
|
|
12
14
|
// cache keys agree with the server-side canonicalization.
|
|
13
15
|
return { granteeType: "email", granteeId: principal.trim().toLowerCase() };
|
|
14
16
|
}
|
|
15
|
-
|
|
17
|
+
// Agent uids ride the same personUid wire slot as people — that is the
|
|
18
|
+
// server contract (group members, DM recipients, and memberships all carry
|
|
19
|
+
// agt_* in the personUid field; see hq-pro handlers).
|
|
20
|
+
if (PERSON_UID_PATTERN.test(principal) || AGENT_UID_PATTERN.test(principal)) {
|
|
16
21
|
return { granteeType: "person", granteeId: principal };
|
|
17
22
|
}
|
|
18
23
|
return null;
|
|
@@ -124,7 +129,7 @@ export function registerGroupsCommand(program) {
|
|
|
124
129
|
});
|
|
125
130
|
groups
|
|
126
131
|
.command("add <groupId> <principal>")
|
|
127
|
-
.description("Add a person to a group (principal: email or
|
|
132
|
+
.description("Add a person or agent to a group (principal: email, personUid, or agentUid)")
|
|
128
133
|
.action(async (groupId, principal) => {
|
|
129
134
|
try {
|
|
130
135
|
if (!GROUP_ID_PATTERN.test(groupId)) {
|
|
@@ -133,7 +138,7 @@ export function registerGroupsCommand(program) {
|
|
|
133
138
|
}
|
|
134
139
|
const detected = detectPrincipalType(principal);
|
|
135
140
|
if (!detected) {
|
|
136
|
-
console.error(chalk.red(`Invalid principal '${principal}':
|
|
141
|
+
console.error(chalk.red(`Invalid principal '${principal}': ${INVALID_PRINCIPAL_HINT}`));
|
|
137
142
|
process.exit(1);
|
|
138
143
|
}
|
|
139
144
|
const token = await ensureCognitoToken();
|
|
@@ -174,7 +179,7 @@ export function registerGroupsCommand(program) {
|
|
|
174
179
|
});
|
|
175
180
|
groups
|
|
176
181
|
.command("remove <groupId> <principal>")
|
|
177
|
-
.description("Remove a person from a group (principal: email or
|
|
182
|
+
.description("Remove a person or agent from a group (principal: email, personUid, or agentUid)")
|
|
178
183
|
.action(async (groupId, principal) => {
|
|
179
184
|
try {
|
|
180
185
|
if (!GROUP_ID_PATTERN.test(groupId)) {
|
|
@@ -183,7 +188,7 @@ export function registerGroupsCommand(program) {
|
|
|
183
188
|
}
|
|
184
189
|
const detected = detectPrincipalType(principal);
|
|
185
190
|
if (!detected) {
|
|
186
|
-
console.error(chalk.red(`Invalid principal '${principal}':
|
|
191
|
+
console.error(chalk.red(`Invalid principal '${principal}': ${INVALID_PRINCIPAL_HINT}`));
|
|
187
192
|
process.exit(1);
|
|
188
193
|
}
|
|
189
194
|
const token = await ensureCognitoToken();
|
|
@@ -345,4 +350,4 @@ export function registerGroupsCommand(program) {
|
|
|
345
350
|
});
|
|
346
351
|
}
|
|
347
352
|
//# sourceMappingURL=groups.js.map
|
|
348
|
-
//# debugId=
|
|
353
|
+
//# debugId=caec6a8e-2b28-583a-acb3-1e70ab6c390c
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
|
|
2
|
-
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="
|
|
2
|
+
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="f2fc6ee0-c7c4-5012-85ce-aea36030aa92")}catch(e){}}();
|
|
3
3
|
import chalk from "chalk";
|
|
4
4
|
import { ensureCognitoToken } from "../utils/cognito-session.js";
|
|
5
5
|
import { vaultApiFetch, getCompanyUid } from "../utils/vault-api.js";
|
|
@@ -75,8 +75,12 @@ function printMeetingTable(meetings) {
|
|
|
75
75
|
console.log(chalk.dim(" No meetings found."));
|
|
76
76
|
return;
|
|
77
77
|
}
|
|
78
|
+
// Titles can come back null/undefined from the API even though the type says
|
|
79
|
+
// string; fall back to a placeholder so width calc + rendering never crash on
|
|
80
|
+
// `undefined.length`.
|
|
81
|
+
const displayTitle = (m) => m.title ?? "(untitled)";
|
|
78
82
|
const ID_W = 8;
|
|
79
|
-
const TITLE_W = Math.min(40, Math.max(10, ...meetings.map((m) => m.
|
|
83
|
+
const TITLE_W = Math.min(40, Math.max(10, ...meetings.map((m) => displayTitle(m).length)));
|
|
80
84
|
const DATE_W = 16;
|
|
81
85
|
const DUR_W = 8;
|
|
82
86
|
const STATUS_W = 12;
|
|
@@ -93,7 +97,8 @@ function printMeetingTable(meetings) {
|
|
|
93
97
|
].join(" ")));
|
|
94
98
|
for (const m of meetings) {
|
|
95
99
|
const id = m.meetingId.slice(0, 8);
|
|
96
|
-
const
|
|
100
|
+
const fullTitle = displayTitle(m);
|
|
101
|
+
const title = fullTitle.length > TITLE_W ? fullTitle.slice(0, TITLE_W - 1) + "…" : fullTitle;
|
|
97
102
|
const date = new Date(m.startTime).toLocaleDateString("en-US", {
|
|
98
103
|
month: "short",
|
|
99
104
|
day: "numeric",
|
|
@@ -432,4 +437,4 @@ export function registerMeetingsCommand(program) {
|
|
|
432
437
|
});
|
|
433
438
|
}
|
|
434
439
|
//# sourceMappingURL=meetings.js.map
|
|
435
|
-
//# debugId=
|
|
440
|
+
//# debugId=f2fc6ee0-c7c4-5012-85ce-aea36030aa92
|
|
@@ -4,7 +4,7 @@ export type { VaultApiOptions } from "../utils/vault-api.js";
|
|
|
4
4
|
export { vaultApiFetch, getCompanyUid, getEntityUid };
|
|
5
5
|
export type SecretTier = "standard" | "sensitive" | "nuclear";
|
|
6
6
|
export type SecretScriptLockMode = "off" | "enforced";
|
|
7
|
-
export type SecretUsageChannel = "run" | "exec" | "env" | "reveal" | "submit-link";
|
|
7
|
+
export type SecretUsageChannel = "run" | "exec" | "env" | "sandbox" | "reveal" | "submit-link";
|
|
8
8
|
export interface SecretScriptUsage {
|
|
9
9
|
scriptId: string;
|
|
10
10
|
path: string;
|
|
@@ -36,6 +36,7 @@ export interface SecretLoadResponse {
|
|
|
36
36
|
message?: string;
|
|
37
37
|
}>;
|
|
38
38
|
}
|
|
39
|
+
export declare function scrubSandboxOutput(text: string, secretNames?: string[]): string;
|
|
39
40
|
export declare function loadRevealedSecrets(token: string, companyUid: string, keys: string[], usage?: SecretUsage): Promise<Map<string, string>>;
|
|
40
41
|
export declare function registerSecretsCommand(program: Command): void;
|
|
41
42
|
//# sourceMappingURL=secrets.d.ts.map
|
package/dist/commands/secrets.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
|
|
2
|
-
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="
|
|
2
|
+
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="12b04dfd-a264-56c7-bfbb-a448c35689b3")}catch(e){}}();
|
|
3
3
|
import chalk from "chalk";
|
|
4
4
|
import * as readline from "node:readline";
|
|
5
5
|
import { spawn } from "node:child_process";
|
|
@@ -10,6 +10,7 @@ import { computeSha256 } from "../utils/integrity.js";
|
|
|
10
10
|
import { SECRET_NAME_PATTERN, GROUP_ID_PATTERN, EMAIL_PATTERN } from "./_patterns.js";
|
|
11
11
|
import { describeSecretsScope, formatSecretSaved, formatSecretsListEmpty, formatSecretsListHeader, } from "./secrets-scope.js";
|
|
12
12
|
import { vaultApiFetch, getCompanyUid, getEntityUid, } from "../utils/vault-api.js";
|
|
13
|
+
import { SandboxRunnerClient, } from "../utils/sandbox-runner-client.js";
|
|
13
14
|
export { vaultApiFetch, getCompanyUid, getEntityUid };
|
|
14
15
|
function scopeOpts(opts) {
|
|
15
16
|
if (opts.personal && opts.company) {
|
|
@@ -183,6 +184,43 @@ async function buildSecretUsage(channel, scriptPath, scriptId, attestationLevel
|
|
|
183
184
|
},
|
|
184
185
|
};
|
|
185
186
|
}
|
|
187
|
+
function parseSecretNameList(input) {
|
|
188
|
+
const keys = input.split(",").map((k) => k.trim()).filter(Boolean);
|
|
189
|
+
if (keys.length === 0) {
|
|
190
|
+
console.error(chalk.red("Error: --only requires at least one secret name."));
|
|
191
|
+
process.exit(1);
|
|
192
|
+
}
|
|
193
|
+
for (const key of keys) {
|
|
194
|
+
if (!SECRET_NAME_PATTERN.test(key)) {
|
|
195
|
+
console.error(chalk.red(`Invalid secret name '${key}': must match ^[A-Z][A-Z0-9_]*(/[A-Z][A-Z0-9_]+)*$`));
|
|
196
|
+
process.exit(1);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return keys;
|
|
200
|
+
}
|
|
201
|
+
function mergeScopeOpts(parent, child) {
|
|
202
|
+
return {
|
|
203
|
+
company: child.company ?? parent.company,
|
|
204
|
+
personal: child.personal ?? parent.personal,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
export function scrubSandboxOutput(text, secretNames = []) {
|
|
208
|
+
let scrubbed = text;
|
|
209
|
+
for (const name of secretNames) {
|
|
210
|
+
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
211
|
+
scrubbed = scrubbed.replace(new RegExp(`\\b(${escaped})\\s*=\\s*([^\\s'"\\n]+|'[^'\\n]*'|"[^"\\n]*")`, "g"), "$1=[REDACTED]");
|
|
212
|
+
scrubbed = scrubbed.replace(new RegExp(`\\b(${escaped})\\s*:\\s*([^\\s'"\\n]+|'[^'\\n]*'|"[^"\\n]*")`, "g"), "$1: [REDACTED]");
|
|
213
|
+
}
|
|
214
|
+
return scrubbed
|
|
215
|
+
.replace(/\bsk-[A-Za-z0-9_-]{8,}\b/g, "[REDACTED]")
|
|
216
|
+
.replace(/\bgh[pousr]_[A-Za-z0-9_]{16,}\b/g, "[REDACTED]")
|
|
217
|
+
.replace(/\b[A-Za-z0-9+/]{40,}={0,2}\b/g, "[REDACTED]");
|
|
218
|
+
}
|
|
219
|
+
function renderSandboxJobResult(job, secretNames) {
|
|
220
|
+
if (job.output) {
|
|
221
|
+
process.stdout.write(scrubSandboxOutput(job.output, secretNames));
|
|
222
|
+
}
|
|
223
|
+
}
|
|
186
224
|
function normalizePolicyRecord(secretPath, data) {
|
|
187
225
|
const policy = data.policy ?? { path: secretPath };
|
|
188
226
|
const scripts = Array.isArray(policy.scripts)
|
|
@@ -826,6 +864,55 @@ export function registerSecretsCommand(program) {
|
|
|
826
864
|
process.exit(1);
|
|
827
865
|
}
|
|
828
866
|
});
|
|
867
|
+
secrets
|
|
868
|
+
.command("sandbox")
|
|
869
|
+
.description("Run a command in the hosted sandbox with named secrets injected as env vars; open egress, secrets never touch this machine")
|
|
870
|
+
.option("--company <slug>", "Company slug (resolves to companyUid)")
|
|
871
|
+
.option("--personal", "Operate on the caller's personal vault (no sharing)")
|
|
872
|
+
.option("--only <keys>", "Comma-separated list of secret names to inject (required)")
|
|
873
|
+
.allowUnknownOption(true)
|
|
874
|
+
.action(async (opts, cmd) => {
|
|
875
|
+
try {
|
|
876
|
+
if (!opts.only || opts.only.trim().length === 0) {
|
|
877
|
+
console.error(chalk.red("Error: --only is required and must name at least one secret."));
|
|
878
|
+
process.exit(1);
|
|
879
|
+
}
|
|
880
|
+
const rawArgs = cmd.args;
|
|
881
|
+
const command = rawArgs.join(" ").trim();
|
|
882
|
+
if (command.length === 0) {
|
|
883
|
+
console.error(chalk.red("Error: no command specified. Usage: hq secrets sandbox --company <slug> --only KEY1,KEY2 -- <command>"));
|
|
884
|
+
process.exit(1);
|
|
885
|
+
}
|
|
886
|
+
if (command.length > 8192) {
|
|
887
|
+
console.error(chalk.red("Error: sandbox command exceeds the 8192 character limit."));
|
|
888
|
+
process.exit(1);
|
|
889
|
+
}
|
|
890
|
+
const keys = parseSecretNameList(opts.only);
|
|
891
|
+
const token = await ensureCognitoToken();
|
|
892
|
+
const scope = scopeOpts(mergeScopeOpts(secrets.opts(), opts));
|
|
893
|
+
const companyUid = await getEntityUid(token, scope);
|
|
894
|
+
const client = new SandboxRunnerClient();
|
|
895
|
+
const started = await client.startJob(token, {
|
|
896
|
+
companyUid,
|
|
897
|
+
secretNames: keys,
|
|
898
|
+
command,
|
|
899
|
+
});
|
|
900
|
+
const job = started.status === "succeeded" || started.status === "failed"
|
|
901
|
+
? await client.getJob(token, started.jobId)
|
|
902
|
+
: await client.pollJob(token, started.jobId);
|
|
903
|
+
renderSandboxJobResult(job, keys);
|
|
904
|
+
const exitCode = typeof job.exitCode === "number" ? job.exitCode : undefined;
|
|
905
|
+
if (job.success === false || (exitCode !== undefined && exitCode !== 0) || job.status === "failed") {
|
|
906
|
+
const code = exitCode && exitCode !== 0 ? exitCode : 1;
|
|
907
|
+
console.error(chalk.red(`Sandbox command failed with exit code ${code}.`));
|
|
908
|
+
process.exit(code);
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
catch (err) {
|
|
912
|
+
console.error(chalk.red("Error:"), err instanceof Error ? scrubSandboxOutput(err.message) : scrubSandboxOutput(String(err)));
|
|
913
|
+
process.exit(1);
|
|
914
|
+
}
|
|
915
|
+
});
|
|
829
916
|
secrets
|
|
830
917
|
.command("exec")
|
|
831
918
|
.description("Run a command with secrets injected as env vars")
|
|
@@ -847,17 +934,7 @@ export function registerSecretsCommand(program) {
|
|
|
847
934
|
console.error(chalk.red("Error: no command specified. Usage: hq secrets exec --only KEY1,KEY2 -- <command>"));
|
|
848
935
|
process.exit(1);
|
|
849
936
|
}
|
|
850
|
-
const keys = _opts.only
|
|
851
|
-
if (keys.length === 0) {
|
|
852
|
-
console.error(chalk.red("Error: --only requires at least one secret name."));
|
|
853
|
-
process.exit(1);
|
|
854
|
-
}
|
|
855
|
-
for (const key of keys) {
|
|
856
|
-
if (!SECRET_NAME_PATTERN.test(key)) {
|
|
857
|
-
console.error(chalk.red(`Invalid secret name '${key}': must match ^[A-Z][A-Z0-9_]*(/[A-Z][A-Z0-9_]+)*$`));
|
|
858
|
-
process.exit(1);
|
|
859
|
-
}
|
|
860
|
-
}
|
|
937
|
+
const keys = parseSecretNameList(_opts.only);
|
|
861
938
|
const token = await ensureCognitoToken();
|
|
862
939
|
const companyUid = await getEntityUid(token, scopeOpts(secrets.opts()));
|
|
863
940
|
const revealed = await loadRevealedSecrets(token, companyUid, keys, await buildSecretUsage("exec", _opts.script));
|
|
@@ -903,17 +980,7 @@ export function registerSecretsCommand(program) {
|
|
|
903
980
|
if (redact) {
|
|
904
981
|
console.error(chalk.yellow("stdout is a terminal — values redacted. Use: source <(hq secrets env --only KEY1,KEY2)"));
|
|
905
982
|
}
|
|
906
|
-
const keys = opts.only
|
|
907
|
-
if (keys.length === 0) {
|
|
908
|
-
console.error(chalk.red("Error: --only requires at least one secret name."));
|
|
909
|
-
process.exit(1);
|
|
910
|
-
}
|
|
911
|
-
for (const key of keys) {
|
|
912
|
-
if (!SECRET_NAME_PATTERN.test(key)) {
|
|
913
|
-
console.error(chalk.red(`Invalid secret name '${key}': must match ^[A-Z][A-Z0-9_]*(/[A-Z][A-Z0-9_]+)*$`));
|
|
914
|
-
process.exit(1);
|
|
915
|
-
}
|
|
916
|
-
}
|
|
983
|
+
const keys = parseSecretNameList(opts.only);
|
|
917
984
|
const token = await ensureCognitoToken();
|
|
918
985
|
const companyUid = await getEntityUid(token, scopeOpts(secrets.opts()));
|
|
919
986
|
const revealed = await loadRevealedSecrets(token, companyUid, keys, await buildSecretUsage("env", opts.script));
|
|
@@ -1187,4 +1254,4 @@ export function registerSecretsCommand(program) {
|
|
|
1187
1254
|
});
|
|
1188
1255
|
}
|
|
1189
1256
|
//# sourceMappingURL=secrets.js.map
|
|
1190
|
-
//# debugId=
|
|
1257
|
+
//# debugId=12b04dfd-a264-56c7-bfbb-a448c35689b3
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export type SandboxRunnerState = "queued" | "running" | "succeeded" | "failed";
|
|
2
|
+
export interface SandboxRunnerStartRequest {
|
|
3
|
+
companyUid: string;
|
|
4
|
+
secretNames: string[];
|
|
5
|
+
command: string;
|
|
6
|
+
}
|
|
7
|
+
export interface SandboxRunnerStartResponse {
|
|
8
|
+
jobId: string;
|
|
9
|
+
status: SandboxRunnerState;
|
|
10
|
+
}
|
|
11
|
+
export interface SandboxRunnerJob {
|
|
12
|
+
jobId: string;
|
|
13
|
+
status: SandboxRunnerState;
|
|
14
|
+
output?: string;
|
|
15
|
+
exitCode?: number;
|
|
16
|
+
success?: boolean;
|
|
17
|
+
}
|
|
18
|
+
export interface SandboxRunnerClientOptions {
|
|
19
|
+
baseUrl?: string;
|
|
20
|
+
fetchImpl?: typeof fetch;
|
|
21
|
+
}
|
|
22
|
+
export interface SandboxRunnerPollOptions {
|
|
23
|
+
intervalMs?: number;
|
|
24
|
+
maxPolls?: number;
|
|
25
|
+
}
|
|
26
|
+
export declare class SandboxRunnerClient {
|
|
27
|
+
private readonly baseUrl;
|
|
28
|
+
private readonly fetchImpl;
|
|
29
|
+
constructor(options?: SandboxRunnerClientOptions);
|
|
30
|
+
startJob(token: string, request: SandboxRunnerStartRequest): Promise<SandboxRunnerStartResponse>;
|
|
31
|
+
getJob(token: string, jobId: string): Promise<SandboxRunnerJob>;
|
|
32
|
+
pollJob(token: string, jobId: string, options?: SandboxRunnerPollOptions): Promise<SandboxRunnerJob>;
|
|
33
|
+
}
|
|
34
|
+
//# sourceMappingURL=sandbox-runner-client.d.ts.map
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
|
|
2
|
+
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="3cbec729-3b2b-5d53-9e90-5680dd725b7d")}catch(e){}}();
|
|
3
|
+
const DEFAULT_SANDBOX_RUNNER_URL = "https://hqapi.getindigo.ai/sandbox";
|
|
4
|
+
function normalizeBaseUrl(baseUrl) {
|
|
5
|
+
return baseUrl.replace(/\/+$/, "");
|
|
6
|
+
}
|
|
7
|
+
function getSandboxRunnerBaseUrl() {
|
|
8
|
+
return normalizeBaseUrl(process.env.HQ_SANDBOX_RUNNER_URL ?? DEFAULT_SANDBOX_RUNNER_URL);
|
|
9
|
+
}
|
|
10
|
+
function isSandboxRunnerState(value) {
|
|
11
|
+
return value === "queued" || value === "running" || value === "succeeded" || value === "failed";
|
|
12
|
+
}
|
|
13
|
+
async function parseJsonResponse(res) {
|
|
14
|
+
return (await res.json().catch(() => ({})));
|
|
15
|
+
}
|
|
16
|
+
function requireString(body, key) {
|
|
17
|
+
const value = body[key];
|
|
18
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
19
|
+
throw new Error(`Sandbox Runner returned an invalid '${key}'.`);
|
|
20
|
+
}
|
|
21
|
+
return value;
|
|
22
|
+
}
|
|
23
|
+
function normalizeJob(body, jobIdFallback) {
|
|
24
|
+
const status = body.status;
|
|
25
|
+
if (!isSandboxRunnerState(status)) {
|
|
26
|
+
throw new Error("Sandbox Runner returned an invalid job status.");
|
|
27
|
+
}
|
|
28
|
+
return {
|
|
29
|
+
jobId: typeof body.jobId === "string" && body.jobId.length > 0
|
|
30
|
+
? body.jobId
|
|
31
|
+
: jobIdFallback ?? requireString(body, "jobId"),
|
|
32
|
+
status,
|
|
33
|
+
output: typeof body.output === "string" ? body.output : undefined,
|
|
34
|
+
exitCode: typeof body.exitCode === "number" ? body.exitCode : undefined,
|
|
35
|
+
success: typeof body.success === "boolean" ? body.success : undefined,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
function delay(ms) {
|
|
39
|
+
if (ms <= 0)
|
|
40
|
+
return Promise.resolve();
|
|
41
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
42
|
+
}
|
|
43
|
+
export class SandboxRunnerClient {
|
|
44
|
+
baseUrl;
|
|
45
|
+
fetchImpl;
|
|
46
|
+
constructor(options = {}) {
|
|
47
|
+
this.baseUrl = normalizeBaseUrl(options.baseUrl ?? getSandboxRunnerBaseUrl());
|
|
48
|
+
this.fetchImpl = options.fetchImpl ?? fetch;
|
|
49
|
+
}
|
|
50
|
+
async startJob(token, request) {
|
|
51
|
+
const res = await this.fetchImpl(`${this.baseUrl}/jobs`, {
|
|
52
|
+
method: "POST",
|
|
53
|
+
headers: {
|
|
54
|
+
Authorization: `Bearer ${token}`,
|
|
55
|
+
"Content-Type": "application/json",
|
|
56
|
+
},
|
|
57
|
+
body: JSON.stringify(request),
|
|
58
|
+
});
|
|
59
|
+
const body = await parseJsonResponse(res);
|
|
60
|
+
if (!res.ok) {
|
|
61
|
+
const message = typeof body.message === "string"
|
|
62
|
+
? body.message
|
|
63
|
+
: typeof body.error === "string"
|
|
64
|
+
? body.error
|
|
65
|
+
: res.statusText;
|
|
66
|
+
throw new Error(`Sandbox Runner rejected job: ${message}`);
|
|
67
|
+
}
|
|
68
|
+
const status = body.status;
|
|
69
|
+
if (!isSandboxRunnerState(status)) {
|
|
70
|
+
throw new Error("Sandbox Runner returned an invalid start status.");
|
|
71
|
+
}
|
|
72
|
+
return {
|
|
73
|
+
jobId: requireString(body, "jobId"),
|
|
74
|
+
status,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
async getJob(token, jobId) {
|
|
78
|
+
const res = await this.fetchImpl(`${this.baseUrl}/jobs/${encodeURIComponent(jobId)}`, {
|
|
79
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
80
|
+
});
|
|
81
|
+
const body = await parseJsonResponse(res);
|
|
82
|
+
if (!res.ok) {
|
|
83
|
+
const message = typeof body.message === "string"
|
|
84
|
+
? body.message
|
|
85
|
+
: typeof body.error === "string"
|
|
86
|
+
? body.error
|
|
87
|
+
: res.statusText;
|
|
88
|
+
throw new Error(`Sandbox Runner job lookup failed: ${message}`);
|
|
89
|
+
}
|
|
90
|
+
return normalizeJob(body, jobId);
|
|
91
|
+
}
|
|
92
|
+
async pollJob(token, jobId, options = {}) {
|
|
93
|
+
const intervalMs = options.intervalMs ?? 1000;
|
|
94
|
+
const maxPolls = options.maxPolls ?? 300;
|
|
95
|
+
for (let attempt = 0; attempt < maxPolls; attempt += 1) {
|
|
96
|
+
const job = await this.getJob(token, jobId);
|
|
97
|
+
if (job.status === "succeeded" || job.status === "failed") {
|
|
98
|
+
return job;
|
|
99
|
+
}
|
|
100
|
+
await delay(intervalMs);
|
|
101
|
+
}
|
|
102
|
+
throw new Error(`Sandbox Runner job '${jobId}' did not finish before the poll limit.`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
//# sourceMappingURL=sandbox-runner-client.js.map
|
|
106
|
+
//# debugId=3cbec729-3b2b-5d53-9e90-5680dd725b7d
|
package/package.json
CHANGED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for `hq groups` principal detection (groups.ts).
|
|
3
|
+
*
|
|
4
|
+
* Regression coverage for feedback_edb1796a-24be-4b9f-bc60-a06f39d1c9f2:
|
|
5
|
+
* `hq groups add` rejected agent identities (agt_<ULID>), forcing per-agent
|
|
6
|
+
* secret shares instead of group-carried ACLs. Agent uids ride the same
|
|
7
|
+
* personUid wire slot as people (server contract — the group-members handler
|
|
8
|
+
* accepts granteeType "person" with an agt_* granteeId, and every ACL
|
|
9
|
+
* evaluation keys off caller.personUid, which for agent JWTs IS the agt_ uid).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { describe, expect, it } from "vitest";
|
|
13
|
+
|
|
14
|
+
import { detectPrincipalType } from "./groups.js";
|
|
15
|
+
|
|
16
|
+
describe("detectPrincipalType", () => {
|
|
17
|
+
it("detects an email principal and normalizes it", () => {
|
|
18
|
+
expect(detectPrincipalType("Jane@Example.COM")).toEqual({
|
|
19
|
+
granteeType: "email",
|
|
20
|
+
granteeId: "jane@example.com",
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("detects a personUid principal", () => {
|
|
25
|
+
expect(detectPrincipalType("prs_01ABCDEF")).toEqual({
|
|
26
|
+
granteeType: "person",
|
|
27
|
+
granteeId: "prs_01ABCDEF",
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("detects an agentUid principal and sends it in the person slot (regression)", () => {
|
|
32
|
+
expect(detectPrincipalType("agt_01KWFVWZYX8H1DN57ZPNZNKM6C")).toEqual({
|
|
33
|
+
granteeType: "person",
|
|
34
|
+
granteeId: "agt_01KWFVWZYX8H1DN57ZPNZNKM6C",
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("rejects malformed principals", () => {
|
|
39
|
+
expect(detectPrincipalType("agt_")).toBeNull();
|
|
40
|
+
expect(detectPrincipalType("prs_")).toBeNull();
|
|
41
|
+
expect(detectPrincipalType("grp_finance")).toBeNull();
|
|
42
|
+
expect(detectPrincipalType("not a principal")).toBeNull();
|
|
43
|
+
});
|
|
44
|
+
});
|
package/src/commands/groups.ts
CHANGED
|
@@ -6,8 +6,12 @@ import { GROUP_ID_PATTERN } from "./_patterns.js";
|
|
|
6
6
|
|
|
7
7
|
const EMAIL_PATTERN = /^[^\s]+@[^\s]+$/;
|
|
8
8
|
const PERSON_UID_PATTERN = /^prs_[A-Za-z0-9_-]+$/;
|
|
9
|
+
const AGENT_UID_PATTERN = /^agt_[A-Za-z0-9_-]+$/;
|
|
9
10
|
|
|
10
|
-
|
|
11
|
+
const INVALID_PRINCIPAL_HINT =
|
|
12
|
+
"must be an email address, a personUid (prs_…), or an agentUid (agt_…)";
|
|
13
|
+
|
|
14
|
+
export function detectPrincipalType(
|
|
11
15
|
principal: string,
|
|
12
16
|
): { granteeType: "email" | "person"; granteeId: string } | null {
|
|
13
17
|
if (EMAIL_PATTERN.test(principal)) {
|
|
@@ -15,7 +19,10 @@ function detectPrincipalType(
|
|
|
15
19
|
// cache keys agree with the server-side canonicalization.
|
|
16
20
|
return { granteeType: "email", granteeId: principal.trim().toLowerCase() };
|
|
17
21
|
}
|
|
18
|
-
|
|
22
|
+
// Agent uids ride the same personUid wire slot as people — that is the
|
|
23
|
+
// server contract (group members, DM recipients, and memberships all carry
|
|
24
|
+
// agt_* in the personUid field; see hq-pro handlers).
|
|
25
|
+
if (PERSON_UID_PATTERN.test(principal) || AGENT_UID_PATTERN.test(principal)) {
|
|
19
26
|
return { granteeType: "person", granteeId: principal };
|
|
20
27
|
}
|
|
21
28
|
return null;
|
|
@@ -130,7 +137,7 @@ export function registerGroupsCommand(program: Command): void {
|
|
|
130
137
|
|
|
131
138
|
groups
|
|
132
139
|
.command("add <groupId> <principal>")
|
|
133
|
-
.description("Add a person to a group (principal: email or
|
|
140
|
+
.description("Add a person or agent to a group (principal: email, personUid, or agentUid)")
|
|
134
141
|
.action(async (groupId: string, principal: string) => {
|
|
135
142
|
try {
|
|
136
143
|
if (!GROUP_ID_PATTERN.test(groupId)) {
|
|
@@ -140,7 +147,7 @@ export function registerGroupsCommand(program: Command): void {
|
|
|
140
147
|
|
|
141
148
|
const detected = detectPrincipalType(principal);
|
|
142
149
|
if (!detected) {
|
|
143
|
-
console.error(chalk.red(`Invalid principal '${principal}':
|
|
150
|
+
console.error(chalk.red(`Invalid principal '${principal}': ${INVALID_PRINCIPAL_HINT}`));
|
|
144
151
|
process.exit(1);
|
|
145
152
|
}
|
|
146
153
|
|
|
@@ -181,7 +188,7 @@ export function registerGroupsCommand(program: Command): void {
|
|
|
181
188
|
|
|
182
189
|
groups
|
|
183
190
|
.command("remove <groupId> <principal>")
|
|
184
|
-
.description("Remove a person from a group (principal: email or
|
|
191
|
+
.description("Remove a person or agent from a group (principal: email, personUid, or agentUid)")
|
|
185
192
|
.action(async (groupId: string, principal: string) => {
|
|
186
193
|
try {
|
|
187
194
|
if (!GROUP_ID_PATTERN.test(groupId)) {
|
|
@@ -191,7 +198,7 @@ export function registerGroupsCommand(program: Command): void {
|
|
|
191
198
|
|
|
192
199
|
const detected = detectPrincipalType(principal);
|
|
193
200
|
if (!detected) {
|
|
194
|
-
console.error(chalk.red(`Invalid principal '${principal}':
|
|
201
|
+
console.error(chalk.red(`Invalid principal '${principal}': ${INVALID_PRINCIPAL_HINT}`));
|
|
195
202
|
process.exit(1);
|
|
196
203
|
}
|
|
197
204
|
|
|
@@ -304,3 +304,49 @@ describe("meetings set-company", () => {
|
|
|
304
304
|
expect(output).not.toContain("Future occurrences of this recurring series will inherit this attribution.");
|
|
305
305
|
});
|
|
306
306
|
});
|
|
307
|
+
|
|
308
|
+
describe("meetings list — null-safe rendering", () => {
|
|
309
|
+
it("does not crash when a meeting has an undefined or null title", async () => {
|
|
310
|
+
// The API can return meetings with a missing/null title even though the
|
|
311
|
+
// type says string. This list previously crashed with
|
|
312
|
+
// "Cannot read properties of undefined (reading 'length')" after printing
|
|
313
|
+
// the header. Assert the command completes and renders a placeholder.
|
|
314
|
+
vi.mocked(vaultApiFetch).mockResolvedValueOnce(
|
|
315
|
+
jsonRes({
|
|
316
|
+
meetings: [
|
|
317
|
+
{
|
|
318
|
+
meetingId: "589bfd78-aaaa-bbbb-cccc-1234567890ab",
|
|
319
|
+
// title intentionally omitted (undefined)
|
|
320
|
+
status: "ready",
|
|
321
|
+
startTime: "2026-01-01T00:00:00Z",
|
|
322
|
+
endTime: "2026-01-01T00:30:00Z",
|
|
323
|
+
duration: 1800,
|
|
324
|
+
participantCount: 3,
|
|
325
|
+
hasTranscript: true,
|
|
326
|
+
hasNotes: false,
|
|
327
|
+
},
|
|
328
|
+
{
|
|
329
|
+
meetingId: "689bfd78-aaaa-bbbb-cccc-1234567890ab",
|
|
330
|
+
title: null,
|
|
331
|
+
status: "ready",
|
|
332
|
+
startTime: "2026-01-02T00:00:00Z",
|
|
333
|
+
endTime: "2026-01-02T00:30:00Z",
|
|
334
|
+
duration: 1800,
|
|
335
|
+
participantCount: 1,
|
|
336
|
+
hasTranscript: false,
|
|
337
|
+
hasNotes: true,
|
|
338
|
+
},
|
|
339
|
+
],
|
|
340
|
+
}),
|
|
341
|
+
);
|
|
342
|
+
|
|
343
|
+
const program = buildProgram();
|
|
344
|
+
await expect(
|
|
345
|
+
program.parseAsync(["node", "hq", "meetings", "list", "--company", "indigo"]),
|
|
346
|
+
).resolves.toBeDefined();
|
|
347
|
+
|
|
348
|
+
const printed = logSpy.mock.calls.map(([line]) => String(line)).join("\n");
|
|
349
|
+
expect(printed).toContain("Meetings (2)");
|
|
350
|
+
expect(printed).toContain("(untitled)");
|
|
351
|
+
});
|
|
352
|
+
});
|
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,199 @@ 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 exitSpy: MockInstance<typeof process.exit>;
|
|
108
|
+
let startJobSpy: MockInstance<SandboxRunnerClient["startJob"]>;
|
|
109
|
+
let pollJobSpy: MockInstance<SandboxRunnerClient["pollJob"]>;
|
|
110
|
+
let getJobSpy: MockInstance<SandboxRunnerClient["getJob"]>;
|
|
111
|
+
|
|
112
|
+
beforeEach(() => {
|
|
113
|
+
stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
|
|
114
|
+
stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
|
|
115
|
+
startJobSpy = vi
|
|
116
|
+
.spyOn(SandboxRunnerClient.prototype, "startJob")
|
|
117
|
+
.mockResolvedValue({ jobId: "job_123", status: "queued" });
|
|
118
|
+
pollJobSpy = vi
|
|
119
|
+
.spyOn(SandboxRunnerClient.prototype, "pollJob")
|
|
120
|
+
.mockResolvedValue({
|
|
121
|
+
jobId: "job_123",
|
|
122
|
+
status: "succeeded",
|
|
123
|
+
output: "done\n",
|
|
124
|
+
exitCode: 0,
|
|
125
|
+
success: true,
|
|
126
|
+
});
|
|
127
|
+
getJobSpy = vi.spyOn(SandboxRunnerClient.prototype, "getJob");
|
|
128
|
+
exitSpy = vi.spyOn(process, "exit").mockImplementation((() => {
|
|
129
|
+
throw new Error("__exit__");
|
|
130
|
+
}) as never);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("registers the sandbox channel value", () => {
|
|
134
|
+
const channel: SecretUsageChannel = "sandbox";
|
|
135
|
+
expect(channel).toBe("sandbox");
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("parses --company, --only, and joins args after -- into a command", async () => {
|
|
139
|
+
const program = buildProgram();
|
|
140
|
+
await program.parseAsync([
|
|
141
|
+
"node",
|
|
142
|
+
"hq",
|
|
143
|
+
"secrets",
|
|
144
|
+
"sandbox",
|
|
145
|
+
"--company",
|
|
146
|
+
"acme",
|
|
147
|
+
"--only",
|
|
148
|
+
"API_KEY,OTHER_KEY",
|
|
149
|
+
"--",
|
|
150
|
+
"node",
|
|
151
|
+
"-e",
|
|
152
|
+
"console.log('hi')",
|
|
153
|
+
"--days",
|
|
154
|
+
"7",
|
|
155
|
+
]);
|
|
156
|
+
|
|
157
|
+
expect(startJobSpy).toHaveBeenCalledWith("test-token", {
|
|
158
|
+
companyUid: "prs_alice",
|
|
159
|
+
secretNames: ["API_KEY", "OTHER_KEY"],
|
|
160
|
+
command: "node -e console.log('hi') --days 7",
|
|
161
|
+
});
|
|
162
|
+
expect(stdoutSpy).toHaveBeenCalledWith("done\n");
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("requires --only", async () => {
|
|
166
|
+
const program = buildProgram();
|
|
167
|
+
|
|
168
|
+
await expect(
|
|
169
|
+
program.parseAsync([
|
|
170
|
+
"node",
|
|
171
|
+
"hq",
|
|
172
|
+
"secrets",
|
|
173
|
+
"sandbox",
|
|
174
|
+
"--",
|
|
175
|
+
"env",
|
|
176
|
+
]),
|
|
177
|
+
).rejects.toThrow("__exit__");
|
|
178
|
+
|
|
179
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
180
|
+
expect(errSpy.mock.calls.flat().join(" ")).toMatch(/--only is required/i);
|
|
181
|
+
expect(startJobSpy).not.toHaveBeenCalled();
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("requires a command after --", async () => {
|
|
185
|
+
const program = buildProgram();
|
|
186
|
+
|
|
187
|
+
await expect(
|
|
188
|
+
program.parseAsync([
|
|
189
|
+
"node",
|
|
190
|
+
"hq",
|
|
191
|
+
"secrets",
|
|
192
|
+
"sandbox",
|
|
193
|
+
"--only",
|
|
194
|
+
"API_KEY",
|
|
195
|
+
]),
|
|
196
|
+
).rejects.toThrow("__exit__");
|
|
197
|
+
|
|
198
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
199
|
+
expect(errSpy.mock.calls.flat().join(" ")).toMatch(/no command specified/i);
|
|
200
|
+
expect(startJobSpy).not.toHaveBeenCalled();
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("resolves identity before posting the runner job", async () => {
|
|
204
|
+
const program = buildProgram();
|
|
205
|
+
await program.parseAsync([
|
|
206
|
+
"node",
|
|
207
|
+
"hq",
|
|
208
|
+
"secrets",
|
|
209
|
+
"--company",
|
|
210
|
+
"parent-co",
|
|
211
|
+
"sandbox",
|
|
212
|
+
"--only",
|
|
213
|
+
"API_KEY",
|
|
214
|
+
"--",
|
|
215
|
+
"env",
|
|
216
|
+
]);
|
|
217
|
+
|
|
218
|
+
expect(ensureCognitoToken).toHaveBeenCalled();
|
|
219
|
+
expect(getEntityUid).toHaveBeenCalledWith("test-token", {
|
|
220
|
+
personal: false,
|
|
221
|
+
companySlug: "parent-co",
|
|
222
|
+
});
|
|
223
|
+
expect(startJobSpy).toHaveBeenCalledWith("test-token", {
|
|
224
|
+
companyUid: "prs_alice",
|
|
225
|
+
secretNames: ["API_KEY"],
|
|
226
|
+
command: "env",
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("prints server-scrubbed output and never batch-loads secrets locally", async () => {
|
|
231
|
+
pollJobSpy.mockResolvedValueOnce({
|
|
232
|
+
jobId: "job_123",
|
|
233
|
+
status: "succeeded",
|
|
234
|
+
output: "token [REDACTED]\nAPI_KEY=[REDACTED]\n",
|
|
235
|
+
exitCode: 0,
|
|
236
|
+
success: true,
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
const program = buildProgram();
|
|
240
|
+
await program.parseAsync([
|
|
241
|
+
"node",
|
|
242
|
+
"hq",
|
|
243
|
+
"secrets",
|
|
244
|
+
"sandbox",
|
|
245
|
+
"--only",
|
|
246
|
+
"API_KEY",
|
|
247
|
+
"--",
|
|
248
|
+
"my-skill",
|
|
249
|
+
]);
|
|
250
|
+
|
|
251
|
+
expect(vaultApiFetch).not.toHaveBeenCalledWith(
|
|
252
|
+
expect.objectContaining({ path: expect.stringContaining("/load") }),
|
|
253
|
+
);
|
|
254
|
+
expect(vaultApiFetch).not.toHaveBeenCalledWith(
|
|
255
|
+
expect.objectContaining({ path: expect.stringContaining("/name/") }),
|
|
256
|
+
);
|
|
257
|
+
const rendered = [
|
|
258
|
+
...stdoutSpy.mock.calls.flat().map(String),
|
|
259
|
+
...stderrSpy.mock.calls.flat().map(String),
|
|
260
|
+
...logSpy.mock.calls.flat().map(String),
|
|
261
|
+
...errSpy.mock.calls.flat().map(String),
|
|
262
|
+
].join("\n");
|
|
263
|
+
expect(rendered).toContain("[REDACTED]");
|
|
264
|
+
expect(stdoutSpy).toHaveBeenCalledWith("token [REDACTED]\nAPI_KEY=[REDACTED]\n");
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it("exits non-zero when the sandbox command fails", async () => {
|
|
268
|
+
pollJobSpy.mockResolvedValueOnce({
|
|
269
|
+
jobId: "job_123",
|
|
270
|
+
status: "failed",
|
|
271
|
+
output: "boom\n",
|
|
272
|
+
exitCode: 7,
|
|
273
|
+
success: false,
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
const program = buildProgram();
|
|
277
|
+
await expect(
|
|
278
|
+
program.parseAsync([
|
|
279
|
+
"node",
|
|
280
|
+
"hq",
|
|
281
|
+
"secrets",
|
|
282
|
+
"sandbox",
|
|
283
|
+
"--only",
|
|
284
|
+
"API_KEY",
|
|
285
|
+
"--",
|
|
286
|
+
"my-skill",
|
|
287
|
+
]),
|
|
288
|
+
).rejects.toThrow("__exit__");
|
|
289
|
+
|
|
290
|
+
expect(stdoutSpy).toHaveBeenCalledWith("boom\n");
|
|
291
|
+
expect(errSpy.mock.calls.flat().join(" ")).toMatch(/exit code 7/i);
|
|
292
|
+
expect(exitSpy).toHaveBeenCalledWith(7);
|
|
293
|
+
expect(getJobSpy).not.toHaveBeenCalled();
|
|
294
|
+
});
|
|
295
|
+
});
|
|
296
|
+
|
|
98
297
|
// HQ-4H: `hq secrets exists` — HEAD existence probe with shell-chaining exit
|
|
99
298
|
// codes (0=present, 1=absent, 2=error). process.exit is spied so the command's
|
|
100
299
|
// 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,56 @@ 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.output) {
|
|
372
|
+
process.stdout.write(scrubSandboxOutput(job.output, secretNames));
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
321
376
|
function normalizePolicyRecord(
|
|
322
377
|
secretPath: string,
|
|
323
378
|
data: SecretPolicyResponse,
|
|
@@ -1186,6 +1241,65 @@ export function registerSecretsCommand(program: Command): void {
|
|
|
1186
1241
|
}
|
|
1187
1242
|
});
|
|
1188
1243
|
|
|
1244
|
+
secrets
|
|
1245
|
+
.command("sandbox")
|
|
1246
|
+
.description("Run a command in the hosted sandbox with named secrets injected as env vars; open egress, secrets never touch this machine")
|
|
1247
|
+
.option("--company <slug>", "Company slug (resolves to companyUid)")
|
|
1248
|
+
.option(
|
|
1249
|
+
"--personal",
|
|
1250
|
+
"Operate on the caller's personal vault (no sharing)",
|
|
1251
|
+
)
|
|
1252
|
+
.option("--only <keys>", "Comma-separated list of secret names to inject (required)")
|
|
1253
|
+
.allowUnknownOption(true)
|
|
1254
|
+
.action(async (opts: { company?: string; personal?: boolean; only?: string }, cmd: Command) => {
|
|
1255
|
+
try {
|
|
1256
|
+
if (!opts.only || opts.only.trim().length === 0) {
|
|
1257
|
+
console.error(chalk.red("Error: --only is required and must name at least one secret."));
|
|
1258
|
+
process.exit(1);
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
const rawArgs = cmd.args;
|
|
1262
|
+
const command = rawArgs.join(" ").trim();
|
|
1263
|
+
if (command.length === 0) {
|
|
1264
|
+
console.error(chalk.red("Error: no command specified. Usage: hq secrets sandbox --company <slug> --only KEY1,KEY2 -- <command>"));
|
|
1265
|
+
process.exit(1);
|
|
1266
|
+
}
|
|
1267
|
+
if (command.length > 8192) {
|
|
1268
|
+
console.error(chalk.red("Error: sandbox command exceeds the 8192 character limit."));
|
|
1269
|
+
process.exit(1);
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
const keys = parseSecretNameList(opts.only);
|
|
1273
|
+
const token = await ensureCognitoToken();
|
|
1274
|
+
const scope = scopeOpts(mergeScopeOpts(secrets.opts(), opts));
|
|
1275
|
+
const companyUid = await getEntityUid(token, scope);
|
|
1276
|
+
const client = new SandboxRunnerClient();
|
|
1277
|
+
const started = await client.startJob(token, {
|
|
1278
|
+
companyUid,
|
|
1279
|
+
secretNames: keys,
|
|
1280
|
+
command,
|
|
1281
|
+
});
|
|
1282
|
+
const job =
|
|
1283
|
+
started.status === "succeeded" || started.status === "failed"
|
|
1284
|
+
? await client.getJob(token, started.jobId)
|
|
1285
|
+
: await client.pollJob(token, started.jobId);
|
|
1286
|
+
|
|
1287
|
+
renderSandboxJobResult(job, keys);
|
|
1288
|
+
const exitCode = typeof job.exitCode === "number" ? job.exitCode : undefined;
|
|
1289
|
+
if (job.success === false || (exitCode !== undefined && exitCode !== 0) || job.status === "failed") {
|
|
1290
|
+
const code = exitCode && exitCode !== 0 ? exitCode : 1;
|
|
1291
|
+
console.error(chalk.red(`Sandbox command failed with exit code ${code}.`));
|
|
1292
|
+
process.exit(code);
|
|
1293
|
+
}
|
|
1294
|
+
} catch (err) {
|
|
1295
|
+
console.error(
|
|
1296
|
+
chalk.red("Error:"),
|
|
1297
|
+
err instanceof Error ? scrubSandboxOutput(err.message) : scrubSandboxOutput(String(err)),
|
|
1298
|
+
);
|
|
1299
|
+
process.exit(1);
|
|
1300
|
+
}
|
|
1301
|
+
});
|
|
1302
|
+
|
|
1189
1303
|
secrets
|
|
1190
1304
|
.command("exec")
|
|
1191
1305
|
.description("Run a command with secrets injected as env vars")
|
|
@@ -1208,18 +1322,7 @@ export function registerSecretsCommand(program: Command): void {
|
|
|
1208
1322
|
process.exit(1);
|
|
1209
1323
|
}
|
|
1210
1324
|
|
|
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
|
-
}
|
|
1325
|
+
const keys = parseSecretNameList(_opts.only);
|
|
1223
1326
|
|
|
1224
1327
|
const token = await ensureCognitoToken();
|
|
1225
1328
|
const companyUid = await getEntityUid(
|
|
@@ -1287,18 +1390,7 @@ export function registerSecretsCommand(program: Command): void {
|
|
|
1287
1390
|
);
|
|
1288
1391
|
}
|
|
1289
1392
|
|
|
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
|
-
}
|
|
1393
|
+
const keys = parseSecretNameList(opts.only);
|
|
1302
1394
|
|
|
1303
1395
|
const token = await ensureCognitoToken();
|
|
1304
1396
|
const companyUid = await getEntityUid(
|
|
@@ -0,0 +1,123 @@
|
|
|
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
|
+
companyUid: "cmp_123",
|
|
23
|
+
secretNames: ["API_KEY"],
|
|
24
|
+
command: "node script.js --x",
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
expect(result).toEqual({ jobId: "job_1", status: "queued" });
|
|
28
|
+
expect(fetchImpl).toHaveBeenCalledWith("https://runner.example/jobs", {
|
|
29
|
+
method: "POST",
|
|
30
|
+
headers: {
|
|
31
|
+
Authorization: "Bearer jwt-token",
|
|
32
|
+
"Content-Type": "application/json",
|
|
33
|
+
},
|
|
34
|
+
body: JSON.stringify({
|
|
35
|
+
companyUid: "cmp_123",
|
|
36
|
+
secretNames: ["API_KEY"],
|
|
37
|
+
command: "node script.js --x",
|
|
38
|
+
}),
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("polls queued and running states until succeeded", async () => {
|
|
43
|
+
const fetchImpl = vi
|
|
44
|
+
.fn<typeof fetch>()
|
|
45
|
+
.mockResolvedValueOnce(jsonRes({ jobId: "job_1", status: "queued" }))
|
|
46
|
+
.mockResolvedValueOnce(jsonRes({ jobId: "job_1", status: "running" }))
|
|
47
|
+
.mockResolvedValueOnce(
|
|
48
|
+
jsonRes({
|
|
49
|
+
jobId: "job_1",
|
|
50
|
+
status: "succeeded",
|
|
51
|
+
output: "ok\n",
|
|
52
|
+
exitCode: 0,
|
|
53
|
+
success: true,
|
|
54
|
+
}),
|
|
55
|
+
);
|
|
56
|
+
const client = new SandboxRunnerClient({
|
|
57
|
+
baseUrl: "https://runner.example",
|
|
58
|
+
fetchImpl,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const result = await client.pollJob("jwt-token", "job_1", {
|
|
62
|
+
intervalMs: 0,
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
expect(result).toEqual({
|
|
66
|
+
jobId: "job_1",
|
|
67
|
+
status: "succeeded",
|
|
68
|
+
output: "ok\n",
|
|
69
|
+
exitCode: 0,
|
|
70
|
+
success: true,
|
|
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
|
+
output: "boom\n",
|
|
86
|
+
exitCode: 2,
|
|
87
|
+
success: false,
|
|
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
|
+
output: "boom\n",
|
|
101
|
+
exitCode: 2,
|
|
102
|
+
success: false,
|
|
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", output: "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
|
+
output: "ok\n",
|
|
119
|
+
exitCode: undefined,
|
|
120
|
+
success: undefined,
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
});
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
export type SandboxRunnerState = "queued" | "running" | "succeeded" | "failed";
|
|
2
|
+
|
|
3
|
+
export interface SandboxRunnerStartRequest {
|
|
4
|
+
companyUid: string;
|
|
5
|
+
secretNames: string[];
|
|
6
|
+
command: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface SandboxRunnerStartResponse {
|
|
10
|
+
jobId: string;
|
|
11
|
+
status: SandboxRunnerState;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface SandboxRunnerJob {
|
|
15
|
+
jobId: string;
|
|
16
|
+
status: SandboxRunnerState;
|
|
17
|
+
output?: string;
|
|
18
|
+
exitCode?: number;
|
|
19
|
+
success?: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface SandboxRunnerClientOptions {
|
|
23
|
+
baseUrl?: string;
|
|
24
|
+
fetchImpl?: typeof fetch;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface SandboxRunnerPollOptions {
|
|
28
|
+
intervalMs?: number;
|
|
29
|
+
maxPolls?: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const DEFAULT_SANDBOX_RUNNER_URL = "https://hqapi.getindigo.ai/sandbox";
|
|
33
|
+
|
|
34
|
+
function normalizeBaseUrl(baseUrl: string): string {
|
|
35
|
+
return baseUrl.replace(/\/+$/, "");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function getSandboxRunnerBaseUrl(): string {
|
|
39
|
+
return normalizeBaseUrl(
|
|
40
|
+
process.env.HQ_SANDBOX_RUNNER_URL ?? DEFAULT_SANDBOX_RUNNER_URL,
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function isSandboxRunnerState(value: unknown): value is SandboxRunnerState {
|
|
45
|
+
return value === "queued" || value === "running" || value === "succeeded" || value === "failed";
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function parseJsonResponse(res: Response): Promise<Record<string, unknown>> {
|
|
49
|
+
return (await res.json().catch(() => ({}))) as Record<string, unknown>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function requireString(body: Record<string, unknown>, key: string): string {
|
|
53
|
+
const value = body[key];
|
|
54
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
55
|
+
throw new Error(`Sandbox Runner returned an invalid '${key}'.`);
|
|
56
|
+
}
|
|
57
|
+
return value;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function normalizeJob(
|
|
61
|
+
body: Record<string, unknown>,
|
|
62
|
+
jobIdFallback?: string,
|
|
63
|
+
): SandboxRunnerJob {
|
|
64
|
+
const status = body.status;
|
|
65
|
+
if (!isSandboxRunnerState(status)) {
|
|
66
|
+
throw new Error("Sandbox Runner returned an invalid job status.");
|
|
67
|
+
}
|
|
68
|
+
return {
|
|
69
|
+
jobId:
|
|
70
|
+
typeof body.jobId === "string" && body.jobId.length > 0
|
|
71
|
+
? body.jobId
|
|
72
|
+
: jobIdFallback ?? requireString(body, "jobId"),
|
|
73
|
+
status,
|
|
74
|
+
output: typeof body.output === "string" ? body.output : undefined,
|
|
75
|
+
exitCode: typeof body.exitCode === "number" ? body.exitCode : undefined,
|
|
76
|
+
success: typeof body.success === "boolean" ? body.success : undefined,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function delay(ms: number): Promise<void> {
|
|
81
|
+
if (ms <= 0) return Promise.resolve();
|
|
82
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export class SandboxRunnerClient {
|
|
86
|
+
private readonly baseUrl: string;
|
|
87
|
+
private readonly fetchImpl: typeof fetch;
|
|
88
|
+
|
|
89
|
+
constructor(options: SandboxRunnerClientOptions = {}) {
|
|
90
|
+
this.baseUrl = normalizeBaseUrl(options.baseUrl ?? getSandboxRunnerBaseUrl());
|
|
91
|
+
this.fetchImpl = options.fetchImpl ?? fetch;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async startJob(
|
|
95
|
+
token: string,
|
|
96
|
+
request: SandboxRunnerStartRequest,
|
|
97
|
+
): Promise<SandboxRunnerStartResponse> {
|
|
98
|
+
const res = await this.fetchImpl(`${this.baseUrl}/jobs`, {
|
|
99
|
+
method: "POST",
|
|
100
|
+
headers: {
|
|
101
|
+
Authorization: `Bearer ${token}`,
|
|
102
|
+
"Content-Type": "application/json",
|
|
103
|
+
},
|
|
104
|
+
body: JSON.stringify(request),
|
|
105
|
+
});
|
|
106
|
+
const body = await parseJsonResponse(res);
|
|
107
|
+
if (!res.ok) {
|
|
108
|
+
const message =
|
|
109
|
+
typeof body.message === "string"
|
|
110
|
+
? body.message
|
|
111
|
+
: typeof body.error === "string"
|
|
112
|
+
? body.error
|
|
113
|
+
: res.statusText;
|
|
114
|
+
throw new Error(`Sandbox Runner rejected job: ${message}`);
|
|
115
|
+
}
|
|
116
|
+
const status = body.status;
|
|
117
|
+
if (!isSandboxRunnerState(status)) {
|
|
118
|
+
throw new Error("Sandbox Runner returned an invalid start status.");
|
|
119
|
+
}
|
|
120
|
+
return {
|
|
121
|
+
jobId: requireString(body, "jobId"),
|
|
122
|
+
status,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async getJob(token: string, jobId: string): Promise<SandboxRunnerJob> {
|
|
127
|
+
const res = await this.fetchImpl(
|
|
128
|
+
`${this.baseUrl}/jobs/${encodeURIComponent(jobId)}`,
|
|
129
|
+
{
|
|
130
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
131
|
+
},
|
|
132
|
+
);
|
|
133
|
+
const body = await parseJsonResponse(res);
|
|
134
|
+
if (!res.ok) {
|
|
135
|
+
const message =
|
|
136
|
+
typeof body.message === "string"
|
|
137
|
+
? body.message
|
|
138
|
+
: typeof body.error === "string"
|
|
139
|
+
? body.error
|
|
140
|
+
: res.statusText;
|
|
141
|
+
throw new Error(`Sandbox Runner job lookup failed: ${message}`);
|
|
142
|
+
}
|
|
143
|
+
return normalizeJob(body, jobId);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async pollJob(
|
|
147
|
+
token: string,
|
|
148
|
+
jobId: string,
|
|
149
|
+
options: SandboxRunnerPollOptions = {},
|
|
150
|
+
): Promise<SandboxRunnerJob> {
|
|
151
|
+
const intervalMs = options.intervalMs ?? 1000;
|
|
152
|
+
const maxPolls = options.maxPolls ?? 300;
|
|
153
|
+
for (let attempt = 0; attempt < maxPolls; attempt += 1) {
|
|
154
|
+
const job = await this.getJob(token, jobId);
|
|
155
|
+
if (job.status === "succeeded" || job.status === "failed") {
|
|
156
|
+
return job;
|
|
157
|
+
}
|
|
158
|
+
await delay(intervalMs);
|
|
159
|
+
}
|
|
160
|
+
throw new Error(`Sandbox Runner job '${jobId}' did not finish before the poll limit.`);
|
|
161
|
+
}
|
|
162
|
+
}
|