@indigoai-us/hq-cli 5.108.5 → 5.108.7
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 +134 -0
- package/dist/commands/auth.js +11 -4
- package/dist/commands/feedback.d.ts +38 -0
- package/dist/commands/feedback.js +80 -0
- package/dist/commands/signals.js +12 -0
- package/dist/commands/sources.js +12 -0
- package/dist/commands/whoami.js +7 -2
- package/dist/utils/cognito-session.d.ts +58 -16
- package/dist/utils/cognito-session.js +229 -39
- package/dist/utils/feedback-diagnostics.d.ts +11 -0
- package/dist/utils/feedback-log-bundle.d.ts +149 -0
- package/dist/utils/feedback-log-bundle.js +430 -0
- package/dist/utils/feedback-logs.d.ts +329 -0
- package/dist/utils/feedback-logs.js +669 -0
- package/package.json +2 -2
|
@@ -23,7 +23,7 @@ import * as os from "os";
|
|
|
23
23
|
import * as path from "path";
|
|
24
24
|
import * as yaml from "js-yaml";
|
|
25
25
|
import chalk from "chalk";
|
|
26
|
-
import { loadCachedTokens, isExpiring, refreshTokens, browserLogin, detectHqCoreVersion, isMachineIdentity, getValidMachineTokens, } from "@indigoai-us/hq-cloud";
|
|
26
|
+
import { loadCachedTokens, isExpiring, refreshTokens, browserLogin, detectHqCoreVersion, isMachineIdentity, getValidMachineTokens, CognitoRefreshError, accessTokenFingerprint, invalidateCachedTokensByFingerprint, } from "@indigoai-us/hq-cloud";
|
|
27
27
|
import { CLI_NAME, CLI_VERSION } from "../cli-version.js";
|
|
28
28
|
import { daemonDir } from "../lib/mesh/live/daemon/paths.js";
|
|
29
29
|
import { workMeshRoot } from "../lib/mesh/live/paths.js";
|
|
@@ -248,40 +248,36 @@ export function machineTokenCacheFile(home = os.homedir(), env = process.env) {
|
|
|
248
248
|
}
|
|
249
249
|
/**
|
|
250
250
|
* True when `HQ_MACHINE_CREDS_FILE` is explicitly set (non-empty) in `env`.
|
|
251
|
-
*
|
|
252
|
-
*
|
|
253
|
-
*
|
|
251
|
+
* Explicit env remains the primary opt-in for daemon units. Fresh agent boxes
|
|
252
|
+
* without the env still mint via {@link wantsMachineCognitoTokens}'s
|
|
253
|
+
* default-file fallback when no usable person session exists.
|
|
254
254
|
*/
|
|
255
255
|
export function isMachineCredsFileEnvSet(env = process.env) {
|
|
256
256
|
const raw = env.HQ_MACHINE_CREDS_FILE;
|
|
257
257
|
return typeof raw === "string" && raw.trim().length > 0;
|
|
258
258
|
}
|
|
259
|
+
/** Default machine-creds path hq-cloud also checks when the env is unset. */
|
|
260
|
+
export function defaultMachineCredsFile(home = os.homedir()) {
|
|
261
|
+
return path.join(home, ".hq-agent", "machine-creds.json");
|
|
262
|
+
}
|
|
259
263
|
/**
|
|
260
|
-
*
|
|
261
|
-
*
|
|
262
|
-
*
|
|
263
|
-
* never flips other CLI commands onto the machine path.
|
|
264
|
+
* Align `process.env.HQ_MACHINE_CREDS_FILE` with a (possibly synthetic) `env`
|
|
265
|
+
* for the duration of `fn`, so hq-cloud's `isMachineIdentity()` sees the same
|
|
266
|
+
* value doctor / tests intended.
|
|
264
267
|
*/
|
|
265
|
-
|
|
266
|
-
if (options.tokenSource === "person")
|
|
267
|
-
return false;
|
|
268
|
-
if (options.tokenSource === "machine") {
|
|
269
|
-
return isMachineIdentity();
|
|
270
|
-
}
|
|
271
|
-
if (!isMachineCredsFileEnvSet(env))
|
|
272
|
-
return false;
|
|
273
|
-
// Align process.env for hq-cloud's isMachineIdentity() when a synthetic env
|
|
274
|
-
// is supplied (doctor / tests).
|
|
268
|
+
function withAlignedMachineCredsEnv(env, fn) {
|
|
275
269
|
const prevCreds = process.env.HQ_MACHINE_CREDS_FILE;
|
|
276
270
|
const nextCreds = env.HQ_MACHINE_CREDS_FILE;
|
|
277
271
|
if (nextCreds !== prevCreds) {
|
|
278
|
-
if (nextCreds === undefined)
|
|
272
|
+
if (nextCreds === undefined || String(nextCreds).trim() === "") {
|
|
279
273
|
delete process.env.HQ_MACHINE_CREDS_FILE;
|
|
280
|
-
|
|
274
|
+
}
|
|
275
|
+
else {
|
|
281
276
|
process.env.HQ_MACHINE_CREDS_FILE = nextCreds;
|
|
277
|
+
}
|
|
282
278
|
}
|
|
283
279
|
try {
|
|
284
|
-
return
|
|
280
|
+
return fn();
|
|
285
281
|
}
|
|
286
282
|
finally {
|
|
287
283
|
if (nextCreds !== prevCreds) {
|
|
@@ -293,9 +289,104 @@ export function wantsMachineCognitoTokens(options = {}, env = process.env) {
|
|
|
293
289
|
}
|
|
294
290
|
}
|
|
295
291
|
/**
|
|
296
|
-
*
|
|
297
|
-
*
|
|
298
|
-
*
|
|
292
|
+
* On-disk person Cognito cache path for `env` (`HQ_STATE_DIR` or `~/.hq`).
|
|
293
|
+
* Deliberately does not use hq-cloud's `loadCachedTokens()`: after a machine
|
|
294
|
+
* mint in this process, that helper returns in-memory machine tokens when the
|
|
295
|
+
* person file is absent, which would falsely block the fresh-box fallback.
|
|
296
|
+
*/
|
|
297
|
+
export function personTokenCacheFile(home = os.homedir(), env = process.env) {
|
|
298
|
+
const override = env.HQ_STATE_DIR?.trim();
|
|
299
|
+
const stateDir = override && override.length > 0 ? override : path.join(home, ".hq");
|
|
300
|
+
return path.join(stateDir, "cognito-tokens.json");
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* True when a usable person Cognito cache file exists under the effective
|
|
304
|
+
* `HQ_STATE_DIR` (or `~/.hq`). Presence alone keeps human commands on the
|
|
305
|
+
* person path even when the default machine-creds file is also on disk
|
|
306
|
+
* (no-flip guarantee).
|
|
307
|
+
*/
|
|
308
|
+
export function hasPersonCachedSession(env = process.env) {
|
|
309
|
+
try {
|
|
310
|
+
const file = personTokenCacheFile(os.homedir(), env);
|
|
311
|
+
if (!fs.existsSync(file))
|
|
312
|
+
return false;
|
|
313
|
+
const raw = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
314
|
+
if (typeof raw.accessToken !== "string" ||
|
|
315
|
+
raw.accessToken.length === 0 ||
|
|
316
|
+
typeof raw.idToken !== "string" ||
|
|
317
|
+
raw.idToken.length === 0) {
|
|
318
|
+
return false;
|
|
319
|
+
}
|
|
320
|
+
// Definitive refresh rejection writes `${file}.invalid.<fingerprint>`;
|
|
321
|
+
// treat that as no usable person session so status/whoami report machine.
|
|
322
|
+
const marker = `${file}.invalid.${accessTokenFingerprint(raw.accessToken)}`;
|
|
323
|
+
if (fs.existsSync(marker))
|
|
324
|
+
return false;
|
|
325
|
+
return true;
|
|
326
|
+
}
|
|
327
|
+
catch {
|
|
328
|
+
return false;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* Whether hq-cloud currently sees readable machine creds (env override or
|
|
333
|
+
* default `~/.hq-agent/machine-creds.json`), honoring a synthetic `env`.
|
|
334
|
+
*/
|
|
335
|
+
function probeMachineIdentity(env) {
|
|
336
|
+
return withAlignedMachineCredsEnv(env, () => isMachineIdentity());
|
|
337
|
+
}
|
|
338
|
+
/**
|
|
339
|
+
* Fresh-box / Outpost bootstrap: env unset, not forced to person, no person
|
|
340
|
+
* cache, and the default machine-creds file is readable. Distinct from
|
|
341
|
+
* explicit `HQ_MACHINE_CREDS_FILE` / `{ tokenSource: "machine" }` opt-in.
|
|
342
|
+
*/
|
|
343
|
+
export function isDefaultMachineCredsFallback(options = {}, env = process.env) {
|
|
344
|
+
if (options.tokenSource === "person")
|
|
345
|
+
return false;
|
|
346
|
+
if (options.tokenSource === "machine")
|
|
347
|
+
return false;
|
|
348
|
+
if (isMachineCredsFileEnvSet(env))
|
|
349
|
+
return false;
|
|
350
|
+
if (hasPersonCachedSession(env))
|
|
351
|
+
return false;
|
|
352
|
+
return probeMachineIdentity(env);
|
|
353
|
+
}
|
|
354
|
+
function logDefaultMachineCredsFallback() {
|
|
355
|
+
console.error(chalk.dim("machine identity from default creds file"));
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* Whether this call should mint/cache via machine creds (USER_PASSWORD_AUTH).
|
|
359
|
+
*
|
|
360
|
+
* Machine path when:
|
|
361
|
+
* - explicit `{ tokenSource: "machine" }` and creds are readable, or
|
|
362
|
+
* - `HQ_MACHINE_CREDS_FILE` is set and readable, or
|
|
363
|
+
* - fresh-box fallback: env unset, no person cache, default
|
|
364
|
+
* `~/.hq-agent/machine-creds.json` readable.
|
|
365
|
+
*
|
|
366
|
+
* Explicit `{ tokenSource: "person" }` never uses machine creds. A present
|
|
367
|
+
* person cache keeps the person path even when the default creds file exists
|
|
368
|
+
* (do not flip a human's commands onto machine tokens).
|
|
369
|
+
*/
|
|
370
|
+
export function wantsMachineCognitoTokens(options = {}, env = process.env) {
|
|
371
|
+
if (options.tokenSource === "person")
|
|
372
|
+
return false;
|
|
373
|
+
if (options.tokenSource === "machine") {
|
|
374
|
+
return probeMachineIdentity(env);
|
|
375
|
+
}
|
|
376
|
+
if (isMachineCredsFileEnvSet(env)) {
|
|
377
|
+
return probeMachineIdentity(env);
|
|
378
|
+
}
|
|
379
|
+
// Fresh-box fallback — default creds file only when no person session.
|
|
380
|
+
if (hasPersonCachedSession(env))
|
|
381
|
+
return false;
|
|
382
|
+
return probeMachineIdentity(env);
|
|
383
|
+
}
|
|
384
|
+
/**
|
|
385
|
+
* Which Cognito token source this process will use for doctor / status / whoami.
|
|
386
|
+
* Matches {@link wantsMachineCognitoTokens}: env opt-in, or default-file
|
|
387
|
+
* fallback when no usable person session is cached (absent, or marked rejected
|
|
388
|
+
* after a definitive Cognito refresh refusal). Transient refresh failures do
|
|
389
|
+
* not flip this to machine.
|
|
299
390
|
*/
|
|
300
391
|
export function resolveCognitoTokenSource(env = process.env) {
|
|
301
392
|
return wantsMachineCognitoTokens({}, env) ? "machine" : "person";
|
|
@@ -400,16 +491,13 @@ export function describeCognitoTokenSource(opts = {}) {
|
|
|
400
491
|
* Run `fn` with `HQ_STATE_DIR` pointed at the machine/daemon token cache so
|
|
401
492
|
* hq-cloud's mint writes never touch the person login file.
|
|
402
493
|
*
|
|
403
|
-
*
|
|
404
|
-
*
|
|
494
|
+
* Always redirects — even when the caller already set `HQ_STATE_DIR` (person
|
|
495
|
+
* cache / tests). Override the machine cache location with
|
|
496
|
+
* `HQ_MACHINE_TOKEN_STATE_DIR` instead. Restores the prior env afterwards.
|
|
405
497
|
*/
|
|
406
498
|
export async function withMachineTokenStateDir(fn, opts = {}) {
|
|
407
499
|
const home = opts.home ?? os.homedir();
|
|
408
500
|
const env = opts.env ?? process.env;
|
|
409
|
-
const existing = env.HQ_STATE_DIR?.trim() || process.env.HQ_STATE_DIR?.trim() || "";
|
|
410
|
-
if (existing) {
|
|
411
|
-
return fn();
|
|
412
|
-
}
|
|
413
501
|
const prev = process.env.HQ_STATE_DIR;
|
|
414
502
|
process.env.HQ_STATE_DIR = machineTokenStateDir(home, env);
|
|
415
503
|
try {
|
|
@@ -425,12 +513,54 @@ export async function withMachineTokenStateDir(fn, opts = {}) {
|
|
|
425
513
|
async function ensureMachineTokens() {
|
|
426
514
|
return withMachineTokenStateDir(() => getValidMachineTokens(DEFAULT_COGNITO));
|
|
427
515
|
}
|
|
516
|
+
/**
|
|
517
|
+
* Mint machine tokens (daemon state dir cache). Logs once when the fresh-box
|
|
518
|
+
* default-creds fallback is what selected the machine path.
|
|
519
|
+
*/
|
|
520
|
+
async function ensureMachineTokensMaybeFallback(options = {}) {
|
|
521
|
+
if (isDefaultMachineCredsFallback(options)) {
|
|
522
|
+
logDefaultMachineCredsFallback();
|
|
523
|
+
}
|
|
524
|
+
return ensureMachineTokens();
|
|
525
|
+
}
|
|
526
|
+
/**
|
|
527
|
+
* After a person-cache miss or refresh failure: if the default machine-creds
|
|
528
|
+
* file is readable and the caller did not force person mode, mint from it.
|
|
529
|
+
* Returns null when the fallback does not apply.
|
|
530
|
+
*/
|
|
531
|
+
async function tryDefaultMachineCredsFallback(options = {}) {
|
|
532
|
+
if (options.tokenSource === "person")
|
|
533
|
+
return null;
|
|
534
|
+
if (isMachineCredsFileEnvSet())
|
|
535
|
+
return null;
|
|
536
|
+
if (!isMachineIdentity())
|
|
537
|
+
return null;
|
|
538
|
+
logDefaultMachineCredsFallback();
|
|
539
|
+
return ensureMachineTokens();
|
|
540
|
+
}
|
|
541
|
+
/**
|
|
542
|
+
* True when a person-token refresh failed with a definitive Cognito auth
|
|
543
|
+
* rejection (refresh token invalid/revoked). Transient network/5xx/timeouts
|
|
544
|
+
* must NOT trigger machine-identity fallback.
|
|
545
|
+
*/
|
|
546
|
+
export function isDefinitivePersonAuthRejection(err) {
|
|
547
|
+
if (err instanceof CognitoRefreshError) {
|
|
548
|
+
return err.requiresReauth === true;
|
|
549
|
+
}
|
|
550
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
551
|
+
return /NotAuthorizedException|invalid_grant|invalid refresh token/i.test(msg);
|
|
552
|
+
}
|
|
553
|
+
function markPersonSessionRejected(accessToken) {
|
|
554
|
+
invalidateCachedTokensByFingerprint(accessTokenFingerprint(accessToken));
|
|
555
|
+
}
|
|
428
556
|
/**
|
|
429
557
|
* Return a non-expired Cognito access token, refreshing or browser-logging-in
|
|
430
558
|
* as needed. Person cache lives at ~/.hq/cognito-tokens.json. Machine minting
|
|
431
|
-
*
|
|
432
|
-
*
|
|
433
|
-
*
|
|
559
|
+
* uses `HQ_MACHINE_CREDS_FILE` / `{ tokenSource: "machine" }`, or the fresh-box
|
|
560
|
+
* fallback when the default creds file is readable and no person session
|
|
561
|
+
* exists (or person refresh fails with a definitive auth rejection).
|
|
562
|
+
* Machine tokens always cache under the dedicated machine/daemon state dir
|
|
563
|
+
* (never the caller's `HQ_STATE_DIR` person cache).
|
|
434
564
|
*
|
|
435
565
|
* Pass `interactive: false` from automated contexts (e.g. the `hq-auth-refresh`
|
|
436
566
|
* bin invoked by the deploy skill) where failing fast is better than opening
|
|
@@ -445,10 +575,10 @@ export async function ensureCognitoToken(options = {}) {
|
|
|
445
575
|
// so vault-API calls from a machine identity send the ID token. The cached
|
|
446
576
|
// token file keeps correct field semantics (real access token in
|
|
447
577
|
// accessToken) for consumers that need token_use=access, e.g. the deploy
|
|
448
|
-
// API via the deploy skill. Cache path: daemon state dir
|
|
449
|
-
//
|
|
578
|
+
// API via the deploy skill. Cache path: dedicated machine/daemon state dir
|
|
579
|
+
// (isolated even when HQ_STATE_DIR points at a person cache).
|
|
450
580
|
if (wantsMachineCognitoTokens(options)) {
|
|
451
|
-
const machine = await
|
|
581
|
+
const machine = await ensureMachineTokensMaybeFallback(options);
|
|
452
582
|
return machine.idToken;
|
|
453
583
|
}
|
|
454
584
|
const cached = loadCachedTokens();
|
|
@@ -464,11 +594,24 @@ export async function ensureCognitoToken(options = {}) {
|
|
|
464
594
|
return refreshed.accessToken;
|
|
465
595
|
}
|
|
466
596
|
catch (err) {
|
|
597
|
+
// Transient/network/server errors stay person errors — never mint machine.
|
|
598
|
+
if (!isDefinitivePersonAuthRejection(err)) {
|
|
599
|
+
throw err;
|
|
600
|
+
}
|
|
601
|
+
markPersonSessionRejected(cached.accessToken);
|
|
602
|
+
const fallback = await tryDefaultMachineCredsFallback(options);
|
|
603
|
+
if (fallback)
|
|
604
|
+
return fallback.idToken;
|
|
467
605
|
if (interactive) {
|
|
468
606
|
console.error(chalk.dim(` Refresh failed (${err instanceof Error ? err.message : err}), falling back to browser login`));
|
|
469
607
|
}
|
|
470
608
|
}
|
|
471
609
|
}
|
|
610
|
+
else {
|
|
611
|
+
const fallback = await tryDefaultMachineCredsFallback(options);
|
|
612
|
+
if (fallback)
|
|
613
|
+
return fallback.idToken;
|
|
614
|
+
}
|
|
472
615
|
if (!interactive) {
|
|
473
616
|
throw new Error("No valid HQ session and interactive login is disabled. Run `hq login` first.");
|
|
474
617
|
}
|
|
@@ -490,7 +633,7 @@ export async function ensureCognitoToken(options = {}) {
|
|
|
490
633
|
export async function ensureCognitoIdToken(options = {}) {
|
|
491
634
|
const interactive = options.interactive ?? true;
|
|
492
635
|
if (wantsMachineCognitoTokens(options)) {
|
|
493
|
-
const machine = await
|
|
636
|
+
const machine = await ensureMachineTokensMaybeFallback(options);
|
|
494
637
|
return machine.idToken;
|
|
495
638
|
}
|
|
496
639
|
const cached = loadCachedTokens();
|
|
@@ -506,11 +649,23 @@ export async function ensureCognitoIdToken(options = {}) {
|
|
|
506
649
|
return refreshed.idToken;
|
|
507
650
|
}
|
|
508
651
|
catch (err) {
|
|
652
|
+
if (!isDefinitivePersonAuthRejection(err)) {
|
|
653
|
+
throw err;
|
|
654
|
+
}
|
|
655
|
+
markPersonSessionRejected(cached.accessToken);
|
|
656
|
+
const fallback = await tryDefaultMachineCredsFallback(options);
|
|
657
|
+
if (fallback)
|
|
658
|
+
return fallback.idToken;
|
|
509
659
|
if (interactive) {
|
|
510
660
|
console.error(chalk.dim(` Refresh failed (${err instanceof Error ? err.message : err}), falling back to browser login`));
|
|
511
661
|
}
|
|
512
662
|
}
|
|
513
663
|
}
|
|
664
|
+
else {
|
|
665
|
+
const fallback = await tryDefaultMachineCredsFallback(options);
|
|
666
|
+
if (fallback)
|
|
667
|
+
return fallback.idToken;
|
|
668
|
+
}
|
|
514
669
|
if (!interactive) {
|
|
515
670
|
throw new Error("No valid HQ session and interactive login is disabled. Run `hq login` first.");
|
|
516
671
|
}
|
|
@@ -551,10 +706,11 @@ export function buildVaultConfig(authToken) {
|
|
|
551
706
|
export async function refreshCachedSession(options = {}) {
|
|
552
707
|
// Machine identities have no refresh token; ensure a valid cached machine
|
|
553
708
|
// session without forcing a re-mint when the cache is already healthy.
|
|
554
|
-
// Tokens land under the daemon state dir
|
|
709
|
+
// Tokens land under the dedicated machine/daemon state dir.
|
|
710
|
+
// Includes the fresh-box default-creds fallback when no person session.
|
|
555
711
|
if (wantsMachineCognitoTokens(options)) {
|
|
556
712
|
try {
|
|
557
|
-
await
|
|
713
|
+
await ensureMachineTokensMaybeFallback(options);
|
|
558
714
|
return { refreshed: true };
|
|
559
715
|
}
|
|
560
716
|
catch (err) {
|
|
@@ -566,6 +722,20 @@ export async function refreshCachedSession(options = {}) {
|
|
|
566
722
|
}
|
|
567
723
|
const cached = loadCachedTokens();
|
|
568
724
|
if (!cached) {
|
|
725
|
+
// Person path had nothing; last chance is default machine creds (e.g. the
|
|
726
|
+
// cache was cleared between the wantsMachine check and now, or identity
|
|
727
|
+
// became readable). Prefer machine mint over "no cached session" on boxes.
|
|
728
|
+
try {
|
|
729
|
+
const fallback = await tryDefaultMachineCredsFallback(options);
|
|
730
|
+
if (fallback)
|
|
731
|
+
return { refreshed: true };
|
|
732
|
+
}
|
|
733
|
+
catch (err) {
|
|
734
|
+
return {
|
|
735
|
+
refreshed: false,
|
|
736
|
+
reason: err instanceof Error ? err.message : String(err),
|
|
737
|
+
};
|
|
738
|
+
}
|
|
569
739
|
return { refreshed: false, reason: "no cached session" };
|
|
570
740
|
}
|
|
571
741
|
if (!isExpiring(cached, 120)) {
|
|
@@ -576,6 +746,26 @@ export async function refreshCachedSession(options = {}) {
|
|
|
576
746
|
return { refreshed: true };
|
|
577
747
|
}
|
|
578
748
|
catch (err) {
|
|
749
|
+
// Transient/network/server refresh errors stay person failures.
|
|
750
|
+
if (!isDefinitivePersonAuthRejection(err)) {
|
|
751
|
+
return {
|
|
752
|
+
refreshed: false,
|
|
753
|
+
reason: err instanceof Error ? err.message : String(err),
|
|
754
|
+
};
|
|
755
|
+
}
|
|
756
|
+
// Definitive rejection — person session is dead; mark it and fall back.
|
|
757
|
+
markPersonSessionRejected(cached.accessToken);
|
|
758
|
+
try {
|
|
759
|
+
const fallback = await tryDefaultMachineCredsFallback(options);
|
|
760
|
+
if (fallback)
|
|
761
|
+
return { refreshed: true };
|
|
762
|
+
}
|
|
763
|
+
catch (mintErr) {
|
|
764
|
+
return {
|
|
765
|
+
refreshed: false,
|
|
766
|
+
reason: mintErr instanceof Error ? mintErr.message : String(mintErr),
|
|
767
|
+
};
|
|
768
|
+
}
|
|
579
769
|
return {
|
|
580
770
|
refreshed: false,
|
|
581
771
|
reason: err instanceof Error ? err.message : String(err),
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type VersionInfo } from "./feedback-versions.js";
|
|
2
|
+
import type { FeedbackLogsBlob } from "./feedback-logs.js";
|
|
2
3
|
export interface GitContext {
|
|
3
4
|
branch: string | null;
|
|
4
5
|
head: string | null;
|
|
@@ -23,6 +24,16 @@ export interface DiagnosticsBlob {
|
|
|
23
24
|
cwd: string;
|
|
24
25
|
git: GitContext;
|
|
25
26
|
recentSentryBreadcrumbs: unknown[];
|
|
27
|
+
/**
|
|
28
|
+
* Redacted tails of the submitter's `~/.hq` log files.
|
|
29
|
+
*
|
|
30
|
+
* Attached by `submitFeedback`, NOT by `collectDiagnostics`, because the
|
|
31
|
+
* size budget can only be computed once the rest of the request body is
|
|
32
|
+
* known (the server caps the whole body at 64 KiB). Absent when the user
|
|
33
|
+
* opted out (`--no-logs` / `HQ_FEEDBACK_LOGS=0`), when no eligible log file
|
|
34
|
+
* exists, or when the body left no headroom.
|
|
35
|
+
*/
|
|
36
|
+
logs?: FeedbackLogsBlob;
|
|
26
37
|
}
|
|
27
38
|
export declare function sanitizeArgv(argv: string[]): string[];
|
|
28
39
|
export declare function collectDiagnostics(): DiagnosticsBlob;
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Full-history log bundle for `hq feedback` — the out-of-band companion to the
|
|
3
|
+
* inline `diagnostics.logs` blob in `feedback-logs.ts`.
|
|
4
|
+
*
|
|
5
|
+
* Inline logs are capped twice over: the feedback endpoint rejects a request
|
|
6
|
+
* body above 64 KiB, and `diagnostics` is written into a DynamoDB item, which
|
|
7
|
+
* caps at 400 KB. Neither can be tuned into carrying a real log history, so the
|
|
8
|
+
* inline blob is deliberately a ~40 KiB summary of 16 KiB tails. This module
|
|
9
|
+
* produces the other half: a gzipped bundle uploaded direct to S3, carrying the
|
|
10
|
+
* files whole rather than in tail-sized slivers.
|
|
11
|
+
*
|
|
12
|
+
* Three properties are load-bearing and none may be traded away:
|
|
13
|
+
*
|
|
14
|
+
* 1. SAME ALLOWLIST. Discovery reuses `feedback-logs.ts` verbatim —
|
|
15
|
+
* `discoverLogFiles`, `discoverStateFiles`, `discoverFlatDirFiles`,
|
|
16
|
+
* `walkJsonFiles`. The bundle carries MORE OF the same files, never more
|
|
17
|
+
* files. Credential material lives outside `~/.hq` (`~/.codex/auth.json`,
|
|
18
|
+
* `~/.hq-agent/machine-creds.json`), so widening discovery here — not
|
|
19
|
+
* raising the size — is what would turn this into an exfiltration bug.
|
|
20
|
+
*
|
|
21
|
+
* 2. SAME REDACTION. Every line goes through `redactLogText` before it is
|
|
22
|
+
* compressed. Shipping a raw archive would be far simpler and would
|
|
23
|
+
* silently undo the entire security model of the inline path.
|
|
24
|
+
*
|
|
25
|
+
* 3. BOUNDED MEMORY. 50 MB compressed is roughly a gigabyte of raw log text
|
|
26
|
+
* at the ratio these files compress at. Nothing is ever fully materialised:
|
|
27
|
+
* files are read in slices, redacted a line at a time, and streamed into
|
|
28
|
+
* gzip, with only the compressed output retained.
|
|
29
|
+
*
|
|
30
|
+
* Output format — gzipped NDJSON, one JSON record per line:
|
|
31
|
+
* {"kind":"manifest","version":1,...} exactly one, first
|
|
32
|
+
* {"kind":"file","name":"logs/hq-sync.log",...} one per file
|
|
33
|
+
* {"kind":"chunk","name":"logs/hq-sync.log","seq":0,...} many per file
|
|
34
|
+
* {"kind":"summary","fileCount":12,...} exactly one, last
|
|
35
|
+
*
|
|
36
|
+
* NDJSON rather than tar so there is no archive dependency, so a truncated
|
|
37
|
+
* bundle is still parseable line-by-line up to the cut, and so the records
|
|
38
|
+
* carry the same redaction metadata the inline blob already reports.
|
|
39
|
+
*/
|
|
40
|
+
import { type LogCandidate } from "./feedback-logs.js";
|
|
41
|
+
import { vaultApiFetch } from "./vault-api.js";
|
|
42
|
+
/**
|
|
43
|
+
* Compressed ceiling. Mirrors `MAX_LOG_BUNDLE_BYTES` in the hq-pro handler
|
|
44
|
+
* `feedback-log-bundles.ts`, which refuses to presign above it — the two must
|
|
45
|
+
* stay in step or the CLI will build bundles the server will not accept.
|
|
46
|
+
*/
|
|
47
|
+
export declare const LOG_BUNDLE_MAX_BYTES: number;
|
|
48
|
+
/**
|
|
49
|
+
* Headroom between the size we stop feeding at and the hard cap.
|
|
50
|
+
*
|
|
51
|
+
* gzip reports compressed bytes only as its internal buffer flushes, so the
|
|
52
|
+
* running total lags the bytes actually consumed. The lag is bounded by that
|
|
53
|
+
* buffer (tens of KiB); a 1 MiB margin covers it with three orders of magnitude
|
|
54
|
+
* to spare, and the final size is asserted against the real cap regardless.
|
|
55
|
+
*/
|
|
56
|
+
export declare const LOG_BUNDLE_SAFETY_MARGIN_BYTES: number;
|
|
57
|
+
/**
|
|
58
|
+
* Per-file raw ceiling. The desktop logger rotates at 32 MiB (hq-desktop-core
|
|
59
|
+
* `logfile.rs`), so this admits a full generation with headroom while stopping
|
|
60
|
+
* one pathological file from consuming the whole bundle. Files above it are
|
|
61
|
+
* read from the TAIL — the end of a log is what explains a failure.
|
|
62
|
+
*/
|
|
63
|
+
export declare const LOG_BUNDLE_MAX_FILE_RAW_BYTES: number;
|
|
64
|
+
export interface LogBundleResult {
|
|
65
|
+
/** The gzipped NDJSON bytes, ready to PUT. */
|
|
66
|
+
gzip: Buffer;
|
|
67
|
+
/** `gzip.byteLength` — what the presign request must declare. */
|
|
68
|
+
sizeBytes: number;
|
|
69
|
+
/** How many files contributed at least one chunk. */
|
|
70
|
+
fileCount: number;
|
|
71
|
+
/** True when the size cap stopped collection before every file was read. */
|
|
72
|
+
truncated: boolean;
|
|
73
|
+
/** Raw (pre-compression, post-redaction) bytes read into the bundle. */
|
|
74
|
+
rawBytes: number;
|
|
75
|
+
/** Total redacted spans across every file. Non-zero is expected and fine. */
|
|
76
|
+
redactions: number;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* What a submission records about its uploaded bundle. Must stay in step with
|
|
80
|
+
* `LogBundleRef` in the hq-pro handler `feedback-log-bundles.ts`, which
|
|
81
|
+
* re-validates every field before persisting it.
|
|
82
|
+
*/
|
|
83
|
+
export interface LogBundleRef {
|
|
84
|
+
key: string;
|
|
85
|
+
sizeBytes: number;
|
|
86
|
+
fileCount: number;
|
|
87
|
+
truncated: boolean;
|
|
88
|
+
}
|
|
89
|
+
export interface BuildLogBundleOptions {
|
|
90
|
+
/** Compressed ceiling. Clamped to {@link LOG_BUNDLE_MAX_BYTES}. */
|
|
91
|
+
maxBytes?: number;
|
|
92
|
+
/** Override `~/.hq` (tests). */
|
|
93
|
+
hqDir?: string;
|
|
94
|
+
/** Override the home directory used to derive `~/.hq` (tests). */
|
|
95
|
+
homeDir?: string;
|
|
96
|
+
/** Override the per-file raw ceiling (tests). */
|
|
97
|
+
maxFileRawBytes?: number;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Order candidates so that, when the cap truncates collection, what survives is
|
|
101
|
+
* what a triager reads first.
|
|
102
|
+
*
|
|
103
|
+
* State documents lead: they are tiny and answer questions a log cannot (which
|
|
104
|
+
* operation is claimed, what the sync cursor is). Log files follow, newest
|
|
105
|
+
* first. Reversing this would let one large old log crowd out every status
|
|
106
|
+
* file — the same class of ordering defect that let ordinary lock entries
|
|
107
|
+
* crowd out a stale claim in the inline collector.
|
|
108
|
+
*/
|
|
109
|
+
export declare function orderBundleCandidates(hqDir: string): LogCandidate[];
|
|
110
|
+
/**
|
|
111
|
+
* Read `absPath` in slices, redacting whole lines, invoking `onChunk` with
|
|
112
|
+
* roughly {@link CHUNK_TEXT_BYTES} of redacted text at a time.
|
|
113
|
+
*
|
|
114
|
+
* Reads from the tail when the file exceeds `maxRawBytes`, and drops the first
|
|
115
|
+
* partial line after seeking so a chunk never begins mid-record. `onChunk`
|
|
116
|
+
* returns false to stop early (the cap was hit).
|
|
117
|
+
*/
|
|
118
|
+
export declare function streamRedactedFile(absPath: string, sizeBytes: number, maxRawBytes: number, chunkBytes: number, onChunk: (text: string, redactions: number) => Promise<boolean>): Promise<{
|
|
119
|
+
fromTail: boolean;
|
|
120
|
+
stopped: boolean;
|
|
121
|
+
}>;
|
|
122
|
+
/**
|
|
123
|
+
* Build a gzipped NDJSON bundle of the submitter's redacted `~/.hq` logs.
|
|
124
|
+
*
|
|
125
|
+
* Returns `undefined` when nothing eligible exists, so the caller can skip the
|
|
126
|
+
* upload entirely. Never throws: a bug report must not fail over its own
|
|
127
|
+
* diagnostics, so any unexpected error yields `undefined` and the submission
|
|
128
|
+
* proceeds with inline logs alone.
|
|
129
|
+
*/
|
|
130
|
+
export declare function buildLogBundle(opts?: BuildLogBundleOptions): Promise<LogBundleResult | undefined>;
|
|
131
|
+
/**
|
|
132
|
+
* Build, presign, and upload a log bundle; return the reference the submission
|
|
133
|
+
* should carry, or `undefined` if anything at all did not work out.
|
|
134
|
+
*
|
|
135
|
+
* Every failure path is silent and non-fatal by design. The bundle is an
|
|
136
|
+
* enrichment on top of the inline logs that already ship in the request body,
|
|
137
|
+
* so a missing endpoint, a disabled bucket, a refused presign, or a failed PUT
|
|
138
|
+
* must all degrade to "submit without it" rather than cost the user their bug
|
|
139
|
+
* report. In particular a 404 is expected and unremarkable while a CLI that
|
|
140
|
+
* knows about bundles is running against a server that does not yet.
|
|
141
|
+
*/
|
|
142
|
+
export declare function uploadLogBundle(opts: {
|
|
143
|
+
token: string;
|
|
144
|
+
enabled: boolean;
|
|
145
|
+
fetchImpl?: typeof fetch;
|
|
146
|
+
build?: typeof buildLogBundle;
|
|
147
|
+
apiFetch?: typeof vaultApiFetch;
|
|
148
|
+
}): Promise<LogBundleRef | undefined>;
|
|
149
|
+
//# sourceMappingURL=feedback-log-bundle.d.ts.map
|