@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/README.md CHANGED
@@ -33,6 +33,8 @@ opencode
33
33
 
34
34
  That's it. OpenCode will now use your Claude subscription directly. All model costs show as $0.00.
35
35
 
36
+ Manual parallel QA: `bash scripts/qa-parallel.sh`
37
+
36
38
  ## What This Fork Adds
37
39
 
38
40
  The [original plugin](https://github.com/anomalyco/opencode-anthropic-auth) provided basic OAuth support. This fork adds:
@@ -555,6 +557,17 @@ Configuration is stored at `~/.config/opencode/anthropic-auth.json`. All setting
555
557
  - In OAuth mode, the plugin always includes `oauth-2025-04-20` in `anthropic-beta`.
556
558
  - This applies to all models, including Haiku.
557
559
 
560
+ ## Per-instance Proxy Lifecycle
561
+
562
+ The plugin uses a dedicated HTTP proxy per OpenCode instance to handle TLS fingerprint mimicry. This architecture provides isolation and graceful degradation:
563
+
564
+ - **Each OpenCode instance owns its own proxy** — when you open multiple OpenCode tabs or windows, each gets an independent proxy process
565
+ - **Proxy dies with parent process** — the proxy monitors its parent PID and exits automatically if the parent dies, preventing orphaned processes
566
+ - **Ephemeral port allocation** — each proxy binds to an available ephemeral port (port 0) to avoid conflicts between instances
567
+ - **Graceful fallback to native fetch** — if Bun is unavailable or the proxy fails to start, requests fall back to native Node.js fetch without TLS mimicry
568
+
569
+ This design ensures that proxy failures are isolated to a single OpenCode instance and never affect other running instances.
570
+
558
571
  ## How It Works
559
572
 
560
573
  When you make a request through OpenCode:
@@ -666,6 +679,12 @@ Make sure `~/.local/bin` is on your PATH:
666
679
  export PATH="$HOME/.local/bin:$PATH"
667
680
  ```
668
681
 
682
+ ## Known Limitations
683
+
684
+ - **Windows native fetch fallback** — On Windows, the Bun-based TLS mimicry proxy is not available. Requests fall back to native Node.js fetch without fingerprint mimicry. This is a platform limitation; the plugin still functions but with reduced request signature parity.
685
+
686
+ - **Claude Code refresh blocking** — When reusing Claude Code credentials, token refresh can block for up to 60 seconds while invoking the `claude` CLI. This is a known latent issue in the credential reuse flow and is outside the scope of this plugin's control.
687
+
669
688
  ## License
670
689
 
671
690
  Same as upstream. See [anomalyco/opencode-anthropic-auth](https://github.com/anomalyco/opencode-anthropic-auth).
@@ -1,64 +1,291 @@
1
1
  // src/bun-proxy.ts
2
- var PORT = parseInt(process.argv[2] || "48372", 10);
3
- var server = Bun.serve({
4
- port: PORT,
5
- async fetch(req) {
6
- if (new URL(req.url).pathname === "/__health") {
7
- return new Response("ok");
2
+ import { resolve } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ // src/parent-pid-watcher.ts
6
+ var DEFAULT_POLL_INTERVAL_MS = 5e3;
7
+ function assertValidParentPid(parentPid) {
8
+ if (!Number.isInteger(parentPid) || parentPid <= 0) {
9
+ throw new Error("Parent PID must be a positive integer.");
10
+ }
11
+ }
12
+ function assertValidPollInterval(pollIntervalMs) {
13
+ if (!Number.isFinite(pollIntervalMs) || pollIntervalMs <= 0) {
14
+ throw new Error("Poll interval must be a positive number.");
15
+ }
16
+ }
17
+ function isParentAlive(parentPid) {
18
+ try {
19
+ process.kill(parentPid, 0);
20
+ return true;
21
+ } catch (error) {
22
+ const code = error.code;
23
+ if (code === "ESRCH") {
24
+ return false;
8
25
  }
9
- const targetUrl = req.headers.get("x-proxy-url");
10
- if (!targetUrl) {
11
- return new Response("Missing x-proxy-url", { status: 400 });
26
+ if (code === "EPERM") {
27
+ return true;
12
28
  }
13
- const headers = new Headers(req.headers);
14
- headers.delete("x-proxy-url");
15
- headers.delete("host");
16
- headers.delete("connection");
17
- const body = req.method !== "GET" && req.method !== "HEAD" ? await req.arrayBuffer() : void 0;
18
- if (targetUrl.includes("/v1/messages") && !targetUrl.includes("count_tokens")) {
19
- const logHeaders = {};
20
- headers.forEach((v, k) => {
21
- logHeaders[k] = k === "authorization" ? "Bearer ***" : v;
22
- });
23
- let systemPreview = "";
24
- if (body) {
25
- try {
26
- const parsed = JSON.parse(new TextDecoder().decode(body));
27
- if (Array.isArray(parsed.system)) {
28
- systemPreview = JSON.stringify(parsed.system.slice(0, 3).map((b) => ({
29
- text: typeof b.text === "string" ? b.text.slice(0, 200) : "(non-text)",
30
- cache_control: b.cache_control
31
- })), null, 2);
32
- }
33
- } catch {
34
- }
29
+ return true;
30
+ }
31
+ }
32
+ var ParentPidWatcher = class {
33
+ parentPid;
34
+ pollIntervalMs;
35
+ onParentGone;
36
+ interval = null;
37
+ shouldMonitorPpidDrift = false;
38
+ constructor(options) {
39
+ this.parentPid = options.parentPid;
40
+ this.pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
41
+ this.onParentGone = options.onParentGone;
42
+ }
43
+ start() {
44
+ if (this.interval) {
45
+ return;
46
+ }
47
+ assertValidParentPid(this.parentPid);
48
+ assertValidPollInterval(this.pollIntervalMs);
49
+ this.shouldMonitorPpidDrift = process.ppid === this.parentPid;
50
+ this.interval = setInterval(() => {
51
+ if (this.shouldMonitorPpidDrift && process.ppid !== this.parentPid) {
52
+ this.handleParentGone();
53
+ return;
35
54
  }
36
- console.error(`
37
- [bun-proxy] === /v1/messages REQUEST ===`);
38
- console.error(`[bun-proxy] URL: ${targetUrl}`);
39
- console.error(`[bun-proxy] Headers: ${JSON.stringify(logHeaders, null, 2)}`);
40
- if (systemPreview) console.error(`[bun-proxy] System blocks (first 3): ${systemPreview}`);
41
- console.error(`[bun-proxy] ===========================
42
- `);
55
+ if (!isParentAlive(this.parentPid)) {
56
+ this.handleParentGone();
57
+ }
58
+ }, this.pollIntervalMs);
59
+ }
60
+ stop() {
61
+ if (!this.interval) {
62
+ return;
63
+ }
64
+ clearInterval(this.interval);
65
+ this.interval = null;
66
+ this.shouldMonitorPpidDrift = false;
67
+ }
68
+ handleParentGone() {
69
+ this.stop();
70
+ this.onParentGone();
71
+ }
72
+ };
73
+
74
+ // src/bun-proxy.ts
75
+ var DEFAULT_ALLOWED_HOSTS = ["api.anthropic.com", "platform.claude.com"];
76
+ var DEFAULT_REQUEST_TIMEOUT_MS = 6e5;
77
+ var DEFAULT_PARENT_EXIT_CODE = 1;
78
+ var DEFAULT_PARENT_POLL_INTERVAL_MS = 5e3;
79
+ var HEALTH_PATH = "/__health";
80
+ var DEBUG_ENABLED = process.env.OPENCODE_ANTHROPIC_DEBUG === "1";
81
+ function isMainModule(argv = process.argv) {
82
+ return Boolean(argv[1]) && resolve(argv[1]) === fileURLToPath(import.meta.url);
83
+ }
84
+ function parseInteger(value) {
85
+ const parsed = Number.parseInt(value ?? "", 10);
86
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
87
+ }
88
+ function parseParentPid(argv) {
89
+ const inlineValue = argv.map((argument) => argument.match(/^--parent-pid=(\d+)$/)?.[1] ?? null).find((value) => value !== null);
90
+ if (inlineValue) {
91
+ return parseInteger(inlineValue);
92
+ }
93
+ const flagIndex = argv.indexOf("--parent-pid");
94
+ return flagIndex >= 0 ? parseInteger(argv[flagIndex + 1]) : null;
95
+ }
96
+ function createNoopWatcher() {
97
+ return {
98
+ start() {
99
+ },
100
+ stop() {
101
+ }
102
+ };
103
+ }
104
+ function createDefaultParentWatcherFactory() {
105
+ return ({ parentPid, onParentExit, pollIntervalMs, exitCode }) => new ParentPidWatcher({
106
+ parentPid,
107
+ pollIntervalMs,
108
+ onParentGone: () => {
109
+ onParentExit(exitCode);
43
110
  }
111
+ });
112
+ }
113
+ function sanitizeForwardHeaders(source) {
114
+ const headers = new Headers(source);
115
+ ["x-proxy-url", "host", "connection", "content-length"].forEach((headerName) => {
116
+ headers.delete(headerName);
117
+ });
118
+ return headers;
119
+ }
120
+ function copyResponseHeaders(source) {
121
+ const headers = new Headers(source);
122
+ ["transfer-encoding", "content-encoding"].forEach((headerName) => {
123
+ headers.delete(headerName);
124
+ });
125
+ return headers;
126
+ }
127
+ function resolveTargetUrl(req, allowedHosts) {
128
+ const targetUrl = req.headers.get("x-proxy-url");
129
+ if (!targetUrl) {
130
+ return new Response("Missing x-proxy-url", { status: 400 });
131
+ }
132
+ try {
133
+ const parsedUrl = new URL(targetUrl);
134
+ if (allowedHosts.size > 0 && !allowedHosts.has(parsedUrl.hostname)) {
135
+ return new Response(`Host not allowed: ${parsedUrl.hostname}`, { status: 403 });
136
+ }
137
+ return parsedUrl;
138
+ } catch {
139
+ return new Response("Invalid x-proxy-url", { status: 400 });
140
+ }
141
+ }
142
+ function createAbortContext(requestTimeoutMs) {
143
+ const timeoutController = new AbortController();
144
+ const timer = setTimeout(() => {
145
+ timeoutController.abort(new DOMException("Upstream request timed out", "TimeoutError"));
146
+ }, requestTimeoutMs);
147
+ timer.unref?.();
148
+ return {
149
+ timeoutSignal: timeoutController.signal,
150
+ cancelTimeout() {
151
+ clearTimeout(timer);
152
+ }
153
+ };
154
+ }
155
+ function isAbortError(error) {
156
+ return error instanceof DOMException ? error.name === "AbortError" || error.name === "TimeoutError" : error instanceof Error && (error.name === "AbortError" || error.name === "TimeoutError");
157
+ }
158
+ function isTimeoutAbort(signal) {
159
+ const reason = signal.reason;
160
+ return reason instanceof DOMException ? reason.name === "TimeoutError" : reason instanceof Error && reason.name === "TimeoutError";
161
+ }
162
+ function createAbortResponse(req, timeoutSignal) {
163
+ return req.signal.aborted ? new Response("Client disconnected", { status: 499 }) : isTimeoutAbort(timeoutSignal) ? new Response("Upstream request timed out", { status: 504 }) : new Response("Upstream request aborted", { status: 499 });
164
+ }
165
+ async function createUpstreamInit(req, signal) {
166
+ const method = req.method || "GET";
167
+ const hasBody = method !== "GET" && method !== "HEAD";
168
+ const bodyText = hasBody ? await req.text() : "";
169
+ return {
170
+ method,
171
+ headers: sanitizeForwardHeaders(req.headers),
172
+ signal,
173
+ ...hasBody && bodyText.length > 0 ? { body: bodyText } : {}
174
+ };
175
+ }
176
+ function logRequest(targetUrl, req) {
177
+ if (!DEBUG_ENABLED) {
178
+ return;
179
+ }
180
+ const logHeaders = Object.fromEntries(
181
+ [...sanitizeForwardHeaders(req.headers).entries()].map(([key, value]) => [
182
+ key,
183
+ key === "authorization" ? "Bearer ***" : value
184
+ ])
185
+ );
186
+ console.error("\n[bun-proxy] === FORWARDED REQUEST ===");
187
+ console.error(`[bun-proxy] ${req.method} ${targetUrl.toString()}`);
188
+ console.error(`[bun-proxy] Headers: ${JSON.stringify(logHeaders, null, 2)}`);
189
+ console.error("[bun-proxy] =========================\n");
190
+ }
191
+ function createProxyRequestHandler(options) {
192
+ const allowedHosts = new Set(options.allowHosts ?? DEFAULT_ALLOWED_HOSTS);
193
+ const requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
194
+ return async function handleProxyRequest(req) {
195
+ if (new URL(req.url).pathname === HEALTH_PATH) {
196
+ return new Response("ok");
197
+ }
198
+ const targetUrl = resolveTargetUrl(req, allowedHosts);
199
+ if (targetUrl instanceof Response) {
200
+ return targetUrl;
201
+ }
202
+ const abortContext = createAbortContext(requestTimeoutMs);
203
+ const upstreamSignal = AbortSignal.any([req.signal, abortContext.timeoutSignal]);
204
+ const upstreamInit = await createUpstreamInit(req, upstreamSignal);
205
+ logRequest(targetUrl, req);
44
206
  try {
45
- const resp = await fetch(targetUrl, {
46
- method: req.method,
47
- headers,
48
- body
207
+ const upstreamResponse = await options.fetchImpl(targetUrl.toString(), upstreamInit);
208
+ return new Response(upstreamResponse.body, {
209
+ status: upstreamResponse.status,
210
+ statusText: upstreamResponse.statusText,
211
+ headers: copyResponseHeaders(upstreamResponse.headers)
49
212
  });
50
- const respHeaders = new Headers(resp.headers);
51
- respHeaders.delete("transfer-encoding");
52
- respHeaders.delete("content-encoding");
53
- return new Response(resp.body, {
54
- status: resp.status,
55
- statusText: resp.statusText,
56
- headers: respHeaders
57
- });
58
- } catch (err) {
59
- const msg = err instanceof Error ? err.message : String(err);
60
- return new Response(msg, { status: 502 });
213
+ } catch (error) {
214
+ if (upstreamSignal.aborted && isAbortError(error)) {
215
+ return createAbortResponse(req, abortContext.timeoutSignal);
216
+ }
217
+ const message = error instanceof Error ? error.message : String(error);
218
+ return new Response(message, { status: 502 });
219
+ } finally {
220
+ abortContext.cancelTimeout();
221
+ }
222
+ };
223
+ }
224
+ function createProxyProcessRuntime(options = {}) {
225
+ const argv = options.argv ?? process.argv;
226
+ const parentPid = parseParentPid(argv);
227
+ if (!parentPid) {
228
+ return createNoopWatcher();
229
+ }
230
+ const exit = options.exit ?? process.exit;
231
+ const parentWatcherFactory = options.parentWatcherFactory ?? createDefaultParentWatcherFactory();
232
+ return parentWatcherFactory({
233
+ parentPid,
234
+ pollIntervalMs: DEFAULT_PARENT_POLL_INTERVAL_MS,
235
+ exitCode: DEFAULT_PARENT_EXIT_CODE,
236
+ onParentExit: (exitCode) => {
237
+ exit(exitCode ?? DEFAULT_PARENT_EXIT_CODE);
61
238
  }
239
+ });
240
+ }
241
+ function assertBunRuntime() {
242
+ if (typeof Bun === "undefined") {
243
+ throw new Error("bun-proxy.ts must be executed with Bun.");
62
244
  }
63
- });
64
- console.log(`BUN_PROXY_PORT=${server.port}`);
245
+ return Bun;
246
+ }
247
+ async function runProxyProcess() {
248
+ const bun = assertBunRuntime();
249
+ const watcher = createProxyProcessRuntime();
250
+ const server = bun.serve({
251
+ port: 0,
252
+ fetch: createProxyRequestHandler({
253
+ fetchImpl: fetch,
254
+ allowHosts: DEFAULT_ALLOWED_HOSTS,
255
+ requestTimeoutMs: DEFAULT_REQUEST_TIMEOUT_MS
256
+ })
257
+ });
258
+ const lifecycle = {
259
+ closed: false
260
+ };
261
+ const shutdown = (exitCode = 0) => {
262
+ if (lifecycle.closed) {
263
+ return;
264
+ }
265
+ lifecycle.closed = true;
266
+ watcher.stop();
267
+ server.stop(true);
268
+ process.exit(exitCode);
269
+ };
270
+ process.on("SIGTERM", () => {
271
+ shutdown(0);
272
+ });
273
+ process.on("SIGINT", () => {
274
+ shutdown(0);
275
+ });
276
+ watcher.start();
277
+ process.stdout.write(`BUN_PROXY_PORT=${server.port}
278
+ `);
279
+ }
280
+ if (isMainModule()) {
281
+ void runProxyProcess().catch((error) => {
282
+ const message = error instanceof Error ? error.stack ?? error.message : String(error);
283
+ process.stderr.write(`${message}
284
+ `);
285
+ process.exit(1);
286
+ });
287
+ }
288
+ export {
289
+ createProxyProcessRuntime,
290
+ createProxyRequestHandler
291
+ };