@ngockhoale/ukit 2.4.1 → 2.4.2
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 +65 -0
- package/package.json +1 -1
- package/scripts/index/refresh-index.mjs +10 -5
- package/src/cli/commands/doctor.js +59 -2
- package/src/core/gatewayProbe.js +143 -15
- package/src/core/gatewayResilienceEnv.js +136 -7
- package/src/index/buildIndex.js +74 -24
- package/templates/.claude/hooks/auto-allow-bash.sh +24 -1
- package/templates/.claude/hooks/auto-prune-bash.sh +4 -0
- package/templates/.claude/hooks/block-dangerous.sh +20 -1
- package/templates/.claude/hooks/completion-gate.sh +19 -2
- package/templates/.claude/hooks/compress-output.sh +17 -2
- package/templates/.claude/hooks/context-hardcap-gate.sh +22 -3
- package/templates/.claude/hooks/context-window-guard.sh +84 -61
- package/templates/.claude/hooks/handoff-model-guard.sh +24 -3
- package/templates/.claude/hooks/handoff-resume.sh +21 -3
- package/templates/.claude/hooks/post-edit-verify.sh +19 -2
- package/templates/.claude/hooks/pre-edit-backup.sh +19 -2
- package/templates/.claude/hooks/protect-files.sh +20 -1
- package/templates/.claude/hooks/record-execution.sh +20 -2
- package/templates/.claude/hooks/reinject-context.sh +1 -1
- package/templates/.claude/hooks/reset-compact-pressure.sh +4 -0
- package/templates/.claude/hooks/sensitive-data-guard.sh +64 -17
- package/templates/.claude/hooks/skill-router.sh +33 -5
- package/templates/.claude/hooks/stale-spec-guard.sh +21 -2
- package/templates/.claude/hooks/task-watchdog.sh +25 -7
- package/templates/.claude/hooks/verification-guard.sh +54 -19
- package/templates/.claude/hooks/vision-router.sh +96 -12
- package/templates/.claude/ukit/index/lib/index-core.mjs +78 -16
- package/templates/.claude/ukit/index/post-edit-verify.mjs +8 -0
- package/templates/.claude/ukit/index/pre-edit-backup.mjs +8 -0
- package/templates/.claude/ukit/index/refresh-index.mjs +10 -5
- package/templates/.claude/ukit/index/stale-spec-check.mjs +8 -0
- package/templates/.claude/ukit/runtime/execution-ledger.mjs +8 -0
- package/templates/.claude/ukit/runtime/hook-input.mjs +120 -0
- package/templates/.claude/ukit/runtime/hook-input.sh +60 -0
- package/templates/.claude/ukit/runtime/output-compression.mjs +8 -0
- package/templates/.claude/ukit/runtime/reinject-context.mjs +8 -0
- package/templates/.claude/ukit/runtime/transcript-tail.mjs +107 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,71 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to UKit are documented here.
|
|
4
4
|
|
|
5
|
+
## 2.4.2 - 2026-09-16
|
|
6
|
+
|
|
7
|
+
Fix: the gateway resilience posture disabled the only recovery path a buffering gateway
|
|
8
|
+
leaves open — the direct client-side cause of the recurring "session stands still mid-turn
|
|
9
|
+
until the user interrupts and re-prompts" freeze. Live probes of the stall-class gateway on
|
|
10
|
+
2026-09-16 proved the full chain: the gateway still buffers entire streams (a 16-token
|
|
11
|
+
probe returned 23 SSE events in ONE body read), so a long generation is silent until it
|
|
12
|
+
finishes; the non-streaming route is healthy (HTTP 200 + valid Anthropic Message); and the
|
|
13
|
+
managed env shipped since 2.3.16 kept the non-streaming fallback DISABLED with a 10-minute
|
|
14
|
+
stream-idle watchdog — meaning the client sat in dead air and, on abort, had no recovery
|
|
15
|
+
path at all. The managed posture now flips to fail-fast-and-recover.
|
|
16
|
+
|
|
17
|
+
- **Posture flip** (`src/core/gatewayResilienceEnv.js`): `CLAUDE_CODE_DISABLE_NONSTREAMING_FALLBACK`
|
|
18
|
+
managed default `'1'` → `'0'` (fallback stays enabled — on a buffering gateway a
|
|
19
|
+
non-streaming POST is the one request shape without an idle watchdog), and
|
|
20
|
+
`CLAUDE_STREAM_IDLE_TIMEOUT_MS` `'600000'` → `'120000'` (a silent stream aborts after
|
|
21
|
+
2 minutes and recovers via the healthy non-streaming route instead of hanging for 10).
|
|
22
|
+
Values equal to the previously shipped defaults are UKit's own earlier writes and are
|
|
23
|
+
migrated on the next `ukit install` (new `GATEWAY_RESILIENCE_ENV_LEGACY_DEFAULTS` map +
|
|
24
|
+
`migrated` report field, backup written as usual); genuinely user-tuned values are still
|
|
25
|
+
never touched.
|
|
26
|
+
- **Probe misdiagnosis fixed** (`src/core/gatewayProbe.js`): the probe 404'd healthy
|
|
27
|
+
alias-based gateways because it POSTed the built-in default `claude-sonnet-4-5`, which is
|
|
28
|
+
not provisioned there ("No active credentials for provider: claude", `model_not_found`,
|
|
29
|
+
reproduced live) while real sessions run on the `ANTHROPIC_DEFAULT_*_MODEL` lane aliases.
|
|
30
|
+
The probe now resolves `ANTHROPIC_MODEL` → `ANTHROPIC_DEFAULT_OPUS/SONNET/HAIKU_MODEL`
|
|
31
|
+
before the built-in default and reports the probed model. `anthropic-version` is now sent
|
|
32
|
+
for the Bearer scheme too (live-verified accepted), and the API key is resolved through
|
|
33
|
+
the same 3-tier order as the base URL (env → project settings → home settings) so plain
|
|
34
|
+
terminals no longer probe keyless (`resolveGatewayApiKey`).
|
|
35
|
+
- **request-id re-classified** (same file, `docs/GATEWAY.md` §3): a missing `request-id`
|
|
36
|
+
on an HTTP 200 + valid Anthropic Message is an advisory hint, not a FAIL — the real
|
|
37
|
+
gateway ships no request-id even on healthy responses and sessions complete fine. The
|
|
38
|
+
Message-shape hard gate still catches the 888-byte error-envelope class from 2.3.16.
|
|
39
|
+
- **Docs**: `docs/GATEWAY.md` intro/§1/§2/§3/§4/§5 updated for the fail-fast posture,
|
|
40
|
+
the 2026-09-16 buffering reconfirmation, the legacy-value migration, and the probe's
|
|
41
|
+
alias-model + key resolution.
|
|
42
|
+
- **Verification**: focused suites green (gatewayProbe 14, gatewayResilienceEnv 23,
|
|
43
|
+
packageVersion 3); full `yarn test` green; live re-probe after the fix reports the alias
|
|
44
|
+
model, a healthy non-streaming route, and the still-present buffering signature.
|
|
45
|
+
- **Orphan-leak class closed for the remaining hooks (2.4.1 follow-up)**: the 2.4.1 watchdog
|
|
46
|
+
shipped in exactly one hook (`skill-router.sh`); the other 18 node-spawning hooks could still
|
|
47
|
+
orphan a node grandchild to launchd when their work hung (wedged import, stalled mount — this
|
|
48
|
+
repo lives on an external volume). Every remaining hook now arms the same wall-clock
|
|
49
|
+
self-deadline: inline heredoc/`node -e` blocks carry
|
|
50
|
+
`setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref()` directly, wrapper hooks pass
|
|
51
|
+
`UKIT_HOOK_DEADLINE_MS` (default 3000 ms) and their runtime scripts (`execution-ledger`,
|
|
52
|
+
`output-compression`, `reinject-context`, `post-edit-verify`, `pre-edit-backup`,
|
|
53
|
+
`stale-spec-check`) self-exit only when that env var is present, so CLI usage is never
|
|
54
|
+
self-killed. Lock-mutation blocks with a 5000 ms bounded wait (auto-allow-bash,
|
|
55
|
+
auto-prune-bash, reset-compact-pressure) use an 8000 ms deadline so the watchdog can never
|
|
56
|
+
fire while the mutation is legally waiting for the lock.
|
|
57
|
+
`tests/consistency/hookWatchdogCoverage.test.js` enforces the coverage for every future hook.
|
|
58
|
+
- **Bounded stdin transport for the hook chain (C19 wave 1, H01)**: hook payloads no longer
|
|
59
|
+
materialize unbounded in a shell variable or the exec environment — the exec-env path that
|
|
60
|
+
made large PreToolUse payloads fail `exec` outright (E2BIG) on macOS. New
|
|
61
|
+
`templates/.claude/ukit/runtime/hook-input.sh` (`ukit_stage_hook_input` via `mktemp` +
|
|
62
|
+
byte-capped `head`, drained producer, overflow policies `truncate`/`temp-file`/`refuse`)
|
|
63
|
+
and `hook-input.mjs` (streaming reader + CLI) stage every payload to a bounded temp file;
|
|
64
|
+
all 16 node-spawning wrapper hooks now pass `INPUT_FILE`, never the payload, with a
|
|
65
|
+
bounded inline fallback when the helper is absent. Sensitive-data-guard overflow is
|
|
66
|
+
fail-closed (`refuse`, silent exit 2); 32 MiB receipts verified < 10 s.
|
|
67
|
+
|
|
68
|
+
- Machine-envelope lane in the sensitive-data gate: harness-injected notifications (task-notification / system-reminder / cross-session-message tags) now deliver with a redacted labels/counts advisory instead of being blocked when they quote secret-shaped test fixtures — ends the silent "Waiting for N background agents" stall. User-typed prompts, file/grep/bash channels, allowlist and overflow-refuse posture unchanged (fail-closed, default-ON, never echoes values).
|
|
69
|
+
|
|
5
70
|
## 2.4.1 - 2026-09-14
|
|
6
71
|
|
|
7
72
|
Fix: the skill-router hook could hang the session and leak an orphaned process. `skill-router.sh`
|
package/package.json
CHANGED
|
@@ -4,7 +4,7 @@ import path from 'node:path';
|
|
|
4
4
|
|
|
5
5
|
import {
|
|
6
6
|
buildCodeIndex,
|
|
7
|
-
|
|
7
|
+
inspectIndexStaleness,
|
|
8
8
|
DEFAULT_INDEX_CACHE_MAX_AGE_MS,
|
|
9
9
|
} from '../../src/index/buildIndex.js';
|
|
10
10
|
|
|
@@ -19,9 +19,10 @@ const changedFiles = changedArg
|
|
|
19
19
|
|
|
20
20
|
const force = readBooleanFlag(args, '--force');
|
|
21
21
|
const lastRefreshMs = await getLastRefreshTime(rootDir);
|
|
22
|
-
const
|
|
23
|
-
?
|
|
24
|
-
: await
|
|
22
|
+
const staleness = force
|
|
23
|
+
? null
|
|
24
|
+
: await inspectIndexStaleness({ rootDir, maxAgeMs: DEFAULT_INDEX_CACHE_MAX_AGE_MS });
|
|
25
|
+
const stale = force || staleness.stale;
|
|
25
26
|
|
|
26
27
|
if (!stale) {
|
|
27
28
|
console.log('[index:refresh] skipped (cache fresh)');
|
|
@@ -33,7 +34,11 @@ if (!stale) {
|
|
|
33
34
|
}
|
|
34
35
|
console.log(`root: ${rootDir}`);
|
|
35
36
|
} else {
|
|
36
|
-
|
|
37
|
+
// Reuse the staleness check's discovery snapshot: one enumeration per refresh.
|
|
38
|
+
const summary = await buildCodeIndex({
|
|
39
|
+
rootDir,
|
|
40
|
+
discoverySnapshot: staleness ? staleness.snapshot : null,
|
|
41
|
+
});
|
|
37
42
|
|
|
38
43
|
console.log('[index:refresh] completed');
|
|
39
44
|
if (lastRefreshMs !== null) {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import fs from 'node:fs/promises';
|
|
3
|
+
import os from 'node:os';
|
|
3
4
|
import { pathExists, readJsonIfExists } from '../../core/fileOps.js';
|
|
4
5
|
import { buildPathConfig } from '../../core/paths.js';
|
|
5
6
|
import { buildRuntimePaths } from '../../core/runtimePaths.js';
|
|
@@ -10,8 +11,13 @@ import { detectProviders } from '../../context/detectProviders.js';
|
|
|
10
11
|
import { profileSkills } from '../../core/skillProfile.js';
|
|
11
12
|
import {
|
|
12
13
|
resolveGatewayBaseUrl,
|
|
14
|
+
resolveGatewayApiKey,
|
|
13
15
|
probeGateway,
|
|
14
16
|
} from '../../core/gatewayProbe.js';
|
|
17
|
+
import {
|
|
18
|
+
GATEWAY_RESILIENCE_ENV_DEFAULTS,
|
|
19
|
+
scanProfileResilienceEnv,
|
|
20
|
+
} from '../../core/gatewayResilienceEnv.js';
|
|
15
21
|
|
|
16
22
|
export const DOCTOR_HELP_FLAGS = new Set(['--help', '-h']);
|
|
17
23
|
const KNOWN_FLAGS = new Set([...DOCTOR_HELP_FLAGS, '--skills', '--gateway']);
|
|
@@ -28,7 +34,7 @@ export function printDoctorHelp() {
|
|
|
28
34
|
console.log(' --gateway Live gateway probe (streaming + non-streaming); advisory only');
|
|
29
35
|
}
|
|
30
36
|
|
|
31
|
-
export async function runDoctor({ packageRoot, projectRoot, argv = [] }) {
|
|
37
|
+
export async function runDoctor({ packageRoot, projectRoot, argv = [], homeDir = os.homedir() }) {
|
|
32
38
|
const unknownFlags = argv.filter((flag) => !KNOWN_FLAGS.has(flag));
|
|
33
39
|
if (unknownFlags.length > 0) {
|
|
34
40
|
throw new Error(`Unknown option: ${unknownFlags[0]}. Supported: ${SUPPORTED_FLAGS_LIST}`);
|
|
@@ -158,6 +164,40 @@ export async function runDoctor({ packageRoot, projectRoot, argv = [] }) {
|
|
|
158
164
|
if (argv.includes('--gateway')) {
|
|
159
165
|
console.log('');
|
|
160
166
|
console.log('[UKit] Gateway probe (advisory — does not affect exit code):');
|
|
167
|
+
|
|
168
|
+
// Per-profile resilience scan. A session launched with
|
|
169
|
+
// `--settings ~/.claude/profile/<name>.json` bypasses ~/.claude/settings.json, so the
|
|
170
|
+
// managed keys can be missing exactly where they matter while the main settings file
|
|
171
|
+
// looks healthy. Advisory only: this never enters `checks` and never touches exitCode.
|
|
172
|
+
console.log(
|
|
173
|
+
`[UKit] Profile resilience scan (advisory) — managed keys: ${Object.keys(GATEWAY_RESILIENCE_ENV_DEFAULTS).join(', ')}`,
|
|
174
|
+
);
|
|
175
|
+
let profileScan = null;
|
|
176
|
+
try {
|
|
177
|
+
profileScan = await scanProfileResilienceEnv({ homeDir });
|
|
178
|
+
} catch {
|
|
179
|
+
profileScan = null;
|
|
180
|
+
}
|
|
181
|
+
if (!profileScan || profileScan.scanned.length === 0) {
|
|
182
|
+
console.log('[UKit] - No profile files found (nothing to check).');
|
|
183
|
+
} else {
|
|
184
|
+
for (const entry of profileScan.scanned) {
|
|
185
|
+
if (entry.missing.length === 0) {
|
|
186
|
+
console.log(`[UKit] ✓ ${entry.file}`);
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
console.log(
|
|
190
|
+
`[UKit] ✗ ${entry.file} — ${entry.validJson ? `missing ${entry.missing.join(', ')}` : 'not valid JSON (assuming every managed key missing)'}`,
|
|
191
|
+
);
|
|
192
|
+
console.log(
|
|
193
|
+
`[UKit] remedy: add ${entry.missing.join(', ')} to the profile's "env" block; see docs/GATEWAY.md`,
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
if (profileScan.compliant) {
|
|
197
|
+
console.log('[UKit] All scanned profile files carry the managed resilience keys.');
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
161
201
|
let resolved;
|
|
162
202
|
try {
|
|
163
203
|
resolved = await resolveGatewayBaseUrl({ projectRoot });
|
|
@@ -168,9 +208,25 @@ export async function runDoctor({ packageRoot, projectRoot, argv = [] }) {
|
|
|
168
208
|
console.log('[UKit] - Gateway probe skipped — no custom gateway configured (ANTHROPIC_BASE_URL not set in env, project .claude/settings.json, or ~/.claude/settings.json).');
|
|
169
209
|
} else {
|
|
170
210
|
console.log(`[UKit] baseUrl: ${resolved.baseUrl} (source: ${resolved.source})`);
|
|
211
|
+
// Plain terminals do not export ANTHROPIC_* (the key lives in settings.json env and is
|
|
212
|
+
// only injected into Claude Code sessions), so resolve the key through the same 3-tier
|
|
213
|
+
// order as the baseUrl — otherwise the probe authenticates keyless and misreports.
|
|
214
|
+
let keyInfo = null;
|
|
215
|
+
try {
|
|
216
|
+
keyInfo = await resolveGatewayApiKey({ projectRoot });
|
|
217
|
+
} catch {
|
|
218
|
+
keyInfo = null;
|
|
219
|
+
}
|
|
220
|
+
if (!keyInfo?.value) {
|
|
221
|
+
console.log('[UKit] - No API key found (env, project settings, home settings) — probe runs unauthenticated and may 401/404.');
|
|
222
|
+
}
|
|
171
223
|
let result;
|
|
172
224
|
try {
|
|
173
|
-
result = await probeGateway({
|
|
225
|
+
result = await probeGateway({
|
|
226
|
+
baseUrl: resolved.baseUrl,
|
|
227
|
+
apiKey: keyInfo?.value ?? null,
|
|
228
|
+
apiKeySource: keyInfo?.scheme ?? null,
|
|
229
|
+
});
|
|
174
230
|
} catch (error) {
|
|
175
231
|
result = null;
|
|
176
232
|
console.log(`[UKit] ✗ Gateway probe threw: ${error?.message ?? String(error)}`);
|
|
@@ -178,6 +234,7 @@ export async function runDoctor({ packageRoot, projectRoot, argv = [] }) {
|
|
|
178
234
|
if (result) {
|
|
179
235
|
const s = result.streaming;
|
|
180
236
|
const n = result.nonStreaming;
|
|
237
|
+
console.log(`[UKit] probed model: ${result.model}`);
|
|
181
238
|
const streamingLabel = s.ok
|
|
182
239
|
? `streaming OK (events=${s.eventsReceived}, chunks=${s.chunks})`
|
|
183
240
|
: `streaming FAIL (events=${s.eventsReceived}, chunks=${s.chunks}, buffered=${s.buffered}${s.error ? `, error=${s.error}` : ''})`;
|
package/src/core/gatewayProbe.js
CHANGED
|
@@ -22,6 +22,7 @@ import os from 'node:os';
|
|
|
22
22
|
import path from 'node:path';
|
|
23
23
|
|
|
24
24
|
const GATEWAY_DOC = 'docs/GATEWAY.md';
|
|
25
|
+
const SETTINGS_RELATIVE_PROBE_PATH = path.join('.claude', 'settings.json');
|
|
25
26
|
|
|
26
27
|
function safeReadFile(filePath) {
|
|
27
28
|
try {
|
|
@@ -96,6 +97,53 @@ export async function resolveGatewayBaseUrl({
|
|
|
96
97
|
return { baseUrl: null, source: null };
|
|
97
98
|
}
|
|
98
99
|
|
|
100
|
+
function readSettingsEnvBlock(filePath) {
|
|
101
|
+
const json = safeReadJson(filePath);
|
|
102
|
+
if (!json || typeof json !== 'object') return null;
|
|
103
|
+
const envBlock = json.env;
|
|
104
|
+
if (!envBlock || typeof envBlock !== 'object' || Array.isArray(envBlock)) return null;
|
|
105
|
+
return envBlock;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Resolves the API key the probe should authenticate with, mirroring the same 3-tier probe
|
|
110
|
+
* order as `resolveGatewayBaseUrl`: env → project `.claude/settings.json` env → home
|
|
111
|
+
* `~/.claude/settings.json` env. Plain terminals do not export ANTHROPIC_* (Claude Code
|
|
112
|
+
* injects its settings env only into its own sessions), so without the settings tiers
|
|
113
|
+
* `ukit doctor --gateway` probes keyless and misreports an auth-gated gateway.
|
|
114
|
+
*
|
|
115
|
+
* The `scheme` field mirrors `pickApiKey`'s source (`'api-key'` → x-api-key,
|
|
116
|
+
* `'auth-token'` → Authorization: Bearer) so callers can pass it straight into
|
|
117
|
+
* `probeGateway` as `apiKeySource`. Never throws.
|
|
118
|
+
*
|
|
119
|
+
* @param {{ projectRoot: string, homeDir?: string, env?: object }} options
|
|
120
|
+
* @returns {{ value: string|null, scheme: 'api-key'|'auth-token'|null,
|
|
121
|
+
* source: 'env'|'claude-settings'|null }}
|
|
122
|
+
*/
|
|
123
|
+
export function resolveGatewayApiKey({
|
|
124
|
+
projectRoot,
|
|
125
|
+
homeDir = os.homedir(),
|
|
126
|
+
env = process.env,
|
|
127
|
+
} = {}) {
|
|
128
|
+
const envKey = pickApiKey(env ?? {});
|
|
129
|
+
if (envKey) {
|
|
130
|
+
return { value: envKey.value, scheme: envKey.source, source: 'env' };
|
|
131
|
+
}
|
|
132
|
+
const settingsPaths = [
|
|
133
|
+
...(projectRoot ? [path.join(projectRoot, SETTINGS_RELATIVE_PROBE_PATH)] : []),
|
|
134
|
+
path.join(homeDir, SETTINGS_RELATIVE_PROBE_PATH),
|
|
135
|
+
];
|
|
136
|
+
for (const settingsPath of settingsPaths) {
|
|
137
|
+
const envBlock = readSettingsEnvBlock(settingsPath);
|
|
138
|
+
if (!envBlock) continue;
|
|
139
|
+
const key = pickApiKey(envBlock);
|
|
140
|
+
if (key) {
|
|
141
|
+
return { value: key.value, scheme: key.source, source: 'claude-settings' };
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return { value: null, scheme: null, source: null };
|
|
145
|
+
}
|
|
146
|
+
|
|
99
147
|
function pickApiKey(envLike) {
|
|
100
148
|
if (!envLike || typeof envLike !== 'object') return null;
|
|
101
149
|
// Source matters: ANTHROPIC_API_KEY is sent by Claude Code as `x-api-key`, while
|
|
@@ -112,7 +160,16 @@ function pickApiKey(envLike) {
|
|
|
112
160
|
|
|
113
161
|
function pickModel(envLike) {
|
|
114
162
|
if (!envLike || typeof envLike !== 'object') return null;
|
|
115
|
-
|
|
163
|
+
// ANTHROPIC_MODEL wins, then the per-lane alias mappings Claude Code itself resolves
|
|
164
|
+
// (2026-09-16 live evidence: alias-based gateways provision ONLY the mapped lane models;
|
|
165
|
+
// probing the built-in default `claude-sonnet-4-5` returned HTTP 404 `model_not_found` /
|
|
166
|
+
// "No active credentials for provider: claude" on a gateway whose real sessions work).
|
|
167
|
+
return pickString(
|
|
168
|
+
envLike.ANTHROPIC_MODEL,
|
|
169
|
+
envLike.ANTHROPIC_DEFAULT_OPUS_MODEL,
|
|
170
|
+
envLike.ANTHROPIC_DEFAULT_SONNET_MODEL,
|
|
171
|
+
envLike.ANTHROPIC_DEFAULT_HAIKU_MODEL,
|
|
172
|
+
);
|
|
116
173
|
}
|
|
117
174
|
|
|
118
175
|
// Minimal VALID Anthropic Messages API body. The probe only measures transport shape
|
|
@@ -132,11 +189,14 @@ function buildRequestBody({ stream, model }) {
|
|
|
132
189
|
// * ANTHROPIC_API_KEY -> x-api-key (Anthropic-native)
|
|
133
190
|
// * ANTHROPIC_AUTH_TOKEN -> Authorization: Bearer (OpenAI-style / proxy gateways)
|
|
134
191
|
// Without this split, token-auth gateways 401 the probe even when transport shape is
|
|
135
|
-
// valid (Reviewer Round-1 important finding #3).
|
|
192
|
+
// valid (Reviewer Round-1 important finding #3). `anthropic-version` is sent for BOTH
|
|
193
|
+
// schemes: it is required by Anthropic-native gateways and ignored by OpenAI-style proxies
|
|
194
|
+
// (2026-09-16 live evidence: the token-auth UNIC gateway returns 200 + valid Message with
|
|
195
|
+
// the header present).
|
|
136
196
|
function buildAuthHeaders(keyValue, keySource) {
|
|
137
197
|
if (!keyValue) return {};
|
|
138
198
|
if (keySource === 'auth-token') {
|
|
139
|
-
return { Authorization: `Bearer ${keyValue}
|
|
199
|
+
return { Authorization: `Bearer ${keyValue}`, 'anthropic-version': '2023-06-01' };
|
|
140
200
|
}
|
|
141
201
|
// Default to the Anthropic-native scheme — covers explicit apiKey arg and
|
|
142
202
|
// ANTHROPIC_API_KEY env (the dominant case).
|
|
@@ -161,6 +221,62 @@ function parseSseEvents(text) {
|
|
|
161
221
|
return count;
|
|
162
222
|
}
|
|
163
223
|
|
|
224
|
+
// TASK-010 (error-body surfacing): on a non-OK response the gateway body often carries the
|
|
225
|
+
// operator-actionable cause (2026-09-16 live evidence: HTTP 404 + `{"error":{"message":"No
|
|
226
|
+
// active credentials for provider: anthropic"}}`). The read is bounded and the surfaced
|
|
227
|
+
// message is capped at 300 chars (PLAN §3/§4 contract) so a giant HTML error page can never
|
|
228
|
+
// flood the doctor output.
|
|
229
|
+
const ERROR_DETAIL_MAX_CHARS = 300;
|
|
230
|
+
const ERROR_BODY_MAX_CHARS = 4096;
|
|
231
|
+
|
|
232
|
+
async function readBodyTextBounded(response) {
|
|
233
|
+
const body = response?.body;
|
|
234
|
+
if (!body || typeof body.getReader !== 'function') return '';
|
|
235
|
+
const reader = body.getReader();
|
|
236
|
+
const decoder = new TextDecoder();
|
|
237
|
+
let text = '';
|
|
238
|
+
try {
|
|
239
|
+
while (text.length < ERROR_BODY_MAX_CHARS) {
|
|
240
|
+
const { value, done } = await reader.read();
|
|
241
|
+
if (done) break;
|
|
242
|
+
if (value) text += decoder.decode(value, { stream: true });
|
|
243
|
+
}
|
|
244
|
+
} catch {
|
|
245
|
+
// A failing/unreadable error body must never turn into a probe crash — fall back to
|
|
246
|
+
// whatever was collected so far (possibly empty).
|
|
247
|
+
} finally {
|
|
248
|
+
try {
|
|
249
|
+
reader.releaseLock();
|
|
250
|
+
} catch {
|
|
251
|
+
// reader may already be released
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return text;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Extracts the gateway error message from a non-OK response body:
|
|
259
|
+
* `JSON.parse(text).error.message`, truncated to `ERROR_DETAIL_MAX_CHARS`. Returns `null`
|
|
260
|
+
* when the body is missing, unreadable, non-JSON, or carries no error message — callers
|
|
261
|
+
* then leave `errorDetail` unset and keep today's `HTTP <status>` wording. Never throws.
|
|
262
|
+
*/
|
|
263
|
+
async function extractErrorDetail(response) {
|
|
264
|
+
const text = await readBodyTextBounded(response);
|
|
265
|
+
if (!text) return null;
|
|
266
|
+
let parsed = null;
|
|
267
|
+
try {
|
|
268
|
+
parsed = JSON.parse(text);
|
|
269
|
+
} catch {
|
|
270
|
+
return null;
|
|
271
|
+
}
|
|
272
|
+
const message =
|
|
273
|
+
parsed && typeof parsed === 'object' && typeof parsed?.error?.message === 'string'
|
|
274
|
+
? parsed.error.message
|
|
275
|
+
: null;
|
|
276
|
+
if (!message) return null;
|
|
277
|
+
return message.slice(0, ERROR_DETAIL_MAX_CHARS);
|
|
278
|
+
}
|
|
279
|
+
|
|
164
280
|
/**
|
|
165
281
|
* Streaming probe: posts `stream: true` and counts SSE frames + distinct body reads.
|
|
166
282
|
*
|
|
@@ -169,7 +285,8 @@ function parseSseEvents(text) {
|
|
|
169
285
|
* buffering gateway dumps everything in one read.
|
|
170
286
|
*
|
|
171
287
|
* @returns {Promise<{ ok: boolean, eventsReceived: number, firstEventMs: number|null,
|
|
172
|
-
* chunks: number, buffered: boolean, error?: string
|
|
288
|
+
* chunks: number, buffered: boolean, error?: string,
|
|
289
|
+
* errorDetail?: string|null }>}
|
|
173
290
|
*/
|
|
174
291
|
async function runStreamingProbe({ url, headers, body: requestBody, fetchImpl, timeoutMs }) {
|
|
175
292
|
const controller = new AbortController();
|
|
@@ -190,6 +307,7 @@ async function runStreamingProbe({ url, headers, body: requestBody, fetchImpl, t
|
|
|
190
307
|
chunks: 0,
|
|
191
308
|
buffered: false,
|
|
192
309
|
error: `HTTP ${response?.status ?? 'unknown'}`,
|
|
310
|
+
errorDetail: await extractErrorDetail(response),
|
|
193
311
|
};
|
|
194
312
|
}
|
|
195
313
|
const body = response.body;
|
|
@@ -278,7 +396,8 @@ async function runStreamingProbe({ url, headers, body: requestBody, fetchImpl, t
|
|
|
278
396
|
* gateway error envelope slip through Claude Code's retry path and kill the user's turn.
|
|
279
397
|
*
|
|
280
398
|
* @returns {Promise<{ ok: boolean, httpOk: boolean, isAnthropicMessage: boolean,
|
|
281
|
-
* requestIdPresent: boolean, bodyBytes: number|null, error?: string
|
|
399
|
+
* requestIdPresent: boolean, bodyBytes: number|null, error?: string,
|
|
400
|
+
* errorDetail?: string|null }>}
|
|
282
401
|
*/
|
|
283
402
|
async function runNonStreamingProbe({ url, headers, body: requestBody, fetchImpl, timeoutMs }) {
|
|
284
403
|
const controller = new AbortController();
|
|
@@ -300,6 +419,7 @@ async function runNonStreamingProbe({ url, headers, body: requestBody, fetchImpl
|
|
|
300
419
|
requestIdPresent,
|
|
301
420
|
bodyBytes: null,
|
|
302
421
|
error: `HTTP ${response?.status ?? 'unknown'}`,
|
|
422
|
+
errorDetail: await extractErrorDetail(response),
|
|
303
423
|
};
|
|
304
424
|
}
|
|
305
425
|
const body = response.body;
|
|
@@ -341,7 +461,12 @@ async function runNonStreamingProbe({ url, headers, body: requestBody, fetchImpl
|
|
|
341
461
|
typeof parsed === 'object' &&
|
|
342
462
|
parsed.type === 'message' &&
|
|
343
463
|
Array.isArray(parsed.content);
|
|
344
|
-
|
|
464
|
+
// 2026-09-16 live re-classification: the real UNIC gateway returns HTTP 200 + a valid
|
|
465
|
+
// Anthropic Message with NO request-id header, and real sessions complete fine — the
|
|
466
|
+
// hard pass/fail gate is the Message shape (which still catches the 888-byte error-
|
|
467
|
+
// envelope class). A missing request-id is reported as an advisory hint instead of a
|
|
468
|
+
// hard fail; see GATEWAY_DOC §3.
|
|
469
|
+
const ok = isAnthropicMessage;
|
|
345
470
|
return {
|
|
346
471
|
ok,
|
|
347
472
|
httpOk: true,
|
|
@@ -406,11 +531,12 @@ export async function probeGateway({
|
|
|
406
531
|
if (!streaming.ok) {
|
|
407
532
|
if (streaming.buffered) {
|
|
408
533
|
hints.push(
|
|
409
|
-
`
|
|
534
|
+
`Gateway buffers the entire stream (the stall signature). Managed posture: keep CLAUDE_STREAM_IDLE_TIMEOUT_MS low so the idle watchdog fires, and keep the non-streaming fallback ENABLED so the aborted stream recovers via a non-streaming retry; see ${GATEWAY_DOC}.`,
|
|
410
535
|
);
|
|
411
536
|
} else if (streaming.error) {
|
|
537
|
+
const gatewayCause = streaming.errorDetail ? ` — ${streaming.errorDetail}` : '';
|
|
412
538
|
hints.push(
|
|
413
|
-
`Streaming probe failed: ${streaming.error} — see ${GATEWAY_DOC}.`,
|
|
539
|
+
`Streaming probe failed: ${streaming.error}${gatewayCause} (probed model: ${effectiveModel}) — see ${GATEWAY_DOC}.`,
|
|
414
540
|
);
|
|
415
541
|
} else {
|
|
416
542
|
hints.push(
|
|
@@ -421,21 +547,23 @@ export async function probeGateway({
|
|
|
421
547
|
if (!nonStreaming.ok) {
|
|
422
548
|
if (nonStreaming.httpOk && !nonStreaming.isAnthropicMessage) {
|
|
423
549
|
hints.push(
|
|
424
|
-
`
|
|
425
|
-
);
|
|
426
|
-
} else if (nonStreaming.httpOk && !nonStreaming.requestIdPresent) {
|
|
427
|
-
hints.push(
|
|
428
|
-
`Gateway must set the request-id (or anthropic-request-id) response header — see ${GATEWAY_DOC}.`,
|
|
550
|
+
`Non-streaming route returned JSON but not an Anthropic Message — leave CLAUDE_CODE_DISABLE_NONSTREAMING_FALLBACK=1 (fallback must stay off); see ${GATEWAY_DOC}.`,
|
|
429
551
|
);
|
|
430
552
|
} else if (nonStreaming.error) {
|
|
553
|
+
const gatewayCause = nonStreaming.errorDetail ? ` — ${nonStreaming.errorDetail}` : '';
|
|
431
554
|
hints.push(
|
|
432
|
-
`Non-streaming probe failed: ${nonStreaming.error} — see ${GATEWAY_DOC}.`,
|
|
555
|
+
`Non-streaming probe failed: ${nonStreaming.error}${gatewayCause} (probed model: ${effectiveModel}) — see ${GATEWAY_DOC}.`,
|
|
433
556
|
);
|
|
434
557
|
} else {
|
|
435
558
|
hints.push(`Non-streaming probe failed — see ${GATEWAY_DOC}.`);
|
|
436
559
|
}
|
|
437
560
|
}
|
|
561
|
+
if (nonStreaming.httpOk && nonStreaming.isAnthropicMessage && !nonStreaming.requestIdPresent) {
|
|
562
|
+
hints.push(
|
|
563
|
+
`Gateway did not set a request-id (or anthropic-request-id) header — advisory only, diagnosability is degraded but transport works; see ${GATEWAY_DOC}.`,
|
|
564
|
+
);
|
|
565
|
+
}
|
|
438
566
|
|
|
439
567
|
const verdict = streaming.ok && nonStreaming.ok ? 'pass' : 'fail';
|
|
440
|
-
return { streaming, nonStreaming, verdict, hints };
|
|
568
|
+
return { streaming, nonStreaming, verdict, hints, model: effectiveModel };
|
|
441
569
|
}
|