@indigoai-us/hq-cli 5.108.25 → 5.109.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.
Files changed (49) hide show
  1. package/CHANGELOG.md +88 -0
  2. package/dist/commands/__fixtures__/access-vault.d.ts +93 -0
  3. package/dist/commands/__fixtures__/access-vault.js +166 -0
  4. package/dist/commands/access.d.ts +158 -0
  5. package/dist/commands/access.js +783 -0
  6. package/dist/commands/cloud.js +11 -1
  7. package/dist/commands/files-browse.d.ts +25 -1
  8. package/dist/commands/files-browse.js +81 -17
  9. package/dist/commands/files.js +15 -5
  10. package/dist/commands/integrations-api.d.ts +15 -0
  11. package/dist/commands/integrations-connect.js +84 -3
  12. package/dist/commands/integrations-oauth.js +62 -3
  13. package/dist/commands/mcp-registration.d.ts +17 -7
  14. package/dist/commands/mcp-registration.js +16 -27
  15. package/dist/commands/mesh.js +174 -50
  16. package/dist/commands/pack-install.js +5 -5
  17. package/dist/commands/secrets.d.ts +7 -0
  18. package/dist/commands/secrets.js +26 -2
  19. package/dist/commands/sync-mode.js +12 -1
  20. package/dist/commands/sync-narrow.js +12 -1
  21. package/dist/lib/mesh/live/backfill-held.d.ts +42 -1
  22. package/dist/lib/mesh/live/backfill-held.js +95 -13
  23. package/dist/lib/mesh/live/daemon/doctor.d.ts +15 -0
  24. package/dist/lib/mesh/live/daemon/doctor.js +41 -10
  25. package/dist/lib/mesh/live/daemon/mode.d.ts +37 -0
  26. package/dist/lib/mesh/live/daemon/mode.js +88 -0
  27. package/dist/lib/mesh/live/daemon/run.d.ts +8 -0
  28. package/dist/lib/mesh/live/daemon/run.js +39 -28
  29. package/dist/lib/mesh/live/daemon/state.d.ts +2 -0
  30. package/dist/lib/mesh/live/emit-client.d.ts +99 -0
  31. package/dist/lib/mesh/live/emit-client.js +193 -0
  32. package/dist/lib/mesh/live/emit-evidence.d.ts +49 -0
  33. package/dist/lib/mesh/live/emit-evidence.js +77 -0
  34. package/dist/lib/mesh/live/emit-replay.d.ts +26 -0
  35. package/dist/lib/mesh/live/emit-replay.js +157 -0
  36. package/dist/lib/mesh/live/emit-retry.d.ts +25 -0
  37. package/dist/lib/mesh/live/emit-retry.js +79 -0
  38. package/dist/lib/mesh/live/emit.d.ts +54 -0
  39. package/dist/lib/mesh/live/emit.js +153 -0
  40. package/dist/lib/narrow-hint-banner.d.ts +3 -7
  41. package/dist/lib/narrow-hint-banner.js +13 -34
  42. package/dist/lib/plan-limit-nag.d.ts +0 -3
  43. package/dist/lib/plan-limit-nag.js +10 -20
  44. package/dist/register-all.js +3 -0
  45. package/dist/utils/access-denied-hint.d.ts +32 -0
  46. package/dist/utils/access-denied-hint.js +139 -0
  47. package/dist/utils/access-requests.d.ts +28 -0
  48. package/dist/utils/access-requests.js +98 -0
  49. package/package.json +1 -1
@@ -12,6 +12,7 @@
12
12
  * hq sync pull — pull all permitted files from the vault
13
13
  * hq sync status — show local journal summary
14
14
  */
15
+ import { isAccessDeniedError, accessDeniedKeyOf, formatAccessDenied, } from "../utils/access-denied-hint.js";
15
16
  import chalk from "chalk";
16
17
  import * as fs from "fs";
17
18
  import * as path from "path";
@@ -44,6 +45,14 @@ export class SyncExitError extends Error {
44
45
  * CLI are released independently.
45
46
  */
46
47
  export function formatSyncFailure(err) {
48
+ if (isAccessDeniedError(err)) {
49
+ const key = accessDeniedKeyOf(err);
50
+ const base = err instanceof Error ? err.message : String(err);
51
+ if (key) {
52
+ return formatAccessDenied(`Access denied for '${key}': ${base}`, key);
53
+ }
54
+ return formatAccessDenied(base);
55
+ }
47
56
  if (typeof err === "object" && err !== null) {
48
57
  const diagnostic = err;
49
58
  if (diagnostic.code === "POLICY_WRITE_SCOPE_TRUNCATED") {
@@ -978,7 +987,8 @@ export function registerCloudCommands(program) {
978
987
  catch (err) {
979
988
  if (syncHealth)
980
989
  await syncHealth.failed();
981
- console.error(chalk.red("\n✗ Pull failed:"), err instanceof Error ? err.message : String(err));
990
+ const message = formatSyncFailure(err);
991
+ console.error(chalk.red("\n✗ Pull failed:"), message);
982
992
  process.exit(1);
983
993
  }
984
994
  });
@@ -35,7 +35,7 @@
35
35
  */
36
36
  import { Command } from "commander";
37
37
  import { ListObjectsV2Command, GetObjectCommand, type ListObjectsV2CommandOutput, type GetObjectCommandOutput } from "@aws-sdk/client-s3";
38
- import { type ExplicitGrant } from "@indigoai-us/hq-cloud";
38
+ import { VaultClient, type ExplicitGrant } from "@indigoai-us/hq-cloud";
39
39
  /** STS-vended credential set the browse/cat path consumes. */
40
40
  export interface BrowseCredentials {
41
41
  accessKeyId: string;
@@ -433,7 +433,18 @@ export declare const PRESIGN_BUFFER_MAX_BYTES: number;
433
433
  export declare function createCompanyPresignClient(input: {
434
434
  token: string;
435
435
  companyUid: string;
436
+ /**
437
+ * Company slug the client is scoped to. Attached as `company` on every 403
438
+ * so the access ladder targets THIS tenant rather than the active company
439
+ * (keys on these errors are bucket-relative and carry no slug).
440
+ */
441
+ companySlug?: string;
436
442
  }): FilesBrowseS3Client;
443
+ /**
444
+ * Production company-mode client factory — closes over the caller's token and
445
+ * (when known) the company slug, so 403s are tenant-anchored.
446
+ */
447
+ export declare function makeCompanyPresignFactory(token: string, companySlug?: string): CompanyBrowseClientFactory;
437
448
  /**
438
449
  * `FilesBrowseVaultClient` for a Bearer `hqk_` key.
439
450
  *
@@ -455,6 +466,19 @@ export declare function createCompanyPresignClient(input: {
455
466
  * roll-up) throw a fail-closed error naming the Cognito requirement.
456
467
  */
457
468
  export declare function createApiKeyBrowseVaultClient(token: string): FilesBrowseVaultClient & FilesSharedWithMeVaultClient;
469
+ /**
470
+ * The caller's credential plus the vault client that can carry it.
471
+ *
472
+ * `cognito` is undefined under HQ_API_KEY: `VaultClient` speaks only the JWT
473
+ * routes, so the keyed adapter stands in for the company path — and the
474
+ * personal path, which needs `VaultClient.entity.listByType` plus an STS vend,
475
+ * has nothing to run on and must fail closed at its branch.
476
+ */
477
+ export declare function resolveBrowseSession(): Promise<{
478
+ token: string;
479
+ client: FilesBrowseVaultClient & FilesSharedWithMeVaultClient;
480
+ cognito?: VaultClient;
481
+ }>;
458
482
  /**
459
483
  * Wire `hq files browse` + `hq files cat` onto an existing `files`
460
484
  * Commander group. `registerFilesCommand` in files.ts builds the group
@@ -33,6 +33,7 @@
33
33
  * Cross-package note: depends on the `VaultClient.sts.vend`/`.vendSelf`
34
34
  * methods and `grantPathToPrefix` from hq-cloud.
35
35
  */
36
+ import { isAccessDeniedError, accessDeniedKeyOf, reportAccessDenied, accessDeniedCompanyOf, } from "../utils/access-denied-hint.js";
36
37
  import chalk from "chalk";
37
38
  import * as fs from "node:fs";
38
39
  import * as path from "node:path";
@@ -595,7 +596,13 @@ export const PRESIGN_BUFFER_MAX_BYTES = 32 * 1024 * 1024;
595
596
  * `send(...)` as if it held an S3 client.
596
597
  */
597
598
  export function createCompanyPresignClient(input) {
598
- const { token, companyUid } = input;
599
+ const { token, companyUid, companySlug } = input;
600
+ const denied = (message, key, extra) => Object.assign(new Error(message), {
601
+ status: 403,
602
+ key,
603
+ ...(companySlug ? { company: companySlug } : {}),
604
+ ...(extra ?? {}),
605
+ });
599
606
  async function listObjects(cmd) {
600
607
  const prefix = cmd.input.Prefix ?? "";
601
608
  const query = { company: companyUid };
@@ -606,7 +613,11 @@ export function createCompanyPresignClient(input) {
606
613
  const res = await vaultApiFetch({ token, path: "/v1/files/list", query });
607
614
  if (!res.ok) {
608
615
  const body = (await res.json().catch(() => ({})));
609
- throw new Error(body.message ?? body.error ?? `files list failed (${res.status})`);
616
+ const message = body.message ?? body.error ?? `files list failed (${res.status})`;
617
+ if (res.status === 403) {
618
+ throw denied(message, prefix);
619
+ }
620
+ throw new Error(message);
610
621
  }
611
622
  const body = (await res.json());
612
623
  return {
@@ -636,13 +647,21 @@ export function createCompanyPresignClient(input) {
636
647
  });
637
648
  if (!res.ok) {
638
649
  const body = (await res.json().catch(() => ({})));
639
- throw new Error(body.message ?? body.error ?? `presign failed (${res.status})`);
650
+ const message = body.message ?? body.error ?? `presign failed (${res.status})`;
651
+ if (res.status === 403) {
652
+ throw denied(message, key);
653
+ }
654
+ throw new Error(message);
640
655
  }
641
656
  const body = (await res.json());
642
657
  const first = body.results?.[0];
643
658
  if (!first || !first.url) {
644
659
  // Per-key denial/validation surfaces here (e.g. FILES_PRESIGN_FORBIDDEN).
645
- throw new Error(first?.error ?? `No presigned URL returned for '${key}'`);
660
+ const message = first?.error ?? `No presigned URL returned for '${key}'`;
661
+ if (first?.code === "FILES_PRESIGN_FORBIDDEN") {
662
+ throw denied(message, key, { code: "FILES_PRESIGN_FORBIDDEN" });
663
+ }
664
+ throw new Error(message);
646
665
  }
647
666
  const dl = await fetch(first.url);
648
667
  if (!dl.ok) {
@@ -687,9 +706,21 @@ export function createCompanyPresignClient(input) {
687
706
  }
688
707
  return { send };
689
708
  }
690
- /** Production company-mode client factory — closes over the caller's token. */
691
- function makeCompanyPresignFactory(token) {
692
- return ({ companyUid }) => createCompanyPresignClient({ token, companyUid });
709
+ /**
710
+ * Production company-mode client factory — closes over the caller's token and
711
+ * (when known) the company slug, so 403s are tenant-anchored.
712
+ */
713
+ export function makeCompanyPresignFactory(token, companySlug) {
714
+ return ({ companyUid }) => createCompanyPresignClient({ token, companyUid, companySlug });
715
+ }
716
+ /**
717
+ * Tenant anchor for the access ladder in a CLI catch block: prefer the slug
718
+ * the failing client stamped on the 403, else the `--company` flag. `hqRoot`
719
+ * rides along so `hq access` reads the same tree the command was run against.
720
+ */
721
+ function accessLadderContextFor(err, command, hqRoot) {
722
+ const flag = command.optsWithGlobals().company;
723
+ return { company: accessDeniedCompanyOf(err) ?? flag, hqRoot };
693
724
  }
694
725
  // ── HQ_API_KEY vault client (keyed routes only) ──────────────────────────────
695
726
  /** Fail-closed error the top-level handler prints without a Sentry report. */
@@ -793,7 +824,7 @@ function personalVaultNeedsCognito() {
793
824
  * personal path, which needs `VaultClient.entity.listByType` plus an STS vend,
794
825
  * has nothing to run on and must fail closed at its branch.
795
826
  */
796
- async function resolveBrowseSession() {
827
+ export async function resolveBrowseSession() {
797
828
  const cred = await resolveVaultCredential();
798
829
  if (cred.kind === "api-key") {
799
830
  return {
@@ -882,7 +913,7 @@ export function registerFilesBrowseCommands(filesCmd) {
882
913
  pathPrefix: pathArg,
883
914
  companySlug: slug,
884
915
  vaultClient: client,
885
- companyClient: makeCompanyPresignFactory(accessToken),
916
+ companyClient: makeCompanyPresignFactory(accessToken, slug),
886
917
  region: DEFAULT_COGNITO.region,
887
918
  });
888
919
  console.log(formatBrowseTable(result.rows));
@@ -895,7 +926,13 @@ export function registerFilesBrowseCommands(filesCmd) {
895
926
  }
896
927
  }
897
928
  catch (err) {
898
- console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
929
+ const message = err instanceof Error ? err.message : String(err);
930
+ if (isAccessDeniedError(err)) {
931
+ await reportAccessDenied(chalk.red("Error:") + " " + message, accessDeniedKeyOf(err) ?? pathArg, accessLadderContextFor(err, command, options.hqRoot));
932
+ }
933
+ else {
934
+ console.error(chalk.red("Error:"), message);
935
+ }
899
936
  process.exit(1);
900
937
  }
901
938
  });
@@ -959,7 +996,7 @@ export function registerFilesBrowseCommands(filesCmd) {
959
996
  hqRoot: options.hqRoot,
960
997
  companySlug: slug,
961
998
  vaultClient: client,
962
- companyClient: makeCompanyPresignFactory(accessToken),
999
+ companyClient: makeCompanyPresignFactory(accessToken, slug),
963
1000
  region: DEFAULT_COGNITO.region,
964
1001
  });
965
1002
  if (result.destination.kind === "file") {
@@ -967,7 +1004,13 @@ export function registerFilesBrowseCommands(filesCmd) {
967
1004
  }
968
1005
  }
969
1006
  catch (err) {
970
- console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
1007
+ const message = err instanceof Error ? err.message : String(err);
1008
+ if (isAccessDeniedError(err)) {
1009
+ await reportAccessDenied(chalk.red("Error:") + " " + message, accessDeniedKeyOf(err) ?? keyArg, accessLadderContextFor(err, command, options.hqRoot));
1010
+ }
1011
+ else {
1012
+ console.error(chalk.red("Error:"), message);
1013
+ }
971
1014
  process.exit(1);
972
1015
  }
973
1016
  });
@@ -1003,7 +1046,13 @@ export function registerFilesBrowseCommands(filesCmd) {
1003
1046
  console.log(formatSharedWithMeTable(rows));
1004
1047
  }
1005
1048
  catch (err) {
1006
- console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
1049
+ const message = err instanceof Error ? err.message : String(err);
1050
+ if (isAccessDeniedError(err)) {
1051
+ await reportAccessDenied(chalk.red("Error:") + " " + message, accessDeniedKeyOf(err), accessLadderContextFor(err, command));
1052
+ }
1053
+ else {
1054
+ console.error(chalk.red("Error:"), message);
1055
+ }
1007
1056
  process.exit(1);
1008
1057
  }
1009
1058
  });
@@ -1049,13 +1098,22 @@ export function registerFilesBrowseCommands(filesCmd) {
1049
1098
  query,
1050
1099
  companySlug: company,
1051
1100
  vaultClient: client,
1052
- companyClient: makeCompanyPresignFactory(accessToken),
1101
+ companyClient: makeCompanyPresignFactory(accessToken, company),
1053
1102
  region: DEFAULT_COGNITO.region,
1054
1103
  });
1055
1104
  console.log(formatBrowseTable(rows));
1056
1105
  }
1057
1106
  catch (err) {
1058
- console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
1107
+ const message = err instanceof Error ? err.message : String(err);
1108
+ if (isAccessDeniedError(err)) {
1109
+ await reportAccessDenied(chalk.red("Error:") + " " + message, accessDeniedKeyOf(err) ??
1110
+ (command.optsWithGlobals().company
1111
+ ? `companies/${command.optsWithGlobals().company}`
1112
+ : undefined), accessLadderContextFor(err, command));
1113
+ }
1114
+ else {
1115
+ console.error(chalk.red("Error:"), message);
1116
+ }
1059
1117
  process.exit(1);
1060
1118
  }
1061
1119
  });
@@ -1091,7 +1149,7 @@ export function registerFilesBrowseCommands(filesCmd) {
1091
1149
  hqRoot: options.hqRoot,
1092
1150
  companySlug: slug,
1093
1151
  vaultClient: client,
1094
- companyClient: makeCompanyPresignFactory(accessToken),
1152
+ companyClient: makeCompanyPresignFactory(accessToken, slug),
1095
1153
  region: DEFAULT_COGNITO.region,
1096
1154
  });
1097
1155
  console.error(chalk.green("✓"), `Materialized ${result.filesWritten} file(s), ${result.bytesWritten} bytes.`);
@@ -1100,7 +1158,13 @@ export function registerFilesBrowseCommands(filesCmd) {
1100
1158
  }
1101
1159
  }
1102
1160
  catch (err) {
1103
- console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
1161
+ const message = err instanceof Error ? err.message : String(err);
1162
+ if (isAccessDeniedError(err)) {
1163
+ await reportAccessDenied(chalk.red("Error:") + " " + message, accessDeniedKeyOf(err) ?? pathArg, accessLadderContextFor(err, command, options.hqRoot));
1164
+ }
1165
+ else {
1166
+ console.error(chalk.red("Error:"), message);
1167
+ }
1104
1168
  process.exit(1);
1105
1169
  }
1106
1170
  });
@@ -7,6 +7,7 @@ import { vaultApiFetch, getCompanyUid } from "./secrets.js";
7
7
  import { GROUP_ID_PATTERN, EMAIL_PATTERN, normalizeFilePrefix } from "./_patterns.js";
8
8
  import { looksLikeCompanyUid } from "../utils/vault-api.js";
9
9
  import { AuthError } from "../utils/auth-error.js";
10
+ import { formatAccessDenied, offerAccessLadder, } from "../utils/access-denied-hint.js";
10
11
  // ---------------------------------------------------------------------------
11
12
  // Pure helpers (exported for unit tests)
12
13
  // ---------------------------------------------------------------------------
@@ -75,9 +76,9 @@ export function formatShareSessionError(err) {
75
76
  }
76
77
  if (err.status === 403) {
77
78
  if (err.path) {
78
- return `Not authorized to share '${err.path}' — you need read access on every path`;
79
+ return formatAccessDenied(`Not authorized to share '${err.path}' — you need read access on every path`, err.path);
79
80
  }
80
- return "Not authorized — you need to be a company member with read access on every path";
81
+ return formatAccessDenied("Not authorized — you need to be a company member with read access on every path");
81
82
  }
82
83
  if (err.status === 400) {
83
84
  return `Invalid request: ${err.message}`;
@@ -236,6 +237,9 @@ export function registerFilesCommand(program) {
236
237
  }
237
238
  else if (res.status === 403) {
238
239
  console.error(chalk.red("Not authorized to modify this file prefix's ACL"));
240
+ await offerAccessLadder(canonicalPrefix, {
241
+ company: files.opts().company,
242
+ });
239
243
  }
240
244
  else if (res.status === 404) {
241
245
  // A 404 means ZERO rows were removed — never report a green success
@@ -304,6 +308,9 @@ export function registerFilesCommand(program) {
304
308
  }
305
309
  else if (treeRes.status === 403) {
306
310
  console.error(chalk.red("Not authorized to view this file prefix's ACL"));
311
+ await offerAccessLadder(canonicalPrefix, {
312
+ company: files.opts().company,
313
+ });
307
314
  }
308
315
  else if (treeRes.status >= 500) {
309
316
  console.error(chalk.red(`Server error: ${body.error ?? treeRes.statusText}`));
@@ -566,6 +573,9 @@ async function runDirectGrant(params) {
566
573
  }
567
574
  else if (res.status === 403) {
568
575
  console.error(chalk.red("Not authorized to share this file prefix"));
576
+ await offerAccessLadder(canonicalPrefix, {
577
+ company: params.companySlug,
578
+ });
569
579
  }
570
580
  else if (res.status === 404) {
571
581
  console.error(chalk.red("ACL record not found — the prefix may not have an ACL yet"));
@@ -666,7 +676,7 @@ export function formatFilesDeleteError(err, prefix) {
666
676
  return "Not authenticated — please run `hq login`";
667
677
  }
668
678
  if (err.status === 403) {
669
- return `Not authorized to delete '${prefix}' — you need write access on it`;
679
+ return formatAccessDenied(`Not authorized to delete '${prefix}' — you need write access on it`, prefix);
670
680
  }
671
681
  if (err.status === 400) {
672
682
  return `Invalid request: ${err.message}`;
@@ -1084,9 +1094,9 @@ function formatFilesRecoveryError(err, key) {
1084
1094
  return "Not authenticated — please run hq login";
1085
1095
  }
1086
1096
  if (err.status === 403) {
1087
- return key
1097
+ return formatAccessDenied(key
1088
1098
  ? "Not authorized to modify '" + key + "' — you need write access on it"
1089
- : "Not authorized to view this vault path";
1099
+ : "Not authorized to view this vault path", key);
1090
1100
  }
1091
1101
  if (err.code === "FILES_RESTORE_VERSION_NOT_FOUND") {
1092
1102
  return "That version is unavailable or is a delete marker.";
@@ -212,6 +212,21 @@ export interface OAuthStartInput {
212
212
  * state row and attaches it at code exchange; it is never logged.
213
213
  */
214
214
  clientSecret?: string;
215
+ /**
216
+ * The exact permissions to request at the authorize step.
217
+ *
218
+ * Normally leave this alone: hq-pro sends whatever a vendor needs, either
219
+ * the scopes a pre-registered client was granted or, for the vendors that
220
+ * reject a scope-less request outright, a server-side list. This exists for
221
+ * the case that list does not cover — an app that wants narrower access
222
+ * than the default, or a vendor whose requirement HQ does not yet know.
223
+ *
224
+ * hq-pro honours it on EVERY connect path (catalog, domain, discovery
225
+ * receipt, direct `mcpUrl`) and it beats both the static-client and
226
+ * registry-derived scopes, unlike `clientId`, which is read only on the
227
+ * direct `mcpUrl` path.
228
+ */
229
+ scopes?: string[];
215
230
  }
216
231
  export declare function startOAuth(token: string, companyUid: string, input: OAuthStartInput): Promise<OAuthStartResult>;
217
232
  export declare function completeOAuth(token: string, companyUid: string, input: {
@@ -76,6 +76,64 @@ const BRING_YOUR_OWN_CLIENT_CODES = new Set([
76
76
  "OAUTH_REGISTRATION_UNSUPPORTED",
77
77
  "CLIENT_REGISTRATION_REFUSED",
78
78
  ]);
79
+ /**
80
+ * Server-side limits, restated here so a typo fails in the terminal with a
81
+ * sentence rather than as an opaque 400 three network hops away. Kept in step
82
+ * with hq-pro's own `parseRequestedScopes`; if that changes, change this.
83
+ */
84
+ const MAX_SCOPES = 32;
85
+ const MAX_SCOPE_LENGTH = 256;
86
+ /**
87
+ * Parses `--scopes`, accepting either separator a person would reach for:
88
+ * `--scopes "mcp:read mcp:write"` and `--scopes mcp:read,mcp:write` are the
89
+ * same request. Order is preserved and duplicates are dropped, so a repeated
90
+ * scope is not sent twice.
91
+ *
92
+ * Rejects rather than silently repairing. A scope list is a permissions
93
+ * request; quietly dropping a malformed entry would ask a vendor for something
94
+ * other than what the caller typed, and the caller would not find out until an
95
+ * agent hit a missing permission later.
96
+ */
97
+ function parseRequestedScopes(raw) {
98
+ if (raw === undefined)
99
+ return undefined;
100
+ const entries = raw.split(/[\s,]+/).filter((entry) => entry.length > 0);
101
+ if (entries.length === 0) {
102
+ throw new IntegrationsCliError("--scopes was empty — pass at least one permission.", {
103
+ expected: true,
104
+ });
105
+ }
106
+ if (entries.length > MAX_SCOPES) {
107
+ throw new IntegrationsCliError(`--scopes takes at most ${MAX_SCOPES} permissions; ${entries.length} were given.`, { expected: true });
108
+ }
109
+ const scopes = [];
110
+ for (const entry of entries) {
111
+ if (entry.length > MAX_SCOPE_LENGTH) {
112
+ throw new IntegrationsCliError(`--scopes entries must be at most ${MAX_SCOPE_LENGTH} characters.`, { expected: true });
113
+ }
114
+ if (!scopes.includes(entry))
115
+ scopes.push(entry);
116
+ }
117
+ return scopes;
118
+ }
119
+ /**
120
+ * Refuse a `--scopes` that could not possibly be honoured, before anything is
121
+ * sent. Permissions are an OAuth concept: an API-key app has none to ask for,
122
+ * and an app that needs no auth has nothing to ask.
123
+ */
124
+ function assertScopesApplicable(opts, target, authMode) {
125
+ if (opts.scopes === undefined)
126
+ return;
127
+ if (opts.token || opts.tokenStdin) {
128
+ throw new IntegrationsCliError("--scopes is for a browser sign-in and --token is for an API key; pass one or the other, not both.", { expected: true });
129
+ }
130
+ // An UNKNOWN mode is left alone for the same reason --client-id leaves it
131
+ // alone: the direct-endpoint path often learns it is OAuth only once hq-pro
132
+ // says so, and refusing here would block the very case this flag is for.
133
+ if (authMode === "key" || authMode === "none") {
134
+ throw new IntegrationsCliError(`${target.label} does not use a browser sign-in, so --scopes does not apply to it.`, { expected: true });
135
+ }
136
+ }
79
137
  /**
80
138
  * Looks-like-a-domain test for the positional `<app>` argument. Deliberately
81
139
  * loose — hq-pro does the real resolution — but tight enough that `linear.app`
@@ -398,13 +456,17 @@ async function connectViaOAuth(token, companyUid, target, opts) {
398
456
  expected: true,
399
457
  });
400
458
  }
401
- const startInput = { ...target.ref };
459
+ const requestedScopes = parseRequestedScopes(opts.scopes);
460
+ const startInput = {
461
+ ...target.ref,
462
+ ...(requestedScopes ? { scopes: requestedScopes } : {}),
463
+ };
402
464
  // A user-registered app pins the console callback and skips the loopback
403
465
  // entirely — see `userClientSignIn` for why an ephemeral port cannot be the
404
466
  // redirect URI here.
405
467
  const userClient = await resolveUserOAuthClient(opts, target.label);
406
468
  if (userClient)
407
- return await userClientSignIn(token, companyUid, target, opts, userClient);
469
+ return await userClientSignIn(token, companyUid, target, opts, userClient, requestedScopes);
408
470
  // A loopback listener only works if the browser runs on THIS machine. Over
409
471
  // SSH the person opens the printed URL on their workstation, so the provider
410
472
  // redirects to the workstation's 127.0.0.1 while the listener sits on the
@@ -567,6 +629,13 @@ async function consoleHandoff(target, opts) {
567
629
  const url = consoleIntegrationsUrl(opts.company, consoleOrigin());
568
630
  console.error(chalk.yellow(`${target.label} signs in through the console, not the terminal — this HQ backend finishes these sign-ins there.`));
569
631
  printConsoleDestination(target, opts, url);
632
+ // A console sign-in is started by the console, so there is nowhere to put a
633
+ // --scopes list. Saying nothing would let the connect finish with default
634
+ // permissions while the caller believes theirs were requested -- the exact
635
+ // silent-wrong-permissions failure --scopes exists to prevent.
636
+ if (opts.scopes) {
637
+ console.error(chalk.yellow(`Note: --scopes cannot be carried into a console sign-in, so this connect will use ${target.label}'s default permissions.`));
638
+ }
570
639
  if (url && opts.browser !== false)
571
640
  await open(url).catch(() => { });
572
641
  console.error(chalk.dim("When the browser says it connected, run `hq integrations list` to confirm."));
@@ -624,6 +693,11 @@ function byoClientCommand(target, opts) {
624
693
  if (provider)
625
694
  parts.push(`--provider ${shellQuote(provider)}`);
626
695
  parts.push("--client-id <client-id-from-the-provider>", "--client-secret-stdin");
696
+ // The whole point of --scopes is that this vendor needs permissions HQ would
697
+ // not otherwise send. Dropping them from the recovery command would hand the
698
+ // caller a line that reproduces the failure they are recovering from.
699
+ if (opts.scopes)
700
+ parts.push(`--scopes ${shellQuote(opts.scopes)}`);
627
701
  return parts.join(" ");
628
702
  }
629
703
  /**
@@ -705,11 +779,15 @@ async function resolveUserOAuthClient(opts, appLabel) {
705
779
  * `companyUid` is omitted, so the callback completes and the console names the
706
780
  * app it connected. Ship this only against a backend carrying both.
707
781
  */
708
- async function userClientSignIn(token, companyUid, target, opts, client) {
782
+ async function userClientSignIn(token, companyUid, target, opts, client, requestedScopes) {
709
783
  const started = await startOAuth(token, companyUid, {
710
784
  ...target.ref,
711
785
  clientId: client.clientId,
712
786
  ...(client.clientSecret ? { clientSecret: client.clientSecret } : {}),
787
+ // A caller who registered the app themselves is the one who knows which
788
+ // permissions that registration was granted, so their list must reach the
789
+ // authorize step on this path too.
790
+ ...(requestedScopes ? { scopes: requestedScopes } : {}),
713
791
  });
714
792
  console.error(`Open this URL to sign in to ${started.displayName || target.label}:`);
715
793
  console.error(` ${started.authorizationUrl}`);
@@ -772,6 +850,7 @@ async function connectApp(token, companyUid, app, opts, expectedRevivedConnectio
772
850
  const target = await resolveTarget(token, companyUid, app, opts, expectedRevivedConnectionId !== undefined);
773
851
  const authMode = opts.auth ?? target.authClass;
774
852
  assertUserClientApplicable(opts, target, authMode);
853
+ assertScopesApplicable(opts, target, authMode);
775
854
  if (authMode === "oauth") {
776
855
  const result = await connectViaOAuth(token, companyUid, target, opts);
777
856
  if (result)
@@ -932,6 +1011,7 @@ export function registerConnectCommands(integrations) {
932
1011
  .option("--auth <mode>", "Force the auth mode: none, key, or oauth (default: detect)")
933
1012
  .option("--client-id <id>", "Client id of an OAuth app you registered with the provider yourself")
934
1013
  .option("--client-secret-stdin", "Read that app's client secret from stdin")
1014
+ .option("--scopes <list>", "Permissions to request, space- or comma-separated (default: what the app needs)")
935
1015
  .option("--no-browser", "Print the sign-in URL instead of opening a browser")
936
1016
  .option("--timeout <seconds>", "How long to wait for a browser sign-in (default 300)")
937
1017
  .option("--json", "Machine-readable output")
@@ -1005,6 +1085,7 @@ export function registerConnectCommands(integrations) {
1005
1085
  // start an ordinary sign-in, and `--client-id` beside `--token` would
1006
1086
  // reinstall with the bearer token and drop the OAuth app.
1007
1087
  assertUserClientApplicable(opts, target, target.authClass);
1088
+ assertScopesApplicable(opts, target, target.authClass);
1008
1089
  if (target.authClass === "oauth") {
1009
1090
  const result = await connectViaOAuth(token, companyUid, target, opts);
1010
1091
  if (result)
@@ -23,10 +23,69 @@ import { IntegrationsCliError } from "./integrations-core.js";
23
23
  export const LOOPBACK_CALLBACK_PATH = "/hq/integrations/oauth/callback";
24
24
  /** How long to wait for the browser round trip before giving the port back. */
25
25
  const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000;
26
+ /**
27
+ * Escapes text destined for the callback page.
28
+ *
29
+ * Every caller passes a literal today, so nothing here is attacker-controlled.
30
+ * It is escaped anyway because this function builds HTML: the next person to
31
+ * pass a provider-supplied string through it should not have to notice that
32
+ * the escaping was missing.
33
+ */
34
+ function escapeHtml(value) {
35
+ return value
36
+ .replace(/&/g, "&amp;")
37
+ .replace(/</g, "&lt;")
38
+ .replace(/>/g, "&gt;")
39
+ .replace(/"/g, "&quot;");
40
+ }
41
+ /**
42
+ * The hq-console visual language, inlined.
43
+ *
44
+ * Deliberately NOT the console's webfont: this page is served by a local port
45
+ * and is often the browser's first paint after a redirect, so a blocking
46
+ * request to fonts.googleapis.com would either delay it or fail outright on a
47
+ * plane. Geist is named first so it is used when the machine has it, with the
48
+ * system stack behind. Token values track src/app/globals.css in hq-console —
49
+ * near-black ground, hairline border, one 13px size, weight 400 only, grey
50
+ * heading over bright body.
51
+ */
52
+ const CALLBACK_PAGE_STYLE = `
53
+ :root { color-scheme: dark }
54
+ * { box-sizing: border-box }
55
+ body {
56
+ margin: 0; min-height: 100vh; display: flex;
57
+ align-items: center; justify-content: center;
58
+ background: #17161a; color: #f2efe9;
59
+ font-family: Geist, ui-sans-serif, system-ui, -apple-system, sans-serif;
60
+ font-size: 13px; font-weight: 400; letter-spacing: -0.013em; line-height: 1.6;
61
+ }
62
+ main {
63
+ width: 100%; max-width: 26rem; margin: 2rem; padding: 1.75rem;
64
+ border: 1px solid rgba(255,255,255,0.1); border-radius: 10px;
65
+ background: #121116;
66
+ }
67
+ .mark { color: #6f6a62; margin-bottom: 1.5rem }
68
+ h1 { font-size: 13px; font-weight: 400; color: #a8a29a; margin: 0 0 0.5rem }
69
+ p { margin: 0; color: #f2efe9 }
70
+ .dot {
71
+ display: inline-block; width: 6px; height: 6px; border-radius: 50%;
72
+ margin-right: 0.5rem; vertical-align: 1px; background: var(--dot);
73
+ }
74
+ `;
26
75
  function respond(res, status, title, detail) {
27
- const body = `<!doctype html><meta charset="utf-8"><title>${title}</title>
28
- <body style="font:15px system-ui;margin:4rem auto;max-width:32rem;color:#111">
29
- <h1 style="font-size:1.1rem;font-weight:600">${title}</h1><p>${detail}</p></body>`;
76
+ // Green only when the browser leg genuinely succeeded. A cancelled or
77
+ // incomplete sign-in gets amber, because the page must not read as success
78
+ // when the terminal is about to report a failure.
79
+ const dot = status === 200 ? "#34d399" : "#fbbf24";
80
+ const body = `<!doctype html><html lang="en"><meta charset="utf-8">
81
+ <meta name="viewport" content="width=device-width,initial-scale=1">
82
+ <title>${escapeHtml(title)} · HQ</title>
83
+ <style>${CALLBACK_PAGE_STYLE}</style>
84
+ <body><main>
85
+ <div class="mark">HQ</div>
86
+ <h1><span class="dot" style="--dot:${dot}"></span>${escapeHtml(title)}</h1>
87
+ <p>${escapeHtml(detail)}</p>
88
+ </main></body></html>`;
30
89
  res.writeHead(status, {
31
90
  "content-type": "text/html; charset=utf-8",
32
91
  // The page is a terminal handoff, never something to keep or re-fetch.
@@ -52,7 +52,6 @@
52
52
  * first-class skip), and {@link registerMcpServers} is the pack-install routing
53
53
  * seam over it.
54
54
  */
55
- import { type FlagReader } from '../lib/flag-registry.js';
56
55
  /** Base for every MCP-registration error; carries a stable machine-checkable `code`. */
57
56
  export declare abstract class McpRegistrationError extends Error {
58
57
  abstract readonly code: string;
@@ -596,10 +595,17 @@ export declare function registerServer(opts: RegisterServerOptions): RegisterSer
596
595
  * when neither a url nor a command is present.
597
596
  */
598
597
  export declare function manifestTarget(manifest: McpManifest): string;
599
- /** Resolve the gate once at the start of an MCP registration operation. */
600
- export declare function isMcpRegistrationEnabled(flagReader?: FlagReader): boolean;
601
- /** Freeze a registration decision so multi-server work cannot split on refresh. */
602
- export declare function captureMcpRegistrationDecision(flagReader?: FlagReader): FlagReader;
598
+ /**
599
+ * Resolve the MCP registration kill switch from its env var alone.
600
+ *
601
+ * `HQ_DISABLE_MCP_REGISTRATION` is an operator's own machine-local opt-out, not
602
+ * a rollout flag, so it is read straight from the environment — no registry
603
+ * lookup. The polarity is inverted and the value match is asymmetric on
604
+ * purpose: ONLY the exact value "1" disables registration; unset and every
605
+ * other value (including "0" and "false") leave it enabled. Do not "tidy" this
606
+ * into a boolean parse — that would silently disable anyone who wrote "false".
607
+ */
608
+ export declare function isMcpRegistrationEnabled(env?: Readonly<Record<string, string | undefined>>): boolean;
603
609
  /**
604
610
  * Register one pack's MCP servers into the shared agent configs (the public seam
605
611
  * `pack-install` routes `wire:'merge'` keys to). For each declared server name it
@@ -627,8 +633,12 @@ export declare function registerMcpServers(pkg: string, names: string[], options
627
633
  /** Lock tuning + backup stamp passthrough (tests). */
628
634
  lock?: AcquireLockOptions;
629
635
  stamp?: string;
630
- /** Test seam: held registry snapshot reader. */
631
- flagReader?: FlagReader;
636
+ /**
637
+ * Pre-resolved kill-switch decision, frozen once before a multi-server loop
638
+ * so an install writes either all of a pack's servers or none of them.
639
+ * Omitted → read `HQ_DISABLE_MCP_REGISTRATION` directly.
640
+ */
641
+ registrationEnabled?: boolean;
632
642
  }): RegisterServerResult[];
633
643
  /** smol-toml's value-table type (its `parse` return + `stringify` input). */
634
644
  type TomlTable = Record<string, unknown>;