@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 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
@@ -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]="35b60d26-d8e7-5c10-9ad4-db53285eaaf0")}catch(e){}}();
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
- function detectPrincipalType(principal) {
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
- if (PERSON_UID_PATTERN.test(principal)) {
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 personUid)")
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}': must be an email address or a personUid matching prs_<alphanumeric>`));
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 personUid)")
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}': must be an email address or a personUid matching prs_<alphanumeric>`));
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=35b60d26-d8e7-5c10-9ad4-db53285eaaf0
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]="2640a391-a3e6-5830-b56f-03b5fab3a4bd")}catch(e){}}();
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.title.length)));
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 title = m.title.length > TITLE_W ? m.title.slice(0, TITLE_W - 1) + "…" : m.title;
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=2640a391-a3e6-5830-b56f-03b5fab3a4bd
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
@@ -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]="1dfcad18-3626-5c68-8b4c-0c147e896113")}catch(e){}}();
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]="18312d3d-7daf-5faf-ac29-c82ce7bd0b29")}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,49 @@ 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.stdout) {
221
+ process.stdout.write(scrubSandboxOutput(job.stdout, secretNames));
222
+ }
223
+ if (job.stderr) {
224
+ process.stderr.write(scrubSandboxOutput(job.stderr, secretNames));
225
+ }
226
+ if (job.logsTail) {
227
+ process.stderr.write(scrubSandboxOutput(job.logsTail, secretNames));
228
+ }
229
+ }
186
230
  function normalizePolicyRecord(secretPath, data) {
187
231
  const policy = data.policy ?? { path: secretPath };
188
232
  const scripts = Array.isArray(policy.scripts)
@@ -826,6 +870,56 @@ export function registerSecretsCommand(program) {
826
870
  process.exit(1);
827
871
  }
828
872
  });
873
+ secrets
874
+ .command("sandbox")
875
+ .description("Run a skill in the hosted sandbox with secrets injected server-side")
876
+ .option("--company <slug>", "Company slug (resolves to companyUid)")
877
+ .option("--personal", "Operate on the caller's personal vault (no sharing)")
878
+ .option("--only <keys>", "Comma-separated list of secret names the skill may use")
879
+ .option("--script <path>", "Attach local script identity for script-locked secrets")
880
+ .allowUnknownOption(true)
881
+ .action(async (opts, cmd) => {
882
+ try {
883
+ const dashIndex = process.argv.indexOf("--");
884
+ const rawArgs = dashIndex !== -1 ? process.argv.slice(dashIndex + 1) : cmd.args;
885
+ if (rawArgs.length === 0) {
886
+ console.error(chalk.red("Error: no skill specified. Usage: hq secrets sandbox [--company X] [--only KEY1,KEY2] -- <skill> [args...]"));
887
+ process.exit(1);
888
+ }
889
+ const [skillId, ...skillArgs] = rawArgs;
890
+ const keys = opts.only ? 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 usage = await buildSecretUsage("sandbox", opts.script);
895
+ const client = new SandboxRunnerClient();
896
+ const started = await client.startJob(token, {
897
+ skillId,
898
+ companyUid,
899
+ args: skillArgs.length > 0 ? { argv: skillArgs } : undefined,
900
+ companySlug: scope.companySlug,
901
+ only: keys.length > 0 ? keys : undefined,
902
+ usage,
903
+ });
904
+ const job = started.status === "succeeded" || started.status === "failed"
905
+ ? await client.getJob(token, started.jobId)
906
+ : await client.pollJob(token, started.jobId);
907
+ renderSandboxJobResult(job, keys);
908
+ if (job.status === "failed") {
909
+ if (job.error) {
910
+ console.error(chalk.red("Sandbox job failed:"), scrubSandboxOutput(job.error, keys));
911
+ }
912
+ process.exit(job.exitCode && job.exitCode > 0 ? job.exitCode : 1);
913
+ }
914
+ if (job.exitCode && job.exitCode !== 0) {
915
+ process.exit(job.exitCode);
916
+ }
917
+ }
918
+ catch (err) {
919
+ console.error(chalk.red("Error:"), err instanceof Error ? scrubSandboxOutput(err.message) : scrubSandboxOutput(String(err)));
920
+ process.exit(1);
921
+ }
922
+ });
829
923
  secrets
830
924
  .command("exec")
831
925
  .description("Run a command with secrets injected as env vars")
@@ -847,17 +941,7 @@ export function registerSecretsCommand(program) {
847
941
  console.error(chalk.red("Error: no command specified. Usage: hq secrets exec --only KEY1,KEY2 -- <command>"));
848
942
  process.exit(1);
849
943
  }
850
- const keys = _opts.only.split(",").map((k) => k.trim()).filter(Boolean);
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
- }
944
+ const keys = parseSecretNameList(_opts.only);
861
945
  const token = await ensureCognitoToken();
862
946
  const companyUid = await getEntityUid(token, scopeOpts(secrets.opts()));
863
947
  const revealed = await loadRevealedSecrets(token, companyUid, keys, await buildSecretUsage("exec", _opts.script));
@@ -903,17 +987,7 @@ export function registerSecretsCommand(program) {
903
987
  if (redact) {
904
988
  console.error(chalk.yellow("stdout is a terminal — values redacted. Use: source <(hq secrets env --only KEY1,KEY2)"));
905
989
  }
906
- const keys = opts.only.split(",").map((k) => k.trim()).filter(Boolean);
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
- }
990
+ const keys = parseSecretNameList(opts.only);
917
991
  const token = await ensureCognitoToken();
918
992
  const companyUid = await getEntityUid(token, scopeOpts(secrets.opts()));
919
993
  const revealed = await loadRevealedSecrets(token, companyUid, keys, await buildSecretUsage("env", opts.script));
@@ -1187,4 +1261,4 @@ export function registerSecretsCommand(program) {
1187
1261
  });
1188
1262
  }
1189
1263
  //# sourceMappingURL=secrets.js.map
1190
- //# debugId=1dfcad18-3626-5c68-8b4c-0c147e896113
1264
+ //# debugId=18312d3d-7daf-5faf-ac29-c82ce7bd0b29
@@ -0,0 +1,39 @@
1
+ export type SandboxRunnerState = "queued" | "running" | "succeeded" | "failed";
2
+ export interface SandboxRunnerStartRequest {
3
+ skillId: string;
4
+ companyUid: string;
5
+ args?: Record<string, unknown>;
6
+ companySlug?: string;
7
+ only?: string[];
8
+ usage?: unknown;
9
+ }
10
+ export interface SandboxRunnerStartResponse {
11
+ jobId: string;
12
+ status: SandboxRunnerState;
13
+ }
14
+ export interface SandboxRunnerJob {
15
+ jobId: string;
16
+ status: SandboxRunnerState;
17
+ stdout?: string;
18
+ stderr?: string;
19
+ logsTail?: string;
20
+ exitCode?: number;
21
+ error?: string;
22
+ }
23
+ export interface SandboxRunnerClientOptions {
24
+ baseUrl?: string;
25
+ fetchImpl?: typeof fetch;
26
+ }
27
+ export interface SandboxRunnerPollOptions {
28
+ intervalMs?: number;
29
+ maxPolls?: number;
30
+ }
31
+ export declare class SandboxRunnerClient {
32
+ private readonly baseUrl;
33
+ private readonly fetchImpl;
34
+ constructor(options?: SandboxRunnerClientOptions);
35
+ startJob(token: string, request: SandboxRunnerStartRequest): Promise<SandboxRunnerStartResponse>;
36
+ getJob(token: string, jobId: string): Promise<SandboxRunnerJob>;
37
+ pollJob(token: string, jobId: string, options?: SandboxRunnerPollOptions): Promise<SandboxRunnerJob>;
38
+ }
39
+ //# sourceMappingURL=sandbox-runner-client.d.ts.map
@@ -0,0 +1,113 @@
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]="8203259b-d3ae-5c9b-845e-f4c1470c30f3")}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
+ const output = typeof body.stdout === "string"
29
+ ? body.stdout
30
+ : typeof body.output === "string"
31
+ ? body.output
32
+ : undefined;
33
+ return {
34
+ jobId: typeof body.jobId === "string" && body.jobId.length > 0
35
+ ? body.jobId
36
+ : jobIdFallback ?? requireString(body, "jobId"),
37
+ status,
38
+ stdout: output,
39
+ stderr: typeof body.stderr === "string" ? body.stderr : undefined,
40
+ logsTail: typeof body.logsTail === "string" ? body.logsTail : undefined,
41
+ exitCode: typeof body.exitCode === "number" ? body.exitCode : undefined,
42
+ error: typeof body.error === "string" ? body.error : undefined,
43
+ };
44
+ }
45
+ function delay(ms) {
46
+ if (ms <= 0)
47
+ return Promise.resolve();
48
+ return new Promise((resolve) => setTimeout(resolve, ms));
49
+ }
50
+ export class SandboxRunnerClient {
51
+ baseUrl;
52
+ fetchImpl;
53
+ constructor(options = {}) {
54
+ this.baseUrl = normalizeBaseUrl(options.baseUrl ?? getSandboxRunnerBaseUrl());
55
+ this.fetchImpl = options.fetchImpl ?? fetch;
56
+ }
57
+ async startJob(token, request) {
58
+ const res = await this.fetchImpl(`${this.baseUrl}/jobs`, {
59
+ method: "POST",
60
+ headers: {
61
+ Authorization: `Bearer ${token}`,
62
+ "Content-Type": "application/json",
63
+ },
64
+ body: JSON.stringify(request),
65
+ });
66
+ const body = await parseJsonResponse(res);
67
+ if (!res.ok) {
68
+ const message = typeof body.message === "string"
69
+ ? body.message
70
+ : typeof body.error === "string"
71
+ ? body.error
72
+ : res.statusText;
73
+ throw new Error(`Sandbox Runner rejected job: ${message}`);
74
+ }
75
+ const status = body.status;
76
+ if (!isSandboxRunnerState(status)) {
77
+ throw new Error("Sandbox Runner returned an invalid start status.");
78
+ }
79
+ return {
80
+ jobId: requireString(body, "jobId"),
81
+ status,
82
+ };
83
+ }
84
+ async getJob(token, jobId) {
85
+ const res = await this.fetchImpl(`${this.baseUrl}/jobs/${encodeURIComponent(jobId)}`, {
86
+ headers: { Authorization: `Bearer ${token}` },
87
+ });
88
+ const body = await parseJsonResponse(res);
89
+ if (!res.ok) {
90
+ const message = typeof body.message === "string"
91
+ ? body.message
92
+ : typeof body.error === "string"
93
+ ? body.error
94
+ : res.statusText;
95
+ throw new Error(`Sandbox Runner job lookup failed: ${message}`);
96
+ }
97
+ return normalizeJob(body, jobId);
98
+ }
99
+ async pollJob(token, jobId, options = {}) {
100
+ const intervalMs = options.intervalMs ?? 1000;
101
+ const maxPolls = options.maxPolls ?? 300;
102
+ for (let attempt = 0; attempt < maxPolls; attempt += 1) {
103
+ const job = await this.getJob(token, jobId);
104
+ if (job.status === "succeeded" || job.status === "failed") {
105
+ return job;
106
+ }
107
+ await delay(intervalMs);
108
+ }
109
+ throw new Error(`Sandbox Runner job '${jobId}' did not finish before the poll limit.`);
110
+ }
111
+ }
112
+ //# sourceMappingURL=sandbox-runner-client.js.map
113
+ //# debugId=8203259b-d3ae-5c9b-845e-f4c1470c30f3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.56.0",
3
+ "version": "5.57.0",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -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
+ });
@@ -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
- function detectPrincipalType(
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
- if (PERSON_UID_PATTERN.test(principal)) {
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 personUid)")
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}': must be an email address or a personUid matching prs_<alphanumeric>`));
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 personUid)")
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}': must be an email address or a personUid matching prs_<alphanumeric>`));
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
+ });