@ngockhoale/ukit 2.4.1 → 2.4.3
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/manifests/platform.full.yaml +19 -111
- package/package.json +2 -1
- package/scripts/index/refresh-index.mjs +48 -18
- package/src/cli/commands/doctor.js +59 -2
- package/src/core/compact/threshold.js +36 -6
- package/src/core/gatewayProbe.js +143 -15
- package/src/core/gatewayResilienceEnv.js +136 -7
- package/src/diagnostics/classifyHang.js +246 -0
- package/src/index/buildIndex.js +1096 -75
- package/templates/.claude/hooks/auto-allow-bash.sh +99 -87
- package/templates/.claude/hooks/auto-prune-bash.sh +4 -0
- package/templates/.claude/hooks/block-dangerous.sh +46 -1
- package/templates/.claude/hooks/completion-gate.sh +65 -7
- package/templates/.claude/hooks/compress-output.sh +49 -2
- package/templates/.claude/hooks/context-hardcap-gate.sh +52 -4
- package/templates/.claude/hooks/context-window-guard.sh +204 -71
- package/templates/.claude/hooks/handoff-model-guard.sh +50 -3
- package/templates/.claude/hooks/handoff-resume.sh +47 -3
- package/templates/.claude/hooks/post-edit-verify.sh +45 -2
- package/templates/.claude/hooks/pre-edit-backup.sh +45 -2
- package/templates/.claude/hooks/protect-files.sh +46 -1
- package/templates/.claude/hooks/record-execution.sh +46 -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 +101 -18
- package/templates/.claude/hooks/skill-router.sh +59 -5
- package/templates/.claude/hooks/stale-spec-guard.sh +47 -2
- package/templates/.claude/hooks/task-watchdog.sh +129 -126
- package/templates/.claude/hooks/verification-guard.sh +136 -106
- package/templates/.claude/hooks/vision-router.sh +138 -18
- package/templates/.claude/settings.json +0 -5
- package/templates/.claude/ukit/index/lib/index-core.mjs +1027 -68
- 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 +48 -18
- package/templates/.claude/ukit/index/route-task.mjs +610 -4
- package/templates/.claude/ukit/index/stale-spec-check.mjs +8 -0
- package/templates/.claude/ukit/runtime/async-lock.mjs +340 -0
- package/templates/.claude/ukit/runtime/compact-threshold.mjs +73 -24
- package/templates/.claude/ukit/runtime/context-capacity.mjs +144 -0
- package/templates/.claude/ukit/runtime/execution-ledger.mjs +672 -170
- package/templates/.claude/ukit/runtime/hook-chain-budget.mjs +92 -0
- package/templates/.claude/ukit/runtime/hook-chain-runner.mjs +84 -29
- package/templates/.claude/ukit/runtime/hook-input.mjs +120 -0
- package/templates/.claude/ukit/runtime/hook-input.sh +140 -0
- package/templates/.claude/ukit/runtime/hook-payload-store.mjs +160 -0
- package/templates/.claude/ukit/runtime/hook-process.mjs +250 -0
- package/templates/.claude/ukit/runtime/hook-telemetry.mjs +255 -0
- package/templates/.claude/ukit/runtime/hook-telemetry.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/stop-coordinator.mjs +509 -0
- package/templates/.claude/ukit/runtime/task-watchdog.mjs +180 -6
- package/templates/.claude/ukit/runtime/transcript-tail.mjs +107 -0
- package/templates/.omp/hooks/pre/ukit-bridge.js +171 -57
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
|
}
|
|
@@ -4,14 +4,24 @@ import os from 'node:os';
|
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
|
|
6
6
|
// Managed defaults that keep Claude Code responsive when it is pointed at a custom
|
|
7
|
-
// gateway (any non-empty ANTHROPIC_BASE_URL).
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
7
|
+
// gateway (any non-empty ANTHROPIC_BASE_URL). Posture since 2.4.2 — "fail fast, recover
|
|
8
|
+
// via non-streaming" — based on 2026-09-16 live probes of the stall-class gateway:
|
|
9
|
+
// * The gateway buffers entire streams (23 SSE events delivered in ONE body read), so a
|
|
10
|
+
// long generation is silent until it finishes and Claude Code's stream-idle watchdog
|
|
11
|
+
// is the only thing that can end the silence. `CLAUDE_STREAM_IDLE_TIMEOUT_MS='120000'`
|
|
12
|
+
// bounds that dead air to 2 minutes instead of hanging for 10.
|
|
13
|
+
// * The non-streaming route is HEALTHY on the stall-class gateway (HTTP 200 + valid
|
|
14
|
+
// Anthropic Message), and a non-streaming POST has no idle watchdog at all — it is the
|
|
15
|
+
// one request shape that reliably survives a buffered long generation. So the fallback
|
|
16
|
+
// must stay ENABLED (`CLAUDE_CODE_DISABLE_NONSTREAMING_FALLBACK='0'`); disabling it
|
|
17
|
+
// (the 2.3.16–2.4.1 posture) removed the only recovery path and turned stalls into
|
|
18
|
+
// silent freezes the user had to interrupt by hand.
|
|
12
19
|
//
|
|
13
20
|
// These are written ONLY when no user-set value already lives in the project's
|
|
14
|
-
// `.claude/settings.json` env block — never clobber, always preserve
|
|
21
|
+
// `.claude/settings.json` env block — never clobber, always preserve — EXCEPT for values
|
|
22
|
+
// equal to `GATEWAY_RESILIENCE_ENV_LEGACY_DEFAULTS` (the shipped defaults of
|
|
23
|
+
// 2.3.16–2.4.1): those were written by UKit itself, so they migrate to the current
|
|
24
|
+
// defaults on the next install instead of being mistaken for user intent. Re-running
|
|
15
25
|
// `ukit install` is a no-op once the keys are in place.
|
|
16
26
|
//
|
|
17
27
|
// Probe order mirrors `templates/.claude/ukit/index/unic-gateway.mjs` (env → project
|
|
@@ -21,12 +31,22 @@ import path from 'node:path';
|
|
|
21
31
|
// is pointed at ANY gateway, so we apply resilience regardless of vendor.
|
|
22
32
|
|
|
23
33
|
export const GATEWAY_RESILIENCE_ENV_DEFAULTS = Object.freeze({
|
|
34
|
+
CLAUDE_CODE_DISABLE_NONSTREAMING_FALLBACK: '0',
|
|
35
|
+
CLAUDE_STREAM_IDLE_TIMEOUT_MS: '120000',
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// Values shipped as the managed defaults by 2.3.16–2.4.1. A settings env key holding one
|
|
39
|
+
// of these exact values was written by UKit, not by the user, so `applyGatewayResilienceEnv`
|
|
40
|
+
// migrates it to the current default. A user who deliberately tuned a value always uses a
|
|
41
|
+
// non-shipped number (e.g. '300000', '900000') and stays untouched.
|
|
42
|
+
export const GATEWAY_RESILIENCE_ENV_LEGACY_DEFAULTS = Object.freeze({
|
|
24
43
|
CLAUDE_CODE_DISABLE_NONSTREAMING_FALLBACK: '1',
|
|
25
44
|
CLAUDE_STREAM_IDLE_TIMEOUT_MS: '600000',
|
|
26
45
|
});
|
|
27
46
|
|
|
28
47
|
const BASE_URL_ENV_VAR = 'ANTHROPIC_BASE_URL';
|
|
29
48
|
const SETTINGS_RELATIVE_PATH = path.join('.claude', 'settings.json');
|
|
49
|
+
const PROFILE_RELATIVE_PATH = path.join('.claude', 'profile');
|
|
30
50
|
|
|
31
51
|
function readBaseUrlFromEnvValue(value) {
|
|
32
52
|
return typeof value === 'string' && value.trim().length > 0 ? value : null;
|
|
@@ -124,6 +144,7 @@ function backupPathFor(settingsPath) {
|
|
|
124
144
|
* applied: string[],
|
|
125
145
|
* unchanged: string[],
|
|
126
146
|
* skipped: string[],
|
|
147
|
+
* migrated: string[],
|
|
127
148
|
* changed: boolean,
|
|
128
149
|
* reason?: string,
|
|
129
150
|
* }>}
|
|
@@ -162,6 +183,7 @@ export async function applyGatewayResilienceEnv({
|
|
|
162
183
|
applied: [],
|
|
163
184
|
unchanged: [],
|
|
164
185
|
skipped: [],
|
|
186
|
+
migrated: [],
|
|
165
187
|
changed: false,
|
|
166
188
|
reason: `failed to read settings: ${error?.message ?? String(error)}`,
|
|
167
189
|
};
|
|
@@ -180,6 +202,7 @@ export async function applyGatewayResilienceEnv({
|
|
|
180
202
|
applied: [],
|
|
181
203
|
unchanged: [],
|
|
182
204
|
skipped: [],
|
|
205
|
+
migrated: [],
|
|
183
206
|
changed: false,
|
|
184
207
|
reason: `failed to parse settings JSON: ${error?.message ?? String(error)}`,
|
|
185
208
|
};
|
|
@@ -204,6 +227,7 @@ export async function applyGatewayResilienceEnv({
|
|
|
204
227
|
const applied = [];
|
|
205
228
|
const unchanged = [];
|
|
206
229
|
const skipped = [];
|
|
230
|
+
const migrated = [];
|
|
207
231
|
let mutated = false;
|
|
208
232
|
|
|
209
233
|
for (const [key, defaultValue] of Object.entries(GATEWAY_RESILIENCE_ENV_DEFAULTS)) {
|
|
@@ -218,6 +242,18 @@ export async function applyGatewayResilienceEnv({
|
|
|
218
242
|
unchanged.push(key);
|
|
219
243
|
continue;
|
|
220
244
|
}
|
|
245
|
+
// A value equal to a previously-shipped UKit default was written by UKit itself —
|
|
246
|
+
// migrate it to the current default instead of treating it as user intent.
|
|
247
|
+
if (
|
|
248
|
+
typeof currentValue === 'string' &&
|
|
249
|
+
currentValue === GATEWAY_RESILIENCE_ENV_LEGACY_DEFAULTS[key]
|
|
250
|
+
) {
|
|
251
|
+
settings.env[key] = defaultValue;
|
|
252
|
+
applied.push(key);
|
|
253
|
+
migrated.push(key);
|
|
254
|
+
mutated = true;
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
221
257
|
// User owns a non-default value — never touch it.
|
|
222
258
|
skipped.push(key);
|
|
223
259
|
}
|
|
@@ -228,6 +264,7 @@ export async function applyGatewayResilienceEnv({
|
|
|
228
264
|
applied,
|
|
229
265
|
unchanged,
|
|
230
266
|
skipped,
|
|
267
|
+
migrated,
|
|
231
268
|
changed: false,
|
|
232
269
|
};
|
|
233
270
|
}
|
|
@@ -243,6 +280,7 @@ export async function applyGatewayResilienceEnv({
|
|
|
243
280
|
applied,
|
|
244
281
|
unchanged,
|
|
245
282
|
skipped,
|
|
283
|
+
migrated,
|
|
246
284
|
changed: true,
|
|
247
285
|
};
|
|
248
286
|
}
|
|
@@ -271,7 +309,11 @@ export function formatGatewayResilienceReport(report) {
|
|
|
271
309
|
);
|
|
272
310
|
|
|
273
311
|
for (const key of report.applied || []) {
|
|
274
|
-
|
|
312
|
+
const legacyValue = GATEWAY_RESILIENCE_ENV_LEGACY_DEFAULTS[key];
|
|
313
|
+
const migrated = (report.migrated || []).includes(key) && legacyValue !== undefined;
|
|
314
|
+
lines.push(
|
|
315
|
+
` - ${key} = ${JSON.stringify(GATEWAY_RESILIENCE_ENV_DEFAULTS[key])}${migrated ? ` (migrated from previous UKit default ${JSON.stringify(legacyValue)})` : ' (applied)'}`,
|
|
316
|
+
);
|
|
275
317
|
}
|
|
276
318
|
for (const key of report.unchanged || []) {
|
|
277
319
|
lines.push(` - ${key} = ${JSON.stringify(GATEWAY_RESILIENCE_ENV_DEFAULTS[key])} (already set)`);
|
|
@@ -290,3 +332,90 @@ export function formatGatewayResilienceReport(report) {
|
|
|
290
332
|
|
|
291
333
|
return lines;
|
|
292
334
|
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Advisory presence scan of the per-profile settings files under
|
|
338
|
+
* `<homeDir>/.claude/profile/`. A session launched with
|
|
339
|
+
* `--settings ~/.claude/profile/<name>.json` bypasses `~/.claude/settings.json` entirely, so
|
|
340
|
+
* a managed resilience key can be absent exactly where it matters while every other check
|
|
341
|
+
* reports green. This is the delivery-gap detector for that hole.
|
|
342
|
+
*
|
|
343
|
+
* Presence-check only — never a value-check. After the documented posture flip the values
|
|
344
|
+
* are user/machine-owned (a profile may legitimately hold a legacy `'1'`/`'600000'` pair or a
|
|
345
|
+
* tuned number), so "is the key present" is the whole contract and no value is compared
|
|
346
|
+
* against `GATEWAY_RESILIENCE_ENV_DEFAULTS`.
|
|
347
|
+
*
|
|
348
|
+
* Only regular files ending in `.json` are scanned (sorted by name); decoys that live in the
|
|
349
|
+
* real profile dir — backups like `x.json.bak-20260916`, `notes.sh`, and subdirectories — are
|
|
350
|
+
* ignored, and the scan is not recursive. An absent profile directory is not an error: it
|
|
351
|
+
* resolves `{ scanned: [], compliant: true }` so a machine that never used profiles stays
|
|
352
|
+
* quiet. Never throws: an unreadable directory, unreadable file, or invalid JSON is
|
|
353
|
+
* reflected in the per-file result instead of propagating.
|
|
354
|
+
*
|
|
355
|
+
* @param {{ homeDir?: string, profileDir?: string|null, keys?: string[]|null }} [options]
|
|
356
|
+
* @returns {Promise<{ profileDir: string, scanned: Array<{ file: string, validJson: boolean, hasEnvBlock: boolean, missing: string[] }>, compliant: boolean }>}
|
|
357
|
+
*/
|
|
358
|
+
export async function scanProfileResilienceEnv({
|
|
359
|
+
homeDir = os.homedir(),
|
|
360
|
+
profileDir = null,
|
|
361
|
+
keys = null,
|
|
362
|
+
} = {}) {
|
|
363
|
+
const resolvedProfileDir = profileDir || path.join(homeDir, PROFILE_RELATIVE_PATH);
|
|
364
|
+
const managedKeys = Array.isArray(keys) && keys.length > 0
|
|
365
|
+
? keys
|
|
366
|
+
: Object.keys(GATEWAY_RESILIENCE_ENV_DEFAULTS);
|
|
367
|
+
|
|
368
|
+
const emptyResult = { profileDir: resolvedProfileDir, scanned: [], compliant: true };
|
|
369
|
+
|
|
370
|
+
let entries;
|
|
371
|
+
try {
|
|
372
|
+
entries = await fs.readdir(resolvedProfileDir, { withFileTypes: true });
|
|
373
|
+
} catch {
|
|
374
|
+
// Absent (ENOENT) or unreadable (EACCES) profile dir — nothing to warn about.
|
|
375
|
+
return emptyResult;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const jsonFiles = entries
|
|
379
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith('.json'))
|
|
380
|
+
.map((entry) => entry.name)
|
|
381
|
+
.sort();
|
|
382
|
+
|
|
383
|
+
if (jsonFiles.length === 0) {
|
|
384
|
+
return emptyResult;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const scanned = [];
|
|
388
|
+
for (const name of jsonFiles) {
|
|
389
|
+
const file = path.join(resolvedProfileDir, name);
|
|
390
|
+
let raw;
|
|
391
|
+
try {
|
|
392
|
+
raw = await fs.readFile(file, 'utf8');
|
|
393
|
+
} catch {
|
|
394
|
+
scanned.push({ file, validJson: false, hasEnvBlock: false, missing: [...managedKeys] });
|
|
395
|
+
continue;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
let parsed;
|
|
399
|
+
try {
|
|
400
|
+
parsed = JSON.parse(raw);
|
|
401
|
+
} catch {
|
|
402
|
+
scanned.push({ file, validJson: false, hasEnvBlock: false, missing: [...managedKeys] });
|
|
403
|
+
continue;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const envBlock = parsed?.env;
|
|
407
|
+
const hasEnvBlock =
|
|
408
|
+
Boolean(envBlock) && typeof envBlock === 'object' && !Array.isArray(envBlock);
|
|
409
|
+
const missing = hasEnvBlock
|
|
410
|
+
? managedKeys.filter((key) => !(key in envBlock))
|
|
411
|
+
: [...managedKeys];
|
|
412
|
+
|
|
413
|
+
scanned.push({ file, validJson: true, hasEnvBlock, missing });
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
return {
|
|
417
|
+
profileDir: resolvedProfileDir,
|
|
418
|
+
scanned,
|
|
419
|
+
compliant: scanned.every((entry) => entry.missing.length === 0),
|
|
420
|
+
};
|
|
421
|
+
}
|