@indigoai-us/hq-cli 5.129.0 → 5.131.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.
Files changed (35) hide show
  1. package/CHANGELOG.md +52 -1
  2. package/contracts/preflight/v1/fixtures.json +87 -0
  3. package/contracts/preflight/v1/schema.json +58 -0
  4. package/dist/command-catalog.generated.d.ts +27 -0
  5. package/dist/command-catalog.generated.js +34 -0
  6. package/dist/commands/core.js +7 -1
  7. package/dist/commands/dm.d.ts +2 -0
  8. package/dist/commands/dm.js +1 -1
  9. package/dist/commands/files.js +137 -9
  10. package/dist/commands/mesh.js +46 -12
  11. package/dist/lib/core-utils/sentry-report.d.ts +27 -7
  12. package/dist/lib/core-utils/sentry-report.js +428 -28
  13. package/dist/lib/mesh/api.d.ts +2 -0
  14. package/dist/lib/mesh/api.js +15 -2
  15. package/dist/lib/mesh/live/daemon/doctor.d.ts +4 -0
  16. package/dist/lib/mesh/live/daemon/doctor.js +10 -2
  17. package/dist/lib/mesh/live/daemon/run.js +3 -0
  18. package/dist/lib/mesh/live/emit-client.d.ts +2 -0
  19. package/dist/lib/mesh/live/emit-client.js +15 -8
  20. package/dist/lib/mesh/live/emit.d.ts +9 -0
  21. package/dist/lib/mesh/live/emit.js +28 -2
  22. package/dist/lib/mesh/live/flush.js +9 -3
  23. package/dist/lib/search-index/index.js +22 -3
  24. package/dist/lib/work-context/preflight-contract.d.ts +28 -0
  25. package/dist/lib/work-context/preflight-contract.js +85 -0
  26. package/dist/main.js +11 -1
  27. package/dist/utils/cognito-session.d.ts +39 -0
  28. package/dist/utils/cognito-session.js +119 -3
  29. package/dist/utils/environmental-error.d.ts +18 -0
  30. package/dist/utils/environmental-error.js +240 -7
  31. package/dist/utils/expected-cli-error.d.ts +2 -0
  32. package/dist/utils/expected-cli-error.js +4 -0
  33. package/dist/utils/vault-api.d.ts +1 -0
  34. package/dist/utils/vault-api.js +11 -3
  35. package/package.json +3 -2
@@ -33,6 +33,10 @@ export interface DaemonDoctorReport {
33
33
  tokenSource: CognitoTokenSource;
34
34
  /** human | agent | unknown — from token claims / machine signal. */
35
35
  actorKind: CognitoActorKind;
36
+ /** Subject in the person login file (`hq whoami`), or empty claims. */
37
+ whoamiSubject: string;
38
+ /** Subject the daemon will authenticate as (machine cache, else person file). */
39
+ daemonSubject: string;
36
40
  /** Credential vend refused (disabled / unsupported / 403). */
37
41
  presenceRefusal?: DaemonStateFile["presenceRefusal"];
38
42
  /** Companies the current presence credential did not include. */
@@ -5,7 +5,7 @@
5
5
  import * as fs from "node:fs";
6
6
  import * as os from "node:os";
7
7
  import * as path from "node:path";
8
- import { describeCognitoTokenSource, } from "../../../../utils/cognito-session.js";
8
+ import { describeCognitoTokenSource, formatCognitoSubject, loadMachineCachedTokens, readPersonTokenFile, } from "../../../../utils/cognito-session.js";
9
9
  import { listOutboxOperations, outboxStats, } from "../../../work-context/outbox.js";
10
10
  import { readSessionState } from "../../../work-context/state.js";
11
11
  import { workContextRoot } from "../../../work-context/paths.js";
@@ -145,6 +145,12 @@ export function collectDaemonDoctor(deps = {}) {
145
145
  unhealthyReasons.push(`presence credential refused (${presenceRefusal.code}); next retry ${presenceRefusal.nextRetryAt}`);
146
146
  }
147
147
  const auth = describeCognitoTokenSource({ home, env });
148
+ const whoamiTokens = readPersonTokenFile(home, env);
149
+ const daemonTokens = auth.tokenSource === "machine"
150
+ ? loadMachineCachedTokens(home, env)
151
+ : whoamiTokens;
152
+ const whoamiSubject = formatCognitoSubject(whoamiTokens);
153
+ const daemonSubject = formatCognitoSubject(daemonTokens);
148
154
  const emitState = readEmitState(meshRoot);
149
155
  const emitRetry = emitRetryDepth(meshRoot);
150
156
  if (emitRetry > 0) {
@@ -169,6 +175,8 @@ export function collectDaemonDoctor(deps = {}) {
169
175
  oldestSpoolAgeMs,
170
176
  tokenSource: auth.tokenSource,
171
177
  actorKind: auth.actorKind,
178
+ whoamiSubject,
179
+ daemonSubject,
172
180
  presenceRefusal,
173
181
  presenceDroppedCompanies,
174
182
  emitMode,
@@ -200,7 +208,7 @@ export function formatDaemonDoctor(report) {
200
208
  lines.push(`spool depth: ${report.spoolDepth}`, `held: ${report.heldCount}`, `held with no local evidence: ${report.heldWithNoLocalEvidence}`, `outbox depth: ${report.outboxDepth}`);
201
209
  }
202
210
  lines.push(`attributed by device default: ${report.attributedByDeviceDefault}`, `device-default authorization denials: ${report.deviceDefaultAuthorizationDenials}`, `dead-letter: ${report.deadLetterCount}`, `unattributed events: ${report.unattributedEvents.total} ` +
203
- `(spool=${report.unattributedEvents.spool} held=${report.unattributedEvents.held} dead-letter=${report.unattributedEvents.deadLetter})`, `token source: ${report.tokenSource}`, `actor kind: ${report.actorKind}`);
211
+ `(spool=${report.unattributedEvents.spool} held=${report.unattributedEvents.held} dead-letter=${report.unattributedEvents.deadLetter})`, `token source: ${report.tokenSource}`, `actor kind: ${report.actorKind}`, `identity whoami: ${report.whoamiSubject}`, `identity daemon: ${report.daemonSubject}`);
204
212
  if (report.emitMode === "legacy") {
205
213
  lines.push(`last flush: ${report.lastFlushAt ?? "(never)"}${report.lastFlushResult
206
214
  ? ` ok=${report.lastFlushResult.ok} posted=${report.lastFlushResult.posted}`
@@ -137,6 +137,9 @@ export async function runMeshDaemon(deps = {}) {
137
137
  // expires after ~1h to every vend/flush forever, so the second credential
138
138
  // renewal (~96 min in) and everything after it failed with HTTP 401 and the
139
139
  // daemon went dark once its IoT credentials expired.
140
+ // ensureCognitoToken refuses a refresh whose subject differs from the
141
+ // active person cache and restores the file. The daemon must not replace
142
+ // the operator login with another cached session.
140
143
  const defaultGetToken = async () => ensureCognitoToken({ interactive: false, tokenSource: "machine" });
141
144
  const getToken = deps.getToken ?? defaultGetToken;
142
145
  const resolveMembership = deps.resolveMembership ?? resolveActiveMembershipCompany;
@@ -23,6 +23,8 @@ export interface EmitEventResult {
23
23
  status: EmitEventStatus;
24
24
  /** Present when status != accepted (no_company_evidence | not_a_member | …). */
25
25
  reason?: string;
26
+ /** Server rejection code when status is rejected (`code`, else `reason`). */
27
+ code?: string;
26
28
  /** Resolved company (accepted, or unassigned-into-single-company). */
27
29
  companyUid?: string;
28
30
  }
@@ -58,10 +58,7 @@ export function parseEmitResults(body) {
58
58
  if (!status)
59
59
  continue;
60
60
  const res = { eventId, status };
61
- if (typeof r.reason === "string" && r.reason.trim())
62
- res.reason = r.reason.trim();
63
- else if (typeof r.code === "string" && r.code.trim())
64
- res.reason = r.code.trim();
61
+ applyEmitReasonAndCode(res, r);
65
62
  if (typeof r.companyUid === "string" && r.companyUid.trim()) {
66
63
  res.companyUid = r.companyUid.trim();
67
64
  }
@@ -86,16 +83,26 @@ export function parseEmitResults(body) {
86
83
  if (!eventId)
87
84
  continue;
88
85
  const res = { eventId, status: "rejected" };
89
- if (typeof r.reason === "string" && r.reason.trim())
90
- res.reason = r.reason.trim();
91
- else if (typeof r.code === "string" && r.code.trim())
92
- res.reason = r.code.trim();
86
+ applyEmitReasonAndCode(res, r);
93
87
  out.push(res);
94
88
  }
95
89
  }
96
90
  }
97
91
  return out.length > 0 ? out : null;
98
92
  }
93
+ function applyEmitReasonAndCode(res, raw) {
94
+ const reason = typeof raw.reason === "string" ? raw.reason.trim() : "";
95
+ const code = typeof raw.code === "string" ? raw.code.trim() : "";
96
+ if (reason)
97
+ res.reason = reason;
98
+ else if (code)
99
+ res.reason = code;
100
+ if (res.status !== "rejected")
101
+ return;
102
+ const rejectionCode = code || reason;
103
+ if (rejectionCode)
104
+ res.code = rejectionCode;
105
+ }
99
106
  function normalizeStatus(v) {
100
107
  if (typeof v !== "string")
101
108
  return null;
@@ -47,8 +47,17 @@ export interface EmitEventsSummary {
47
47
  retryDepth: number;
48
48
  batches: number;
49
49
  statuses: Record<EmitEventStatus, number>;
50
+ /** Server code for each terminally rejected event. */
51
+ rejections: Array<{
52
+ eventId: string;
53
+ code: string;
54
+ }>;
50
55
  lastPostAt?: string;
51
56
  }
57
+ export declare function formatEmitRejectionLines(rejections: ReadonlyArray<{
58
+ eventId: string;
59
+ code: string;
60
+ }>): string[];
52
61
  /** Drain the retry file, post pending + new events, persist failures. */
53
62
  export declare function emitEvents(deps: EmitEventsDeps): Promise<EmitEventsSummary>;
54
63
  //# sourceMappingURL=emit.d.ts.map
@@ -41,9 +41,23 @@ function writeEmitState(workMeshRoot, state) {
41
41
  /* best-effort */
42
42
  }
43
43
  }
44
+ function rejectionCodeFromBody(body, status) {
45
+ if (body && typeof body === "object" && !Array.isArray(body)) {
46
+ const rec = body;
47
+ if (typeof rec.code === "string" && rec.code.trim())
48
+ return rec.code.trim();
49
+ if (typeof rec.error === "string" && /^[A-Z0-9_]+$/.test(rec.error.trim())) {
50
+ return rec.error.trim();
51
+ }
52
+ }
53
+ return `HTTP_${status}`;
54
+ }
44
55
  function eventIdOf(e) {
45
56
  return typeof e.eventId === "string" && e.eventId.trim() ? e.eventId.trim() : null;
46
57
  }
58
+ export function formatEmitRejectionLines(rejections) {
59
+ return rejections.map((row) => `work-mesh: rejected eventId=${row.eventId} code=${row.code}`);
60
+ }
47
61
  /** Drain the retry file, post pending + new events, persist failures. */
48
62
  export async function emitEvents(deps) {
49
63
  const now = deps.now ?? (() => new Date());
@@ -74,6 +88,7 @@ export async function emitEvents(deps) {
74
88
  retryDepth: 0,
75
89
  batches: 0,
76
90
  statuses: { accepted: 0, unassigned: 0, rejected: 0 },
91
+ rejections: [],
77
92
  };
78
93
  const keep = [];
79
94
  let anyPosted = false;
@@ -95,7 +110,13 @@ export async function emitEvents(deps) {
95
110
  // Non-retryable 4xx (e.g. 401/400) → drop with a log (never user data).
96
111
  summary.rejected += chunk.length;
97
112
  summary.statuses.rejected += chunk.length;
98
- deps.log?.(`emit batch dropped (${chunk.length}) non-retryable status=${result.status}`);
113
+ const batchCode = rejectionCodeFromBody(result.body, result.status);
114
+ for (const event of chunk) {
115
+ const id = eventIdOf(event);
116
+ if (id)
117
+ summary.rejections.push({ eventId: id, code: batchCode });
118
+ }
119
+ deps.log?.(`emit batch dropped (${chunk.length}) non-retryable status=${result.status} code=${batchCode}`);
99
120
  }
100
121
  continue;
101
122
  }
@@ -121,8 +142,13 @@ export async function emitEvents(deps) {
121
142
  summary.accepted += 1;
122
143
  else if (r.status === "unassigned")
123
144
  summary.unassigned += 1;
124
- else
145
+ else if (id) {
125
146
  summary.rejected += 1;
147
+ summary.rejections.push({
148
+ eventId: id,
149
+ code: r.code || r.reason || "rejected",
150
+ });
151
+ }
126
152
  }
127
153
  }
128
154
  const written = writeEmitRetry(deps.workMeshRoot, keep, retryMax);
@@ -92,8 +92,14 @@ function isLocallyReconcilableHeldEvent(event, state, workContextRoot) {
92
92
  repoIdentityKey,
93
93
  }).status === "resolved");
94
94
  }
95
- function deadLetterEnvelope(event, reason, responseCode, at) {
96
- return JSON.stringify({ event, reason, responseCode, at });
95
+ function deadLetterEnvelope(event, reason, responseCode, at, code) {
96
+ return JSON.stringify({
97
+ event,
98
+ reason,
99
+ responseCode,
100
+ at,
101
+ ...(code ? { code } : {}),
102
+ });
97
103
  }
98
104
  function groupBySession(events) {
99
105
  const map = new Map();
@@ -431,7 +437,7 @@ function accountBatchSuccess(body, originalChunk, meshRoot, now, summary, markDi
431
437
  const item = byId.get(row.eventId);
432
438
  if (!item)
433
439
  continue;
434
- appendDeadLetterLine(deadLetterEnvelope(item.event, row.code, row.code, at), meshRoot);
440
+ appendDeadLetterLine(deadLetterEnvelope(item.event, row.code, row.code, at, row.code), meshRoot);
435
441
  summary.deadLettered += 1;
436
442
  rejectedIds.add(row.eventId);
437
443
  markDisposed(item.event);
@@ -1,6 +1,5 @@
1
1
  import { spawnSync } from 'node:child_process';
2
2
  import * as fs from 'node:fs';
3
- import { createRequire } from 'node:module';
4
3
  import * as os from 'node:os';
5
4
  import * as path from 'node:path';
6
5
  import { fileURLToPath } from 'node:url';
@@ -10,9 +9,9 @@ import { isQmdNativeBindingError } from '../../utils/qmd-native-binding-error.js
10
9
  import { isQmdQueryDocumentError } from '../../utils/qmd-query-document-error.js';
11
10
  import { isQmdStoreMissingError } from '../../utils/qmd-store-missing-error.js';
12
11
  import { isQmdStoreUnopenableError } from '../../utils/qmd-store-unopenable-error.js';
12
+ import { markExpectedUserError } from '../../utils/expected-cli-error.js';
13
13
  import { redactErrorText } from '../../utils/redact-error-text.js';
14
14
  import { planCommandSpawn } from '../../utils/windows-spawn.js';
15
- const require = createRequire(import.meta.url);
16
15
  /** The `path` implementation for a (possibly injected) platform. */
17
16
  function pathFor(platform) {
18
17
  return platform === 'win32' ? path.win32 : path.posix;
@@ -1005,6 +1004,22 @@ function describeQmdTermination(stdout, stderr) {
1005
1004
  return `qmd reported: ${summary.reason}`;
1006
1005
  return '';
1007
1006
  }
1007
+ /**
1008
+ * qmd caller-state failures that the top-level boundary may surface without a
1009
+ * crash report. The match is deliberately against qmd's raw stderr and a
1010
+ * closed subcommand set: a query containing these words must not change the
1011
+ * disposition of an unrelated qmd exit.
1012
+ */
1013
+ function isExpectedQmdCallerFailure(args, stderr) {
1014
+ const subcommand = args[0];
1015
+ if (subcommand === 'get') {
1016
+ return /^Document not found:\s+[^\r\n]+$/m.test(stderr);
1017
+ }
1018
+ if (subcommand === 'collection') {
1019
+ return /(?:^|\b)Failed to parse\s+[^\r\n]+:\s*.+$/m.test(stderr);
1020
+ }
1021
+ return false;
1022
+ }
1008
1023
  /** Normalise a spawn result and raise the typed qmd failures. */
1009
1024
  function finishRunQmd(result, bin, args, attribution) {
1010
1025
  const normalized = {
@@ -1215,7 +1230,11 @@ function finishRunQmd(result, bin, args, attribution) {
1215
1230
  if (alreadyExists) {
1216
1231
  throw new QmdCollectionExistsError(message, args, normalized.status, normalized.stdout, normalized.stderr, alreadyExists[1]);
1217
1232
  }
1218
- throw new QmdExitError(message, args, normalized.status, normalized.stdout, normalized.stderr);
1233
+ const exitError = new QmdExitError(message, args, normalized.status, normalized.stdout, normalized.stderr);
1234
+ if (isExpectedQmdCallerFailure(args, normalized.stderr)) {
1235
+ throw markExpectedUserError(exitError);
1236
+ }
1237
+ throw exitError;
1219
1238
  }
1220
1239
  /** Run qmd with captured output and typed failures. */
1221
1240
  export function runQmd(args, options = {}) {
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Offline preflight ContextResult shapes that `hq mesh context reconcile --offline`
3
+ * can emit. migration_pending is a migrate-path status, not a preflight result.
4
+ *
5
+ * contractVersion is WORK_CONTEXT_CONTRACT_VERSION. Bump it only together with
6
+ * contracts/preflight/v<N>/ and the vendored copy in hq-core
7
+ * (core/hooks/SessionStart/preflight-fixtures.json). Each side pins the version
8
+ * in tests; a one-sided bump fails that side until the other is updated.
9
+ */
10
+ import { type ContextResult } from "./contract.js";
11
+ export declare const PREFLIGHT_CONTRACT_VERSION: 1;
12
+ /** Classifications reconcile actually returns on the offline preflight path. */
13
+ export declare const OFFLINE_PREFLIGHT_CLASSIFICATIONS: readonly ["unresolved", "needs_company", "company_conflict", "needs_project", "needs_task", "bound", "untracked"];
14
+ export type OfflinePreflightClassification = (typeof OFFLINE_PREFLIGHT_CLASSIFICATIONS)[number];
15
+ /**
16
+ * One representative result per emitted classification, using the kind and
17
+ * delivery the offline path actually returns (company-resolved results are
18
+ * kind "queued" / delivery "queued"; local-only results are delivery "clean").
19
+ */
20
+ export declare function preflightFixtureFor(classification: OfflinePreflightClassification): ContextResult;
21
+ export interface PreflightFixtureFile {
22
+ contractVersion: typeof PREFLIGHT_CONTRACT_VERSION;
23
+ source: "hq-cli contracts/preflight/v1/fixtures.json";
24
+ classifications: readonly OfflinePreflightClassification[];
25
+ fixtures: ContextResult[];
26
+ }
27
+ export declare function buildPreflightFixtures(): PreflightFixtureFile;
28
+ //# sourceMappingURL=preflight-contract.d.ts.map
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Offline preflight ContextResult shapes that `hq mesh context reconcile --offline`
3
+ * can emit. migration_pending is a migrate-path status, not a preflight result.
4
+ *
5
+ * contractVersion is WORK_CONTEXT_CONTRACT_VERSION. Bump it only together with
6
+ * contracts/preflight/v<N>/ and the vendored copy in hq-core
7
+ * (core/hooks/SessionStart/preflight-fixtures.json). Each side pins the version
8
+ * in tests; a one-sided bump fails that side until the other is updated.
9
+ */
10
+ import { WORK_CONTEXT_CONTRACT_VERSION, } from "./contract.js";
11
+ export const PREFLIGHT_CONTRACT_VERSION = WORK_CONTEXT_CONTRACT_VERSION;
12
+ /** Classifications reconcile actually returns on the offline preflight path. */
13
+ export const OFFLINE_PREFLIGHT_CLASSIFICATIONS = [
14
+ "unresolved",
15
+ "needs_company",
16
+ "company_conflict",
17
+ "needs_project",
18
+ "needs_task",
19
+ "bound",
20
+ "untracked",
21
+ ];
22
+ const FIXTURE_SESSION = "sid-preflight-fixture";
23
+ const FIXTURE_OP = "op-preflight-fixture";
24
+ function base(classification, kind, delivery) {
25
+ return {
26
+ contractVersion: PREFLIGHT_CONTRACT_VERSION,
27
+ kind,
28
+ classification,
29
+ delivery,
30
+ lifecycle: "open",
31
+ sessionId: FIXTURE_SESSION,
32
+ clientOperationId: FIXTURE_OP,
33
+ };
34
+ }
35
+ /**
36
+ * One representative result per emitted classification, using the kind and
37
+ * delivery the offline path actually returns (company-resolved results are
38
+ * kind "queued" / delivery "queued"; local-only results are delivery "clean").
39
+ */
40
+ export function preflightFixtureFor(classification) {
41
+ switch (classification) {
42
+ case "unresolved":
43
+ return base("unresolved", "queued", "queued");
44
+ case "needs_company":
45
+ return base("needs_company", "needs_company", "clean");
46
+ case "company_conflict":
47
+ return base("company_conflict", "company_conflict", "clean");
48
+ case "needs_project":
49
+ return {
50
+ ...base("needs_project", "queued", "queued"),
51
+ companySlug: "acme",
52
+ companyUid: "cmp_acme",
53
+ };
54
+ case "needs_task":
55
+ return {
56
+ ...base("needs_task", "queued", "queued"),
57
+ companySlug: "acme",
58
+ companyUid: "cmp_acme",
59
+ projectId: "dispatch-proj",
60
+ };
61
+ case "bound":
62
+ return {
63
+ ...base("bound", "queued", "queued"),
64
+ companySlug: "acme",
65
+ companyUid: "cmp_acme",
66
+ projectId: "dispatch-proj",
67
+ taskId: "US-9",
68
+ };
69
+ case "untracked":
70
+ return base("untracked", "untracked", "clean");
71
+ default: {
72
+ const never = classification;
73
+ return never;
74
+ }
75
+ }
76
+ }
77
+ export function buildPreflightFixtures() {
78
+ return {
79
+ contractVersion: PREFLIGHT_CONTRACT_VERSION,
80
+ source: "hq-cli contracts/preflight/v1/fixtures.json",
81
+ classifications: [...OFFLINE_PREFLIGHT_CLASSIFICATIONS],
82
+ fixtures: OFFLINE_PREFLIGHT_CLASSIFICATIONS.map(preflightFixtureFor),
83
+ };
84
+ }
85
+ //# sourceMappingURL=preflight-contract.js.map
package/dist/main.js CHANGED
@@ -11,7 +11,7 @@ import { fileURLToPath } from "node:url";
11
11
  import { Command } from "commander";
12
12
  import { finishSentrySession, initSentry, Sentry } from "./sentry.js";
13
13
  import { sanitizeArgv } from "./utils/feedback-diagnostics.js";
14
- import { environmentalFsErrorMessage } from "./utils/environmental-error.js";
14
+ import { environmentalFsErrorMessage, filesystemErrorCaptureContext, filesystemErrorMessage, } from "./utils/environmental-error.js";
15
15
  import { syncStateLockMessage } from "./utils/sync-state-lock-error.js";
16
16
  import { networkTransportErrorMessage } from "./utils/network-transport-error.js";
17
17
  import { qmdNativeBindingErrorMessage } from "./utils/qmd-native-binding-error.js";
@@ -690,6 +690,12 @@ export async function handleTopLevelError(err, deps = defaultTopLevelErrorDepend
690
690
  lockMsg
691
691
  ? null
692
692
  : networkTransportErrorMessage(err);
693
+ // Windows EPERM/EACCES/EBUSY/UNKNOWN filesystem failures are not proven
694
+ // caller-owned conditions. Keep them at error level and attach bounded,
695
+ // installation-independent context so OneDrive/antivirus/path-length
696
+ // hypotheses can be separated without creating one issue per host path.
697
+ const fsContext = filesystemErrorCaptureContext(err);
698
+ const fsMsg = fsContext ? filesystemErrorMessage(err) : null;
693
699
  if (qmdMsg) {
694
700
  deps.stderr.write(`hq: ${qmdMsg}\n`);
695
701
  }
@@ -735,6 +741,10 @@ export async function handleTopLevelError(err, deps = defaultTopLevelErrorDepend
735
741
  else if (transportMsg) {
736
742
  deps.stderr.write(`hq: ${transportMsg}\n`);
737
743
  }
744
+ else if (fsMsg && fsContext) {
745
+ deps.sentry.captureException(err, fsContext);
746
+ deps.stderr.write(`hq: ${fsMsg}\n`);
747
+ }
738
748
  else {
739
749
  // A genuinely unclassified fault is still captured exactly once. Two
740
750
  // shapes attach bounded, hq-derived context so the next occurrence
@@ -153,6 +153,45 @@ export declare function wantsMachineCognitoTokens(options?: Pick<EnsureCognitoTo
153
153
  * not flip this to machine.
154
154
  */
155
155
  export declare function resolveCognitoTokenSource(env?: NodeJS.ProcessEnv): CognitoTokenSource;
156
+ /**
157
+ * Refresh wrote a different Cognito subject than the active cache.
158
+ * Callers must not fall through to browser login or machine mint — that
159
+ * would replace the operator session the refusal is protecting.
160
+ */
161
+ export declare class IdentitySwapRefusal extends Error {
162
+ constructor(message: string);
163
+ }
164
+ export declare function isIdentitySwapRefusal(err: unknown): err is IdentitySwapRefusal;
165
+ /** `sub` plus `custom:entityUid` from an unverified JWT. Either may be absent. */
166
+ export declare function cognitoSubjectFromToken(token: string | undefined): {
167
+ sub: string | null;
168
+ entityUid: string | null;
169
+ };
170
+ /** Stable subject key. Null when neither sub nor entity uid is present. */
171
+ export declare function cognitoSubjectKey(tokens: {
172
+ idToken?: string;
173
+ accessToken?: string;
174
+ } | null | undefined): string | null;
175
+ /** Doctor/status line. Claims only — never token material. */
176
+ export declare function formatCognitoSubject(tokens: {
177
+ idToken?: string;
178
+ accessToken?: string;
179
+ } | null | undefined): string;
180
+ /**
181
+ * Person cache as stored on disk for `env`. Does not consult hq-cloud's
182
+ * in-memory fallback, which can surface a different session when the file
183
+ * is missing or unreadable.
184
+ */
185
+ export declare function readPersonTokenFile(home?: string, env?: NodeJS.ProcessEnv): CognitoTokens | null;
186
+ /**
187
+ * Refresh the active person session and refuse a subject change.
188
+ *
189
+ * hq-cloud's `refreshTokens` saves as soon as Cognito responds, and on a
190
+ * cache miss or unreadable file it can refresh an in-memory session for a
191
+ * different subject (same app client). Snapshot the active file first; if
192
+ * the response subject differs, put the bytes back and leave them there.
193
+ */
194
+ export declare function refreshActivePersonTokens(cached: CognitoTokens, env?: NodeJS.ProcessEnv): Promise<CognitoTokens>;
156
195
  /** Derive actor kind from an ID token's custom:entity* claims (no verify). */
157
196
  export declare function actorKindFromIdToken(idToken: string | undefined): CognitoActorKind;
158
197
  /**
@@ -409,6 +409,115 @@ function decodeJwtPayload(token) {
409
409
  return null;
410
410
  }
411
411
  }
412
+ /**
413
+ * Refresh wrote a different Cognito subject than the active cache.
414
+ * Callers must not fall through to browser login or machine mint — that
415
+ * would replace the operator session the refusal is protecting.
416
+ */
417
+ export class IdentitySwapRefusal extends Error {
418
+ constructor(message) {
419
+ super(message);
420
+ this.name = "IdentitySwapRefusal";
421
+ }
422
+ }
423
+ export function isIdentitySwapRefusal(err) {
424
+ return err instanceof IdentitySwapRefusal;
425
+ }
426
+ /** `sub` plus `custom:entityUid` from an unverified JWT. Either may be absent. */
427
+ export function cognitoSubjectFromToken(token) {
428
+ const claims = decodeJwtPayload(token);
429
+ const sub = typeof claims?.sub === "string" && claims.sub.length > 0 ? claims.sub : null;
430
+ const entity = claims?.["custom:entityUid"];
431
+ const entityUid = typeof entity === "string" && entity.length > 0 ? entity : null;
432
+ return { sub, entityUid };
433
+ }
434
+ /** Stable subject key. Null when neither sub nor entity uid is present. */
435
+ export function cognitoSubjectKey(tokens) {
436
+ if (!tokens)
437
+ return null;
438
+ const fromId = cognitoSubjectFromToken(tokens.idToken);
439
+ const fromAccess = cognitoSubjectFromToken(tokens.accessToken);
440
+ const sub = fromId.sub ?? fromAccess.sub;
441
+ const entityUid = fromId.entityUid ?? fromAccess.entityUid;
442
+ if (!sub && !entityUid)
443
+ return null;
444
+ return `${sub ?? ""}|${entityUid ?? ""}`;
445
+ }
446
+ /** Doctor/status line. Claims only — never token material. */
447
+ export function formatCognitoSubject(tokens) {
448
+ if (!tokens)
449
+ return "sub=- entityUid=-";
450
+ const fromId = cognitoSubjectFromToken(tokens.idToken);
451
+ const fromAccess = cognitoSubjectFromToken(tokens.accessToken);
452
+ return `sub=${fromId.sub ?? fromAccess.sub ?? "-"} entityUid=${fromId.entityUid ?? fromAccess.entityUid ?? "-"}`;
453
+ }
454
+ function readTokenFile(file) {
455
+ try {
456
+ if (!fs.existsSync(file))
457
+ return null;
458
+ const raw = JSON.parse(fs.readFileSync(file, "utf8"));
459
+ if (typeof raw.accessToken !== "string" || typeof raw.idToken !== "string")
460
+ return null;
461
+ return raw;
462
+ }
463
+ catch {
464
+ return null;
465
+ }
466
+ }
467
+ /**
468
+ * Person cache as stored on disk for `env`. Does not consult hq-cloud's
469
+ * in-memory fallback, which can surface a different session when the file
470
+ * is missing or unreadable.
471
+ */
472
+ export function readPersonTokenFile(home = os.homedir(), env = process.env) {
473
+ return readTokenFile(personTokenCacheFile(home, env));
474
+ }
475
+ function restoreTokenFile(file, before) {
476
+ if (before) {
477
+ const dir = path.dirname(file);
478
+ if (!fs.existsSync(dir))
479
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
480
+ const tmp = path.join(dir, `.cognito-tokens.json.restore.${process.pid}`);
481
+ fs.writeFileSync(tmp, before, { mode: 0o600 });
482
+ fs.renameSync(tmp, file);
483
+ return;
484
+ }
485
+ if (fs.existsSync(file))
486
+ fs.unlinkSync(file);
487
+ }
488
+ /**
489
+ * Refresh the active person session and refuse a subject change.
490
+ *
491
+ * hq-cloud's `refreshTokens` saves as soon as Cognito responds, and on a
492
+ * cache miss or unreadable file it can refresh an in-memory session for a
493
+ * different subject (same app client). Snapshot the active file first; if
494
+ * the response subject differs, put the bytes back and leave them there.
495
+ */
496
+ export async function refreshActivePersonTokens(cached, env = process.env) {
497
+ const file = personTokenCacheFile(os.homedir(), env);
498
+ const onDisk = readTokenFile(file);
499
+ const diskKey = cognitoSubjectKey(onDisk);
500
+ const cachedKey = cognitoSubjectKey(cached);
501
+ if (diskKey && cachedKey && diskKey !== cachedKey) {
502
+ const message = `refusing token refresh: cached subject ${formatCognitoSubject(cached)} ` +
503
+ `does not match active subject ${formatCognitoSubject(onDisk)}; token file left unchanged`;
504
+ console.error(message);
505
+ throw new IdentitySwapRefusal(message);
506
+ }
507
+ const activeTokens = diskKey && cachedKey && diskKey === cachedKey ? onDisk : cached;
508
+ const activeKey = cognitoSubjectKey(activeTokens);
509
+ const before = fs.existsSync(file) ? fs.readFileSync(file) : null;
510
+ const refreshed = await refreshTokens(DEFAULT_COGNITO, activeTokens.refreshToken, activeTokens.accessToken);
511
+ const nextKey = cognitoSubjectKey(refreshed);
512
+ if (activeKey && nextKey && nextKey !== activeKey) {
513
+ restoreTokenFile(file, before);
514
+ const message = `refusing token refresh: refreshed subject ${formatCognitoSubject(refreshed)} ` +
515
+ `does not match active subject ${formatCognitoSubject(activeTokens)}; token file left unchanged`;
516
+ console.error(message);
517
+ throw new IdentitySwapRefusal(message);
518
+ }
519
+ return refreshed;
520
+ }
412
521
  /** Derive actor kind from an ID token's custom:entity* claims (no verify). */
413
522
  export function actorKindFromIdToken(idToken) {
414
523
  const claims = decodeJwtPayload(idToken);
@@ -616,10 +725,12 @@ async function ensureCognitoTokenUnserialized(options) {
616
725
  if (interactive) {
617
726
  console.error(chalk.dim(" Refreshing expiring HQ session..."));
618
727
  }
619
- const refreshed = await refreshTokens(DEFAULT_COGNITO, cached.refreshToken);
728
+ const refreshed = await refreshActivePersonTokens(cached);
620
729
  return refreshed.accessToken;
621
730
  }
622
731
  catch (err) {
732
+ if (isIdentitySwapRefusal(err))
733
+ throw err;
623
734
  // Transient/network/server errors stay person errors — never mint machine.
624
735
  if (!isDefinitivePersonAuthRejection(err)) {
625
736
  throw err;
@@ -676,10 +787,12 @@ async function ensureCognitoIdTokenUnserialized(options) {
676
787
  if (interactive) {
677
788
  console.error(chalk.dim(" Refreshing expiring HQ session..."));
678
789
  }
679
- const refreshed = await refreshTokens(DEFAULT_COGNITO, cached.refreshToken);
790
+ const refreshed = await refreshActivePersonTokens(cached);
680
791
  return refreshed.idToken;
681
792
  }
682
793
  catch (err) {
794
+ if (isIdentitySwapRefusal(err))
795
+ throw err;
683
796
  if (!isDefinitivePersonAuthRejection(err)) {
684
797
  throw err;
685
798
  }
@@ -778,10 +891,13 @@ async function refreshCachedSessionUnserialized(options) {
778
891
  return { refreshed: true };
779
892
  }
780
893
  try {
781
- await refreshTokens(DEFAULT_COGNITO, cached.refreshToken);
894
+ await refreshActivePersonTokens(cached);
782
895
  return { refreshed: true };
783
896
  }
784
897
  catch (err) {
898
+ if (isIdentitySwapRefusal(err)) {
899
+ return { refreshed: false, reason: err.message };
900
+ }
785
901
  // Transient/network/server refresh errors stay person failures.
786
902
  if (!isDefinitivePersonAuthRejection(err)) {
787
903
  return {
@@ -7,4 +7,22 @@
7
7
  * result means "this is a normal error; handle it as usual (capture to Sentry)".
8
8
  */
9
9
  export declare function environmentalFsErrorMessage(err: unknown): string | null;
10
+ export type FilesystemErrorCaptureContext = {
11
+ fingerprint: ["fs-error", string, string, string];
12
+ tags: Record<string, string>;
13
+ extra: {
14
+ fs_path: string;
15
+ fs_reason: string;
16
+ };
17
+ };
18
+ /** Return only the registered command path, never positional user data. */
19
+ export declare function hqCommand(argv?: readonly string[]): string;
20
+ /**
21
+ * Build stable Sentry context for known OS filesystem refusals. The failure is
22
+ * intentionally still captured: this is instrumentation for the OneDrive /
23
+ * antivirus / path-length hypotheses, not an expected-error suppression.
24
+ */
25
+ export declare function filesystemErrorCaptureContext(err: unknown, argv?: readonly string[]): FilesystemErrorCaptureContext | null;
26
+ /** Plain operator guidance for a captured filesystem failure. */
27
+ export declare function filesystemErrorMessage(err: unknown): string | null;
10
28
  //# sourceMappingURL=environmental-error.d.ts.map