@vacbo/opencode-anthropic-fix 0.0.45 → 0.1.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.
Files changed (61) hide show
  1. package/README.md +19 -0
  2. package/dist/bun-proxy.mjs +282 -55
  3. package/dist/opencode-anthropic-auth-cli.mjs +194 -55
  4. package/dist/opencode-anthropic-auth-plugin.js +1801 -613
  5. package/package.json +4 -4
  6. package/src/__tests__/billing-edge-cases.test.ts +84 -0
  7. package/src/__tests__/bun-proxy.parallel.test.ts +460 -0
  8. package/src/__tests__/debug-gating.test.ts +76 -0
  9. package/src/__tests__/decomposition-smoke.test.ts +92 -0
  10. package/src/__tests__/fingerprint-regression.test.ts +1 -1
  11. package/src/__tests__/helpers/conversation-history.smoke.test.ts +338 -0
  12. package/src/__tests__/helpers/conversation-history.ts +376 -0
  13. package/src/__tests__/helpers/deferred.smoke.test.ts +161 -0
  14. package/src/__tests__/helpers/deferred.ts +122 -0
  15. package/src/__tests__/helpers/in-memory-storage.smoke.test.ts +166 -0
  16. package/src/__tests__/helpers/in-memory-storage.ts +152 -0
  17. package/src/__tests__/helpers/mock-bun-proxy.smoke.test.ts +92 -0
  18. package/src/__tests__/helpers/mock-bun-proxy.ts +229 -0
  19. package/src/__tests__/helpers/plugin-fetch-harness.smoke.test.ts +337 -0
  20. package/src/__tests__/helpers/plugin-fetch-harness.ts +401 -0
  21. package/src/__tests__/helpers/sse.smoke.test.ts +243 -0
  22. package/src/__tests__/helpers/sse.ts +288 -0
  23. package/src/__tests__/index.parallel.test.ts +711 -0
  24. package/src/__tests__/sanitization-regex.test.ts +65 -0
  25. package/src/__tests__/state-bounds.test.ts +110 -0
  26. package/src/account-identity.test.ts +213 -0
  27. package/src/account-identity.ts +108 -0
  28. package/src/accounts.dedup.test.ts +696 -0
  29. package/src/accounts.test.ts +2 -1
  30. package/src/accounts.ts +485 -191
  31. package/src/bun-fetch.test.ts +379 -0
  32. package/src/bun-fetch.ts +447 -191
  33. package/src/bun-proxy.ts +289 -57
  34. package/src/circuit-breaker.test.ts +274 -0
  35. package/src/circuit-breaker.ts +235 -0
  36. package/src/cli.test.ts +1 -0
  37. package/src/cli.ts +37 -18
  38. package/src/commands/router.ts +25 -5
  39. package/src/env.ts +1 -0
  40. package/src/headers/billing.ts +11 -5
  41. package/src/index.ts +224 -247
  42. package/src/oauth.ts +7 -1
  43. package/src/parent-pid-watcher.test.ts +219 -0
  44. package/src/parent-pid-watcher.ts +99 -0
  45. package/src/plugin-helpers.ts +112 -0
  46. package/src/refresh-helpers.ts +169 -0
  47. package/src/refresh-lock.test.ts +36 -9
  48. package/src/refresh-lock.ts +2 -2
  49. package/src/request/body.history.test.ts +398 -0
  50. package/src/request/body.ts +200 -13
  51. package/src/request/metadata.ts +6 -2
  52. package/src/response/index.ts +1 -1
  53. package/src/response/mcp.ts +60 -31
  54. package/src/response/streaming.test.ts +382 -0
  55. package/src/response/streaming.ts +403 -76
  56. package/src/storage.test.ts +127 -104
  57. package/src/storage.ts +152 -62
  58. package/src/system-prompt/builder.ts +33 -3
  59. package/src/system-prompt/sanitize.ts +12 -2
  60. package/src/token-refresh.test.ts +84 -1
  61. package/src/token-refresh.ts +14 -8
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@vacbo/opencode-anthropic-fix",
3
- "version": "0.0.45",
4
- "main": "./dist/opencode-anthropic-auth-plugin.js",
3
+ "version": "0.1.2",
4
+ "main": "dist/opencode-anthropic-auth-plugin.js",
5
5
  "bin": {
6
- "opencode-anthropic-auth": "./dist/opencode-anthropic-auth-cli.mjs",
7
- "oaa": "./dist/opencode-anthropic-auth-cli.mjs"
6
+ "opencode-anthropic-auth": "dist/opencode-anthropic-auth-cli.mjs",
7
+ "oaa": "dist/opencode-anthropic-auth-cli.mjs"
8
8
  },
9
9
  "files": [
10
10
  "dist/**",
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Billing header edge case tests (Task 10 from quality-refactor plan)
3
+ *
4
+ * Verifies cc_entrypoint uses nullish coalescing (??) not logical OR (||)
5
+ * and handles short/empty messages without crashing.
6
+ */
7
+
8
+ import { describe, it, expect, afterEach } from "vitest";
9
+
10
+ import { buildAnthropicBillingHeader } from "../headers/billing.js";
11
+
12
+ describe("buildAnthropicBillingHeader cc_entrypoint nullish coalescing", () => {
13
+ const originalEntrypoint = process.env.CLAUDE_CODE_ENTRYPOINT;
14
+ const originalAttribution = process.env.CLAUDE_CODE_ATTRIBUTION_HEADER;
15
+
16
+ afterEach(() => {
17
+ if (originalEntrypoint === undefined) {
18
+ delete process.env.CLAUDE_CODE_ENTRYPOINT;
19
+ } else {
20
+ process.env.CLAUDE_CODE_ENTRYPOINT = originalEntrypoint;
21
+ }
22
+ if (originalAttribution === undefined) {
23
+ delete process.env.CLAUDE_CODE_ATTRIBUTION_HEADER;
24
+ } else {
25
+ process.env.CLAUDE_CODE_ATTRIBUTION_HEADER = originalAttribution;
26
+ }
27
+ });
28
+
29
+ it("defaults to 'cli' when CLAUDE_CODE_ENTRYPOINT is unset", () => {
30
+ delete process.env.CLAUDE_CODE_ENTRYPOINT;
31
+ delete process.env.CLAUDE_CODE_ATTRIBUTION_HEADER;
32
+ const header = buildAnthropicBillingHeader("2.1.98", [{ role: "user", content: "hello" }]);
33
+ expect(header).toContain("cc_entrypoint=cli");
34
+ });
35
+
36
+ it("uses explicit value when CLAUDE_CODE_ENTRYPOINT is set to non-empty string", () => {
37
+ process.env.CLAUDE_CODE_ENTRYPOINT = "slash";
38
+ delete process.env.CLAUDE_CODE_ATTRIBUTION_HEADER;
39
+ const header = buildAnthropicBillingHeader("2.1.98", [{ role: "user", content: "hello" }]);
40
+ expect(header).toContain("cc_entrypoint=slash");
41
+ });
42
+
43
+ it("preserves empty string when CLAUDE_CODE_ENTRYPOINT is set to '' (?? not ||)", () => {
44
+ // With logical OR (||), empty string would fall through to "cli".
45
+ // With nullish coalescing (??), empty string is kept as-is.
46
+ process.env.CLAUDE_CODE_ENTRYPOINT = "";
47
+ delete process.env.CLAUDE_CODE_ATTRIBUTION_HEADER;
48
+ const header = buildAnthropicBillingHeader("2.1.98", [{ role: "user", content: "hello" }]);
49
+ expect(header).not.toContain("cc_entrypoint=cli");
50
+ });
51
+ });
52
+
53
+ describe("buildAnthropicBillingHeader version suffix edge cases", () => {
54
+ const originalAttribution = process.env.CLAUDE_CODE_ATTRIBUTION_HEADER;
55
+
56
+ afterEach(() => {
57
+ if (originalAttribution === undefined) {
58
+ delete process.env.CLAUDE_CODE_ATTRIBUTION_HEADER;
59
+ } else {
60
+ process.env.CLAUDE_CODE_ATTRIBUTION_HEADER = originalAttribution;
61
+ }
62
+ });
63
+
64
+ it("handles empty message content without crashing", () => {
65
+ delete process.env.CLAUDE_CODE_ATTRIBUTION_HEADER;
66
+ expect(() => buildAnthropicBillingHeader("2.1.98", [{ role: "user", content: "" }])).not.toThrow();
67
+ });
68
+
69
+ it("handles single-character message", () => {
70
+ delete process.env.CLAUDE_CODE_ATTRIBUTION_HEADER;
71
+ expect(() => buildAnthropicBillingHeader("2.1.98", [{ role: "user", content: "x" }])).not.toThrow();
72
+ });
73
+
74
+ it("handles empty messages array", () => {
75
+ delete process.env.CLAUDE_CODE_ATTRIBUTION_HEADER;
76
+ expect(() => buildAnthropicBillingHeader("2.1.98", [])).not.toThrow();
77
+ });
78
+
79
+ it("includes the CC version in the header output", () => {
80
+ delete process.env.CLAUDE_CODE_ATTRIBUTION_HEADER;
81
+ const header = buildAnthropicBillingHeader("2.1.98", [{ role: "user", content: "hello world" }]);
82
+ expect(header).toContain("2.1.98");
83
+ });
84
+ });
@@ -0,0 +1,460 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { fileURLToPath } from "node:url";
3
+
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+
6
+ import { createDeferred, nextTick } from "./helpers/deferred.js";
7
+ import { createMockBunProxy } from "./helpers/mock-bun-proxy.js";
8
+ import {
9
+ contentBlockDeltaEvent,
10
+ contentBlockStartEvent,
11
+ contentBlockStopEvent,
12
+ encodeSSEStream,
13
+ makeSSEResponse,
14
+ messageStartEvent,
15
+ messageStopEvent,
16
+ } from "./helpers/sse.js";
17
+
18
+ interface ParentWatcher {
19
+ start(): void;
20
+ stop(): void;
21
+ }
22
+
23
+ type ParentWatcherFactory = (options: {
24
+ parentPid: number;
25
+ onParentExit: (exitCode?: number) => void;
26
+ pollIntervalMs?: number;
27
+ exitCode?: number;
28
+ }) => ParentWatcher;
29
+
30
+ interface BunProxyRequestHandlerOptions {
31
+ fetchImpl: typeof fetch;
32
+ allowHosts?: string[];
33
+ requestTimeoutMs?: number;
34
+ }
35
+
36
+ interface BunProxyProcessRuntimeOptions {
37
+ argv?: string[];
38
+ fetchImpl?: typeof fetch;
39
+ exit?: (code?: number) => void;
40
+ parentWatcherFactory?: ParentWatcherFactory;
41
+ }
42
+
43
+ interface BunProxyModuleContract {
44
+ createProxyRequestHandler(options: BunProxyRequestHandlerOptions): (request: Request) => Promise<Response>;
45
+ createProxyProcessRuntime?(options: BunProxyProcessRuntimeOptions): ParentWatcher;
46
+ }
47
+
48
+ const bunProxySourcePath = fileURLToPath(new URL("../bun-proxy.ts", import.meta.url));
49
+ const bunProxySource = readFileSync(bunProxySourcePath, "utf-8");
50
+
51
+ async function loadBunProxyModule(): Promise<BunProxyModuleContract> {
52
+ const modulePath = "../bun-proxy.js";
53
+ return (await import(modulePath)) as BunProxyModuleContract;
54
+ }
55
+
56
+ async function createProxyRequestHandler(
57
+ fetchImpl: typeof fetch,
58
+ overrides: Partial<BunProxyRequestHandlerOptions> = {},
59
+ ): Promise<(request: Request) => Promise<Response>> {
60
+ const { createProxyRequestHandler } = await loadBunProxyModule();
61
+
62
+ return createProxyRequestHandler({
63
+ fetchImpl,
64
+ allowHosts: ["api.anthropic.com", "platform.claude.com"],
65
+ requestTimeoutMs: 50,
66
+ ...overrides,
67
+ });
68
+ }
69
+
70
+ function makeProxyRequest(
71
+ requestId: number,
72
+ overrides: {
73
+ body?: string;
74
+ headers?: HeadersInit;
75
+ method?: string;
76
+ signal?: AbortSignal;
77
+ targetUrl?: string;
78
+ } = {},
79
+ ): Request {
80
+ const headers = new Headers(overrides.headers);
81
+ headers.set("x-proxy-url", overrides.targetUrl ?? "https://api.anthropic.com/v1/messages");
82
+
83
+ if (!headers.has("content-type")) {
84
+ headers.set("content-type", "application/json");
85
+ }
86
+
87
+ return new Request("http://127.0.0.1/proxy", {
88
+ method: overrides.method ?? "POST",
89
+ headers,
90
+ body: overrides.body ?? JSON.stringify({ requestId }),
91
+ signal: overrides.signal,
92
+ });
93
+ }
94
+
95
+ function makeSSETranscript(requestId: number): string {
96
+ return encodeSSEStream([
97
+ messageStartEvent({
98
+ message: {
99
+ id: `msg_${requestId}`,
100
+ type: "message",
101
+ role: "assistant",
102
+ content: [],
103
+ model: "claude-3-opus-20240229",
104
+ stop_reason: null,
105
+ stop_sequence: null,
106
+ usage: {
107
+ input_tokens: 10,
108
+ output_tokens: 0,
109
+ },
110
+ },
111
+ }),
112
+ contentBlockStartEvent(0, {
113
+ content_block: {
114
+ type: "text",
115
+ text: "",
116
+ },
117
+ }),
118
+ contentBlockDeltaEvent(0, `stream-${requestId}-a`),
119
+ contentBlockDeltaEvent(0, `stream-${requestId}-b`),
120
+ contentBlockStopEvent(0),
121
+ messageStopEvent(),
122
+ ]);
123
+ }
124
+
125
+ async function flushMicrotasks(turns = 8): Promise<void> {
126
+ for (let index = 0; index < turns; index += 1) {
127
+ await nextTick();
128
+ }
129
+ }
130
+
131
+ beforeEach(() => {
132
+ vi.resetModules();
133
+ });
134
+
135
+ afterEach(() => {
136
+ vi.restoreAllMocks();
137
+ vi.doUnmock("../parent-pid-watcher.js");
138
+ });
139
+
140
+ describe("bun-proxy parallel request contract (RED)", () => {
141
+ it("single proxy handles 10 concurrent fetches with distinct bodies and responses", async () => {
142
+ const proxy = createMockBunProxy({
143
+ forwardToMockFetch: vi.fn(async (_input, init) => new Response(String(init?.body), { status: 200 })),
144
+ });
145
+ const handler = await createProxyRequestHandler(proxy.child.forwardFetch as typeof fetch);
146
+
147
+ const responses = await Promise.all(
148
+ Array.from({ length: 10 }, (_, requestId) => handler(makeProxyRequest(requestId))),
149
+ );
150
+ const bodies = await Promise.all(responses.map((response) => response.text()));
151
+
152
+ expect(bodies).toEqual(Array.from({ length: 10 }, (_, requestId) => JSON.stringify({ requestId })));
153
+ });
154
+
155
+ it("single proxy handles 50 concurrent fetches with distinct bodies", async () => {
156
+ const proxy = createMockBunProxy({
157
+ forwardToMockFetch: vi.fn(async (_input, init) => new Response(String(init?.body), { status: 200 })),
158
+ });
159
+ const handler = await createProxyRequestHandler(proxy.child.forwardFetch as typeof fetch);
160
+
161
+ const responses = await Promise.all(
162
+ Array.from({ length: 50 }, (_, requestId) => handler(makeProxyRequest(requestId))),
163
+ );
164
+ const bodies = await Promise.all(responses.map((response) => response.text()));
165
+
166
+ expect(bodies).toEqual(Array.from({ length: 50 }, (_, requestId) => JSON.stringify({ requestId })));
167
+ });
168
+
169
+ it("starts all concurrent upstream fetches in parallel without serialization", async () => {
170
+ const deferredResponses = Array.from({ length: 50 }, () => createDeferred<Response>());
171
+ let nextResponse = 0;
172
+ const proxy = createMockBunProxy({
173
+ forwardToMockFetch: vi.fn(() => deferredResponses[nextResponse++]!.promise),
174
+ });
175
+ const handler = await createProxyRequestHandler(proxy.child.forwardFetch as typeof fetch);
176
+
177
+ const pendingResponses = Array.from({ length: 50 }, (_, requestId) => handler(makeProxyRequest(requestId)));
178
+
179
+ await flushMicrotasks();
180
+
181
+ expect(proxy.getInFlightCount()).toBe(50);
182
+
183
+ deferredResponses.forEach((deferred, requestId) => {
184
+ deferred.resolve(new Response(`ok-${requestId}`, { status: 200 }));
185
+ });
186
+
187
+ await Promise.all(pendingResponses);
188
+ });
189
+
190
+ it("slow request does not head-of-line block siblings", async () => {
191
+ const slowResponse = createDeferred<Response>();
192
+ const proxy = createMockBunProxy({
193
+ forwardToMockFetch: vi.fn(async (_input, init) => {
194
+ const { requestId } = JSON.parse(String(init?.body)) as { requestId: number };
195
+ if (requestId === 0) {
196
+ return slowResponse.promise;
197
+ }
198
+
199
+ return new Response(`fast-${requestId}`, { status: 200 });
200
+ }),
201
+ });
202
+ const handler = await createProxyRequestHandler(proxy.child.forwardFetch as typeof fetch);
203
+
204
+ const responses = Array.from({ length: 10 }, (_, requestId) => handler(makeProxyRequest(requestId)));
205
+ const fastBodies = await Promise.all(
206
+ responses.slice(1).map(async (responsePromise) => {
207
+ const response = await responsePromise;
208
+ return response.text();
209
+ }),
210
+ );
211
+
212
+ expect(fastBodies).toEqual(Array.from({ length: 9 }, (_, index) => `fast-${index + 1}`));
213
+ expect(proxy.getInFlightCount()).toBe(1);
214
+
215
+ slowResponse.resolve(new Response("fast-0", { status: 200 }));
216
+
217
+ const slowBody = await (await responses[0]).text();
218
+ expect(slowBody).toBe("fast-0");
219
+ });
220
+
221
+ it("concurrent SSE streams maintain per-stream event ordering", async () => {
222
+ const proxy = createMockBunProxy({
223
+ forwardToMockFetch: vi.fn(async (_input, init) => {
224
+ const { requestId } = JSON.parse(String(init?.body)) as { requestId: number };
225
+ return makeSSEResponse(makeSSETranscript(requestId));
226
+ }),
227
+ });
228
+ const handler = await createProxyRequestHandler(proxy.child.forwardFetch as typeof fetch);
229
+
230
+ const responses = await Promise.all(
231
+ Array.from({ length: 5 }, (_, requestId) => handler(makeProxyRequest(requestId))),
232
+ );
233
+ const transcripts = await Promise.all(responses.map((response) => response.text()));
234
+
235
+ transcripts.forEach((transcript, requestId) => {
236
+ expect(transcript).toContain(`stream-${requestId}-a`);
237
+ expect(transcript).toContain(`stream-${requestId}-b`);
238
+ expect(transcript.indexOf(`stream-${requestId}-a`)).toBeLessThan(transcript.indexOf(`stream-${requestId}-b`));
239
+ });
240
+ });
241
+
242
+ it("upstream error in 1 request does not affect siblings", async () => {
243
+ const proxy = createMockBunProxy({
244
+ forwardToMockFetch: vi.fn(async (_input, init) => {
245
+ const { requestId } = JSON.parse(String(init?.body)) as { requestId: number };
246
+ if (requestId === 3) {
247
+ throw new Error("boom-3");
248
+ }
249
+
250
+ return new Response(`ok-${requestId}`, { status: 200 });
251
+ }),
252
+ });
253
+ const handler = await createProxyRequestHandler(proxy.child.forwardFetch as typeof fetch);
254
+
255
+ const outcomes = await Promise.allSettled(
256
+ Array.from({ length: 10 }, async (_, requestId) => {
257
+ const response = await handler(makeProxyRequest(requestId));
258
+ return {
259
+ status: response.status,
260
+ body: await response.text(),
261
+ };
262
+ }),
263
+ );
264
+
265
+ outcomes.forEach((outcome, requestId) => {
266
+ if (requestId === 3) {
267
+ expect(outcome.status === "rejected" || (outcome.status === "fulfilled" && outcome.value.status >= 500)).toBe(
268
+ true,
269
+ );
270
+ return;
271
+ }
272
+
273
+ expect(outcome.status).toBe("fulfilled");
274
+ if (outcome.status === "fulfilled") {
275
+ expect(outcome.value).toEqual({
276
+ status: 200,
277
+ body: `ok-${requestId}`,
278
+ });
279
+ }
280
+ });
281
+ });
282
+
283
+ it("upstream timeout of 1 request does not crash the proxy or cascade to later requests", async () => {
284
+ const abortedRequestIds: number[] = [];
285
+ const fetchImpl = vi.fn((_input: string | URL | Request, init?: RequestInit) => {
286
+ const { requestId } = JSON.parse(String(init?.body)) as { requestId: number };
287
+ const deferred = createDeferred<Response>();
288
+
289
+ init?.signal?.addEventListener(
290
+ "abort",
291
+ () => {
292
+ abortedRequestIds.push(requestId);
293
+ deferred.reject(init.signal?.reason ?? new DOMException("Timed out", "TimeoutError"));
294
+ },
295
+ { once: true },
296
+ );
297
+
298
+ if (requestId !== 0) {
299
+ deferred.resolve(new Response(`ok-${requestId}`, { status: 200 }));
300
+ }
301
+
302
+ return deferred.promise;
303
+ }) as typeof fetch;
304
+ const handler = await createProxyRequestHandler(fetchImpl, { requestTimeoutMs: 25 });
305
+
306
+ const responses = Array.from({ length: 10 }, (_, requestId) => handler(makeProxyRequest(requestId)));
307
+ const fastStatuses = await Promise.all(
308
+ responses.slice(1).map(async (responsePromise) => {
309
+ const response = await responsePromise;
310
+ return response.status;
311
+ }),
312
+ );
313
+
314
+ await new Promise((resolve) => setTimeout(resolve, 50));
315
+
316
+ expect(fastStatuses).toEqual(Array.from({ length: 9 }, () => 200));
317
+ expect(abortedRequestIds).toEqual([0]);
318
+
319
+ await expect(handler(makeProxyRequest(99))).resolves.toBeInstanceOf(Response);
320
+ });
321
+
322
+ it("canceling 1 of 10 requests aborts only that upstream signal and not siblings", async () => {
323
+ const abortedRequestIds: number[] = [];
324
+ const deferredResponses = new Map<number, ReturnType<typeof createDeferred<Response>>>();
325
+ const fetchImpl = vi.fn((_input: string | URL | Request, init?: RequestInit) => {
326
+ const { requestId } = JSON.parse(String(init?.body)) as { requestId: number };
327
+ const deferred = createDeferred<Response>();
328
+
329
+ deferredResponses.set(requestId, deferred);
330
+ init?.signal?.addEventListener(
331
+ "abort",
332
+ () => {
333
+ abortedRequestIds.push(requestId);
334
+ deferred.reject(init.signal?.reason ?? new DOMException("Aborted", "AbortError"));
335
+ },
336
+ { once: true },
337
+ );
338
+
339
+ return deferred.promise;
340
+ }) as typeof fetch;
341
+ const handler = await createProxyRequestHandler(fetchImpl);
342
+ const controllers = Array.from({ length: 10 }, () => new AbortController());
343
+ const responses = Array.from({ length: 10 }, (_, requestId) =>
344
+ handler(makeProxyRequest(requestId, { signal: controllers[requestId].signal })),
345
+ );
346
+
347
+ await flushMicrotasks();
348
+ controllers[4].abort(new DOMException("client disconnected", "AbortError"));
349
+
350
+ deferredResponses.forEach((deferred, requestId) => {
351
+ if (requestId !== 4) {
352
+ deferred.resolve(new Response(`ok-${requestId}`, { status: 200 }));
353
+ }
354
+ });
355
+
356
+ const outcomes = await Promise.allSettled(
357
+ responses.map(async (responsePromise) => {
358
+ const response = await responsePromise;
359
+ return response.status;
360
+ }),
361
+ );
362
+
363
+ expect(abortedRequestIds).toEqual([4]);
364
+ outcomes.forEach((outcome, requestId) => {
365
+ if (requestId === 4) {
366
+ expect(outcome.status === "rejected" || (outcome.status === "fulfilled" && outcome.value >= 400)).toBe(true);
367
+ return;
368
+ }
369
+
370
+ expect(outcome).toMatchObject({
371
+ status: "fulfilled",
372
+ value: 200,
373
+ });
374
+ });
375
+ });
376
+
377
+ it("client disconnect aborts the upstream fetch signal", async () => {
378
+ let upstreamSignal: AbortSignal | null | undefined;
379
+ const upstreamResponse = createDeferred<Response>();
380
+ const fetchImpl = vi.fn((_input: string | URL | Request, init?: RequestInit) => {
381
+ upstreamSignal = init?.signal;
382
+ init?.signal?.addEventListener(
383
+ "abort",
384
+ () => {
385
+ upstreamResponse.reject(init.signal?.reason ?? new DOMException("client disconnected", "AbortError"));
386
+ },
387
+ { once: true },
388
+ );
389
+
390
+ return upstreamResponse.promise;
391
+ }) as typeof fetch;
392
+ const handler = await createProxyRequestHandler(fetchImpl);
393
+ const controller = new AbortController();
394
+ const responsePromise = handler(makeProxyRequest(123, { signal: controller.signal }));
395
+
396
+ await flushMicrotasks();
397
+
398
+ expect(fetchImpl).toHaveBeenCalledTimes(1);
399
+ expect(upstreamSignal).toBeDefined();
400
+ expect(upstreamSignal?.aborted).toBe(false);
401
+
402
+ controller.abort(new DOMException("client disconnected", "AbortError"));
403
+
404
+ await expect(responsePromise).resolves.toMatchObject({ status: 499 });
405
+ expect(upstreamSignal?.aborted).toBe(true);
406
+ expect(upstreamSignal?.reason).toBeInstanceOf(DOMException);
407
+ expect((upstreamSignal?.reason as DOMException | undefined)?.name).toBe("AbortError");
408
+ });
409
+
410
+ it("releases all in-flight bookkeeping after repeated bursts to keep memory bounded", async () => {
411
+ const proxy = createMockBunProxy({
412
+ forwardToMockFetch: vi.fn(async (_input, init) => new Response(String(init?.body), { status: 200 })),
413
+ });
414
+ const handler = await createProxyRequestHandler(proxy.child.forwardFetch as typeof fetch);
415
+
416
+ for (let burst = 0; burst < 3; burst += 1) {
417
+ const responses = await Promise.all(
418
+ Array.from({ length: 50 }, (_, requestId) => handler(makeProxyRequest(burst * 100 + requestId))),
419
+ );
420
+ await Promise.all(responses.map((response) => response.text()));
421
+ expect(proxy.getInFlightCount()).toBe(0);
422
+ }
423
+ });
424
+
425
+ it("ties the upstream fetch to the incoming request signal without pre-fetch body awaits or mutable globals", () => {
426
+ expect(bunProxySource).toMatch(/AbortSignal\.any\s*\(\s*\[\s*req\.signal/i);
427
+ expect(bunProxySource).not.toMatch(/await\s+req\.arrayBuffer\s*\(/);
428
+ expect(bunProxySource).not.toMatch(/^let\s+/m);
429
+ });
430
+
431
+ it("starts a parent-PID watcher so the subprocess exits when the parent dies", async () => {
432
+ const parentWatcher = {
433
+ start: vi.fn(),
434
+ stop: vi.fn(),
435
+ } satisfies ParentWatcher;
436
+ const parentWatcherFactory = vi.fn(({ onParentExit }: Parameters<ParentWatcherFactory>[0]) => {
437
+ onParentExit(0);
438
+ return parentWatcher;
439
+ });
440
+ const exit = vi.fn();
441
+ const proxy = createMockBunProxy();
442
+ const { createProxyProcessRuntime } = await loadBunProxyModule();
443
+
444
+ const runtime = createProxyProcessRuntime?.({
445
+ argv: ["bun", "run", "bun-proxy.ts", "--parent-pid", "4242"],
446
+ fetchImpl: proxy.child.forwardFetch as typeof fetch,
447
+ exit,
448
+ parentWatcherFactory,
449
+ });
450
+
451
+ runtime?.start();
452
+
453
+ expect(parentWatcherFactory).toHaveBeenCalledWith(
454
+ expect.objectContaining({
455
+ parentPid: 4242,
456
+ }),
457
+ );
458
+ expect(exit).toHaveBeenCalledWith(0);
459
+ });
460
+ });
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Debug gating behavior tests (Tasks 2-5, 11-12 from quality-refactor plan)
3
+ *
4
+ * Verifies that console output from the proxy subsystem respects the debug flag
5
+ * and that silent error swallowing has been removed. These are SOURCE-CODE GREP
6
+ * tests — we verify the plumbing exists without spawning subprocesses.
7
+ */
8
+
9
+ import { describe, it, expect } from "vitest";
10
+ import { readFileSync } from "node:fs";
11
+ import { fileURLToPath } from "node:url";
12
+ import { dirname, join } from "node:path";
13
+
14
+ const __dirname = dirname(fileURLToPath(import.meta.url));
15
+ const SRC_ROOT = join(__dirname, "..");
16
+ const bunFetchSource = readFileSync(join(SRC_ROOT, "bun-fetch.ts"), "utf8");
17
+ const bunProxySource = readFileSync(join(SRC_ROOT, "bun-proxy.ts"), "utf8");
18
+
19
+ describe("debug gating in bun-fetch.ts (Task 2/4)", () => {
20
+ it("threads OPENCODE_ANTHROPIC_DEBUG env var", () => {
21
+ expect(bunFetchSource).toContain("OPENCODE_ANTHROPIC_DEBUG");
22
+ });
23
+
24
+ it("gates /tmp debug dump behind debug flag if any /tmp writes exist", () => {
25
+ const hasTmpWrite = /writeFileSync\s*\(\s*["']\/tmp\/opencode/.test(bunFetchSource);
26
+ if (hasTmpWrite) {
27
+ expect(bunFetchSource).toMatch(/if\s*\(\s*(debug|resolveDebug)[^)]*\)|(debug|resolveDebug)\s*&&/);
28
+ } else {
29
+ expect(hasTmpWrite).toBe(false);
30
+ }
31
+ });
32
+
33
+ it("does not register an uncaughtException handler (Task 12)", () => {
34
+ expect(bunFetchSource).not.toContain("uncaughtException");
35
+ });
36
+
37
+ it("does not register an unhandledRejection handler (Task 12)", () => {
38
+ expect(bunFetchSource).not.toContain("unhandledRejection");
39
+ });
40
+ });
41
+
42
+ describe("debug gating in bun-proxy.ts (Task 3)", () => {
43
+ it("references OPENCODE_ANTHROPIC_DEBUG for request logging", () => {
44
+ expect(bunProxySource).toContain("OPENCODE_ANTHROPIC_DEBUG");
45
+ });
46
+
47
+ it("uses AbortSignal-based timeout handling on upstream fetch (Task 11)", () => {
48
+ expect(bunProxySource).toMatch(/AbortSignal\.(timeout|any)/);
49
+ });
50
+
51
+ it("emits BUN_PROXY_PORT IPC ungated (parent must always detect port)", () => {
52
+ expect(bunProxySource).toContain("BUN_PROXY_PORT");
53
+ });
54
+ });
55
+
56
+ describe("silent error swallowing fixes (Task 5)", () => {
57
+ it("accounts.ts saveToDisk catch logs the error", () => {
58
+ const source = readFileSync(join(SRC_ROOT, "accounts.ts"), "utf8");
59
+ expect(source).not.toMatch(/saveToDisk\(\)\s*\.catch\(\s*\(\)\s*=>\s*\{\s*\}\s*\)/);
60
+ });
61
+
62
+ it("token-refresh.ts has no '.catch(() => undefined)' patterns", () => {
63
+ const source = readFileSync(join(SRC_ROOT, "token-refresh.ts"), "utf8");
64
+ expect(source).not.toMatch(/\.catch\(\s*\(\)\s*=>\s*undefined\s*\)/);
65
+ });
66
+
67
+ it("request/body.ts JSON parse catch binds the error parameter", () => {
68
+ const source = readFileSync(join(SRC_ROOT, "request", "body.ts"), "utf8");
69
+ expect(source).not.toMatch(/catch\s*\{\s*return\s+body\s*;?\s*\}/);
70
+ });
71
+
72
+ it("request/metadata.ts extractFileIds catch binds the error parameter", () => {
73
+ const source = readFileSync(join(SRC_ROOT, "request", "metadata.ts"), "utf8");
74
+ expect(source).not.toMatch(/catch\s*\{\s*return\s+\[\]\s*;?\s*\}/);
75
+ });
76
+ });