opencode-anthropic-fix 0.1.32 → 0.2.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.
Files changed (42) hide show
  1. package/README.md +9 -6
  2. package/cli.mjs +43 -35
  3. package/index.mjs +377 -3646
  4. package/lib/backoff.mjs +1 -1
  5. package/lib/cc-credentials.mjs +1 -1
  6. package/lib/config.mjs +2 -2
  7. package/lib/config.test.mjs +305 -1
  8. package/lib/diagnose.mjs +184 -0
  9. package/lib/diagnose.test.mjs +122 -0
  10. package/lib/env.mjs +19 -0
  11. package/lib/env.test.mjs +25 -0
  12. package/lib/mimicry/cache.mjs +62 -0
  13. package/lib/mimicry/cache.test.mjs +112 -0
  14. package/lib/mimicry/headers.mjs +528 -0
  15. package/lib/mimicry/headers.test.mjs +50 -0
  16. package/lib/mimicry/models.mjs +146 -0
  17. package/lib/mimicry/models.test.mjs +132 -0
  18. package/lib/mimicry/request-body.mjs +682 -0
  19. package/lib/mimicry/request-body.test.mjs +34 -0
  20. package/lib/mimicry/request-helpers.mjs +243 -0
  21. package/lib/mimicry/request-helpers.test.mjs +155 -0
  22. package/lib/mimicry/response-stream.mjs +429 -0
  23. package/lib/mimicry/response-stream.test.mjs +127 -0
  24. package/lib/mimicry/system-prompt.mjs +638 -0
  25. package/lib/mimicry/system-prompt.test.mjs +73 -0
  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/rotation.mjs +16 -0
  32. package/lib/rotation.test.mjs +46 -0
  33. package/lib/session-metrics.mjs +75 -0
  34. package/lib/session-metrics.test.mjs +67 -0
  35. package/lib/storage.test.mjs +82 -0
  36. package/lib/token-economy/microcompact.mjs +35 -0
  37. package/lib/token-economy/microcompact.test.mjs +28 -0
  38. package/lib/token-economy/transforms.mjs +637 -0
  39. package/lib/token-economy/transforms.test.mjs +192 -0
  40. package/lib/tuning.mjs +70 -0
  41. package/lib/tuning.test.mjs +89 -0
  42. package/package.json +4 -1
package/lib/backoff.mjs CHANGED
@@ -134,7 +134,7 @@ function extractErrorSignals(body) {
134
134
  * @returns {boolean}
135
135
  */
136
136
  function bodyHasAccountError(body) {
137
- const { errorType, message, text } = extractErrorSignals(body);
137
+ const { errorType, message } = extractErrorSignals(body);
138
138
 
139
139
  const typeSignals = [
140
140
  "rate_limit",
@@ -84,7 +84,7 @@ function readKeychainService(service) {
84
84
  }).trim();
85
85
 
86
86
  return parseCCCredentialData(raw, "cc-keychain", `keychain:${service}`);
87
- } catch (err) {
87
+ } catch {
88
88
  // Exit code 36 = user denied, 44 = not found, 128 = killed
89
89
  return null;
90
90
  }
package/lib/config.mjs CHANGED
@@ -4,7 +4,7 @@ import { homedir } from "node:os";
4
4
  import { randomBytes } from "node:crypto";
5
5
 
6
6
  /**
7
- * @typedef {'sticky' | 'round-robin' | 'hybrid'} AccountSelectionStrategy
7
+ * @typedef {'sticky' | 'round-robin' | 'hybrid' | 'single'} AccountSelectionStrategy
8
8
  */
9
9
 
10
10
  /**
@@ -449,7 +449,7 @@ export const DEFAULT_CONFIG = {
449
449
  },
450
450
  };
451
451
 
452
- export const VALID_STRATEGIES = ["sticky", "round-robin", "hybrid"];
452
+ export const VALID_STRATEGIES = ["sticky", "round-robin", "hybrid", "single"];
453
453
 
454
454
  /** OpenCode's OAuth client ID for Anthropic console auth flows. */
455
455
  export const CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
@@ -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(() => {
@@ -111,6 +138,13 @@ describe("loadConfig", () => {
111
138
  expect(config.account_selection_strategy).toBe("sticky");
112
139
  });
113
140
 
141
+ it("accepts 'single' strategy from config file", () => {
142
+ existsSync.mockReturnValue(true);
143
+ readFileSync.mockReturnValue(JSON.stringify({ account_selection_strategy: "single" }));
144
+ const config = loadConfig();
145
+ expect(config.account_selection_strategy).toBe("single");
146
+ });
147
+
114
148
  it("ignores invalid strategy", () => {
115
149
  existsSync.mockReturnValue(true);
116
150
  readFileSync.mockReturnValue(JSON.stringify({ account_selection_strategy: "invalid" }));
@@ -188,6 +222,206 @@ describe("loadConfig", () => {
188
222
  expect(config.token_bucket.regeneration_rate_per_minute).toBe(6);
189
223
  });
190
224
 
225
+ it("validates and merges every supported nested config section", () => {
226
+ existsSync.mockReturnValue(true);
227
+ readFileSync.mockReturnValue(
228
+ JSON.stringify({
229
+ failure_ttl_seconds: 9_000,
230
+ signature_emulation: {
231
+ enabled: false,
232
+ fetch_claude_code_version_on_startup: false,
233
+ prompt_compaction: "off",
234
+ workload: " nightly-agent ",
235
+ },
236
+ override_model_limits: { enabled: true, context: 3_000_000, output: -1 },
237
+ custom_betas: [" beta-one ", "", 42, "beta-two"],
238
+ health_score: {
239
+ initial: 80,
240
+ success_reward: 5,
241
+ rate_limit_penalty: -15,
242
+ failure_penalty: -30,
243
+ recovery_rate_per_hour: 4,
244
+ min_usable: 60,
245
+ max_score: 90,
246
+ },
247
+ token_bucket: { max_tokens: 75, regeneration_rate_per_minute: 8, initial_tokens: 70 },
248
+ toasts: { quiet: true, debounce_seconds: 12 },
249
+ headers: {
250
+ emulation_profile: " 2.1.0 ",
251
+ overrides: { "x-test": "value", ignored: 7, "": "empty-key" },
252
+ disable: [" X-REMOVE ", 10, "", "X-OTHER"],
253
+ billing_header: false,
254
+ },
255
+ idle_refresh: { enabled: false, window_minutes: 90, min_interval_minutes: 45 },
256
+ cache_policy: {
257
+ ttl: "5m",
258
+ ttl_supported: false,
259
+ boundary_marker: true,
260
+ hit_rate_warning_threshold: 2,
261
+ },
262
+ fast_mode: true,
263
+ telemetry: { emulate_minimal: true },
264
+ usage_toast: true,
265
+ cc_credential_reuse: { enabled: true, auto_detect: false, prefer_over_oauth: true },
266
+ adaptive_context: { enabled: false, escalation_threshold: 200_000, deescalation_threshold: 220_000 },
267
+ willow_mode: { enabled: false, idle_threshold_minutes: 45, cooldown_minutes: 90, min_turns_before_suggest: 5 },
268
+ token_economy: {
269
+ token_efficient_tools: true,
270
+ redact_thinking: false,
271
+ context_hint: false,
272
+ conservative: false,
273
+ debug_dump_bodies: true,
274
+ ttl_thinking_strip: false,
275
+ proactive_microcompact: false,
276
+ microcompact_percent: 20,
277
+ microcompact_keep_recent: 100,
278
+ stable_tool_ordering: false,
279
+ deferred_tool_names: ["read", 12, "grep"],
280
+ adaptive_thinking_zero_simple: false,
281
+ tool_result_dedupe: true,
282
+ fast_mode_auto: true,
283
+ trailing_summary_trim: true,
284
+ role_scoped_cache_ttl: false,
285
+ lean_system_non_main: false,
286
+ simple_system_prompt: false,
287
+ },
288
+ token_economy_strategies: {
289
+ system_prompt_tailing: true,
290
+ system_prompt_tail_turns: 0,
291
+ system_prompt_tail_max_chars: 100_000,
292
+ tool_deferral: true,
293
+ tool_description_compaction: true,
294
+ adaptive_tool_set: true,
295
+ tool_result_dedupe_session_wide: true,
296
+ haiku_rolling_summary: true,
297
+ stale_read_eviction: true,
298
+ per_tool_class_prune: true,
299
+ },
300
+ output_cap: { enabled: false, default_max_tokens: 100, escalated_max_tokens: 300_000 },
301
+ preconnect: { enabled: false, timeout_ms: 50 },
302
+ overflow_recovery: { enabled: false, safety_margin: 20_000 },
303
+ cache_break_detection: { enabled: false, alert_threshold: 1_500, adaptive_breakpoint: false },
304
+ request_classification: {
305
+ enabled: false,
306
+ background_max_service_retries: 3,
307
+ background_max_should_retries: 4,
308
+ },
309
+ token_budget: { enabled: true, default: 25_000, completion_threshold: 2 },
310
+ microcompact: { enabled: false, threshold_percent: 120 },
311
+ overload_recovery: { enabled: false, default_cooldown_ms: 500, poll_quota_on_overload: false },
312
+ account_management: { proactive_disabled: false },
313
+ anti_verbosity: { enabled: false, length_anchors: false },
314
+ oauth: { sdk_token_useragent: false },
315
+ }),
316
+ );
317
+
318
+ const config = loadConfig();
319
+
320
+ expect(config.failure_ttl_seconds).toBe(7_200);
321
+ expect(config.signature_emulation).toEqual({
322
+ enabled: false,
323
+ fetch_claude_code_version_on_startup: false,
324
+ prompt_compaction: "off",
325
+ workload: "nightly-agent",
326
+ });
327
+ expect(config.override_model_limits).toEqual({ enabled: true, context: 2_000_000, output: 0 });
328
+ expect(config.custom_betas).toEqual(["beta-one", "beta-two"]);
329
+ expect(config.health_score).toEqual({
330
+ initial: 80,
331
+ success_reward: 5,
332
+ rate_limit_penalty: -15,
333
+ failure_penalty: -30,
334
+ recovery_rate_per_hour: 4,
335
+ min_usable: 60,
336
+ max_score: 90,
337
+ });
338
+ expect(config.token_bucket).toEqual({ max_tokens: 75, regeneration_rate_per_minute: 8, initial_tokens: 70 });
339
+ expect(config.toasts).toEqual({ quiet: true, debounce_seconds: 12 });
340
+ expect(config.headers).toEqual({
341
+ emulation_profile: "2.1.0",
342
+ overrides: { "x-test": "value" },
343
+ disable: ["x-remove", "x-other"],
344
+ billing_header: false,
345
+ });
346
+ expect(config.idle_refresh).toEqual({ enabled: false, window_minutes: 90, min_interval_minutes: 45 });
347
+ expect(config.cache_policy).toEqual({
348
+ ttl: "5m",
349
+ ttl_supported: false,
350
+ boundary_marker: true,
351
+ hit_rate_warning_threshold: 1,
352
+ });
353
+ expect(config.fast_mode).toBe(true);
354
+ expect(config.telemetry.emulate_minimal).toBe(true);
355
+ expect(config.usage_toast).toBe(true);
356
+ expect(config.cc_credential_reuse).toEqual({ enabled: true, auto_detect: false, prefer_over_oauth: true });
357
+ expect(config.adaptive_context).toEqual({
358
+ enabled: false,
359
+ escalation_threshold: 200_000,
360
+ deescalation_threshold: 150_000,
361
+ });
362
+ expect(config.willow_mode).toEqual({
363
+ enabled: false,
364
+ idle_threshold_minutes: 45,
365
+ cooldown_minutes: 90,
366
+ min_turns_before_suggest: 5,
367
+ });
368
+ expect(config.token_economy).toEqual({
369
+ token_efficient_tools: true,
370
+ redact_thinking: false,
371
+ context_hint: false,
372
+ conservative: false,
373
+ debug_dump_bodies: true,
374
+ ttl_thinking_strip: false,
375
+ proactive_microcompact: false,
376
+ microcompact_percent: 30,
377
+ microcompact_keep_recent: 64,
378
+ stable_tool_ordering: false,
379
+ deferred_tool_names: ["read", "grep"],
380
+ adaptive_thinking_zero_simple: false,
381
+ tool_result_dedupe: true,
382
+ fast_mode_auto: true,
383
+ trailing_summary_trim: true,
384
+ role_scoped_cache_ttl: false,
385
+ lean_system_non_main: false,
386
+ simple_system_prompt: false,
387
+ });
388
+ expect(config.token_economy_strategies).toEqual({
389
+ system_prompt_tailing: true,
390
+ system_prompt_tail_turns: 1,
391
+ system_prompt_tail_max_chars: 50_000,
392
+ tool_deferral: true,
393
+ tool_description_compaction: true,
394
+ adaptive_tool_set: true,
395
+ tool_result_dedupe_session_wide: true,
396
+ haiku_rolling_summary: true,
397
+ stale_read_eviction: true,
398
+ per_tool_class_prune: true,
399
+ });
400
+ expect(config.output_cap).toEqual({ enabled: false, default_max_tokens: 256, escalated_max_tokens: 200_000 });
401
+ expect(config.preconnect).toEqual({ enabled: false, timeout_ms: 100 });
402
+ expect(config.overflow_recovery).toEqual({ enabled: false, safety_margin: 10_000 });
403
+ expect(config.cache_break_detection).toEqual({
404
+ enabled: false,
405
+ alert_threshold: 1_500,
406
+ adaptive_breakpoint: false,
407
+ });
408
+ expect(config.request_classification).toEqual({
409
+ enabled: false,
410
+ background_max_service_retries: 3,
411
+ background_max_should_retries: 4,
412
+ });
413
+ expect(config.token_budget).toEqual({ enabled: true, default: 25_000, completion_threshold: 1 });
414
+ expect(config.microcompact).toEqual({ enabled: false, threshold_percent: 100 });
415
+ expect(config.overload_recovery).toEqual({
416
+ enabled: false,
417
+ default_cooldown_ms: 1_000,
418
+ poll_quota_on_overload: false,
419
+ });
420
+ expect(config.account_management.proactive_disabled).toBe(false);
421
+ expect(config.anti_verbosity).toEqual({ enabled: false, length_anchors: false });
422
+ expect(config.oauth.sdk_token_useragent).toBe(false);
423
+ });
424
+
191
425
  // Environment variable overrides
192
426
  it("overrides strategy from OPENCODE_ANTHROPIC_STRATEGY", () => {
193
427
  existsSync.mockReturnValue(false);
@@ -378,6 +612,76 @@ describe("loadConfig", () => {
378
612
  const config = loadConfig();
379
613
  expect(config.adaptive_context.enabled).toBe(false);
380
614
  });
615
+
616
+ it("applies true-form environment overrides for feature switches", () => {
617
+ existsSync.mockReturnValue(true);
618
+ readFileSync.mockReturnValue(
619
+ JSON.stringify({
620
+ signature_emulation: { enabled: false, fetch_claude_code_version_on_startup: false, prompt_compaction: "off" },
621
+ override_model_limits: { enabled: false },
622
+ cc_credential_reuse: { enabled: false },
623
+ account_management: { proactive_disabled: false },
624
+ anti_verbosity: { enabled: false },
625
+ }),
626
+ );
627
+ process.env.OPENCODE_ANTHROPIC_EMULATE_CLAUDE_CODE_SIGNATURE = "true";
628
+ process.env.OPENCODE_ANTHROPIC_FETCH_CLAUDE_CODE_VERSION = "true";
629
+ process.env.OPENCODE_ANTHROPIC_PROMPT_COMPACTION = "minimal";
630
+ process.env.OPENCODE_ANTHROPIC_OVERRIDE_MODEL_LIMITS = "true";
631
+ process.env.OPENCODE_ANTHROPIC_CC_CREDENTIALS = "true";
632
+ process.env.OPENCODE_ANTHROPIC_PROACTIVE_DISABLED = "true";
633
+ process.env.OPENCODE_ANTHROPIC_ANTI_VERBOSITY = "true";
634
+
635
+ const config = loadConfig();
636
+
637
+ expect(config.signature_emulation).toMatchObject({
638
+ enabled: true,
639
+ fetch_claude_code_version_on_startup: true,
640
+ prompt_compaction: "minimal",
641
+ });
642
+ expect(config.override_model_limits.enabled).toBe(true);
643
+ expect(config.cc_credential_reuse.enabled).toBe(true);
644
+ expect(config.account_management.proactive_disabled).toBe(true);
645
+ expect(config.anti_verbosity.enabled).toBe(true);
646
+ });
647
+
648
+ it("applies false-form environment overrides for feature switches", () => {
649
+ existsSync.mockReturnValue(false);
650
+ process.env.OPENCODE_ANTHROPIC_OVERRIDE_MODEL_LIMITS = "false";
651
+ process.env.OPENCODE_ANTHROPIC_CC_CREDENTIALS = "false";
652
+ process.env.OPENCODE_ANTHROPIC_PROACTIVE_DISABLED = "false";
653
+ process.env.OPENCODE_ANTHROPIC_ANTI_VERBOSITY = "false";
654
+ process.env.OPENCODE_ANTHROPIC_LENGTH_ANCHORS = "false";
655
+
656
+ const config = loadConfig();
657
+
658
+ expect(config.override_model_limits.enabled).toBe(false);
659
+ expect(config.cc_credential_reuse.enabled).toBe(false);
660
+ expect(config.account_management.proactive_disabled).toBe(false);
661
+ expect(config.anti_verbosity).toEqual({ enabled: false, length_anchors: false });
662
+ });
663
+ });
664
+
665
+ describe("loadRawConfig", () => {
666
+ beforeEach(() => {
667
+ vi.resetAllMocks();
668
+ });
669
+
670
+ it("returns parsed values without applying validation", () => {
671
+ existsSync.mockReturnValue(true);
672
+ readFileSync.mockReturnValue(JSON.stringify({ failure_ttl_seconds: -10, custom_key: "preserved" }));
673
+
674
+ expect(loadRawConfig()).toEqual({ failure_ttl_seconds: -10, custom_key: "preserved" });
675
+ });
676
+
677
+ it("returns an empty object for missing, malformed, or non-object config", () => {
678
+ existsSync.mockReturnValueOnce(false).mockReturnValue(true);
679
+ readFileSync.mockReturnValueOnce("invalid json").mockReturnValueOnce("[]");
680
+
681
+ expect(loadRawConfig()).toEqual({});
682
+ expect(loadRawConfig()).toEqual({});
683
+ expect(loadRawConfig()).toEqual({});
684
+ });
381
685
  });
382
686
 
383
687
  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
+ });