@indigoai-us/hq-cli 5.98.3 → 5.99.1
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 +43 -0
- package/assets/scaffold/core/scripts/checkpoint-stop-gate.sh +347 -0
- package/assets/scaffold/core/scripts/hook-lib.sh +557 -0
- package/assets/scaffold/core/scripts/hq-session.sh +251 -0
- package/assets/scaffold/core/scripts/lib/session-id.sh +96 -0
- package/assets/scaffold/core/scripts/lib/session-scope-capability.sh +52 -0
- package/dist/commands/core-checkpoint.js +11 -3
- package/dist/commands/core.js +60 -5
- package/dist/commands/doctor.d.ts +97 -0
- package/dist/commands/doctor.js +228 -0
- package/dist/commands/scaffold-fast.d.ts +41 -0
- package/dist/commands/scaffold-fast.js +57 -0
- package/dist/fast-core.d.ts +16 -0
- package/dist/fast-core.js +47 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +10 -1
- package/dist/lib/core-utils/soft-timeout.d.ts +55 -0
- package/dist/lib/core-utils/soft-timeout.js +205 -0
- package/dist/lib/core-utils/timeout-guard.d.ts +62 -0
- package/dist/lib/core-utils/timeout-guard.js +207 -0
- package/dist/lib/doctor/__testing__/fake-hq-tree.d.ts +194 -0
- package/dist/lib/doctor/__testing__/fake-hq-tree.js +357 -0
- package/dist/lib/doctor/allowed-divergence.d.ts +72 -0
- package/dist/lib/doctor/allowed-divergence.js +134 -0
- package/dist/lib/doctor/checks/claude-wiring.d.ts +55 -0
- package/dist/lib/doctor/checks/claude-wiring.js +524 -0
- package/dist/lib/doctor/checks/codex-wiring.d.ts +45 -0
- package/dist/lib/doctor/checks/codex-wiring.js +376 -0
- package/dist/lib/doctor/checks/grok-wiring.d.ts +35 -0
- package/dist/lib/doctor/checks/grok-wiring.js +186 -0
- package/dist/lib/doctor/checks/runtime-probe.d.ts +101 -0
- package/dist/lib/doctor/checks/runtime-probe.js +335 -0
- package/dist/lib/doctor/compat.d.ts +85 -0
- package/dist/lib/doctor/compat.js +102 -0
- package/dist/lib/doctor/deep/classify.d.ts +61 -0
- package/dist/lib/doctor/deep/classify.js +75 -0
- package/dist/lib/doctor/deep/effects.d.ts +107 -0
- package/dist/lib/doctor/deep/effects.js +229 -0
- package/dist/lib/doctor/deep/executor.d.ts +112 -0
- package/dist/lib/doctor/deep/executor.js +369 -0
- package/dist/lib/doctor/deep/parity.d.ts +129 -0
- package/dist/lib/doctor/deep/parity.js +355 -0
- package/dist/lib/doctor/deep/sandbox.d.ts +190 -0
- package/dist/lib/doctor/deep/sandbox.js +572 -0
- package/dist/lib/doctor/fix/apply.d.ts +119 -0
- package/dist/lib/doctor/fix/apply.js +352 -0
- package/dist/lib/doctor/fix/backup.d.ts +40 -0
- package/dist/lib/doctor/fix/backup.js +64 -0
- package/dist/lib/doctor/fix/remediation.d.ts +71 -0
- package/dist/lib/doctor/fix/remediation.js +103 -0
- package/dist/lib/doctor/fixtures/discover.d.ts +96 -0
- package/dist/lib/doctor/fixtures/discover.js +287 -0
- package/dist/lib/doctor/fixtures/schema.d.ts +171 -0
- package/dist/lib/doctor/fixtures/schema.js +248 -0
- package/dist/lib/doctor/hook-gate-profiles.d.ts +55 -0
- package/dist/lib/doctor/hook-gate-profiles.js +107 -0
- package/dist/lib/doctor/json-output.d.ts +90 -0
- package/dist/lib/doctor/json-output.js +76 -0
- package/dist/lib/doctor/payload-shapes.d.ts +170 -0
- package/dist/lib/doctor/payload-shapes.js +275 -0
- package/dist/lib/doctor/platform.d.ts +244 -0
- package/dist/lib/doctor/platform.js +490 -0
- package/dist/lib/doctor/registry.d.ts +49 -0
- package/dist/lib/doctor/registry.js +176 -0
- package/dist/lib/doctor/report.d.ts +87 -0
- package/dist/lib/doctor/report.js +164 -0
- package/dist/lib/doctor/types.d.ts +87 -0
- package/dist/lib/doctor/types.js +29 -0
- package/dist/main.js +6 -0
- package/dist/utils/version-check.js +2 -2
- package/dist/utils/version-gate.d.ts +1 -1
- package/dist/utils/version-gate.js +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `hq core soft-timeout <interval> [--hard-cap <spec>] [--label <name>] -- <cmd…>`
|
|
3
|
+
*
|
|
4
|
+
* Run a command under a SOFT timeout: WARN, don't kill. This is the CLI-hosted
|
|
5
|
+
* form of the shell primitive `core/scripts/lib/soft-timeout.sh`, so HQ scripts
|
|
6
|
+
* and hooks can call `hq core soft-timeout …` directly instead of shelling out
|
|
7
|
+
* to a bundled script.
|
|
8
|
+
*
|
|
9
|
+
* Finding 2.1 (team harness analysis 2026-08-10 — 1,393 forced-kill events):
|
|
10
|
+
* a fixed deadline SIGTERMs an operation still making progress and loses the
|
|
11
|
+
* in-flight work. The fix is warn-and-continue — on each interval the command
|
|
12
|
+
* keeps running and a SOFT-TIMEOUT notice is written to stderr so whoever is
|
|
13
|
+
* watching decides (wait / background / kill), instead of the runner deciding
|
|
14
|
+
* by killing progressing work.
|
|
15
|
+
*
|
|
16
|
+
* Contract (identical to the shell primitive):
|
|
17
|
+
* - `<interval>` — warn every interval. Bare number = seconds; 90s / 2m / 1h.
|
|
18
|
+
* - `--hard-cap <spec>` — OPTIONAL safety ceiling for unattended callers.
|
|
19
|
+
* Pure soft has no cap and never terminates. At the cap the command is
|
|
20
|
+
* SIGTERM'd, then SIGKILL'd after a short grace, and the exit code is 124.
|
|
21
|
+
* - `--label <name>` — name shown in the notice (default: the command basename).
|
|
22
|
+
* - Exit status: the command's own, verbatim — except 124 on a hard-cap.
|
|
23
|
+
* A soft warning alone never changes the exit status.
|
|
24
|
+
*/
|
|
25
|
+
import { spawn } from "node:child_process";
|
|
26
|
+
import { basename } from "node:path";
|
|
27
|
+
import { constants as osConstants } from "node:os";
|
|
28
|
+
/** Parse a duration spec (Ns / Nm / Nh / bare N=seconds) to integer seconds. */
|
|
29
|
+
export function parseDurationSecs(spec) {
|
|
30
|
+
const m = /^([0-9]+)([smh]?)$/.exec(spec);
|
|
31
|
+
if (!m)
|
|
32
|
+
return null;
|
|
33
|
+
const n = Number(m[1]);
|
|
34
|
+
switch (m[2]) {
|
|
35
|
+
case "":
|
|
36
|
+
case "s":
|
|
37
|
+
return n;
|
|
38
|
+
case "m":
|
|
39
|
+
return n * 60;
|
|
40
|
+
case "h":
|
|
41
|
+
return n * 3600;
|
|
42
|
+
default:
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/** Parse the `hq core soft-timeout` argv. Throws a plain Error on misuse. */
|
|
47
|
+
export function parseSoftTimeoutArgs(argv) {
|
|
48
|
+
const args = [...argv];
|
|
49
|
+
if (args.length === 0 || args[0].startsWith("--")) {
|
|
50
|
+
throw new Error("soft-timeout: first argument must be the warn interval");
|
|
51
|
+
}
|
|
52
|
+
const intervalSecs = parseDurationSecs(args.shift());
|
|
53
|
+
if (intervalSecs === null || intervalSecs <= 0) {
|
|
54
|
+
throw new Error("soft-timeout: interval must be a positive duration (e.g. 120, 2m)");
|
|
55
|
+
}
|
|
56
|
+
let hardCapSecs = null;
|
|
57
|
+
let label = null;
|
|
58
|
+
// Consume our own options; the FIRST non-option token (or an explicit `--`)
|
|
59
|
+
// begins the wrapped command. `--` is optional because Commander strips a
|
|
60
|
+
// single leading `--` before these args reach us — so `hq core soft-timeout
|
|
61
|
+
// 30 -- sleep 60` arrives here as `30 sleep 60`, and treating the first
|
|
62
|
+
// non-option as the command start makes both forms parse identically.
|
|
63
|
+
while (args.length > 0) {
|
|
64
|
+
const tok = args[0];
|
|
65
|
+
if (tok === "--") {
|
|
66
|
+
args.shift();
|
|
67
|
+
break;
|
|
68
|
+
}
|
|
69
|
+
if (tok === "--hard-cap") {
|
|
70
|
+
args.shift();
|
|
71
|
+
const spec = args.shift();
|
|
72
|
+
const secs = spec ? parseDurationSecs(spec) : null;
|
|
73
|
+
if (secs === null || secs <= 0)
|
|
74
|
+
throw new Error(`soft-timeout: invalid --hard-cap '${spec ?? ""}'`);
|
|
75
|
+
hardCapSecs = secs;
|
|
76
|
+
}
|
|
77
|
+
else if (tok === "--label") {
|
|
78
|
+
args.shift();
|
|
79
|
+
label = args.shift() ?? null;
|
|
80
|
+
if (label === null)
|
|
81
|
+
throw new Error("soft-timeout: --label needs a value");
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
break; // first non-option token: the wrapped command starts here
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (args.length === 0) {
|
|
88
|
+
throw new Error("soft-timeout: no command given (expected: soft-timeout <interval> [...] -- cmd)");
|
|
89
|
+
}
|
|
90
|
+
return { intervalSecs, hardCapSecs, label: label ?? null, command: args };
|
|
91
|
+
}
|
|
92
|
+
function hhmmss(now) {
|
|
93
|
+
return now.toISOString().slice(11, 19);
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Execute the command under the soft timeout. Resolves to the exit code the
|
|
97
|
+
* process should exit with. Never rejects on a timeout — a warning is only a
|
|
98
|
+
* stderr line. Rejects only on spawn failure.
|
|
99
|
+
*/
|
|
100
|
+
export function runSoftTimeout(parsed, deps = {}) {
|
|
101
|
+
const spawnFn = deps.spawnFn ?? spawn;
|
|
102
|
+
const err = deps.stderr ?? process.stderr;
|
|
103
|
+
const now = deps.now ?? (() => new Date());
|
|
104
|
+
const killGroup = deps.killGroup ?? ((pid, sig) => process.kill(-pid, sig));
|
|
105
|
+
const { intervalSecs, hardCapSecs, command } = parsed;
|
|
106
|
+
const label = parsed.label ?? basename(command[0]);
|
|
107
|
+
return new Promise((resolve, reject) => {
|
|
108
|
+
// detached: the child leads its own process group so a hard cap can signal
|
|
109
|
+
// the WHOLE group (child + descendants), not just the child — otherwise an
|
|
110
|
+
// orphaned grandchild (e.g. run-project's builder) keeps running after the
|
|
111
|
+
// wrapper returns. stdio inherited so the command's stdin/stdout/stderr —
|
|
112
|
+
// including piped or interactive input — pass through unchanged.
|
|
113
|
+
const child = spawnFn(command[0], command.slice(1), { stdio: "inherit", detached: true });
|
|
114
|
+
let capped = false;
|
|
115
|
+
let ticks = 0;
|
|
116
|
+
// Signal the child's process group when we can (detached leader), else the
|
|
117
|
+
// child alone. Guarded: the group may already be gone.
|
|
118
|
+
const signalTree = (sig) => {
|
|
119
|
+
try {
|
|
120
|
+
if (typeof child.pid === "number")
|
|
121
|
+
killGroup(child.pid, sig);
|
|
122
|
+
else
|
|
123
|
+
child.kill(sig);
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
try {
|
|
127
|
+
child.kill(sig);
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
/* already gone */
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
const warner = setInterval(() => {
|
|
135
|
+
ticks += 1;
|
|
136
|
+
const elapsed = ticks * intervalSecs;
|
|
137
|
+
err.write(`[${hhmmss(now())}] SOFT-TIMEOUT: ${label} still running after ${elapsed}s ` +
|
|
138
|
+
`(${ticks}× ${intervalSecs}s window) — exceeded its window; NOT killed. ` +
|
|
139
|
+
`Decide: wait / background / kill (kill ${child.pid}).\n`);
|
|
140
|
+
}, intervalSecs * 1000);
|
|
141
|
+
// The hard cap runs on its OWN timer, independent of the warning interval,
|
|
142
|
+
// so a cap shorter than (or not a multiple of) the interval still fires on
|
|
143
|
+
// time rather than at the next interval boundary.
|
|
144
|
+
let capTimer = null;
|
|
145
|
+
let killTimer = null;
|
|
146
|
+
if (hardCapSecs !== null) {
|
|
147
|
+
capTimer = setTimeout(() => {
|
|
148
|
+
capped = true;
|
|
149
|
+
err.write(`[${hhmmss(now())}] SOFT-TIMEOUT: ${label} hit hard cap ${hardCapSecs}s — sending SIGTERM.\n`);
|
|
150
|
+
signalTree("SIGTERM");
|
|
151
|
+
killTimer = setTimeout(() => signalTree("SIGKILL"), 5000);
|
|
152
|
+
}, hardCapSecs * 1000);
|
|
153
|
+
}
|
|
154
|
+
const cleanup = () => {
|
|
155
|
+
clearInterval(warner);
|
|
156
|
+
if (capTimer)
|
|
157
|
+
clearTimeout(capTimer);
|
|
158
|
+
if (killTimer)
|
|
159
|
+
clearTimeout(killTimer);
|
|
160
|
+
};
|
|
161
|
+
child.on("error", (e) => {
|
|
162
|
+
cleanup();
|
|
163
|
+
reject(e);
|
|
164
|
+
});
|
|
165
|
+
child.on("exit", (code, signal) => {
|
|
166
|
+
cleanup();
|
|
167
|
+
if (capped) {
|
|
168
|
+
resolve(124);
|
|
169
|
+
}
|
|
170
|
+
else if (code !== null) {
|
|
171
|
+
resolve(code);
|
|
172
|
+
}
|
|
173
|
+
else {
|
|
174
|
+
// Killed by a signal (external SIGINT/SIGHUP/SIGTERM/…): mirror the
|
|
175
|
+
// shell convention of 128 + signal number for whichever signal it was,
|
|
176
|
+
// so callers keep the real termination status (e.g. 130 for SIGINT).
|
|
177
|
+
const n = signal ? osConstants.signals[signal] : undefined;
|
|
178
|
+
resolve(typeof n === "number" ? 128 + n : 1);
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Entry point for the `hq core soft-timeout` command. Parses argv, runs, and
|
|
185
|
+
* returns the exit code (never throws for a timeout; throws only on misuse or
|
|
186
|
+
* spawn failure, which the caller maps to a non-zero exit).
|
|
187
|
+
*/
|
|
188
|
+
export async function softTimeoutCommand(argv) {
|
|
189
|
+
let parsed;
|
|
190
|
+
try {
|
|
191
|
+
parsed = parseSoftTimeoutArgs(argv);
|
|
192
|
+
}
|
|
193
|
+
catch (e) {
|
|
194
|
+
process.stderr.write(`${e.message}\n`);
|
|
195
|
+
return 2;
|
|
196
|
+
}
|
|
197
|
+
try {
|
|
198
|
+
return await runSoftTimeout(parsed);
|
|
199
|
+
}
|
|
200
|
+
catch (e) {
|
|
201
|
+
process.stderr.write(`soft-timeout: failed to run command: ${e.message}\n`);
|
|
202
|
+
return 127;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
//# sourceMappingURL=soft-timeout.js.map
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `hq core timeout-guard` — PreToolUse decision for the foreground-timeout guard.
|
|
3
|
+
*
|
|
4
|
+
* The hook logic that used to live in
|
|
5
|
+
* `.claude/hooks/block-foreground-timeout-over-harness-ceiling.sh` now lives
|
|
6
|
+
* here so it can be maintained (and gated) in one place; the shipped hook is a
|
|
7
|
+
* thin shim that pipes the PreToolUse JSON to this command and blocks iff it
|
|
8
|
+
* exits 2.
|
|
9
|
+
*
|
|
10
|
+
* Finding 2.1 (team harness analysis 2026-08-10 — 1,393 forced-kill events): a
|
|
11
|
+
* FOREGROUND shell tool call is SIGTERM'd at the harness's outer deadline
|
|
12
|
+
* (~2 min default, 10 min max) regardless of any longer timeout it declares, so
|
|
13
|
+
* the in-flight work is lost (exit 143). We can't change the harness, so the
|
|
14
|
+
* guard blocks a long foreground declaration and steers it to a background run
|
|
15
|
+
* (no outer deadline, auto-notifies). It fires for Claude, Codex, and Grok —
|
|
16
|
+
* their shell tool calls all reach the hook normalized to `tool_input.command`.
|
|
17
|
+
*
|
|
18
|
+
* Rollout gate: for now the guard only acts for `@getindigo.ai` HQ users; every
|
|
19
|
+
* other identity (and machine identities / logged-out) is allowed through.
|
|
20
|
+
*
|
|
21
|
+
* Exit codes: 0 = allow, 2 = block.
|
|
22
|
+
*/
|
|
23
|
+
/**
|
|
24
|
+
* Worst-case inner deadline (seconds) declared in a command: a `timeout` /
|
|
25
|
+
* `gtimeout` prefix (matched by executable BASENAME, so `/usr/bin/timeout`
|
|
26
|
+
* counts) or a `perl -e 'alarm(N)…'` deadline. Segment on shell separators and
|
|
27
|
+
* only treat a segment's LEADING command word as an invocation, so the word
|
|
28
|
+
* `timeout` inside a quoted argument (e.g. `printf 'uses timeout 601s'`) does
|
|
29
|
+
* not trip it.
|
|
30
|
+
*/
|
|
31
|
+
export declare function parseInnerDeadlineSecs(command: string): number;
|
|
32
|
+
export interface TimeoutGuardInput {
|
|
33
|
+
command: string;
|
|
34
|
+
toolTimeoutMs?: number;
|
|
35
|
+
runInBackground?: boolean;
|
|
36
|
+
}
|
|
37
|
+
export interface GuardVerdict {
|
|
38
|
+
block: boolean;
|
|
39
|
+
reason?: string;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Pure decision: does this foreground declaration exceed the deadline it can
|
|
43
|
+
* actually secure? Compares the inner deadline against the EFFECTIVE ceiling —
|
|
44
|
+
* the declared tool timeout (capped at the harness max) or the 2-minute default
|
|
45
|
+
* when none is declared — rather than always against the 10-minute max.
|
|
46
|
+
*/
|
|
47
|
+
export declare function evaluateTimeoutGuard(input: TimeoutGuardInput): GuardVerdict;
|
|
48
|
+
export interface TimeoutGuardDeps {
|
|
49
|
+
/** Resolve the current HQ user email for the rollout gate. Default: cached id token. */
|
|
50
|
+
getEmail?: () => string | undefined;
|
|
51
|
+
env?: NodeJS.ProcessEnv;
|
|
52
|
+
stderr?: NodeJS.WritableStream;
|
|
53
|
+
}
|
|
54
|
+
/** Whether the guard is active for this identity (rollout gate). */
|
|
55
|
+
export declare function isGatedUser(email: string | undefined): boolean;
|
|
56
|
+
/**
|
|
57
|
+
* Command entry: read the PreToolUse JSON, apply the gate + short-circuits, and
|
|
58
|
+
* return the exit code (0 allow / 2 block). Never throws — a parse failure or a
|
|
59
|
+
* missing identity fails OPEN (allow), because a hook must not break tool calls.
|
|
60
|
+
*/
|
|
61
|
+
export declare function timeoutGuardCommand(stdin: string, deps?: TimeoutGuardDeps): number;
|
|
62
|
+
//# sourceMappingURL=timeout-guard.d.ts.map
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `hq core timeout-guard` — PreToolUse decision for the foreground-timeout guard.
|
|
3
|
+
*
|
|
4
|
+
* The hook logic that used to live in
|
|
5
|
+
* `.claude/hooks/block-foreground-timeout-over-harness-ceiling.sh` now lives
|
|
6
|
+
* here so it can be maintained (and gated) in one place; the shipped hook is a
|
|
7
|
+
* thin shim that pipes the PreToolUse JSON to this command and blocks iff it
|
|
8
|
+
* exits 2.
|
|
9
|
+
*
|
|
10
|
+
* Finding 2.1 (team harness analysis 2026-08-10 — 1,393 forced-kill events): a
|
|
11
|
+
* FOREGROUND shell tool call is SIGTERM'd at the harness's outer deadline
|
|
12
|
+
* (~2 min default, 10 min max) regardless of any longer timeout it declares, so
|
|
13
|
+
* the in-flight work is lost (exit 143). We can't change the harness, so the
|
|
14
|
+
* guard blocks a long foreground declaration and steers it to a background run
|
|
15
|
+
* (no outer deadline, auto-notifies). It fires for Claude, Codex, and Grok —
|
|
16
|
+
* their shell tool calls all reach the hook normalized to `tool_input.command`.
|
|
17
|
+
*
|
|
18
|
+
* Rollout gate: for now the guard only acts for `@getindigo.ai` HQ users; every
|
|
19
|
+
* other identity (and machine identities / logged-out) is allowed through.
|
|
20
|
+
*
|
|
21
|
+
* Exit codes: 0 = allow, 2 = block.
|
|
22
|
+
*/
|
|
23
|
+
import { loadCachedTokens } from "@indigoai-us/hq-cloud";
|
|
24
|
+
import { peekIdToken } from "../../utils/id-token.js";
|
|
25
|
+
// Observed Claude Code Bash-tool bounds. The tool `timeout` (ms) raises the
|
|
26
|
+
// deadline up to the max; with none declared the default applies.
|
|
27
|
+
const HARNESS_DEFAULT_MS = 120_000; // 2 min
|
|
28
|
+
const HARNESS_MAX_MS = 600_000; // 10 min
|
|
29
|
+
const GATE_DOMAIN = "@getindigo.ai";
|
|
30
|
+
function basename(token) {
|
|
31
|
+
const i = token.lastIndexOf("/");
|
|
32
|
+
return i >= 0 ? token.slice(i + 1) : token;
|
|
33
|
+
}
|
|
34
|
+
/** Duration spec (Ns / Nm / Nh / Nd / bare N=seconds) → seconds, or 0. */
|
|
35
|
+
function durationToSecs(tok) {
|
|
36
|
+
const m = /^([0-9]+)([smhd]?)$/.exec(tok);
|
|
37
|
+
if (!m)
|
|
38
|
+
return 0;
|
|
39
|
+
const n = Number(m[1]);
|
|
40
|
+
switch (m[2]) {
|
|
41
|
+
case "":
|
|
42
|
+
case "s":
|
|
43
|
+
return n;
|
|
44
|
+
case "m":
|
|
45
|
+
return n * 60;
|
|
46
|
+
case "h":
|
|
47
|
+
return n * 3600;
|
|
48
|
+
case "d":
|
|
49
|
+
return n * 86400;
|
|
50
|
+
default:
|
|
51
|
+
return 0;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Worst-case inner deadline (seconds) declared in a command: a `timeout` /
|
|
56
|
+
* `gtimeout` prefix (matched by executable BASENAME, so `/usr/bin/timeout`
|
|
57
|
+
* counts) or a `perl -e 'alarm(N)…'` deadline. Segment on shell separators and
|
|
58
|
+
* only treat a segment's LEADING command word as an invocation, so the word
|
|
59
|
+
* `timeout` inside a quoted argument (e.g. `printf 'uses timeout 601s'`) does
|
|
60
|
+
* not trip it.
|
|
61
|
+
*/
|
|
62
|
+
export function parseInnerDeadlineSecs(command) {
|
|
63
|
+
let worst = 0;
|
|
64
|
+
for (const rawSeg of command.split(/[;|&()]/)) {
|
|
65
|
+
const seg = rawSeg.trim();
|
|
66
|
+
if (!seg)
|
|
67
|
+
continue;
|
|
68
|
+
const words = seg.split(/\s+/);
|
|
69
|
+
let i = 0;
|
|
70
|
+
while (i < words.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[i]))
|
|
71
|
+
i++; // skip env assigns
|
|
72
|
+
if (i >= words.length)
|
|
73
|
+
continue;
|
|
74
|
+
const cmd = basename(words[i]);
|
|
75
|
+
if (cmd !== "timeout" && cmd !== "gtimeout")
|
|
76
|
+
continue;
|
|
77
|
+
i++;
|
|
78
|
+
let dur = "";
|
|
79
|
+
while (i < words.length) {
|
|
80
|
+
const t = words[i];
|
|
81
|
+
if (t === "--foreground" || t === "--preserve-status" || t === "-v" || t === "--verbose") {
|
|
82
|
+
i++;
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (t === "-s" || t === "--signal" || t === "-k" || t === "--kill-after") {
|
|
86
|
+
i += 2;
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
if (t.startsWith("-")) {
|
|
90
|
+
i++;
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
dur = t;
|
|
94
|
+
break;
|
|
95
|
+
}
|
|
96
|
+
if (dur)
|
|
97
|
+
worst = Math.max(worst, durationToSecs(dur));
|
|
98
|
+
}
|
|
99
|
+
// perl alarm() — spans separators, so match on the raw command.
|
|
100
|
+
const mMin = /alarm\(\s*([0-9]+)\s*\*\s*60/.exec(command);
|
|
101
|
+
if (mMin)
|
|
102
|
+
worst = Math.max(worst, Number(mMin[1]) * 60);
|
|
103
|
+
else {
|
|
104
|
+
const mSec = /alarm\(?\s*([0-9]+)/.exec(command);
|
|
105
|
+
if (mSec)
|
|
106
|
+
worst = Math.max(worst, Number(mSec[1]));
|
|
107
|
+
}
|
|
108
|
+
return worst;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Pure decision: does this foreground declaration exceed the deadline it can
|
|
112
|
+
* actually secure? Compares the inner deadline against the EFFECTIVE ceiling —
|
|
113
|
+
* the declared tool timeout (capped at the harness max) or the 2-minute default
|
|
114
|
+
* when none is declared — rather than always against the 10-minute max.
|
|
115
|
+
*/
|
|
116
|
+
export function evaluateTimeoutGuard(input) {
|
|
117
|
+
const { command, toolTimeoutMs } = input;
|
|
118
|
+
if (input.runInBackground)
|
|
119
|
+
return { block: false };
|
|
120
|
+
if (toolTimeoutMs !== undefined && toolTimeoutMs > HARNESS_MAX_MS) {
|
|
121
|
+
return {
|
|
122
|
+
block: true,
|
|
123
|
+
reason: `the Bash tool 'timeout' parameter is ${toolTimeoutMs}ms (> the ${HARNESS_MAX_MS}ms / 10-minute harness ceiling)`,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
const effectiveMs = toolTimeoutMs !== undefined ? Math.min(toolTimeoutMs, HARNESS_MAX_MS) : HARNESS_DEFAULT_MS;
|
|
127
|
+
const innerSecs = parseInnerDeadlineSecs(command);
|
|
128
|
+
if (innerSecs > 0 && innerSecs * 1000 > effectiveMs) {
|
|
129
|
+
const ceilingDesc = toolTimeoutMs !== undefined
|
|
130
|
+
? `the declared ${Math.round(effectiveMs / 1000)}s tool deadline`
|
|
131
|
+
: `the ~${HARNESS_DEFAULT_MS / 1000}s default foreground deadline`;
|
|
132
|
+
return {
|
|
133
|
+
block: true,
|
|
134
|
+
reason: `an inner ${innerSecs}s deadline (timeout/gtimeout/alarm) exceeds ${ceilingDesc}`,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
return { block: false };
|
|
138
|
+
}
|
|
139
|
+
const BLOCK_MESSAGE = (reason) => `BLOCKED: this FOREGROUND command declares a timeout past the harness ceiling —
|
|
140
|
+
${reason}.
|
|
141
|
+
|
|
142
|
+
A foreground shell tool call is SIGTERM'd at the harness's outer deadline
|
|
143
|
+
(~2 min default, 10 min max) regardless of any longer timeout you declared. It
|
|
144
|
+
will die with exit 143 ("timed out after 10m 0s") and lose whatever it was doing
|
|
145
|
+
— the largest harness-friction cluster in the 2026-08-10 team analysis
|
|
146
|
+
(finding 2.1, 1,393 events).
|
|
147
|
+
|
|
148
|
+
Do this instead: launch it as a background task (run_in_background: true).
|
|
149
|
+
Background tasks survive turn boundaries, have no outer deadline, and auto-notify
|
|
150
|
+
on completion. (\`hq core soft-timeout\` warns without killing for work you
|
|
151
|
+
supervise yourself.)
|
|
152
|
+
|
|
153
|
+
Policy: hq-foreground-timeout-killed-by-harness-deadline.
|
|
154
|
+
Bypass for one sanctioned foreground run: prefix HQ_ALLOW_LONG_FOREGROUND=1.`;
|
|
155
|
+
function defaultEmail() {
|
|
156
|
+
try {
|
|
157
|
+
const cached = loadCachedTokens();
|
|
158
|
+
if (!cached)
|
|
159
|
+
return undefined;
|
|
160
|
+
const email = peekIdToken(cached.idToken).email;
|
|
161
|
+
return typeof email === "string" ? email : undefined;
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
return undefined;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
/** Whether the guard is active for this identity (rollout gate). */
|
|
168
|
+
export function isGatedUser(email) {
|
|
169
|
+
return typeof email === "string" && email.toLowerCase().endsWith(GATE_DOMAIN);
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Command entry: read the PreToolUse JSON, apply the gate + short-circuits, and
|
|
173
|
+
* return the exit code (0 allow / 2 block). Never throws — a parse failure or a
|
|
174
|
+
* missing identity fails OPEN (allow), because a hook must not break tool calls.
|
|
175
|
+
*/
|
|
176
|
+
export function timeoutGuardCommand(stdin, deps = {}) {
|
|
177
|
+
const env = deps.env ?? process.env;
|
|
178
|
+
const err = deps.stderr ?? process.stderr;
|
|
179
|
+
let payload;
|
|
180
|
+
try {
|
|
181
|
+
payload = JSON.parse(stdin);
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
return 0;
|
|
185
|
+
}
|
|
186
|
+
const ti = payload?.tool_input ?? {};
|
|
187
|
+
const command = typeof ti.command === "string" ? ti.command : "";
|
|
188
|
+
if (!command)
|
|
189
|
+
return 0;
|
|
190
|
+
// The inline escape hatch and the env escape hatch both bypass.
|
|
191
|
+
if (env.HQ_ALLOW_LONG_FOREGROUND === "1")
|
|
192
|
+
return 0;
|
|
193
|
+
if (/(^|\s)HQ_ALLOW_LONG_FOREGROUND=1(\s|$)/.test(command))
|
|
194
|
+
return 0;
|
|
195
|
+
// Rollout gate: only act for @getindigo.ai identities.
|
|
196
|
+
const getEmail = deps.getEmail ?? defaultEmail;
|
|
197
|
+
if (!isGatedUser(getEmail()))
|
|
198
|
+
return 0;
|
|
199
|
+
const runInBackground = ti.run_in_background === true;
|
|
200
|
+
const toolTimeoutMs = typeof ti.timeout === "number" ? ti.timeout : undefined;
|
|
201
|
+
const verdict = evaluateTimeoutGuard({ command, toolTimeoutMs, runInBackground });
|
|
202
|
+
if (!verdict.block)
|
|
203
|
+
return 0;
|
|
204
|
+
err.write(`${BLOCK_MESSAGE(verdict.reason ?? "declared timeout exceeds the harness ceiling")}\n`);
|
|
205
|
+
return 2;
|
|
206
|
+
}
|
|
207
|
+
//# sourceMappingURL=timeout-guard.js.map
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* buildFakeHqTree — a reusable test harness that materialises a fake HQ tree in
|
|
3
|
+
* the OS temp directory from a declarative spec.
|
|
4
|
+
*
|
|
5
|
+
* Every `hq doctor` check is fundamentally a function of the on-disk shape of an
|
|
6
|
+
* HQ tree: which hooks exist, whether they are executable, whether they are
|
|
7
|
+
* registered in settings, which `hook-gate.sh` profiles list them, and whether
|
|
8
|
+
* the Codex mirror still matches its Claude original. This helper lets a test
|
|
9
|
+
* declare that shape — including its deliberately-broken variants — and get back
|
|
10
|
+
* a throwaway tree plus a manifest describing exactly what was written, without
|
|
11
|
+
* ever touching the real HQ tree.
|
|
12
|
+
*
|
|
13
|
+
* Design notes:
|
|
14
|
+
* - Everything is written under a `mkdtempSync` root inside `os.tmpdir()`. The
|
|
15
|
+
* real HQ tree is never read or written.
|
|
16
|
+
* - The root path is canonicalised with `realpathSync` so callers can assert
|
|
17
|
+
* containment under the temp dir on platforms (macOS) where the temp dir is
|
|
18
|
+
* a symlink (`/var` -> `/private/var`).
|
|
19
|
+
* - Cleanup is automatic: the first build registers a single `process` exit
|
|
20
|
+
* handler that removes every tracked root, so nothing survives a test run,
|
|
21
|
+
* including on test failure. `tree.cleanup()` is also exposed for eager
|
|
22
|
+
* removal, and is idempotent.
|
|
23
|
+
*
|
|
24
|
+
* The reference for this pattern is core/scripts/test-codex-hook-adapter.sh,
|
|
25
|
+
* which stands up a throwaway HQ tree with `mktemp -d` and stubbed hook scripts.
|
|
26
|
+
*/
|
|
27
|
+
/** Claude/Codex lifecycle events a hook can be registered against. */
|
|
28
|
+
export type HookEventName = "PreToolUse" | "PostToolUse" | "UserPromptSubmit" | "SessionStart" | "Stop" | "SubagentStop" | "PreCompact" | "Notification";
|
|
29
|
+
/** The three `hook-gate.sh` allowlist profiles. */
|
|
30
|
+
export type GateProfile = "minimal" | "standard" | "strict";
|
|
31
|
+
/** Canonical ordering of the gate profiles. */
|
|
32
|
+
export declare const GATE_PROFILES: readonly GateProfile[];
|
|
33
|
+
/**
|
|
34
|
+
* Declares a Codex mirror of a Claude hook. Claude and Grok both execute the
|
|
35
|
+
* canonical `.claude/hooks/` scripts; only Codex runs duplicated copies, so the
|
|
36
|
+
* Codex mirror is the entire drift surface the doctor has to watch.
|
|
37
|
+
*/
|
|
38
|
+
export interface FakeCodexMirrorSpec {
|
|
39
|
+
/** Whether the mirror file is written to `.codex/hooks/`. Default: true. */
|
|
40
|
+
present?: boolean;
|
|
41
|
+
/**
|
|
42
|
+
* Script body for the mirror. Defaults to the Claude original's body (an
|
|
43
|
+
* identical, healthy mirror). Supply a different string to model content
|
|
44
|
+
* drift between the Codex copy and its Claude original.
|
|
45
|
+
*/
|
|
46
|
+
body?: string;
|
|
47
|
+
/** Whether the mirror file carries the executable bit. Default: true. */
|
|
48
|
+
executable?: boolean;
|
|
49
|
+
/** Explicit file mode override (masked to 0o777). Overrides `executable`. */
|
|
50
|
+
mode?: number;
|
|
51
|
+
/** Whether the mirror is registered in `.codex/hooks.json`. Default: true. */
|
|
52
|
+
registered?: boolean;
|
|
53
|
+
/** Optional settings matcher (e.g. "Bash", "Glob"). */
|
|
54
|
+
matcher?: string;
|
|
55
|
+
}
|
|
56
|
+
/** Declares a single hook and its cross-platform state. */
|
|
57
|
+
export interface FakeHookSpec {
|
|
58
|
+
/** Hook id, e.g. "detect-secrets". Also names the `<id>.sh` file. */
|
|
59
|
+
id: string;
|
|
60
|
+
/** Script body. Default: a trivial pass-through that exits 0. */
|
|
61
|
+
body?: string;
|
|
62
|
+
/**
|
|
63
|
+
* Whether the `.claude/hooks/<id>.sh` file is written to disk. Default: true.
|
|
64
|
+
* Set false to model a missing hook file (combine with `registered: true` for
|
|
65
|
+
* a hook that is registered in settings but absent from disk).
|
|
66
|
+
*/
|
|
67
|
+
present?: boolean;
|
|
68
|
+
/**
|
|
69
|
+
* Whether the hook file carries the executable bit. Default: true. Set false
|
|
70
|
+
* to model a present-but-not-executable hook (mode 0o644).
|
|
71
|
+
*/
|
|
72
|
+
executable?: boolean;
|
|
73
|
+
/** Explicit file mode override (masked to 0o777). Overrides `executable`. */
|
|
74
|
+
mode?: number;
|
|
75
|
+
/** Whether the hook is registered in `.claude/settings.json`. Default: true. */
|
|
76
|
+
registered?: boolean;
|
|
77
|
+
/** Events the hook registers against. Default: ["PreToolUse"]. */
|
|
78
|
+
events?: HookEventName[];
|
|
79
|
+
/** Optional settings matcher (e.g. "Bash", "Glob"). */
|
|
80
|
+
matcher?: string;
|
|
81
|
+
/**
|
|
82
|
+
* Which `hook-gate.sh` profiles list this id. Default: all three. Pass a
|
|
83
|
+
* subset (e.g. ["minimal"]) to model a hook id present in only some of the
|
|
84
|
+
* three profiles — the exact defect the three-profile check exists to catch.
|
|
85
|
+
*/
|
|
86
|
+
profiles?: GateProfile[];
|
|
87
|
+
/**
|
|
88
|
+
* Codex mirror configuration. When omitted, a healthy identical mirror is
|
|
89
|
+
* created. Pass `false` to model a Claude hook with no Codex counterpart, or
|
|
90
|
+
* an object to override the mirror (e.g. `{ body }` to model content drift).
|
|
91
|
+
*/
|
|
92
|
+
codex?: FakeCodexMirrorSpec | false;
|
|
93
|
+
}
|
|
94
|
+
/** Declares the `.grok/hooks/` state. */
|
|
95
|
+
export interface FakeGrokSpec {
|
|
96
|
+
/** Whether the `.grok/hooks/` scaffold is created at all. Default: true. */
|
|
97
|
+
present?: boolean;
|
|
98
|
+
/** Whether `hq-grok-hook-adapter.sh` carries the executable bit. Default: true. */
|
|
99
|
+
adapterExecutable?: boolean;
|
|
100
|
+
/**
|
|
101
|
+
* Whether the user-global bridge under `~/.grok/hooks/` is recorded as
|
|
102
|
+
* installed. The real bridge lives in the home dir and cannot be simulated in
|
|
103
|
+
* a project-local temp tree, so this is a manifest flag only. Default: true.
|
|
104
|
+
*/
|
|
105
|
+
bridgeInstalled?: boolean;
|
|
106
|
+
}
|
|
107
|
+
/** The declarative spec passed to {@link buildFakeHqTree}. */
|
|
108
|
+
export interface FakeHqTreeSpec {
|
|
109
|
+
/** Hooks to materialise. Default: none. */
|
|
110
|
+
hooks?: FakeHookSpec[];
|
|
111
|
+
/** Extra top-level keys merged into `.claude/settings.json`. */
|
|
112
|
+
claudeSettings?: Record<string, unknown>;
|
|
113
|
+
/** Grok scaffold config, or `false` to omit `.grok/`. Default: healthy. */
|
|
114
|
+
grok?: FakeGrokSpec | false;
|
|
115
|
+
/** Initialise a git repo at the tree root. Default: false. */
|
|
116
|
+
git?: boolean;
|
|
117
|
+
/** temp-dir name prefix. Default: "hq-doctor-fake-". */
|
|
118
|
+
prefix?: string;
|
|
119
|
+
}
|
|
120
|
+
/** Manifest entry describing a materialised Codex mirror. */
|
|
121
|
+
export interface FakeCodexManifestEntry {
|
|
122
|
+
/** Absolute path where the mirror lives (whether or not it was written). */
|
|
123
|
+
scriptPath: string;
|
|
124
|
+
/** Whether the mirror file exists on disk. */
|
|
125
|
+
present: boolean;
|
|
126
|
+
/** On-disk mode bits (0o777-masked), or null when absent. */
|
|
127
|
+
mode: number | null;
|
|
128
|
+
/** Whether the mirror carries the executable bit. */
|
|
129
|
+
executable: boolean;
|
|
130
|
+
/** Whether the mirror is registered in `.codex/hooks.json`. */
|
|
131
|
+
registered: boolean;
|
|
132
|
+
/** Whether the mirror's content differs from its Claude original. */
|
|
133
|
+
drifted: boolean;
|
|
134
|
+
}
|
|
135
|
+
/** Manifest entry describing a materialised hook across all platforms. */
|
|
136
|
+
export interface FakeHookManifestEntry {
|
|
137
|
+
id: string;
|
|
138
|
+
/** Absolute path of the Claude script (whether or not it was written). */
|
|
139
|
+
scriptPath: string;
|
|
140
|
+
/** Whether the Claude hook file exists on disk. */
|
|
141
|
+
present: boolean;
|
|
142
|
+
/** On-disk mode bits (0o777-masked), or null when absent. */
|
|
143
|
+
mode: number | null;
|
|
144
|
+
/** Whether the Claude hook carries the executable bit. */
|
|
145
|
+
executable: boolean;
|
|
146
|
+
/** Whether the hook is registered in `.claude/settings.json`. */
|
|
147
|
+
registered: boolean;
|
|
148
|
+
/** Events the hook is registered against. */
|
|
149
|
+
events: HookEventName[];
|
|
150
|
+
/** Optional settings matcher (e.g. "Bash", "Glob"), or null when unset. */
|
|
151
|
+
matcher: string | null;
|
|
152
|
+
/** Which `hook-gate.sh` profiles list this id. */
|
|
153
|
+
profiles: GateProfile[];
|
|
154
|
+
/** The Codex mirror, or null when the hook has no Codex counterpart. */
|
|
155
|
+
codex: FakeCodexManifestEntry | null;
|
|
156
|
+
}
|
|
157
|
+
/** A structured description of everything {@link buildFakeHqTree} wrote. */
|
|
158
|
+
export interface FakeHqTreeManifest {
|
|
159
|
+
root: string;
|
|
160
|
+
claudeSettingsPath: string;
|
|
161
|
+
claudeHooksDir: string;
|
|
162
|
+
claudeHookGatePath: string;
|
|
163
|
+
codexHooksJsonPath: string;
|
|
164
|
+
codexHooksDir: string;
|
|
165
|
+
codexHookGatePath: string;
|
|
166
|
+
grokDir: string | null;
|
|
167
|
+
grokAdapterPath: string | null;
|
|
168
|
+
grokRegistrationPath: string | null;
|
|
169
|
+
grokBridgeInstalled: boolean;
|
|
170
|
+
gitInitialised: boolean;
|
|
171
|
+
hooks: FakeHookManifestEntry[];
|
|
172
|
+
}
|
|
173
|
+
/** The handle returned by {@link buildFakeHqTree}. */
|
|
174
|
+
export interface FakeHqTree {
|
|
175
|
+
/** Absolute, canonicalised tree root under the OS temp dir. */
|
|
176
|
+
root: string;
|
|
177
|
+
/** Structured description of everything that was written. */
|
|
178
|
+
manifest: FakeHqTreeManifest;
|
|
179
|
+
/** Join a path relative to the tree root. */
|
|
180
|
+
path: (...segments: string[]) => string;
|
|
181
|
+
/** Remove this tree from disk. Idempotent. */
|
|
182
|
+
cleanup: () => void;
|
|
183
|
+
}
|
|
184
|
+
/** Remove every fake HQ tree still on disk. Safe to call repeatedly. */
|
|
185
|
+
export declare function cleanupAllFakeHqTrees(): void;
|
|
186
|
+
/** The set of tree roots this process is still tracking for cleanup. */
|
|
187
|
+
export declare function trackedFakeHqTreeRoots(): string[];
|
|
188
|
+
/**
|
|
189
|
+
* Materialise a fake HQ tree from `spec` and return its root, manifest, and a
|
|
190
|
+
* cleanup handle. The tree lives under `os.tmpdir()` and is swept automatically
|
|
191
|
+
* when the process exits.
|
|
192
|
+
*/
|
|
193
|
+
export declare function buildFakeHqTree(spec?: FakeHqTreeSpec): FakeHqTree;
|
|
194
|
+
//# sourceMappingURL=fake-hq-tree.d.ts.map
|