@indigoai-us/hq-cli 5.94.2 → 5.95.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -0
- package/dist/commands/index-cmd.d.ts +1 -1
- package/dist/commands/index-cmd.js +2 -2
- package/dist/commands/integrations.js +79 -6
- package/dist/lib/search-index/background.d.ts +5 -2
- package/dist/lib/search-index/background.js +94 -15
- package/dist/main.js +15 -0
- package/dist/utils/network-transport-error.d.ts +16 -0
- package/dist/utils/network-transport-error.js +184 -0
- package/dist/utils/redact-error-text.d.ts +10 -0
- package/dist/utils/redact-error-text.js +33 -0
- package/dist/utils/unexpected-cli-error.d.ts +8 -8
- package/dist/utils/unexpected-cli-error.js +11 -22
- package/dist/utils/vault-api.js +29 -9
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.94.3]
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- Upstream gateway faults and pure network transport failures no longer open
|
|
10
|
+
Sentry crash reports against hq-cli. Gateway HTTP 5xx and JSON-RPC
|
|
11
|
+
`-32050 PROVIDER_ERROR` are third-party/provider failures already recorded
|
|
12
|
+
first-party in hq-pro; bare `TypeError('fetch failed')` with a recognized
|
|
13
|
+
transport errno/undici cause (for example `ConnectTimeoutError`) is a
|
|
14
|
+
network fault, not an hq-cli bug. Both now classify as expected, print an
|
|
15
|
+
actionable message, skip Sentry, and keep exit 1. Unclassified errors,
|
|
16
|
+
including hq-cli bugs that surface as a plain TypeError, still report. (#338)
|
|
17
|
+
|
|
5
18
|
## [5.94.2]
|
|
6
19
|
|
|
7
20
|
### Fixed
|
|
@@ -9,7 +9,7 @@ export type SearchIndexDependencies = {
|
|
|
9
9
|
resolveQmdVersion: () => string | undefined;
|
|
10
10
|
runQmd: (args: string[], options?: RunQmdOptions) => QmdProcessResult;
|
|
11
11
|
runBackgroundLauncher?: (dependencies: BackgroundDependencies) => BackgroundResult;
|
|
12
|
-
runBackgroundWorker?: (dependencies: BackgroundDependencies) => BackgroundResult
|
|
12
|
+
runBackgroundWorker?: (dependencies: BackgroundDependencies) => BackgroundResult | Promise<BackgroundResult>;
|
|
13
13
|
backgroundStatus?: (dependencies: BackgroundDependencies) => BackgroundStatus;
|
|
14
14
|
};
|
|
15
15
|
/** Incrementally update qmd, embedding only when an operator explicitly asks. */
|
|
@@ -73,13 +73,13 @@ export function registerIndexCommand(program, dependencies = defaults) {
|
|
|
73
73
|
.option('--log <path>', 'Write worker output to this log file')
|
|
74
74
|
.addOption(new Option('--worker').hideHelp())
|
|
75
75
|
.option('--hq-root <path>', 'HQ root to index (defaults to auto-detected root)')
|
|
76
|
-
.action((options) => {
|
|
76
|
+
.action(async (options) => {
|
|
77
77
|
const hqRoot = resolveRoot(options.hqRoot);
|
|
78
78
|
const background = makeBackgroundDependencies(hqRoot, dependencies);
|
|
79
79
|
if (options.log)
|
|
80
80
|
background.env = { ...background.env, QMD_REINDEX_LOG: options.log };
|
|
81
81
|
const result = options.worker
|
|
82
|
-
? (dependencies.runBackgroundWorker ?? runBackgroundWorker)(background)
|
|
82
|
+
? await (dependencies.runBackgroundWorker ?? runBackgroundWorker)(background)
|
|
83
83
|
: (dependencies.runBackgroundLauncher ?? runBackgroundLauncher)(background);
|
|
84
84
|
if (!options.worker && result.state === 'launched')
|
|
85
85
|
console.log(result.pid);
|
|
@@ -30,6 +30,7 @@ import chalk from "chalk";
|
|
|
30
30
|
import { ensureCognitoIdToken } from "../utils/cognito-session.js";
|
|
31
31
|
import { getCompanyUid, vaultApiFetch } from "../utils/vault-api.js";
|
|
32
32
|
import { AuthError } from "../utils/auth-error.js";
|
|
33
|
+
import { redactErrorText } from "../utils/redact-error-text.js";
|
|
33
34
|
export class IntegrationsCliError extends Error {
|
|
34
35
|
/**
|
|
35
36
|
* True when the error is the caller's request/state/permission (a client 4xx
|
|
@@ -53,6 +54,43 @@ export class IntegrationsCliError extends Error {
|
|
|
53
54
|
function isClientError(status) {
|
|
54
55
|
return status >= 400 && status < 500;
|
|
55
56
|
}
|
|
57
|
+
/**
|
|
58
|
+
* Statuses that mean HQ's integration gateway (or the third-party provider
|
|
59
|
+
* behind it) could not serve this request right now — an UPSTREAM AVAILABILITY
|
|
60
|
+
* event, not an hq-cli defect and not something the caller did wrong.
|
|
61
|
+
*
|
|
62
|
+
* HQ-CLI-F: the 500/503s in Sentry correlate request-for-request with the
|
|
63
|
+
* hq-pro `IntegrationMcpFunction` Lambda hitting its 30s timeout while waiting
|
|
64
|
+
* on a provider (CloudWatch `integration_mcp_audit event=provider_error`, and
|
|
65
|
+
* the same spikes counted in the AWS/Lambda `Errors` metric). The event is
|
|
66
|
+
* already recorded first-party, in the project that owns the fix; mirroring it
|
|
67
|
+
* into hq-cli's tracker is duplicate, unactionable noise. 429 is included
|
|
68
|
+
* because a rate-limited call is the same "retry in a moment" outcome (it was
|
|
69
|
+
* already `expected` via `isClientError`; only its wording changes here).
|
|
70
|
+
*/
|
|
71
|
+
function isUpstreamUnavailable(status) {
|
|
72
|
+
return status === 429 || status === 500 || status === 502 || status === 503 || status === 504;
|
|
73
|
+
}
|
|
74
|
+
/** Actionable wording for an upstream-availability status. */
|
|
75
|
+
function upstreamUnavailableMessage(status) {
|
|
76
|
+
return status === 429
|
|
77
|
+
? `HQ's integration gateway is rate-limiting this request (HTTP 429). Wait a moment and retry.`
|
|
78
|
+
: `HQ's integration gateway is temporarily unavailable (HTTP ${status}). ` +
|
|
79
|
+
`This is a service-side hiccup, not a problem with your command — retry in a moment.`;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Shared non-2xx guard for every integration-gateway call site. Raises the
|
|
83
|
+
* expected, actionable upstream-availability error when the status says the
|
|
84
|
+
* service is down or throttling; returns otherwise so the caller keeps its own
|
|
85
|
+
* status-specific message and `expected` classification unchanged.
|
|
86
|
+
*/
|
|
87
|
+
function raiseIfUpstreamUnavailable(res) {
|
|
88
|
+
if (isUpstreamUnavailable(res.status)) {
|
|
89
|
+
throw new IntegrationsCliError(upstreamUnavailableMessage(res.status), {
|
|
90
|
+
expected: true,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
}
|
|
56
94
|
// The integration gateway answers `POST /v1/integrations/mcp` JSON-RPC-style: a
|
|
57
95
|
// transport failure is a non-2xx HTTP status, but a GOVERNED refusal arrives as
|
|
58
96
|
// HTTP 200 carrying a JSON-RPC `error` object (mirrors hq-pro's
|
|
@@ -67,12 +105,35 @@ function isClientError(status) {
|
|
|
67
105
|
// read-only share rejecting a write.
|
|
68
106
|
// -32602 INVALID_PARAMS — an unknown tool or unsupported provider for the
|
|
69
107
|
// connection (a bad request the caller can correct).
|
|
108
|
+
// -32050 PROVIDER_ERROR — a THIRD-PARTY provider fault, surfaced verbatim.
|
|
109
|
+
// hq-pro mints this code for EVERY provider fault
|
|
110
|
+
// and for nothing else: `integration-mcp/server.ts`
|
|
111
|
+
// maps an `IntegrationMcpError` with status
|
|
112
|
+
// 'provider_error' to -32050, raised by
|
|
113
|
+
// `integration-mcp/dispatch.ts` for ProviderTimeout,
|
|
114
|
+
// ProviderRateLimited, ProviderParseError,
|
|
115
|
+
// ProviderWriteUnknown and TokenRefreshFailed.
|
|
116
|
+
// NOTE the remote server's OWN JSON-RPC code is
|
|
117
|
+
// embedded in the message TEXT ("Remote MCP request
|
|
118
|
+
// failed (-32602): …"); the wire code hq-cli sees is
|
|
119
|
+
// always -32050, so the codes above never match it.
|
|
120
|
+
// Every occurrence is already recorded first-party
|
|
121
|
+
// in hq-pro — `integration_mcp_audit
|
|
122
|
+
// event=provider_error` with reason/provider/tool,
|
|
123
|
+
// an `integration_mcp_health_signal` metric, and
|
|
124
|
+
// hq-pro's own Sentry project — so hq-cli reporting
|
|
125
|
+
// it again is duplicate noise in the wrong tracker,
|
|
126
|
+
// filed against a codebase that cannot fix it
|
|
127
|
+
// (HQ-CLI-F). Should hq-pro ever reuse -32050 for
|
|
128
|
+
// an hq-pro-side fault, the mapping site named above
|
|
129
|
+
// is where that change is traceable; -32603 below
|
|
130
|
+
// remains the code for hq-pro's own faults.
|
|
70
131
|
// Everything else stays unexpected so a genuine fault still reaches Sentry:
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
const EXPECTED_GATEWAY_ERROR_CODES = new Set([-32003, -32602]);
|
|
132
|
+
// INTERNAL_ERROR (-32603), CONFLICT (-32009, which the gateway also raises for
|
|
133
|
+
// a confirm queue being unavailable or an owner notification failing — real
|
|
134
|
+
// backend faults worth a report), METHOD_NOT_FOUND / PARSE_ERROR, and any
|
|
135
|
+
// absent or unrecognized code.
|
|
136
|
+
const EXPECTED_GATEWAY_ERROR_CODES = new Set([-32003, -32602, -32050]);
|
|
76
137
|
function isExpectedGatewayError(code) {
|
|
77
138
|
return code != null && EXPECTED_GATEWAY_ERROR_CODES.has(code);
|
|
78
139
|
}
|
|
@@ -104,6 +165,7 @@ export async function fetchConnections(token, companyUid) {
|
|
|
104
165
|
});
|
|
105
166
|
if (!res.ok) {
|
|
106
167
|
raiseIfUnauthorized(res);
|
|
168
|
+
raiseIfUpstreamUnavailable(res);
|
|
107
169
|
const body = (await res.json().catch(() => ({})));
|
|
108
170
|
throw new IntegrationsCliError(body.error ?? `Failed to list integrations (HTTP ${res.status})`, { expected: isClientError(res.status) });
|
|
109
171
|
}
|
|
@@ -162,10 +224,20 @@ export async function callGateway(token, params) {
|
|
|
162
224
|
const message = (await res.json().catch(() => null));
|
|
163
225
|
if (!res.ok || !message) {
|
|
164
226
|
raiseIfUnauthorized(res);
|
|
227
|
+
raiseIfUpstreamUnavailable(res);
|
|
165
228
|
throw new IntegrationsCliError(`Integration gateway request failed (HTTP ${res.status}).`, { expected: isClientError(res.status) });
|
|
166
229
|
}
|
|
167
230
|
if (message.error) {
|
|
168
|
-
|
|
231
|
+
// The gateway's error text is minted UPSTREAM (hq-pro, the integration
|
|
232
|
+
// factory, and beyond it the third-party provider), so it is untrusted:
|
|
233
|
+
// scrub credentials and bound the length here, at the throw site, because
|
|
234
|
+
// an `expected` error is printed straight from `err.message` by the
|
|
235
|
+
// top-level handler and never passes through `unexpectedCliErrorMessage`.
|
|
236
|
+
// The chain is idempotent, so the unexpected path scrubbing again is a
|
|
237
|
+
// no-op. This preserves PR #298's user-visible diagnostic; it only makes
|
|
238
|
+
// it safe on the newly-expected path.
|
|
239
|
+
throw new IntegrationsCliError(redactErrorText(message.error.message ?? "") ||
|
|
240
|
+
"Integration gateway returned an error.", { expected: isExpectedGatewayError(message.error.code) });
|
|
169
241
|
}
|
|
170
242
|
return message;
|
|
171
243
|
}
|
|
@@ -344,6 +416,7 @@ export function registerIntegrationsCommand(program) {
|
|
|
344
416
|
const body = (await res.json().catch(() => ({})));
|
|
345
417
|
if (!res.ok) {
|
|
346
418
|
raiseIfUnauthorized(res);
|
|
419
|
+
raiseIfUpstreamUnavailable(res);
|
|
347
420
|
throw new IntegrationsCliError(body.error ?? `${decision} failed (HTTP ${res.status})`, { expected: isClientError(res.status) });
|
|
348
421
|
}
|
|
349
422
|
if (opts.json) {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type QmdProcessResult, type RunQmdOptions } from './index.js';
|
|
2
2
|
export type BackgroundResult = {
|
|
3
|
-
state: 'skipped-agent' | 'skipped' | 'quiet' | 'busy' | 'completed' | 'update-failed';
|
|
3
|
+
state: 'skipped-agent' | 'skipped' | 'quiet' | 'busy' | 'completed' | 'update-failed' | 'terminated';
|
|
4
4
|
} | {
|
|
5
5
|
state: 'launched';
|
|
6
6
|
pid: number;
|
|
@@ -20,6 +20,9 @@ export type BackgroundDependencies = {
|
|
|
20
20
|
}) => number;
|
|
21
21
|
/** Test seam for simulating a competing owner replacing the atomic record. */
|
|
22
22
|
afterOwnerPublish?: (ownerFile: string) => void;
|
|
23
|
+
/** Test seams for signal delivery; production uses the real process. */
|
|
24
|
+
processEvents?: Pick<NodeJS.Process, 'once'>;
|
|
25
|
+
exit?: (code: number) => void;
|
|
23
26
|
};
|
|
24
27
|
export type BackgroundStatus = {
|
|
25
28
|
lock: 'held' | 'stale' | 'free';
|
|
@@ -33,7 +36,7 @@ export declare function installWorkerCleanup(cleanup: () => void, processEvents?
|
|
|
33
36
|
/** Start a detached worker; this public entry never owns the qmd pipeline. */
|
|
34
37
|
export declare function runBackgroundLauncher(dependencies: BackgroundDependencies): BackgroundResult;
|
|
35
38
|
/** Run the single-flight cleanup → update → embed pipeline in a worker only. */
|
|
36
|
-
export declare function runBackgroundWorker(dependencies: BackgroundDependencies): BackgroundResult
|
|
39
|
+
export declare function runBackgroundWorker(dependencies: BackgroundDependencies): Promise<BackgroundResult>;
|
|
37
40
|
/** Report the background lock and latest successful completion for `hq index status`. */
|
|
38
41
|
export declare function backgroundStatus(dependencies: BackgroundDependencies): BackgroundStatus;
|
|
39
42
|
//# sourceMappingURL=background.d.ts.map
|
|
@@ -170,6 +170,10 @@ function acquireClaim(home, observedGeneration, dependencies) {
|
|
|
170
170
|
const claimant = path.join(claim, `c.${dependencies.pid}.${dependencies.random()}`);
|
|
171
171
|
try {
|
|
172
172
|
fs.mkdirSync(claimant);
|
|
173
|
+
// Test-only deterministic failure inject (unset in production) — shell
|
|
174
|
+
// parity with the script's claimant write_owner_record injection.
|
|
175
|
+
if (dependencies.env.QMD_FORCE_CLAIMANT_WRITE_FAIL)
|
|
176
|
+
throw new Error('forced claimant write failure');
|
|
173
177
|
fs.writeFileSync(path.join(claimant, 'owner'), `pid=${dependencies.pid}\nts=${dependencies.now()}\n`);
|
|
174
178
|
return { claim, claimant };
|
|
175
179
|
}
|
|
@@ -236,6 +240,10 @@ function createAndPublishLock(home, dependencies) {
|
|
|
236
240
|
const ownerFile = path.join(directory, 'owner');
|
|
237
241
|
const temporary = path.join(directory, `.owner.tmp.${dependencies.pid}.${dependencies.random()}`);
|
|
238
242
|
try {
|
|
243
|
+
// Test-only deterministic failure inject (unset in production) — shell
|
|
244
|
+
// parity: the script's write_owner_record honored the same variable.
|
|
245
|
+
if (dependencies.env.QMD_FORCE_OWNER_WRITE_FAIL)
|
|
246
|
+
throw new Error('forced owner write failure');
|
|
239
247
|
fs.writeFileSync(temporary, `pid=${dependencies.pid}\nts=${dependencies.now()}\nnonce=${nonce}\n`);
|
|
240
248
|
fs.renameSync(temporary, ownerFile);
|
|
241
249
|
dependencies.afterOwnerPublish?.(ownerFile);
|
|
@@ -290,11 +298,52 @@ function releaseLock(home, dependencies) {
|
|
|
290
298
|
export function installWorkerCleanup(cleanup, processEvents = process, exit = () => undefined) {
|
|
291
299
|
processEvents.once('exit', cleanup);
|
|
292
300
|
// Unlike Bash, Node cannot turn a SIGKILL or an already-defaulted signal into
|
|
293
|
-
// catchable cleanup. SIGINT/SIGTERM are registered here
|
|
294
|
-
//
|
|
295
|
-
//
|
|
301
|
+
// catchable cleanup. SIGINT/SIGTERM/SIGHUP are registered here (the shell
|
|
302
|
+
// worker trapped `INT TERM HUP`; a detached worker's controlling terminal
|
|
303
|
+
// going away delivers HUP, and the default disposition would kill the
|
|
304
|
+
// process without releasing the lock) and the CLI's normal process exit then
|
|
305
|
+
// runs the same idempotent owner release. Exiting prevents a synchronous
|
|
306
|
+
// pipeline from continuing after it has released ownership.
|
|
296
307
|
processEvents.once('SIGINT', () => { cleanup(); exit(0); });
|
|
297
308
|
processEvents.once('SIGTERM', () => { cleanup(); exit(0); });
|
|
309
|
+
processEvents.once('SIGHUP', () => { cleanup(); exit(0); });
|
|
310
|
+
}
|
|
311
|
+
function workerLogPath(env) {
|
|
312
|
+
return env.QMD_REINDEX_LOG
|
|
313
|
+
?? env.QMD_HANDOFF_LOG
|
|
314
|
+
?? path.join(env.HANDOFF_LOG_DIR ?? '/tmp', 'qmd-handoff.log');
|
|
315
|
+
}
|
|
316
|
+
function appendWorkerLog(logPath, text) {
|
|
317
|
+
if (!text)
|
|
318
|
+
return;
|
|
319
|
+
try {
|
|
320
|
+
fs.mkdirSync(path.dirname(logPath), { recursive: true });
|
|
321
|
+
fs.appendFileSync(logPath, text.endsWith('\n') ? text : `${text}\n`);
|
|
322
|
+
}
|
|
323
|
+
catch { /* logging is best-effort, exactly like the shell worker's >>"$LOG" */ }
|
|
324
|
+
}
|
|
325
|
+
/** Keep only the trailing QMD_HANDOFF_LOG_MAX_BYTES of the worker log (shell cap_log parity). */
|
|
326
|
+
function capWorkerLog(logPath, env) {
|
|
327
|
+
const raw = env.QMD_HANDOFF_LOG_MAX_BYTES ?? '65536';
|
|
328
|
+
if (!/^\d+$/.test(raw))
|
|
329
|
+
return;
|
|
330
|
+
const max = Number(raw);
|
|
331
|
+
if (max === 0)
|
|
332
|
+
return;
|
|
333
|
+
try {
|
|
334
|
+
if (!fs.existsSync(logPath) || fs.statSync(logPath).size <= max)
|
|
335
|
+
return;
|
|
336
|
+
const content = fs.readFileSync(logPath);
|
|
337
|
+
fs.writeFileSync(logPath, content.subarray(content.length - max));
|
|
338
|
+
}
|
|
339
|
+
catch { /* best-effort, matching the shell's cap_log */ }
|
|
340
|
+
}
|
|
341
|
+
function stepOutput(result) {
|
|
342
|
+
return `${result.stdout ?? ''}${result.stderr ?? ''}`;
|
|
343
|
+
}
|
|
344
|
+
function errorOutput(error) {
|
|
345
|
+
const e = error;
|
|
346
|
+
return `${e.stdout ?? ''}${e.stderr ?? ''}` || (e.message ?? '');
|
|
298
347
|
}
|
|
299
348
|
/** Start a detached worker; this public entry never owns the qmd pipeline. */
|
|
300
349
|
export function runBackgroundLauncher(dependencies) {
|
|
@@ -309,13 +358,10 @@ export function runBackgroundLauncher(dependencies) {
|
|
|
309
358
|
catch {
|
|
310
359
|
return { state: 'skipped' };
|
|
311
360
|
}
|
|
312
|
-
|
|
313
|
-
?? dependencies.env.QMD_HANDOFF_LOG
|
|
314
|
-
?? path.join(dependencies.env.HANDOFF_LOG_DIR ?? '/tmp', 'qmd-handoff.log');
|
|
315
|
-
return { state: 'launched', pid: dependencies.spawnWorker({ logPath }) };
|
|
361
|
+
return { state: 'launched', pid: dependencies.spawnWorker({ logPath: workerLogPath(dependencies.env) }) };
|
|
316
362
|
}
|
|
317
363
|
/** Run the single-flight cleanup → update → embed pipeline in a worker only. */
|
|
318
|
-
export function runBackgroundWorker(dependencies) {
|
|
364
|
+
export async function runBackgroundWorker(dependencies) {
|
|
319
365
|
if (isHostedAgent(dependencies.env))
|
|
320
366
|
return { state: 'skipped-agent' };
|
|
321
367
|
const home = dependencies.env.HOME;
|
|
@@ -329,14 +375,32 @@ export function runBackgroundWorker(dependencies) {
|
|
|
329
375
|
}
|
|
330
376
|
if (isRecentCompletion(home, dependencies) || !acquireLock(home, dependencies))
|
|
331
377
|
return { state: 'busy' };
|
|
378
|
+
// Shell-worker log parity: each step's captured output appends to the
|
|
379
|
+
// handoff log and the log keeps only its trailing QMD_HANDOFF_LOG_MAX_BYTES.
|
|
380
|
+
// The cap lives inside the exit cleanup because a signal-path process.exit
|
|
381
|
+
// never unwinds to a `finally` — the shell capped in its EXIT trap for the
|
|
382
|
+
// same reason.
|
|
383
|
+
const logPath = workerLogPath(dependencies.env);
|
|
332
384
|
let released = false;
|
|
333
385
|
const cleanup = () => {
|
|
334
386
|
if (released)
|
|
335
387
|
return;
|
|
336
388
|
released = true;
|
|
337
389
|
releaseLock(home, dependencies);
|
|
390
|
+
capWorkerLog(logPath, dependencies.env);
|
|
391
|
+
};
|
|
392
|
+
installWorkerCleanup(cleanup, dependencies.processEvents ?? process, dependencies.exit ?? ((code) => process.exit(code)));
|
|
393
|
+
// A signal that lands during a synchronous qmd step cannot interrupt it, and
|
|
394
|
+
// Node defers the handler until the event loop next turns — which a fully
|
|
395
|
+
// synchronous pipeline never lets happen, so SIGTERM used to be processed
|
|
396
|
+
// only AFTER update/embed had already run. Bash traps fire between commands;
|
|
397
|
+
// yielding one event-loop turn between steps restores that contract. If the
|
|
398
|
+
// handler ran (production exits; tests inject `exit`), `released` is set and
|
|
399
|
+
// the pipeline stops before its next step.
|
|
400
|
+
const signalWindow = async () => {
|
|
401
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
402
|
+
return released;
|
|
338
403
|
};
|
|
339
|
-
installWorkerCleanup(cleanup, process, (code) => process.exit(code));
|
|
340
404
|
try {
|
|
341
405
|
if (isRecentCompletion(home, dependencies))
|
|
342
406
|
return { state: 'busy' };
|
|
@@ -349,21 +413,36 @@ export function runBackgroundWorker(dependencies) {
|
|
|
349
413
|
// The shell worker has no collection-registration step. Keep this #306
|
|
350
414
|
// integration best-effort so it cannot suppress a later index update.
|
|
351
415
|
}
|
|
416
|
+
if (await signalWindow())
|
|
417
|
+
return { state: 'terminated' };
|
|
352
418
|
try {
|
|
353
|
-
dependencies.runQmd(['cleanup'], { cwd: dependencies.hqRoot });
|
|
419
|
+
appendWorkerLog(logPath, stepOutput(dependencies.runQmd(['cleanup'], { cwd: dependencies.hqRoot })));
|
|
354
420
|
}
|
|
355
|
-
catch {
|
|
421
|
+
catch (error) {
|
|
422
|
+
appendWorkerLog(logPath, errorOutput(error)); // cleanup is intentionally best-effort
|
|
423
|
+
}
|
|
424
|
+
if (await signalWindow())
|
|
425
|
+
return { state: 'terminated' };
|
|
356
426
|
try {
|
|
357
|
-
dependencies.runQmd(['update'], { cwd: dependencies.hqRoot });
|
|
427
|
+
appendWorkerLog(logPath, stepOutput(dependencies.runQmd(['update'], { cwd: dependencies.hqRoot })));
|
|
358
428
|
}
|
|
359
|
-
catch {
|
|
429
|
+
catch (error) {
|
|
430
|
+
appendWorkerLog(logPath, errorOutput(error));
|
|
431
|
+
appendWorkerLog(logPath, `[qmd-reindex-bg] update-failed ts=${dependencies.now()}`);
|
|
360
432
|
return { state: 'update-failed' };
|
|
361
433
|
}
|
|
434
|
+
if (await signalWindow())
|
|
435
|
+
return { state: 'terminated' };
|
|
362
436
|
try {
|
|
363
|
-
dependencies.runQmd(['embed'], { cwd: dependencies.hqRoot });
|
|
437
|
+
appendWorkerLog(logPath, stepOutput(dependencies.runQmd(['embed'], { cwd: dependencies.hqRoot })));
|
|
438
|
+
}
|
|
439
|
+
catch (error) {
|
|
440
|
+
appendWorkerLog(logPath, errorOutput(error)); // a completed embed attempt still permits the stamp
|
|
364
441
|
}
|
|
365
|
-
|
|
442
|
+
if (await signalWindow())
|
|
443
|
+
return { state: 'terminated' };
|
|
366
444
|
writeCompletion(home, dependencies);
|
|
445
|
+
appendWorkerLog(logPath, `[qmd-reindex-bg] done ts=${dependencies.now()}`);
|
|
367
446
|
return { state: 'completed' };
|
|
368
447
|
}
|
|
369
448
|
finally {
|
package/dist/main.js
CHANGED
|
@@ -61,6 +61,7 @@ import { registerSearchCommand } from "./commands/search.js";
|
|
|
61
61
|
import { registerIndexCommand } from "./commands/index-cmd.js";
|
|
62
62
|
import { sanitizeArgv } from "./utils/feedback-diagnostics.js";
|
|
63
63
|
import { environmentalFsErrorMessage } from "./utils/environmental-error.js";
|
|
64
|
+
import { networkTransportErrorMessage } from "./utils/network-transport-error.js";
|
|
64
65
|
import { isExpectedUserError } from "./utils/expected-cli-error.js";
|
|
65
66
|
import { isEpipe } from "./utils/epipe.js";
|
|
66
67
|
import { isInterceptedProcessExit } from "./utils/intercepted-process-exit.js";
|
|
@@ -343,9 +344,23 @@ export function handleTopLevelError(err, deps = defaultTopLevelErrorDependencies
|
|
|
343
344
|
// identical, unfixable crash reports (HQ-CLI-2). Genuine errors still go
|
|
344
345
|
// to Sentry and still exit 1.
|
|
345
346
|
const envMsg = environmentalFsErrorMessage(err);
|
|
347
|
+
// A raw network transport failure (undici's `TypeError: fetch failed`
|
|
348
|
+
// with a ConnectTimeoutError / ECONNREFUSED / ENOTFOUND cause) is the
|
|
349
|
+
// caller's connectivity, not an hq-cli defect. Before this branch it fell
|
|
350
|
+
// through to the capture below: it filed a crash report with the useless
|
|
351
|
+
// culprit `?(undici)`, and printed only the equally useless line
|
|
352
|
+
// `hq: TypeError: fetch failed` (before #337 landed the always-print
|
|
353
|
+
// fallback, it printed nothing at all) — HQ-CLI-G. Print an actionable
|
|
354
|
+
// message that names the unreachable host, exit 1, and skip Sentry.
|
|
355
|
+
// Ordered after the environmental check so a full disk keeps its exact
|
|
356
|
+
// existing message.
|
|
357
|
+
const transportMsg = envMsg ? null : networkTransportErrorMessage(err);
|
|
346
358
|
if (envMsg) {
|
|
347
359
|
deps.stderr.write(`hq: ${envMsg}\n`);
|
|
348
360
|
}
|
|
361
|
+
else if (transportMsg) {
|
|
362
|
+
deps.stderr.write(`hq: ${transportMsg}\n`);
|
|
363
|
+
}
|
|
349
364
|
else {
|
|
350
365
|
deps.sentry.captureException(err);
|
|
351
366
|
// Always emit something. Printing only when unexpectedCliErrorMessage()
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The recognized transport code for `err`, or null when `err` is not a
|
|
3
|
+
* transport failure. Used for breadcrumb annotation at the fetch call site —
|
|
4
|
+
* a bounded, non-secret label, never the error text itself.
|
|
5
|
+
*/
|
|
6
|
+
export declare function networkTransportErrorCode(err: unknown): string | null;
|
|
7
|
+
/**
|
|
8
|
+
* If `err` is a raw network transport failure, return a short, actionable
|
|
9
|
+
* user-facing message; otherwise return `null`.
|
|
10
|
+
*
|
|
11
|
+
* A non-null result means the caller should PRINT the message, exit non-zero,
|
|
12
|
+
* and SKIP Sentry capture — the condition is the caller's network, not a bug
|
|
13
|
+
* HQ can fix. A null result means "handle this as usual (capture to Sentry)".
|
|
14
|
+
*/
|
|
15
|
+
export declare function networkTransportErrorMessage(err: unknown): string | null;
|
|
16
|
+
//# sourceMappingURL=network-transport-error.d.ts.map
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// src/utils/network-transport-error.ts
|
|
2
|
+
//
|
|
3
|
+
// Classify raw NETWORK TRANSPORT failures — the request never reached (or
|
|
4
|
+
// never completed against) the server — as the user's connectivity rather
|
|
5
|
+
// than an hq-cli code defect. Sibling of `environmental-error.ts` (HQ-CLI-2,
|
|
6
|
+
// full disk / read-only fs) and `expected-cli-error.ts` (HQ-CLI-6, client
|
|
7
|
+
// 4xx): a failure that is not an hq-cli defect is surfaced to the user with an
|
|
8
|
+
// actionable message and skipped for Sentry capture.
|
|
9
|
+
//
|
|
10
|
+
// HQ-CLI-G (Sentry 7652140783): `hq integrations list` resolved the caller's
|
|
11
|
+
// company through `vaultApiFetch` → bare `fetch`; undici could not connect and
|
|
12
|
+
// threw `TypeError: fetch failed` whose `cause` was
|
|
13
|
+
// `ConnectTimeoutError: Connect Timeout Error (attempted address:
|
|
14
|
+
// hqapi.hq.computer:443, timeout: 10000ms)`. That value is not an
|
|
15
|
+
// `IntegrationsCliError`, so `environmentalFsErrorMessage` and
|
|
16
|
+
// `unexpectedCliErrorMessage` both returned null and the top-level handler
|
|
17
|
+
// took its last-resort branch: capture to Sentry (culprit `?(undici)`, no
|
|
18
|
+
// actionable stack) and print NOTHING. The same process had reached the same
|
|
19
|
+
// host one second earlier, so this was a transient client-side connect
|
|
20
|
+
// failure. Suppressing the crash report is only half the fix — the silent
|
|
21
|
+
// exit is the other half, which is why this returns a MESSAGE, never a bare
|
|
22
|
+
// boolean.
|
|
23
|
+
//
|
|
24
|
+
// Matching is deliberately conservative. A recognized errno / undici code (or
|
|
25
|
+
// a recognized undici error name) must appear on the error or somewhere in its
|
|
26
|
+
// bounded cause chain; message text alone never qualifies. That keeps an
|
|
27
|
+
// hq-cli bug that happens to surface as a `TypeError` reportable.
|
|
28
|
+
import { redactErrorText } from "./redact-error-text.js";
|
|
29
|
+
/** The exact message undici gives the `fetch()` wrapper around a transport fault. */
|
|
30
|
+
const FETCH_FAILED_MESSAGE = "fetch failed";
|
|
31
|
+
/**
|
|
32
|
+
* Recognized transport codes → the phrase shown to the user.
|
|
33
|
+
*
|
|
34
|
+
* Deliberately absent:
|
|
35
|
+
* - EPIPE — a closed downstream reader, handled earlier and exits 0 (HQ-6B).
|
|
36
|
+
* - UND_ERR_RESPONSE_STATUS_CODE — the server answered; that is an HTTP
|
|
37
|
+
* status to classify at the call site, not a transport failure.
|
|
38
|
+
*/
|
|
39
|
+
const TRANSPORT_CODE_REASONS = {
|
|
40
|
+
ECONNREFUSED: "the connection was refused",
|
|
41
|
+
ECONNRESET: "the connection was reset",
|
|
42
|
+
ENOTFOUND: "the hostname could not be resolved",
|
|
43
|
+
EAI_AGAIN: "the DNS lookup failed temporarily",
|
|
44
|
+
ETIMEDOUT: "the connection timed out",
|
|
45
|
+
EHOSTUNREACH: "the host is unreachable",
|
|
46
|
+
ENETUNREACH: "the network is unreachable",
|
|
47
|
+
ENETDOWN: "the network is down",
|
|
48
|
+
UND_ERR_CONNECT_TIMEOUT: "the connection timed out",
|
|
49
|
+
UND_ERR_HEADERS_TIMEOUT: "the server did not respond in time",
|
|
50
|
+
UND_ERR_SOCKET: "the connection closed unexpectedly",
|
|
51
|
+
};
|
|
52
|
+
/**
|
|
53
|
+
* Recognized undici error names, for builds where the `code` property is
|
|
54
|
+
* absent but the typed error still identifies itself (the shape Sentry
|
|
55
|
+
* recorded for HQ-CLI-G was `ConnectTimeoutError`).
|
|
56
|
+
*/
|
|
57
|
+
const TRANSPORT_NAME_REASONS = {
|
|
58
|
+
ConnectTimeoutError: "the connection timed out",
|
|
59
|
+
HeadersTimeoutError: "the server did not respond in time",
|
|
60
|
+
SocketError: "the connection closed unexpectedly",
|
|
61
|
+
};
|
|
62
|
+
/**
|
|
63
|
+
* Depth cap for `cause` traversal. A self-referential or mutually-referential
|
|
64
|
+
* cause chain must terminate rather than hang the error boundary, so the walk
|
|
65
|
+
* is bounded on BOTH depth and a visited set.
|
|
66
|
+
*/
|
|
67
|
+
const MAX_CAUSE_DEPTH = 8;
|
|
68
|
+
/** Hostnames/IPs only — anything else is dropped before it reaches stderr. */
|
|
69
|
+
const SAFE_HOST_CHARACTERS = /[^A-Za-z0-9._:[\]-]/g;
|
|
70
|
+
const MAX_HOST_LENGTH = 80;
|
|
71
|
+
function readStringProperty(node, key) {
|
|
72
|
+
const value = node[key];
|
|
73
|
+
return typeof value === "string" && value.length > 0 ? value : null;
|
|
74
|
+
}
|
|
75
|
+
function errorNameOf(node) {
|
|
76
|
+
return (readStringProperty(node, "name") ??
|
|
77
|
+
(typeof node.constructor?.name === "string"
|
|
78
|
+
? (node.constructor.name)
|
|
79
|
+
: null));
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Best-effort host recovery: Node errno errors carry `hostname`/`address`
|
|
83
|
+
* (+ `port`); undici's ConnectTimeoutError only names the peer in its message
|
|
84
|
+
* ("attempted address: host:443").
|
|
85
|
+
*/
|
|
86
|
+
function hostFrom(node) {
|
|
87
|
+
const base = readStringProperty(node, "hostname") ?? readStringProperty(node, "address");
|
|
88
|
+
if (base) {
|
|
89
|
+
const port = node.port;
|
|
90
|
+
return typeof port === "number" && Number.isFinite(port) ? `${base}:${port}` : base;
|
|
91
|
+
}
|
|
92
|
+
const message = readStringProperty(node, "message");
|
|
93
|
+
if (message) {
|
|
94
|
+
const match = /attempted address:\s*([^\s,)]+)/i.exec(message);
|
|
95
|
+
if (match)
|
|
96
|
+
return match[1];
|
|
97
|
+
}
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Walk `root` and its `cause` chain (plus any `AggregateError.errors`, which
|
|
102
|
+
* is how undici reports a multi-address connect failure) for a recognized
|
|
103
|
+
* transport code or error name. Bounded by depth AND a visited set.
|
|
104
|
+
*/
|
|
105
|
+
function findTransportFailure(root) {
|
|
106
|
+
const seen = new Set();
|
|
107
|
+
const queue = [{ node: root, depth: 0 }];
|
|
108
|
+
let host = null;
|
|
109
|
+
while (queue.length > 0) {
|
|
110
|
+
const { node, depth } = queue.shift();
|
|
111
|
+
if (node === null || typeof node !== "object")
|
|
112
|
+
continue;
|
|
113
|
+
if (seen.has(node))
|
|
114
|
+
continue;
|
|
115
|
+
seen.add(node);
|
|
116
|
+
host = host ?? hostFrom(node);
|
|
117
|
+
const code = readStringProperty(node, "code");
|
|
118
|
+
if (code && TRANSPORT_CODE_REASONS[code]) {
|
|
119
|
+
return { code, reason: TRANSPORT_CODE_REASONS[code], host };
|
|
120
|
+
}
|
|
121
|
+
const name = errorNameOf(node);
|
|
122
|
+
if (name && TRANSPORT_NAME_REASONS[name]) {
|
|
123
|
+
return { code: name, reason: TRANSPORT_NAME_REASONS[name], host };
|
|
124
|
+
}
|
|
125
|
+
if (depth >= MAX_CAUSE_DEPTH)
|
|
126
|
+
continue;
|
|
127
|
+
const cause = node.cause;
|
|
128
|
+
if (cause !== undefined && cause !== null) {
|
|
129
|
+
queue.push({ node: cause, depth: depth + 1 });
|
|
130
|
+
}
|
|
131
|
+
const aggregated = node.errors;
|
|
132
|
+
if (Array.isArray(aggregated)) {
|
|
133
|
+
for (const entry of aggregated)
|
|
134
|
+
queue.push({ node: entry, depth: depth + 1 });
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Reject anything whose SHAPE says "this may be an hq-cli defect" before the
|
|
141
|
+
* allowlist even runs. A `TypeError` is the one thing undici reuses for a
|
|
142
|
+
* transport fault, and only with the exact `fetch failed` message; every other
|
|
143
|
+
* `TypeError` is far likelier to be our own bug (calling a non-function,
|
|
144
|
+
* reading a property of undefined) and must stay reportable.
|
|
145
|
+
*/
|
|
146
|
+
function hasTransportShape(err) {
|
|
147
|
+
if (!(err instanceof Error))
|
|
148
|
+
return false;
|
|
149
|
+
if (err instanceof TypeError && err.message !== FETCH_FAILED_MESSAGE)
|
|
150
|
+
return false;
|
|
151
|
+
return true;
|
|
152
|
+
}
|
|
153
|
+
function classify(err) {
|
|
154
|
+
if (!hasTransportShape(err))
|
|
155
|
+
return null;
|
|
156
|
+
return findTransportFailure(err);
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* The recognized transport code for `err`, or null when `err` is not a
|
|
160
|
+
* transport failure. Used for breadcrumb annotation at the fetch call site —
|
|
161
|
+
* a bounded, non-secret label, never the error text itself.
|
|
162
|
+
*/
|
|
163
|
+
export function networkTransportErrorCode(err) {
|
|
164
|
+
return classify(err)?.code ?? null;
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* If `err` is a raw network transport failure, return a short, actionable
|
|
168
|
+
* user-facing message; otherwise return `null`.
|
|
169
|
+
*
|
|
170
|
+
* A non-null result means the caller should PRINT the message, exit non-zero,
|
|
171
|
+
* and SKIP Sentry capture — the condition is the caller's network, not a bug
|
|
172
|
+
* HQ can fix. A null result means "handle this as usual (capture to Sentry)".
|
|
173
|
+
*/
|
|
174
|
+
export function networkTransportErrorMessage(err) {
|
|
175
|
+
const failure = classify(err);
|
|
176
|
+
if (!failure)
|
|
177
|
+
return null;
|
|
178
|
+
const host = failure.host
|
|
179
|
+
? redactErrorText(failure.host).replace(SAFE_HOST_CHARACTERS, "").slice(0, MAX_HOST_LENGTH)
|
|
180
|
+
: "";
|
|
181
|
+
const where = host ? ` (${host})` : "";
|
|
182
|
+
return `Could not reach HQ${where}: ${failure.reason}. Check your network connection and try again.`;
|
|
183
|
+
}
|
|
184
|
+
//# sourceMappingURL=network-transport-error.js.map
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** Upper bound on any redacted diagnostic — a message, not a payload dump. */
|
|
2
|
+
export declare const REDACTED_TEXT_MAX_LENGTH = 1000;
|
|
3
|
+
/**
|
|
4
|
+
* Strip credentials from `text`, flatten control characters/whitespace, and
|
|
5
|
+
* bound the length. Idempotent, so applying it at both the throw site and the
|
|
6
|
+
* print site is safe. Returns `""` when nothing survives — callers supply
|
|
7
|
+
* their own fallback wording.
|
|
8
|
+
*/
|
|
9
|
+
export declare function redactErrorText(text: string): string;
|
|
10
|
+
//# sourceMappingURL=redact-error-text.d.ts.map
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// src/utils/redact-error-text.ts
|
|
2
|
+
//
|
|
3
|
+
// One credential-redaction + bounding chain for error text that leaves the
|
|
4
|
+
// process — printed to the user's terminal or shipped to Sentry.
|
|
5
|
+
//
|
|
6
|
+
// It was originally inline in `unexpected-cli-error.ts`, which only runs on the
|
|
7
|
+
// UNEXPECTED path. Once upstream-minted gateway text can be classified
|
|
8
|
+
// `expected` (HQ-CLI-F), that text is printed by the top-level handler's
|
|
9
|
+
// expected branch instead, which prints `err.message` verbatim. Rather than
|
|
10
|
+
// duplicate the chain at the second print site, it lives here and both callers
|
|
11
|
+
// share it: provider-minted text is untrusted and must never be printed raw.
|
|
12
|
+
/** Upper bound on any redacted diagnostic — a message, not a payload dump. */
|
|
13
|
+
export const REDACTED_TEXT_MAX_LENGTH = 1_000;
|
|
14
|
+
/**
|
|
15
|
+
* Strip credentials from `text`, flatten control characters/whitespace, and
|
|
16
|
+
* bound the length. Idempotent, so applying it at both the throw site and the
|
|
17
|
+
* print site is safe. Returns `""` when nothing survives — callers supply
|
|
18
|
+
* their own fallback wording.
|
|
19
|
+
*/
|
|
20
|
+
export function redactErrorText(text) {
|
|
21
|
+
return text
|
|
22
|
+
.replace(/-----BEGIN[^-]+PRIVATE KEY-----[\s\S]*?-----END[^-]+PRIVATE KEY-----/g, "[REDACTED]")
|
|
23
|
+
.replace(/\p{Cc}/gu, " ")
|
|
24
|
+
.replace(/\b(?:Bearer|Basic)\s+[A-Za-z0-9._~+/=-]+/gi, "[REDACTED]")
|
|
25
|
+
.replace(/\b(password|secret|client[_-]?secret|api[_-]?key|(?:id|access|refresh)[_-]?token|token|authorization)\s*[:=]\s*(?:"[^"]*"|'[^']*'|[^\s&"'<>;,}]+)/gi, (_match, label) => `${label}=[REDACTED]`)
|
|
26
|
+
.replace(/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g, "[REDACTED]")
|
|
27
|
+
.replace(/\b(?:hqk_|xox[abprs]-|github_pat_|gh[pousr]_)[A-Za-z0-9_-]+\b/gi, "[REDACTED]")
|
|
28
|
+
.replace(/\bAKIA[0-9A-Z]{16}\b/g, "[REDACTED]")
|
|
29
|
+
.replace(/\s+/g, " ")
|
|
30
|
+
.trim()
|
|
31
|
+
.slice(0, REDACTED_TEXT_MAX_LENGTH);
|
|
32
|
+
}
|
|
33
|
+
//# sourceMappingURL=redact-error-text.js.map
|
|
@@ -3,22 +3,22 @@
|
|
|
3
3
|
* messages are already part of a user-facing protocol contract. Unknown
|
|
4
4
|
* exceptions remain Sentry-only so local implementation details and secrets
|
|
5
5
|
* are not printed indiscriminately.
|
|
6
|
+
*
|
|
7
|
+
* The redaction chain lives in `redact-error-text.ts` so the EXPECTED print
|
|
8
|
+
* path (which prints `err.message` directly from the top-level handler) can
|
|
9
|
+
* scrub the same upstream-minted text with the same rules.
|
|
6
10
|
*/
|
|
7
11
|
export declare function unexpectedCliErrorMessage(err: unknown): string | null;
|
|
8
12
|
/**
|
|
9
|
-
*
|
|
13
|
+
* Last-resort operator message for an error the CLI has no specific handling
|
|
14
|
+
* for. Redacted through the same chain as the integrations path; falls back to
|
|
15
|
+
* a fixed string when the value carries no usable message.
|
|
10
16
|
*
|
|
11
|
-
* Exists so the top-level handler can ALWAYS emit a diagnostic.
|
|
17
|
+
* Exists so the top-level handler can ALWAYS emit a diagnostic. It previously
|
|
12
18
|
* printed only when unexpectedCliErrorMessage() returned a value, and that
|
|
13
19
|
* returns null for everything except IntegrationsCliError — so any other
|
|
14
20
|
* failure (a qmd crash, for instance) exited 1 with zero bytes on both
|
|
15
21
|
* streams, which is indistinguishable from success-with-no-output.
|
|
16
22
|
*/
|
|
17
|
-
export declare function redactForOperator(raw: string): string;
|
|
18
|
-
/**
|
|
19
|
-
* Last-resort operator message for an error the CLI has no specific handling
|
|
20
|
-
* for. Redacted through the same chain as the integrations path; falls back to
|
|
21
|
-
* a fixed string when the value carries no usable message.
|
|
22
|
-
*/
|
|
23
23
|
export declare function fallbackOperatorMessage(err: unknown): string;
|
|
24
24
|
//# sourceMappingURL=unexpected-cli-error.d.ts.map
|
|
@@ -1,48 +1,37 @@
|
|
|
1
1
|
import { IntegrationsCliError } from "../commands/integrations.js";
|
|
2
|
+
import { redactErrorText } from "./redact-error-text.js";
|
|
2
3
|
/**
|
|
3
4
|
* Return a bounded operator-facing diagnostic for unexpected errors whose
|
|
4
5
|
* messages are already part of a user-facing protocol contract. Unknown
|
|
5
6
|
* exceptions remain Sentry-only so local implementation details and secrets
|
|
6
7
|
* are not printed indiscriminately.
|
|
8
|
+
*
|
|
9
|
+
* The redaction chain lives in `redact-error-text.ts` so the EXPECTED print
|
|
10
|
+
* path (which prints `err.message` directly from the top-level handler) can
|
|
11
|
+
* scrub the same upstream-minted text with the same rules.
|
|
7
12
|
*/
|
|
8
13
|
export function unexpectedCliErrorMessage(err) {
|
|
9
14
|
if (!(err instanceof IntegrationsCliError) || err.expected)
|
|
10
15
|
return null;
|
|
11
|
-
return
|
|
16
|
+
return redactErrorText(err.message) || "Integration request failed";
|
|
12
17
|
}
|
|
13
18
|
/**
|
|
14
|
-
*
|
|
19
|
+
* Last-resort operator message for an error the CLI has no specific handling
|
|
20
|
+
* for. Redacted through the same chain as the integrations path; falls back to
|
|
21
|
+
* a fixed string when the value carries no usable message.
|
|
15
22
|
*
|
|
16
|
-
* Exists so the top-level handler can ALWAYS emit a diagnostic.
|
|
23
|
+
* Exists so the top-level handler can ALWAYS emit a diagnostic. It previously
|
|
17
24
|
* printed only when unexpectedCliErrorMessage() returned a value, and that
|
|
18
25
|
* returns null for everything except IntegrationsCliError — so any other
|
|
19
26
|
* failure (a qmd crash, for instance) exited 1 with zero bytes on both
|
|
20
27
|
* streams, which is indistinguishable from success-with-no-output.
|
|
21
28
|
*/
|
|
22
|
-
export function redactForOperator(raw) {
|
|
23
|
-
return raw
|
|
24
|
-
.replace(/-----BEGIN[^-]+PRIVATE KEY-----[\s\S]*?-----END[^-]+PRIVATE KEY-----/g, "[REDACTED]")
|
|
25
|
-
.replace(/\p{Cc}/gu, " ")
|
|
26
|
-
.replace(/\b(?:Bearer|Basic)\s+[A-Za-z0-9._~+/=-]+/gi, "[REDACTED]")
|
|
27
|
-
.replace(/\b(password|secret|client[_-]?secret|api[_-]?key|(?:id|access|refresh)[_-]?token|token|authorization)\s*[:=]\s*(?:"[^"]*"|'[^']*'|[^\s&"'<>;,}]+)/gi, (_match, label) => `${label}=[REDACTED]`)
|
|
28
|
-
.replace(/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g, "[REDACTED]")
|
|
29
|
-
.replace(/\b(?:hqk_|xox[abprs]-|github_pat_|gh[pousr]_)[A-Za-z0-9_-]+\b/gi, "[REDACTED]")
|
|
30
|
-
.replace(/\bAKIA[0-9A-Z]{16}\b/g, "[REDACTED]")
|
|
31
|
-
.replace(/\s+/g, " ")
|
|
32
|
-
.trim()
|
|
33
|
-
.slice(0, 1_000);
|
|
34
|
-
}
|
|
35
|
-
/**
|
|
36
|
-
* Last-resort operator message for an error the CLI has no specific handling
|
|
37
|
-
* for. Redacted through the same chain as the integrations path; falls back to
|
|
38
|
-
* a fixed string when the value carries no usable message.
|
|
39
|
-
*/
|
|
40
29
|
export function fallbackOperatorMessage(err) {
|
|
41
30
|
const raw = err instanceof Error
|
|
42
31
|
? `${err.name}: ${err.message}`
|
|
43
32
|
: typeof err === "string"
|
|
44
33
|
? err
|
|
45
34
|
: "";
|
|
46
|
-
return
|
|
35
|
+
return redactErrorText(raw) || "command failed with an unreported error";
|
|
47
36
|
}
|
|
48
37
|
//# sourceMappingURL=unexpected-cli-error.js.map
|
package/dist/utils/vault-api.js
CHANGED
|
@@ -3,6 +3,7 @@ import { Sentry } from '../sentry.js';
|
|
|
3
3
|
import { AuthError } from './auth-error.js';
|
|
4
4
|
import { CompanySelectionError } from './company-selection-error.js';
|
|
5
5
|
import { recordPlanLimitStatus } from '../lib/plan-limit-nag.js';
|
|
6
|
+
import { networkTransportErrorCode } from './network-transport-error.js';
|
|
6
7
|
/**
|
|
7
8
|
* Best-effort peek of a 2xx JSON body for plan-limit status (US-016).
|
|
8
9
|
*
|
|
@@ -134,15 +135,34 @@ export async function vaultApiFetch(opts) {
|
|
|
134
135
|
level: "info",
|
|
135
136
|
data: { url: safeUrl, method },
|
|
136
137
|
});
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
138
|
+
let response;
|
|
139
|
+
try {
|
|
140
|
+
response = await fetch(url.toString(), {
|
|
141
|
+
method,
|
|
142
|
+
headers: {
|
|
143
|
+
Authorization: `Bearer ${opts.token}`,
|
|
144
|
+
'Content-Type': 'application/json',
|
|
145
|
+
},
|
|
146
|
+
body: opts.body ? JSON.stringify(opts.body) : undefined,
|
|
147
|
+
signal: opts.signal,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
catch (err) {
|
|
151
|
+
// A transport failure never reaches the server, so the non-2xx breadcrumb
|
|
152
|
+
// below never fires and the run's last HTTP trace is the request that
|
|
153
|
+
// silently vanished (exactly what made HQ-CLI-G's event unreadable). Leave
|
|
154
|
+
// a bounded, non-secret trail — the already-redacted safeUrl plus the
|
|
155
|
+
// recognized transport code — then RE-THROW the original error untouched,
|
|
156
|
+
// so `main.ts`'s classifier stays the single decision point.
|
|
157
|
+
const code = networkTransportErrorCode(err);
|
|
158
|
+
Sentry.addBreadcrumb({
|
|
159
|
+
category: "http",
|
|
160
|
+
message: `${method} ${path} → transport error`,
|
|
161
|
+
level: "warning",
|
|
162
|
+
data: { url: safeUrl, method, ...(code ? { code } : {}) },
|
|
163
|
+
});
|
|
164
|
+
throw err;
|
|
165
|
+
}
|
|
146
166
|
if (!response.ok) {
|
|
147
167
|
Sentry.addBreadcrumb({
|
|
148
168
|
category: "http",
|