@indigoai-us/hq-cli 5.108.24 → 5.108.26
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 +70 -0
- package/dist/commands/files.d.ts +11 -0
- package/dist/commands/files.js +206 -30
- package/dist/commands/integrations-api.d.ts +15 -0
- package/dist/commands/integrations-connect.js +84 -3
- package/dist/commands/integrations-oauth.js +62 -3
- package/dist/commands/mcp-registration.d.ts +17 -7
- package/dist/commands/mcp-registration.js +16 -27
- package/dist/commands/mesh.js +174 -50
- package/dist/commands/pack-install.js +5 -5
- package/dist/commands/secrets.d.ts +7 -0
- package/dist/commands/secrets.js +26 -2
- package/dist/lib/mesh/live/backfill-held.d.ts +42 -1
- package/dist/lib/mesh/live/backfill-held.js +95 -13
- package/dist/lib/mesh/live/daemon/doctor.d.ts +15 -0
- package/dist/lib/mesh/live/daemon/doctor.js +41 -10
- package/dist/lib/mesh/live/daemon/mode.d.ts +37 -0
- package/dist/lib/mesh/live/daemon/mode.js +88 -0
- package/dist/lib/mesh/live/daemon/run.d.ts +8 -0
- package/dist/lib/mesh/live/daemon/run.js +39 -28
- package/dist/lib/mesh/live/daemon/state.d.ts +2 -0
- package/dist/lib/mesh/live/emit-client.d.ts +99 -0
- package/dist/lib/mesh/live/emit-client.js +193 -0
- package/dist/lib/mesh/live/emit-evidence.d.ts +49 -0
- package/dist/lib/mesh/live/emit-evidence.js +77 -0
- package/dist/lib/mesh/live/emit-replay.d.ts +26 -0
- package/dist/lib/mesh/live/emit-replay.js +157 -0
- package/dist/lib/mesh/live/emit-retry.d.ts +25 -0
- package/dist/lib/mesh/live/emit-retry.js +79 -0
- package/dist/lib/mesh/live/emit.d.ts +54 -0
- package/dist/lib/mesh/live/emit.js +153 -0
- package/dist/lib/narrow-hint-banner.d.ts +3 -7
- package/dist/lib/narrow-hint-banner.js +13 -34
- package/dist/lib/plan-limit-nag.d.ts +0 -3
- package/dist/lib/plan-limit-nag.js +10 -20
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,76 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.108.26] — 2026-09-08
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **Daemon receive-only mode** (owner decision 2026-09-08; contract
|
|
10
|
+
direct-emit-v1 §6.3). `hq mesh mode <get|legacy|direct|check>` selects the
|
|
11
|
+
daemon's emit mode, resolved from `HQ_MESH_MODE` then `~/.hq/work-mesh/config.json`
|
|
12
|
+
(`emitMode`), defaulting to **legacy**. In `legacy` the daemon runs the spool
|
|
13
|
+
watch + flush + held + outbox replay + transcript-watch emit path (unchanged).
|
|
14
|
+
In `direct` it runs **receive-only** — the presence subscriber keeps the cache
|
|
15
|
+
the desktop app reads warm on `hq/{personUid}/mesh`, with no spool/held/outbox/
|
|
16
|
+
flush/transcript-watch — because sessions POST events directly. `hq mesh mode
|
|
17
|
+
check` probes whether `/v1/mesh/events` is live so the flag can flip after the
|
|
18
|
+
server route lands. `hq mesh daemon doctor` reports `mode` + `subscription`
|
|
19
|
+
state and omits the spool/held/outbox lines in direct mode.
|
|
20
|
+
- **Direct-emit path** (owner decision 2026-09-08 "retire the client daemon";
|
|
21
|
+
contract `direct-emit-v1`). `hq mesh session <kind>` now POSTs each Work Mesh
|
|
22
|
+
event straight to `POST /v1/mesh/events` with the caller's normal HQ bearer
|
|
23
|
+
token instead of appending to the local spool; the server attributes the event
|
|
24
|
+
to a company server-side and fans it out over MQTT. Evidence for attribution
|
|
25
|
+
travels in an `evidence` object gathered client-side: `companySlug` (from the
|
|
26
|
+
session meta), `cwd`, `hqRoot`, `touchedPaths` (`--touched-path`, repeatable),
|
|
27
|
+
`repoPath` (derived from the enclosing git work-tree), `project`, `task`. On a
|
|
28
|
+
network/5xx/429 failure the batch is written to a tiny local retry file that the
|
|
29
|
+
next invocation drains — no long-lived process. `--enqueue` is retained as a
|
|
30
|
+
no-op for hook back-compat (it now emits directly).
|
|
31
|
+
- `hq mesh emit` — drains the retry file; `--replay-legacy` also replays the
|
|
32
|
+
legacy `spool.jsonl` + `held.jsonl` backlog through the new endpoint, lifting
|
|
33
|
+
each event's local-only fields into `evidence` and reusing its `eventId` so the
|
|
34
|
+
server dedupes.
|
|
35
|
+
- `hq mesh daemon doctor` now reports the direct-emit path: `emit last post`
|
|
36
|
+
(lastPostAt), per-status counts from the last attempt, and `emit retry depth`.
|
|
37
|
+
- `hq integrations connect --scopes "<list>"` sends an explicit permissions list
|
|
38
|
+
on the OAuth start request, for a vendor whose requirements HQ does not yet
|
|
39
|
+
know. Space- or comma-separated; order preserved, duplicates dropped. Omitting
|
|
40
|
+
the flag is unchanged — the request carries no `scopes` field at all and
|
|
41
|
+
hq-pro keeps choosing, because an empty list would ask a vendor for no access
|
|
42
|
+
rather than for its default. Refused up front where it could never be honoured
|
|
43
|
+
(alongside `--token`, or against an app whose auth mode is known to be `key`
|
|
44
|
+
or `none`), and carried through the printed bring-your-own-client command. A
|
|
45
|
+
console hand-off cannot carry it, and now says so.
|
|
46
|
+
|
|
47
|
+
### Changed
|
|
48
|
+
|
|
49
|
+
- The loopback sign-in callback page — the one HQ surface a person sees in a
|
|
50
|
+
browser during `hq integrations connect` — now carries the hq-console visual
|
|
51
|
+
language instead of an unstyled white page: near-black ground, hairline-bordered
|
|
52
|
+
card, one 13px size, grey heading over bright body, and a dim `HQ` mark.
|
|
53
|
+
Deliberately ships no webfont: the page is served by a local port and is often
|
|
54
|
+
the browser's first paint after a redirect, where a blocking font request would
|
|
55
|
+
delay it and would fail outright offline — exactly when a loopback callback
|
|
56
|
+
still works.
|
|
57
|
+
|
|
58
|
+
### Fixed
|
|
59
|
+
|
|
60
|
+
- `hq secrets set` now attributes its writes to a `cli_secrets_set` telemetry
|
|
61
|
+
surface, so CLI-created secrets are no longer recorded as generic `api` traffic.
|
|
62
|
+
- `hq mesh context backfill-held` now forwards the company evidence each held
|
|
63
|
+
event recorded at enqueue time (`cwd`, `hqRoot`, the session's bound
|
|
64
|
+
`companySlug`, and any `project`/`task`) into the synthesized reconcile
|
|
65
|
+
observation, so an ended session attributes to the company it was actually
|
|
66
|
+
bound to (or its `companies/{slug}/` cwd) instead of resolving to
|
|
67
|
+
`needs_company` and staying held forever. `--dry-run` now predicts with the
|
|
68
|
+
same evidence + shared resolver the write path uses, so its `reconciled`/
|
|
69
|
+
`unresolved` counts match a real run — previously `--dry-run` reported every
|
|
70
|
+
unattributed session as would-reconcile while the write run, given no
|
|
71
|
+
evidence in its observation, resolved none.
|
|
72
|
+
|
|
73
|
+
## [5.108.25] — 2026-09-08
|
|
74
|
+
|
|
5
75
|
## [5.108.24] — 2026-09-08
|
|
6
76
|
|
|
7
77
|
### Added
|
package/dist/commands/files.d.ts
CHANGED
|
@@ -89,6 +89,17 @@ export declare function stripRedundantCompanyScope(prefix: string): {
|
|
|
89
89
|
prefix: string;
|
|
90
90
|
strippedSlug: string;
|
|
91
91
|
} | null;
|
|
92
|
+
/**
|
|
93
|
+
* The company an anchored path names, or `undefined` for a relative/personal
|
|
94
|
+
* path.
|
|
95
|
+
*
|
|
96
|
+
* Discarding this slug is what makes retargeting possible: strip the anchor,
|
|
97
|
+
* then resolve the company from somewhere else, and the operation lands on a
|
|
98
|
+
* DIFFERENT company than the operator pasted. Callers pair this with
|
|
99
|
+
* `normalizeCompanyFilePrefix` so the anchor either MATCHES the explicit
|
|
100
|
+
* `--company` (or is refused), or BECOMES the company when none was given.
|
|
101
|
+
*/
|
|
102
|
+
export declare function companySlugFromAnchor(prefix: string, personal?: boolean): string | undefined;
|
|
92
103
|
export declare function runFilesDelete(params: RunFilesDeleteParams, deps?: {
|
|
93
104
|
confirm?: ConfirmFn;
|
|
94
105
|
}): Promise<void>;
|
package/dist/commands/files.js
CHANGED
|
@@ -5,6 +5,8 @@ import { resolveVaultCredential } from "../utils/resolve-vault-credential.js";
|
|
|
5
5
|
import { gateApiKeyCapabilities } from "../utils/api-key-command-gate.js";
|
|
6
6
|
import { vaultApiFetch, getCompanyUid } from "./secrets.js";
|
|
7
7
|
import { GROUP_ID_PATTERN, EMAIL_PATTERN, normalizeFilePrefix } from "./_patterns.js";
|
|
8
|
+
import { looksLikeCompanyUid } from "../utils/vault-api.js";
|
|
9
|
+
import { AuthError } from "../utils/auth-error.js";
|
|
8
10
|
// ---------------------------------------------------------------------------
|
|
9
11
|
// Pure helpers (exported for unit tests)
|
|
10
12
|
// ---------------------------------------------------------------------------
|
|
@@ -157,8 +159,8 @@ export function registerFilesCommand(program) {
|
|
|
157
159
|
process.exit(1);
|
|
158
160
|
}
|
|
159
161
|
// Fork: --with present → existing direct-grant path. No --with →
|
|
160
|
-
// browser-launch share-session path.
|
|
161
|
-
//
|
|
162
|
+
// browser-launch share-session path. The direct-grant path
|
|
163
|
+
// operates on a single prefix.
|
|
162
164
|
if (opts.with !== undefined) {
|
|
163
165
|
if (paths.length !== 1) {
|
|
164
166
|
console.error(chalk.red("Direct grant (--with) takes exactly one prefix. Pass multiple paths only when minting a share-session URL."));
|
|
@@ -190,7 +192,15 @@ export function registerFilesCommand(program) {
|
|
|
190
192
|
.requiredOption("--with <principal>", "Email address, group id, or '@all' to remove the company-wide grant")
|
|
191
193
|
.action(async (prefix, opts) => {
|
|
192
194
|
try {
|
|
193
|
-
|
|
195
|
+
// Unshare MUTATES access with no preview, and it resolves the target
|
|
196
|
+
// company from --company (not from the pasted anchor), so a mismatched
|
|
197
|
+
// anchor would revoke a real grant in the wrong company.
|
|
198
|
+
const canonicalPrefix = normalizeCompanyFilePrefix(prefix, {
|
|
199
|
+
mutating: true,
|
|
200
|
+
...(files.opts().company !== undefined
|
|
201
|
+
? { companySlug: files.opts().company }
|
|
202
|
+
: {}),
|
|
203
|
+
});
|
|
194
204
|
const principal = opts.with;
|
|
195
205
|
const isAll = principal === "@all";
|
|
196
206
|
const isEmail = !isAll && EMAIL_PATTERN.test(principal);
|
|
@@ -264,10 +274,21 @@ export function registerFilesCommand(program) {
|
|
|
264
274
|
.description("Show the ACL (access control list) for a file prefix")
|
|
265
275
|
.action(async (prefix) => {
|
|
266
276
|
try {
|
|
267
|
-
|
|
277
|
+
// Read-only, so stripping is safe — but a mismatched anchor would show
|
|
278
|
+
// a DIFFERENT company's ACL than the one the operator pasted.
|
|
279
|
+
const explicitCompany = files.opts().company;
|
|
280
|
+
const canonicalPrefix = normalizeCompanyFilePrefix(prefix, {
|
|
281
|
+
...(explicitCompany !== undefined
|
|
282
|
+
? { companySlug: explicitCompany }
|
|
283
|
+
: {}),
|
|
284
|
+
});
|
|
268
285
|
const token = (await resolveVaultCredential()).token;
|
|
269
|
-
|
|
270
|
-
|
|
286
|
+
// Resolve through the anchor so the answer describes the company the
|
|
287
|
+
// operator actually pasted, and never a different one.
|
|
288
|
+
const companyUid = await resolveCompanyUidForAnchoredPath(token, {
|
|
289
|
+
explicit: explicitCompany,
|
|
290
|
+
anchorSlug: companySlugFromAnchor(prefix),
|
|
291
|
+
});
|
|
271
292
|
// `/acl/tree` carries the prefix's own row metadata (directRow) and the
|
|
272
293
|
// caller's effectivePermission alongside direct/inherited/children, so
|
|
273
294
|
// a single request returns everything the "files acl" view needs.
|
|
@@ -474,7 +495,13 @@ export function registerFilesCommand(program) {
|
|
|
474
495
|
return files;
|
|
475
496
|
}
|
|
476
497
|
async function runDirectGrant(params) {
|
|
477
|
-
const canonicalPrefix =
|
|
498
|
+
const canonicalPrefix = normalizeCompanyFilePrefix(params.prefix, {
|
|
499
|
+
mutating: true,
|
|
500
|
+
rootAdvice: "Use --full --with <principal> to share the entire vault, or pass a bounded path.",
|
|
501
|
+
...(params.companySlug !== undefined
|
|
502
|
+
? { companySlug: params.companySlug }
|
|
503
|
+
: {}),
|
|
504
|
+
});
|
|
478
505
|
if (!params.permission) {
|
|
479
506
|
console.error(chalk.red("--permission is required when --with is set (read | write)"));
|
|
480
507
|
process.exit(1);
|
|
@@ -565,9 +592,15 @@ async function runDirectGrant(params) {
|
|
|
565
592
|
}
|
|
566
593
|
}
|
|
567
594
|
async function runShareSession(params) {
|
|
568
|
-
//
|
|
569
|
-
//
|
|
570
|
-
const normalizedPaths = params.paths.map(
|
|
595
|
+
// Validate every path before minting a session; an anchored path must never
|
|
596
|
+
// silently become a share of the corresponding company-relative objects.
|
|
597
|
+
const normalizedPaths = params.paths.map((prefix) => normalizeCompanyFilePrefix(prefix, {
|
|
598
|
+
mutating: true,
|
|
599
|
+
rootAdvice: "Use --full --with <principal> to share the entire vault, or pass a bounded path.",
|
|
600
|
+
...(params.companySlug !== undefined
|
|
601
|
+
? { companySlug: params.companySlug }
|
|
602
|
+
: {}),
|
|
603
|
+
}));
|
|
571
604
|
let expiresInMs;
|
|
572
605
|
if (params.expires !== undefined) {
|
|
573
606
|
const parsed = parseDuration(params.expires);
|
|
@@ -723,36 +756,179 @@ export function stripRedundantCompanyScope(prefix) {
|
|
|
723
756
|
return null;
|
|
724
757
|
return { prefix: m[2] ?? "", strippedSlug: m[1] };
|
|
725
758
|
}
|
|
759
|
+
/**
|
|
760
|
+
* Resolve the company for a possibly-anchored path, refusing any retarget.
|
|
761
|
+
*
|
|
762
|
+
* The pure normalizer can only compare slug against slug. That left a hole: a
|
|
763
|
+
* UID-form `--company` was skipped by the string check, so
|
|
764
|
+
* `--company cmp_beta delete <alpha-anchored-path>` stripped the anchor,
|
|
765
|
+
* resolved beta, and previewed/deleted from beta. Comparing IDENTITIES rather
|
|
766
|
+
* than strings is the only way to close it, and that needs resolution.
|
|
767
|
+
*
|
|
768
|
+
* - no anchor -> unchanged behaviour
|
|
769
|
+
* - anchor, no --company -> the anchor IS the company
|
|
770
|
+
* - UID anchor -> compare with the selected company UID
|
|
771
|
+
* - slug + explicit UID -> validate the selected entity's slug directly
|
|
772
|
+
* - slug + slug -> resolve BOTH and require the same uid
|
|
773
|
+
*
|
|
774
|
+
* API keys can verify UID anchors through their company selector or keyed
|
|
775
|
+
* membership. Slug anchors still fail closed because keys cannot read entities.
|
|
776
|
+
*/
|
|
777
|
+
async function resolveCompanyUidForAnchoredPath(token, opts) {
|
|
778
|
+
if (opts.anchorSlug === undefined) {
|
|
779
|
+
return getCompanyUid(token, opts.explicit);
|
|
780
|
+
}
|
|
781
|
+
if (looksLikeCompanyUid(opts.anchorSlug)) {
|
|
782
|
+
const selectedUid = await getCompanyUid(token, opts.explicit ?? (token.startsWith("hqk_") ? undefined : opts.anchorSlug));
|
|
783
|
+
if (selectedUid !== opts.anchorSlug) {
|
|
784
|
+
throw new Error(`Refusing anchored path: it names company '${opts.anchorSlug}' ` +
|
|
785
|
+
`but this command targets '${selectedUid}'. Pass a matching company or path.`);
|
|
786
|
+
}
|
|
787
|
+
return selectedUid;
|
|
788
|
+
}
|
|
789
|
+
if (token.startsWith("hqk_")) {
|
|
790
|
+
throw new Error(`Refusing anchored path under an API key: the anchor names ` +
|
|
791
|
+
`'${opts.anchorSlug}', and an API key cannot resolve a slug to verify ` +
|
|
792
|
+
`that is the company it is bound to. Pass the bucket-relative path.`);
|
|
793
|
+
}
|
|
794
|
+
if (opts.explicit !== undefined && looksLikeCompanyUid(opts.explicit)) {
|
|
795
|
+
// An explicit UID disambiguates duplicate slugs. Resolving the slug first
|
|
796
|
+
// would fail before the caller's unambiguous selector could be considered.
|
|
797
|
+
const response = await vaultApiFetch({
|
|
798
|
+
token,
|
|
799
|
+
path: `/entity/${encodeURIComponent(opts.explicit)}`,
|
|
800
|
+
signal: AbortSignal.timeout(15_000),
|
|
801
|
+
});
|
|
802
|
+
if (response.status === 401)
|
|
803
|
+
throw new AuthError();
|
|
804
|
+
if (!response.ok) {
|
|
805
|
+
throw new Error(`Failed to resolve company '${opts.explicit}' (HTTP ${response.status})`);
|
|
806
|
+
}
|
|
807
|
+
const data = (await response.json());
|
|
808
|
+
if (data.entity?.uid !== opts.explicit ||
|
|
809
|
+
data.entity.type !== "company" ||
|
|
810
|
+
data.entity.slug !== opts.anchorSlug) {
|
|
811
|
+
throw new Error(`Refusing anchored path: cannot verify that company '${opts.explicit}' ` +
|
|
812
|
+
`has the anchored slug '${opts.anchorSlug}'. Pass a matching company or path.`);
|
|
813
|
+
}
|
|
814
|
+
return opts.explicit;
|
|
815
|
+
}
|
|
816
|
+
const anchorUid = await getCompanyUid(token, opts.anchorSlug);
|
|
817
|
+
if (opts.explicit === undefined)
|
|
818
|
+
return anchorUid;
|
|
819
|
+
const explicitUid = await getCompanyUid(token, opts.explicit);
|
|
820
|
+
if (explicitUid !== anchorUid) {
|
|
821
|
+
throw new Error(`Refusing anchored path: it names company '${opts.anchorSlug}' ` +
|
|
822
|
+
`(${anchorUid}) but this command targets '${opts.explicit}' ` +
|
|
823
|
+
`(${explicitUid}). Re-run with --company ${opts.anchorSlug}, or pass ` +
|
|
824
|
+
`the bucket-relative path.`);
|
|
825
|
+
}
|
|
826
|
+
return anchorUid;
|
|
827
|
+
}
|
|
828
|
+
/**
|
|
829
|
+
* The company an anchored path names, or `undefined` for a relative/personal
|
|
830
|
+
* path.
|
|
831
|
+
*
|
|
832
|
+
* Discarding this slug is what makes retargeting possible: strip the anchor,
|
|
833
|
+
* then resolve the company from somewhere else, and the operation lands on a
|
|
834
|
+
* DIFFERENT company than the operator pasted. Callers pair this with
|
|
835
|
+
* `normalizeCompanyFilePrefix` so the anchor either MATCHES the explicit
|
|
836
|
+
* `--company` (or is refused), or BECOMES the company when none was given.
|
|
837
|
+
*/
|
|
838
|
+
export function companySlugFromAnchor(prefix, personal) {
|
|
839
|
+
if (personal)
|
|
840
|
+
return undefined;
|
|
841
|
+
return stripRedundantCompanyScope(prefix)?.strippedSlug;
|
|
842
|
+
}
|
|
843
|
+
/**
|
|
844
|
+
* Normalize company paths at the CLI boundary, preserving personal keys.
|
|
845
|
+
*
|
|
846
|
+
* `mutating` marks a route that CHANGES access and has no preview or
|
|
847
|
+
* confirmation step (grant, share, unshare). Those refuse an anchored path
|
|
848
|
+
* outright rather than guessing, because stripping the anchor changes which
|
|
849
|
+
* objects the operation reaches and there is nothing downstream to catch it.
|
|
850
|
+
*
|
|
851
|
+
* `companySlug` is the company the command is actually targeting. When the
|
|
852
|
+
* anchor names a DIFFERENT company we refuse in every mode: stripping would
|
|
853
|
+
* silently retarget the operation at the active company, so
|
|
854
|
+
* `--company beta unshare <alpha-anchored-path>` would revoke a real grant in
|
|
855
|
+
* beta while leaving alpha untouched.
|
|
856
|
+
*/
|
|
857
|
+
function normalizeCompanyFilePrefix(prefix, options = {}) {
|
|
858
|
+
const scope = options.personal ? null : stripRedundantCompanyScope(prefix);
|
|
859
|
+
if (!scope)
|
|
860
|
+
return normalizeFilePrefix(prefix);
|
|
861
|
+
// A `cmp_…` reference is a UID, not a slug, so it can only be compared to the
|
|
862
|
+
// anchor by RESOLVING it — which this pure helper cannot do. Comparing the
|
|
863
|
+
// strings would refuse `--company cmp_acme` against an `acme` anchor even
|
|
864
|
+
// though both name the same company, and `getCompanyUid`'s ambiguity error
|
|
865
|
+
// explicitly tells callers to retry with `--company <uid>`. Skip the check
|
|
866
|
+
// rather than break that recovery path; the UID is authoritative either way.
|
|
867
|
+
if (options.companySlug !== undefined &&
|
|
868
|
+
!looksLikeCompanyUid(options.companySlug) &&
|
|
869
|
+
!looksLikeCompanyUid(scope.strippedSlug) &&
|
|
870
|
+
options.companySlug !== scope.strippedSlug) {
|
|
871
|
+
throw new Error(`Refusing anchored path '${prefix}': it names company ` +
|
|
872
|
+
`'${scope.strippedSlug}' but this command targets ` +
|
|
873
|
+
`'${options.companySlug}'. Stripping the anchor would apply the ` +
|
|
874
|
+
`operation to '${options.companySlug}' instead. Re-run with ` +
|
|
875
|
+
`--company ${scope.strippedSlug}, or pass the bucket-relative path.`);
|
|
876
|
+
}
|
|
877
|
+
// A bare anchor strips to nothing. The ACL routes would ship that empty
|
|
878
|
+
// prefix to a server that answers 400, so fail here with a message that says
|
|
879
|
+
// what to do instead. `deferRootReject` is for callers that already have a
|
|
880
|
+
// root guard with better copy — `runFilesDelete` says "Refusing to delete the
|
|
881
|
+
// vault root", which beats anything this generic helper could write.
|
|
882
|
+
if (!scope.prefix && !options.deferRootReject) {
|
|
883
|
+
// Route-specific: `--full` exists ONLY on `files share`. Suggesting it for
|
|
884
|
+
// `unshare` fails with an unknown option and names the opposite operation.
|
|
885
|
+
const advice = options.rootAdvice ??
|
|
886
|
+
"Pass a bounded bucket-relative path (e.g. 'projects/foo').";
|
|
887
|
+
throw new Error(`Refusing anchored path '${prefix}': it resolves to the vault root. ` +
|
|
888
|
+
advice);
|
|
889
|
+
}
|
|
890
|
+
if (options.mutating) {
|
|
891
|
+
throw new Error(`Refusing anchored path '${prefix}': the vault is already ` +
|
|
892
|
+
`company-scoped, so removing 'companies/${scope.strippedSlug}/' would ` +
|
|
893
|
+
`change which objects this affects. Retry with the bucket-relative ` +
|
|
894
|
+
`path '${normalizeFilePrefix(scope.prefix)}' if that is what you intend.`);
|
|
895
|
+
}
|
|
896
|
+
console.error(chalk.yellow(`Note: stripped redundant 'companies/${scope.strippedSlug}/' — the vault ` +
|
|
897
|
+
`is already company-scoped; using bucket-relative '${scope.prefix}'.`));
|
|
898
|
+
return normalizeFilePrefix(scope.prefix);
|
|
899
|
+
}
|
|
726
900
|
export async function runFilesDelete(params, deps = {}) {
|
|
727
901
|
const confirm = deps.confirm ?? realConfirm;
|
|
728
|
-
//
|
|
729
|
-
//
|
|
730
|
-
//
|
|
731
|
-
//
|
|
732
|
-
//
|
|
733
|
-
//
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
// server as a vault-wide delete. The server enforces this too (defense in
|
|
744
|
-
// depth), but failing fast here is clearer and avoids a wasted round-trip.
|
|
745
|
-
const normalized = normalizeFilePrefix(rawPrefix);
|
|
902
|
+
// Strip the local company anchor before preview/confirmation (HQ-8F/HQ-CA).
|
|
903
|
+
// Personal keys are already bucket-relative and must keep any such anchor.
|
|
904
|
+
// Normalize trailing `/` to `/*`, then reject the root/empty prefix before
|
|
905
|
+
// any network call, including when stripping a bare company anchor empties it.
|
|
906
|
+
// The anchor names a company. Compare it with --company (refusing a
|
|
907
|
+
// mismatch), and when --company was omitted ADOPT it rather than letting the
|
|
908
|
+
// membership fallback pick a different company to delete from.
|
|
909
|
+
const anchorSlug = companySlugFromAnchor(params.prefix, params.personal);
|
|
910
|
+
const normalized = normalizeCompanyFilePrefix(params.prefix, {
|
|
911
|
+
personal: params.personal,
|
|
912
|
+
deferRootReject: true,
|
|
913
|
+
...(params.companySlug !== undefined
|
|
914
|
+
? { companySlug: params.companySlug }
|
|
915
|
+
: {}),
|
|
916
|
+
});
|
|
746
917
|
if (normalized === "" || normalized === "*" || normalized === "/*") {
|
|
747
918
|
console.error(chalk.red("Refusing to delete the vault root. Pass a bounded prefix (e.g. 'projects/foo/' or 'projects/foo/*') or an exact key."));
|
|
748
919
|
process.exit(1);
|
|
749
920
|
}
|
|
750
921
|
const token = (await resolveVaultCredential()).token;
|
|
751
922
|
// Personal scope resolves the vault server-side from the caller's identity —
|
|
752
|
-
// no company to look up. Company scope resolves the companyUid
|
|
923
|
+
// no company to look up. Company scope resolves the companyUid through the
|
|
924
|
+
// anchor-aware resolver so an anchored path can never delete from a company
|
|
925
|
+
// other than the one it names.
|
|
753
926
|
const companyUid = params.personal
|
|
754
927
|
? undefined
|
|
755
|
-
: await
|
|
928
|
+
: await resolveCompanyUidForAnchoredPath(token, {
|
|
929
|
+
explicit: params.companySlug,
|
|
930
|
+
anchorSlug,
|
|
931
|
+
});
|
|
756
932
|
const scopeArgs = params.personal
|
|
757
933
|
? { personal: true }
|
|
758
934
|
: { companyUid };
|
|
@@ -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
|
|
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, "&")
|
|
37
|
+
.replace(/</g, "<")
|
|
38
|
+
.replace(/>/g, ">")
|
|
39
|
+
.replace(/"/g, """);
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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.
|