@indigoai-us/hq-cli 5.326.0 → 5.328.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -0
- package/assets/scaffold/core/scripts/hq-dm-bind.sh +1 -1
- package/assets/scaffold/core/scripts/hq-job-run.sh +8 -8
- package/assets/scaffold/core/scripts/ontology-audience-grant.sh +1 -1
- package/assets/scaffold/core/scripts/ontology-candidate.sh +1 -1
- package/assets/scaffold/core/scripts/resolve-company.sh +1 -1
- package/dist/command-catalog.generated.d.ts +9 -0
- package/dist/command-catalog.generated.js +10 -0
- package/dist/commands/auth.js +3 -1
- package/dist/commands/core-detect-stale-review-base-fast.d.ts +2 -0
- package/dist/commands/core-detect-stale-review-base-fast.js +23 -0
- package/dist/commands/core-register-project-native.js +1 -1
- package/dist/commands/core-repo-run-registry-fast.d.ts +2 -0
- package/dist/commands/core-repo-run-registry-fast.js +35 -0
- package/dist/commands/core-rrr-worktree-scope-fix-fast-flag.d.ts +7 -0
- package/dist/commands/core-rrr-worktree-scope-fix-fast-flag.js +25 -0
- package/dist/commands/core-session-journal-native.js +1 -1
- package/dist/commands/core-share-suggestion-state-fast.d.ts +2 -0
- package/dist/commands/core-share-suggestion-state-fast.js +25 -0
- package/dist/commands/logout.js +2 -0
- package/dist/commands/mcp-serve.js +1 -1
- package/dist/commands/mcp-status.js +7 -0
- package/dist/fast-core.js +3 -6
- package/dist/fast-scaffold.js +5 -1
- package/dist/lib/daemon/mcp-endpoint.d.ts +8 -0
- package/dist/lib/daemon/mcp-endpoint.js +30 -0
- package/dist/lib/daemon/mcp-oauth.d.ts +24 -0
- package/dist/lib/daemon/mcp-oauth.js +298 -0
- package/dist/lib/daemon/ops/mcp.d.ts +0 -1
- package/dist/lib/daemon/ops/mcp.js +31 -31
- package/dist/lib/process-flag-company-context-fast.d.ts +4 -0
- package/dist/lib/process-flag-company-context-fast.js +20 -0
- package/dist/lib/process-flag-company-context-light.d.ts +9 -0
- package/dist/lib/process-flag-company-context-light.js +81 -0
- package/dist/lib/process-flag-company-context.d.ts +2 -4
- package/dist/lib/process-flag-company-context.js +2 -46
- package/dist/node-network-compat-fast.js +4 -0
- package/dist/utils/hq-roots-core.d.ts +130 -0
- package/dist/utils/hq-roots-core.js +354 -0
- package/dist/utils/hq-roots.d.ts +2 -129
- package/dist/utils/hq-roots.js +2 -353
- package/dist/utils/package-root-diagnostics.d.ts +5 -36
- package/dist/utils/package-root-diagnostics.js +4 -83
- package/dist/utils/package-root-resolution-error.d.ts +32 -0
- package/dist/utils/package-root-resolution-error.js +79 -0
- package/dist/utils/vault-api.js +16 -1
- package/package.json +1 -1
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as http from "node:http";
|
|
4
|
+
import * as os from "node:os";
|
|
5
|
+
import * as path from "node:path";
|
|
6
|
+
import open from "open";
|
|
7
|
+
import { resolveCognitoTokenSource } from "../../utils/cognito-session.js";
|
|
8
|
+
import { anywhereRuntimeEnabled } from "../../commands/anywhere-runtime-flag.js";
|
|
9
|
+
import { resolveHqMcpOAuthOrigin } from "./mcp-endpoint.js";
|
|
10
|
+
const MCP_SCOPE = "openid email profile hq-mcp/use";
|
|
11
|
+
const EXPIRY_SKEW_MS = 60_000;
|
|
12
|
+
const DEFAULT_CALLBACK_TIMEOUT_MS = 5 * 60_000;
|
|
13
|
+
const MAX_RESPONSE_BYTES = 64 * 1024;
|
|
14
|
+
export class McpOAuthError extends Error {
|
|
15
|
+
code = "hq_mcp_auth_required";
|
|
16
|
+
constructor(message) {
|
|
17
|
+
super(message);
|
|
18
|
+
this.name = "McpOAuthError";
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
function cacheFile(env, home, origin) {
|
|
22
|
+
const stateDir = env.HQ_STATE_DIR?.trim() || path.join(home, ".hq");
|
|
23
|
+
const originHash = createHash("sha256").update(origin.origin).digest("hex").slice(0, 16);
|
|
24
|
+
return path.join(path.resolve(stateDir), `mcp-oauth-tokens-${originHash}.json`);
|
|
25
|
+
}
|
|
26
|
+
function readCache(file) {
|
|
27
|
+
try {
|
|
28
|
+
const value = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
29
|
+
if (!value || typeof value !== "object")
|
|
30
|
+
return undefined;
|
|
31
|
+
const cache = value;
|
|
32
|
+
if (cache.version !== 1 || typeof cache.origin !== "string" || !cache.origin ||
|
|
33
|
+
typeof cache.clientId !== "string" || !cache.clientId ||
|
|
34
|
+
typeof cache.accessToken !== "string" || !cache.accessToken ||
|
|
35
|
+
typeof cache.refreshToken !== "string" || !cache.refreshToken ||
|
|
36
|
+
typeof cache.expiresAt !== "number" || !Number.isFinite(cache.expiresAt))
|
|
37
|
+
return undefined;
|
|
38
|
+
return cache;
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
if (error.code === "ENOENT" || error instanceof SyntaxError)
|
|
42
|
+
return undefined;
|
|
43
|
+
throw new McpOAuthError("The hosted MCP token cache could not be read. Check its permissions or run `hq mcp login` again.");
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
function writeCache(file, cache) {
|
|
47
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
48
|
+
const temporary = `${file}.tmp-${process.pid}-${randomBytes(6).toString("hex")}`;
|
|
49
|
+
const fd = fs.openSync(temporary, "wx", 0o600);
|
|
50
|
+
try {
|
|
51
|
+
fs.writeFileSync(fd, JSON.stringify(cache));
|
|
52
|
+
fs.fsyncSync(fd);
|
|
53
|
+
}
|
|
54
|
+
finally {
|
|
55
|
+
fs.closeSync(fd);
|
|
56
|
+
}
|
|
57
|
+
try {
|
|
58
|
+
fs.renameSync(temporary, file);
|
|
59
|
+
fs.chmodSync(file, 0o600);
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
try {
|
|
63
|
+
fs.unlinkSync(temporary);
|
|
64
|
+
}
|
|
65
|
+
catch { /* retain the original write error */ }
|
|
66
|
+
throw error;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
function asObject(value) {
|
|
70
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : undefined;
|
|
71
|
+
}
|
|
72
|
+
async function postForm(url, form, fetchImpl) {
|
|
73
|
+
let response;
|
|
74
|
+
try {
|
|
75
|
+
response = await fetchImpl(url, {
|
|
76
|
+
method: "POST",
|
|
77
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
78
|
+
body: form.toString(),
|
|
79
|
+
signal: AbortSignal.timeout(30_000),
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
throw new McpOAuthError("Hosted MCP sign-in could not reach the OAuth service. Retry `hq mcp login`.");
|
|
84
|
+
}
|
|
85
|
+
if (!response.ok) {
|
|
86
|
+
throw new McpOAuthError("Hosted MCP OAuth request failed (HTTP " + response.status + "). Retry `hq mcp login`.");
|
|
87
|
+
}
|
|
88
|
+
let data;
|
|
89
|
+
try {
|
|
90
|
+
const raw = await response.text();
|
|
91
|
+
if (raw.length > MAX_RESPONSE_BYTES)
|
|
92
|
+
throw new Error("response too large");
|
|
93
|
+
data = JSON.parse(raw);
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
throw new McpOAuthError("Hosted MCP OAuth returned an invalid response. Retry `hq mcp login`.");
|
|
97
|
+
}
|
|
98
|
+
const result = asObject(data);
|
|
99
|
+
if (!result)
|
|
100
|
+
throw new McpOAuthError("Hosted MCP OAuth returned an invalid response. Retry `hq mcp login`.");
|
|
101
|
+
return result;
|
|
102
|
+
}
|
|
103
|
+
function validTokens(data, now, previous) {
|
|
104
|
+
if (typeof data.access_token !== "string" || !data.access_token ||
|
|
105
|
+
typeof data.expires_in !== "number" || !Number.isFinite(data.expires_in) || data.expires_in <= 0)
|
|
106
|
+
throw new McpOAuthError("Hosted MCP OAuth did not return a usable access token.");
|
|
107
|
+
const refreshToken = typeof data.refresh_token === "string" && data.refresh_token
|
|
108
|
+
? data.refresh_token
|
|
109
|
+
: previous?.refreshToken;
|
|
110
|
+
if (!refreshToken)
|
|
111
|
+
throw new McpOAuthError("Hosted MCP OAuth did not return a refresh token. Run `hq mcp login` again.");
|
|
112
|
+
return {
|
|
113
|
+
version: 1,
|
|
114
|
+
origin: previous?.origin ?? "",
|
|
115
|
+
clientId: previous?.clientId ?? "",
|
|
116
|
+
accessToken: data.access_token,
|
|
117
|
+
refreshToken,
|
|
118
|
+
expiresAt: now + data.expires_in * 1000,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
function oauthUrl(origin, endpoint) {
|
|
122
|
+
return new URL(endpoint, origin);
|
|
123
|
+
}
|
|
124
|
+
function newPkce() {
|
|
125
|
+
const verifier = randomBytes(32).toString("base64url");
|
|
126
|
+
return { verifier, challenge: createHash("sha256").update(verifier).digest("base64url") };
|
|
127
|
+
}
|
|
128
|
+
async function runLogin(options) {
|
|
129
|
+
const env = options.env ?? process.env;
|
|
130
|
+
if (!(options.isEnabled ?? (() => anywhereRuntimeEnabled(env)))()) {
|
|
131
|
+
throw new McpOAuthError("HQ Anywhere is not enabled. Enable the hq-anywhere-runtime flag to use `hq mcp login`.");
|
|
132
|
+
}
|
|
133
|
+
if ((options.isMachine ?? (() => resolveCognitoTokenSource(env) === "machine"))()) {
|
|
134
|
+
throw new McpOAuthError("`hq mcp login` is for person accounts; this box uses machine credentials.");
|
|
135
|
+
}
|
|
136
|
+
const origin = options.oauthOrigin ?? resolveHqMcpOAuthOrigin(env);
|
|
137
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
138
|
+
const output = options.writeOutput ?? ((message) => process.stdout.write(`${message}\n`));
|
|
139
|
+
const pkce = newPkce();
|
|
140
|
+
const state = randomBytes(24).toString("base64url");
|
|
141
|
+
let authorizationCode;
|
|
142
|
+
let callbackError;
|
|
143
|
+
let resolveCallback;
|
|
144
|
+
const callback = new Promise((resolve) => { resolveCallback = resolve; });
|
|
145
|
+
const server = http.createServer((request, response) => {
|
|
146
|
+
const url = new URL(request.url ?? "/", "http://127.0.0.1");
|
|
147
|
+
if (request.method !== "GET" || url.pathname !== "/oauth/callback") {
|
|
148
|
+
response.writeHead(404).end("Not found");
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
if (url.searchParams.get("state") !== state) {
|
|
152
|
+
callbackError = new Error("state");
|
|
153
|
+
}
|
|
154
|
+
else if (url.searchParams.has("error")) {
|
|
155
|
+
callbackError = new Error("authorization");
|
|
156
|
+
}
|
|
157
|
+
else {
|
|
158
|
+
authorizationCode = url.searchParams.get("code") ?? undefined;
|
|
159
|
+
if (!authorizationCode)
|
|
160
|
+
callbackError = new Error("code");
|
|
161
|
+
}
|
|
162
|
+
response.writeHead(callbackError ? 400 : 200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
|
|
163
|
+
response.end(callbackError ? "Sign-in failed. Return to the HQ CLI." : "Sign-in complete. Return to the HQ CLI.");
|
|
164
|
+
resolveCallback();
|
|
165
|
+
});
|
|
166
|
+
let timeout;
|
|
167
|
+
try {
|
|
168
|
+
await new Promise((resolve, reject) => {
|
|
169
|
+
server.once("error", reject);
|
|
170
|
+
server.listen(0, "127.0.0.1", resolve);
|
|
171
|
+
});
|
|
172
|
+
const address = server.address();
|
|
173
|
+
if (!address || typeof address === "string")
|
|
174
|
+
throw new McpOAuthError("Could not start the local OAuth callback listener.");
|
|
175
|
+
const redirectUri = `http://127.0.0.1:${address.port}/oauth/callback`;
|
|
176
|
+
let registrationResponse;
|
|
177
|
+
try {
|
|
178
|
+
registrationResponse = await fetchImpl(oauthUrl(origin, "/oauth/register"), {
|
|
179
|
+
method: "POST",
|
|
180
|
+
headers: { "content-type": "application/json" },
|
|
181
|
+
body: JSON.stringify({
|
|
182
|
+
redirect_uris: [redirectUri], token_endpoint_auth_method: "none",
|
|
183
|
+
grant_types: ["authorization_code", "refresh_token"], response_types: ["code"],
|
|
184
|
+
client_name: "HQ CLI",
|
|
185
|
+
}),
|
|
186
|
+
signal: AbortSignal.timeout(30_000),
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
throw new McpOAuthError("Hosted MCP client registration failed. Retry `hq mcp login`.");
|
|
191
|
+
}
|
|
192
|
+
if (!registrationResponse.ok)
|
|
193
|
+
throw new McpOAuthError(`Hosted MCP client registration failed (HTTP ${registrationResponse.status}).`);
|
|
194
|
+
let registration;
|
|
195
|
+
try {
|
|
196
|
+
registration = asObject(await registrationResponse.json());
|
|
197
|
+
}
|
|
198
|
+
catch { /* handled below */ }
|
|
199
|
+
if (!registration || typeof registration.client_id !== "string" || !registration.client_id.startsWith("dcr_")) {
|
|
200
|
+
throw new McpOAuthError("Hosted MCP returned an invalid client registration.");
|
|
201
|
+
}
|
|
202
|
+
const clientId = registration.client_id;
|
|
203
|
+
const authorize = oauthUrl(origin, "/oauth/authorize");
|
|
204
|
+
authorize.search = new URLSearchParams({
|
|
205
|
+
response_type: "code", client_id: clientId, redirect_uri: redirectUri,
|
|
206
|
+
code_challenge: pkce.challenge, code_challenge_method: "S256", state, scope: MCP_SCOPE,
|
|
207
|
+
}).toString();
|
|
208
|
+
const remoteShell = Boolean(env.SSH_CONNECTION || env.SSH_CLIENT || env.SSH_TTY);
|
|
209
|
+
if (remoteShell) {
|
|
210
|
+
output(`Open this URL on your workstation to sign in to hosted hq-mcp:\n${authorize.toString()}\nForward the callback port first with: ssh -L ${address.port}:127.0.0.1:${address.port} <this-host>`);
|
|
211
|
+
if (options.openBrowser)
|
|
212
|
+
await options.openBrowser(authorize.toString());
|
|
213
|
+
}
|
|
214
|
+
else
|
|
215
|
+
try {
|
|
216
|
+
await (options.openBrowser ?? ((url) => open(url)))(authorize.toString());
|
|
217
|
+
}
|
|
218
|
+
catch {
|
|
219
|
+
output(`Open this URL to sign in to hosted hq-mcp:\n${authorize.toString()}`);
|
|
220
|
+
}
|
|
221
|
+
timeout = setTimeout(() => {
|
|
222
|
+
callbackError = new Error("timeout");
|
|
223
|
+
resolveCallback();
|
|
224
|
+
}, options.callbackTimeoutMs ?? DEFAULT_CALLBACK_TIMEOUT_MS);
|
|
225
|
+
await callback;
|
|
226
|
+
if (callbackError || !authorizationCode)
|
|
227
|
+
throw new McpOAuthError("Hosted MCP sign-in did not complete. Retry `hq mcp login`.");
|
|
228
|
+
const tokens = await postForm(oauthUrl(origin, "/oauth/token"), new URLSearchParams({
|
|
229
|
+
grant_type: "authorization_code", client_id: clientId, code: authorizationCode,
|
|
230
|
+
redirect_uri: redirectUri, code_verifier: pkce.verifier,
|
|
231
|
+
}), fetchImpl);
|
|
232
|
+
const now = (options.now ?? Date.now)();
|
|
233
|
+
const cache = validTokens(tokens, now);
|
|
234
|
+
cache.origin = origin.origin;
|
|
235
|
+
cache.clientId = clientId;
|
|
236
|
+
return cache;
|
|
237
|
+
}
|
|
238
|
+
finally {
|
|
239
|
+
if (timeout)
|
|
240
|
+
clearTimeout(timeout);
|
|
241
|
+
await new Promise((resolve) => server.close(() => resolve()));
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
export function mcpOAuthCacheFile(options = {}) {
|
|
245
|
+
const env = options.env ?? process.env;
|
|
246
|
+
return cacheFile(env, options.home ?? os.homedir(), options.oauthOrigin ?? resolveHqMcpOAuthOrigin(env));
|
|
247
|
+
}
|
|
248
|
+
/** Perform hosted DCR + PKCE login and cache only the hq-mcp audience tokens. */
|
|
249
|
+
export async function loginHostedMcp(options = {}) {
|
|
250
|
+
const cache = await runLogin(options);
|
|
251
|
+
writeCache(mcpOAuthCacheFile(options), cache);
|
|
252
|
+
const writeOutput = options.writeOutput ?? ((message) => process.stdout.write(`${message}\n`));
|
|
253
|
+
writeOutput("Signed in to hosted hq-mcp. The MCP audience token is stored in the private HQ token cache.");
|
|
254
|
+
}
|
|
255
|
+
/** Load or refresh a person's hosted MCP token without printing token material. */
|
|
256
|
+
export async function getMcpAudienceToken(options = {}) {
|
|
257
|
+
const file = mcpOAuthCacheFile(options);
|
|
258
|
+
const cache = readCache(file);
|
|
259
|
+
const now = (options.now ?? Date.now)();
|
|
260
|
+
if (!cache || cache.origin !== (options.oauthOrigin ?? resolveHqMcpOAuthOrigin(options.env ?? process.env)).origin)
|
|
261
|
+
throw new McpOAuthError("Sign in to hosted hq-mcp with `hq mcp login` first.");
|
|
262
|
+
if (cache.expiresAt > now + EXPIRY_SKEW_MS)
|
|
263
|
+
return cache.accessToken;
|
|
264
|
+
const origin = options.oauthOrigin ?? resolveHqMcpOAuthOrigin(options.env ?? process.env);
|
|
265
|
+
const tokens = await postForm(oauthUrl(origin, "/oauth/token"), new URLSearchParams({
|
|
266
|
+
grant_type: "refresh_token", client_id: cache.clientId, refresh_token: cache.refreshToken,
|
|
267
|
+
}), options.fetchImpl ?? fetch);
|
|
268
|
+
const refreshed = validTokens(tokens, now, cache);
|
|
269
|
+
refreshed.clientId = cache.clientId;
|
|
270
|
+
writeCache(file, refreshed);
|
|
271
|
+
return refreshed.accessToken;
|
|
272
|
+
}
|
|
273
|
+
/** Remove all per-origin hosted MCP OAuth caches during either HQ logout flow. */
|
|
274
|
+
export function clearMcpOAuthCaches(options = {}) {
|
|
275
|
+
const env = options.env ?? process.env;
|
|
276
|
+
const stateDir = path.resolve(env.HQ_STATE_DIR?.trim() || path.join(options.home ?? os.homedir(), ".hq"));
|
|
277
|
+
let entries;
|
|
278
|
+
try {
|
|
279
|
+
entries = fs.readdirSync(stateDir);
|
|
280
|
+
}
|
|
281
|
+
catch (error) {
|
|
282
|
+
if (error.code === "ENOENT")
|
|
283
|
+
return;
|
|
284
|
+
throw new McpOAuthError("The hosted MCP token cache could not be cleared. Check its directory permissions.");
|
|
285
|
+
}
|
|
286
|
+
for (const entry of entries) {
|
|
287
|
+
if (entry !== "mcp-oauth-tokens.json" && !/^mcp-oauth-tokens-[a-f0-9]{16}\.json$/.test(entry))
|
|
288
|
+
continue;
|
|
289
|
+
try {
|
|
290
|
+
fs.unlinkSync(path.join(stateDir, entry));
|
|
291
|
+
}
|
|
292
|
+
catch (error) {
|
|
293
|
+
if (error.code !== "ENOENT")
|
|
294
|
+
throw new McpOAuthError("The hosted MCP token cache could not be cleared. Check its file permissions.");
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
//# sourceMappingURL=mcp-oauth.js.map
|
|
@@ -13,7 +13,6 @@ export interface McpToolResult {
|
|
|
13
13
|
}
|
|
14
14
|
export declare function jsonResult(value: unknown): McpToolResult;
|
|
15
15
|
export declare const HQ_MCP_HTTP_TIMEOUT_MS = 30000;
|
|
16
|
-
/** Derive the hosted MCP sibling from the same hq-pro URL used by hq-cli. */
|
|
17
16
|
export declare function resolveHqMcpEndpoint(env?: NodeJS.ProcessEnv): URL;
|
|
18
17
|
export interface HqMcpBridgeOptions {
|
|
19
18
|
endpoint?: URL;
|
|
@@ -2,10 +2,7 @@
|
|
|
2
2
|
import { spawn } from "node:child_process";
|
|
3
3
|
import * as fs from "node:fs";
|
|
4
4
|
import * as path from "node:path";
|
|
5
|
-
import {
|
|
6
|
-
import { StreamableHTTPClientTransport, StreamableHTTPError } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
7
|
-
import { ensureCognitoToken, isMachineIdentity } from "../../../utils/cognito-session.js";
|
|
8
|
-
import { DEFAULT_VAULT_API_URL } from "../../../utils/cognito-session.js";
|
|
5
|
+
import { McpEndpointError, resolveHqMcpEndpoint as resolveEndpoint } from "../mcp-endpoint.js";
|
|
9
6
|
import { ensurePrivateDir, openPrivateAppend } from "../paths.js";
|
|
10
7
|
export class McpOpError extends Error {
|
|
11
8
|
code;
|
|
@@ -20,28 +17,28 @@ export function jsonResult(value) {
|
|
|
20
17
|
return { content: [{ type: "text", text: JSON.stringify(value, null, 2) }], structuredContent: structured };
|
|
21
18
|
}
|
|
22
19
|
export const HQ_MCP_HTTP_TIMEOUT_MS = 30_000;
|
|
23
|
-
/** Derive the hosted MCP sibling from the same hq-pro URL used by hq-cli. */
|
|
24
20
|
export function resolveHqMcpEndpoint(env = process.env) {
|
|
25
|
-
const apiUrl = env.HQ_VAULT_API_URL?.trim() || DEFAULT_VAULT_API_URL;
|
|
26
|
-
let api;
|
|
27
21
|
try {
|
|
28
|
-
|
|
22
|
+
return resolveEndpoint(env);
|
|
29
23
|
}
|
|
30
|
-
catch {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
throw new McpOpError("hq_mcp_config_error", "Hosted hq-mcp requires an HTTPS hqapi host in HQ_VAULT_API_URL.");
|
|
24
|
+
catch (error) {
|
|
25
|
+
if (error instanceof McpEndpointError)
|
|
26
|
+
throw new McpOpError(error.code, error.message);
|
|
27
|
+
throw error;
|
|
35
28
|
}
|
|
36
|
-
api.hostname = `hq-mcp.${api.hostname.slice("hqapi.".length)}`;
|
|
37
|
-
api.pathname = "/mcp";
|
|
38
|
-
api.search = "";
|
|
39
|
-
api.hash = "";
|
|
40
|
-
return api;
|
|
41
29
|
}
|
|
42
30
|
async function defaultMcpToken() {
|
|
43
|
-
|
|
44
|
-
|
|
31
|
+
const { ensureCognitoToken, resolveCognitoTokenSource } = await import("../../../utils/cognito-session.js");
|
|
32
|
+
if (resolveCognitoTokenSource() !== "machine") {
|
|
33
|
+
const { getMcpAudienceToken, McpOAuthError } = await import("../mcp-oauth.js");
|
|
34
|
+
try {
|
|
35
|
+
return await getMcpAudienceToken();
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
if (error instanceof McpOAuthError)
|
|
39
|
+
throw new McpOpError(error.code, error.message);
|
|
40
|
+
throw new McpOpError("hq_mcp_auth_required", "Hosted MCP sign-in is required. Run `hq mcp login` and retry.");
|
|
41
|
+
}
|
|
45
42
|
}
|
|
46
43
|
try {
|
|
47
44
|
// Send this box's existing machine token. The hosted hq-mcp audience check
|
|
@@ -82,12 +79,7 @@ export class HqMcpBridge {
|
|
|
82
79
|
if (!tools.some((tool) => tool.name === name)) {
|
|
83
80
|
throw new McpOpError("hq_mcp_tool_unavailable", `Hosted hq-mcp does not expose ${name}.`);
|
|
84
81
|
}
|
|
85
|
-
|
|
86
|
-
return await client.callTool({ name, arguments: args });
|
|
87
|
-
}
|
|
88
|
-
catch (error) {
|
|
89
|
-
throw this.toSafeError(error, token);
|
|
90
|
-
}
|
|
82
|
+
return await client.callTool({ name, arguments: args });
|
|
91
83
|
}, timeoutMs);
|
|
92
84
|
}
|
|
93
85
|
close() {
|
|
@@ -104,11 +96,17 @@ export class HqMcpBridge {
|
|
|
104
96
|
let timeout;
|
|
105
97
|
let timedOut = false;
|
|
106
98
|
const abortController = new AbortController();
|
|
99
|
+
let httpErrorClass;
|
|
107
100
|
try {
|
|
101
|
+
const [clientModule, httpModule] = await Promise.all([
|
|
102
|
+
import("@modelcontextprotocol/sdk/client/index.js"),
|
|
103
|
+
import("@modelcontextprotocol/sdk/client/streamableHttp.js"),
|
|
104
|
+
]);
|
|
105
|
+
httpErrorClass = httpModule.StreamableHTTPError;
|
|
108
106
|
token = await this.getToken();
|
|
109
107
|
if (!token.trim())
|
|
110
108
|
throw new McpOpError("hq_mcp_auth_required", "An hq-mcp audience token is required.");
|
|
111
|
-
transport = new StreamableHTTPClientTransport(this.endpoint, {
|
|
109
|
+
transport = new httpModule.StreamableHTTPClientTransport(this.endpoint, {
|
|
112
110
|
requestInit: {
|
|
113
111
|
headers: { Authorization: `Bearer ${token}` },
|
|
114
112
|
},
|
|
@@ -117,7 +115,7 @@ export class HqMcpBridge {
|
|
|
117
115
|
signal: init?.signal ? AbortSignal.any([abortController.signal, init.signal]) : abortController.signal,
|
|
118
116
|
}),
|
|
119
117
|
});
|
|
120
|
-
client = new Client({ name: "hqd", version: "1" }, { capabilities: {} });
|
|
118
|
+
client = new clientModule.Client({ name: "hqd", version: "1" }, { capabilities: {} });
|
|
121
119
|
this.activeClients.add(client);
|
|
122
120
|
timeout = setTimeout(() => {
|
|
123
121
|
timedOut = true;
|
|
@@ -133,7 +131,7 @@ export class HqMcpBridge {
|
|
|
133
131
|
if (timedOut) {
|
|
134
132
|
throw new McpOpError("hq_mcp_timeout", `Hosted hq-mcp request did not finish within ${timeoutMs}ms.`);
|
|
135
133
|
}
|
|
136
|
-
throw this.toSafeError(error, token);
|
|
134
|
+
throw this.toSafeError(error, token, httpErrorClass);
|
|
137
135
|
}
|
|
138
136
|
finally {
|
|
139
137
|
if (timeout)
|
|
@@ -157,10 +155,12 @@ export class HqMcpBridge {
|
|
|
157
155
|
}
|
|
158
156
|
}
|
|
159
157
|
}
|
|
160
|
-
toSafeError(error, token) {
|
|
158
|
+
toSafeError(error, token, StreamableHTTPErrorClass) {
|
|
161
159
|
if (error instanceof McpOpError)
|
|
162
160
|
return error;
|
|
163
|
-
|
|
161
|
+
if (error instanceof McpEndpointError)
|
|
162
|
+
return new McpOpError(error.code, error.message);
|
|
163
|
+
const status = StreamableHTTPErrorClass && error instanceof StreamableHTTPErrorClass ? error.code : undefined;
|
|
164
164
|
if (status === 401 || status === 403) {
|
|
165
165
|
return new McpOpError("hq_mcp_auth_required", "Hosted hq-mcp rejected this token. Use the DCR plus PKCE person flow or configure the hq-mcp machine client ID on this box.");
|
|
166
166
|
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** Fast scope resolver; it loads manifest and HQ-root code only for slug scopes. */
|
|
2
|
+
import { type ProcessFlagCompanyContext } from "./process-flag-company-context-light.js";
|
|
3
|
+
export declare function resolveFastProcessFlagCompanyContext(env?: NodeJS.ProcessEnv): Promise<ProcessFlagCompanyContext>;
|
|
4
|
+
//# sourceMappingURL=process-flag-company-context-fast.d.ts.map
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** Fast scope resolver; it loads manifest and HQ-root code only for slug scopes. */
|
|
2
|
+
import { normalizedCompanyUid, resolveLightProcessFlagCompanyContext, } from "./process-flag-company-context-light.js";
|
|
3
|
+
export async function resolveFastProcessFlagCompanyContext(env = process.env) {
|
|
4
|
+
const context = resolveLightProcessFlagCompanyContext(env);
|
|
5
|
+
let companyUid = context.companyUid;
|
|
6
|
+
if (!companyUid && context.companySlug) {
|
|
7
|
+
const [{ resolveDefaultHqRoot }, { readCompaniesManifestMap }] = await Promise.all([
|
|
8
|
+
import("../utils/hq-root-resolver.js"),
|
|
9
|
+
import("./work-context/repo-remote.js"),
|
|
10
|
+
]);
|
|
11
|
+
const hqRoot = resolveDefaultHqRoot({ env });
|
|
12
|
+
const manifestUid = readCompaniesManifestMap(hqRoot)?.[context.companySlug]?.cloud_uid;
|
|
13
|
+
companyUid = typeof manifestUid === "string" ? normalizedCompanyUid(manifestUid) : undefined;
|
|
14
|
+
}
|
|
15
|
+
return {
|
|
16
|
+
...(companyUid ? { companyUid } : {}),
|
|
17
|
+
...(context.companySlug ? { companySlug: context.companySlug } : {}),
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
//# sourceMappingURL=process-flag-company-context-fast.js.map
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** Lightweight process company context shared by full and fast flag readers. */
|
|
2
|
+
export interface ProcessFlagCompanyContext {
|
|
3
|
+
companyUid?: string;
|
|
4
|
+
companySlug?: string;
|
|
5
|
+
}
|
|
6
|
+
export declare function normalizedCompanyUid(value: string | undefined): string | undefined;
|
|
7
|
+
/** Resolve explicit, machine, or bound-session company scope without defaults. */
|
|
8
|
+
export declare function resolveLightProcessFlagCompanyContext(env?: NodeJS.ProcessEnv): ProcessFlagCompanyContext;
|
|
9
|
+
//# sourceMappingURL=process-flag-company-context-light.d.ts.map
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/** Lightweight process company context shared by full and fast flag readers. */
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as os from "node:os";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
const COMPANY_UID_RE = /^cmp_[A-Za-z0-9]{3,128}$/;
|
|
6
|
+
const COMPANY_SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
7
|
+
const SESSION_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
8
|
+
const DEFAULT_AGENT_IDENTITY_FILE = "/var/lib/hq-agent/identity.json";
|
|
9
|
+
export function normalizedCompanyUid(value) {
|
|
10
|
+
const candidate = value?.trim() ?? "";
|
|
11
|
+
return COMPANY_UID_RE.test(candidate) ? candidate : undefined;
|
|
12
|
+
}
|
|
13
|
+
function normalizedCompanySlug(value) {
|
|
14
|
+
const candidate = value?.trim() ?? "";
|
|
15
|
+
return COMPANY_SLUG_RE.test(candidate) ? candidate : undefined;
|
|
16
|
+
}
|
|
17
|
+
function record(value) {
|
|
18
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
19
|
+
? value
|
|
20
|
+
: undefined;
|
|
21
|
+
}
|
|
22
|
+
function readAgentIdentity(env) {
|
|
23
|
+
const configured = env.HQ_AGENT_IDENTITY_FILE;
|
|
24
|
+
const file = configured === undefined ? DEFAULT_AGENT_IDENTITY_FILE : configured.trim();
|
|
25
|
+
if (!file)
|
|
26
|
+
return undefined;
|
|
27
|
+
try {
|
|
28
|
+
const identity = record(JSON.parse(fs.readFileSync(file, "utf8")));
|
|
29
|
+
const companyUid = normalizedCompanyUid(typeof identity?.companyUid === "string" ? identity.companyUid : undefined);
|
|
30
|
+
const companySlug = normalizedCompanySlug(typeof identity?.companySlug === "string" ? identity.companySlug : undefined);
|
|
31
|
+
return companyUid || companySlug ? {
|
|
32
|
+
...(companyUid ? { companyUid } : {}),
|
|
33
|
+
...(companySlug ? { companySlug } : {}),
|
|
34
|
+
} : undefined;
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
function readSessionCompany(env) {
|
|
41
|
+
const sessionId = env.HQ_SESSION_ID?.trim();
|
|
42
|
+
if (!sessionId || sessionId.includes("..") || !SESSION_ID_RE.test(sessionId))
|
|
43
|
+
return undefined;
|
|
44
|
+
const contextRoot = env.HQ_WORK_CONTEXT_ROOT?.trim() || path.join(os.homedir(), ".hq", "work-context");
|
|
45
|
+
try {
|
|
46
|
+
const sessionPath = path.join(contextRoot, "sessions", sessionId + ".json");
|
|
47
|
+
const session = record(JSON.parse(fs.readFileSync(sessionPath, "utf8")));
|
|
48
|
+
if (session?.sessionId !== sessionId ||
|
|
49
|
+
session.contextStatus !== "bound" ||
|
|
50
|
+
session.companyConfidence === "device_default")
|
|
51
|
+
return undefined;
|
|
52
|
+
const companyUid = normalizedCompanyUid(typeof session.companyUid === "string" ? session.companyUid : undefined);
|
|
53
|
+
const companySlug = normalizedCompanySlug(typeof session.companySlug === "string" ? session.companySlug : undefined);
|
|
54
|
+
return companyUid || companySlug ? {
|
|
55
|
+
...(companyUid ? { companyUid } : {}),
|
|
56
|
+
...(companySlug ? { companySlug } : {}),
|
|
57
|
+
} : undefined;
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/** Resolve explicit, machine, or bound-session company scope without defaults. */
|
|
64
|
+
export function resolveLightProcessFlagCompanyContext(env = process.env) {
|
|
65
|
+
const explicitUid = normalizedCompanyUid(env.HQ_COMPANY_UID);
|
|
66
|
+
const explicitSlug = normalizedCompanySlug(env.HQ_COMPANY_SLUG);
|
|
67
|
+
if (explicitUid || explicitSlug) {
|
|
68
|
+
return { companyUid: explicitUid, companySlug: explicitSlug };
|
|
69
|
+
}
|
|
70
|
+
const spawnValue = env.HQ_SPAWN_COMPANY?.trim();
|
|
71
|
+
if (spawnValue && COMPANY_UID_RE.test(spawnValue))
|
|
72
|
+
return { companyUid: spawnValue };
|
|
73
|
+
const spawnSlug = normalizedCompanySlug(spawnValue);
|
|
74
|
+
if (spawnSlug)
|
|
75
|
+
return { companySlug: spawnSlug };
|
|
76
|
+
const identity = readAgentIdentity(env);
|
|
77
|
+
if (identity)
|
|
78
|
+
return identity;
|
|
79
|
+
return readSessionCompany(env) ?? {};
|
|
80
|
+
}
|
|
81
|
+
//# sourceMappingURL=process-flag-company-context-light.js.map
|
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
/** Company scope shared by the full flag registry and its fast-path reader. */
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
companySlug?: string;
|
|
5
|
-
}
|
|
2
|
+
import { type ProcessFlagCompanyContext } from "./process-flag-company-context-light.js";
|
|
3
|
+
export type { ProcessFlagCompanyContext } from "./process-flag-company-context-light.js";
|
|
6
4
|
/** Resolve the same company identity the process-wide flag reader uses. */
|
|
7
5
|
export declare function resolveProcessFlagCompanyContext(env?: NodeJS.ProcessEnv): ProcessFlagCompanyContext;
|
|
8
6
|
//# sourceMappingURL=process-flag-company-context.d.ts.map
|
|
@@ -1,51 +1,7 @@
|
|
|
1
1
|
/** Company scope shared by the full flag registry and its fast-path reader. */
|
|
2
|
-
import { readMachineCompanyBinding } from "./work-context/company.js";
|
|
3
|
-
import { readSessionState } from "./work-context/state.js";
|
|
4
|
-
import { workContextRoot } from "./work-context/paths.js";
|
|
5
2
|
import { readCompaniesManifestMap } from "./work-context/repo-remote.js";
|
|
6
3
|
import { resolveDefaultHqRoot } from "../utils/hq-root-resolver.js";
|
|
7
|
-
|
|
8
|
-
const COMPANY_SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
9
|
-
function normalizedCompanyUid(value) {
|
|
10
|
-
const candidate = value?.trim() ?? "";
|
|
11
|
-
return COMPANY_UID_RE.test(candidate) ? candidate : undefined;
|
|
12
|
-
}
|
|
13
|
-
function normalizedCompanySlug(value) {
|
|
14
|
-
const candidate = value?.trim() ?? "";
|
|
15
|
-
return COMPANY_SLUG_RE.test(candidate) ? candidate : undefined;
|
|
16
|
-
}
|
|
17
|
-
/** Resolve explicit, machine, or bound-session company scope without defaults. */
|
|
18
|
-
function processCompanyContext(env = process.env) {
|
|
19
|
-
const explicitUid = normalizedCompanyUid(env.HQ_COMPANY_UID);
|
|
20
|
-
const explicitSlug = normalizedCompanySlug(env.HQ_COMPANY_SLUG);
|
|
21
|
-
if (explicitUid || explicitSlug) {
|
|
22
|
-
return { companyUid: explicitUid, companySlug: explicitSlug };
|
|
23
|
-
}
|
|
24
|
-
const machine = readMachineCompanyBinding(env);
|
|
25
|
-
const spawnValue = env.HQ_SPAWN_COMPANY?.trim();
|
|
26
|
-
const spawnSlug = normalizedCompanySlug(spawnValue);
|
|
27
|
-
const machineContext = spawnValue && COMPANY_UID_RE.test(spawnValue)
|
|
28
|
-
? { companyUid: spawnValue }
|
|
29
|
-
: spawnSlug
|
|
30
|
-
? { companySlug: spawnSlug }
|
|
31
|
-
: {
|
|
32
|
-
companyUid: normalizedCompanyUid(machine?.uid),
|
|
33
|
-
companySlug: normalizedCompanySlug(machine?.slug),
|
|
34
|
-
};
|
|
35
|
-
if (machineContext.companyUid || machineContext.companySlug)
|
|
36
|
-
return machineContext;
|
|
37
|
-
const sessionId = env.HQ_SESSION_ID?.trim();
|
|
38
|
-
if (!sessionId)
|
|
39
|
-
return {};
|
|
40
|
-
const session = readSessionState(sessionId, workContextRoot(undefined, env));
|
|
41
|
-
if (session?.contextStatus !== "bound" || session.companyConfidence === "device_default") {
|
|
42
|
-
return {};
|
|
43
|
-
}
|
|
44
|
-
return {
|
|
45
|
-
companyUid: normalizedCompanyUid(session.companyUid),
|
|
46
|
-
companySlug: normalizedCompanySlug(session.companySlug),
|
|
47
|
-
};
|
|
48
|
-
}
|
|
4
|
+
import { normalizedCompanyUid, resolveLightProcessFlagCompanyContext, } from "./process-flag-company-context-light.js";
|
|
49
5
|
function companyUidForContext(context, env = process.env) {
|
|
50
6
|
if (context.companyUid)
|
|
51
7
|
return context.companyUid;
|
|
@@ -57,7 +13,7 @@ function companyUidForContext(context, env = process.env) {
|
|
|
57
13
|
}
|
|
58
14
|
/** Resolve the same company identity the process-wide flag reader uses. */
|
|
59
15
|
export function resolveProcessFlagCompanyContext(env = process.env) {
|
|
60
|
-
const context =
|
|
16
|
+
const context = resolveLightProcessFlagCompanyContext(env);
|
|
61
17
|
const companyUid = companyUidForContext(context, env);
|
|
62
18
|
return {
|
|
63
19
|
...(companyUid ? { companyUid } : {}),
|
|
@@ -11,6 +11,10 @@ export function shouldConfigureNodeNetworkCompatibility(argv) {
|
|
|
11
11
|
const fastCore = parseFastCore(argv);
|
|
12
12
|
if (fastCore)
|
|
13
13
|
return fastCore.entry.requiresNodeNetworkCompatibility === true;
|
|
14
|
+
// The MCP shim only forwards over the local hqd socket; it does not make
|
|
15
|
+
// outbound network requests and does not need the network-family patch.
|
|
16
|
+
if (argv.length === 4 && argv[2] === "mcp" && argv[3] === "serve")
|
|
17
|
+
return false;
|
|
14
18
|
// The lightweight secrets route still makes vault API requests.
|
|
15
19
|
if (parseSecretsEnvFastRequest(argv))
|
|
16
20
|
return true;
|