@indigoai-us/hq-cli 5.342.8 → 5.342.10
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 +9 -0
- package/dist/commands/core-job-post-exit-assets.d.ts +8 -0
- package/dist/commands/core-job-post-exit-assets.js +37 -0
- package/dist/commands/core-job-run-native.d.ts +1 -0
- package/dist/commands/core-job-run-native.js +31 -11
- package/dist/commands/integrations-core.d.ts +3 -0
- package/dist/commands/integrations-core.js +36 -3
- package/dist/commands/run.js +4 -1
- package/dist/commands/secrets.js +4 -1
- package/dist/main.js +36 -1
- package/dist/utils/child-wrapper-exit.d.ts +5 -0
- package/dist/utils/child-wrapper-exit.js +9 -0
- package/dist/utils/child-wrapper-lease.d.ts +5 -0
- package/dist/utils/child-wrapper-lease.js +18 -0
- package/dist/utils/resident-package-preload.d.ts +1 -1
- package/dist/utils/resident-package-preload.js +22 -1
- package/dist/utils/vault-api.js +23 -10
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.342.10] — 2026-10-04
|
|
6
|
+
- Attach bounded status, gateway error-class, and integrations subcommand tags to captured gateway faults.
|
|
7
|
+
|
|
8
|
+
## [5.342.9] — 2026-10-04
|
|
9
|
+
- Child wrappers now release their package-use lease after startup modules are preloaded, so long-running child processes do not block CLI updates.
|
|
10
|
+
- Keep nested HQ child leases and materialize core-job post-exit helpers before releasing the parent package lease.
|
|
11
|
+
- Run the core migration differential check on pull requests only when migration, build, or differential inputs change, while retaining it on main pushes.
|
|
12
|
+
- Reduce startup work on the fast `hq secrets env` route while keeping the server authorization request on every call.
|
|
13
|
+
|
|
5
14
|
## [5.342.8] — 2026-10-04
|
|
6
15
|
- Keep blocker escalation failures visible on the lane and stop retrying the same failed fallback every watcher turn.
|
|
7
16
|
- Release the daemon's hq-cli package-use lease after all reachable lazy runtime modules preload successfully, allowing CLI updates while preserving the existing restart-on-update path.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export interface CoreJobPostExitHelpers {
|
|
2
|
+
notifyBin: string;
|
|
3
|
+
remediateBin: string;
|
|
4
|
+
cleanup(): void;
|
|
5
|
+
}
|
|
6
|
+
/** Copy package-backed helpers before the runner releases its package-use lease. */
|
|
7
|
+
export declare function materializeCoreJobPostExitHelpers(scriptPath: string, env: NodeJS.ProcessEnv): CoreJobPostExitHelpers;
|
|
8
|
+
//# sourceMappingURL=core-job-post-exit-assets.d.ts.map
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as os from "node:os";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
/** Copy package-backed helpers before the runner releases its package-use lease. */
|
|
5
|
+
export function materializeCoreJobPostExitHelpers(scriptPath, env) {
|
|
6
|
+
const sourceDir = path.dirname(scriptPath);
|
|
7
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-job-post-exit-"));
|
|
8
|
+
const materialize = (name, override) => {
|
|
9
|
+
if (override)
|
|
10
|
+
return override;
|
|
11
|
+
const source = path.join(sourceDir, name);
|
|
12
|
+
const target = path.join(tempDir, name);
|
|
13
|
+
fs.copyFileSync(source, target);
|
|
14
|
+
fs.chmodSync(target, fs.statSync(source).mode & 0o777);
|
|
15
|
+
return target;
|
|
16
|
+
};
|
|
17
|
+
try {
|
|
18
|
+
const notifyBin = materialize("hq-job-notify.sh", env.HQ_JOB_NOTIFY_BIN);
|
|
19
|
+
const remediateBin = materialize("hq-job-remediate.sh", env.HQ_JOB_REMEDIATE_BIN);
|
|
20
|
+
let cleaned = false;
|
|
21
|
+
return {
|
|
22
|
+
notifyBin,
|
|
23
|
+
remediateBin,
|
|
24
|
+
cleanup() {
|
|
25
|
+
if (cleaned)
|
|
26
|
+
return;
|
|
27
|
+
cleaned = true;
|
|
28
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
34
|
+
throw error;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
//# sourceMappingURL=core-job-post-exit-assets.js.map
|
|
@@ -9,6 +9,8 @@ import { resolveLiveRoot, resolveBundledAsset } from "../utils/hq-roots.js";
|
|
|
9
9
|
import { runBundledScript } from "../utils/run-bundled-script.js";
|
|
10
10
|
import { describeCoreCommand } from "./core-command-catalog.js";
|
|
11
11
|
import { coreNativeJobsEnabled } from "./core-native-jobs-flag.js";
|
|
12
|
+
import { releaseChildWrapperPackageUseLeaseAfterStart } from "../utils/child-wrapper-lease.js";
|
|
13
|
+
import { materializeCoreJobPostExitHelpers } from "./core-job-post-exit-assets.js";
|
|
12
14
|
const RUN_ASSET = "core/scripts/hq-job-run.sh";
|
|
13
15
|
const RUNNER_NAME = "hq-job-run";
|
|
14
16
|
export const OUTPOST_IDENTITY_FILE = "/etc/outpost/codex-identity.env";
|
|
@@ -152,6 +154,8 @@ export async function spawnProcess(request) {
|
|
|
152
154
|
if (exitFile)
|
|
153
155
|
fs.writeFileSync(exitFile, `EXIT=${request.env.HQ_JOB_RUN_STUB_EXIT_CODE ?? "0"}\n`);
|
|
154
156
|
}
|
|
157
|
+
if (nestedAgent || (request.file === "tmux" && request.args[0] === "new-session"))
|
|
158
|
+
request.onChildStart?.();
|
|
155
159
|
const diagnostic = request.env.HQ_JOB_RUN_STUB_DIAGNOSTIC || "";
|
|
156
160
|
if (nestedAgent && request.logPath && diagnostic)
|
|
157
161
|
appendLog(request.logPath, `${diagnostic}\n`);
|
|
@@ -179,6 +183,8 @@ export async function spawnProcess(request) {
|
|
|
179
183
|
stdio: ["ignore", "pipe", "pipe"],
|
|
180
184
|
detached: processGroup,
|
|
181
185
|
});
|
|
186
|
+
if (request.onChildStart)
|
|
187
|
+
child.once("spawn", request.onChildStart);
|
|
182
188
|
child.once("error", reject);
|
|
183
189
|
child.stdout.on("data", (chunk) => {
|
|
184
190
|
if (request.logPath) {
|
|
@@ -685,8 +691,8 @@ async function ingestRunStatus(jobId, runAt, runId, exit, failureClass, actions,
|
|
|
685
691
|
emitLog(`run status ingest failed job=${jobId} http=${status} (logged; not failing run)`);
|
|
686
692
|
void logFile;
|
|
687
693
|
}
|
|
688
|
-
async function runHelper(binary, args, cwd, env, deps, logPath, timeoutMs) {
|
|
689
|
-
return await deps.runProcess({ file: binary, args, cwd, env, ...(logPath ? { logPath } : {}), ...(timeoutMs ? { timeoutMs } : {}) });
|
|
694
|
+
async function runHelper(binary, args, cwd, env, deps, logPath, timeoutMs, onChildStart) {
|
|
695
|
+
return await deps.runProcess({ file: binary, args, cwd, env, ...(logPath ? { logPath } : {}), ...(timeoutMs ? { timeoutMs } : {}), ...(onChildStart ? { onChildStart } : {}) });
|
|
690
696
|
}
|
|
691
697
|
async function callNotify(jobId, jobName, outcome, summary, duration, logPath, failureClass, nextAction, owner, notifyMode, hqRoot, notifyBin, deps) {
|
|
692
698
|
if (deps.env.HQ_JOB_RUN_NO_NOTIFY === "1") {
|
|
@@ -876,7 +882,7 @@ function bashPrintfQuote(value) {
|
|
|
876
882
|
}
|
|
877
883
|
return [...value].map((character) => safe.test(character) ? character : `\\${character}`).join("");
|
|
878
884
|
}
|
|
879
|
-
async function runRemoteClaude(jobId, runtime, prompt, runCwd, timeoutSeconds, stamp, logDir, logFile, deps) {
|
|
885
|
+
async function runRemoteClaude(jobId, runtime, prompt, runCwd, timeoutSeconds, stamp, logDir, logFile, deps, onChildStart) {
|
|
880
886
|
if (!pathExecutable("tmux", deps.env) && deps.env.HQ_JOB_RUN_STUBS !== "1") {
|
|
881
887
|
deps.appendFile(logFile, "remote surface requested but tmux missing — falling back to headless\n");
|
|
882
888
|
return 99;
|
|
@@ -900,7 +906,7 @@ async function runRemoteClaude(jobId, runtime, prompt, runCwd, timeoutSeconds, s
|
|
|
900
906
|
const tmuxEnv = deps.env.HQ_JOB_RUN_STUBS === "1"
|
|
901
907
|
? { ...deps.env, HQ_JOB_RUN_REMOTE_DONE_FILE: doneFile, HQ_JOB_RUN_REMOTE_EXIT_FILE: exitFile }
|
|
902
908
|
: deps.env;
|
|
903
|
-
const runTmux = async (args) => await runHelper("tmux", args, deps.cwd, tmuxEnv, deps);
|
|
909
|
+
const runTmux = async (args) => await runHelper("tmux", args, deps.cwd, tmuxEnv, deps, undefined, undefined, args[0] === "new-session" ? onChildStart : undefined);
|
|
904
910
|
await runTmux(["kill-session", "-t", session]);
|
|
905
911
|
await runTmux(["new-session", "-d", "-s", session, "-x", "120", "-y", "40", "-c", runCwd, tmuxCommand]);
|
|
906
912
|
let elapsedSeconds = 0;
|
|
@@ -945,13 +951,16 @@ async function runRemoteClaude(jobId, runtime, prompt, runCwd, timeoutSeconds, s
|
|
|
945
951
|
await runTmux(["kill-session", "-t", session]);
|
|
946
952
|
return code;
|
|
947
953
|
}
|
|
948
|
-
async function runAgent(runtime, surface, prompt, runCwd, timeoutSeconds, secretNames, company, logFile, stamp, jobId, logDir, deps) {
|
|
954
|
+
async function runAgent(runtime, surface, prompt, runCwd, timeoutSeconds, secretNames, company, logFile, stamp, jobId, logDir, deps, canReleasePackageLease = true) {
|
|
949
955
|
if (deps.env.HQ_JOB_RUN_SKIP_EXEC === "1") {
|
|
950
956
|
deps.appendFile(logFile, "(HQ_JOB_RUN_SKIP_EXEC=1 — not invoking runtime CLI)\n");
|
|
951
957
|
return 0;
|
|
952
958
|
}
|
|
953
959
|
if (runtime === "claude" && surface === "remote") {
|
|
954
|
-
const remoteCode = await runRemoteClaude(jobId, runtime, prompt, runCwd, timeoutSeconds, stamp, logDir, logFile, deps)
|
|
960
|
+
const remoteCode = await runRemoteClaude(jobId, runtime, prompt, runCwd, timeoutSeconds, stamp, logDir, logFile, deps, () => {
|
|
961
|
+
if (canReleasePackageLease)
|
|
962
|
+
releaseChildWrapperPackageUseLeaseAfterStart("coreJobRun");
|
|
963
|
+
});
|
|
955
964
|
if (remoteCode !== 99)
|
|
956
965
|
return remoteCode;
|
|
957
966
|
}
|
|
@@ -979,7 +988,10 @@ async function runAgent(runtime, surface, prompt, runCwd, timeoutSeconds, secret
|
|
|
979
988
|
else if (secretNames.length > 0) {
|
|
980
989
|
deps.appendFile(logFile, "warning: requirements.secrets set but hq CLI missing — running without secrets exec\n");
|
|
981
990
|
}
|
|
982
|
-
const result = await runHelper(file, args, runCwd, deps.env, deps, logFile, timeoutMs)
|
|
991
|
+
const result = await runHelper(file, args, runCwd, deps.env, deps, logFile, timeoutMs, () => {
|
|
992
|
+
if (canReleasePackageLease)
|
|
993
|
+
releaseChildWrapperPackageUseLeaseAfterStart("coreJobRun", file === "hq");
|
|
994
|
+
});
|
|
983
995
|
return result.code;
|
|
984
996
|
}
|
|
985
997
|
async function recordSkip(jobId, jobName, runtime, skipReason, failureClass, action, statusSuffix, notifyOutcome, owner, notifyMode, company, hqRoot, logFile, statusFile, startedAt, startedEpoch, logDir, stamp, notifyBin, remediateBin, deps, authStatus = "") {
|
|
@@ -1089,12 +1101,19 @@ export async function runJob(argv, hqRootOverride, injected) {
|
|
|
1089
1101
|
catch (error) {
|
|
1090
1102
|
return emitDie(`failed to acquire global run lock: ${lockPath}${error instanceof Error && error.message.includes("flock") ? ` (${error.message})` : ""}`);
|
|
1091
1103
|
}
|
|
1104
|
+
let postExitHelpers = null;
|
|
1092
1105
|
try {
|
|
1093
1106
|
const startedAt = isoNow(deps);
|
|
1094
1107
|
const startedEpoch = deps.epochNow();
|
|
1095
1108
|
deps.appendFile(logFile, `=== hq-job-run ${jobId} ===\nstarted_at=${startedAt}\nruntime=${runtime}\ntimeout_seconds=${timeoutSeconds}\nsurface=${surface}\njob_file=${jobFile}\nhq_root=${hqRoot}\n`);
|
|
1096
|
-
|
|
1097
|
-
|
|
1109
|
+
try {
|
|
1110
|
+
postExitHelpers = materializeCoreJobPostExitHelpers(deps.scriptPath, env);
|
|
1111
|
+
}
|
|
1112
|
+
catch (error) {
|
|
1113
|
+
deps.appendFile(logFile, `post_exit_helpers_unavailable=${error instanceof Error ? error.name : "Error"}; package lease retained\n`);
|
|
1114
|
+
}
|
|
1115
|
+
const notifyBin = postExitHelpers?.notifyBin ?? env.HQ_JOB_NOTIFY_BIN ?? path.join(path.dirname(deps.scriptPath), "hq-job-notify.sh");
|
|
1116
|
+
const remediateBin = postExitHelpers?.remediateBin ?? env.HQ_JOB_REMEDIATE_BIN ?? path.join(path.dirname(deps.scriptPath), "hq-job-remediate.sh");
|
|
1098
1117
|
const authStatus = readAuthStatus(runtime, env, deps.home, deps);
|
|
1099
1118
|
if (authStatus !== "ok") {
|
|
1100
1119
|
const reason = authStatus === "no_cli"
|
|
@@ -1116,7 +1135,7 @@ export async function runJob(argv, hqRootOverride, injected) {
|
|
|
1116
1135
|
return emitDie(error instanceof Error ? error.message : String(error));
|
|
1117
1136
|
}
|
|
1118
1137
|
const secretNames = jobSecretNames(job);
|
|
1119
|
-
let exitCode = await runAgent(runtime, surface, prompt, runCwd, timeoutSeconds, secretNames, company, logFile, stamp, jobId, logDir, deps);
|
|
1138
|
+
let exitCode = await runAgent(runtime, surface, prompt, runCwd, timeoutSeconds, secretNames, company, logFile, stamp, jobId, logDir, deps, postExitHelpers !== null);
|
|
1120
1139
|
let failureClass = "";
|
|
1121
1140
|
let nextAction = "";
|
|
1122
1141
|
let retried = 0;
|
|
@@ -1138,7 +1157,7 @@ export async function runJob(argv, hqRootOverride, injected) {
|
|
|
1138
1157
|
retried = 1;
|
|
1139
1158
|
deps.appendFile(logFile, "remediate_retry=1\n");
|
|
1140
1159
|
emitLog(`self-remediate recommended retry for ${failureClass} — retrying once`);
|
|
1141
|
-
exitCode = await runAgent(runtime, surface, prompt, runCwd, timeoutSeconds, secretNames, company, logFile, stamp, jobId, logDir, deps);
|
|
1160
|
+
exitCode = await runAgent(runtime, surface, prompt, runCwd, timeoutSeconds, secretNames, company, logFile, stamp, jobId, logDir, deps, postExitHelpers !== null);
|
|
1142
1161
|
if (exitCode !== 0) {
|
|
1143
1162
|
failureClass = classifyFailure(exitCode, logFile, "", deps);
|
|
1144
1163
|
deps.appendFile(logFile, `failure_class_after_retry=${failureClass}\n`);
|
|
@@ -1172,6 +1191,7 @@ export async function runJob(argv, hqRootOverride, injected) {
|
|
|
1172
1191
|
}
|
|
1173
1192
|
finally {
|
|
1174
1193
|
await lease.release();
|
|
1194
|
+
postExitHelpers?.cleanup();
|
|
1175
1195
|
}
|
|
1176
1196
|
}
|
|
1177
1197
|
export function registerCoreJobRunCommand(job) {
|
|
@@ -150,6 +150,8 @@ export declare class IntegrationsCliError extends Error {
|
|
|
150
150
|
* fingerprint as anything but the finite allowlist in sentry-fingerprint.ts.
|
|
151
151
|
*/
|
|
152
152
|
readonly rpcCode?: number;
|
|
153
|
+
/** Fixed vocabulary for the integrations subcommand that called the gateway. */
|
|
154
|
+
readonly gatewaySubcommand?: "tools_list" | "tools_call" | "other";
|
|
153
155
|
/**
|
|
154
156
|
* HQ's own OAuth callback URL, carried on the errors that say a provider
|
|
155
157
|
* refused to register HQ automatically. The provider must register the exact
|
|
@@ -164,6 +166,7 @@ export declare class IntegrationsCliError extends Error {
|
|
|
164
166
|
status?: number;
|
|
165
167
|
oauthProtected?: boolean;
|
|
166
168
|
rpcCode?: number;
|
|
169
|
+
gatewaySubcommand?: "tools_list" | "tools_call" | "other";
|
|
167
170
|
redirectUri?: string;
|
|
168
171
|
});
|
|
169
172
|
}
|
|
@@ -55,6 +55,8 @@ export class IntegrationsCliError extends Error {
|
|
|
55
55
|
* fingerprint as anything but the finite allowlist in sentry-fingerprint.ts.
|
|
56
56
|
*/
|
|
57
57
|
rpcCode;
|
|
58
|
+
/** Fixed vocabulary for the integrations subcommand that called the gateway. */
|
|
59
|
+
gatewaySubcommand;
|
|
58
60
|
/**
|
|
59
61
|
* HQ's own OAuth callback URL, carried on the errors that say a provider
|
|
60
62
|
* refused to register HQ automatically. The provider must register the exact
|
|
@@ -75,6 +77,8 @@ export class IntegrationsCliError extends Error {
|
|
|
75
77
|
this.oauthProtected = opts.oauthProtected;
|
|
76
78
|
if (opts.rpcCode !== undefined)
|
|
77
79
|
this.rpcCode = opts.rpcCode;
|
|
80
|
+
if (opts.gatewaySubcommand !== undefined)
|
|
81
|
+
this.gatewaySubcommand = opts.gatewaySubcommand;
|
|
78
82
|
if (opts.redirectUri !== undefined) {
|
|
79
83
|
Object.defineProperty(this, "redirectUri", {
|
|
80
84
|
value: opts.redirectUri,
|
|
@@ -175,6 +179,18 @@ const EXPECTED_GATEWAY_ERROR_CODES = new Set([-32003, -32602, -32050]);
|
|
|
175
179
|
function isExpectedGatewayError(code) {
|
|
176
180
|
return code != null && EXPECTED_GATEWAY_ERROR_CODES.has(code);
|
|
177
181
|
}
|
|
182
|
+
function expectedGatewayErrorFallback(code) {
|
|
183
|
+
switch (code) {
|
|
184
|
+
case -32003:
|
|
185
|
+
return "You do not have access to this integration. Ask its owner to share it with you.";
|
|
186
|
+
case -32602:
|
|
187
|
+
return "The integration rejected this request. Check the tool name and arguments.";
|
|
188
|
+
case -32050:
|
|
189
|
+
return "The connected provider could not complete this request. Wait a moment and try again.";
|
|
190
|
+
default:
|
|
191
|
+
return undefined;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
178
194
|
// A 401 from ANY integration-gateway vault call means the caller's HQ session
|
|
179
195
|
// is expired or missing — an expected auth state fixed by `hq login`, not an
|
|
180
196
|
// hq-cli defect. Raise the same typed AuthError the vault company-resolution
|
|
@@ -399,7 +415,18 @@ export async function resolveConnection(token, companyUid, app, opts, recoveryOp
|
|
|
399
415
|
}
|
|
400
416
|
return selectConnection(connections, opts);
|
|
401
417
|
}
|
|
418
|
+
function gatewaySubcommandFor(params) {
|
|
419
|
+
const name = params.name;
|
|
420
|
+
if (typeof name !== "string")
|
|
421
|
+
return "other";
|
|
422
|
+
if (name.endsWith(".mcp.tools.list"))
|
|
423
|
+
return "tools_list";
|
|
424
|
+
if (name.endsWith(".mcp.tools.call"))
|
|
425
|
+
return "tools_call";
|
|
426
|
+
return "other";
|
|
427
|
+
}
|
|
402
428
|
export async function callGateway(token, params, idempotencyKey) {
|
|
429
|
+
const gatewaySubcommand = gatewaySubcommandFor(params);
|
|
403
430
|
const deadline = Date.now() + ASYNC_GATEWAY_DEADLINE_MS;
|
|
404
431
|
const res = await vaultApiFetch({
|
|
405
432
|
token,
|
|
@@ -414,6 +441,7 @@ export async function callGateway(token, params, idempotencyKey) {
|
|
|
414
441
|
});
|
|
415
442
|
const message = (await res.json().catch(() => null));
|
|
416
443
|
let terminalMessage = message;
|
|
444
|
+
let terminalStatus = res.status;
|
|
417
445
|
if (res.status === 202) {
|
|
418
446
|
const accepted = message;
|
|
419
447
|
const jobId = accepted && typeof accepted.jobId === "string" ? accepted.jobId : "";
|
|
@@ -427,7 +455,9 @@ export async function callGateway(token, params, idempotencyKey) {
|
|
|
427
455
|
if (!idempotencyKey) {
|
|
428
456
|
throw new IntegrationsCliError(`Integration gateway accepted async job ${jobId} without an idempotency key.`, { expected: false, status: 202 });
|
|
429
457
|
}
|
|
430
|
-
|
|
458
|
+
const terminal = await pollAsyncGatewayJob(token, companyUid, jobId, idempotencyKey, deadline);
|
|
459
|
+
terminalMessage = terminal.message;
|
|
460
|
+
terminalStatus = terminal.status;
|
|
431
461
|
}
|
|
432
462
|
if (res.status !== 202 && (!res.ok || !terminalMessage)) {
|
|
433
463
|
raiseIfUnauthorized(res);
|
|
@@ -437,7 +467,7 @@ export async function callGateway(token, params, idempotencyKey) {
|
|
|
437
467
|
// reaches the Sentry fingerprint's HTTP bucket instead of colliding under
|
|
438
468
|
// default grouping. Same reasoning as raiseForResponse, which already sets
|
|
439
469
|
// `status`. This does not change the `expected` decision above.
|
|
440
|
-
throw new IntegrationsCliError(`Integration gateway request failed (HTTP ${res.status}).`, { expected: isClientError(res.status), status: res.status });
|
|
470
|
+
throw new IntegrationsCliError(`Integration gateway request failed (HTTP ${res.status}).`, { expected: isClientError(res.status), status: res.status, gatewaySubcommand });
|
|
441
471
|
}
|
|
442
472
|
if (terminalMessage?.error) {
|
|
443
473
|
// The gateway's error text is minted UPSTREAM (hq-pro, the integration
|
|
@@ -449,8 +479,11 @@ export async function callGateway(token, params, idempotencyKey) {
|
|
|
449
479
|
// no-op. This preserves PR #298's user-visible diagnostic; it only makes
|
|
450
480
|
// it safe on the newly-expected path.
|
|
451
481
|
throw new IntegrationsCliError(redactErrorText(terminalMessage.error.message ?? "") ||
|
|
482
|
+
expectedGatewayErrorFallback(terminalMessage.error.code) ||
|
|
452
483
|
"Integration gateway returned an error.", {
|
|
453
484
|
expected: isExpectedGatewayError(terminalMessage.error.code),
|
|
485
|
+
status: terminalStatus,
|
|
486
|
+
gatewaySubcommand,
|
|
454
487
|
// Carry the protocol code to the capture site as a bounded grouping
|
|
455
488
|
// discriminator (HQ-CLI collision, Sentry 7642756130). This does not
|
|
456
489
|
// alter the `expected` decision above; it only labels the error.
|
|
@@ -518,7 +551,7 @@ async function pollAsyncGatewayJob(token, companyUid, jobId, idempotencyKey, dea
|
|
|
518
551
|
if (!hasJsonRpcOutcome) {
|
|
519
552
|
throw new IntegrationsCliError(`Integration job ${jobId} returned an invalid JSON-RPC response.`, { expected: false });
|
|
520
553
|
}
|
|
521
|
-
return response;
|
|
554
|
+
return { message: response, status: res.status };
|
|
522
555
|
}
|
|
523
556
|
if (poll.status !== "pending") {
|
|
524
557
|
throw new IntegrationsCliError(`Integration job ${jobId} returned unknown status '${String(poll.status)}'.`, { expected: true });
|
package/dist/commands/run.js
CHANGED
|
@@ -12,6 +12,8 @@ import { installHqPlugin, prewarmHqSecrets } from '../run/hq-plugin.js';
|
|
|
12
12
|
import { assertEnvGraphLoaded, resolveEnvValuesOrThrow, EnvGraphLoadError, EnvResolutionError, } from '../run/env-graph-guard.js';
|
|
13
13
|
import { hadFatalRejection } from '../unhandled-rejection-boundary.js';
|
|
14
14
|
import { withRunSecretReadTelemetry } from './run-feature-telemetry.js';
|
|
15
|
+
import { isHqCliChild, releaseChildWrapperPackageUseLeaseAfterStart } from '../utils/child-wrapper-lease.js';
|
|
16
|
+
import { runChildWrapperExitCode } from '../utils/child-wrapper-exit.js';
|
|
15
17
|
const SECRET_LOAD_TIMEOUT_MS = 30_000;
|
|
16
18
|
export async function buildRunUsage(scriptPath, scriptId) {
|
|
17
19
|
if (scriptId && !scriptPath) {
|
|
@@ -165,6 +167,7 @@ export function registerRunCommand(program) {
|
|
|
165
167
|
stdio: 'inherit',
|
|
166
168
|
env: { ...process.env, ...resolvedEnv },
|
|
167
169
|
});
|
|
170
|
+
child.once('spawn', () => releaseChildWrapperPackageUseLeaseAfterStart("childWrapper", isHqCliChild(childCmd, restArgs)));
|
|
168
171
|
child.on('error', (err) => {
|
|
169
172
|
process.stderr.write(`Error: failed to start command '${childCmd}': ${err.message}\n`);
|
|
170
173
|
process.exit(1);
|
|
@@ -177,7 +180,7 @@ export function registerRunCommand(program) {
|
|
|
177
180
|
// which this explicit exit would otherwise erase. Force a non-zero
|
|
178
181
|
// code so such a rejection is never masked by a child that exited 0,
|
|
179
182
|
// while still propagating a genuine non-zero child status.
|
|
180
|
-
process.exit(
|
|
183
|
+
process.exit(runChildWrapperExitCode(code, hadFatalRejection()));
|
|
181
184
|
});
|
|
182
185
|
}
|
|
183
186
|
catch (err) {
|
package/dist/commands/secrets.js
CHANGED
|
@@ -3,6 +3,8 @@ export { buildSecretUsage, emitEnvExportsAndExit, loadRevealedSecrets, parseSecr
|
|
|
3
3
|
import chalk from "chalk";
|
|
4
4
|
import { ensureCognitoToken } from "../utils/cognito-session.js";
|
|
5
5
|
import { removeCacheEntry, clearAllCache } from "../utils/secrets-cache.js";
|
|
6
|
+
import { isHqCliChild, releaseChildWrapperPackageUseLeaseAfterStart } from "../utils/child-wrapper-lease.js";
|
|
7
|
+
import { secretsExecChildExitCode } from "../utils/child-wrapper-exit.js";
|
|
6
8
|
import { isPlanGateError } from "../utils/plan-gate-error.js";
|
|
7
9
|
import { SECRET_NAME_PATTERN, GROUP_ID_PATTERN, EMAIL_PATTERN } from "./_patterns.js";
|
|
8
10
|
import { describeSecretsScope, formatSecretSaved, formatSecretsListEmpty, formatSecretsListHeader, } from "./secrets-scope.js";
|
|
@@ -1136,6 +1138,7 @@ export function registerSecretsCommand(program) {
|
|
|
1136
1138
|
stdio: "inherit",
|
|
1137
1139
|
env: { ...process.env, ...secretEnv },
|
|
1138
1140
|
});
|
|
1141
|
+
child.once("spawn", () => releaseChildWrapperPackageUseLeaseAfterStart("childWrapper", isHqCliChild(childCmd, childCmdArgs)));
|
|
1139
1142
|
child.on("error", (err) => {
|
|
1140
1143
|
console.error(chalk.red(`Failed to start command '${childCmd}': ${err.message}`));
|
|
1141
1144
|
process.exit(1);
|
|
@@ -1144,7 +1147,7 @@ export function registerSecretsCommand(program) {
|
|
|
1144
1147
|
if (signal) {
|
|
1145
1148
|
process.kill(process.pid, signal);
|
|
1146
1149
|
}
|
|
1147
|
-
process.exit(code
|
|
1150
|
+
process.exit(secretsExecChildExitCode(code));
|
|
1148
1151
|
});
|
|
1149
1152
|
}
|
|
1150
1153
|
catch (err) {
|
package/dist/main.js
CHANGED
|
@@ -335,6 +335,39 @@ function qmdSpawnFailureCaptureContext(err) {
|
|
|
335
335
|
return undefined;
|
|
336
336
|
return { contexts: { qmd_spawn_failure: diagnostics } };
|
|
337
337
|
}
|
|
338
|
+
const GATEWAY_RPC_CODE_TAGS = new Map([
|
|
339
|
+
[-32603, "internal_error"],
|
|
340
|
+
[-32009, "conflict"],
|
|
341
|
+
[-32003, "unauthorized"],
|
|
342
|
+
[-32602, "invalid_params"],
|
|
343
|
+
[-32050, "provider_error"],
|
|
344
|
+
]);
|
|
345
|
+
function integrationsGatewayCaptureContext(err) {
|
|
346
|
+
if (err === null || typeof err !== "object")
|
|
347
|
+
return undefined;
|
|
348
|
+
const record = err;
|
|
349
|
+
const subcommand = record.gatewaySubcommand;
|
|
350
|
+
if (subcommand === undefined)
|
|
351
|
+
return undefined;
|
|
352
|
+
const subcommandTag = subcommand === "tools_list" || subcommand === "tools_call"
|
|
353
|
+
? subcommand
|
|
354
|
+
: "other";
|
|
355
|
+
const status = record.status;
|
|
356
|
+
const statusTag = typeof status === "number" && Number.isInteger(status) && status >= 100 && status <= 599
|
|
357
|
+
? String(status)
|
|
358
|
+
: status === undefined ? "unknown" : "other";
|
|
359
|
+
const rpcCode = record.rpcCode;
|
|
360
|
+
const codeTag = typeof rpcCode === "number" && Number.isInteger(rpcCode)
|
|
361
|
+
? GATEWAY_RPC_CODE_TAGS.get(rpcCode) ?? "other"
|
|
362
|
+
: rpcCode === undefined ? "missing" : "other";
|
|
363
|
+
return {
|
|
364
|
+
tags: {
|
|
365
|
+
integration_gateway_status: statusTag,
|
|
366
|
+
integration_gateway_code: codeTag,
|
|
367
|
+
integration_subcommand: subcommandTag,
|
|
368
|
+
},
|
|
369
|
+
};
|
|
370
|
+
}
|
|
338
371
|
/** Classify a top-level failure without making the CLI process boundary opaque to tests. */
|
|
339
372
|
export async function handleTopLevelError(err, deps = defaultTopLevelErrorDependencies) {
|
|
340
373
|
// A typed plan gate, or an hq-cloud vault error carrying a plan-limit
|
|
@@ -853,9 +886,11 @@ export async function handleTopLevelError(err, deps = defaultTopLevelErrorDepend
|
|
|
853
886
|
const spawnContext = qmdSpawnFailureCaptureContext(err);
|
|
854
887
|
const installContext = incompleteInstallCaptureContext(err);
|
|
855
888
|
const qmdContext = qmdExitCaptureContext(err);
|
|
856
|
-
|
|
889
|
+
const gatewayContext = integrationsGatewayCaptureContext(err);
|
|
890
|
+
if (spawnContext || installContext || qmdContext || gatewayContext) {
|
|
857
891
|
deps.sentry.captureException(err, {
|
|
858
892
|
...qmdContext,
|
|
893
|
+
...(gatewayContext ? { tags: gatewayContext.tags } : {}),
|
|
859
894
|
...((spawnContext || installContext)
|
|
860
895
|
? { contexts: { ...spawnContext?.contexts, ...installContext } }
|
|
861
896
|
: {}),
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/** Preserve the existing hq run child-exit policy for use by the close handler. */
|
|
2
|
+
export declare function runChildWrapperExitCode(code: number | null, fatalRejection: boolean): number;
|
|
3
|
+
/** Preserve hq secrets exec's direct child exit-code propagation. */
|
|
4
|
+
export declare function secretsExecChildExitCode(code: number | null): number;
|
|
5
|
+
//# sourceMappingURL=child-wrapper-exit.d.ts.map
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** Preserve the existing hq run child-exit policy for use by the close handler. */
|
|
2
|
+
export function runChildWrapperExitCode(code, fatalRejection) {
|
|
3
|
+
return fatalRejection ? (code || 1) : (code ?? 1);
|
|
4
|
+
}
|
|
5
|
+
/** Preserve hq secrets exec's direct child exit-code propagation. */
|
|
6
|
+
export function secretsExecChildExitCode(code) {
|
|
7
|
+
return code ?? 1;
|
|
8
|
+
}
|
|
9
|
+
//# sourceMappingURL=child-wrapper-exit.js.map
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { type ResidentPackagePath } from "./resident-package-preload.js";
|
|
2
|
+
export declare function isHqCliChild(command: string, args?: readonly string[]): boolean;
|
|
3
|
+
/** A child wrapper can release its own lease only after its child has started. */
|
|
4
|
+
export declare function releaseChildWrapperPackageUseLeaseAfterStart(residentPath: ResidentPackagePath, childIsHqCli?: boolean): void;
|
|
5
|
+
//# sourceMappingURL=child-wrapper-lease.d.ts.map
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { releaseCurrentPackageUseLease, releasePackageUseLeaseAfterResidentPreload } from "./package-use-lease.js";
|
|
2
|
+
import { preloadResidentPackageModules, reportResidentPackagePreloadFailure } from "./resident-package-preload.js";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
export function isHqCliChild(command, args = []) {
|
|
5
|
+
const base = path.basename(command).toLowerCase();
|
|
6
|
+
if (base === "hq" || base === "hq.exe" || base === "hq.cmd" || base === "hq.bat")
|
|
7
|
+
return true;
|
|
8
|
+
return /(?:^|[\\/])(?:dist[\\/]bin[\\/]hq|hq)\.(?:c|m)?js$/i.test(args[0] ?? "");
|
|
9
|
+
}
|
|
10
|
+
/** A child wrapper can release its own lease only after its child has started. */
|
|
11
|
+
export function releaseChildWrapperPackageUseLeaseAfterStart(residentPath, childIsHqCli = false) {
|
|
12
|
+
// Nested HQ commands must acquire their own lease before an updater can
|
|
13
|
+
// replace the tree. Keep this process's lease through that child's lifetime.
|
|
14
|
+
if (childIsHqCli)
|
|
15
|
+
return;
|
|
16
|
+
void releasePackageUseLeaseAfterResidentPreload(() => preloadResidentPackageModules(residentPath), reportResidentPackagePreloadFailure, releaseCurrentPackageUseLease);
|
|
17
|
+
}
|
|
18
|
+
//# sourceMappingURL=child-wrapper-lease.js.map
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type ResidentPackagePath = "bot" | "mcp" | "daemon";
|
|
1
|
+
export type ResidentPackagePath = "bot" | "mcp" | "daemon" | "childWrapper" | "coreJobRun";
|
|
2
2
|
/** Package imports reachable lazily from each resident command's runtime graph. */
|
|
3
3
|
export declare const RESIDENT_PACKAGE_MODULES: Record<ResidentPackagePath, readonly string[]>;
|
|
4
4
|
export declare class ResidentPackagePreloadError extends Error {
|
|
@@ -1,10 +1,21 @@
|
|
|
1
1
|
/** Package imports reachable lazily from each resident command's runtime graph. */
|
|
2
2
|
export const RESIDENT_PACKAGE_MODULES = {
|
|
3
|
-
bot: [
|
|
3
|
+
bot: [
|
|
4
|
+
"../lib/flag-registry.js",
|
|
5
|
+
"../commands/access.js",
|
|
6
|
+
"../sentry.js",
|
|
7
|
+
"../lib/plan-limit-nag.js",
|
|
8
|
+
"../utils/plan-gate-error.js",
|
|
9
|
+
"open",
|
|
10
|
+
],
|
|
4
11
|
mcp: ["../lib/mcp/tools.js", "../lib/mcp/prompts.js"],
|
|
12
|
+
childWrapper: ["../main.js"],
|
|
13
|
+
coreJobRun: ["../lib/flag-registry.js"],
|
|
5
14
|
daemon: [
|
|
6
15
|
"../sentry.js",
|
|
7
16
|
"../utils/cognito-session.js",
|
|
17
|
+
"../lib/plan-limit-nag.js",
|
|
18
|
+
"../utils/plan-gate-error.js",
|
|
8
19
|
"../lib/daemon/mcp-oauth.js",
|
|
9
20
|
"@modelcontextprotocol/sdk/client/index.js",
|
|
10
21
|
"@modelcontextprotocol/sdk/client/streamableHttp.js",
|
|
@@ -20,18 +31,28 @@ const LOADERS = {
|
|
|
20
31
|
const { preloadSentrySdk } = await import("../sentry.js");
|
|
21
32
|
preloadSentrySdk();
|
|
22
33
|
},
|
|
34
|
+
() => import("../lib/plan-limit-nag.js"),
|
|
35
|
+
() => import("../utils/plan-gate-error.js"),
|
|
23
36
|
() => import("open"),
|
|
24
37
|
],
|
|
25
38
|
mcp: [
|
|
26
39
|
() => import("../lib/mcp/tools.js"),
|
|
27
40
|
() => import("../lib/mcp/prompts.js"),
|
|
28
41
|
],
|
|
42
|
+
childWrapper: [
|
|
43
|
+
() => import("../main.js"),
|
|
44
|
+
],
|
|
45
|
+
coreJobRun: [
|
|
46
|
+
() => import("../lib/flag-registry.js"),
|
|
47
|
+
],
|
|
29
48
|
daemon: [
|
|
30
49
|
async () => {
|
|
31
50
|
const { preloadSentrySdk } = await import("../sentry.js");
|
|
32
51
|
preloadSentrySdk();
|
|
33
52
|
},
|
|
34
53
|
() => import("../utils/cognito-session.js"),
|
|
54
|
+
() => import("../lib/plan-limit-nag.js"),
|
|
55
|
+
() => import("../utils/plan-gate-error.js"),
|
|
35
56
|
() => import("../lib/daemon/mcp-oauth.js"),
|
|
36
57
|
() => import("@modelcontextprotocol/sdk/client/index.js"),
|
|
37
58
|
() => import("@modelcontextprotocol/sdk/client/streamableHttp.js"),
|
package/dist/utils/vault-api.js
CHANGED
|
@@ -1,11 +1,8 @@
|
|
|
1
1
|
import { DEFAULT_VAULT_API_URL, cognitoCachePrincipalUid } from './cognito-session.js';
|
|
2
2
|
import { CLI_VERSION } from '../cli-version.js';
|
|
3
|
-
import { Sentry } from '../sentry.js';
|
|
4
3
|
import { AuthError } from './auth-error.js';
|
|
5
4
|
import { CompanySelectionError } from './company-selection-error.js';
|
|
6
|
-
import { recordPlanLimitStatus } from '../lib/plan-limit-nag.js';
|
|
7
5
|
import { networkTransportErrorCode } from './network-transport-error.js';
|
|
8
|
-
import { planGateErrorFromResponse } from './plan-gate-error.js';
|
|
9
6
|
import { markExpectedUserError } from './expected-cli-error.js';
|
|
10
7
|
import { redactErrorText } from './redact-error-text.js';
|
|
11
8
|
import { isSecretLoadUnavailable, isUnavailableSecretLoadHttpStatus, SecretLoadUnavailableError } from './secret-load-availability.js';
|
|
@@ -24,6 +21,10 @@ import { readCachedScopeUid, writeCachedScopeUid } from './secrets-cache.js';
|
|
|
24
21
|
*/
|
|
25
22
|
const IDENTITY_LOOKUP_TIMEOUT_MS = 15_000;
|
|
26
23
|
const LAMBDA_INVOKE_RETRY_MAX_DELAY_MS = 100;
|
|
24
|
+
async function addVaultBreadcrumb(breadcrumb) {
|
|
25
|
+
const { Sentry } = await import('../sentry.js');
|
|
26
|
+
Sentry.addBreadcrumb(breadcrumb);
|
|
27
|
+
}
|
|
27
28
|
function isLambdaInvokeServiceError(response, bodyText) {
|
|
28
29
|
if (response.status !== 504) {
|
|
29
30
|
return false;
|
|
@@ -128,7 +129,11 @@ async function peekPlanLimitStatus(response, options = {}) {
|
|
|
128
129
|
try {
|
|
129
130
|
const text = new TextDecoder().decode(buf);
|
|
130
131
|
try {
|
|
131
|
-
|
|
132
|
+
const parsed = JSON.parse(text);
|
|
133
|
+
if (hasPlanLimitStatusPayload(parsed)) {
|
|
134
|
+
const { recordPlanLimitStatus } = await import('../lib/plan-limit-nag.js');
|
|
135
|
+
recordPlanLimitStatus(parsed);
|
|
136
|
+
}
|
|
132
137
|
}
|
|
133
138
|
catch {
|
|
134
139
|
// Malformed JSON must be ignored silently.
|
|
@@ -162,6 +167,12 @@ async function peekPlanLimitStatus(response, options = {}) {
|
|
|
162
167
|
return response;
|
|
163
168
|
}
|
|
164
169
|
}
|
|
170
|
+
function hasPlanLimitStatusPayload(body) {
|
|
171
|
+
if (body === null || typeof body !== 'object' || Array.isArray(body)) {
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
return Object.hasOwn(body, 'planLimits') || Object.hasOwn(body, 'thresholdCrossings');
|
|
175
|
+
}
|
|
165
176
|
/**
|
|
166
177
|
* Map Cognito JWT paths onto parallel Bearer `hqk_` routes under `/v1/keys/…`.
|
|
167
178
|
* Returns null when no keyed route exists (caller must fail closed).
|
|
@@ -252,7 +263,7 @@ export async function vaultApiFetch(opts) {
|
|
|
252
263
|
const { path, url } = resolveVaultApiRequestTarget(opts);
|
|
253
264
|
const method = opts.method ?? 'GET';
|
|
254
265
|
const safeUrl = url.search ? `${url.origin}${url.pathname}?<redacted>` : `${url.origin}${url.pathname}`;
|
|
255
|
-
|
|
266
|
+
await addVaultBreadcrumb({
|
|
256
267
|
category: "http",
|
|
257
268
|
message: `${method} ${path}`,
|
|
258
269
|
level: "info",
|
|
@@ -282,7 +293,7 @@ export async function vaultApiFetch(opts) {
|
|
|
282
293
|
// Keep the original transport failure and its bounded, redacted trail on
|
|
283
294
|
// both the first request and the single retry.
|
|
284
295
|
const code = networkTransportErrorCode(err);
|
|
285
|
-
|
|
296
|
+
await addVaultBreadcrumb({
|
|
286
297
|
category: "http",
|
|
287
298
|
message: `${method} ${path} → transport error`,
|
|
288
299
|
level: "warning",
|
|
@@ -293,7 +304,7 @@ export async function vaultApiFetch(opts) {
|
|
|
293
304
|
};
|
|
294
305
|
const response = await fetchWithLambdaInvokeRetry(fetchWithTransportBreadcrumb, method.toUpperCase() === 'GET' && opts.body === undefined, opts.signal);
|
|
295
306
|
if (!response.ok) {
|
|
296
|
-
|
|
307
|
+
await addVaultBreadcrumb({
|
|
297
308
|
category: "http",
|
|
298
309
|
message: `${method} ${path} → ${response.status}`,
|
|
299
310
|
level: "warning",
|
|
@@ -308,7 +319,9 @@ export async function vaultApiFetch(opts) {
|
|
|
308
319
|
const companyUid = typeof opts.body?.companyUid === "string"
|
|
309
320
|
? opts.body.companyUid
|
|
310
321
|
: /\/(cmp_[^/?]+)/.exec(opts.path)?.[1];
|
|
311
|
-
const planGate =
|
|
322
|
+
const planGate = response.status === 402
|
|
323
|
+
? await (await import('./plan-gate-error.js')).planGateErrorFromResponse(response, companyUid)
|
|
324
|
+
: { error: null, response };
|
|
312
325
|
if (planGate.error)
|
|
313
326
|
throw planGate.error;
|
|
314
327
|
return planGate.response;
|
|
@@ -346,7 +359,7 @@ export async function vaultApiFetchPublic(opts) {
|
|
|
346
359
|
const safeUrl = url.search
|
|
347
360
|
? `${url.origin}${url.pathname}?<redacted>`
|
|
348
361
|
: `${url.origin}${url.pathname}`;
|
|
349
|
-
|
|
362
|
+
await addVaultBreadcrumb({
|
|
350
363
|
category: 'http',
|
|
351
364
|
message: `GET ${opts.path}`,
|
|
352
365
|
level: 'info',
|
|
@@ -357,7 +370,7 @@ export async function vaultApiFetchPublic(opts) {
|
|
|
357
370
|
headers: { 'Content-Type': 'application/json' },
|
|
358
371
|
}), true);
|
|
359
372
|
if (!response.ok) {
|
|
360
|
-
|
|
373
|
+
await addVaultBreadcrumb({
|
|
361
374
|
category: 'http',
|
|
362
375
|
message: `GET ${opts.path} → ${response.status}`,
|
|
363
376
|
level: 'warning',
|