@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.
- package/dist/commands/agents.js +8 -3
- package/dist/commands/files.d.ts +61 -0
- package/dist/commands/files.js +274 -0
- package/dist/commands/mcp-registration.d.ts +4 -5
- package/dist/commands/mcp-registration.js +5 -4
- package/dist/commands/outposts.d.ts +20 -4
- package/dist/commands/outposts.js +79 -10
- package/dist/commands/pack-install.d.ts +14 -17
- package/dist/commands/pack-install.js +53 -29
- package/dist/commands/pkg-install.js +3 -1
- package/dist/commands/run.d.ts +2 -0
- package/dist/commands/run.js +9 -3
- package/dist/commands/secrets.js +189 -87
- package/dist/run/hq-plugin.js +94 -31
- package/dist/utils/billing-gate.d.ts +15 -0
- package/dist/utils/billing-gate.js +35 -0
- package/dist/utils/sandbox-runner-client.d.ts +1 -0
- package/dist/utils/sandbox-runner-client.js +1 -0
- package/dist/utils/secrets-cache.d.ts +4 -5
- package/dist/utils/secrets-cache.js +5 -8
- package/package.json +3 -2
- package/pnpm-workspace.yaml +2 -0
- package/src/commands/agents.test.ts +41 -0
- package/src/commands/agents.ts +7 -3
- package/src/commands/files-recovery.test.ts +361 -0
- package/src/commands/files.ts +410 -0
- package/src/commands/mcp-registration.ts +9 -9
- package/src/commands/outposts.test.ts +155 -24
- package/src/commands/outposts.ts +199 -45
- package/src/commands/pack-install-secret-authorization.test.ts +115 -0
- package/src/commands/pack-install.test.ts +5 -1
- package/src/commands/pack-install.ts +67 -29
- package/src/commands/pkg-install.ts +3 -1
- package/src/commands/run.test.ts +45 -0
- package/src/commands/run.ts +20 -4
- package/src/commands/secrets.test.ts +366 -25
- package/src/commands/secrets.ts +222 -96
- package/src/run/hq-plugin.test.ts +186 -10
- package/src/run/hq-plugin.ts +102 -32
- package/src/utils/__fixtures__/scan-packages.generated-block.sh +23 -0
- package/src/utils/billing-gate.ts +46 -0
- package/src/utils/pack-contributions.test.ts +90 -31
- package/src/utils/sandbox-runner-client.test.ts +28 -0
- package/src/utils/sandbox-runner-client.ts +2 -0
- package/src/utils/secrets-cache.ts +5 -8
- package/test/commands/signals.test.ts +2 -2
- package/test/commands/sources.test.ts +2 -2
- package/test/helpers/vault-service-mock.ts +76 -17
- 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 {
|
|
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
|
|
1755
|
-
*
|
|
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
|
-
*
|
|
1758
|
-
*
|
|
1759
|
-
*
|
|
1760
|
-
*
|
|
1761
|
-
*
|
|
1762
|
-
*
|
|
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
|
-
*
|
|
1768
|
-
*
|
|
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(
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1836
|
-
|
|
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(
|
|
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
|
|
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
|
+
});
|
package/src/commands/run.ts
CHANGED
|
@@ -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
|
-
|
|
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: {
|
|
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>;
|