@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.
- package/CHANGELOG.md +52 -1
- package/contracts/preflight/v1/fixtures.json +87 -0
- package/contracts/preflight/v1/schema.json +58 -0
- package/dist/command-catalog.generated.d.ts +27 -0
- package/dist/command-catalog.generated.js +34 -0
- package/dist/commands/core.js +7 -1
- package/dist/commands/dm.d.ts +2 -0
- package/dist/commands/dm.js +1 -1
- package/dist/commands/files.js +137 -9
- package/dist/commands/mesh.js +46 -12
- package/dist/lib/core-utils/sentry-report.d.ts +27 -7
- package/dist/lib/core-utils/sentry-report.js +428 -28
- package/dist/lib/mesh/api.d.ts +2 -0
- package/dist/lib/mesh/api.js +15 -2
- package/dist/lib/mesh/live/daemon/doctor.d.ts +4 -0
- package/dist/lib/mesh/live/daemon/doctor.js +10 -2
- package/dist/lib/mesh/live/daemon/run.js +3 -0
- package/dist/lib/mesh/live/emit-client.d.ts +2 -0
- package/dist/lib/mesh/live/emit-client.js +15 -8
- package/dist/lib/mesh/live/emit.d.ts +9 -0
- package/dist/lib/mesh/live/emit.js +28 -2
- package/dist/lib/mesh/live/flush.js +9 -3
- package/dist/lib/search-index/index.js +22 -3
- package/dist/lib/work-context/preflight-contract.d.ts +28 -0
- package/dist/lib/work-context/preflight-contract.js +85 -0
- package/dist/main.js +11 -1
- package/dist/utils/cognito-session.d.ts +39 -0
- package/dist/utils/cognito-session.js +119 -3
- package/dist/utils/environmental-error.d.ts +18 -0
- package/dist/utils/environmental-error.js +240 -7
- package/dist/utils/expected-cli-error.d.ts +2 -0
- package/dist/utils/expected-cli-error.js +4 -0
- package/dist/utils/vault-api.d.ts +1 -0
- package/dist/utils/vault-api.js +11 -3
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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({
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|