@indigoai-us/hq-cli 5.75.0 → 5.77.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/dist/commands/agents.js +8 -3
  2. package/dist/commands/files.d.ts +61 -0
  3. package/dist/commands/files.js +274 -0
  4. package/dist/commands/mcp-registration.d.ts +4 -5
  5. package/dist/commands/mcp-registration.js +5 -4
  6. package/dist/commands/outposts.d.ts +20 -4
  7. package/dist/commands/outposts.js +79 -10
  8. package/dist/commands/pack-install.d.ts +14 -17
  9. package/dist/commands/pack-install.js +53 -29
  10. package/dist/commands/pkg-install.js +3 -1
  11. package/dist/commands/run.d.ts +2 -0
  12. package/dist/commands/run.js +9 -3
  13. package/dist/commands/secrets.js +189 -87
  14. package/dist/run/hq-plugin.js +94 -31
  15. package/dist/utils/billing-gate.d.ts +15 -0
  16. package/dist/utils/billing-gate.js +35 -0
  17. package/dist/utils/sandbox-runner-client.d.ts +1 -0
  18. package/dist/utils/sandbox-runner-client.js +1 -0
  19. package/dist/utils/secrets-cache.d.ts +4 -5
  20. package/dist/utils/secrets-cache.js +5 -8
  21. package/package.json +3 -2
  22. package/pnpm-workspace.yaml +2 -0
  23. package/src/commands/agents.test.ts +41 -0
  24. package/src/commands/agents.ts +7 -3
  25. package/src/commands/files-recovery.test.ts +361 -0
  26. package/src/commands/files.ts +410 -0
  27. package/src/commands/mcp-registration.ts +9 -9
  28. package/src/commands/outposts.test.ts +155 -24
  29. package/src/commands/outposts.ts +199 -45
  30. package/src/commands/pack-install-secret-authorization.test.ts +115 -0
  31. package/src/commands/pack-install.test.ts +5 -1
  32. package/src/commands/pack-install.ts +67 -29
  33. package/src/commands/pkg-install.ts +3 -1
  34. package/src/commands/run.test.ts +45 -0
  35. package/src/commands/run.ts +20 -4
  36. package/src/commands/secrets.test.ts +366 -25
  37. package/src/commands/secrets.ts +222 -96
  38. package/src/run/hq-plugin.test.ts +186 -10
  39. package/src/run/hq-plugin.ts +102 -32
  40. package/src/utils/__fixtures__/scan-packages.generated-block.sh +23 -0
  41. package/src/utils/billing-gate.ts +46 -0
  42. package/src/utils/pack-contributions.test.ts +90 -31
  43. package/src/utils/sandbox-runner-client.test.ts +28 -0
  44. package/src/utils/sandbox-runner-client.ts +2 -0
  45. package/src/utils/secrets-cache.ts +5 -8
  46. package/test/commands/signals.test.ts +2 -2
  47. package/test/commands/sources.test.ts +2 -2
  48. package/test/helpers/vault-service-mock.ts +76 -17
  49. package/test/sources-signals/smoke.test.ts +2 -2
@@ -56,7 +56,8 @@ import { findHqRoot } from '../utils/manifest.js';
56
56
  import { readHqVersion, routeContribution, listInstalledPacks } from '../utils/pack-contributions.js';
57
57
  import { CONTRIBUTION_TABLE, payloadFor } from '../utils/contribution-table.js';
58
58
  import { safeExtractTarball } from './safe-extract.js';
59
- import { vaultApiFetchPublic } from '../utils/vault-api.js';
59
+ import { getCompanyUid, vaultApiFetchPublic } from '../utils/vault-api.js';
60
+ import { ensureCognitoToken } from '../utils/cognito-session.js';
60
61
  import {
61
62
  redactSecrets,
62
63
  SECRET_REDACTION,
@@ -65,7 +66,8 @@ import {
65
66
  type McpManifest,
66
67
  type SecretResolver,
67
68
  } from './mcp-registration.js';
68
- import { readCache, listSecretCacheScopes } from '../utils/secrets-cache.js';
69
+ import { listSecretCacheScopes } from '../utils/secrets-cache.js';
70
+ import { loadRevealedSecrets } from './secrets.js';
69
71
  import type { PackManifest, PackContributeKey } from '../types.js';
70
72
 
71
73
  const PACK_UPDATE_CACHE_TTL_MS = 12 * 60 * 60 * 1000;
@@ -1751,33 +1753,59 @@ function evalConditional(expr: string): boolean {
1751
1753
  // ---------------------------------------------------------------------------
1752
1754
 
1753
1755
  /**
1754
- * Build the install-time {@link SecretResolver} bound to the HQ vault's local
1755
- * secrets-cache (`~/.hq/secrets-cache/<scope>/<NAME>`, AES-256-GCM, 0600, TTL'd).
1756
+ * Build an install-time {@link SecretResolver} from a fresh server authorization
1757
+ * decision. The encrypted cache may identify one unambiguous scope, but its
1758
+ * plaintext is never trusted or read to register an MCP server.
1756
1759
  *
1757
- * Active-company resolution at install time is INDIRECT by design: `hq install`
1758
- * has no `--company` flag and runs OFFLINE (no token), so we cannot resolve a
1759
- * single active company UID the way `hq run` / `hq secrets` do (via
1760
- * `getEntityUid` over the network). Instead we probe EVERY cached scope
1761
- * (`cmp_*`/`prs_*` whichever has minted secrets locally) for the requested
1762
- * name and return the first hit. This naturally resolves to whichever company
1763
- * context just provisioned the secret (e.g. the one `/connect-shopify` minted
1764
- * `VYG_API_KEY` under), without guessing, and works whether the vault scoped the
1765
- * key under a company or person entity.
1760
+ * With `--company`, the caller-selected company is resolved normally. Without
1761
+ * it, exactly one cache scope may identify the intended company/person context;
1762
+ * multiple scopes are ambiguous and fail closed. The requested names are then
1763
+ * loaded through the same server-authorized endpoint as exec/env/run. Offline,
1764
+ * expired-session, forbidden, not-found, script-lock, and malformed-response
1765
+ * outcomes all produce an empty resolver, so MCP registration is deferred.
1766
1766
  *
1767
- * On MISS across all scopes (no cache, expired TTL, or key never minted) it
1768
- * returns `null` which is exactly what {@link registerMcpServers}'s
1769
- * unresolvable-secret path keys off to defer that server gracefully. With no
1770
- * cached scopes at all (`listSecretCacheScopes()` → `[]`) it simply returns
1771
- * `null` for every name, the desired graceful-deferral behavior.
1767
+ * The returned synchronous resolver contains only values from that fresh
1768
+ * authorization response. No cached value is a fallback.
1772
1769
  */
1773
- export function makeInstallSecretResolver(): SecretResolver {
1774
- return (name: string): string | null => {
1775
- for (const scope of listSecretCacheScopes()) {
1776
- const value = readCache(scope, name);
1777
- if (value !== null) return value;
1770
+ export async function makeInstallSecretResolver(
1771
+ secretNames: string[],
1772
+ company?: string,
1773
+ ): Promise<SecretResolver> {
1774
+ const names = [...new Set(secretNames)];
1775
+ if (names.length === 0) return () => null;
1776
+
1777
+ try {
1778
+ const token = await ensureCognitoToken({ interactive: false });
1779
+ let scopeUid: string;
1780
+ if (company) {
1781
+ scopeUid = await getCompanyUid(token, company);
1782
+ } else {
1783
+ const scopes = listSecretCacheScopes();
1784
+ if (scopes.length !== 1) return () => null;
1785
+ [scopeUid] = scopes;
1778
1786
  }
1779
- return null;
1780
- };
1787
+ const authorized = await loadRevealedSecrets(token, scopeUid, names);
1788
+ return (name: string): string | null => authorized.get(name) ?? null;
1789
+ } catch {
1790
+ return () => null;
1791
+ }
1792
+ }
1793
+
1794
+ const INSTALL_SECRET_REF_RE =
1795
+ /\$\{secret:([A-Z][A-Z0-9_]*(?:\/[A-Z][A-Z0-9_]+)*)\}/g;
1796
+
1797
+ function collectManifestSecretNames(manifest: McpManifest): string[] {
1798
+ const names = new Set<string>();
1799
+ for (const value of [
1800
+ ...Object.values(manifest.headers ?? {}),
1801
+ ...Object.values(manifest.env ?? {}),
1802
+ ]) {
1803
+ INSTALL_SECRET_REF_RE.lastIndex = 0;
1804
+ for (let match = INSTALL_SECRET_REF_RE.exec(value); match; match = INSTALL_SECRET_REF_RE.exec(value)) {
1805
+ names.add(match[1]);
1806
+ }
1807
+ }
1808
+ return [...names];
1781
1809
  }
1782
1810
 
1783
1811
  /**
@@ -1829,17 +1857,21 @@ function extractDeferredSecretName(message: string): string {
1829
1857
  * - ANY OTHER error → RE-THROW (abort install). Only the
1830
1858
  * unresolvable-secret case is swallowed.
1831
1859
  */
1832
- function wireMcpServers(
1860
+ async function wireMcpServers(
1833
1861
  pkg: PackManifest,
1834
1862
  destDir: string,
1835
- ): { registered: string[]; skipped: string[] } {
1836
- const resolveSecret = makeInstallSecretResolver();
1863
+ company?: string,
1864
+ ): Promise<{ registered: string[]; skipped: string[] }> {
1837
1865
  const loadManifest = (name: string): McpManifest => loadMcpManifestFrom(destDir, name);
1838
1866
  const registered: string[] = [];
1839
1867
  const skipped: string[] = [];
1840
1868
 
1841
1869
  for (const name of pkg.contributes.mcp ?? []) {
1842
1870
  try {
1871
+ const resolveSecret = await makeInstallSecretResolver(
1872
+ collectManifestSecretNames(loadManifest(name)),
1873
+ company,
1874
+ );
1843
1875
  // Per-server call: registerMcpServers throws on the FIRST unresolvable
1844
1876
  // secret, so calling it one name at a time lets us catch + continue.
1845
1877
  registerMcpServers(pkg.name, [name], { loadManifest, resolveSecret });
@@ -2016,6 +2048,8 @@ export function runScanPackages(hqRoot: string, opts: { quiet?: boolean } = {}):
2016
2048
 
2017
2049
  export interface InstallPackOptions {
2018
2050
  allowHooks?: boolean;
2051
+ /** Company context for online authorization of install-time MCP secret refs. */
2052
+ company?: string;
2019
2053
  /**
2020
2054
  * US-010 install-time MCP trust prompt bypass (CI/ambient-trust). Mirrors
2021
2055
  * `allowHooks`: when set, `confirmMcp` skips the prompt and prints a yellow
@@ -2172,7 +2206,11 @@ export async function installPack(
2172
2206
  // Per-server secret-deferral keeps a fresh install (key not yet minted) at
2173
2207
  // exit 0, deferring only the unresolvable server (see wireMcpServers).
2174
2208
  if (Array.isArray(pkg.contributes.mcp) && pkg.contributes.mcp.length > 0) {
2175
- const { registered, skipped } = wireMcpServers(pkg, destDir);
2209
+ const { registered, skipped } = await wireMcpServers(
2210
+ pkg,
2211
+ destDir,
2212
+ opts.company,
2213
+ );
2176
2214
  // One-line summary (server NAMES only — never resolved secret VALUES).
2177
2215
  say(
2178
2216
  chalk.dim(
@@ -41,7 +41,7 @@ export function registerPackageInstallCommand(parent: Command): void {
41
41
  'Install a package. Sources: bare slug (registry, Cognito-gated), ' +
42
42
  '@scope/name[@ver] (npm pack), git URL[#ref], or local path.'
43
43
  )
44
- .option('--company <co>', 'Scope the package to a specific company (registry flow only)')
44
+ .option('--company <co>', 'Scope package secret authorization to a specific company')
45
45
  .option('--allow-hooks', 'Skip the hooks confirmation prompt (content-pack flow)')
46
46
  .option('--allow-mcp', 'Skip the MCP server confirmation prompt (content-pack flow)')
47
47
  .option('--branch', 'Follow a ref instead of SHA-pinning (git content-pack flow)')
@@ -53,6 +53,7 @@ export function registerPackageInstallCommand(parent: Command): void {
53
53
  try {
54
54
  if (sourceMatchesPackPattern(source)) {
55
55
  await installPack(source, {
56
+ company: opts.company,
56
57
  allowHooks: opts.allowHooks,
57
58
  allowMcp: opts.allowMcp,
58
59
  followBranch: opts.branch,
@@ -63,6 +64,7 @@ export function registerPackageInstallCommand(parent: Command): void {
63
64
  // would fail at registry setup. Resolve it through the marketplace
64
65
  // listings transport — the live install path — instead.
65
66
  await installPack(`${MARKETPLACE_PREFIX}${source}`, {
67
+ company: opts.company,
66
68
  allowHooks: opts.allowHooks,
67
69
  allowMcp: opts.allowMcp,
68
70
  followBranch: opts.branch,
@@ -0,0 +1,45 @@
1
+ import { createHash } from "node:crypto";
2
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join, resolve } from "node:path";
5
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
6
+ import { buildRunUsage } from "./run.js";
7
+
8
+ describe("hq run script usage", () => {
9
+ let tempDir: string;
10
+
11
+ beforeEach(() => {
12
+ tempDir = mkdtempSync(join(tmpdir(), "hq-run-usage-test-"));
13
+ });
14
+
15
+ afterEach(() => {
16
+ rmSync(tempDir, { recursive: true, force: true });
17
+ });
18
+
19
+ it("preserves a custom relative script id while resolving and hashing the file", async () => {
20
+ const scriptPath = join(tempDir, "deploy.sh");
21
+ const scriptBody = "#!/usr/bin/env bash\necho deploy\n";
22
+ writeFileSync(scriptPath, scriptBody);
23
+
24
+ const usage = await buildRunUsage(
25
+ scriptPath,
26
+ "scripts/deploy.sh",
27
+ );
28
+
29
+ expect(usage).toEqual({
30
+ channel: "run",
31
+ script: {
32
+ scriptId: "scripts/deploy.sh",
33
+ path: resolve(scriptPath),
34
+ sha256: createHash("sha256").update(scriptBody).digest("hex"),
35
+ attestationLevel: "self-asserted-hash",
36
+ },
37
+ });
38
+ });
39
+
40
+ it("rejects a script id when no script path can be attested", async () => {
41
+ await expect(buildRunUsage(undefined, "deploy-script")).rejects.toThrow(
42
+ "--script-id requires --script",
43
+ );
44
+ });
45
+ });
@@ -10,7 +10,15 @@ import { discoverSchemas } from '../run/discover-schemas.js';
10
10
  import { installHqPlugin, prewarmHqSecrets, type PluginState, type InstallHqPluginOpts } from '../run/hq-plugin.js';
11
11
  import type { SecretLoadResponse, SecretUsage } from './secrets.js';
12
12
 
13
- async function buildRunUsage(scriptPath?: string): Promise<SecretUsage | undefined> {
13
+ const SECRET_LOAD_TIMEOUT_MS = 30_000;
14
+
15
+ export async function buildRunUsage(
16
+ scriptPath?: string,
17
+ scriptId?: string,
18
+ ): Promise<SecretUsage | undefined> {
19
+ if (scriptId && !scriptPath) {
20
+ throw new Error('--script-id requires --script');
21
+ }
14
22
  if (!scriptPath) {
15
23
  return undefined;
16
24
  }
@@ -18,7 +26,7 @@ async function buildRunUsage(scriptPath?: string): Promise<SecretUsage | undefin
18
26
  return {
19
27
  channel: 'run',
20
28
  script: {
21
- scriptId: resolvedPath,
29
+ scriptId: scriptId ?? resolvedPath,
22
30
  path: resolvedPath,
23
31
  sha256: await computeSha256(resolvedPath),
24
32
  attestationLevel: 'self-asserted-hash',
@@ -33,9 +41,16 @@ export function registerRunCommand(program: Command): void {
33
41
  .option('--company <slug>', 'Company slug (overrides @hqCompany in schema)')
34
42
  .option('--schema <path>', 'Explicit schema path (skips walk-up discovery)')
35
43
  .option('--script <path>', 'Attach local script identity for script-locked secrets')
44
+ .option('--script-id <id>', 'Stable script identifier approved by policy')
36
45
  .option('--check', 'Resolve schema and validate vars without executing the command')
37
46
  .allowUnknownOption(true)
38
- .action(async (opts: { company?: string; schema?: string; script?: string; check?: boolean }) => {
47
+ .action(async (opts: {
48
+ company?: string;
49
+ schema?: string;
50
+ script?: string;
51
+ scriptId?: string;
52
+ check?: boolean;
53
+ }) => {
39
54
  try {
40
55
  const dashIndex = process.argv.indexOf('--');
41
56
  const childArgs = dashIndex !== -1 ? process.argv.slice(dashIndex + 1) : [];
@@ -78,7 +93,7 @@ export function registerRunCommand(program: Command): void {
78
93
 
79
94
  const token = await ensureCognitoToken();
80
95
  const uid = await getCompanyUid(token, slug);
81
- const usage = await buildRunUsage(opts.script);
96
+ const usage = await buildRunUsage(opts.script, opts.scriptId);
82
97
 
83
98
  const fetchBatch: InstallHqPluginOpts['fetchBatch'] = async (companyUid, names, requestUsage) => {
84
99
  const res = await vaultApiFetch({
@@ -86,6 +101,7 @@ export function registerRunCommand(program: Command): void {
86
101
  path: `/secrets/${encodeURIComponent(companyUid)}/load`,
87
102
  method: 'POST',
88
103
  body: requestUsage ? { names, usage: requestUsage } : { names },
104
+ signal: AbortSignal.timeout(SECRET_LOAD_TIMEOUT_MS),
89
105
  });
90
106
  if (!res.ok) {
91
107
  const body = await res.json().catch(() => ({})) as Record<string, unknown>;