@indigoai-us/hq-cli 5.103.23 → 5.103.25
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 +11 -0
- package/assets/scaffold/core/scripts/checkpoint-stop-gate.sh +31 -11
- package/dist/commands/doctor.d.ts +9 -5
- package/dist/commands/doctor.js +10 -6
- package/dist/lib/doctor/checks/integrations.d.ts +43 -0
- package/dist/lib/doctor/checks/integrations.js +237 -0
- package/dist/lib/doctor/registry.js +4 -0
- package/dist/lib/doctor/types.d.ts +4 -0
- package/dist/main.js +16 -0
- package/dist/utils/browser-login-abandoned.d.ts +12 -0
- package/dist/utils/browser-login-abandoned.js +70 -0
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.103.25] — 2026-08-27
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- Codex Stop hooks now record their automatic checkpoint out of band after a
|
|
10
|
+
user-facing reply has already been delivered, avoiding the visible
|
|
11
|
+
`Hook re-prompted Codex` filler message while preserving the missing-reply
|
|
12
|
+
safety gate.
|
|
13
|
+
|
|
14
|
+
## [5.103.24] — 2026-08-27
|
|
15
|
+
|
|
5
16
|
## [5.103.23] — 2026-08-26
|
|
6
17
|
|
|
7
18
|
### Changed
|
|
@@ -490,6 +490,32 @@ set -uo pipefail
|
|
|
490
490
|
exit 0
|
|
491
491
|
fi
|
|
492
492
|
|
|
493
|
+
# A turn whose user-facing reply is ALREADY delivered cannot be blocked into
|
|
494
|
+
# silence. Hosts re-prompt an assistant turn that produces no visible text
|
|
495
|
+
# ("your previous response had no visible output"), so blocking here forces a
|
|
496
|
+
# trailing filler message — the very double-messaging this gate exists to
|
|
497
|
+
# prevent. Every wording tried so far only shrank that filler (full
|
|
498
|
+
# restatement, then a meta-note, then a bare <br>); none removed it, because
|
|
499
|
+
# the instruction asks for a turn shape the host will not accept.
|
|
500
|
+
#
|
|
501
|
+
# So do not ask. Let the turn end and record the checkpoint out-of-band. The
|
|
502
|
+
# payload carries the transcript, which is what the sibling already reads to
|
|
503
|
+
# derive files, decisions and learnings, so the record keeps its substance
|
|
504
|
+
# without an agent-authored summary. Turns that checkpoint BEFORE replying
|
|
505
|
+
# never reach this branch and keep the full hand-written payload; the
|
|
506
|
+
# reply-required gate above still runs first, so a turn with no reply at all
|
|
507
|
+
# is unaffected.
|
|
508
|
+
if [ "$replied" = 1 ]; then
|
|
509
|
+
(nohup hq core checkpoint \
|
|
510
|
+
--session-id "$session_id" \
|
|
511
|
+
--trigger stop-gate-auto \
|
|
512
|
+
--transcript "$transcript_path" \
|
|
513
|
+
--summary "turn ended with its user-facing reply already delivered" \
|
|
514
|
+
>/dev/null 2>&1 &)
|
|
515
|
+
rm -f "$block_count_file" 2>/dev/null || true
|
|
516
|
+
exit 0
|
|
517
|
+
fi
|
|
518
|
+
|
|
493
519
|
# Loop guard for the missing-checkpoint demand below: cap consecutive blocks
|
|
494
520
|
# per session, then fail open (see the counter's comment above).
|
|
495
521
|
if [ "$block_count" -ge 3 ]; then
|
|
@@ -500,18 +526,12 @@ set -uo pipefail
|
|
|
500
526
|
# Built with printf rather than concatenation so the session id can appear in
|
|
501
527
|
# both commands without re-splitting the message into fragments.
|
|
502
528
|
#
|
|
503
|
-
#
|
|
504
|
-
#
|
|
505
|
-
#
|
|
506
|
-
#
|
|
507
|
-
# one). The transcript already tells us which case we are in, so say exactly
|
|
508
|
-
# one thing.
|
|
529
|
+
# Only the not-yet-replied case reaches here — a delivered reply took the
|
|
530
|
+
# out-of-band branch above — so this instruction never has to forbid a
|
|
531
|
+
# restatement, and the agent's reply lands in final position where every host
|
|
532
|
+
# renders it in full.
|
|
509
533
|
flags_spec=' hq core checkpoint --session-id %s --trigger stop-gate --summary "<what changed, in one line>" [--file <path>] [--decision "<choice and why>"] [--learning "<reusable rule>"] [--next "<outstanding step>"]\n\nOnly --summary is required, and the repeatable flags are what the sibling uses to enrich the record, distil policies and update the indexes — a bare summary gives it almost nothing to work with. Write them as machine record, not prose for the user, and pass each one that genuinely applies:\n --file every path you created or modified this turn\n --decision a choice you made that a reader would otherwise have to reverse-engineer\n --learning a rule that changes how someone acts next time, not a restatement of what just happened\n --next work that is genuinely still outstanding\nOmit a flag rather than padding it: an empty or invented learning is worse than none.\n\nIf this turn only read or inspected things and changed no state, the correct call instead is:\n\n hq core checkpoint --session-id %s --idle'
|
|
510
|
-
|
|
511
|
-
reason="$(printf 'This turn changed something, so it needs an end-of-turn checkpoint before it can end.\n\nYour user-facing reply is ALREADY delivered — the message you just wrote is visible to the user. Do NOT send it again, in full or summarized: repeating it double-messages the user, which is exactly the bug this gate guards against.\n\nTHE SIBLING (a background maintenance agent) reads only the checkpoint payload, never your chat reply — anything it needs must go into the flags.\n\nRun the checkpoint now as the FINAL action of the turn and end the turn immediately after it, adding no further text:\n\n'"$flags_spec" "$session_id" "$session_id")"
|
|
512
|
-
else
|
|
513
|
-
reason="$(printf 'This turn changed something, so it needs an end-of-turn checkpoint. Two different audiences are involved — do not conflate them:\n\n1. THE USER reads your normal chat reply. You have not sent one yet this turn — everything you owe them (results, links, answers, status, decisions) must go into it. The checkpoint is invisible to them and does NOT count as having replied.\n2. THE SIBLING (a background maintenance agent) reads the checkpoint payload. It never sees your chat reply, so anything it needs must go into the flags.\n\nORDER: run the checkpoint FIRST, then deliver your complete user-facing reply as the FINAL text of the turn — final-position text is the one placement every host renders in full. Every link, URL, instruction, command, and decision the user needs must appear in that final message. The gate enforces this: a turn that ends without a user-facing reply is blocked until the reply is delivered.\n\n'"$flags_spec" "$session_id" "$session_id")"
|
|
514
|
-
fi
|
|
534
|
+
reason="$(printf 'This turn changed something, so it needs an end-of-turn checkpoint. Two different audiences are involved — do not conflate them:\n\n1. THE USER reads your normal chat reply. You have not sent one yet this turn — everything you owe them (results, links, answers, status, decisions) must go into it. The checkpoint is invisible to them and does NOT count as having replied.\n2. THE SIBLING (a background maintenance agent) reads the checkpoint payload. It never sees your chat reply, so anything it needs must go into the flags.\n\nORDER: run the checkpoint FIRST, then deliver your complete user-facing reply as the FINAL text of the turn — final-position text is the one placement every host renders in full. Every link, URL, instruction, command, and decision the user needs must appear in that final message. The gate enforces this: a turn that ends without a user-facing reply is blocked until the reply is delivered.\n\n'"$flags_spec" "$session_id" "$session_id")"
|
|
515
535
|
|
|
516
536
|
# Codex surfaces a blocked Stop reason as a synthetic user prompt. Preserve
|
|
517
537
|
# the actionable instruction out-of-band, then use the stable marker covered
|
|
@@ -7,10 +7,9 @@
|
|
|
7
7
|
* to every check.
|
|
8
8
|
* - Running outside any HQ tree exits non-zero with a message naming exactly
|
|
9
9
|
* what it looked for — never a throw, never a false PASS.
|
|
10
|
-
* -
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* grok) with a version read and a one-line prompt.
|
|
10
|
+
* - Most families inspect the local tree. The integrations family makes one
|
|
11
|
+
* control-plane inventory read for the active company, without invoking a
|
|
12
|
+
* provider tool. `--live-runtimes` is the opt-in AI CLI probe.
|
|
14
13
|
*
|
|
15
14
|
* US-015 adds reporting, `--json`, and the exit-code contract: the exit code is
|
|
16
15
|
* 0 unless some result is FAIL or UNKNOWN (WARN/UNTESTED/NA/KNOWN-DEFECT never
|
|
@@ -86,6 +85,10 @@ export interface RunDoctorOptions {
|
|
|
86
85
|
* family reports UNTESTED rather than spawning anything.
|
|
87
86
|
*/
|
|
88
87
|
liveRuntimes?: boolean;
|
|
88
|
+
/** Company slug for the integrations family; omitted uses active membership. */
|
|
89
|
+
company?: string;
|
|
90
|
+
/** Internal test seam; production CLI always runs integrations checks. */
|
|
91
|
+
integrations?: boolean;
|
|
89
92
|
}
|
|
90
93
|
/** The outcome of a doctor run, returned rather than thrown so it is testable. */
|
|
91
94
|
export interface RunDoctorResult {
|
|
@@ -98,7 +101,8 @@ export interface RunDoctorResult {
|
|
|
98
101
|
}
|
|
99
102
|
/**
|
|
100
103
|
* Resolve the HQ root, run every registered check family against it, and render
|
|
101
|
-
* a plain-text summary.
|
|
104
|
+
* a plain-text summary. The integrations family makes one bounded inventory
|
|
105
|
+
* read; all other default tiers remain local unless explicitly opted in.
|
|
102
106
|
*/
|
|
103
107
|
export declare function runDoctor(options?: RunDoctorOptions): Promise<RunDoctorResult>;
|
|
104
108
|
/** Register the top-level `hq doctor` command so it appears in `hq --help`. */
|
package/dist/commands/doctor.js
CHANGED
|
@@ -7,10 +7,9 @@
|
|
|
7
7
|
* to every check.
|
|
8
8
|
* - Running outside any HQ tree exits non-zero with a message naming exactly
|
|
9
9
|
* what it looked for — never a throw, never a false PASS.
|
|
10
|
-
* -
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* grok) with a version read and a one-line prompt.
|
|
10
|
+
* - Most families inspect the local tree. The integrations family makes one
|
|
11
|
+
* control-plane inventory read for the active company, without invoking a
|
|
12
|
+
* provider tool. `--live-runtimes` is the opt-in AI CLI probe.
|
|
14
13
|
*
|
|
15
14
|
* US-015 adds reporting, `--json`, and the exit-code contract: the exit code is
|
|
16
15
|
* 0 unless some result is FAIL or UNKNOWN (WARN/UNTESTED/NA/KNOWN-DEFECT never
|
|
@@ -79,7 +78,8 @@ function isHqRoot(dir) {
|
|
|
79
78
|
}
|
|
80
79
|
/**
|
|
81
80
|
* Resolve the HQ root, run every registered check family against it, and render
|
|
82
|
-
* a plain-text summary.
|
|
81
|
+
* a plain-text summary. The integrations family makes one bounded inventory
|
|
82
|
+
* read; all other default tiers remain local unless explicitly opted in.
|
|
83
83
|
*/
|
|
84
84
|
export async function runDoctor(options = {}) {
|
|
85
85
|
const write = options.stdout ?? ((chunk) => void process.stdout.write(chunk));
|
|
@@ -102,6 +102,8 @@ export async function runDoctor(options = {}) {
|
|
|
102
102
|
platform: { id: platform.id, evidence: platform.evidence },
|
|
103
103
|
sessionId: options.sessionId,
|
|
104
104
|
liveRuntimes: options.liveRuntimes === true,
|
|
105
|
+
company: options.company,
|
|
106
|
+
integrations: options.integrations !== false,
|
|
105
107
|
};
|
|
106
108
|
const families = await registry.run(context);
|
|
107
109
|
// `--deep-test` (US-008): after the read-only tiers, actually fire pure-guard
|
|
@@ -150,13 +152,14 @@ export async function runDoctor(options = {}) {
|
|
|
150
152
|
export function registerDoctorCommand(program) {
|
|
151
153
|
program
|
|
152
154
|
.command("doctor")
|
|
153
|
-
.description("Verify HQ hook
|
|
155
|
+
.description("Verify HQ hook wiring, runtimes, and connection health (read-only; integrations uses one control-plane inventory read).")
|
|
154
156
|
.option("--json", "Emit the machine-readable JSON document (no colour).")
|
|
155
157
|
.option("--verbose", "Also print every PASS result in text output.")
|
|
156
158
|
.option("--no-color", "Disable ANSI colour even on a TTY.")
|
|
157
159
|
.option("--session-id <id>", "Scope the runtime probe's ledger check to this exact session.")
|
|
158
160
|
.option("--deep-test", "Also fire pure-guard hooks through the real gate under all three profiles (sandboxed).")
|
|
159
161
|
.option("--live-runtimes", "Also probe each installed AI CLI (claude, codex, grok) with a one-line prompt to verify login and subscription (networked; uses your subscriptions).")
|
|
162
|
+
.option("--company <slug>", "Company slug for integrations checks (otherwise resolves your single active company).")
|
|
160
163
|
.option("--fix", "Apply the allowlisted safe repairs (backs up first; read-only without this flag).")
|
|
161
164
|
.option("--yes", "Skip the interactive --fix confirmation (non-interactive use).")
|
|
162
165
|
.option("--force", "Let --fix run despite uncommitted changes under .claude/, .codex/, or .grok/.")
|
|
@@ -204,6 +207,7 @@ export function registerDoctorCommand(program) {
|
|
|
204
207
|
sessionId: opts.sessionId,
|
|
205
208
|
deepTest: opts.deepTest === true,
|
|
206
209
|
liveRuntimes: opts.liveRuntimes === true,
|
|
210
|
+
company: opts.company,
|
|
207
211
|
});
|
|
208
212
|
// Set the exit code rather than calling process.exit, so the CLI's
|
|
209
213
|
// normal shutdown (telemetry flush) still runs. Non-zero means either an
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Company integration health for `hq doctor`.
|
|
3
|
+
*
|
|
4
|
+
* This family makes one authenticated read of HQ's connection inventory. It
|
|
5
|
+
* never calls an integration gateway or provider tool, so it cannot mutate
|
|
6
|
+
* data, spend provider quota, or trigger provider rate limits. It classifies
|
|
7
|
+
* stored health signals only: a recorded rejection overrides a misleading
|
|
8
|
+
* `connected` status, but a clean connected row is not live-provider proof.
|
|
9
|
+
*/
|
|
10
|
+
import { type AdminConnection } from "../../../commands/integrations-core.js";
|
|
11
|
+
import type { CheckContext, CheckFamily, CheckResult } from "../types.js";
|
|
12
|
+
export declare const INTEGRATIONS_FAMILY_ID = "integrations";
|
|
13
|
+
export declare const INTEGRATIONS_PREFIX = "integrations";
|
|
14
|
+
/** Additive health fields returned by newer control planes. */
|
|
15
|
+
export interface IntegrationConnection extends AdminConnection {
|
|
16
|
+
errorReason?: string;
|
|
17
|
+
degradedReason?: string;
|
|
18
|
+
needsReauthReason?: string;
|
|
19
|
+
/** Server-derived remediation flow; optional for older control planes. */
|
|
20
|
+
fix_path?: string;
|
|
21
|
+
}
|
|
22
|
+
export interface IntegrationsDoctorDeps {
|
|
23
|
+
ensureToken?: () => Promise<string>;
|
|
24
|
+
resolveCompany?: (token: string, company: string | undefined) => Promise<string>;
|
|
25
|
+
listConnections?: (token: string, companyUid: string) => Promise<IntegrationConnection[]>;
|
|
26
|
+
}
|
|
27
|
+
type FindingKind = "reconnect" | "re-add" | "provider-blocked";
|
|
28
|
+
interface Finding {
|
|
29
|
+
provider: string;
|
|
30
|
+
connectionId: string;
|
|
31
|
+
kind: FindingKind;
|
|
32
|
+
message: string;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Expected session/company prerequisites degrade to NA instead of crashing or
|
|
36
|
+
* becoming a false connection failure. An unreadable inventory remains UNKNOWN.
|
|
37
|
+
*/
|
|
38
|
+
export declare function checkIntegrations(context: CheckContext, deps?: IntegrationsDoctorDeps): Promise<CheckResult[]>;
|
|
39
|
+
/** Classify without echoing untrusted provider text, which may contain secrets. */
|
|
40
|
+
export declare function classifyConnection(connection: IntegrationConnection): Finding[];
|
|
41
|
+
export declare const integrationsFamily: CheckFamily;
|
|
42
|
+
export {};
|
|
43
|
+
//# sourceMappingURL=integrations.d.ts.map
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Company integration health for `hq doctor`.
|
|
3
|
+
*
|
|
4
|
+
* This family makes one authenticated read of HQ's connection inventory. It
|
|
5
|
+
* never calls an integration gateway or provider tool, so it cannot mutate
|
|
6
|
+
* data, spend provider quota, or trigger provider rate limits. It classifies
|
|
7
|
+
* stored health signals only: a recorded rejection overrides a misleading
|
|
8
|
+
* `connected` status, but a clean connected row is not live-provider proof.
|
|
9
|
+
*/
|
|
10
|
+
import { ensureCognitoIdToken } from "../../../utils/cognito-session.js";
|
|
11
|
+
import { isAuthError } from "../../../utils/auth-error.js";
|
|
12
|
+
import { isCompanySelectionError } from "../../../utils/company-selection-error.js";
|
|
13
|
+
import { getCompanyUid } from "../../../utils/vault-api.js";
|
|
14
|
+
import { bareProvider, fetchConnections, IntegrationsCliError, } from "../../../commands/integrations-core.js";
|
|
15
|
+
export const INTEGRATIONS_FAMILY_ID = "integrations";
|
|
16
|
+
export const INTEGRATIONS_PREFIX = "integrations";
|
|
17
|
+
/**
|
|
18
|
+
* Expected session/company prerequisites degrade to NA instead of crashing or
|
|
19
|
+
* becoming a false connection failure. An unreadable inventory remains UNKNOWN.
|
|
20
|
+
*/
|
|
21
|
+
export async function checkIntegrations(context, deps = {}) {
|
|
22
|
+
if (context.integrations === false) {
|
|
23
|
+
return [{
|
|
24
|
+
status: "NA",
|
|
25
|
+
checkId: `${INTEGRATIONS_PREFIX}.skipped`,
|
|
26
|
+
message: "Integration checks were not requested for this run.",
|
|
27
|
+
}];
|
|
28
|
+
}
|
|
29
|
+
const ensureToken = deps.ensureToken ?? (() => ensureCognitoIdToken({ interactive: false }));
|
|
30
|
+
const resolveCompany = deps.resolveCompany ?? getCompanyUid;
|
|
31
|
+
const listConnections = deps.listConnections ?? ((token, companyUid) => fetchConnections(token, companyUid));
|
|
32
|
+
let token;
|
|
33
|
+
try {
|
|
34
|
+
token = await ensureToken();
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return skippedForSession();
|
|
38
|
+
}
|
|
39
|
+
let companyUid;
|
|
40
|
+
try {
|
|
41
|
+
companyUid = await resolveCompany(token, context.company);
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
if (isAuthError(error))
|
|
45
|
+
return skippedForSession();
|
|
46
|
+
if (isCompanySelectionError(error)) {
|
|
47
|
+
return skippedForCompany(error instanceof Error ? error : new Error("Company selection failed."));
|
|
48
|
+
}
|
|
49
|
+
return unknown("The active company could not be resolved; retry after checking HQ connectivity.");
|
|
50
|
+
}
|
|
51
|
+
let connections;
|
|
52
|
+
try {
|
|
53
|
+
connections = await listConnections(token, companyUid);
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
if (isAuthError(error))
|
|
57
|
+
return skippedForSession();
|
|
58
|
+
if (error instanceof IntegrationsCliError && error.expected) {
|
|
59
|
+
return [{
|
|
60
|
+
status: "NA",
|
|
61
|
+
checkId: `${INTEGRATIONS_PREFIX}.unavailable`,
|
|
62
|
+
message: "Integration inventory is unavailable to this account, so no connection health was assessed.",
|
|
63
|
+
}];
|
|
64
|
+
}
|
|
65
|
+
return unknown("The integration connection inventory could not be read; retry after checking HQ connectivity.");
|
|
66
|
+
}
|
|
67
|
+
const active = connections.filter((connection) => connection.status !== "revoked");
|
|
68
|
+
if (active.length === 0) {
|
|
69
|
+
return [{
|
|
70
|
+
status: "NA",
|
|
71
|
+
checkId: `${INTEGRATIONS_PREFIX}.connections`,
|
|
72
|
+
message: "No active integration connections to assess. Revoked connection tombstones are ignored.",
|
|
73
|
+
}];
|
|
74
|
+
}
|
|
75
|
+
const findings = active.flatMap(classifyConnection);
|
|
76
|
+
if (findings.length === 0) {
|
|
77
|
+
return [{
|
|
78
|
+
status: "PASS",
|
|
79
|
+
checkId: `${INTEGRATIONS_PREFIX}.connections`,
|
|
80
|
+
message: `${active.length} active integration connection${active.length === 1 ? "" : "s"} reported healthy; only the control-plane inventory was read (no provider calls).`,
|
|
81
|
+
}];
|
|
82
|
+
}
|
|
83
|
+
return groupFindings(findings, context.company);
|
|
84
|
+
}
|
|
85
|
+
/** Classify without echoing untrusted provider text, which may contain secrets. */
|
|
86
|
+
export function classifyConnection(connection) {
|
|
87
|
+
if (connection.status === "revoked")
|
|
88
|
+
return [];
|
|
89
|
+
const provider = bareProvider(connection.provider);
|
|
90
|
+
const reason = recordedReason(connection);
|
|
91
|
+
const connectedWithRecordedFailure = connection.status === "connected" && reason !== "";
|
|
92
|
+
if (isProviderBlocked(reason) || connection.status === "degraded") {
|
|
93
|
+
return [{
|
|
94
|
+
provider,
|
|
95
|
+
connectionId: connection.id,
|
|
96
|
+
kind: "provider-blocked",
|
|
97
|
+
message: connectedWithRecordedFailure
|
|
98
|
+
? "reports connected, but recorded provider health says access is blocked upstream"
|
|
99
|
+
: "provider-side access is blocked or unavailable",
|
|
100
|
+
}];
|
|
101
|
+
}
|
|
102
|
+
if (isTokenRefreshFailure(reason) || isCredentialFailure(reason)) {
|
|
103
|
+
const problem = isTokenRefreshFailure(reason)
|
|
104
|
+
? "token refresh failed"
|
|
105
|
+
: connectedWithRecordedFailure
|
|
106
|
+
? "reports connected, but the provider rejected the stored credentials"
|
|
107
|
+
: "the provider rejected the stored credentials";
|
|
108
|
+
return [{ provider, connectionId: connection.id, kind: "reconnect", message: problem }];
|
|
109
|
+
}
|
|
110
|
+
if (connection.status !== "connected" && connection.fix_path === "blocked-upstream") {
|
|
111
|
+
return [{
|
|
112
|
+
provider,
|
|
113
|
+
connectionId: connection.id,
|
|
114
|
+
kind: "provider-blocked",
|
|
115
|
+
message: "provider-side access is blocked or unavailable",
|
|
116
|
+
}];
|
|
117
|
+
}
|
|
118
|
+
// The server is authoritative about an API-key credential that must be
|
|
119
|
+
// re-entered. Do not apply a stale fix path to a clean connected row, but do
|
|
120
|
+
// preserve this useful branch logic for a non-healthy row.
|
|
121
|
+
if (connection.status !== "connected" && connection.fix_path === "re-add") {
|
|
122
|
+
return [{
|
|
123
|
+
provider,
|
|
124
|
+
connectionId: connection.id,
|
|
125
|
+
kind: "re-add",
|
|
126
|
+
message: "needs its API key re-entered",
|
|
127
|
+
}];
|
|
128
|
+
}
|
|
129
|
+
if (connection.status === "needs-reauth" || connection.status === "error") {
|
|
130
|
+
return [{
|
|
131
|
+
provider,
|
|
132
|
+
connectionId: connection.id,
|
|
133
|
+
kind: "reconnect",
|
|
134
|
+
message: connection.status === "needs-reauth" ? "needs re-authentication" : "is in an error state",
|
|
135
|
+
}];
|
|
136
|
+
}
|
|
137
|
+
if (connection.status !== "connected") {
|
|
138
|
+
return [{
|
|
139
|
+
provider,
|
|
140
|
+
connectionId: connection.id,
|
|
141
|
+
kind: "reconnect",
|
|
142
|
+
message: `reports an unrecognized non-healthy status (${connection.status})`,
|
|
143
|
+
}];
|
|
144
|
+
}
|
|
145
|
+
return [];
|
|
146
|
+
}
|
|
147
|
+
function groupFindings(findings, company) {
|
|
148
|
+
const groups = new Map();
|
|
149
|
+
for (const finding of findings) {
|
|
150
|
+
const key = `${finding.kind}\u0000${finding.provider}\u0000${finding.message}`;
|
|
151
|
+
const entries = groups.get(key) ?? [];
|
|
152
|
+
entries.push(finding);
|
|
153
|
+
groups.set(key, entries);
|
|
154
|
+
}
|
|
155
|
+
return [...groups.values()]
|
|
156
|
+
.sort((a, b) => a[0].provider.localeCompare(b[0].provider))
|
|
157
|
+
.map((entries) => resultForGroup(entries, company));
|
|
158
|
+
}
|
|
159
|
+
function resultForGroup(entries, company) {
|
|
160
|
+
const first = entries[0];
|
|
161
|
+
const count = entries.length;
|
|
162
|
+
const ids = entries.map((entry) => entry.connectionId);
|
|
163
|
+
const preview = ids.slice(0, 6);
|
|
164
|
+
const overflow = ids.length - preview.length;
|
|
165
|
+
const namedConnections = preview.join(", ") + (overflow > 0 ? ` (+${overflow} more)` : "");
|
|
166
|
+
const plural = count === 1 ? "connection" : "connections";
|
|
167
|
+
if (first.kind === "provider-blocked") {
|
|
168
|
+
return {
|
|
169
|
+
status: "FAIL",
|
|
170
|
+
checkId: `${INTEGRATIONS_PREFIX}.provider-blocked.${first.provider}`,
|
|
171
|
+
target: namedConnections,
|
|
172
|
+
message: `${first.provider}: ${count} ${plural} ${first.message}. This is not reported as a local credential repair.`,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
if (first.kind === "re-add") {
|
|
176
|
+
return {
|
|
177
|
+
status: "FAIL",
|
|
178
|
+
checkId: `${INTEGRATIONS_PREFIX}.re-add.${first.provider}`,
|
|
179
|
+
target: namedConnections,
|
|
180
|
+
message: `${first.provider}: ${count} ${plural} ${first.message}.`,
|
|
181
|
+
remediation: `hq integrations connect ${first.provider} --token-stdin${company ? ` --company ${company}` : ""}`,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
const companyArg = company ? ` --company ${company}` : "";
|
|
185
|
+
const remediation = preview
|
|
186
|
+
.map((id) => `hq integrations reconnect --connection ${id}${companyArg}`)
|
|
187
|
+
.join("; ");
|
|
188
|
+
return {
|
|
189
|
+
status: "FAIL",
|
|
190
|
+
checkId: `${INTEGRATIONS_PREFIX}.reconnect.${first.provider}`,
|
|
191
|
+
target: namedConnections,
|
|
192
|
+
message: `${first.provider}: ${count} ${plural} ${first.message}.`,
|
|
193
|
+
remediation: overflow > 0
|
|
194
|
+
? `Reconnect the listed connections, then repeat for the remaining ${overflow}: ${remediation}`
|
|
195
|
+
: remediation,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
function recordedReason(connection) {
|
|
199
|
+
return [connection.errorReason, connection.needsReauthReason, connection.degradedReason]
|
|
200
|
+
.filter((value) => typeof value === "string")
|
|
201
|
+
.join(" ")
|
|
202
|
+
.toLowerCase();
|
|
203
|
+
}
|
|
204
|
+
function isTokenRefreshFailure(reason) {
|
|
205
|
+
return /token[\s_-]*refresh|refresh[\s_-]*token|invalid_grant/.test(reason);
|
|
206
|
+
}
|
|
207
|
+
function isCredentialFailure(reason) {
|
|
208
|
+
return /rejected[\s_-]*(the[\s_-]*)?stored[\s_-]*credentials|credentials[\s_-]*rejected|unauthori[sz]ed|invalid[\s_-]*(token|credentials)/.test(reason);
|
|
209
|
+
}
|
|
210
|
+
function isProviderBlocked(reason) {
|
|
211
|
+
return /\b403\b|access[\s_-]*denied|upstream[\s_-]*(block|blocked)|provider[\s_-]*(block|blocked|unavailable)|service[\s_-]*unavailable/.test(reason);
|
|
212
|
+
}
|
|
213
|
+
function skippedForSession() {
|
|
214
|
+
return [{
|
|
215
|
+
status: "NA",
|
|
216
|
+
checkId: `${INTEGRATIONS_PREFIX}.session`,
|
|
217
|
+
message: "Integration checks skipped: no usable HQ session is available.",
|
|
218
|
+
remediation: "Run `hq login`, then rerun `hq doctor`.",
|
|
219
|
+
}];
|
|
220
|
+
}
|
|
221
|
+
function skippedForCompany(error) {
|
|
222
|
+
return [{
|
|
223
|
+
status: "NA",
|
|
224
|
+
checkId: `${INTEGRATIONS_PREFIX}.company`,
|
|
225
|
+
message: "Integration checks skipped: no single active company could be selected.",
|
|
226
|
+
remediation: `Rerun \`hq doctor --company <slug>\`. (${error.message})`,
|
|
227
|
+
}];
|
|
228
|
+
}
|
|
229
|
+
function unknown(message) {
|
|
230
|
+
return [{ status: "UNKNOWN", checkId: `${INTEGRATIONS_PREFIX}.inventory`, message }];
|
|
231
|
+
}
|
|
232
|
+
export const integrationsFamily = {
|
|
233
|
+
id: INTEGRATIONS_FAMILY_ID,
|
|
234
|
+
title: "Integration connection health",
|
|
235
|
+
run: (context) => checkIntegrations(context),
|
|
236
|
+
};
|
|
237
|
+
//# sourceMappingURL=integrations.js.map
|
|
@@ -19,6 +19,7 @@ import { checkCodexWiring } from "./checks/codex-wiring.js";
|
|
|
19
19
|
import { checkGrokWiring } from "./checks/grok-wiring.js";
|
|
20
20
|
import { checkRuntimeProbe } from "./checks/runtime-probe.js";
|
|
21
21
|
import { runtimeHealthFamily } from "./checks/runtime-health.js";
|
|
22
|
+
import { integrationsFamily } from "./checks/integrations.js";
|
|
22
23
|
import { fixtureCoverageFamily } from "./fixtures/discover.js";
|
|
23
24
|
import { checkClaudeWiring } from "./checks/claude-wiring.js";
|
|
24
25
|
/**
|
|
@@ -177,6 +178,9 @@ export function createDefaultRegistry() {
|
|
|
177
178
|
// runtime is logged out or broken; this family closes that blind spot. Its
|
|
178
179
|
// default tier is a pure PATH scan, preserving the offline contract.
|
|
179
180
|
registry.register(runtimeHealthFamily);
|
|
181
|
+
// The engine remains family-agnostic: integrations is one bounded,
|
|
182
|
+
// read-only inventory family, registered alongside all other checks.
|
|
183
|
+
registry.register(integrationsFamily);
|
|
180
184
|
return registry;
|
|
181
185
|
}
|
|
182
186
|
//# sourceMappingURL=registry.js.map
|
|
@@ -66,6 +66,10 @@ export interface CheckContext {
|
|
|
66
66
|
* executing anything.
|
|
67
67
|
*/
|
|
68
68
|
liveRuntimes?: boolean;
|
|
69
|
+
/** Company slug for company-scoped families; absent uses active membership. */
|
|
70
|
+
company?: string;
|
|
71
|
+
/** Internal test seam; production `hq doctor` always enables integrations. */
|
|
72
|
+
integrations?: boolean;
|
|
69
73
|
}
|
|
70
74
|
/**
|
|
71
75
|
* A check family: an id, a human title, and an async `run` returning per-item
|
package/dist/main.js
CHANGED
|
@@ -72,6 +72,7 @@ import { isExpectedUserError } from "./utils/expected-cli-error.js";
|
|
|
72
72
|
import { isEpipe } from "./utils/epipe.js";
|
|
73
73
|
import { isInterceptedProcessExit } from "./utils/intercepted-process-exit.js";
|
|
74
74
|
import { isAuthError } from "./utils/auth-error.js";
|
|
75
|
+
import { browserLoginAbandonedMessage } from "./utils/browser-login-abandoned.js";
|
|
75
76
|
import { isCompanySelectionError } from "./utils/company-selection-error.js";
|
|
76
77
|
import { canOfferTeamUpgrade, formatPlanGateError, isPlanGateError, offerTeamUpgrade, } from "./utils/plan-gate-error.js";
|
|
77
78
|
import { upgradeToTeam } from "./utils/team-upgrade.js";
|
|
@@ -394,6 +395,21 @@ export async function handleTopLevelError(err, deps = defaultTopLevelErrorDepend
|
|
|
394
395
|
deps.stderr.write(`hq: ${err.message}\n`);
|
|
395
396
|
deps.setExitCode(1);
|
|
396
397
|
}
|
|
398
|
+
else if (browserLoginAbandonedMessage(err)) {
|
|
399
|
+
// HQ-CLI-V: the caller triggered the implicit browser sign-in fallback
|
|
400
|
+
// (an expiring/absent HQ session) and then never completed it —
|
|
401
|
+
// @indigoai-us/hq-cloud's browserLogin rejected either at its 15-minute
|
|
402
|
+
// deadline or because the user declined at the IdP (access_denied). A
|
|
403
|
+
// human walking away from an OAuth prompt is expected auth state the user
|
|
404
|
+
// fixes with `hq login`, not an hq-cli defect. Print the fixed actionable
|
|
405
|
+
// remedy and skip Sentry so one abandoned login doesn't file a permanent
|
|
406
|
+
// high-priority "crash". Placed with the auth carve-out (both mean "your
|
|
407
|
+
// HQ session isn't usable; run hq login"); every genuine browser-login or
|
|
408
|
+
// token-exchange fault stays outside the closed allowlist and still
|
|
409
|
+
// captures below.
|
|
410
|
+
deps.stderr.write(`hq: ${browserLoginAbandonedMessage(err)}\n`);
|
|
411
|
+
deps.setExitCode(1);
|
|
412
|
+
}
|
|
397
413
|
else if (isPlanGateError(err)) {
|
|
398
414
|
// hq-pro's plan denials are expected product limits, never a CLI crash.
|
|
399
415
|
// The shared vault client has already decoded and typed the small safe
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* If `err` is an abandoned implicit browser sign-in, return a short, fixed
|
|
3
|
+
* user-facing remedy; otherwise return `null`.
|
|
4
|
+
*
|
|
5
|
+
* A non-null result means the caller should print the message and SKIP Sentry
|
|
6
|
+
* capture — a user who walked away from an OAuth prompt is not an hq-cli defect.
|
|
7
|
+
* A null result means "handle as usual (capture to Sentry)". CognitoRefreshError
|
|
8
|
+
* is a refresh fault, never an abandonment, so it is excluded structurally even
|
|
9
|
+
* though it extends CognitoAuthError.
|
|
10
|
+
*/
|
|
11
|
+
export declare function browserLoginAbandonedMessage(err: unknown): string | null;
|
|
12
|
+
//# sourceMappingURL=browser-login-abandoned.d.ts.map
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// src/utils/browser-login-abandoned.ts
|
|
2
|
+
//
|
|
3
|
+
// Classify an ABANDONED implicit browser sign-in — the caller triggered the
|
|
4
|
+
// fallback OAuth flow (an expiring/absent HQ session on any of the ~100
|
|
5
|
+
// ensureCognitoToken/ensureCognitoIdToken call sites) and then never completed
|
|
6
|
+
// it. @indigoai-us/hq-cloud's browserLogin arms a 15-minute deadline and also
|
|
7
|
+
// rejects when the identity provider reports the user declined; both mint a
|
|
8
|
+
// bare CognitoAuthError. A human walking away from an OAuth prompt is expected
|
|
9
|
+
// auth state the user fixes with `hq login`, NOT an hq-cli defect — so the
|
|
10
|
+
// top-level handler prints an actionable line and exits non-zero but SKIPS
|
|
11
|
+
// Sentry capture, mirroring the auth (HQ-CLI-8), company-selection (HQ-CLI-7),
|
|
12
|
+
// expected-user-error (HQ-CLI-6), and environmental-FS (HQ-CLI-2) carve-outs.
|
|
13
|
+
//
|
|
14
|
+
// HQ-CLI-V (Sentry indigo-d0/hq-cli 7685055900): `hq integrations list
|
|
15
|
+
// --company <slug> --json` tried to refresh an expiring session, the refresh
|
|
16
|
+
// 400'd (invalid_grant), and it fell back to browserLogin(). Nobody finished
|
|
17
|
+
// the sign-in, so exactly 15 minutes later browserLogin rejected with
|
|
18
|
+
// `CognitoAuthError("Login timed out after 15 minutes")`. That error carries no
|
|
19
|
+
// `code`, no `expected` flag, and is not an AuthError/CompanySelectionError/…,
|
|
20
|
+
// so every predicate in handleTopLevelError returned false and the abandoned
|
|
21
|
+
// login was captured as a high-priority, unfixable "crash".
|
|
22
|
+
//
|
|
23
|
+
// The allowlist is CLOSED at the two upstream-minted human-abandonment
|
|
24
|
+
// signatures and defaults to null (capture). Every other CognitoAuthError — a
|
|
25
|
+
// state-parameter mismatch (possible CSRF), a missing callback code, a
|
|
26
|
+
// non-access_denied IdP error, a token-exchange failure — and every
|
|
27
|
+
// CognitoRefreshError (a refresh fault, not an abandonment) still reaches
|
|
28
|
+
// Sentry.
|
|
29
|
+
import { CognitoAuthError, CognitoRefreshError } from "@indigoai-us/hq-cloud";
|
|
30
|
+
/**
|
|
31
|
+
* Fixed, actionable remedy. Deliberately interpolates NO upstream error text,
|
|
32
|
+
* so this carve-out can never widen disclosure and needs no redaction pass.
|
|
33
|
+
*/
|
|
34
|
+
const BROWSER_LOGIN_ABANDONED_MESSAGE = "Browser sign-in was not completed. Run `hq login`, finish the sign-in in " +
|
|
35
|
+
"your browser, then re-run your command.";
|
|
36
|
+
/**
|
|
37
|
+
* The 15-minute login-deadline signature (@indigoai-us/hq-cloud cognito-auth
|
|
38
|
+
* `waitForAuthCode`). The minute count is tolerated (`\d+`) so an upstream
|
|
39
|
+
* retune of the deadline turns the contract test red rather than silently
|
|
40
|
+
* restoring the Sentry noise.
|
|
41
|
+
*/
|
|
42
|
+
const LOGIN_TIMED_OUT = /^Login timed out after \d+ minutes?$/;
|
|
43
|
+
/**
|
|
44
|
+
* The user declined at the identity provider. Matched EXACTLY — other
|
|
45
|
+
* `Cognito returned error: <x>` values (server_error, …) are not abandonment
|
|
46
|
+
* and stay reportable.
|
|
47
|
+
*/
|
|
48
|
+
const ACCESS_DENIED = "Cognito returned error: access_denied";
|
|
49
|
+
/**
|
|
50
|
+
* If `err` is an abandoned implicit browser sign-in, return a short, fixed
|
|
51
|
+
* user-facing remedy; otherwise return `null`.
|
|
52
|
+
*
|
|
53
|
+
* A non-null result means the caller should print the message and SKIP Sentry
|
|
54
|
+
* capture — a user who walked away from an OAuth prompt is not an hq-cli defect.
|
|
55
|
+
* A null result means "handle as usual (capture to Sentry)". CognitoRefreshError
|
|
56
|
+
* is a refresh fault, never an abandonment, so it is excluded structurally even
|
|
57
|
+
* though it extends CognitoAuthError.
|
|
58
|
+
*/
|
|
59
|
+
export function browserLoginAbandonedMessage(err) {
|
|
60
|
+
if (!(err instanceof CognitoAuthError))
|
|
61
|
+
return null;
|
|
62
|
+
if (err instanceof CognitoRefreshError)
|
|
63
|
+
return null;
|
|
64
|
+
const { message } = err;
|
|
65
|
+
if (LOGIN_TIMED_OUT.test(message) || message === ACCESS_DENIED) {
|
|
66
|
+
return BROWSER_LOGIN_ABANDONED_MESSAGE;
|
|
67
|
+
}
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
//# sourceMappingURL=browser-login-abandoned.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@indigoai-us/hq-cli",
|
|
3
|
-
"version": "5.103.
|
|
3
|
+
"version": "5.103.25",
|
|
4
4
|
"description": "HQ by Indigo management CLI — modules and cloud sync",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
"dependencies": {
|
|
31
31
|
"@aws-sdk/client-iot-data-plane": "^3.1096.0",
|
|
32
32
|
"@aws-sdk/client-s3": "^3.1049.0",
|
|
33
|
-
"@indigoai-us/hq-cloud": "~6.15.
|
|
33
|
+
"@indigoai-us/hq-cloud": "~6.15.79",
|
|
34
34
|
"@indigoai-us/hq-onboarding": "^0.1.0",
|
|
35
35
|
"@sentry/node": "^10.49.0",
|
|
36
36
|
"@tobilu/qmd": "2.5.3",
|