opencode-anthropic-fix 0.1.31 → 0.2.0

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.
Files changed (40) hide show
  1. package/README.md +2 -0
  2. package/cli.mjs +42 -35
  3. package/index.mjs +399 -3591
  4. package/lib/config.mjs +9 -10
  5. package/lib/config.test.mjs +298 -1
  6. package/lib/diagnose.mjs +184 -0
  7. package/lib/diagnose.test.mjs +122 -0
  8. package/lib/env.mjs +19 -0
  9. package/lib/env.test.mjs +25 -0
  10. package/lib/mimicry/cache.mjs +62 -0
  11. package/lib/mimicry/cache.test.mjs +112 -0
  12. package/lib/mimicry/headers.mjs +530 -0
  13. package/lib/mimicry/headers.test.mjs +50 -0
  14. package/lib/mimicry/models.mjs +146 -0
  15. package/lib/mimicry/models.test.mjs +132 -0
  16. package/lib/mimicry/request-body.mjs +684 -0
  17. package/lib/mimicry/request-body.test.mjs +34 -0
  18. package/lib/mimicry/request-helpers.mjs +243 -0
  19. package/lib/mimicry/request-helpers.test.mjs +155 -0
  20. package/lib/mimicry/response-stream.mjs +429 -0
  21. package/lib/mimicry/response-stream.test.mjs +127 -0
  22. package/lib/mimicry/system-prompt.mjs +638 -0
  23. package/lib/mimicry/system-prompt.test.mjs +73 -0
  24. package/lib/oauth.mjs +1 -1
  25. package/lib/oauth.test.mjs +8 -4
  26. package/lib/redact.mjs +147 -0
  27. package/lib/redact.test.mjs +154 -0
  28. package/lib/refresh-lock.test.mjs +52 -0
  29. package/lib/retry/overload-loop.mjs +83 -0
  30. package/lib/retry/overload-loop.test.mjs +107 -0
  31. package/lib/session-metrics.mjs +75 -0
  32. package/lib/session-metrics.test.mjs +67 -0
  33. package/lib/storage.test.mjs +82 -0
  34. package/lib/token-economy/microcompact.mjs +35 -0
  35. package/lib/token-economy/microcompact.test.mjs +28 -0
  36. package/lib/token-economy/transforms.mjs +637 -0
  37. package/lib/token-economy/transforms.test.mjs +192 -0
  38. package/lib/tuning.mjs +70 -0
  39. package/lib/tuning.test.mjs +89 -0
  40. package/package.json +4 -1
package/lib/config.mjs CHANGED
@@ -435,18 +435,17 @@ export const DEFAULT_CONFIG = {
435
435
  /** Also inject numeric length anchors (≤25 words between tool calls, ≤100 words final). */
436
436
  length_anchors: true,
437
437
  },
438
- /** OAuth token-endpoint request fingerprint. HIGH-RISK knob — leave OFF unless
439
- * validated against a live refresh. */
438
+ /** OAuth token-endpoint request fingerprint. */
440
439
  oauth: {
441
- /** When true, the OAuth token EXCHANGE/REFRESH POSTs use Claude Code
442
- * 2.1.195's SDK-native-fetch fingerprint (`User-Agent:
440
+ /** When true (DEFAULT), the OAuth token EXCHANGE/REFRESH POSTs use Claude
441
+ * Code 2.1.195's SDK-native-fetch fingerprint (`User-Agent:
443
442
  * anthropic-sdk-typescript/0.94.0 userOAuthProvider`, `anthropic-beta:
444
- * oauth-2025-04-20`, no axios `Accept`). When false (DEFAULT), the legacy
445
- * axios fingerprint (`User-Agent: axios/1.13.6`, axios `Accept` header) is
446
- * sent byte-for-byte as before.
447
- * Default OFF: the token endpoint historically 429'd requests lacking the
448
- * axios UA, so this stays opt-in until proven against live refresh. */
449
- sdk_token_useragent: false,
443
+ * oauth-2025-04-20`, no axios `Accept`). When false, the legacy axios
444
+ * fingerprint (`User-Agent: axios/1.13.6`, axios `Accept` header) is sent
445
+ * byte-for-byte as before.
446
+ * Default ON to match upstream CC 2.1.195's userOAuthProvider. Set to
447
+ * `false` to revert to the legacy axios/1.13.6 + `Accept` fingerprint. */
448
+ sdk_token_useragent: true,
450
449
  },
451
450
  };
452
451
 
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
- import { loadConfig, DEFAULT_CONFIG, getConfigDir, getConfigPath } from "./config.mjs";
2
+ import { loadConfig, loadRawConfig, DEFAULT_CONFIG, getConfigDir, getConfigPath } from "./config.mjs";
3
3
  import { existsSync, readFileSync } from "node:fs";
4
4
 
5
5
  // Mock fs module
@@ -49,6 +49,28 @@ describe("getConfigDir", () => {
49
49
  const dir = getConfigDir();
50
50
  expect(dir.endsWith("opencode")).toBe(true);
51
51
  });
52
+
53
+ it("uses APPDATA on Windows", () => {
54
+ const originalAppData = process.env.APPDATA;
55
+ process.env.APPDATA = "C:\\Users\\test\\AppData\\Roaming";
56
+
57
+ expect(getConfigDir()).toBe("C:\\Users\\test\\AppData\\Roaming\\opencode");
58
+
59
+ if (originalAppData === undefined) delete process.env.APPDATA;
60
+ else process.env.APPDATA = originalAppData;
61
+ });
62
+
63
+ it("uses XDG_CONFIG_HOME on non-Windows platforms", () => {
64
+ const platform = vi.spyOn(process, "platform", "get").mockReturnValue("linux");
65
+ const originalXdgConfigHome = process.env.XDG_CONFIG_HOME;
66
+ process.env.XDG_CONFIG_HOME = "/tmp/custom-config";
67
+
68
+ expect(getConfigDir()).toBe("\\tmp\\custom-config\\opencode");
69
+
70
+ platform.mockRestore();
71
+ if (originalXdgConfigHome === undefined) delete process.env.XDG_CONFIG_HOME;
72
+ else process.env.XDG_CONFIG_HOME = originalXdgConfigHome;
73
+ });
52
74
  });
53
75
 
54
76
  describe("getConfigPath", () => {
@@ -70,7 +92,12 @@ describe("loadConfig", () => {
70
92
  delete process.env.OPENCODE_ANTHROPIC_EMULATE_CLAUDE_CODE_SIGNATURE;
71
93
  delete process.env.OPENCODE_ANTHROPIC_FETCH_CLAUDE_CODE_VERSION;
72
94
  delete process.env.OPENCODE_ANTHROPIC_PROMPT_COMPACTION;
95
+ delete process.env.OPENCODE_ANTHROPIC_OVERRIDE_MODEL_LIMITS;
96
+ delete process.env.OPENCODE_ANTHROPIC_CC_CREDENTIALS;
73
97
  delete process.env.OPENCODE_ANTHROPIC_ADAPTIVE_CONTEXT;
98
+ delete process.env.OPENCODE_ANTHROPIC_PROACTIVE_DISABLED;
99
+ delete process.env.OPENCODE_ANTHROPIC_ANTI_VERBOSITY;
100
+ delete process.env.OPENCODE_ANTHROPIC_LENGTH_ANCHORS;
74
101
  });
75
102
 
76
103
  afterEach(() => {
@@ -188,6 +215,206 @@ describe("loadConfig", () => {
188
215
  expect(config.token_bucket.regeneration_rate_per_minute).toBe(6);
189
216
  });
190
217
 
218
+ it("validates and merges every supported nested config section", () => {
219
+ existsSync.mockReturnValue(true);
220
+ readFileSync.mockReturnValue(
221
+ JSON.stringify({
222
+ failure_ttl_seconds: 9_000,
223
+ signature_emulation: {
224
+ enabled: false,
225
+ fetch_claude_code_version_on_startup: false,
226
+ prompt_compaction: "off",
227
+ workload: " nightly-agent ",
228
+ },
229
+ override_model_limits: { enabled: true, context: 3_000_000, output: -1 },
230
+ custom_betas: [" beta-one ", "", 42, "beta-two"],
231
+ health_score: {
232
+ initial: 80,
233
+ success_reward: 5,
234
+ rate_limit_penalty: -15,
235
+ failure_penalty: -30,
236
+ recovery_rate_per_hour: 4,
237
+ min_usable: 60,
238
+ max_score: 90,
239
+ },
240
+ token_bucket: { max_tokens: 75, regeneration_rate_per_minute: 8, initial_tokens: 70 },
241
+ toasts: { quiet: true, debounce_seconds: 12 },
242
+ headers: {
243
+ emulation_profile: " 2.1.0 ",
244
+ overrides: { "x-test": "value", ignored: 7, "": "empty-key" },
245
+ disable: [" X-REMOVE ", 10, "", "X-OTHER"],
246
+ billing_header: false,
247
+ },
248
+ idle_refresh: { enabled: false, window_minutes: 90, min_interval_minutes: 45 },
249
+ cache_policy: {
250
+ ttl: "5m",
251
+ ttl_supported: false,
252
+ boundary_marker: true,
253
+ hit_rate_warning_threshold: 2,
254
+ },
255
+ fast_mode: true,
256
+ telemetry: { emulate_minimal: true },
257
+ usage_toast: true,
258
+ cc_credential_reuse: { enabled: true, auto_detect: false, prefer_over_oauth: true },
259
+ adaptive_context: { enabled: false, escalation_threshold: 200_000, deescalation_threshold: 220_000 },
260
+ willow_mode: { enabled: false, idle_threshold_minutes: 45, cooldown_minutes: 90, min_turns_before_suggest: 5 },
261
+ token_economy: {
262
+ token_efficient_tools: true,
263
+ redact_thinking: false,
264
+ context_hint: false,
265
+ conservative: false,
266
+ debug_dump_bodies: true,
267
+ ttl_thinking_strip: false,
268
+ proactive_microcompact: false,
269
+ microcompact_percent: 20,
270
+ microcompact_keep_recent: 100,
271
+ stable_tool_ordering: false,
272
+ deferred_tool_names: ["read", 12, "grep"],
273
+ adaptive_thinking_zero_simple: false,
274
+ tool_result_dedupe: true,
275
+ fast_mode_auto: true,
276
+ trailing_summary_trim: true,
277
+ role_scoped_cache_ttl: false,
278
+ lean_system_non_main: false,
279
+ simple_system_prompt: false,
280
+ },
281
+ token_economy_strategies: {
282
+ system_prompt_tailing: true,
283
+ system_prompt_tail_turns: 0,
284
+ system_prompt_tail_max_chars: 100_000,
285
+ tool_deferral: true,
286
+ tool_description_compaction: true,
287
+ adaptive_tool_set: true,
288
+ tool_result_dedupe_session_wide: true,
289
+ haiku_rolling_summary: true,
290
+ stale_read_eviction: true,
291
+ per_tool_class_prune: true,
292
+ },
293
+ output_cap: { enabled: false, default_max_tokens: 100, escalated_max_tokens: 300_000 },
294
+ preconnect: { enabled: false, timeout_ms: 50 },
295
+ overflow_recovery: { enabled: false, safety_margin: 20_000 },
296
+ cache_break_detection: { enabled: false, alert_threshold: 1_500, adaptive_breakpoint: false },
297
+ request_classification: {
298
+ enabled: false,
299
+ background_max_service_retries: 3,
300
+ background_max_should_retries: 4,
301
+ },
302
+ token_budget: { enabled: true, default: 25_000, completion_threshold: 2 },
303
+ microcompact: { enabled: false, threshold_percent: 120 },
304
+ overload_recovery: { enabled: false, default_cooldown_ms: 500, poll_quota_on_overload: false },
305
+ account_management: { proactive_disabled: false },
306
+ anti_verbosity: { enabled: false, length_anchors: false },
307
+ oauth: { sdk_token_useragent: false },
308
+ }),
309
+ );
310
+
311
+ const config = loadConfig();
312
+
313
+ expect(config.failure_ttl_seconds).toBe(7_200);
314
+ expect(config.signature_emulation).toEqual({
315
+ enabled: false,
316
+ fetch_claude_code_version_on_startup: false,
317
+ prompt_compaction: "off",
318
+ workload: "nightly-agent",
319
+ });
320
+ expect(config.override_model_limits).toEqual({ enabled: true, context: 2_000_000, output: 0 });
321
+ expect(config.custom_betas).toEqual(["beta-one", "beta-two"]);
322
+ expect(config.health_score).toEqual({
323
+ initial: 80,
324
+ success_reward: 5,
325
+ rate_limit_penalty: -15,
326
+ failure_penalty: -30,
327
+ recovery_rate_per_hour: 4,
328
+ min_usable: 60,
329
+ max_score: 90,
330
+ });
331
+ expect(config.token_bucket).toEqual({ max_tokens: 75, regeneration_rate_per_minute: 8, initial_tokens: 70 });
332
+ expect(config.toasts).toEqual({ quiet: true, debounce_seconds: 12 });
333
+ expect(config.headers).toEqual({
334
+ emulation_profile: "2.1.0",
335
+ overrides: { "x-test": "value" },
336
+ disable: ["x-remove", "x-other"],
337
+ billing_header: false,
338
+ });
339
+ expect(config.idle_refresh).toEqual({ enabled: false, window_minutes: 90, min_interval_minutes: 45 });
340
+ expect(config.cache_policy).toEqual({
341
+ ttl: "5m",
342
+ ttl_supported: false,
343
+ boundary_marker: true,
344
+ hit_rate_warning_threshold: 1,
345
+ });
346
+ expect(config.fast_mode).toBe(true);
347
+ expect(config.telemetry.emulate_minimal).toBe(true);
348
+ expect(config.usage_toast).toBe(true);
349
+ expect(config.cc_credential_reuse).toEqual({ enabled: true, auto_detect: false, prefer_over_oauth: true });
350
+ expect(config.adaptive_context).toEqual({
351
+ enabled: false,
352
+ escalation_threshold: 200_000,
353
+ deescalation_threshold: 150_000,
354
+ });
355
+ expect(config.willow_mode).toEqual({
356
+ enabled: false,
357
+ idle_threshold_minutes: 45,
358
+ cooldown_minutes: 90,
359
+ min_turns_before_suggest: 5,
360
+ });
361
+ expect(config.token_economy).toEqual({
362
+ token_efficient_tools: true,
363
+ redact_thinking: false,
364
+ context_hint: false,
365
+ conservative: false,
366
+ debug_dump_bodies: true,
367
+ ttl_thinking_strip: false,
368
+ proactive_microcompact: false,
369
+ microcompact_percent: 30,
370
+ microcompact_keep_recent: 64,
371
+ stable_tool_ordering: false,
372
+ deferred_tool_names: ["read", "grep"],
373
+ adaptive_thinking_zero_simple: false,
374
+ tool_result_dedupe: true,
375
+ fast_mode_auto: true,
376
+ trailing_summary_trim: true,
377
+ role_scoped_cache_ttl: false,
378
+ lean_system_non_main: false,
379
+ simple_system_prompt: false,
380
+ });
381
+ expect(config.token_economy_strategies).toEqual({
382
+ system_prompt_tailing: true,
383
+ system_prompt_tail_turns: 1,
384
+ system_prompt_tail_max_chars: 50_000,
385
+ tool_deferral: true,
386
+ tool_description_compaction: true,
387
+ adaptive_tool_set: true,
388
+ tool_result_dedupe_session_wide: true,
389
+ haiku_rolling_summary: true,
390
+ stale_read_eviction: true,
391
+ per_tool_class_prune: true,
392
+ });
393
+ expect(config.output_cap).toEqual({ enabled: false, default_max_tokens: 256, escalated_max_tokens: 200_000 });
394
+ expect(config.preconnect).toEqual({ enabled: false, timeout_ms: 100 });
395
+ expect(config.overflow_recovery).toEqual({ enabled: false, safety_margin: 10_000 });
396
+ expect(config.cache_break_detection).toEqual({
397
+ enabled: false,
398
+ alert_threshold: 1_500,
399
+ adaptive_breakpoint: false,
400
+ });
401
+ expect(config.request_classification).toEqual({
402
+ enabled: false,
403
+ background_max_service_retries: 3,
404
+ background_max_should_retries: 4,
405
+ });
406
+ expect(config.token_budget).toEqual({ enabled: true, default: 25_000, completion_threshold: 1 });
407
+ expect(config.microcompact).toEqual({ enabled: false, threshold_percent: 100 });
408
+ expect(config.overload_recovery).toEqual({
409
+ enabled: false,
410
+ default_cooldown_ms: 1_000,
411
+ poll_quota_on_overload: false,
412
+ });
413
+ expect(config.account_management.proactive_disabled).toBe(false);
414
+ expect(config.anti_verbosity).toEqual({ enabled: false, length_anchors: false });
415
+ expect(config.oauth.sdk_token_useragent).toBe(false);
416
+ });
417
+
191
418
  // Environment variable overrides
192
419
  it("overrides strategy from OPENCODE_ANTHROPIC_STRATEGY", () => {
193
420
  existsSync.mockReturnValue(false);
@@ -378,6 +605,76 @@ describe("loadConfig", () => {
378
605
  const config = loadConfig();
379
606
  expect(config.adaptive_context.enabled).toBe(false);
380
607
  });
608
+
609
+ it("applies true-form environment overrides for feature switches", () => {
610
+ existsSync.mockReturnValue(true);
611
+ readFileSync.mockReturnValue(
612
+ JSON.stringify({
613
+ signature_emulation: { enabled: false, fetch_claude_code_version_on_startup: false, prompt_compaction: "off" },
614
+ override_model_limits: { enabled: false },
615
+ cc_credential_reuse: { enabled: false },
616
+ account_management: { proactive_disabled: false },
617
+ anti_verbosity: { enabled: false },
618
+ }),
619
+ );
620
+ process.env.OPENCODE_ANTHROPIC_EMULATE_CLAUDE_CODE_SIGNATURE = "true";
621
+ process.env.OPENCODE_ANTHROPIC_FETCH_CLAUDE_CODE_VERSION = "true";
622
+ process.env.OPENCODE_ANTHROPIC_PROMPT_COMPACTION = "minimal";
623
+ process.env.OPENCODE_ANTHROPIC_OVERRIDE_MODEL_LIMITS = "true";
624
+ process.env.OPENCODE_ANTHROPIC_CC_CREDENTIALS = "true";
625
+ process.env.OPENCODE_ANTHROPIC_PROACTIVE_DISABLED = "true";
626
+ process.env.OPENCODE_ANTHROPIC_ANTI_VERBOSITY = "true";
627
+
628
+ const config = loadConfig();
629
+
630
+ expect(config.signature_emulation).toMatchObject({
631
+ enabled: true,
632
+ fetch_claude_code_version_on_startup: true,
633
+ prompt_compaction: "minimal",
634
+ });
635
+ expect(config.override_model_limits.enabled).toBe(true);
636
+ expect(config.cc_credential_reuse.enabled).toBe(true);
637
+ expect(config.account_management.proactive_disabled).toBe(true);
638
+ expect(config.anti_verbosity.enabled).toBe(true);
639
+ });
640
+
641
+ it("applies false-form environment overrides for feature switches", () => {
642
+ existsSync.mockReturnValue(false);
643
+ process.env.OPENCODE_ANTHROPIC_OVERRIDE_MODEL_LIMITS = "false";
644
+ process.env.OPENCODE_ANTHROPIC_CC_CREDENTIALS = "false";
645
+ process.env.OPENCODE_ANTHROPIC_PROACTIVE_DISABLED = "false";
646
+ process.env.OPENCODE_ANTHROPIC_ANTI_VERBOSITY = "false";
647
+ process.env.OPENCODE_ANTHROPIC_LENGTH_ANCHORS = "false";
648
+
649
+ const config = loadConfig();
650
+
651
+ expect(config.override_model_limits.enabled).toBe(false);
652
+ expect(config.cc_credential_reuse.enabled).toBe(false);
653
+ expect(config.account_management.proactive_disabled).toBe(false);
654
+ expect(config.anti_verbosity).toEqual({ enabled: false, length_anchors: false });
655
+ });
656
+ });
657
+
658
+ describe("loadRawConfig", () => {
659
+ beforeEach(() => {
660
+ vi.resetAllMocks();
661
+ });
662
+
663
+ it("returns parsed values without applying validation", () => {
664
+ existsSync.mockReturnValue(true);
665
+ readFileSync.mockReturnValue(JSON.stringify({ failure_ttl_seconds: -10, custom_key: "preserved" }));
666
+
667
+ expect(loadRawConfig()).toEqual({ failure_ttl_seconds: -10, custom_key: "preserved" });
668
+ });
669
+
670
+ it("returns an empty object for missing, malformed, or non-object config", () => {
671
+ existsSync.mockReturnValueOnce(false).mockReturnValue(true);
672
+ readFileSync.mockReturnValueOnce("invalid json").mockReturnValueOnce("[]");
673
+
674
+ expect(loadRawConfig()).toEqual({});
675
+ expect(loadRawConfig()).toEqual({});
676
+ expect(loadRawConfig()).toEqual({});
677
+ });
381
678
  });
382
679
 
383
680
  describe("token_economy_strategies.haiku_rolling_summary", () => {
@@ -0,0 +1,184 @@
1
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ import { getConfigDir, loadConfig } from "./config.mjs";
7
+ import { redactSecrets } from "./redact.mjs";
8
+ import { FALLBACK_CLAUDE_CLI_VERSION } from "./request-headers.mjs";
9
+ import { loadAccounts } from "./storage.mjs";
10
+
11
+ const ENV_KEYS = [
12
+ "OPENCODE_ANTHROPIC_STRATEGY",
13
+ "OPENCODE_ANTHROPIC_DEBUG",
14
+ "OPENCODE_ANTHROPIC_MAX_BUDGET_USD",
15
+ "INITIAL_ACCOUNT",
16
+ "TELEMETRY_EMULATE",
17
+ "IGNORE_BUDGET",
18
+ "SIGNATURE_USER_ID",
19
+ "DISABLE_ADAPTIVE_THINKING",
20
+ "DISABLE_FAST_MODE",
21
+ "STREAM_IDLE_TIMEOUT_MS",
22
+ "CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS",
23
+ "DISABLE_1M_CONTEXT",
24
+ "ORGANIZATION_UUID",
25
+ "ENTRYPOINT",
26
+ "USE_BEDROCK",
27
+ "USE_FOUNDRY",
28
+ "USE_ANTHROPIC_AWS",
29
+ "USE_MANTLE",
30
+ "USE_VERTEX",
31
+ "ACCOUNT_UUID",
32
+ "EXTRA_METADATA",
33
+ "ATTRIBUTION_HEADER",
34
+ "WORKLOAD",
35
+ "FORCE_GLOBAL_CACHE",
36
+ "BACKGROUND",
37
+ "CONTAINER_ID",
38
+ "REMOTE_SESSION_ID",
39
+ "ADDITIONAL_PROTECTION",
40
+ ];
41
+
42
+ function getPluginVersion() {
43
+ try {
44
+ const packagePath = fileURLToPath(new URL("../package.json", import.meta.url));
45
+ const packageJson = JSON.parse(readFileSync(packagePath, "utf8"));
46
+ return typeof packageJson.version === "string" ? packageJson.version : "unknown";
47
+ } catch {
48
+ return "unknown";
49
+ }
50
+ }
51
+
52
+ function getRuntimeEnv() {
53
+ const env = {};
54
+ for (const key of ENV_KEYS) {
55
+ if (process.env[key] !== undefined) env[key] = process.env[key];
56
+ }
57
+ return redactSecrets(env);
58
+ }
59
+
60
+ async function getAccountSummary() {
61
+ try {
62
+ const stored = await loadAccounts();
63
+ if (!stored || !Array.isArray(stored.accounts)) return { count: 0, items: [] };
64
+
65
+ const items = stored.accounts.map((account, index) => {
66
+ const item = { index };
67
+ for (const key of ["email", "disabled", "enabled", "consecutiveFailures", "rateLimitResetTimes"]) {
68
+ if (Object.hasOwn(account, key)) item[key] = account[key];
69
+ }
70
+ return item;
71
+ });
72
+
73
+ return redactSecrets({ count: items.length, items });
74
+ } catch {
75
+ return { count: 0, items: [] };
76
+ }
77
+ }
78
+
79
+ function sortedArtifactFiles(directory, prefix, extension) {
80
+ try {
81
+ if (!existsSync(directory)) return [];
82
+ return readdirSync(directory)
83
+ .filter((file) => file.startsWith(prefix) && file.endsWith(extension))
84
+ .sort()
85
+ .slice(-10);
86
+ } catch {
87
+ return [];
88
+ }
89
+ }
90
+
91
+ function requestDumpMetadata(directory) {
92
+ return sortedArtifactFiles(directory, "req-", ".json").map((file) => {
93
+ try {
94
+ const filePath = path.join(directory, file);
95
+ const envelope = JSON.parse(readFileSync(filePath, "utf8"));
96
+ return {
97
+ file,
98
+ correlationId: envelope.correlationId,
99
+ timestamp: envelope.timestamp,
100
+ bytes: statSync(filePath).size,
101
+ };
102
+ } catch {
103
+ return { file, error: "unparseable" };
104
+ }
105
+ });
106
+ }
107
+
108
+ function correlationIdFromResponseFile(file) {
109
+ const stem = file.slice("res-".length, -".sse".length);
110
+ const isoTimestamp = /^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z-(.+)$/i.exec(stem);
111
+ if (isoTimestamp) return isoTimestamp[1];
112
+
113
+ const numericTimestamp = /^\d{10,}-(.+)$/.exec(stem);
114
+ if (numericTimestamp) return numericTimestamp[1];
115
+
116
+ const uuid = /([0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12})$/i.exec(stem);
117
+ return uuid?.[1] ?? stem;
118
+ }
119
+
120
+ function responseDumpMetadata(directory) {
121
+ const items = [];
122
+ for (const file of sortedArtifactFiles(directory, "res-", ".sse")) {
123
+ try {
124
+ items.push({
125
+ file,
126
+ correlationId: correlationIdFromResponseFile(file),
127
+ bytes: statSync(path.join(directory, file)).size,
128
+ });
129
+ } catch {
130
+ // A file can disappear between directory enumeration and stat; omit it rather than abort diagnostics.
131
+ }
132
+ }
133
+ return items;
134
+ }
135
+
136
+ function debugHeadersMetadata() {
137
+ try {
138
+ const filePath = path.join(getConfigDir(), "debug-headers.log");
139
+ if (!existsSync(filePath)) return { exists: false, sizeBytes: 0 };
140
+ return { exists: true, sizeBytes: statSync(filePath).size };
141
+ } catch {
142
+ return { exists: false, sizeBytes: 0 };
143
+ }
144
+ }
145
+
146
+ function getArtifactSummary() {
147
+ try {
148
+ const dumpsDirectory = path.join(os.homedir(), ".opencode", "opencode-anthropic-fix", "request-dumps");
149
+ return {
150
+ requestDumps: requestDumpMetadata(dumpsDirectory),
151
+ responseDumps: responseDumpMetadata(dumpsDirectory),
152
+ debugHeadersLog: debugHeadersMetadata(),
153
+ };
154
+ } catch {
155
+ return {
156
+ requestDumps: [],
157
+ responseDumps: [],
158
+ debugHeadersLog: { exists: false, sizeBytes: 0 },
159
+ };
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Build a shareable diagnostic snapshot without exposing credentials.
165
+ *
166
+ * @returns {Promise<object>}
167
+ */
168
+ export async function buildDiagnosticBundle() {
169
+ const bundle = {
170
+ meta: {
171
+ generatedAt: new Date().toISOString(),
172
+ pluginVersion: getPluginVersion(),
173
+ nodeVersion: process.version,
174
+ platform: process.platform,
175
+ mimicryBaseline: FALLBACK_CLAUDE_CLI_VERSION,
176
+ },
177
+ config: redactSecrets(loadConfig()),
178
+ env: getRuntimeEnv(),
179
+ accounts: await getAccountSummary(),
180
+ artifacts: getArtifactSummary(),
181
+ };
182
+
183
+ return redactSecrets(bundle);
184
+ }
@@ -0,0 +1,122 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ const state = vi.hoisted(() => ({
4
+ accounts: null,
5
+ config: {},
6
+ configDir: "/diagnose/config",
7
+ debugHeadersExists: false,
8
+ directoryExists: false,
9
+ entries: [],
10
+ fileContents: new Map(),
11
+ home: "/diagnose/home",
12
+ }));
13
+
14
+ vi.mock("node:fs", () => ({
15
+ existsSync: vi.fn((filePath) => {
16
+ const value = String(filePath);
17
+ if (value.endsWith("debug-headers.log")) return state.debugHeadersExists;
18
+ if (value.endsWith("request-dumps")) return state.directoryExists;
19
+ return false;
20
+ }),
21
+ readFileSync: vi.fn((filePath) => {
22
+ const value = String(filePath);
23
+ if (value.endsWith("package.json")) return JSON.stringify({ version: "1.2.3-test" });
24
+ const file = value.split(/[\\/]/).at(-1);
25
+ if (!state.fileContents.has(file)) throw new Error(`Missing fixture: ${file}`);
26
+ return state.fileContents.get(file);
27
+ }),
28
+ readdirSync: vi.fn(() => [...state.entries]),
29
+ statSync: vi.fn((filePath) => {
30
+ const file = String(filePath).split(/[\\/]/).at(-1);
31
+ const contents = state.fileContents.get(file) ?? "";
32
+ return { size: Buffer.byteLength(contents) };
33
+ }),
34
+ }));
35
+
36
+ vi.mock("node:os", () => ({
37
+ default: { homedir: vi.fn(() => state.home) },
38
+ }));
39
+
40
+ vi.mock("./config.mjs", () => ({
41
+ getConfigDir: vi.fn(() => state.configDir),
42
+ loadConfig: vi.fn(() => state.config),
43
+ }));
44
+
45
+ vi.mock("./storage.mjs", () => ({
46
+ loadAccounts: vi.fn(async () => state.accounts),
47
+ }));
48
+
49
+ import { buildDiagnosticBundle } from "./diagnose.mjs";
50
+
51
+ describe("buildDiagnosticBundle", () => {
52
+ beforeEach(() => {
53
+ state.accounts = null;
54
+ state.config = {};
55
+ state.debugHeadersExists = false;
56
+ state.directoryExists = false;
57
+ state.entries = [];
58
+ state.fileContents.clear();
59
+ });
60
+
61
+ it("returns all sections without leaking seeded secrets", async () => {
62
+ const bearer = "Bearer diagnoseBearerToken123";
63
+ const apiKey = "sk-ant-diagnoseSecret123";
64
+ const oauthToken = "oat01DiagnoseSecret123";
65
+ const refreshToken = "literal-refresh-secret";
66
+ const access = "literal-access-secret";
67
+
68
+ state.config = {
69
+ authorization: bearer,
70
+ apiKey,
71
+ metadata: oauthToken,
72
+ refreshToken,
73
+ };
74
+ state.accounts = {
75
+ accounts: [
76
+ {
77
+ email: "alice@example.com",
78
+ enabled: true,
79
+ consecutiveFailures: 2,
80
+ rateLimitResetTimes: { primary: 1234 },
81
+ access,
82
+ refreshToken,
83
+ },
84
+ ],
85
+ };
86
+
87
+ const bundle = await buildDiagnosticBundle();
88
+ const serialized = JSON.stringify(bundle);
89
+
90
+ expect(Object.keys(bundle)).toEqual(["meta", "config", "env", "accounts", "artifacts"]);
91
+ expect(serialized).not.toMatch(/Bearer\s+[A-Za-z0-9]/);
92
+ expect(serialized).not.toMatch(/sk-ant-[A-Za-z0-9]{4,}/);
93
+ expect(serialized).not.toMatch(/\boat01[A-Za-z0-9]{8,}/);
94
+ for (const secret of [bearer, apiKey, oauthToken, refreshToken, access]) {
95
+ expect(serialized).not.toContain(secret);
96
+ }
97
+ expect(bundle.accounts.items[0].email).toBe("a***@example.com");
98
+ expect(bundle.accounts.items[0]).not.toHaveProperty("access");
99
+ expect(bundle.accounts.items[0]).not.toHaveProperty("refreshToken");
100
+ });
101
+
102
+ it("degrades gracefully when accounts and artifact directories are absent", async () => {
103
+ const bundle = await buildDiagnosticBundle();
104
+
105
+ expect(bundle.accounts).toEqual({ count: 0, items: [] });
106
+ expect(bundle.artifacts.requestDumps).toEqual([]);
107
+ expect(bundle.artifacts.responseDumps).toEqual([]);
108
+ expect(bundle.artifacts.debugHeadersLog).toEqual({ exists: false, sizeBytes: 0 });
109
+ });
110
+
111
+ it("records a corrupted request dump and continues", async () => {
112
+ const corruptFile = "req-2026-04-10T12-00-00-000Z-correlation.json";
113
+ state.directoryExists = true;
114
+ state.entries = [corruptFile];
115
+ state.fileContents.set(corruptFile, "{not-json");
116
+
117
+ const bundle = await buildDiagnosticBundle();
118
+
119
+ expect(bundle.artifacts.requestDumps).toEqual([{ file: corruptFile, error: "unparseable" }]);
120
+ expect(bundle.artifacts.responseDumps).toEqual([]);
121
+ });
122
+ });
package/lib/env.mjs ADDED
@@ -0,0 +1,19 @@
1
+ /**
2
+ * @param {string | undefined} value
3
+ * @returns {boolean}
4
+ */
5
+ export function isTruthyEnv(value) {
6
+ if (!value) return false;
7
+ const normalized = value.trim().toLowerCase();
8
+ return normalized === "1" || normalized === "true" || normalized === "yes";
9
+ }
10
+
11
+ /**
12
+ * @param {string | undefined} value
13
+ * @returns {boolean}
14
+ */
15
+ export function isFalsyEnv(value) {
16
+ if (!value) return false;
17
+ const normalized = value.trim().toLowerCase();
18
+ return normalized === "0" || normalized === "false" || normalized === "no";
19
+ }