@indigoai-us/hq-cli 5.74.0 → 5.76.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/mcp-registration.d.ts +4 -5
- package/dist/commands/mcp-registration.js +5 -4
- package/dist/commands/outposts.d.ts +23 -5
- package/dist/commands/outposts.js +207 -14
- 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/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/mcp-registration.ts +9 -9
- package/src/commands/outposts.test.ts +252 -24
- package/src/commands/outposts.ts +405 -50
- 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/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
|
@@ -429,11 +429,10 @@ export interface McpManifest {
|
|
|
429
429
|
/**
|
|
430
430
|
* Resolve a `${secret:NAME}` reference to its plaintext value, or return `null`
|
|
431
431
|
* when the secret is unavailable (un-minted / TTL-expired / no company context).
|
|
432
|
-
* Production binds this to
|
|
433
|
-
*
|
|
434
|
-
*
|
|
435
|
-
*
|
|
436
|
-
* reference at all.
|
|
432
|
+
* Production binds this to an in-memory map populated by a fresh vault `/load`
|
|
433
|
+
* authorization response; tests inject a pure map. Returning `null` for a
|
|
434
|
+
* referenced secret is a hard error at emit (we refuse to write a broken
|
|
435
|
+
* header), distinct from a name with no reference at all.
|
|
437
436
|
*/
|
|
438
437
|
export type SecretResolver = (name: string) => string | null;
|
|
439
438
|
/** True iff `value` contains at least one `${secret:NAME}` reference. */
|
|
@@ -848,7 +848,8 @@ function restoreSafely(realTarget, backupDir) {
|
|
|
848
848
|
//
|
|
849
849
|
// SECRET SAFETY (the hard rule from the PRD authModel / US-002 AC):
|
|
850
850
|
// - `${secret:NAME}` resolves ONLY here, at emit, via the injected
|
|
851
|
-
// {@link SecretResolver}
|
|
851
|
+
// {@link SecretResolver}. Production injects values returned by a fresh
|
|
852
|
+
// server authorization; the encrypted disk cache is never a value fallback.
|
|
852
853
|
// - The RESOLVED value lands in `~/.claude.json` — a 0600 user-global file that
|
|
853
854
|
// is NOT synced/committed — because the runtime needs the real header to work.
|
|
854
855
|
// - The resolved value is REDACTED from every return value and never echoed; we
|
|
@@ -885,9 +886,9 @@ export function resolveSecretRefs(value, resolve, secretSink) {
|
|
|
885
886
|
return value.replace(SECRET_REF_RE, (_match, name) => {
|
|
886
887
|
const resolved = resolve(name);
|
|
887
888
|
if (resolved === null || resolved === undefined) {
|
|
888
|
-
throw new McpManifestError(`cannot resolve \${secret:${name}} at emit — the secret
|
|
889
|
-
'
|
|
890
|
-
'write a half-resolved header.');
|
|
889
|
+
throw new McpManifestError(`cannot resolve \${secret:${name}} at emit — the secret was not available ` +
|
|
890
|
+
'after online vault authorization (check login, company scope, and access, ' +
|
|
891
|
+
'then re-install). HQ refuses to write a half-resolved header.');
|
|
891
892
|
}
|
|
892
893
|
if (resolved.length > 0)
|
|
893
894
|
secretSink.add(resolved);
|
|
@@ -23,13 +23,27 @@
|
|
|
23
23
|
import { Command } from "commander";
|
|
24
24
|
import { spawnSync } from "node:child_process";
|
|
25
25
|
import { type BillingErrorPayload } from "../utils/billing-gate.js";
|
|
26
|
+
/**
|
|
27
|
+
* hq-pro's per-person cap envelope on a `409` provision block. Unlike every
|
|
28
|
+
* other `/outpost/*` failure this body carries NO `message`/`error` field —
|
|
29
|
+
* only the cap facts — so it has to be decoded structurally or the reason
|
|
30
|
+
* degrades to a bare `res.statusText` ("Conflict").
|
|
31
|
+
*/
|
|
32
|
+
export interface OutpostCappedPayload {
|
|
33
|
+
limit: number;
|
|
34
|
+
outposts: OutpostSummary[];
|
|
35
|
+
}
|
|
36
|
+
/** Decode hq-pro's `{ capped, limit, outposts }` cap envelope, if present. */
|
|
37
|
+
export declare function parseCappedPayload(body: unknown): OutpostCappedPayload | undefined;
|
|
26
38
|
/** A non-2xx from the `/outpost/*` control plane. Carries status + `step`. */
|
|
27
39
|
export declare class OutpostHttpError extends Error {
|
|
28
40
|
status: number;
|
|
29
41
|
step?: string;
|
|
30
42
|
/** hq-pro's billing envelope on a `402 billing_required` provision block. */
|
|
31
43
|
billing?: BillingErrorPayload;
|
|
32
|
-
|
|
44
|
+
/** hq-pro's cap envelope on a `409` provision block. */
|
|
45
|
+
capped?: OutpostCappedPayload;
|
|
46
|
+
constructor(status: number, message: string, step?: string, billing?: BillingErrorPayload, capped?: OutpostCappedPayload);
|
|
33
47
|
}
|
|
34
48
|
/** Row summary from `GET /outpost/list`. */
|
|
35
49
|
export interface OutpostSummary {
|
|
@@ -59,9 +73,11 @@ export declare function outpostRequest<T>(opts: {
|
|
|
59
73
|
/**
|
|
60
74
|
* Provision the caller's Outpost. Sends the cached Cognito refresh token so the
|
|
61
75
|
* box can authenticate AS the caller (the same body the console's
|
|
62
|
-
* `provisionMyOutpost` sends).
|
|
63
|
-
* per-person cap gets
|
|
64
|
-
*
|
|
76
|
+
* `provisionMyOutpost` sends). No duplicate is ever created: a caller already at
|
|
77
|
+
* their per-person cap gets a `409` whose body lists their existing boxes —
|
|
78
|
+
* thrown here as an `OutpostHttpError` carrying `capped` (hq-pro checks the cap
|
|
79
|
+
* BEFORE activation billing, so a capped call is never charged). The refresh
|
|
80
|
+
* token is sent over HTTPS and NEVER printed.
|
|
65
81
|
*/
|
|
66
82
|
export declare function provisionOutpost(token: string, input: {
|
|
67
83
|
refreshToken: string;
|
|
@@ -120,6 +136,8 @@ export interface OutpostExecSubmission {
|
|
|
120
136
|
instanceId: string;
|
|
121
137
|
commandId: string;
|
|
122
138
|
outputPrefix: string;
|
|
139
|
+
/** Shell budget applied server-side (AWS-RunShellScript executionTimeout). */
|
|
140
|
+
executionTimeoutSeconds?: number;
|
|
123
141
|
}
|
|
124
142
|
/** Poll response from `mode: "result"`; streams arrive only when terminal. */
|
|
125
143
|
export interface OutpostExecAsyncResult {
|
|
@@ -134,7 +152,7 @@ export interface OutpostExecAsyncResult {
|
|
|
134
152
|
truncated?: boolean;
|
|
135
153
|
}
|
|
136
154
|
export declare function stageExecInput(token: string, outpostId?: string): Promise<OutpostExecStage>;
|
|
137
|
-
export declare function submitExec(token: string, command: string, outpostId?: string): Promise<OutpostExecSubmission>;
|
|
155
|
+
export declare function submitExec(token: string, command: string, outpostId?: string, timeoutSeconds?: number): Promise<OutpostExecSubmission>;
|
|
138
156
|
export declare function fetchExecResult(token: string, commandId: string, outpostId?: string): Promise<OutpostExecAsyncResult>;
|
|
139
157
|
/** Preserve a single command string; safely join argv when Commander split it. */
|
|
140
158
|
export declare function joinCommandParts(commandParts: string[]): string;
|
|
@@ -32,18 +32,33 @@ import { loadCachedTokens } from "@indigoai-us/hq-cloud";
|
|
|
32
32
|
import { ensureCognitoToken } from "../utils/cognito-session.js";
|
|
33
33
|
import { vaultApiFetch } from "../utils/vault-api.js";
|
|
34
34
|
import { OUTPOST_PRICE_CENTS, confirmChargeOrExit, parseBillingPayload, surfaceBillingRequired, } from "../utils/billing-gate.js";
|
|
35
|
+
/** Decode hq-pro's `{ capped, limit, outposts }` cap envelope, if present. */
|
|
36
|
+
export function parseCappedPayload(body) {
|
|
37
|
+
if (!body || typeof body !== "object")
|
|
38
|
+
return undefined;
|
|
39
|
+
const b = body;
|
|
40
|
+
if (b.capped !== true)
|
|
41
|
+
return undefined;
|
|
42
|
+
return {
|
|
43
|
+
limit: typeof b.limit === "number" ? b.limit : 0,
|
|
44
|
+
outposts: Array.isArray(b.outposts) ? b.outposts : [],
|
|
45
|
+
};
|
|
46
|
+
}
|
|
35
47
|
/** A non-2xx from the `/outpost/*` control plane. Carries status + `step`. */
|
|
36
48
|
export class OutpostHttpError extends Error {
|
|
37
49
|
status;
|
|
38
50
|
step;
|
|
39
51
|
/** hq-pro's billing envelope on a `402 billing_required` provision block. */
|
|
40
52
|
billing;
|
|
41
|
-
|
|
53
|
+
/** hq-pro's cap envelope on a `409` provision block. */
|
|
54
|
+
capped;
|
|
55
|
+
constructor(status, message, step, billing, capped) {
|
|
42
56
|
super(message);
|
|
43
57
|
this.name = "OutpostHttpError";
|
|
44
58
|
this.status = status;
|
|
45
59
|
this.step = step;
|
|
46
60
|
this.billing = billing;
|
|
61
|
+
this.capped = capped;
|
|
47
62
|
}
|
|
48
63
|
}
|
|
49
64
|
/**
|
|
@@ -62,16 +77,18 @@ export async function outpostRequest(opts) {
|
|
|
62
77
|
: typeof body.error === "string"
|
|
63
78
|
? body.error
|
|
64
79
|
: res.statusText;
|
|
65
|
-
throw new OutpostHttpError(res.status, message, body.step, parseBillingPayload(body));
|
|
80
|
+
throw new OutpostHttpError(res.status, message, body.step, parseBillingPayload(body), parseCappedPayload(body));
|
|
66
81
|
}
|
|
67
82
|
return (await res.json());
|
|
68
83
|
}
|
|
69
84
|
/**
|
|
70
85
|
* Provision the caller's Outpost. Sends the cached Cognito refresh token so the
|
|
71
86
|
* box can authenticate AS the caller (the same body the console's
|
|
72
|
-
* `provisionMyOutpost` sends).
|
|
73
|
-
* per-person cap gets
|
|
74
|
-
*
|
|
87
|
+
* `provisionMyOutpost` sends). No duplicate is ever created: a caller already at
|
|
88
|
+
* their per-person cap gets a `409` whose body lists their existing boxes —
|
|
89
|
+
* thrown here as an `OutpostHttpError` carrying `capped` (hq-pro checks the cap
|
|
90
|
+
* BEFORE activation billing, so a capped call is never charged). The refresh
|
|
91
|
+
* token is sent over HTTPS and NEVER printed.
|
|
75
92
|
*/
|
|
76
93
|
export async function provisionOutpost(token, input) {
|
|
77
94
|
return outpostRequest({
|
|
@@ -162,12 +179,16 @@ export async function stageExecInput(token, outpostId) {
|
|
|
162
179
|
query: outpostId ? { outpostId } : undefined,
|
|
163
180
|
});
|
|
164
181
|
}
|
|
165
|
-
export async function submitExec(token, command, outpostId) {
|
|
182
|
+
export async function submitExec(token, command, outpostId, timeoutSeconds) {
|
|
166
183
|
return outpostRequest({
|
|
167
184
|
token,
|
|
168
185
|
path: "/outpost/exec",
|
|
169
186
|
method: "POST",
|
|
170
|
-
body: {
|
|
187
|
+
body: {
|
|
188
|
+
mode: "submit",
|
|
189
|
+
command,
|
|
190
|
+
...(timeoutSeconds !== undefined ? { timeoutSeconds } : {}),
|
|
191
|
+
},
|
|
171
192
|
query: outpostId ? { outpostId } : undefined,
|
|
172
193
|
});
|
|
173
194
|
}
|
|
@@ -296,6 +317,24 @@ function ensureTrailingNewline(s) {
|
|
|
296
317
|
// ---------------------------------------------------------------------------
|
|
297
318
|
// Command registration
|
|
298
319
|
// ---------------------------------------------------------------------------
|
|
320
|
+
/**
|
|
321
|
+
* Explain a `409` per-person cap. hq-pro checks the cap BEFORE activation
|
|
322
|
+
* billing, so nothing was charged — worth saying, since the caller just
|
|
323
|
+
* confirmed a recurring charge to get here.
|
|
324
|
+
*/
|
|
325
|
+
function surfaceOutpostCapped(capped) {
|
|
326
|
+
const owned = capped.outposts.length;
|
|
327
|
+
console.error(chalk.yellow(`You're already at your Outpost limit (${owned} of ${capped.limit}). ` +
|
|
328
|
+
`No new box was provisioned and you have not been charged.`));
|
|
329
|
+
for (const o of capped.outposts) {
|
|
330
|
+
const detail = [o.state, o.instanceName, o.region]
|
|
331
|
+
.filter(Boolean)
|
|
332
|
+
.join(" ");
|
|
333
|
+
console.error(` ${o.outpostId} ${detail}`);
|
|
334
|
+
}
|
|
335
|
+
console.error(chalk.dim("Inspect it: hq outposts status"));
|
|
336
|
+
console.error(chalk.dim("Or tear it down first: hq outposts destroy --id <id> --yes"));
|
|
337
|
+
}
|
|
299
338
|
function fail(err) {
|
|
300
339
|
if (err instanceof OutpostHttpError) {
|
|
301
340
|
console.error(chalk.red(err.message));
|
|
@@ -611,7 +650,17 @@ function authGitHubViaVault(deps) {
|
|
|
611
650
|
"unset GITHUB_TOKEN",
|
|
612
651
|
'printf "%s" "$TOKEN" | gh auth login --with-token',
|
|
613
652
|
].join("\n");
|
|
614
|
-
const authed = runBestEffort(deps, "hq", [
|
|
653
|
+
const authed = runBestEffort(deps, "hq", [
|
|
654
|
+
"secrets",
|
|
655
|
+
"--personal",
|
|
656
|
+
"exec",
|
|
657
|
+
"--only",
|
|
658
|
+
"GITHUB_TOKEN",
|
|
659
|
+
"--",
|
|
660
|
+
"bash",
|
|
661
|
+
"-c",
|
|
662
|
+
ghLogin,
|
|
663
|
+
], "GitHub auth via vault");
|
|
615
664
|
if (authed) {
|
|
616
665
|
// Let plain `git clone https://github.com/...` reuse gh's token so private
|
|
617
666
|
// repos don't prompt for a username/password.
|
|
@@ -666,7 +715,15 @@ async function replicaSyncOutpost(opts, deps) {
|
|
|
666
715
|
console.warn("replica-sync: auth refresh failed — skipping this cycle (token likely expired).");
|
|
667
716
|
return;
|
|
668
717
|
}
|
|
669
|
-
runBestEffort(deps, "hq", [
|
|
718
|
+
runBestEffort(deps, "hq", [
|
|
719
|
+
"sync",
|
|
720
|
+
"pull",
|
|
721
|
+
"--personal",
|
|
722
|
+
"--hq-root",
|
|
723
|
+
hqRoot,
|
|
724
|
+
"--on-conflict",
|
|
725
|
+
"keep",
|
|
726
|
+
], "personal vault pull");
|
|
670
727
|
// rescue lays down the core kernel and expects companies/ to exist.
|
|
671
728
|
deps.mkdirp(path.join(hqRoot, "companies"));
|
|
672
729
|
runBestEffort(deps, "hq", ["rescue", "--hq-root", hqRoot, "--yes"], "HQ kernel rescue");
|
|
@@ -718,9 +775,17 @@ export function registerOutpostsCommand(program, selfDeployOverrides = {}) {
|
|
|
718
775
|
.option("--yes", "Confirm the $80/month charge (required to provision)")
|
|
719
776
|
.action(async function (opts) {
|
|
720
777
|
try {
|
|
778
|
+
// Reject an unrecognized runtime rather than resolving it to claude:
|
|
779
|
+
// `--runtime codx` would otherwise hand back a silently Claude box.
|
|
780
|
+
if (opts.runtime !== undefined &&
|
|
781
|
+
opts.runtime !== "claude" &&
|
|
782
|
+
opts.runtime !== "codex") {
|
|
783
|
+
console.error(chalk.red(`Invalid --runtime '${opts.runtime}': must be 'claude' or 'codex'.`));
|
|
784
|
+
process.exit(1);
|
|
785
|
+
}
|
|
721
786
|
const agentRuntime = opts.runtime === "codex"
|
|
722
787
|
? "codex"
|
|
723
|
-
: opts.runtime
|
|
788
|
+
: opts.runtime === "claude"
|
|
724
789
|
? "claude"
|
|
725
790
|
: undefined;
|
|
726
791
|
let diskSizeGb;
|
|
@@ -763,6 +828,14 @@ export function registerOutpostsCommand(program, selfDeployOverrides = {}) {
|
|
|
763
828
|
await surfaceBillingRequired(token, err.billing);
|
|
764
829
|
process.exit(1);
|
|
765
830
|
}
|
|
831
|
+
// Already at the per-person cap → say so and name the box they own,
|
|
832
|
+
// not a bare "Conflict".
|
|
833
|
+
if (err instanceof OutpostHttpError &&
|
|
834
|
+
err.status === 409 &&
|
|
835
|
+
err.capped) {
|
|
836
|
+
surfaceOutpostCapped(err.capped);
|
|
837
|
+
process.exit(1);
|
|
838
|
+
}
|
|
766
839
|
throw err;
|
|
767
840
|
}
|
|
768
841
|
}
|
|
@@ -835,8 +908,18 @@ export function registerOutpostsCommand(program, selfDeployOverrides = {}) {
|
|
|
835
908
|
});
|
|
836
909
|
outposts
|
|
837
910
|
.command("exec <command...>")
|
|
838
|
-
.description("Run a shell command on an Outpost and print its output (use -- before flags meant for the remote command)"
|
|
911
|
+
.description("Run a shell command on an Outpost and print its output (use -- before flags meant for the remote command). " +
|
|
912
|
+
"Default is synchronous (API Gateway ~20s cap). Use --async for long jobs, or --detach to print the commandId and return immediately.")
|
|
839
913
|
.option("--id <outpostId>", "Outpost id (defaults to your primary box)")
|
|
914
|
+
.option("--async", "Submit via the async transport and wait for completion (bypasses the ~20s sync cap; shell budget defaults to 48h)")
|
|
915
|
+
.option("--detach", "Submit via the async transport, print commandId, and return immediately (pair with `hq outposts exec-result --wait`)")
|
|
916
|
+
.option("--timeout-seconds <n>", "Async shell budget in seconds (AWS-RunShellScript executionTimeout; 1..172800). Implies --async unless --detach is set.", (v) => {
|
|
917
|
+
const n = Number(v);
|
|
918
|
+
if (!Number.isInteger(n)) {
|
|
919
|
+
throw new Error("--timeout-seconds must be an integer");
|
|
920
|
+
}
|
|
921
|
+
return n;
|
|
922
|
+
})
|
|
840
923
|
.option("--json", "Emit raw JSON")
|
|
841
924
|
.action(async function (commandParts, opts) {
|
|
842
925
|
try {
|
|
@@ -845,9 +928,94 @@ export function registerOutpostsCommand(program, selfDeployOverrides = {}) {
|
|
|
845
928
|
console.error(chalk.red("No command given. Usage: hq outposts exec -- <command>"));
|
|
846
929
|
process.exit(1);
|
|
847
930
|
}
|
|
931
|
+
if (opts.async && opts.detach) {
|
|
932
|
+
console.error(chalk.red("Use either --async (submit + wait) or --detach (submit only), not both."));
|
|
933
|
+
process.exit(1);
|
|
934
|
+
}
|
|
935
|
+
// --timeout-seconds only applies to the async path; bare use implies --async.
|
|
936
|
+
const useAsync = Boolean(opts.async) ||
|
|
937
|
+
Boolean(opts.detach) ||
|
|
938
|
+
opts.timeoutSeconds !== undefined;
|
|
939
|
+
if (opts.timeoutSeconds !== undefined) {
|
|
940
|
+
if (!Number.isInteger(opts.timeoutSeconds) ||
|
|
941
|
+
opts.timeoutSeconds < 1 ||
|
|
942
|
+
opts.timeoutSeconds > 172_800) {
|
|
943
|
+
console.error(chalk.red("--timeout-seconds must be an integer between 1 and 172800 (48h, the AWS-RunShellScript max)"));
|
|
944
|
+
process.exit(1);
|
|
945
|
+
}
|
|
946
|
+
}
|
|
848
947
|
const token = await ensureCognitoToken();
|
|
849
948
|
// Run from the box's HQ folder by default (works over both SSM and SSH).
|
|
850
949
|
const remoteCommand = withRemoteHqDir(command);
|
|
950
|
+
if (useAsync) {
|
|
951
|
+
try {
|
|
952
|
+
const submitted = await submitExec(token, remoteCommand, opts.id, opts.timeoutSeconds);
|
|
953
|
+
if (opts.detach) {
|
|
954
|
+
const output = {
|
|
955
|
+
commandId: submitted.commandId,
|
|
956
|
+
...(submitted.executionTimeoutSeconds !== undefined
|
|
957
|
+
? { executionTimeoutSeconds: submitted.executionTimeoutSeconds }
|
|
958
|
+
: opts.timeoutSeconds !== undefined
|
|
959
|
+
? { executionTimeoutSeconds: opts.timeoutSeconds }
|
|
960
|
+
: {}),
|
|
961
|
+
};
|
|
962
|
+
if (opts.json) {
|
|
963
|
+
process.stdout.write(JSON.stringify(output) + "\n");
|
|
964
|
+
}
|
|
965
|
+
else {
|
|
966
|
+
printKeyValues(output);
|
|
967
|
+
console.error(chalk.dim("Submitted. Poll with: hq outposts exec-result --command-id " +
|
|
968
|
+
submitted.commandId +
|
|
969
|
+
(opts.id ? ` --id ${opts.id}` : "") +
|
|
970
|
+
" --wait"));
|
|
971
|
+
}
|
|
972
|
+
return;
|
|
973
|
+
}
|
|
974
|
+
// --async (or --timeout-seconds without --detach): wait for terminal.
|
|
975
|
+
if (!opts.json) {
|
|
976
|
+
console.error(chalk.dim(`Submitted ${submitted.commandId}; waiting for completion…`));
|
|
977
|
+
}
|
|
978
|
+
const result = await waitForExecResult(token, submitted.commandId, opts.id);
|
|
979
|
+
if (opts.json) {
|
|
980
|
+
process.stdout.write(JSON.stringify({
|
|
981
|
+
commandId: submitted.commandId,
|
|
982
|
+
done: result.done,
|
|
983
|
+
status: result.status,
|
|
984
|
+
exitCode: result.exitCode ?? null,
|
|
985
|
+
stdout: result.stdout ?? "",
|
|
986
|
+
stderr: result.stderr ?? "",
|
|
987
|
+
truncated: result.truncated ?? false,
|
|
988
|
+
}, null, 2) + "\n");
|
|
989
|
+
}
|
|
990
|
+
else {
|
|
991
|
+
if (result.stdout)
|
|
992
|
+
process.stdout.write(result.stdout);
|
|
993
|
+
if (result.stderr)
|
|
994
|
+
process.stderr.write(result.stderr);
|
|
995
|
+
if (result.truncated) {
|
|
996
|
+
console.error(chalk.yellow("(output truncated — redirect to a file on the box for full output)"));
|
|
997
|
+
}
|
|
998
|
+
if (result.status !== "Success" && result.exitCode === null) {
|
|
999
|
+
console.error(chalk.yellow(`(command ended with SSM status: ${result.status})`));
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
process.exitCode =
|
|
1003
|
+
typeof result.exitCode === "number" ? result.exitCode : 0;
|
|
1004
|
+
return;
|
|
1005
|
+
}
|
|
1006
|
+
catch (err) {
|
|
1007
|
+
// Async requires EC2/SSM. Lightsail has no async channel — refuse
|
|
1008
|
+
// rather than silently falling back to a live SSH hold, which is
|
|
1009
|
+
// the exact timeout failure mode --async is meant to escape.
|
|
1010
|
+
if (err instanceof OutpostHttpError &&
|
|
1011
|
+
err.step === "platform-unsupported") {
|
|
1012
|
+
console.error(chalk.red("Async exec requires an EC2 Outpost (SSM). This box is Lightsail — " +
|
|
1013
|
+
"re-provision on EC2, or run a short sync command / SSH session instead."));
|
|
1014
|
+
process.exit(1);
|
|
1015
|
+
}
|
|
1016
|
+
throw err;
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
851
1019
|
try {
|
|
852
1020
|
const result = await execOutpost(token, remoteCommand, opts.id);
|
|
853
1021
|
if (opts.json) {
|
|
@@ -940,8 +1108,15 @@ export function registerOutpostsCommand(program, selfDeployOverrides = {}) {
|
|
|
940
1108
|
});
|
|
941
1109
|
outposts
|
|
942
1110
|
.command("exec-submit <command...>")
|
|
943
|
-
.description("Submit an asynchronous shell command to an Outpost")
|
|
1111
|
+
.description("Submit an asynchronous shell command to an Outpost (returns immediately with commandId; shell budget defaults to 48h)")
|
|
944
1112
|
.option("--id <outpostId>", "Outpost id (defaults to your primary box)")
|
|
1113
|
+
.option("--timeout-seconds <n>", "Shell budget in seconds (AWS-RunShellScript executionTimeout; 1..172800)", (v) => {
|
|
1114
|
+
const n = Number(v);
|
|
1115
|
+
if (!Number.isInteger(n)) {
|
|
1116
|
+
throw new Error("--timeout-seconds must be an integer");
|
|
1117
|
+
}
|
|
1118
|
+
return n;
|
|
1119
|
+
})
|
|
945
1120
|
.option("--json", "Emit raw JSON")
|
|
946
1121
|
.action(async function (commandParts, opts) {
|
|
947
1122
|
try {
|
|
@@ -950,9 +1125,27 @@ export function registerOutpostsCommand(program, selfDeployOverrides = {}) {
|
|
|
950
1125
|
console.error(chalk.red("No command given. Usage: hq outposts exec-submit -- <command>"));
|
|
951
1126
|
process.exit(1);
|
|
952
1127
|
}
|
|
1128
|
+
if (opts.timeoutSeconds !== undefined) {
|
|
1129
|
+
if (!Number.isInteger(opts.timeoutSeconds) ||
|
|
1130
|
+
opts.timeoutSeconds < 1 ||
|
|
1131
|
+
opts.timeoutSeconds > 172_800) {
|
|
1132
|
+
console.error(chalk.red("--timeout-seconds must be an integer between 1 and 172800 (48h)"));
|
|
1133
|
+
process.exit(1);
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
953
1136
|
const token = await ensureCognitoToken();
|
|
954
|
-
|
|
955
|
-
|
|
1137
|
+
// exec-submit is the raw fire-and-forget path — do NOT wrap with
|
|
1138
|
+
// withRemoteHqDir here (callers that want the HQ cwd use `exec --async`
|
|
1139
|
+
// or prefix their own cd). Matches the existing contract.
|
|
1140
|
+
const submitted = await submitExec(token, command, opts.id, opts.timeoutSeconds);
|
|
1141
|
+
const output = {
|
|
1142
|
+
commandId: submitted.commandId,
|
|
1143
|
+
...(submitted.executionTimeoutSeconds !== undefined
|
|
1144
|
+
? { executionTimeoutSeconds: submitted.executionTimeoutSeconds }
|
|
1145
|
+
: opts.timeoutSeconds !== undefined
|
|
1146
|
+
? { executionTimeoutSeconds: opts.timeoutSeconds }
|
|
1147
|
+
: {}),
|
|
1148
|
+
};
|
|
956
1149
|
if (opts.json) {
|
|
957
1150
|
process.stdout.write(JSON.stringify(output) + "\n");
|
|
958
1151
|
}
|
|
@@ -305,26 +305,21 @@ export declare function renderMcpServerLine(payloadDir: string, item: string): s
|
|
|
305
305
|
*/
|
|
306
306
|
export declare function confirmMcp(pkg: PackManifest, payloadDir: string, allowMcp: boolean): Promise<boolean>;
|
|
307
307
|
/**
|
|
308
|
-
* Build
|
|
309
|
-
*
|
|
308
|
+
* Build an install-time {@link SecretResolver} from a fresh server authorization
|
|
309
|
+
* decision. The encrypted cache may identify one unambiguous scope, but its
|
|
310
|
+
* plaintext is never trusted or read to register an MCP server.
|
|
310
311
|
*
|
|
311
|
-
*
|
|
312
|
-
*
|
|
313
|
-
*
|
|
314
|
-
*
|
|
315
|
-
*
|
|
316
|
-
*
|
|
317
|
-
* context just provisioned the secret (e.g. the one `/connect-shopify` minted
|
|
318
|
-
* `VYG_API_KEY` under), without guessing, and works whether the vault scoped the
|
|
319
|
-
* key under a company or person entity.
|
|
312
|
+
* With `--company`, the caller-selected company is resolved normally. Without
|
|
313
|
+
* it, exactly one cache scope may identify the intended company/person context;
|
|
314
|
+
* multiple scopes are ambiguous and fail closed. The requested names are then
|
|
315
|
+
* loaded through the same server-authorized endpoint as exec/env/run. Offline,
|
|
316
|
+
* expired-session, forbidden, not-found, script-lock, and malformed-response
|
|
317
|
+
* outcomes all produce an empty resolver, so MCP registration is deferred.
|
|
320
318
|
*
|
|
321
|
-
*
|
|
322
|
-
*
|
|
323
|
-
* unresolvable-secret path keys off to defer that server gracefully. With no
|
|
324
|
-
* cached scopes at all (`listSecretCacheScopes()` → `[]`) it simply returns
|
|
325
|
-
* `null` for every name, the desired graceful-deferral behavior.
|
|
319
|
+
* The returned synchronous resolver contains only values from that fresh
|
|
320
|
+
* authorization response. No cached value is a fallback.
|
|
326
321
|
*/
|
|
327
|
-
export declare function makeInstallSecretResolver(): SecretResolver
|
|
322
|
+
export declare function makeInstallSecretResolver(secretNames: string[], company?: string): Promise<SecretResolver>;
|
|
328
323
|
/**
|
|
329
324
|
* Install the fetched payload to `<hqRoot>/core/packages/<pkg.name>/` (HQ
|
|
330
325
|
* v12+ layout). The HQ template (`hq-core` / `hq-core-staging`) ships
|
|
@@ -381,6 +376,8 @@ export declare function runScanPackages(hqRoot: string, opts?: {
|
|
|
381
376
|
}): void;
|
|
382
377
|
export interface InstallPackOptions {
|
|
383
378
|
allowHooks?: boolean;
|
|
379
|
+
/** Company context for online authorization of install-time MCP secret refs. */
|
|
380
|
+
company?: string;
|
|
384
381
|
/**
|
|
385
382
|
* US-010 install-time MCP trust prompt bypass (CI/ambient-trust). Mirrors
|
|
386
383
|
* `allowHooks`: when set, `confirmMcp` skips the prompt and prints a yellow
|
|
@@ -49,9 +49,11 @@ import { findHqRoot } from '../utils/manifest.js';
|
|
|
49
49
|
import { readHqVersion, routeContribution, listInstalledPacks } from '../utils/pack-contributions.js';
|
|
50
50
|
import { CONTRIBUTION_TABLE, payloadFor } from '../utils/contribution-table.js';
|
|
51
51
|
import { safeExtractTarball } from './safe-extract.js';
|
|
52
|
-
import { vaultApiFetchPublic } from '../utils/vault-api.js';
|
|
52
|
+
import { getCompanyUid, vaultApiFetchPublic } from '../utils/vault-api.js';
|
|
53
|
+
import { ensureCognitoToken } from '../utils/cognito-session.js';
|
|
53
54
|
import { redactSecrets, SECRET_REDACTION, registerMcpServers, McpManifestError, } from './mcp-registration.js';
|
|
54
|
-
import {
|
|
55
|
+
import { listSecretCacheScopes } from '../utils/secrets-cache.js';
|
|
56
|
+
import { loadRevealedSecrets } from './secrets.js';
|
|
55
57
|
const PACK_UPDATE_CACHE_TTL_MS = 12 * 60 * 60 * 1000;
|
|
56
58
|
const PACK_UPDATE_FETCH_TIMEOUT_MS = 3_000;
|
|
57
59
|
const gitLsRemoteMemo = new Map();
|
|
@@ -1349,34 +1351,56 @@ function evalConditional(expr) {
|
|
|
1349
1351
|
// key) still SUCCEEDS, skipping only the server whose secret is not yet present.
|
|
1350
1352
|
// ---------------------------------------------------------------------------
|
|
1351
1353
|
/**
|
|
1352
|
-
* Build
|
|
1353
|
-
*
|
|
1354
|
+
* Build an install-time {@link SecretResolver} from a fresh server authorization
|
|
1355
|
+
* decision. The encrypted cache may identify one unambiguous scope, but its
|
|
1356
|
+
* plaintext is never trusted or read to register an MCP server.
|
|
1354
1357
|
*
|
|
1355
|
-
*
|
|
1356
|
-
*
|
|
1357
|
-
*
|
|
1358
|
-
*
|
|
1359
|
-
*
|
|
1360
|
-
*
|
|
1361
|
-
* context just provisioned the secret (e.g. the one `/connect-shopify` minted
|
|
1362
|
-
* `VYG_API_KEY` under), without guessing, and works whether the vault scoped the
|
|
1363
|
-
* key under a company or person entity.
|
|
1358
|
+
* With `--company`, the caller-selected company is resolved normally. Without
|
|
1359
|
+
* it, exactly one cache scope may identify the intended company/person context;
|
|
1360
|
+
* multiple scopes are ambiguous and fail closed. The requested names are then
|
|
1361
|
+
* loaded through the same server-authorized endpoint as exec/env/run. Offline,
|
|
1362
|
+
* expired-session, forbidden, not-found, script-lock, and malformed-response
|
|
1363
|
+
* outcomes all produce an empty resolver, so MCP registration is deferred.
|
|
1364
1364
|
*
|
|
1365
|
-
*
|
|
1366
|
-
*
|
|
1367
|
-
* unresolvable-secret path keys off to defer that server gracefully. With no
|
|
1368
|
-
* cached scopes at all (`listSecretCacheScopes()` → `[]`) it simply returns
|
|
1369
|
-
* `null` for every name, the desired graceful-deferral behavior.
|
|
1365
|
+
* The returned synchronous resolver contains only values from that fresh
|
|
1366
|
+
* authorization response. No cached value is a fallback.
|
|
1370
1367
|
*/
|
|
1371
|
-
export function makeInstallSecretResolver() {
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1368
|
+
export async function makeInstallSecretResolver(secretNames, company) {
|
|
1369
|
+
const names = [...new Set(secretNames)];
|
|
1370
|
+
if (names.length === 0)
|
|
1371
|
+
return () => null;
|
|
1372
|
+
try {
|
|
1373
|
+
const token = await ensureCognitoToken({ interactive: false });
|
|
1374
|
+
let scopeUid;
|
|
1375
|
+
if (company) {
|
|
1376
|
+
scopeUid = await getCompanyUid(token, company);
|
|
1377
1377
|
}
|
|
1378
|
-
|
|
1379
|
-
|
|
1378
|
+
else {
|
|
1379
|
+
const scopes = listSecretCacheScopes();
|
|
1380
|
+
if (scopes.length !== 1)
|
|
1381
|
+
return () => null;
|
|
1382
|
+
[scopeUid] = scopes;
|
|
1383
|
+
}
|
|
1384
|
+
const authorized = await loadRevealedSecrets(token, scopeUid, names);
|
|
1385
|
+
return (name) => authorized.get(name) ?? null;
|
|
1386
|
+
}
|
|
1387
|
+
catch {
|
|
1388
|
+
return () => null;
|
|
1389
|
+
}
|
|
1390
|
+
}
|
|
1391
|
+
const INSTALL_SECRET_REF_RE = /\$\{secret:([A-Z][A-Z0-9_]*(?:\/[A-Z][A-Z0-9_]+)*)\}/g;
|
|
1392
|
+
function collectManifestSecretNames(manifest) {
|
|
1393
|
+
const names = new Set();
|
|
1394
|
+
for (const value of [
|
|
1395
|
+
...Object.values(manifest.headers ?? {}),
|
|
1396
|
+
...Object.values(manifest.env ?? {}),
|
|
1397
|
+
]) {
|
|
1398
|
+
INSTALL_SECRET_REF_RE.lastIndex = 0;
|
|
1399
|
+
for (let match = INSTALL_SECRET_REF_RE.exec(value); match; match = INSTALL_SECRET_REF_RE.exec(value)) {
|
|
1400
|
+
names.add(match[1]);
|
|
1401
|
+
}
|
|
1402
|
+
}
|
|
1403
|
+
return [...names];
|
|
1380
1404
|
}
|
|
1381
1405
|
/**
|
|
1382
1406
|
* Load + shape-validate + parse a pack's per-server MCP manifest from disk.
|
|
@@ -1424,13 +1448,13 @@ function extractDeferredSecretName(message) {
|
|
|
1424
1448
|
* - ANY OTHER error → RE-THROW (abort install). Only the
|
|
1425
1449
|
* unresolvable-secret case is swallowed.
|
|
1426
1450
|
*/
|
|
1427
|
-
function wireMcpServers(pkg, destDir) {
|
|
1428
|
-
const resolveSecret = makeInstallSecretResolver();
|
|
1451
|
+
async function wireMcpServers(pkg, destDir, company) {
|
|
1429
1452
|
const loadManifest = (name) => loadMcpManifestFrom(destDir, name);
|
|
1430
1453
|
const registered = [];
|
|
1431
1454
|
const skipped = [];
|
|
1432
1455
|
for (const name of pkg.contributes.mcp ?? []) {
|
|
1433
1456
|
try {
|
|
1457
|
+
const resolveSecret = await makeInstallSecretResolver(collectManifestSecretNames(loadManifest(name)), company);
|
|
1434
1458
|
// Per-server call: registerMcpServers throws on the FIRST unresolvable
|
|
1435
1459
|
// secret, so calling it one name at a time lets us catch + continue.
|
|
1436
1460
|
registerMcpServers(pkg.name, [name], { loadManifest, resolveSecret });
|
|
@@ -1676,7 +1700,7 @@ export async function installPack(source, opts = {}) {
|
|
|
1676
1700
|
// Per-server secret-deferral keeps a fresh install (key not yet minted) at
|
|
1677
1701
|
// exit 0, deferring only the unresolvable server (see wireMcpServers).
|
|
1678
1702
|
if (Array.isArray(pkg.contributes.mcp) && pkg.contributes.mcp.length > 0) {
|
|
1679
|
-
const { registered, skipped } = wireMcpServers(pkg, destDir);
|
|
1703
|
+
const { registered, skipped } = await wireMcpServers(pkg, destDir, opts.company);
|
|
1680
1704
|
// One-line summary (server NAMES only — never resolved secret VALUES).
|
|
1681
1705
|
say(chalk.dim(` MCP servers: registered [${registered.join(', ')}]; ` +
|
|
1682
1706
|
`skipped [${skipped.join(', ')}].`));
|
|
@@ -29,7 +29,7 @@ export function registerPackageInstallCommand(parent) {
|
|
|
29
29
|
.command('install <source>')
|
|
30
30
|
.description('Install a package. Sources: bare slug (registry, Cognito-gated), ' +
|
|
31
31
|
'@scope/name[@ver] (npm pack), git URL[#ref], or local path.')
|
|
32
|
-
.option('--company <co>', 'Scope
|
|
32
|
+
.option('--company <co>', 'Scope package secret authorization to a specific company')
|
|
33
33
|
.option('--allow-hooks', 'Skip the hooks confirmation prompt (content-pack flow)')
|
|
34
34
|
.option('--allow-mcp', 'Skip the MCP server confirmation prompt (content-pack flow)')
|
|
35
35
|
.option('--branch', 'Follow a ref instead of SHA-pinning (git content-pack flow)')
|
|
@@ -37,6 +37,7 @@ export function registerPackageInstallCommand(parent) {
|
|
|
37
37
|
try {
|
|
38
38
|
if (sourceMatchesPackPattern(source)) {
|
|
39
39
|
await installPack(source, {
|
|
40
|
+
company: opts.company,
|
|
40
41
|
allowHooks: opts.allowHooks,
|
|
41
42
|
allowMcp: opts.allowMcp,
|
|
42
43
|
followBranch: opts.branch,
|
|
@@ -48,6 +49,7 @@ export function registerPackageInstallCommand(parent) {
|
|
|
48
49
|
// would fail at registry setup. Resolve it through the marketplace
|
|
49
50
|
// listings transport — the live install path — instead.
|
|
50
51
|
await installPack(`${MARKETPLACE_PREFIX}${source}`, {
|
|
52
|
+
company: opts.company,
|
|
51
53
|
allowHooks: opts.allowHooks,
|
|
52
54
|
allowMcp: opts.allowMcp,
|
|
53
55
|
followBranch: opts.branch,
|
package/dist/commands/run.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
1
|
import { Command } from 'commander';
|
|
2
|
+
import type { SecretUsage } from './secrets.js';
|
|
3
|
+
export declare function buildRunUsage(scriptPath?: string, scriptId?: string): Promise<SecretUsage | undefined>;
|
|
2
4
|
export declare function registerRunCommand(program: Command): void;
|
|
3
5
|
//# sourceMappingURL=run.d.ts.map
|