@indigoai-us/hq-cli 5.108.9 → 5.108.11
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 +18 -0
- package/dist/lib/doctor/checks/runtime-probe.d.ts +81 -0
- package/dist/lib/doctor/checks/runtime-probe.js +192 -0
- package/dist/lib/doctor/compat.d.ts +16 -0
- package/dist/lib/doctor/compat.js +12 -1
- package/dist/utils/self-update.js +8 -1
- package/dist/utils/version-gate.d.ts +26 -0
- package/dist/utils/version-gate.js +54 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,24 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.108.11] — 2026-09-05
|
|
6
|
+
|
|
7
|
+
## [5.108.10] — 2026-09-05
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- `hq doctor` now self-attests hook enforcement on agents-v2 (hermes) fleet
|
|
12
|
+
hosts, which host detection leaves platform-unknown. When the runtime is
|
|
13
|
+
agents-v2 (the runtime marker reports `agents-v2`, or the on-box hook adapter
|
|
14
|
+
is installed under the tree), `.claude/settings.json` wires that on-box
|
|
15
|
+
adapter, and a policy-trigger ledger evidences a live turn (the exact
|
|
16
|
+
session's ledger under `--session-id`, otherwise any ledger fresh within the
|
|
17
|
+
freshness window), the runtime probe reports platform `agents-v2` and PASS
|
|
18
|
+
instead of UNKNOWN — because the on-box adapter provably wrote the ledger
|
|
19
|
+
through the same `.claude` hooks. This is the TypeScript twin of
|
|
20
|
+
`agents_v2_attested` in `check-hq-hooks.sh`; all three signals are required, so
|
|
21
|
+
no non-agents-v2 host's verdict changes.
|
|
22
|
+
|
|
5
23
|
## [5.108.9] — 2026-09-05
|
|
6
24
|
|
|
7
25
|
## [5.108.8] — 2026-09-05
|
|
@@ -49,6 +49,25 @@ export declare const RUNTIME_PROBE_PREFIX = "hooks.runtime";
|
|
|
49
49
|
export declare const POLICY_TRIGGER_LEDGER_RELPATH = "workspace/orchestrator/policy-trigger-state";
|
|
50
50
|
/** The two lifecycle events `check-hq-hooks.sh` requires a command hook on. */
|
|
51
51
|
export declare const REQUIRED_HOOK_EVENTS: readonly ["SessionStart", "PreToolUse"];
|
|
52
|
+
/**
|
|
53
|
+
* Default runtime marker path an agents-v2 (hermes) box writes its runtime mode
|
|
54
|
+
* to. Overridable via `HQ_RUNTIME_MARKER_FILE`, exactly as the shell reads
|
|
55
|
+
* `${HQ_RUNTIME_MARKER_FILE:-/var/lib/hq-agent/runtime.json}`.
|
|
56
|
+
*/
|
|
57
|
+
export declare const HQ_RUNTIME_MARKER_DEFAULT = "/var/lib/hq-agent/runtime.json";
|
|
58
|
+
/**
|
|
59
|
+
* Path (relative to the HQ root) of the on-box agents-v2 hook adapter. Its
|
|
60
|
+
* presence under the tree is one of the two signals that the runtime is
|
|
61
|
+
* agents-v2, mirroring `hq_runtime_mode` in `check-hq-hooks.sh`.
|
|
62
|
+
*/
|
|
63
|
+
export declare const AGENTS_V2_ADAPTER_RELPATH = ".agents-v2-hooks/hq-agents-v2-hook-adapter.sh";
|
|
64
|
+
/**
|
|
65
|
+
* Default hours a policy-trigger ledger may age and still evidence a live
|
|
66
|
+
* agents-v2 turn when no exact session id is given. Overridable via
|
|
67
|
+
* `HQ_V2_LEDGER_MAX_AGE_HOURS`, matching the shell's
|
|
68
|
+
* `HQ_V2_LEDGER_MAX_AGE_HOURS="${HQ_V2_LEDGER_MAX_AGE_HOURS:-24}"`.
|
|
69
|
+
*/
|
|
70
|
+
export declare const HQ_V2_LEDGER_MAX_AGE_HOURS_DEFAULT = 24;
|
|
52
71
|
/** Whether the ledger was found. Mirrors the script's `present`/`missing`. */
|
|
53
72
|
export type LedgerState = "present" | "missing";
|
|
54
73
|
/** Options for {@link reproduceCheckHqHooks}. */
|
|
@@ -92,6 +111,68 @@ export interface HookLoadReproduction {
|
|
|
92
111
|
* exception, so the probe degrades exactly like the defensively-written script.
|
|
93
112
|
*/
|
|
94
113
|
export declare function reproduceCheckHqHooks(opts: ReproduceOptions): HookLoadReproduction;
|
|
114
|
+
/** Inputs for the agents-v2 attestation helpers. */
|
|
115
|
+
export interface AgentsV2AttestationOptions {
|
|
116
|
+
/** Absolute HQ tree root. */
|
|
117
|
+
hqRoot: string;
|
|
118
|
+
/** Optional session scope for the ledger check (the `--session-id` flag). */
|
|
119
|
+
sessionId?: string;
|
|
120
|
+
/** Environment to read (marker path, freshness window). Default: process.env. */
|
|
121
|
+
env?: NodeJS.ProcessEnv;
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Whether the runtime is agents-v2. Two signals, either sufficient — the same
|
|
125
|
+
* two `hq_runtime_mode` uses: the runtime marker
|
|
126
|
+
* (`HQ_RUNTIME_MARKER_FILE`, default {@link HQ_RUNTIME_MARKER_DEFAULT}) reads
|
|
127
|
+
* `runtimeMode == "agents-v2"`, OR the on-box adapter is installed under the
|
|
128
|
+
* tree at {@link AGENTS_V2_ADAPTER_RELPATH}.
|
|
129
|
+
*/
|
|
130
|
+
export declare function isAgentsV2Runtime(hqRoot: string, env?: NodeJS.ProcessEnv): boolean;
|
|
131
|
+
/**
|
|
132
|
+
* Env var overriding the agents-v2 runtime (hermes) config path (chiefly for
|
|
133
|
+
* tests), mirroring `HQ_RUNTIME_MARKER_FILE` and the shell's
|
|
134
|
+
* `HQ_HERMES_CONFIG_FILE`.
|
|
135
|
+
*/
|
|
136
|
+
export declare const HQ_HERMES_CONFIG_ENV = "HQ_HERMES_CONFIG_FILE";
|
|
137
|
+
/**
|
|
138
|
+
* The agents-v2 runtime (hermes) config whose `hooks:` block wires the on-box
|
|
139
|
+
* adapter into every lifecycle event — the env override, else `~/.hermes/config.yaml`
|
|
140
|
+
* (where `provision/render-config.sh` renders it). Mirrors the shell's
|
|
141
|
+
* `HQ_HERMES_CONFIG_FILE="${HQ_HERMES_CONFIG_FILE:-$HOME/.hermes/config.yaml}"`.
|
|
142
|
+
*/
|
|
143
|
+
export declare function resolveHermesConfigPath(env?: NodeJS.ProcessEnv): string;
|
|
144
|
+
/**
|
|
145
|
+
* Whether the agents-v2 runtime actually wires the on-box hook adapter.
|
|
146
|
+
*
|
|
147
|
+
* The wiring lives in the RUNTIME's hook config, not `.claude/settings.json`. On
|
|
148
|
+
* a real box the v2 runtime's shell-hook dispatcher (`agent/shell_hooks.py`)
|
|
149
|
+
* reads a `hooks:` block from `~/.hermes/config.yaml` with one entry per
|
|
150
|
+
* lifecycle event, each invoking `hq-agents-v2-hook-adapter.sh`; the adapter in
|
|
151
|
+
* turn READS `.claude/settings.json` (via `hook-adapter-core.sh`) to fan out to
|
|
152
|
+
* the classic `.claude/hooks` set. So `.claude/settings.json` never NAMES the
|
|
153
|
+
* adapter — it wires the classic `hook-gate.sh` hooks — and grepping it for the
|
|
154
|
+
* adapter always fails on a real box (verified on the v2.17 canary
|
|
155
|
+
* i-0277243ad3aed8109, 2026-09-05: settings.json had 0 adapter refs / 94
|
|
156
|
+
* hook-gate.sh refs, while ~/.hermes/config.yaml wired the adapter across 7
|
|
157
|
+
* events). Require BOTH the adapter installed under the tree at
|
|
158
|
+
* {@link AGENTS_V2_ADAPTER_RELPATH} AND the runtime config invoking it. Mirrors
|
|
159
|
+
* `hq_runtime_config_wires_v2_adapter` in `check-hq-hooks.sh`.
|
|
160
|
+
*/
|
|
161
|
+
export declare function runtimeConfigWiresV2Adapter(hqRoot: string, env?: NodeJS.ProcessEnv): boolean;
|
|
162
|
+
/**
|
|
163
|
+
* Whether a policy-trigger ledger evidencing a live agents-v2 turn is present:
|
|
164
|
+
* the exact session's ledger when a session id is given (session identity
|
|
165
|
+
* implies freshness), otherwise any ledger modified within the freshness window
|
|
166
|
+
* so a long-dead tree cannot self-attest off a stale file. Mirrors
|
|
167
|
+
* `hq_v2_ledger_present`.
|
|
168
|
+
*/
|
|
169
|
+
export declare function v2LedgerPresent(opts: AgentsV2AttestationOptions): boolean;
|
|
170
|
+
/**
|
|
171
|
+
* All three agents-v2 self-attestation conditions. Used only to GRANT PASS to a
|
|
172
|
+
* hermes box that host detection leaves platform-unknown; never to withhold it.
|
|
173
|
+
* The exact conjunction of `agents_v2_attested` in `check-hq-hooks.sh`.
|
|
174
|
+
*/
|
|
175
|
+
export declare function agentsV2Attested(opts: AgentsV2AttestationOptions): boolean;
|
|
95
176
|
/**
|
|
96
177
|
* The runtime-probe check family entry. Reproduces the script verdict, then
|
|
97
178
|
* renders it as a single platform-aware doctor result. See the module header for
|
|
@@ -38,6 +38,7 @@
|
|
|
38
38
|
* very evidence it is looking for.
|
|
39
39
|
*/
|
|
40
40
|
import * as fs from "node:fs";
|
|
41
|
+
import * as os from "node:os";
|
|
41
42
|
import * as path from "node:path";
|
|
42
43
|
import { scanHookCommand } from "./claude-wiring.js";
|
|
43
44
|
/** Common id prefix for every result the runtime probe emits. */
|
|
@@ -51,6 +52,25 @@ export const RUNTIME_PROBE_PREFIX = "hooks.runtime";
|
|
|
51
52
|
export const POLICY_TRIGGER_LEDGER_RELPATH = "workspace/orchestrator/policy-trigger-state";
|
|
52
53
|
/** The two lifecycle events `check-hq-hooks.sh` requires a command hook on. */
|
|
53
54
|
export const REQUIRED_HOOK_EVENTS = ["SessionStart", "PreToolUse"];
|
|
55
|
+
/**
|
|
56
|
+
* Default runtime marker path an agents-v2 (hermes) box writes its runtime mode
|
|
57
|
+
* to. Overridable via `HQ_RUNTIME_MARKER_FILE`, exactly as the shell reads
|
|
58
|
+
* `${HQ_RUNTIME_MARKER_FILE:-/var/lib/hq-agent/runtime.json}`.
|
|
59
|
+
*/
|
|
60
|
+
export const HQ_RUNTIME_MARKER_DEFAULT = "/var/lib/hq-agent/runtime.json";
|
|
61
|
+
/**
|
|
62
|
+
* Path (relative to the HQ root) of the on-box agents-v2 hook adapter. Its
|
|
63
|
+
* presence under the tree is one of the two signals that the runtime is
|
|
64
|
+
* agents-v2, mirroring `hq_runtime_mode` in `check-hq-hooks.sh`.
|
|
65
|
+
*/
|
|
66
|
+
export const AGENTS_V2_ADAPTER_RELPATH = ".agents-v2-hooks/hq-agents-v2-hook-adapter.sh";
|
|
67
|
+
/**
|
|
68
|
+
* Default hours a policy-trigger ledger may age and still evidence a live
|
|
69
|
+
* agents-v2 turn when no exact session id is given. Overridable via
|
|
70
|
+
* `HQ_V2_LEDGER_MAX_AGE_HOURS`, matching the shell's
|
|
71
|
+
* `HQ_V2_LEDGER_MAX_AGE_HOURS="${HQ_V2_LEDGER_MAX_AGE_HOURS:-24}"`.
|
|
72
|
+
*/
|
|
73
|
+
export const HQ_V2_LEDGER_MAX_AGE_HOURS_DEFAULT = 24;
|
|
54
74
|
/**
|
|
55
75
|
* Faithfully reproduce `core/scripts/check-hq-hooks.sh --require-ledger` in
|
|
56
76
|
* TypeScript. The checks, in the script's order:
|
|
@@ -110,6 +130,150 @@ export function reproduceCheckHqHooks(opts) {
|
|
|
110
130
|
}
|
|
111
131
|
return { ok: issues.length === 0, ledgerState, issues };
|
|
112
132
|
}
|
|
133
|
+
/**
|
|
134
|
+
* Whether the runtime is agents-v2. Two signals, either sufficient — the same
|
|
135
|
+
* two `hq_runtime_mode` uses: the runtime marker
|
|
136
|
+
* (`HQ_RUNTIME_MARKER_FILE`, default {@link HQ_RUNTIME_MARKER_DEFAULT}) reads
|
|
137
|
+
* `runtimeMode == "agents-v2"`, OR the on-box adapter is installed under the
|
|
138
|
+
* tree at {@link AGENTS_V2_ADAPTER_RELPATH}.
|
|
139
|
+
*/
|
|
140
|
+
export function isAgentsV2Runtime(hqRoot, env = process.env) {
|
|
141
|
+
const markerPath = env.HQ_RUNTIME_MARKER_FILE?.trim() || HQ_RUNTIME_MARKER_DEFAULT;
|
|
142
|
+
if (readRuntimeMarkerMode(markerPath) === "agents-v2")
|
|
143
|
+
return true;
|
|
144
|
+
return isFile(path.join(hqRoot, ...AGENTS_V2_ADAPTER_RELPATH.split("/")));
|
|
145
|
+
}
|
|
146
|
+
/** The `runtimeMode` field of the runtime marker JSON, or null when unreadable. */
|
|
147
|
+
function readRuntimeMarkerMode(markerPath) {
|
|
148
|
+
let raw;
|
|
149
|
+
try {
|
|
150
|
+
raw = fs.readFileSync(markerPath, "utf8");
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
try {
|
|
156
|
+
const parsed = JSON.parse(raw);
|
|
157
|
+
return typeof parsed.runtimeMode === "string" ? parsed.runtimeMode : null;
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Env var overriding the agents-v2 runtime (hermes) config path (chiefly for
|
|
165
|
+
* tests), mirroring `HQ_RUNTIME_MARKER_FILE` and the shell's
|
|
166
|
+
* `HQ_HERMES_CONFIG_FILE`.
|
|
167
|
+
*/
|
|
168
|
+
export const HQ_HERMES_CONFIG_ENV = "HQ_HERMES_CONFIG_FILE";
|
|
169
|
+
/**
|
|
170
|
+
* The agents-v2 runtime (hermes) config whose `hooks:` block wires the on-box
|
|
171
|
+
* adapter into every lifecycle event — the env override, else `~/.hermes/config.yaml`
|
|
172
|
+
* (where `provision/render-config.sh` renders it). Mirrors the shell's
|
|
173
|
+
* `HQ_HERMES_CONFIG_FILE="${HQ_HERMES_CONFIG_FILE:-$HOME/.hermes/config.yaml}"`.
|
|
174
|
+
*/
|
|
175
|
+
export function resolveHermesConfigPath(env = process.env) {
|
|
176
|
+
const override = env[HQ_HERMES_CONFIG_ENV]?.trim();
|
|
177
|
+
if (override)
|
|
178
|
+
return override;
|
|
179
|
+
return path.join(os.homedir(), ".hermes", "config.yaml");
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Whether the agents-v2 runtime actually wires the on-box hook adapter.
|
|
183
|
+
*
|
|
184
|
+
* The wiring lives in the RUNTIME's hook config, not `.claude/settings.json`. On
|
|
185
|
+
* a real box the v2 runtime's shell-hook dispatcher (`agent/shell_hooks.py`)
|
|
186
|
+
* reads a `hooks:` block from `~/.hermes/config.yaml` with one entry per
|
|
187
|
+
* lifecycle event, each invoking `hq-agents-v2-hook-adapter.sh`; the adapter in
|
|
188
|
+
* turn READS `.claude/settings.json` (via `hook-adapter-core.sh`) to fan out to
|
|
189
|
+
* the classic `.claude/hooks` set. So `.claude/settings.json` never NAMES the
|
|
190
|
+
* adapter — it wires the classic `hook-gate.sh` hooks — and grepping it for the
|
|
191
|
+
* adapter always fails on a real box (verified on the v2.17 canary
|
|
192
|
+
* i-0277243ad3aed8109, 2026-09-05: settings.json had 0 adapter refs / 94
|
|
193
|
+
* hook-gate.sh refs, while ~/.hermes/config.yaml wired the adapter across 7
|
|
194
|
+
* events). Require BOTH the adapter installed under the tree at
|
|
195
|
+
* {@link AGENTS_V2_ADAPTER_RELPATH} AND the runtime config invoking it. Mirrors
|
|
196
|
+
* `hq_runtime_config_wires_v2_adapter` in `check-hq-hooks.sh`.
|
|
197
|
+
*/
|
|
198
|
+
export function runtimeConfigWiresV2Adapter(hqRoot, env = process.env) {
|
|
199
|
+
const adapter = path.join(hqRoot, ...AGENTS_V2_ADAPTER_RELPATH.split("/"));
|
|
200
|
+
if (!isFile(adapter))
|
|
201
|
+
return false;
|
|
202
|
+
let raw;
|
|
203
|
+
try {
|
|
204
|
+
raw = fs.readFileSync(resolveHermesConfigPath(env), "utf8");
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
return false;
|
|
208
|
+
}
|
|
209
|
+
return raw.includes("hq-agents-v2-hook-adapter.sh");
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Whether a policy-trigger ledger evidencing a live agents-v2 turn is present:
|
|
213
|
+
* the exact session's ledger when a session id is given (session identity
|
|
214
|
+
* implies freshness), otherwise any ledger modified within the freshness window
|
|
215
|
+
* so a long-dead tree cannot self-attest off a stale file. Mirrors
|
|
216
|
+
* `hq_v2_ledger_present`.
|
|
217
|
+
*/
|
|
218
|
+
export function v2LedgerPresent(opts) {
|
|
219
|
+
const env = opts.env ?? process.env;
|
|
220
|
+
const dir = path.join(opts.hqRoot, ...POLICY_TRIGGER_LEDGER_RELPATH.split("/"));
|
|
221
|
+
if (!isDir(dir))
|
|
222
|
+
return false;
|
|
223
|
+
if (opts.sessionId) {
|
|
224
|
+
return isFile(path.join(dir, `${opts.sessionId}.txt`));
|
|
225
|
+
}
|
|
226
|
+
const cutoffMs = Date.now() - resolveMaxLedgerAgeHours(env) * 60 * 60 * 1000;
|
|
227
|
+
return ledgerDirHasFreshTxt(dir, cutoffMs);
|
|
228
|
+
}
|
|
229
|
+
/** The freshness window in hours, from the env override or the default. */
|
|
230
|
+
function resolveMaxLedgerAgeHours(env) {
|
|
231
|
+
const raw = env.HQ_V2_LEDGER_MAX_AGE_HOURS?.trim();
|
|
232
|
+
if (!raw)
|
|
233
|
+
return HQ_V2_LEDGER_MAX_AGE_HOURS_DEFAULT;
|
|
234
|
+
const parsed = Number.parseInt(raw, 10);
|
|
235
|
+
return Number.isFinite(parsed) && parsed >= 0
|
|
236
|
+
? parsed
|
|
237
|
+
: HQ_V2_LEDGER_MAX_AGE_HOURS_DEFAULT;
|
|
238
|
+
}
|
|
239
|
+
/** True when any `*.txt` under `dir` (recursive) was modified at/after `cutoffMs`. */
|
|
240
|
+
function ledgerDirHasFreshTxt(dir, cutoffMs) {
|
|
241
|
+
let entries;
|
|
242
|
+
try {
|
|
243
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
244
|
+
}
|
|
245
|
+
catch {
|
|
246
|
+
return false;
|
|
247
|
+
}
|
|
248
|
+
for (const entry of entries) {
|
|
249
|
+
const full = path.join(dir, entry.name);
|
|
250
|
+
if (entry.isDirectory()) {
|
|
251
|
+
if (ledgerDirHasFreshTxt(full, cutoffMs))
|
|
252
|
+
return true;
|
|
253
|
+
}
|
|
254
|
+
else if (entry.isFile() && entry.name.endsWith(".txt")) {
|
|
255
|
+
try {
|
|
256
|
+
if (fs.statSync(full).mtimeMs >= cutoffMs)
|
|
257
|
+
return true;
|
|
258
|
+
}
|
|
259
|
+
catch {
|
|
260
|
+
// Unreadable entry: ignore, keep scanning.
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
return false;
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* All three agents-v2 self-attestation conditions. Used only to GRANT PASS to a
|
|
268
|
+
* hermes box that host detection leaves platform-unknown; never to withhold it.
|
|
269
|
+
* The exact conjunction of `agents_v2_attested` in `check-hq-hooks.sh`.
|
|
270
|
+
*/
|
|
271
|
+
export function agentsV2Attested(opts) {
|
|
272
|
+
const env = opts.env ?? process.env;
|
|
273
|
+
return (isAgentsV2Runtime(opts.hqRoot, env) &&
|
|
274
|
+
runtimeConfigWiresV2Adapter(opts.hqRoot, env) &&
|
|
275
|
+
v2LedgerPresent({ ...opts, env }));
|
|
276
|
+
}
|
|
113
277
|
/**
|
|
114
278
|
* The runtime-probe check family entry. Reproduces the script verdict, then
|
|
115
279
|
* renders it as a single platform-aware doctor result. See the module header for
|
|
@@ -122,6 +286,26 @@ export function checkRuntimeProbe(context) {
|
|
|
122
286
|
const checkId = `${RUNTIME_PROBE_PREFIX}.enforcement`;
|
|
123
287
|
const target = POLICY_TRIGGER_LEDGER_RELPATH;
|
|
124
288
|
const scope = sessionId ? ` for session ${sessionId}` : "";
|
|
289
|
+
// agents-v2 (hermes) self-attestation. hq doctor leaves the hermes host
|
|
290
|
+
// platform-unknown and, on an unknown host, the probe would report UNKNOWN
|
|
291
|
+
// below. But the on-box adapter provably wrote the ledger through the same
|
|
292
|
+
// .claude hooks, so grant PASS — and report platform "agents-v2" — when, and
|
|
293
|
+
// only when, the runtime is agents-v2, the runtime config wires the on-box adapter, and a
|
|
294
|
+
// ledger exists (the exact session's under --session-id; otherwise any ledger
|
|
295
|
+
// fresh within the window). Requires the on-box marker/adapter, so this never
|
|
296
|
+
// changes the verdict for any other host. See agentsV2Attested().
|
|
297
|
+
if (agentsV2Attested({ hqRoot, sessionId })) {
|
|
298
|
+
return [
|
|
299
|
+
{
|
|
300
|
+
status: "PASS",
|
|
301
|
+
checkId,
|
|
302
|
+
target,
|
|
303
|
+
message: `Host platform is agents-v2 (hermes fleet): the on-box adapter wrote the ` +
|
|
304
|
+
`policy-trigger ledger through the same .claude hooks, so hook dispatch was ` +
|
|
305
|
+
`observed this session — the ledger has an entry${scope}.`,
|
|
306
|
+
},
|
|
307
|
+
];
|
|
308
|
+
}
|
|
125
309
|
const repro = reproduceCheckHqHooks({ hqRoot, sessionId });
|
|
126
310
|
// Unknown host: the label cannot be trusted, so live enforcement cannot be
|
|
127
311
|
// verified in either direction. UNKNOWN (never PASS or FAIL) is the honest
|
|
@@ -332,4 +516,12 @@ function isFile(file) {
|
|
|
332
516
|
return false;
|
|
333
517
|
}
|
|
334
518
|
}
|
|
519
|
+
function isDir(dir) {
|
|
520
|
+
try {
|
|
521
|
+
return fs.statSync(dir).isDirectory();
|
|
522
|
+
}
|
|
523
|
+
catch {
|
|
524
|
+
return false;
|
|
525
|
+
}
|
|
526
|
+
}
|
|
335
527
|
//# sourceMappingURL=runtime-probe.js.map
|
|
@@ -74,6 +74,22 @@ export interface DeriveVerdictOptions {
|
|
|
74
74
|
* inline script treats a missing ledger — regardless of platform.
|
|
75
75
|
*/
|
|
76
76
|
requireLedger?: boolean;
|
|
77
|
+
/**
|
|
78
|
+
* Whether this is an attested agents-v2 (hermes) box: the runtime is
|
|
79
|
+
* agents-v2, `.claude/settings.json` wires the on-box adapter, and a
|
|
80
|
+
* policy-trigger ledger exists (the exact session's when a session id is
|
|
81
|
+
* given; otherwise a ledger fresh within the freshness window). When true,
|
|
82
|
+
* OBSERVED is granted even though the doctor's runtime check did not report
|
|
83
|
+
* PASS — because hq doctor leaves the hermes host platform-unknown, yet the
|
|
84
|
+
* on-box adapter provably wrote the ledger through the same .claude hooks.
|
|
85
|
+
*
|
|
86
|
+
* This is the exact twin of the `agents_v2_attested` override in
|
|
87
|
+
* `render_from_doctor` (`core/scripts/check-hq-hooks.sh`). Establish it with
|
|
88
|
+
* {@link agentsV2Attested} in `checks/runtime-probe.ts` — the same three
|
|
89
|
+
* signals the shell's `agents_v2_attested` reads. It can only GRANT OBSERVED,
|
|
90
|
+
* never withhold it, so every non-agents-v2 caller is unaffected.
|
|
91
|
+
*/
|
|
92
|
+
agentsV2Attested?: boolean;
|
|
77
93
|
}
|
|
78
94
|
/**
|
|
79
95
|
* Derive the check-hq-hooks.sh verdict from a `hq doctor --json` document,
|
|
@@ -80,7 +80,18 @@ export function deriveCheckHqHooksVerdict(doc, options = {}) {
|
|
|
80
80
|
// for --require-ledger, matching the inline script's "fail on a missing
|
|
81
81
|
// ledger" contract.
|
|
82
82
|
const runtimeResult = doc.results.find((result) => result.checkId === RUNTIME_ENFORCEMENT_CHECK_ID);
|
|
83
|
-
|
|
83
|
+
let runtime = observeRuntime(runtimeResult?.status, requireLedger);
|
|
84
|
+
// agents-v2 self-attestation: hq doctor leaves the hermes host
|
|
85
|
+
// platform-unknown and so does not report the runtime check as PASS, but the
|
|
86
|
+
// on-box adapter provably wrote the ledger through the same .claude hooks.
|
|
87
|
+
// Grant the identical OBSERVED verdict rather than relaying the host-unknown
|
|
88
|
+
// status as a failure. Mirrors render_from_doctor()'s agents_v2_attested
|
|
89
|
+
// override in check-hq-hooks.sh; only ever grants OBSERVED, never withholds it.
|
|
90
|
+
if (requireLedger &&
|
|
91
|
+
runtime !== "OBSERVED" &&
|
|
92
|
+
options.agentsV2Attested === true) {
|
|
93
|
+
runtime = "OBSERVED";
|
|
94
|
+
}
|
|
84
95
|
if (requireLedger && runtime !== "OBSERVED") {
|
|
85
96
|
messages.push(runtimeResult?.message ??
|
|
86
97
|
"policy-trigger ledger was not found under workspace/orchestrator/policy-trigger-state");
|
|
@@ -60,7 +60,7 @@ import { spawnSync } from "node:child_process";
|
|
|
60
60
|
import semver from "semver";
|
|
61
61
|
import chalk from "chalk";
|
|
62
62
|
import { CLI_NAME, CLI_VERSION } from "../cli-version.js";
|
|
63
|
-
import { buildBunInstallArgv, buildPnpmInstallArgv, buildPrefixedInstallArgv, buildSpawnPlan, checkUpdateConvergence, inOwnProcessGroup, isLocalDependencyInstall, openInstallOutput, pnpmUpdateEnv, resolveRunningInstall, runUpdateCommand, } from "./version-gate.js";
|
|
63
|
+
import { buildBunInstallArgv, buildPnpmInstallArgv, buildPrefixedInstallArgv, buildSpawnPlan, checkUpdateConvergence, inOwnProcessGroup, isLocalDependencyInstall, isPrefixWritable, nonWritablePrefixNote, openInstallOutput, pnpmUpdateEnv, resolveRunningInstall, runUpdateCommand, } from "./version-gate.js";
|
|
64
64
|
import { acquireUpdateLock as acquireSharedUpdateLock } from "./update-lock.js";
|
|
65
65
|
import { markLatestIneffective } from "./version-check.js";
|
|
66
66
|
/**
|
|
@@ -268,6 +268,13 @@ async function updateAndReexec(argv, flavor, known, deps) {
|
|
|
268
268
|
console.error(chalk.yellow(`⚠ hq-cli ${latest} is available but the update failed` +
|
|
269
269
|
`${result.detail ? `: ${result.detail}` : ""}`));
|
|
270
270
|
console.error(chalk.dim(` Try manually: ${plan.cmd} ${plan.args.join(" ")}`));
|
|
271
|
+
// A root-owned npm prefix the running user cannot write is the agent-box
|
|
272
|
+
// case (the CLI is a /usr global install and the runtime is unprivileged),
|
|
273
|
+
// and the startup path has no sudo fallback — say so plainly so a failed
|
|
274
|
+
// update on a box reads as the permission wall it is, not a transient error.
|
|
275
|
+
if (install.manager === "npm" && install.prefix && !isPrefixWritable(install.prefix)) {
|
|
276
|
+
console.error(chalk.yellow(` ${nonWritablePrefixNote(install.prefix)}`));
|
|
277
|
+
}
|
|
271
278
|
// A manager-level failure often means the install layout itself is broken
|
|
272
279
|
// (e.g. a hand-rolled pnpm store nested inside the app's bin dir). The
|
|
273
280
|
// manual retry above hits the same layout and fails the same way; point at
|
|
@@ -150,6 +150,30 @@ export declare function resolveRunningInstall(): RunningInstall;
|
|
|
150
150
|
export declare function resolveRunningManager(): InstallManager;
|
|
151
151
|
/** Convenience view of {@link resolveRunningInstall} for callers needing one field. */
|
|
152
152
|
export declare function resolveRunningPrefix(): string | null;
|
|
153
|
+
/**
|
|
154
|
+
* Whether the current process can write the npm global `prefix` — i.e. whether
|
|
155
|
+
* an `npm install -g --prefix <prefix>` could actually replace the installed
|
|
156
|
+
* CLI, or would fail with EACCES.
|
|
157
|
+
*
|
|
158
|
+
* The path npm rewrites is the prefix's `bin` dir (`<prefix>/bin/hq` on unix
|
|
159
|
+
* globals — the `rename /usr/bin/hq` EACCES the agent boxes hit), so that is
|
|
160
|
+
* checked first; the prefix itself is the fallback for `--prefix` layouts that
|
|
161
|
+
* keep the bin beside `node_modules`. A missing dir (ENOENT) is treated as
|
|
162
|
+
* writable: npm would create it, and this check exists to explain a permission
|
|
163
|
+
* wall, not to second-guess a not-yet-created prefix.
|
|
164
|
+
*
|
|
165
|
+
* `access` is injected so the classification is unit-testable without a real
|
|
166
|
+
* root-owned prefix.
|
|
167
|
+
*/
|
|
168
|
+
export declare function isPrefixWritable(prefix: string, access?: (target: string, mode: number) => void): boolean;
|
|
169
|
+
/**
|
|
170
|
+
* One-line operator explanation for a failed global update whose prefix the
|
|
171
|
+
* running user cannot write. This is the agent-box case: the CLI is a
|
|
172
|
+
* root-owned `/usr` global install and the runtime is unprivileged, so the
|
|
173
|
+
* update genuinely cannot converge from here and re-trying it silently would
|
|
174
|
+
* loop. Says so plainly and points at the paths that CAN update it.
|
|
175
|
+
*/
|
|
176
|
+
export declare function nonWritablePrefixNote(prefix: string): string;
|
|
153
177
|
/**
|
|
154
178
|
* Derive `PNPM_HOME` from a pnpm-managed install's own path. pnpm resolves its
|
|
155
179
|
* global bin directory from `PNPM_HOME` (or an explicit `global-bin-dir`), and
|
|
@@ -409,6 +433,8 @@ export declare const __test__: {
|
|
|
409
433
|
isNewerVersion: typeof isNewerVersion;
|
|
410
434
|
isPnpmManagedPackageDir: typeof isPnpmManagedPackageDir;
|
|
411
435
|
isPnpmVirtualStorePackageDir: typeof isPnpmVirtualStorePackageDir;
|
|
436
|
+
isPrefixWritable: typeof isPrefixWritable;
|
|
437
|
+
nonWritablePrefixNote: typeof nonWritablePrefixNote;
|
|
412
438
|
npmPrefixFromPackageDir: typeof npmPrefixFromPackageDir;
|
|
413
439
|
nudgeUpdateRecommended: typeof nudgeUpdateRecommended;
|
|
414
440
|
performUpdate: typeof performUpdate;
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
*/
|
|
30
30
|
import { spawnSync } from "node:child_process";
|
|
31
31
|
import { buildSpawnPlan, quoteForWindowsShell } from "./windows-spawn.js";
|
|
32
|
-
import { closeSync, existsSync, mkdtempSync, openSync, readdirSync, readFileSync, rmSync, } from "node:fs";
|
|
32
|
+
import { accessSync, closeSync, constants as fsConstants, existsSync, mkdtempSync, openSync, readdirSync, readFileSync, rmSync, } from "node:fs";
|
|
33
33
|
import os from "node:os";
|
|
34
34
|
import path from "node:path";
|
|
35
35
|
import { fileURLToPath } from "node:url";
|
|
@@ -277,6 +277,50 @@ export function resolveRunningManager() {
|
|
|
277
277
|
export function resolveRunningPrefix() {
|
|
278
278
|
return resolveRunningInstall().prefix;
|
|
279
279
|
}
|
|
280
|
+
/**
|
|
281
|
+
* Whether the current process can write the npm global `prefix` — i.e. whether
|
|
282
|
+
* an `npm install -g --prefix <prefix>` could actually replace the installed
|
|
283
|
+
* CLI, or would fail with EACCES.
|
|
284
|
+
*
|
|
285
|
+
* The path npm rewrites is the prefix's `bin` dir (`<prefix>/bin/hq` on unix
|
|
286
|
+
* globals — the `rename /usr/bin/hq` EACCES the agent boxes hit), so that is
|
|
287
|
+
* checked first; the prefix itself is the fallback for `--prefix` layouts that
|
|
288
|
+
* keep the bin beside `node_modules`. A missing dir (ENOENT) is treated as
|
|
289
|
+
* writable: npm would create it, and this check exists to explain a permission
|
|
290
|
+
* wall, not to second-guess a not-yet-created prefix.
|
|
291
|
+
*
|
|
292
|
+
* `access` is injected so the classification is unit-testable without a real
|
|
293
|
+
* root-owned prefix.
|
|
294
|
+
*/
|
|
295
|
+
export function isPrefixWritable(prefix, access = (target, mode) => accessSync(target, mode)) {
|
|
296
|
+
for (const dir of [path.join(prefix, "bin"), prefix]) {
|
|
297
|
+
try {
|
|
298
|
+
access(dir, fsConstants.W_OK);
|
|
299
|
+
return true;
|
|
300
|
+
}
|
|
301
|
+
catch (err) {
|
|
302
|
+
// A dir that does not exist yet is not a permission wall — npm creates it.
|
|
303
|
+
if (err?.code === "ENOENT") {
|
|
304
|
+
return true;
|
|
305
|
+
}
|
|
306
|
+
// Any other error (EACCES/EPERM/EROFS) on this candidate: try the next.
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
return false;
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* One-line operator explanation for a failed global update whose prefix the
|
|
313
|
+
* running user cannot write. This is the agent-box case: the CLI is a
|
|
314
|
+
* root-owned `/usr` global install and the runtime is unprivileged, so the
|
|
315
|
+
* update genuinely cannot converge from here and re-trying it silently would
|
|
316
|
+
* loop. Says so plainly and points at the paths that CAN update it.
|
|
317
|
+
*/
|
|
318
|
+
export function nonWritablePrefixNote(prefix) {
|
|
319
|
+
return (`The npm global prefix ${prefix} is not writable by the current user, so this update cannot ` +
|
|
320
|
+
`take effect here. On an agent box this is expected: the CLI is a root-owned global install and ` +
|
|
321
|
+
`updates land as root — via the box's hq-cli-update timer, or \`sudo npm install -g ${CLI_NAME}@latest\` — ` +
|
|
322
|
+
`not from the unprivileged runtime.`);
|
|
323
|
+
}
|
|
280
324
|
/**
|
|
281
325
|
* Derive `PNPM_HOME` from a pnpm-managed install's own path. pnpm resolves its
|
|
282
326
|
* global bin directory from `PNPM_HOME` (or an explicit `global-bin-dir`), and
|
|
@@ -907,6 +951,13 @@ function attemptRequiredUpdate(decision, deps, install) {
|
|
|
907
951
|
}
|
|
908
952
|
if (!result.ok) {
|
|
909
953
|
console.error(chalk.red(`✗ Update failed${result.detail ? `: ${result.detail}` : ""}.`));
|
|
954
|
+
// A root-owned global prefix the running user cannot write is the agent-box
|
|
955
|
+
// case: even the `sudo -n` retry above cannot help without passwordless
|
|
956
|
+
// sudo, so name the wall plainly rather than leaving `exit 75` to read as a
|
|
957
|
+
// generic failure. Only for npm-prefix installs (pnpm/Bun route elsewhere).
|
|
958
|
+
if (!isManagedOutsideNpm && prefix && !isPrefixWritable(prefix)) {
|
|
959
|
+
console.error(chalk.yellow(` ${nonWritablePrefixNote(prefix)}`));
|
|
960
|
+
}
|
|
910
961
|
// The package manager itself is missing from this environment — the usual
|
|
911
962
|
// cause is a minimal-PATH parent (launchd, cron, a bare systemd unit) that
|
|
912
963
|
// never sourced the shell profile which puts PNPM_HOME (or nvm's npm) on
|
|
@@ -996,6 +1047,8 @@ export const __test__ = {
|
|
|
996
1047
|
isNewerVersion,
|
|
997
1048
|
isPnpmManagedPackageDir,
|
|
998
1049
|
isPnpmVirtualStorePackageDir,
|
|
1050
|
+
isPrefixWritable,
|
|
1051
|
+
nonWritablePrefixNote,
|
|
999
1052
|
npmPrefixFromPackageDir,
|
|
1000
1053
|
nudgeUpdateRecommended,
|
|
1001
1054
|
performUpdate,
|