@indigoai-us/hq-cli 5.327.0 → 5.329.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 +13 -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 +3699 -3
- package/dist/command-catalog.generated.js +5462 -1759
- package/dist/command-help.d.ts +2 -0
- package/dist/command-help.js +13 -0
- package/dist/commands/install-global.d.ts +21 -0
- package/dist/commands/install-global.js +267 -32
- package/dist/commands/mcp-serve.js +1 -1
- package/dist/commands/mcp-status.js +58 -1
- package/dist/commands/pack-install.d.ts +3 -1
- package/dist/commands/pack-install.js +17 -4
- package/dist/commands/packs.js +17 -0
- package/dist/commands/pkg-install.js +13 -1
- package/dist/fast-scaffold.d.ts +2 -0
- package/dist/fast-scaffold.js +16 -0
- package/dist/index.js +19 -2
- package/dist/lazy-commands.d.ts +1 -0
- package/dist/lazy-commands.js +64 -4
- package/dist/lib/cli-flag-keys.d.ts +3 -0
- package/dist/lib/cli-flag-keys.js +3 -0
- package/dist/lib/daemon/ops/mcp.js +11 -10
- package/dist/lib/daemon/ops/policy.d.ts +3 -2
- package/dist/lib/daemon/ops/policy.js +12 -6
- package/dist/lib/daemon/server.d.ts +3 -1
- package/dist/lib/daemon/server.js +63 -7
- package/dist/lib/doctor/checks/anywhere.js +58 -4
- package/dist/lib/install/chatgpt-targets.d.ts +8 -0
- package/dist/lib/install/chatgpt-targets.js +15 -0
- package/dist/lib/install/codex-pack-contributions.d.ts +8 -0
- package/dist/lib/install/codex-pack-contributions.js +205 -0
- package/dist/lib/install/codex-targets.d.ts +1 -1
- package/dist/lib/install/codex-targets.js +2 -1
- package/dist/lib/install/grok-targets.d.ts +8 -0
- package/dist/lib/install/grok-targets.js +15 -0
- package/dist/lib/lanes/keep-alive-audit.d.ts +3 -2
- package/dist/lib/lanes/keep-alive-audit.js +36 -8
- package/dist/main.d.ts +2 -6
- package/dist/main.js +3 -41
- package/dist/node-network-compat-fast.js +4 -0
- package/dist/stream-error-boundary.d.ts +7 -0
- package/dist/stream-error-boundary.js +33 -0
- package/dist/utils/vault-api.js +16 -1
- package/package.json +1 -1
|
@@ -55,6 +55,7 @@ import { formatApiKeyCapabilityDenial, peekHqApiKey, } from '../utils/resolve-va
|
|
|
55
55
|
import { redactSecrets, SECRET_REDACTION, isMcpRegistrationEnabled, registerMcpServers, McpManifestError, } from './mcp-registration.js';
|
|
56
56
|
import { listSecretCacheScopes } from '../utils/secrets-cache.js';
|
|
57
57
|
import { loadRevealedSecrets } from './secrets.js';
|
|
58
|
+
import { syncCodexPackHooks, syncCodexPackSkills } from '../lib/install/codex-pack-contributions.js';
|
|
58
59
|
const PACK_UPDATE_CACHE_TTL_MS = 12 * 60 * 60 * 1000;
|
|
59
60
|
const PACK_UPDATE_FETCH_TIMEOUT_MS = 3_000;
|
|
60
61
|
const gitLsRemoteMemo = new Map();
|
|
@@ -969,7 +970,7 @@ export function validateMcpManifest(payloadDir, item) {
|
|
|
969
970
|
// ---------------------------------------------------------------------------
|
|
970
971
|
// Manifest validation (spec §Validation, 10 checks)
|
|
971
972
|
// ---------------------------------------------------------------------------
|
|
972
|
-
export function validateManifest(payloadDir, hqVersion) {
|
|
973
|
+
export function validateManifest(payloadDir, hqVersion, options = {}) {
|
|
973
974
|
// 1. parse
|
|
974
975
|
const manifestPath = path.join(payloadDir, 'package.yaml');
|
|
975
976
|
if (!fs.existsSync(manifestPath)) {
|
|
@@ -990,8 +991,9 @@ export function validateManifest(payloadDir, hqVersion) {
|
|
|
990
991
|
throw new Error('package.yaml must be a YAML mapping');
|
|
991
992
|
}
|
|
992
993
|
// 2. name
|
|
993
|
-
|
|
994
|
-
|
|
994
|
+
const reservedNameAllowed = m.name === 'hq-anywhere' && options.allowReservedName === true;
|
|
995
|
+
if (!m.name || (!reservedNameAllowed && !/^hq-pack-[a-z0-9][a-z0-9-]*$/.test(m.name))) {
|
|
996
|
+
throw new Error(`Invalid pack name "${m.name}" — must match ^hq-pack-[a-z0-9][a-z0-9-]*$ or the reserved hq-anywhere pack name`);
|
|
995
997
|
}
|
|
996
998
|
// 3. version
|
|
997
999
|
if (!m.version || !semverValid(m.version)) {
|
|
@@ -1702,7 +1704,10 @@ export async function installPack(source, opts = {}) {
|
|
|
1702
1704
|
});
|
|
1703
1705
|
break;
|
|
1704
1706
|
}
|
|
1705
|
-
|
|
1707
|
+
// Only an explicit local directory source may use the reserved bundled
|
|
1708
|
+
// pack name. Publisher is self-declared package metadata and is not proof
|
|
1709
|
+
// of registry identity, so remote transports never receive this exception.
|
|
1710
|
+
const pkg = validateManifest(fetched.payloadDir, hqVersion, { allowReservedName: transport === 'local' });
|
|
1706
1711
|
// M0 — pack-to-pack dependency pre-flight. Runs BEFORE any prompts or writes
|
|
1707
1712
|
// so a missing/unsatisfied `requires.packs` aborts with no partial state, and
|
|
1708
1713
|
// before we bother the operator with hook/MCP trust prompts for a pack that
|
|
@@ -1753,6 +1758,14 @@ export async function installPack(source, opts = {}) {
|
|
|
1753
1758
|
const stampedSource = transport === 'marketplace' ? fetched.resolvedSource : source;
|
|
1754
1759
|
stampInstallSource(destDir, stampedSource);
|
|
1755
1760
|
runScanPackages(hqRoot, { quiet: opts.quiet });
|
|
1761
|
+
// US-014: Codex user-scope contributions are opt-in and owned by the pack.
|
|
1762
|
+
// Existing Claude/HQ scan-packages behavior remains unchanged when the flag is off.
|
|
1763
|
+
const codexHome = process.env.HOME ?? os.homedir();
|
|
1764
|
+
const packHooks = pkg.contributes.hooks ?? [];
|
|
1765
|
+
const skillWarnings = syncCodexPackSkills(destDir, pkg.name, pkg.contributes.skills ?? [], false, codexHome);
|
|
1766
|
+
syncCodexPackHooks(destDir, packHooks, false, codexHome);
|
|
1767
|
+
for (const warning of skillWarnings)
|
|
1768
|
+
say(chalk.yellow(` ! ${warning}`));
|
|
1756
1769
|
// US-007 — register `contributes.mcp` servers into the shared Claude/Codex
|
|
1757
1770
|
// agent configs. Fires HERE (after installToPackages + runScanPackages, i.e.
|
|
1758
1771
|
// pack on disk + symlinks wired) and only once the confirmMcp gate above
|
package/dist/commands/packs.js
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
* Spec: knowledge/public/hq-core/package-yaml-spec.md.
|
|
18
18
|
*/
|
|
19
19
|
import * as fs from 'fs';
|
|
20
|
+
import * as os from 'os';
|
|
20
21
|
import * as path from 'path';
|
|
21
22
|
import * as readline from 'readline';
|
|
22
23
|
import { spawnSync } from 'child_process';
|
|
@@ -26,6 +27,7 @@ import { findHqRoot } from '../utils/manifest.js';
|
|
|
26
27
|
import { resolveHqRoot as resolveDetectedHqRoot } from './doctor.js';
|
|
27
28
|
import { classify, resolveLatest, resolveLatestMarketplace, runScanPackages, installPack, } from './pack-install.js';
|
|
28
29
|
import { contributionLinks, linkStatus, listInstalledPacks, findDependentPacks, readPackManifest, unwirePack, unwirePackMcp, readHqVersion, readRecommendedPackages, packagesDir, } from '../utils/pack-contributions.js';
|
|
30
|
+
import { syncCodexPackHooks, syncCodexPackSkills } from '../lib/install/codex-pack-contributions.js';
|
|
29
31
|
function resolveRoot(opts) {
|
|
30
32
|
return opts.hqRoot ? path.resolve(opts.hqRoot) : findHqRoot();
|
|
31
33
|
}
|
|
@@ -247,6 +249,11 @@ async function runUpdate(name, opts) {
|
|
|
247
249
|
continue;
|
|
248
250
|
}
|
|
249
251
|
try {
|
|
252
|
+
// Remove Codex registrations and skills from the old manifest before the
|
|
253
|
+
// pack directory is replaced. The replacement may rename its commands or
|
|
254
|
+
// drop contributions entirely, so the new manifest cannot clean those.
|
|
255
|
+
syncCodexPackHooks(pack.dir, m.contributes?.hooks ?? [], true);
|
|
256
|
+
syncCodexPackSkills(pack.dir, pname, m.contributes?.skills ?? [], true);
|
|
250
257
|
// Un-wire old contributions first so a contribution dropped by the new
|
|
251
258
|
// version doesn't leave a dangling host symlink. installPack re-wires.
|
|
252
259
|
unwirePack(hqRoot, pack.dir, m.contributes ?? {});
|
|
@@ -303,6 +310,16 @@ async function runUninstall(name, opts) {
|
|
|
303
310
|
}
|
|
304
311
|
// 1. Un-wire only our symlinks.
|
|
305
312
|
const { unlinked, skipped } = unwirePack(hqRoot, packDir, contributes);
|
|
313
|
+
// Remove Codex user-scope contributions only when their installed ownership
|
|
314
|
+
// marker/absolute command still points at this pack.
|
|
315
|
+
try {
|
|
316
|
+
const home = process.env.HOME ?? os.homedir();
|
|
317
|
+
syncCodexPackHooks(packDir, contributes.hooks ?? [], true, home);
|
|
318
|
+
warnings.push(...syncCodexPackSkills(packDir, name, contributes.skills ?? [], true, home));
|
|
319
|
+
}
|
|
320
|
+
catch (e) {
|
|
321
|
+
warnings.push(`Codex contribution removal encountered an error: ${e.message}`);
|
|
322
|
+
}
|
|
306
323
|
// 1b. Un-register the pack's MCP (`wire: 'merge'`) servers — invisible to the
|
|
307
324
|
// symlink unwire above. Provenance-scoped: removes ONLY entries stamped with this
|
|
308
325
|
// pack's `_hqPack`, and skip-and-warns on any foreign/unstamped same-named entry.
|
|
@@ -44,7 +44,9 @@ export function registerPackageInstallCommand(parent) {
|
|
|
44
44
|
.option('--allow-mcp', 'Skip the MCP server confirmation prompt (content-pack flow)')
|
|
45
45
|
.option('--branch', 'Follow a ref instead of SHA-pinning (git content-pack flow)')
|
|
46
46
|
.option('--global', 'Install HQ into an agent runtime at user scope (requires the hq-anywhere-runtime flag)')
|
|
47
|
-
.option('--runtime <runtime>', 'Runtime for --global (claude | codex)', 'claude')
|
|
47
|
+
.option('--runtime <runtime>', 'Runtime for --global (claude | codex | chatgpt | grok)', 'claude')
|
|
48
|
+
.option('--via <mode>', 'With --global Claude: install directly or via plugin (direct | plugin)', 'direct')
|
|
49
|
+
.option('--marketplace-dir <dir>', 'With --via plugin: local Claude Code marketplace directory (or HQ_CLAUDE_PLUGIN_MARKETPLACE)')
|
|
48
50
|
.option('--dry-run', 'With --global: print every file and config diff without writing')
|
|
49
51
|
.option('--hq-root <dir>', 'With --global: HQ checkout to install from (default: walk up from cwd)')
|
|
50
52
|
.action(async (source, opts) => {
|
|
@@ -55,6 +57,16 @@ export function registerPackageInstallCommand(parent) {
|
|
|
55
57
|
return;
|
|
56
58
|
}
|
|
57
59
|
const { runInstallGlobal } = await import('./install-global.js');
|
|
60
|
+
if (opts.via !== 'direct' && opts.via !== 'plugin') {
|
|
61
|
+
console.error("error: --via must be 'direct' or 'plugin'");
|
|
62
|
+
process.exitCode = 1;
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
if (opts.via === 'plugin' && (opts.runtime ?? 'claude') !== 'claude') {
|
|
66
|
+
console.error('error: --via plugin is currently supported only with --runtime claude');
|
|
67
|
+
process.exitCode = 1;
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
58
70
|
runInstallGlobal(opts);
|
|
59
71
|
return;
|
|
60
72
|
}
|
package/dist/fast-scaffold.d.ts
CHANGED
|
@@ -85,4 +85,6 @@ export declare function parseSecretsEnvFastRequest(argv: readonly string[]): Par
|
|
|
85
85
|
export declare function parseFastCore(argv: readonly string[]): ParsedFastCore | null;
|
|
86
86
|
/** Whether this invocation is a fast-path scaffold dispatch. */
|
|
87
87
|
export declare function isFastCoreRequest(argv: readonly string[]): boolean;
|
|
88
|
+
/** Metadata-only help can render without importing the selected command graph. */
|
|
89
|
+
export declare function isCommandHelpRequest(argv: readonly string[]): boolean;
|
|
88
90
|
//# sourceMappingURL=fast-scaffold.d.ts.map
|
package/dist/fast-scaffold.js
CHANGED
|
@@ -421,4 +421,20 @@ export function parseFastCore(argv) {
|
|
|
421
421
|
export function isFastCoreRequest(argv) {
|
|
422
422
|
return parseFastCore(argv) !== null;
|
|
423
423
|
}
|
|
424
|
+
/** Metadata-only help can render without importing the selected command graph. */
|
|
425
|
+
export function isCommandHelpRequest(argv) {
|
|
426
|
+
const args = argv.slice(2);
|
|
427
|
+
const delimiter = args.indexOf("--");
|
|
428
|
+
const beforeDelimiter = delimiter === -1 ? args : args.slice(0, delimiter);
|
|
429
|
+
const commandWords = beforeDelimiter.filter((token) => !token.startsWith("-"));
|
|
430
|
+
const explicitHelp = beforeDelimiter.some((token) => token === "--help" || token === "-h");
|
|
431
|
+
if (!explicitHelp && commandWords[0] !== "help")
|
|
432
|
+
return false;
|
|
433
|
+
if (commandWords[0] === "help")
|
|
434
|
+
return commandWords.length <= 2;
|
|
435
|
+
// Native core commands have their own pass-through help and stay on their established path.
|
|
436
|
+
if (commandWords[0] === "core" || commandWords[0] === "run")
|
|
437
|
+
return false;
|
|
438
|
+
return commandWords.length <= 1;
|
|
439
|
+
}
|
|
424
440
|
//# sourceMappingURL=fast-scaffold.js.map
|
package/dist/index.js
CHANGED
|
@@ -6,10 +6,12 @@ import "./node-network-compat-fast.js";
|
|
|
6
6
|
import { isVersionRequest, wantsLabelledVersionReport } from "./version-request.js";
|
|
7
7
|
// Dependency-light: a pure parser + table, no command graph. Safe to load on
|
|
8
8
|
// every path (including --version) without reintroducing the heavy startup.
|
|
9
|
-
import { isFastCoreRequest, parseFastCore, parseSecretsEnvFastRequest, } from "./fast-scaffold.js";
|
|
9
|
+
import { isCommandHelpRequest, isFastCoreRequest, parseFastCore, parseSecretsEnvFastRequest, } from "./fast-scaffold.js";
|
|
10
|
+
import { CLI_COMMAND_HELP_CATALOG_FLAG } from "./lib/cli-flag-keys.js";
|
|
10
11
|
// Also dependency-light: registers a process-level rejection boundary; its only
|
|
11
12
|
// heavy import (main.js) is lazy and reached only when a rejection fires.
|
|
12
13
|
import { installProcessRejectionBoundary } from "./unhandled-rejection-boundary.js";
|
|
14
|
+
import { installFastPathStreamErrorHandlers } from "./utils/fast-stream-errors.js";
|
|
13
15
|
// Install BEFORE any dispatch so a floated rejection on ANY path (the command
|
|
14
16
|
// graph or the fast-core forwarder) becomes a non-zero exit. @sentry/node's
|
|
15
17
|
// onUnhandledRejection integration defaults to 'warn' — it captures but does
|
|
@@ -17,9 +19,11 @@ import { installProcessRejectionBoundary } from "./unhandled-rejection-boundary.
|
|
|
17
19
|
// command never run (HQ-CLI-W). Sets process.exitCode only, never process.exit,
|
|
18
20
|
// so runCli's finally still finalizes release health and flushes Sentry.
|
|
19
21
|
installProcessRejectionBoundary();
|
|
22
|
+
installFastPathStreamErrorHandlers();
|
|
20
23
|
function dispatchCliEntry() {
|
|
21
24
|
const versionRequest = isVersionRequest(process.argv);
|
|
22
|
-
const
|
|
25
|
+
const helpRequest = !versionRequest && isCommandHelpRequest(process.argv);
|
|
26
|
+
const parsedFastCore = versionRequest || helpRequest ? null : parseFastCore(process.argv);
|
|
23
27
|
if (versionRequest) {
|
|
24
28
|
// Bare `--version` is the CLI semver only (the contract field updaters
|
|
25
29
|
// compare). `hq version` and `--json` print the labelled HQ/CLI report.
|
|
@@ -38,6 +42,19 @@ function dispatchCliEntry() {
|
|
|
38
42
|
});
|
|
39
43
|
}
|
|
40
44
|
}
|
|
45
|
+
else if (helpRequest) {
|
|
46
|
+
// The generated-catalog renderer is a default-off startup optimization.
|
|
47
|
+
// When disabled or unavailable, preserve the established main.ts behavior.
|
|
48
|
+
void import("./native-fast-route.js").then(async ({ fastFlagEnabled }) => {
|
|
49
|
+
if (await fastFlagEnabled(CLI_COMMAND_HELP_CATALOG_FLAG)) {
|
|
50
|
+
const { runCommandHelp } = await import("./command-help.js");
|
|
51
|
+
await runCommandHelp(process.argv);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
const { runCli } = await import("./main.js");
|
|
55
|
+
await runCli();
|
|
56
|
+
});
|
|
57
|
+
}
|
|
41
58
|
else if (parsedFastCore?.entry.kind === "native" &&
|
|
42
59
|
(process.env.HQ_FLAG_HQ_CORE_NATIVE_FAST_ROUTE === "true" ||
|
|
43
60
|
process.env.HQ_FLAG_HQ_CORE_NATIVE_FAST_ROUTE === "1")) {
|
package/dist/lazy-commands.d.ts
CHANGED
|
@@ -17,6 +17,7 @@ export declare const LAZY_COMMANDS: readonly LazyCommand[];
|
|
|
17
17
|
* replaces the installed tree between startup and command registration.
|
|
18
18
|
*/
|
|
19
19
|
export declare function registerCommandRoot(program: Command, root: string): Promise<boolean>;
|
|
20
|
+
export declare function findRuntimeHqRoot(start: string, parentOf?: (directory: string) => string, isRoot?: (directory: string) => boolean): string;
|
|
20
21
|
/**
|
|
21
22
|
* Register only generated root metadata. This is enough for root help and
|
|
22
23
|
* Commander suggestions, without evaluating an implementation module.
|
package/dist/lazy-commands.js
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
import { Command } from "commander";
|
|
1
|
+
import { Argument, Command, Option } from "commander";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { dirname, join, resolve } from "node:path";
|
|
2
5
|
import { COMMAND_CATALOG } from "./command-catalog.generated.js";
|
|
3
6
|
const commandCatalog = COMMAND_CATALOG;
|
|
4
7
|
/**
|
|
@@ -26,18 +29,75 @@ export async function registerCommandRoot(program, root) {
|
|
|
26
29
|
function commandDefinition(command) {
|
|
27
30
|
const stub = new Command(command.name)
|
|
28
31
|
.description(command.description);
|
|
32
|
+
if (command.helpOption === false)
|
|
33
|
+
stub.helpOption(false);
|
|
29
34
|
for (const alias of command.aliases)
|
|
30
35
|
stub.alias(alias);
|
|
31
36
|
for (const argument of command.arguments) {
|
|
32
37
|
const name = `${argument.name}${argument.variadic ? "..." : ""}`;
|
|
33
|
-
|
|
38
|
+
const definition = new Argument(argument.required ? `<${name}>` : `[${name}]`, argument.description || undefined);
|
|
39
|
+
if ("defaultValue" in argument)
|
|
40
|
+
definition.default(restoreRuntimePath(argument.defaultValue));
|
|
41
|
+
stub.addArgument(definition);
|
|
42
|
+
}
|
|
43
|
+
for (const option of command.options) {
|
|
44
|
+
const description = restoreRuntimePath(option.description);
|
|
45
|
+
const definition = new Option(option.flags, description);
|
|
46
|
+
if (option.hidden)
|
|
47
|
+
definition.hideHelp();
|
|
48
|
+
if (option.choices)
|
|
49
|
+
definition.choices([...option.choices]);
|
|
50
|
+
if ("defaultValue" in option) {
|
|
51
|
+
definition.default(restoreRuntimePath(option.defaultValue));
|
|
52
|
+
}
|
|
53
|
+
stub.addOption(definition);
|
|
34
54
|
}
|
|
35
|
-
for (const option of command.options)
|
|
36
|
-
stub.option(option.flags, option.description);
|
|
37
55
|
if (command.hidden)
|
|
38
56
|
stub._hidden = true;
|
|
57
|
+
for (const child of command.subcommands)
|
|
58
|
+
stub.addCommand(commandDefinition(child));
|
|
59
|
+
if (command.helpCommand !== undefined)
|
|
60
|
+
stub.addHelpCommand(command.helpCommand);
|
|
61
|
+
stub.usage(command.usage);
|
|
62
|
+
for (const helpText of command.helpText ?? [])
|
|
63
|
+
stub.addHelpText(helpText.position, helpText.text);
|
|
39
64
|
return stub;
|
|
40
65
|
}
|
|
66
|
+
function restoreRuntimePath(value) {
|
|
67
|
+
if (typeof value === "string") {
|
|
68
|
+
let restored = value.replaceAll("<runtime-home>", homedir());
|
|
69
|
+
if (restored.includes("<runtime-path>"))
|
|
70
|
+
restored = restored.replaceAll("<runtime-path>", runtimeHqRoot());
|
|
71
|
+
return restored;
|
|
72
|
+
}
|
|
73
|
+
if (Array.isArray(value))
|
|
74
|
+
return value.map(restoreRuntimePath);
|
|
75
|
+
if (value && typeof value === "object") {
|
|
76
|
+
return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, restoreRuntimePath(entry)]));
|
|
77
|
+
}
|
|
78
|
+
return value;
|
|
79
|
+
}
|
|
80
|
+
function runtimeHqRoot() {
|
|
81
|
+
const args = process.argv.slice(2);
|
|
82
|
+
const rootIndex = args.findIndex((arg) => arg === "--hq-root");
|
|
83
|
+
const explicit = rootIndex >= 0 ? args[rootIndex + 1] : args.find((arg) => arg.startsWith("--hq-root="))?.slice("--hq-root=".length);
|
|
84
|
+
const configured = explicit || process.env.HQ_ROOT || process.env.CLAUDE_PROJECT_DIR;
|
|
85
|
+
if (configured)
|
|
86
|
+
return resolve(configured);
|
|
87
|
+
return findRuntimeHqRoot(resolve(process.cwd()));
|
|
88
|
+
}
|
|
89
|
+
export function findRuntimeHqRoot(start, parentOf = dirname, isRoot = (directory) => existsSync(join(directory, "companies")) &&
|
|
90
|
+
(existsSync(join(directory, ".claude")) || existsSync(join(directory, "core")) || existsSync(join(directory, "personal")))) {
|
|
91
|
+
let directory = start;
|
|
92
|
+
for (;;) {
|
|
93
|
+
if (isRoot(directory))
|
|
94
|
+
return directory;
|
|
95
|
+
const parent = parentOf(directory);
|
|
96
|
+
if (parent === directory)
|
|
97
|
+
return start;
|
|
98
|
+
directory = parent;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
41
101
|
/**
|
|
42
102
|
* Register only generated root metadata. This is enough for root help and
|
|
43
103
|
* Commander suggestions, without evaluating an implementation module.
|
|
@@ -74,7 +74,7 @@ export class HqMcpBridge {
|
|
|
74
74
|
});
|
|
75
75
|
}
|
|
76
76
|
async callTool(name, args, timeoutMs = this.timeoutMs) {
|
|
77
|
-
return this.withClient(async (client
|
|
77
|
+
return this.withClient(async (client) => {
|
|
78
78
|
const { tools } = await client.listTools();
|
|
79
79
|
if (!tools.some((tool) => tool.name === name)) {
|
|
80
80
|
throw new McpOpError("hq_mcp_tool_unavailable", `Hosted hq-mcp does not expose ${name}.`);
|
|
@@ -93,10 +93,16 @@ export class HqMcpBridge {
|
|
|
93
93
|
let token = "";
|
|
94
94
|
let transport;
|
|
95
95
|
let client;
|
|
96
|
-
let timeout;
|
|
97
96
|
let timedOut = false;
|
|
98
97
|
const abortController = new AbortController();
|
|
99
98
|
let httpErrorClass;
|
|
99
|
+
const timeout = setTimeout(() => {
|
|
100
|
+
timedOut = true;
|
|
101
|
+
abortController.abort();
|
|
102
|
+
void client?.close().catch(() => {
|
|
103
|
+
// The deadline already ended the request.
|
|
104
|
+
});
|
|
105
|
+
}, timeoutMs);
|
|
100
106
|
try {
|
|
101
107
|
const [clientModule, httpModule] = await Promise.all([
|
|
102
108
|
import("@modelcontextprotocol/sdk/client/index.js"),
|
|
@@ -104,6 +110,8 @@ export class HqMcpBridge {
|
|
|
104
110
|
]);
|
|
105
111
|
httpErrorClass = httpModule.StreamableHTTPError;
|
|
106
112
|
token = await this.getToken();
|
|
113
|
+
if (timedOut)
|
|
114
|
+
throw new McpOpError("hq_mcp_timeout", `Hosted hq-mcp request did not finish within ${timeoutMs}ms.`);
|
|
107
115
|
if (!token.trim())
|
|
108
116
|
throw new McpOpError("hq_mcp_auth_required", "An hq-mcp audience token is required.");
|
|
109
117
|
transport = new httpModule.StreamableHTTPClientTransport(this.endpoint, {
|
|
@@ -117,15 +125,8 @@ export class HqMcpBridge {
|
|
|
117
125
|
});
|
|
118
126
|
client = new clientModule.Client({ name: "hqd", version: "1" }, { capabilities: {} });
|
|
119
127
|
this.activeClients.add(client);
|
|
120
|
-
timeout = setTimeout(() => {
|
|
121
|
-
timedOut = true;
|
|
122
|
-
abortController.abort();
|
|
123
|
-
void client?.close().catch(() => {
|
|
124
|
-
// The deadline already ended the request.
|
|
125
|
-
});
|
|
126
|
-
}, timeoutMs);
|
|
127
128
|
await client.connect(transport);
|
|
128
|
-
return await work(client
|
|
129
|
+
return await work(client);
|
|
129
130
|
}
|
|
130
131
|
catch (error) {
|
|
131
132
|
if (timedOut) {
|
|
@@ -43,7 +43,8 @@ export declare class PolicyIndex {
|
|
|
43
43
|
private rewatch;
|
|
44
44
|
}
|
|
45
45
|
export interface PolicyCheckArgs {
|
|
46
|
-
|
|
46
|
+
/** Absent means personal/core scope only. */
|
|
47
|
+
company?: string;
|
|
47
48
|
event?: string;
|
|
48
49
|
tool: string;
|
|
49
50
|
input?: Record<string, unknown>;
|
|
@@ -66,7 +67,7 @@ export interface PolicyCheckResult {
|
|
|
66
67
|
* session id of its own per company + caller, so a check never borrows the
|
|
67
68
|
* binding of an unrelated session.
|
|
68
69
|
*/
|
|
69
|
-
export declare function bindCheckSession(hqRoot: string, company: string, callerSession: string | undefined, now: Date): string;
|
|
70
|
+
export declare function bindCheckSession(hqRoot: string, company: string | undefined, callerSession: string | undefined, now: Date): string;
|
|
70
71
|
export interface PolicyCheckDeps {
|
|
71
72
|
hqRoot: string;
|
|
72
73
|
index: PolicyIndex;
|
|
@@ -162,12 +162,13 @@ function runScript(script, payload, env, cwd, timeoutMs) {
|
|
|
162
162
|
*/
|
|
163
163
|
export function bindCheckSession(hqRoot, company, callerSession, now) {
|
|
164
164
|
const caller = (callerSession ?? "default").replace(/[^A-Za-z0-9._-]/g, "_").slice(0, 80) || "default";
|
|
165
|
-
const
|
|
165
|
+
const scope = company ?? "personal";
|
|
166
|
+
const sid = `hqd-${scope}-${caller}`;
|
|
166
167
|
if (!SESSION_RE.test(sid))
|
|
167
168
|
throw new PolicyOpError("bad_args", "sessionId has no usable characters");
|
|
168
169
|
const dir = path.join(hqRoot, "workspace", "sessions", sid);
|
|
169
170
|
const meta = path.join(dir, "meta.yaml");
|
|
170
|
-
const body = `session_id: ${sid}\nstarted_at: ${now.toISOString()}\nsenior: user\
|
|
171
|
+
const body = `session_id: ${sid}\nstarted_at: ${now.toISOString()}\nsenior: user\n${company ? `company_slug: ${company}\n` : ""}source: hqd\n`;
|
|
171
172
|
let current = "";
|
|
172
173
|
try {
|
|
173
174
|
current = fs.readFileSync(meta, "utf8");
|
|
@@ -176,7 +177,9 @@ export function bindCheckSession(hqRoot, company, callerSession, now) {
|
|
|
176
177
|
if (err.code !== "ENOENT")
|
|
177
178
|
throw err;
|
|
178
179
|
}
|
|
179
|
-
if (
|
|
180
|
+
if (company
|
|
181
|
+
? !current.includes(`\ncompany_slug: ${company}\n`)
|
|
182
|
+
: !current.includes(`session_id: ${sid}\n`) || current.includes("\ncompany_slug:")) {
|
|
180
183
|
fs.mkdirSync(dir, { recursive: true });
|
|
181
184
|
const tmp = `${meta}.${process.pid}.tmp`;
|
|
182
185
|
fs.writeFileSync(tmp, body);
|
|
@@ -214,9 +217,9 @@ function blockFromJson(stdout) {
|
|
|
214
217
|
}
|
|
215
218
|
export async function policyCheck(args, deps) {
|
|
216
219
|
const { hqRoot } = deps;
|
|
217
|
-
if (!SLUG_RE.test(args.company))
|
|
220
|
+
if (args.company !== undefined && !SLUG_RE.test(args.company))
|
|
218
221
|
throw new PolicyOpError("bad_args", "company must be a company slug");
|
|
219
|
-
if (!isDir(path.join(hqRoot, "companies", args.company))) {
|
|
222
|
+
if (args.company !== undefined && !isDir(path.join(hqRoot, "companies", args.company))) {
|
|
220
223
|
throw new PolicyOpError("unknown_company", `no companies/${args.company} under ${hqRoot}`);
|
|
221
224
|
}
|
|
222
225
|
const authorizer = path.join(hqRoot, SCOPE_AUTHORIZER);
|
|
@@ -237,9 +240,12 @@ export async function policyCheck(args, deps) {
|
|
|
237
240
|
...(deps.env ?? process.env),
|
|
238
241
|
HQ_ROOT: hqRoot,
|
|
239
242
|
CLAUDE_PROJECT_DIR: hqRoot,
|
|
240
|
-
HQ_POLICY_COMPANY: args.company,
|
|
241
243
|
BASH_ENV: "/dev/null",
|
|
242
244
|
};
|
|
245
|
+
if (args.company === undefined)
|
|
246
|
+
delete env.HQ_POLICY_COMPANY;
|
|
247
|
+
else
|
|
248
|
+
env.HQ_POLICY_COMPANY = args.company;
|
|
243
249
|
delete env.HQ_HOOK_SESSION_ID;
|
|
244
250
|
delete env.HQ_HOOK_TOOL_NAME;
|
|
245
251
|
const timeoutMs = deps.timeoutMs ?? 15_000;
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import { type ResolveResult } from "../registry/registry.js";
|
|
10
10
|
import { SessionRegistry } from "./session-registry.js";
|
|
11
|
-
import { HqMcpBridge } from "./ops/mcp.js";
|
|
11
|
+
import { HqMcpBridge, type RunWorkerRequest } from "./ops/mcp.js";
|
|
12
12
|
export interface HqdServerOptions {
|
|
13
13
|
socketPath: string;
|
|
14
14
|
/** registry.json to resolve against (US-004). */
|
|
@@ -27,6 +27,8 @@ export interface HqdServerOptions {
|
|
|
27
27
|
mcpEnabled?: () => boolean | Promise<boolean>;
|
|
28
28
|
/** Test seam for the hosted Streamable HTTP MCP transport. */
|
|
29
29
|
mcpBridge?: HqMcpBridge;
|
|
30
|
+
/** Test seam for reduced host parity runs without spawning an agent process. */
|
|
31
|
+
workerDispatch?: (request: RunWorkerRequest, hqRoot: string, env: NodeJS.ProcessEnv, now: Date) => Promise<Record<string, unknown>>;
|
|
30
32
|
/** Crash reporter for internal op failures (US-012); run.ts wires Sentry here. */
|
|
31
33
|
reportError?: (op: string, err: unknown) => void;
|
|
32
34
|
}
|
|
@@ -20,6 +20,31 @@ import { PolicyIndex, PolicyOpError, policyCheck } from "./ops/policy.js";
|
|
|
20
20
|
import { HqMcpBridge, jsonResult, McpOpError, runWorker } from "./ops/mcp.js";
|
|
21
21
|
import { HQ_MCP_TOOL_NAMES, HQD_NATIVE_TOOL_NAMES } from "../mcp/tools.js";
|
|
22
22
|
import { anywhereRuntimeEnabled, HQ_ANYWHERE_RUNTIME_FLAG } from "../../commands/anywhere-runtime-flag.js";
|
|
23
|
+
// The allowlist is deliberately read-only. A newly added hosted MCP tool must
|
|
24
|
+
// pass hq.policy_check until it is explicitly reviewed as read-only here.
|
|
25
|
+
const MCP_READ_ONLY_TOOL_NAMES = new Set([
|
|
26
|
+
"search",
|
|
27
|
+
"fetch",
|
|
28
|
+
"hq_companies_list",
|
|
29
|
+
"hq_company_ping",
|
|
30
|
+
"hq_exec_check",
|
|
31
|
+
"hq_files_list",
|
|
32
|
+
"hq_files_read",
|
|
33
|
+
"hq_knowledge_get",
|
|
34
|
+
"hq_knowledge_list",
|
|
35
|
+
"hq_knowledge_search",
|
|
36
|
+
"hq_ping",
|
|
37
|
+
"hq_policies_list",
|
|
38
|
+
"hq_policy_get",
|
|
39
|
+
"hq_project_get",
|
|
40
|
+
"hq_project_status",
|
|
41
|
+
"hq_projects_list",
|
|
42
|
+
"hq_run_list",
|
|
43
|
+
"hq_secrets_list",
|
|
44
|
+
"hq_secrets_schema",
|
|
45
|
+
"hq_version",
|
|
46
|
+
"hq_whoami",
|
|
47
|
+
]);
|
|
23
48
|
class OpError extends Error {
|
|
24
49
|
code;
|
|
25
50
|
constructor(code, message) {
|
|
@@ -165,14 +190,15 @@ export async function startHqdServer(opts) {
|
|
|
165
190
|
case "policy.check": {
|
|
166
191
|
const hqRoot = requireRoot();
|
|
167
192
|
const company = companyFor(args);
|
|
168
|
-
|
|
193
|
+
const personalCapture = args.personalScope === true && args.tool === "hq_personal_capture";
|
|
194
|
+
if (!company && !personalCapture)
|
|
169
195
|
throw new OpError("bad_args", "company is required (or a cwd linked to a company)");
|
|
170
196
|
const input = args.input;
|
|
171
197
|
if (input !== undefined && (input === null || typeof input !== "object" || Array.isArray(input))) {
|
|
172
198
|
throw new OpError("bad_args", "input must be an object");
|
|
173
199
|
}
|
|
174
200
|
return policyCheck({
|
|
175
|
-
company,
|
|
201
|
+
...(company ? { company } : {}),
|
|
176
202
|
event: stringArg(args, "event", false),
|
|
177
203
|
tool: stringArg(args, "tool"),
|
|
178
204
|
input: input,
|
|
@@ -212,6 +238,7 @@ export async function startHqdServer(opts) {
|
|
|
212
238
|
}, { hqRoot: opts.hqRoot, workMeshRoot: opts.workMeshRoot, env, now: clock });
|
|
213
239
|
}
|
|
214
240
|
case "mcp.call": {
|
|
241
|
+
const requestStartedAt = Date.now();
|
|
215
242
|
const name = stringArg(args, "name");
|
|
216
243
|
const raw = args.arguments ?? {};
|
|
217
244
|
if (raw === null || typeof raw !== "object" || Array.isArray(raw))
|
|
@@ -229,21 +256,42 @@ export async function startHqdServer(opts) {
|
|
|
229
256
|
companyArgs.sessionId = sessionId;
|
|
230
257
|
const call = (op, a) => handle({ id: req.id, op, args: a });
|
|
231
258
|
const asResult = (value) => (value instanceof Promise ? value.then(jsonResult) : jsonResult(value));
|
|
259
|
+
const policyGuardedCall = (run) => {
|
|
260
|
+
const company = companyFor(companyArgs);
|
|
261
|
+
const personalCapture = name === "hq_personal_capture" && !company;
|
|
262
|
+
if (!company && !personalCapture)
|
|
263
|
+
throw new OpError("bad_args", "company is required for a write-capable MCP tool");
|
|
264
|
+
return Promise.resolve(call("policy.check", {
|
|
265
|
+
...(company ? { company } : { personalScope: true }),
|
|
266
|
+
cwd: typeof toolArgs.cwd === "string" ? toolArgs.cwd : process.cwd(),
|
|
267
|
+
...(runtime ? { runtime } : {}),
|
|
268
|
+
...(sessionId ? { sessionId } : {}),
|
|
269
|
+
tool: name,
|
|
270
|
+
input: raw,
|
|
271
|
+
})).then((verdict) => {
|
|
272
|
+
const decision = verdict;
|
|
273
|
+
if (decision.decision !== "allow") {
|
|
274
|
+
const reason = Array.isArray(decision.reasons) ? decision.reasons.filter((r) => typeof r === "string").join("; ") : "policy check did not allow this tool";
|
|
275
|
+
throw new OpError("policy_blocked", reason || "policy check did not allow this tool");
|
|
276
|
+
}
|
|
277
|
+
return run();
|
|
278
|
+
});
|
|
279
|
+
};
|
|
232
280
|
switch (name) {
|
|
233
281
|
case "hq_resolve_company":
|
|
234
282
|
return asResult(call("resolve", { cwd: toolArgs.cwd ?? process.cwd() }));
|
|
235
283
|
case "hq_policy_check":
|
|
236
284
|
return asResult(call("policy.check", companyArgs));
|
|
237
285
|
case "hq_journal_append":
|
|
238
|
-
return
|
|
286
|
+
return policyGuardedCall(() => call("journal.append", companyArgs)).then(jsonResult);
|
|
239
287
|
case "hq_run_worker": {
|
|
240
288
|
const tool = companyArgs;
|
|
241
|
-
return runWorker({
|
|
289
|
+
return policyGuardedCall(() => (opts.workerDispatch ?? runWorker)({
|
|
242
290
|
worker: stringArg(tool, "worker"),
|
|
243
291
|
skill: stringArg(tool, "skill", false),
|
|
244
292
|
args: typeof tool.args === "string" ? tool.args : undefined,
|
|
245
293
|
company: companyFor(tool),
|
|
246
|
-
}, requireRoot(), env, clock()).then(jsonResult);
|
|
294
|
+
}, requireRoot(), env, clock())).then(jsonResult);
|
|
247
295
|
}
|
|
248
296
|
default: {
|
|
249
297
|
if (HQD_NATIVE_TOOL_NAMES.has(name) || !HQ_MCP_TOOL_NAMES.has(name))
|
|
@@ -252,13 +300,21 @@ export async function startHqdServer(opts) {
|
|
|
252
300
|
const timeoutMs = typeof args.timeoutMs === "number" && Number.isFinite(args.timeoutMs)
|
|
253
301
|
? Math.max(1, Math.min(args.timeoutMs, 120_000))
|
|
254
302
|
: HQD_DEFAULT_REQUEST_TIMEOUT_MS;
|
|
255
|
-
|
|
303
|
+
const deadline = requestStartedAt + timeoutMs;
|
|
304
|
+
const forward = () => Promise.resolve(mcpEnabled()).then((enabled) => {
|
|
256
305
|
if (!enabled) {
|
|
257
306
|
throw new McpOpError("hq_mcp_unavailable", `Hosted hq-mcp forwarding is disabled by the ${HQ_ANYWHERE_RUNTIME_FLAG} flag.`);
|
|
258
307
|
}
|
|
308
|
+
const remainingMs = Math.floor(deadline - Date.now());
|
|
309
|
+
if (remainingMs <= 0) {
|
|
310
|
+
throw new McpOpError("hq_mcp_timeout", `Hosted hq-mcp request exceeded its ${timeoutMs}ms end-to-end deadline before forwarding.`);
|
|
311
|
+
}
|
|
259
312
|
hqMcp ??= new HqMcpBridge({ env });
|
|
260
|
-
return hqMcp.callTool(name, raw,
|
|
313
|
+
return hqMcp.callTool(name, raw, remainingMs);
|
|
261
314
|
});
|
|
315
|
+
return !MCP_READ_ONLY_TOOL_NAMES.has(name)
|
|
316
|
+
? policyGuardedCall(forward)
|
|
317
|
+
: forward();
|
|
262
318
|
}
|
|
263
319
|
}
|
|
264
320
|
}
|