@indigoai-us/hq-cli 5.77.4 → 5.77.5

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,21 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.77.5]
6
+
7
+ ### Added
8
+
9
+ - **Cross-platform meeting-bot invites.** Run
10
+ `hq meetings invite <meeting-url>` to schedule the meeting bot from any
11
+ terminal. Add `--company <slug-or-uid>` to route the transcript to a company
12
+ vault; omitting it keeps the invite in your personal vault. (#252)
13
+
14
+ ### Fixed
15
+
16
+ - High-security secret refusals now clearly direct callers to `hq secrets
17
+ sandbox` or the HQ secret proxy, including sandbox-only denials and
18
+ `--script` attempts. (#253)
19
+
5
20
  ## [5.77.4]
6
21
 
7
22
  ### Fixed
@@ -208,6 +208,43 @@ export function registerMeetingsCommand(program) {
208
208
  process.exit(1);
209
209
  }
210
210
  });
211
+ // ── hq meetings invite <meeting-url> ──────────────────────────────
212
+ meetings
213
+ .command("invite <meetingUrl>")
214
+ .description("Invite the meeting bot to a Google Meet, Zoom, or Teams URL")
215
+ .action(async (meetingUrl) => {
216
+ try {
217
+ const token = await ensureCognitoToken();
218
+ const companySlug = meetings.opts().company;
219
+ const query = {};
220
+ if (companySlug)
221
+ query.companyId = await getCompanyUid(token, companySlug);
222
+ const res = await vaultApiFetch({
223
+ token,
224
+ method: "POST",
225
+ path: "/v1/bot/invite",
226
+ query,
227
+ body: { meetingUrl },
228
+ });
229
+ if (!res.ok)
230
+ await handleApiError(res);
231
+ const data = (await res.json());
232
+ if (meetings.opts().json) {
233
+ console.log(JSON.stringify(data, null, 2));
234
+ return;
235
+ }
236
+ console.log(chalk.green(`\n✓ Meeting bot invited to ${chalk.cyan(data.meetingUrl ?? meetingUrl)}.`));
237
+ if (data.botId)
238
+ console.log(chalk.dim(` Bot: ${data.botId}`));
239
+ if (data.status)
240
+ console.log(chalk.dim(` Status: ${data.status}`));
241
+ console.log();
242
+ }
243
+ catch (err) {
244
+ console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
245
+ process.exit(1);
246
+ }
247
+ });
211
248
  // ── hq meetings get <id> ──────────────────────────────────────────
212
249
  meetings
213
250
  .command("get <meetingId>")
@@ -131,6 +131,12 @@ function promptSecretInteractively() {
131
131
  // server (the legacy per-key GET path had no such cap).
132
132
  const MAX_BATCH_NAMES = 100;
133
133
  const SECRET_LOAD_TIMEOUT_MS = 30_000;
134
+ function highSecuritySandboxOnlyMessage(secretName) {
135
+ const sandboxExample = secretName
136
+ ? `hq secrets sandbox --only ${secretName} -- <command>`
137
+ : "hq secrets sandbox --only <secret> -- <command>";
138
+ return `High-security secrets can run only in the sandbox (use \`${sandboxExample}\`) or through the HQ secret proxy. Local injection and script locks (\`--script\`) are not supported.`;
139
+ }
134
140
  function parseSecretAclPrincipal(principal) {
135
141
  const p = principal.trim();
136
142
  if (p === "@all") {
@@ -435,10 +441,13 @@ export async function loadRevealedSecrets(token, companyUid, keys, usage) {
435
441
  if (!res.ok) {
436
442
  const body = (await res.json().catch(() => ({})));
437
443
  const message = extractApiMessage(body, res.statusText);
438
- // High-security ("nuclear") refusal surfaced at the batch level (rather
439
- // than per-name): point the caller at the proxy and never leak plaintext.
440
- if (body.code === "high_security_denied" || body.highSecurity === true) {
441
- throw new Error("A requested secret is high-security and cannot be injected locally — it can only be used through the HQ secret proxy, which keeps the plaintext server-side.");
444
+ // High-security refusal surfaced at the batch level (rather than
445
+ // per-name): point the caller at the sandbox/proxy and never leak
446
+ // plaintext. A local script attestation is not an override.
447
+ if (body.code === "high_security_denied" ||
448
+ body.code === "high_security_sandbox_only" ||
449
+ body.highSecurity === true) {
450
+ throw new Error(highSecuritySandboxOnlyMessage());
442
451
  }
443
452
  if (res.status >= 400 &&
444
453
  res.status < 500 &&
@@ -508,14 +517,13 @@ export async function loadRevealedSecrets(token, companyUid, keys, usage) {
508
517
  continue;
509
518
  removeCacheEntry(companyUid, key);
510
519
  const err = errorsByName.get(key);
511
- // High-security ("nuclear") secret: the server refuses to vend it on the
512
- // local-injection (batch-load) path per-name code `high_security_denied`,
513
- // no plaintext returned. Every caller of loadRevealedSecrets injects or
514
- // prints the plaintext locally (`secrets get --reveal`, `secrets exec`,
515
- // `secrets env`), so a high-security secret can NEVER be used here. Surface
516
- // a clear, actionable error pointing at the proxy instead of a raw failure.
517
- if (err?.code === "high_security_denied") {
518
- firstFailure ??= new Error(`Secret '${key}' is high-security and cannot be injected locally — it can only be used through the HQ secret proxy, which keeps the plaintext server-side.`);
520
+ // Every caller of loadRevealedSecrets injects or prints the plaintext
521
+ // locally (`secrets get --reveal`, `secrets exec`, `secrets env`). Both
522
+ // high-security denial shapes therefore receive the same explicit
523
+ // sandbox/proxy guidance, even if the caller supplied `--script`.
524
+ if (err?.code === "high_security_denied" ||
525
+ err?.code === "high_security_sandbox_only") {
526
+ firstFailure ??= new Error(highSecuritySandboxOnlyMessage(key));
519
527
  continue;
520
528
  }
521
529
  const reason = err?.code === "not_found"
@@ -655,7 +663,7 @@ export function registerSecretsCommand(program) {
655
663
  removeCacheEntry(companyUid, name);
656
664
  console.log(chalk.green(formatSecretSaved(name, scopeLabel)));
657
665
  if (opts.highSecurity) {
658
- console.log(chalk.dim(` High-security: destination pinned to ${destinations?.[0]}. This value can never be revealed or injected locally only used through the HQ secret proxy.`));
666
+ console.log(chalk.dim(` High-security: destination pinned to ${destinations?.[0]}. This value can run only in the sandbox (hq secrets sandbox) or through the HQ secret proxy; local injection and script locks are unsupported.`));
659
667
  }
660
668
  }
661
669
  catch (err) {
@@ -677,13 +685,11 @@ export function registerSecretsCommand(program) {
677
685
  });
678
686
  if (!res.ok) {
679
687
  const body = (await res.json().catch(() => ({})));
680
- // High-security ("nuclear") secret: the server refuses to reveal it on
681
- // the local-injection path (403, no plaintext). Surface a clear,
682
- // actionable error pointing the user at the proxy rather than a raw
683
- // 4xx — the value can ONLY be used through the server-side proxy.
688
+ // High-security secrets cannot be revealed locally. Surface the
689
+ // hosted execution path rather than a raw 4xx; `--script` never
690
+ // overrides this boundary.
684
691
  if (res.status === 403 && body.highSecurity === true) {
685
- console.error(chalk.red(`Secret '${name}' is high-security and cannot be revealed locally.`));
686
- console.error(chalk.dim(" It can only be used through the HQ secret proxy, which keeps the plaintext server-side."));
692
+ console.error(chalk.red(highSecuritySandboxOnlyMessage(name)));
687
693
  process.exit(1);
688
694
  }
689
695
  console.error(chalk.red(`Failed to get secret: ${extractApiMessage(body, res.statusText)}`));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.77.4",
3
+ "version": "5.77.5",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -32,6 +32,7 @@ vi.mock("../utils/vault-api.js", async (importOriginal) => {
32
32
  });
33
33
 
34
34
  import { registerMeetingsCommand } from "./meetings.js";
35
+ import { ensureCognitoToken } from "../utils/cognito-session.js";
35
36
  import { getCompanyUid, vaultApiFetch } from "../utils/vault-api.js";
36
37
 
37
38
  let logSpy: MockInstance<typeof console.log>;
@@ -145,6 +146,61 @@ describe("meetings get — short id resolution", () => {
145
146
  });
146
147
  });
147
148
 
149
+ describe("meetings invite", () => {
150
+ it("authenticates and posts the meeting URL with the optional company routing", async () => {
151
+ const meetingUrl = "https://meet.google.com/abc-defg-hij";
152
+ vi.mocked(vaultApiFetch).mockResolvedValueOnce(
153
+ jsonRes({
154
+ botId: "bot_123",
155
+ meetingUrl,
156
+ platform: "google_meet",
157
+ status: "scheduled",
158
+ }),
159
+ );
160
+
161
+ const program = buildProgram();
162
+ await program.parseAsync([
163
+ "node",
164
+ "hq",
165
+ "meetings",
166
+ "--company",
167
+ "indigo",
168
+ "invite",
169
+ meetingUrl,
170
+ ]);
171
+
172
+ expect(ensureCognitoToken).toHaveBeenCalledOnce();
173
+ expect(getCompanyUid).toHaveBeenCalledWith("test-token", "indigo");
174
+ expect(vaultApiFetch).toHaveBeenCalledWith({
175
+ token: "test-token",
176
+ method: "POST",
177
+ path: "/v1/bot/invite",
178
+ query: { companyId: "cmp_indigo" },
179
+ body: { meetingUrl },
180
+ });
181
+ expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Meeting bot invited"));
182
+ });
183
+
184
+ it("omits company routing so the backend uses the caller's personal vault", async () => {
185
+ const meetingUrl = "https://zoom.us/j/123456789";
186
+ vi.mocked(vaultApiFetch).mockResolvedValueOnce(
187
+ jsonRes({ botId: "bot_123", meetingUrl, status: "scheduled" }),
188
+ );
189
+
190
+ const program = buildProgram();
191
+ await program.parseAsync(["node", "hq", "meetings", "invite", meetingUrl]);
192
+
193
+ expect(getCompanyUid).not.toHaveBeenCalled();
194
+ expect(vaultApiFetch).toHaveBeenCalledWith({
195
+ token: "test-token",
196
+ method: "POST",
197
+ path: "/v1/bot/invite",
198
+ query: {},
199
+ body: { meetingUrl },
200
+ });
201
+ });
202
+ });
203
+
148
204
  describe("meetings set-company", () => {
149
205
  it("POSTs the resolved company id and applies to the recurring series by default", async () => {
150
206
  vi.mocked(vaultApiFetch).mockResolvedValueOnce(
@@ -101,6 +101,13 @@ interface MeetingDocument {
101
101
  notes: MeetingNotes | null;
102
102
  }
103
103
 
104
+ interface MeetingBotInviteResponse {
105
+ botId?: string;
106
+ meetingUrl?: string;
107
+ platform?: string;
108
+ status?: string;
109
+ }
110
+
104
111
  function formatDuration(seconds: number): string {
105
112
  const h = Math.floor(seconds / 3600);
106
113
  const m = Math.floor((seconds % 3600) / 60);
@@ -344,6 +351,43 @@ export function registerMeetingsCommand(program: Command): void {
344
351
  }
345
352
  });
346
353
 
354
+ // ── hq meetings invite <meeting-url> ──────────────────────────────
355
+
356
+ meetings
357
+ .command("invite <meetingUrl>")
358
+ .description("Invite the meeting bot to a Google Meet, Zoom, or Teams URL")
359
+ .action(async (meetingUrl: string) => {
360
+ try {
361
+ const token = await ensureCognitoToken();
362
+ const companySlug = meetings.opts().company as string | undefined;
363
+ const query: Record<string, string> = {};
364
+ if (companySlug) query.companyId = await getCompanyUid(token, companySlug);
365
+
366
+ const res = await vaultApiFetch({
367
+ token,
368
+ method: "POST",
369
+ path: "/v1/bot/invite",
370
+ query,
371
+ body: { meetingUrl },
372
+ });
373
+ if (!res.ok) await handleApiError(res);
374
+ const data = (await res.json()) as MeetingBotInviteResponse;
375
+
376
+ if (meetings.opts().json) {
377
+ console.log(JSON.stringify(data, null, 2));
378
+ return;
379
+ }
380
+
381
+ console.log(chalk.green(`\n✓ Meeting bot invited to ${chalk.cyan(data.meetingUrl ?? meetingUrl)}.`));
382
+ if (data.botId) console.log(chalk.dim(` Bot: ${data.botId}`));
383
+ if (data.status) console.log(chalk.dim(` Status: ${data.status}`));
384
+ console.log();
385
+ } catch (err) {
386
+ console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
387
+ process.exit(1);
388
+ }
389
+ });
390
+
347
391
  // ── hq meetings get <id> ──────────────────────────────────────────
348
392
 
349
393
  meetings
@@ -138,6 +138,29 @@ describe("secrets sandbox", () => {
138
138
  expect(channel).toBe("sandbox");
139
139
  });
140
140
 
141
+ it("runs a high-security secret in the sandbox without a local script attestation", async () => {
142
+ const program = buildProgram();
143
+ await program.parseAsync([
144
+ "node",
145
+ "hq",
146
+ "secrets",
147
+ "sandbox",
148
+ "--only",
149
+ "HIGH_SECURITY_KEY",
150
+ "--",
151
+ "env",
152
+ ]);
153
+
154
+ expect(startJobSpy).toHaveBeenCalledWith("test-token", {
155
+ companyUid: "prs_alice",
156
+ secretNames: ["HIGH_SECURITY_KEY"],
157
+ command: "env",
158
+ });
159
+ expect(vaultApiFetch).not.toHaveBeenCalledWith(
160
+ expect.objectContaining({ path: expect.stringContaining("/load") }),
161
+ );
162
+ });
163
+
141
164
  it("parses --company, --only, and joins args after -- into a command", async () => {
142
165
  const program = buildProgram();
143
166
  await program.parseAsync([
@@ -534,9 +557,9 @@ describe("secrets exists (HQ-4H HEAD probe)", () => {
534
557
 
535
558
  // US-003 (secrets-server-proxy): the CLI surfaces the SERVER's refusal of a
536
559
  // high-security ("nuclear") secret on the local-injection path as a clear,
537
- // actionable error pointing at the proxy — and never prints the value. The
538
- // server-side deny is the real control (it returns 403 + highSecurity:true and
539
- // NO plaintext); these tests assert the CLI's surfacing behavior.
560
+ // actionable sandbox/proxy error — and never prints the value. The server-side
561
+ // deny is the real control (it returns 403 + highSecurity:true and NO
562
+ // plaintext); these tests assert the CLI's surfacing behavior.
540
563
  describe("US-003 — CLI refuses high-security secrets on local injection", () => {
541
564
  // The server's 403 refusal shape for a high-security secret.
542
565
  function highSecurityDenied(): Response {
@@ -550,6 +573,20 @@ describe("US-003 — CLI refuses high-security secrets on local injection", () =
550
573
  );
551
574
  }
552
575
 
576
+ function highSecuritySandboxOnly(): Response {
577
+ return jsonRes({
578
+ secrets: [],
579
+ errors: [
580
+ {
581
+ name: "ANTHROPIC_API_KEY",
582
+ code: "high_security_sandbox_only",
583
+ message:
584
+ "High-security-tier secrets can run only in the sandbox (`hq secrets sandbox`) or through the secret proxy. Local injection and script locks (`--script`) are not supported.",
585
+ },
586
+ ],
587
+ });
588
+ }
589
+
553
590
  let exitSpy: MockInstance<typeof process.exit>;
554
591
  beforeEach(() => {
555
592
  exitSpy = vi.spyOn(process, "exit").mockImplementation((() => {
@@ -576,17 +613,22 @@ describe("US-003 — CLI refuses high-security secrets on local injection", () =
576
613
  const exitCode = exitSpy.mock.calls[0]?.[0] as number | undefined;
577
614
 
578
615
  expect(exitCode).toBe(1);
579
- // A clear, actionable error mentioning high-security + the proxy.
616
+ // A clear, actionable error names the sandbox/proxy boundary and rules
617
+ // out script-lock bypasses.
580
618
  const errText = errSpy.mock.calls.flat().join(" ");
581
619
  expect(errText).toMatch(/high-security/i);
620
+ expect(errText).toMatch(/hq secrets sandbox/i);
582
621
  expect(errText).toMatch(/proxy/i);
622
+ expect(errText).toMatch(/--script/);
583
623
  // The value is NEVER printed — no "Value:" line carrying plaintext.
584
624
  const logText = logSpy.mock.calls.flat().join(" ");
585
625
  expect(logText).not.toMatch(/sk-ant/i);
586
626
  });
587
627
 
588
- it("E2E: `secrets exec --only <name> -- env` is denied — proxy-pointing error, command not run", async () => {
589
- vi.mocked(vaultApiFetch).mockResolvedValueOnce(highSecurityDenied());
628
+ it("E2E: `secrets exec --script <path>` is denied — sandbox guidance, command not run", async () => {
629
+ vi.mocked(vaultApiFetch).mockResolvedValueOnce(highSecuritySandboxOnly());
630
+ const scriptPath = join(tempDir, "deploy.sh");
631
+ writeFileSync(scriptPath, "#!/usr/bin/env bash\necho deploy\n");
590
632
 
591
633
  const program = buildProgram();
592
634
  try {
@@ -597,6 +639,10 @@ describe("US-003 — CLI refuses high-security secrets on local injection", () =
597
639
  "exec",
598
640
  "--only",
599
641
  "ANTHROPIC_API_KEY",
642
+ "--script",
643
+ scriptPath,
644
+ "--script-id",
645
+ "script.deploy",
600
646
  "--",
601
647
  "env",
602
648
  ]);
@@ -608,7 +654,10 @@ describe("US-003 — CLI refuses high-security secrets on local injection", () =
608
654
  expect(exitCode).toBe(1);
609
655
  const errText = errSpy.mock.calls.flat().join(" ");
610
656
  expect(errText).toMatch(/high-security/i);
657
+ expect(errText).toMatch(/hq secrets sandbox/i);
611
658
  expect(errText).toMatch(/proxy/i);
659
+ expect(errText).toMatch(/--script/);
660
+ expect(spawn).not.toHaveBeenCalled();
612
661
  });
613
662
  });
614
663
 
@@ -212,6 +212,13 @@ export interface SecretLoadResponse {
212
212
  errors: Array<{ name: string; code: string; message?: string }>;
213
213
  }
214
214
 
215
+ function highSecuritySandboxOnlyMessage(secretName?: string): string {
216
+ const sandboxExample = secretName
217
+ ? `hq secrets sandbox --only ${secretName} -- <command>`
218
+ : "hq secrets sandbox --only <secret> -- <command>";
219
+ return `High-security secrets can run only in the sandbox (use \`${sandboxExample}\`) or through the HQ secret proxy. Local injection and script locks (\`--script\`) are not supported.`;
220
+ }
221
+
215
222
  interface SecretGetResponse {
216
223
  secret: {
217
224
  name: string;
@@ -669,12 +676,15 @@ export async function loadRevealedSecrets(
669
676
  if (!res.ok) {
670
677
  const body = (await res.json().catch(() => ({}))) as Record<string, unknown>;
671
678
  const message = extractApiMessage(body, res.statusText);
672
- // High-security ("nuclear") refusal surfaced at the batch level (rather
673
- // than per-name): point the caller at the proxy and never leak plaintext.
674
- if (body.code === "high_security_denied" || body.highSecurity === true) {
675
- throw new Error(
676
- "A requested secret is high-security and cannot be injected locally — it can only be used through the HQ secret proxy, which keeps the plaintext server-side.",
677
- );
679
+ // High-security refusal surfaced at the batch level (rather than
680
+ // per-name): point the caller at the sandbox/proxy and never leak
681
+ // plaintext. A local script attestation is not an override.
682
+ if (
683
+ body.code === "high_security_denied" ||
684
+ body.code === "high_security_sandbox_only" ||
685
+ body.highSecurity === true
686
+ ) {
687
+ throw new Error(highSecuritySandboxOnlyMessage());
678
688
  }
679
689
  if (
680
690
  res.status >= 400 &&
@@ -752,16 +762,15 @@ export async function loadRevealedSecrets(
752
762
  if (resolved.has(key)) continue;
753
763
  removeCacheEntry(companyUid, key);
754
764
  const err = errorsByName.get(key);
755
- // High-security ("nuclear") secret: the server refuses to vend it on the
756
- // local-injection (batch-load) path per-name code `high_security_denied`,
757
- // no plaintext returned. Every caller of loadRevealedSecrets injects or
758
- // prints the plaintext locally (`secrets get --reveal`, `secrets exec`,
759
- // `secrets env`), so a high-security secret can NEVER be used here. Surface
760
- // a clear, actionable error pointing at the proxy instead of a raw failure.
761
- if (err?.code === "high_security_denied") {
762
- firstFailure ??= new Error(
763
- `Secret '${key}' is high-security and cannot be injected locally — it can only be used through the HQ secret proxy, which keeps the plaintext server-side.`,
764
- );
765
+ // Every caller of loadRevealedSecrets injects or prints the plaintext
766
+ // locally (`secrets get --reveal`, `secrets exec`, `secrets env`). Both
767
+ // high-security denial shapes therefore receive the same explicit
768
+ // sandbox/proxy guidance, even if the caller supplied `--script`.
769
+ if (
770
+ err?.code === "high_security_denied" ||
771
+ err?.code === "high_security_sandbox_only"
772
+ ) {
773
+ firstFailure ??= new Error(highSecuritySandboxOnlyMessage(key));
765
774
  continue;
766
775
  }
767
776
  const reason =
@@ -947,7 +956,7 @@ export function registerSecretsCommand(program: Command): void {
947
956
  if (opts.highSecurity) {
948
957
  console.log(
949
958
  chalk.dim(
950
- ` High-security: destination pinned to ${destinations?.[0]}. This value can never be revealed or injected locally only used through the HQ secret proxy.`,
959
+ ` High-security: destination pinned to ${destinations?.[0]}. This value can run only in the sandbox (hq secrets sandbox) or through the HQ secret proxy; local injection and script locks are unsupported.`,
951
960
  ),
952
961
  );
953
962
  }
@@ -979,21 +988,11 @@ export function registerSecretsCommand(program: Command): void {
979
988
 
980
989
  if (!res.ok) {
981
990
  const body = (await res.json().catch(() => ({}))) as Record<string, unknown>;
982
- // High-security ("nuclear") secret: the server refuses to reveal it on
983
- // the local-injection path (403, no plaintext). Surface a clear,
984
- // actionable error pointing the user at the proxy rather than a raw
985
- // 4xx — the value can ONLY be used through the server-side proxy.
991
+ // High-security secrets cannot be revealed locally. Surface the
992
+ // hosted execution path rather than a raw 4xx; `--script` never
993
+ // overrides this boundary.
986
994
  if (res.status === 403 && body.highSecurity === true) {
987
- console.error(
988
- chalk.red(
989
- `Secret '${name}' is high-security and cannot be revealed locally.`,
990
- ),
991
- );
992
- console.error(
993
- chalk.dim(
994
- " It can only be used through the HQ secret proxy, which keeps the plaintext server-side.",
995
- ),
996
- );
995
+ console.error(chalk.red(highSecuritySandboxOnlyMessage(name)));
997
996
  process.exit(1);
998
997
  }
999
998
  console.error(