opencode-anthropic-fix 0.2.1 → 0.3.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.
- package/README.md +24 -0
- package/index.mjs +155 -149
- package/lib/config.mjs +47 -25
- package/lib/mimicry/adapter-input.mjs +671 -0
- package/lib/mimicry/context-hint-threshold.mjs +127 -0
- package/lib/mimicry/headers.mjs +71 -64
- package/lib/mimicry/request-body.mjs +23 -22
- package/lib/mimicry/system-prompt.mjs +54 -23
- package/lib/mimicry/wire-compat.mjs +305 -0
- package/package.json +3 -3
- package/lib/account-state.test.mjs +0 -109
- package/lib/accounts.test.mjs +0 -912
- package/lib/adaptive-thinking-models.test.mjs +0 -30
- package/lib/backoff.test.mjs +0 -411
- package/lib/cc-credentials.test.mjs +0 -391
- package/lib/config.test.mjs +0 -764
- package/lib/context-hint-persist.mjs +0 -68
- package/lib/diagnose.test.mjs +0 -122
- package/lib/env.test.mjs +0 -25
- package/lib/haiku-call.test.mjs +0 -72
- package/lib/message-transform.test.mjs +0 -173
- package/lib/mimicry/cache.test.mjs +0 -112
- package/lib/mimicry/headers.test.mjs +0 -50
- package/lib/mimicry/models.test.mjs +0 -132
- package/lib/mimicry/request-body.test.mjs +0 -34
- package/lib/mimicry/request-helpers.test.mjs +0 -155
- package/lib/mimicry/response-stream.test.mjs +0 -127
- package/lib/mimicry/system-prompt.test.mjs +0 -73
- package/lib/oauth.test.mjs +0 -343
- package/lib/redact.test.mjs +0 -154
- package/lib/refresh-lock.test.mjs +0 -131
- package/lib/request-headers.test.mjs +0 -164
- package/lib/retry/overload-loop.test.mjs +0 -107
- package/lib/rotation.test.mjs +0 -392
- package/lib/session-metrics.test.mjs +0 -67
- package/lib/storage.test.mjs +0 -696
- package/lib/token-economy/microcompact.test.mjs +0 -28
- package/lib/token-economy/transforms.test.mjs +0 -192
- package/lib/tuning.test.mjs +0 -89
package/lib/oauth.test.mjs
DELETED
|
@@ -1,343 +0,0 @@
|
|
|
1
|
-
import { beforeEach, afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
-
import { exchange, parseOAuthCallback, refreshToken, revoke } from "./oauth.mjs";
|
|
3
|
-
|
|
4
|
-
const mockFetch = vi.fn();
|
|
5
|
-
|
|
6
|
-
describe("oauth headers", () => {
|
|
7
|
-
beforeEach(() => {
|
|
8
|
-
vi.resetAllMocks();
|
|
9
|
-
vi.stubGlobal("fetch", mockFetch);
|
|
10
|
-
});
|
|
11
|
-
|
|
12
|
-
afterEach(() => {
|
|
13
|
-
vi.unstubAllGlobals();
|
|
14
|
-
});
|
|
15
|
-
|
|
16
|
-
it("sends SDK native-fetch fingerprint by default on token exchange", async () => {
|
|
17
|
-
mockFetch.mockResolvedValueOnce({
|
|
18
|
-
ok: false,
|
|
19
|
-
status: 400,
|
|
20
|
-
text: async () => "",
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
await exchange("code#state", "verifier");
|
|
24
|
-
|
|
25
|
-
const [, init] = mockFetch.mock.calls[0];
|
|
26
|
-
expect(init.headers["User-Agent"]).toBe("anthropic-sdk-typescript/0.94.0 userOAuthProvider");
|
|
27
|
-
expect(init.headers["anthropic-beta"]).toBe("oauth-2025-04-20");
|
|
28
|
-
expect("Accept" in init.headers).toBe(false);
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
it("sends SDK native-fetch fingerprint by default on token refresh", async () => {
|
|
32
|
-
mockFetch.mockResolvedValueOnce({
|
|
33
|
-
ok: true,
|
|
34
|
-
json: async () => ({ access_token: "a", refresh_token: "r", expires_in: 3600 }),
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
await refreshToken("refresh-token");
|
|
38
|
-
|
|
39
|
-
const [, init] = mockFetch.mock.calls[0];
|
|
40
|
-
expect(init.headers["User-Agent"]).toBe("anthropic-sdk-typescript/0.94.0 userOAuthProvider");
|
|
41
|
-
expect(init.headers["anthropic-beta"]).toBe("oauth-2025-04-20");
|
|
42
|
-
expect("Accept" in init.headers).toBe(false);
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
it("sends Claude Code user-agent on token revoke", async () => {
|
|
46
|
-
mockFetch.mockResolvedValueOnce({ ok: true });
|
|
47
|
-
|
|
48
|
-
await revoke("refresh-token");
|
|
49
|
-
|
|
50
|
-
const [, init] = mockFetch.mock.calls[0];
|
|
51
|
-
expect(init.headers["User-Agent"]).toBe("axios/1.13.6");
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
it("flag OFF (default) keeps axios fingerprint on exchange", async () => {
|
|
55
|
-
mockFetch.mockResolvedValueOnce({ ok: false, status: 400, text: async () => "" });
|
|
56
|
-
|
|
57
|
-
await exchange("code#state", "verifier", { sdkTokenUserAgent: false });
|
|
58
|
-
|
|
59
|
-
const [, init] = mockFetch.mock.calls[0];
|
|
60
|
-
expect(init.headers["User-Agent"]).toBe("axios/1.13.6");
|
|
61
|
-
expect(init.headers["Accept"]).toBe("application/json, text/plain, */*");
|
|
62
|
-
expect(init.headers["Content-Type"]).toBe("application/json");
|
|
63
|
-
expect(init.headers["anthropic-beta"]).toBeUndefined();
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
it("flag ON sends SDK native-fetch fingerprint on exchange", async () => {
|
|
67
|
-
mockFetch.mockResolvedValueOnce({ ok: false, status: 400, text: async () => "" });
|
|
68
|
-
|
|
69
|
-
await exchange("code#state", "verifier", { sdkTokenUserAgent: true });
|
|
70
|
-
|
|
71
|
-
const [, init] = mockFetch.mock.calls[0];
|
|
72
|
-
expect(init.headers["User-Agent"]).toBe("anthropic-sdk-typescript/0.94.0 userOAuthProvider");
|
|
73
|
-
expect(init.headers["anthropic-beta"]).toBe("oauth-2025-04-20");
|
|
74
|
-
expect(init.headers["Content-Type"]).toBe("application/json");
|
|
75
|
-
expect("Accept" in init.headers).toBe(false);
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
it("flag OFF (default) keeps axios fingerprint on refresh", async () => {
|
|
79
|
-
mockFetch.mockResolvedValueOnce({
|
|
80
|
-
ok: true,
|
|
81
|
-
json: async () => ({ access_token: "a", refresh_token: "r", expires_in: 3600 }),
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
await refreshToken("refresh-token", { sdkTokenUserAgent: false });
|
|
85
|
-
|
|
86
|
-
const [, init] = mockFetch.mock.calls[0];
|
|
87
|
-
expect(init.headers["User-Agent"]).toBe("axios/1.13.6");
|
|
88
|
-
expect(init.headers["Accept"]).toBe("application/json, text/plain, */*");
|
|
89
|
-
expect(init.headers["Content-Type"]).toBe("application/json");
|
|
90
|
-
expect(init.headers["anthropic-beta"]).toBeUndefined();
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
it("flag ON sends SDK native-fetch fingerprint on refresh", async () => {
|
|
94
|
-
mockFetch.mockResolvedValueOnce({
|
|
95
|
-
ok: true,
|
|
96
|
-
json: async () => ({ access_token: "a", refresh_token: "r", expires_in: 3600 }),
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
await refreshToken("refresh-token", { sdkTokenUserAgent: true });
|
|
100
|
-
|
|
101
|
-
const [, init] = mockFetch.mock.calls[0];
|
|
102
|
-
expect(init.headers["User-Agent"]).toBe("anthropic-sdk-typescript/0.94.0 userOAuthProvider");
|
|
103
|
-
expect(init.headers["anthropic-beta"]).toBe("oauth-2025-04-20");
|
|
104
|
-
expect(init.headers["Content-Type"]).toBe("application/json");
|
|
105
|
-
expect("Accept" in init.headers).toBe(false);
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
it("parses Anthropic nested error payloads on exchange failure", async () => {
|
|
109
|
-
mockFetch.mockResolvedValueOnce({
|
|
110
|
-
ok: false,
|
|
111
|
-
status: 400,
|
|
112
|
-
headers: { get: () => null },
|
|
113
|
-
text: async () => JSON.stringify({ error: { type: "invalid_grant", message: "Bad auth code." } }),
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
const result = await exchange("code#state", "verifier");
|
|
117
|
-
expect(result.type).toBe("failed");
|
|
118
|
-
expect(result.status).toBe(400);
|
|
119
|
-
expect(result.code).toBe("invalid_grant");
|
|
120
|
-
expect(result.reason).toBe("Bad auth code.");
|
|
121
|
-
});
|
|
122
|
-
|
|
123
|
-
it("retries token exchange on 429 before succeeding", async () => {
|
|
124
|
-
mockFetch
|
|
125
|
-
.mockResolvedValueOnce({
|
|
126
|
-
ok: false,
|
|
127
|
-
status: 429,
|
|
128
|
-
headers: { get: (key) => (key === "retry-after-ms" ? "1" : null) },
|
|
129
|
-
text: async () => JSON.stringify({ error: { type: "rate_limit_error", message: "retry" } }),
|
|
130
|
-
})
|
|
131
|
-
.mockResolvedValueOnce({
|
|
132
|
-
ok: true,
|
|
133
|
-
json: async () => ({
|
|
134
|
-
access_token: "access",
|
|
135
|
-
refresh_token: "refresh",
|
|
136
|
-
expires_in: 3600,
|
|
137
|
-
account: { email_address: "retry@example.com" },
|
|
138
|
-
}),
|
|
139
|
-
});
|
|
140
|
-
|
|
141
|
-
const result = await exchange("code#state", "verifier");
|
|
142
|
-
expect(result.type).toBe("success");
|
|
143
|
-
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
144
|
-
});
|
|
145
|
-
|
|
146
|
-
it("retries token refresh on 429 before succeeding", async () => {
|
|
147
|
-
mockFetch
|
|
148
|
-
.mockResolvedValueOnce({
|
|
149
|
-
ok: false,
|
|
150
|
-
status: 429,
|
|
151
|
-
headers: { get: (key) => (key === "retry-after-ms" ? "1" : null) },
|
|
152
|
-
text: async () => JSON.stringify({ error: { type: "rate_limit_error", message: "retry" } }),
|
|
153
|
-
})
|
|
154
|
-
.mockResolvedValueOnce({
|
|
155
|
-
ok: true,
|
|
156
|
-
json: async () => ({ access_token: "a", refresh_token: "r", expires_in: 3600 }),
|
|
157
|
-
});
|
|
158
|
-
|
|
159
|
-
const result = await refreshToken("refresh-token");
|
|
160
|
-
expect(result.access_token).toBe("a");
|
|
161
|
-
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
162
|
-
});
|
|
163
|
-
|
|
164
|
-
it("uses nested error type as refresh error code", async () => {
|
|
165
|
-
mockFetch.mockResolvedValueOnce({
|
|
166
|
-
ok: false,
|
|
167
|
-
status: 400,
|
|
168
|
-
headers: { get: () => null },
|
|
169
|
-
text: async () => JSON.stringify({ error: { type: "invalid_grant", message: "Bad refresh token." } }),
|
|
170
|
-
});
|
|
171
|
-
|
|
172
|
-
await expect(refreshToken("refresh-token")).rejects.toMatchObject({
|
|
173
|
-
status: 400,
|
|
174
|
-
code: "invalid_grant",
|
|
175
|
-
});
|
|
176
|
-
});
|
|
177
|
-
|
|
178
|
-
it("returns cooldown hint on exchange 429 without Retry-After headers", async () => {
|
|
179
|
-
vi.useFakeTimers();
|
|
180
|
-
try {
|
|
181
|
-
mockFetch.mockResolvedValue({
|
|
182
|
-
ok: false,
|
|
183
|
-
status: 429,
|
|
184
|
-
headers: { get: () => null },
|
|
185
|
-
text: async () => JSON.stringify({ error: { type: "rate_limit_error", message: "Rate limited" } }),
|
|
186
|
-
});
|
|
187
|
-
|
|
188
|
-
const promise = exchange("code#state", "verifier");
|
|
189
|
-
await vi.runAllTimersAsync();
|
|
190
|
-
const result = await promise;
|
|
191
|
-
|
|
192
|
-
expect(result).toMatchObject({
|
|
193
|
-
type: "failed",
|
|
194
|
-
status: 429,
|
|
195
|
-
code: "rate_limit_error",
|
|
196
|
-
retryAfterMs: 30_000,
|
|
197
|
-
});
|
|
198
|
-
} finally {
|
|
199
|
-
vi.useRealTimers();
|
|
200
|
-
}
|
|
201
|
-
});
|
|
202
|
-
|
|
203
|
-
it("throws 30s cooldown hint on refresh 429 without Retry-After headers", async () => {
|
|
204
|
-
vi.useFakeTimers();
|
|
205
|
-
try {
|
|
206
|
-
mockFetch.mockResolvedValue({
|
|
207
|
-
ok: false,
|
|
208
|
-
status: 429,
|
|
209
|
-
headers: { get: () => null },
|
|
210
|
-
text: async () => JSON.stringify({ error: { type: "rate_limit_error", message: "Rate limited" } }),
|
|
211
|
-
});
|
|
212
|
-
|
|
213
|
-
const assertion = expect(refreshToken("refresh-token")).rejects.toMatchObject({
|
|
214
|
-
status: 429,
|
|
215
|
-
code: "rate_limit_error",
|
|
216
|
-
retryAfterMs: 30_000,
|
|
217
|
-
});
|
|
218
|
-
await vi.runAllTimersAsync();
|
|
219
|
-
await assertion;
|
|
220
|
-
} finally {
|
|
221
|
-
vi.useRealTimers();
|
|
222
|
-
}
|
|
223
|
-
});
|
|
224
|
-
});
|
|
225
|
-
|
|
226
|
-
describe("parseOAuthCallback", () => {
|
|
227
|
-
it("returns empty code and null state for empty/falsy input", () => {
|
|
228
|
-
expect(parseOAuthCallback("")).toEqual({ code: "", state: null });
|
|
229
|
-
expect(parseOAuthCallback(" ")).toEqual({ code: "", state: null });
|
|
230
|
-
expect(parseOAuthCallback(null)).toEqual({ code: "", state: null });
|
|
231
|
-
});
|
|
232
|
-
|
|
233
|
-
it("handles plain authorization code (no state)", () => {
|
|
234
|
-
expect(parseOAuthCallback("myplaincode123")).toEqual({ code: "myplaincode123", state: null });
|
|
235
|
-
});
|
|
236
|
-
|
|
237
|
-
it("trims surrounding whitespace from plain code", () => {
|
|
238
|
-
expect(parseOAuthCallback(" abc123 ")).toEqual({ code: "abc123", state: null });
|
|
239
|
-
});
|
|
240
|
-
|
|
241
|
-
it("parses legacy code#state bare format", () => {
|
|
242
|
-
expect(parseOAuthCallback("authcode#statetoken")).toEqual({ code: "authcode", state: "statetoken" });
|
|
243
|
-
});
|
|
244
|
-
|
|
245
|
-
it("returns null state when # present but empty state segment", () => {
|
|
246
|
-
expect(parseOAuthCallback("authcode#")).toEqual({ code: "authcode", state: null });
|
|
247
|
-
});
|
|
248
|
-
|
|
249
|
-
it("parses full redirect URL with query params", () => {
|
|
250
|
-
expect(parseOAuthCallback("https://example.com/callback?code=MYCODE&state=MYSTATE")).toEqual({
|
|
251
|
-
code: "MYCODE",
|
|
252
|
-
state: "MYSTATE",
|
|
253
|
-
});
|
|
254
|
-
});
|
|
255
|
-
|
|
256
|
-
it("parses full redirect URL with only code param", () => {
|
|
257
|
-
expect(parseOAuthCallback("https://example.com/callback?code=ONLYCODE")).toEqual({
|
|
258
|
-
code: "ONLYCODE",
|
|
259
|
-
state: null,
|
|
260
|
-
});
|
|
261
|
-
});
|
|
262
|
-
|
|
263
|
-
it("parses query string with leading ?", () => {
|
|
264
|
-
expect(parseOAuthCallback("?code=QS_CODE&state=QS_STATE")).toEqual({
|
|
265
|
-
code: "QS_CODE",
|
|
266
|
-
state: "QS_STATE",
|
|
267
|
-
});
|
|
268
|
-
});
|
|
269
|
-
|
|
270
|
-
it("parses bare query string without leading ?", () => {
|
|
271
|
-
expect(parseOAuthCallback("code=BARE_CODE&state=BARE_STATE")).toEqual({
|
|
272
|
-
code: "BARE_CODE",
|
|
273
|
-
state: "BARE_STATE",
|
|
274
|
-
});
|
|
275
|
-
});
|
|
276
|
-
|
|
277
|
-
it("parses hash fragment URL (OAuth implicit / SPA flows)", () => {
|
|
278
|
-
expect(parseOAuthCallback("https://example.com/callback#code=HASH_CODE&state=HASH_STATE")).toEqual({
|
|
279
|
-
code: "HASH_CODE",
|
|
280
|
-
state: "HASH_STATE",
|
|
281
|
-
});
|
|
282
|
-
});
|
|
283
|
-
|
|
284
|
-
it("parses bare hash fragment string", () => {
|
|
285
|
-
expect(parseOAuthCallback("#code=HFRAG_CODE&state=HFRAG_STATE")).toEqual({
|
|
286
|
-
code: "HFRAG_CODE",
|
|
287
|
-
state: "HFRAG_STATE",
|
|
288
|
-
});
|
|
289
|
-
});
|
|
290
|
-
|
|
291
|
-
it("URL-decodes percent-encoded code and state values", () => {
|
|
292
|
-
expect(parseOAuthCallback("?code=hello%20world&state=foo%2Bbar")).toEqual({
|
|
293
|
-
code: "hello world",
|
|
294
|
-
state: "foo+bar",
|
|
295
|
-
});
|
|
296
|
-
});
|
|
297
|
-
|
|
298
|
-
it("returns empty string (not null) when state query param is present but empty", () => {
|
|
299
|
-
// state= is explicitly present with an empty value; URLSearchParams.get() returns ""
|
|
300
|
-
// for keys that exist with no value, so state is "" not null.
|
|
301
|
-
expect(parseOAuthCallback("?code=CODE&state=")).toEqual({ code: "CODE", state: "" });
|
|
302
|
-
});
|
|
303
|
-
|
|
304
|
-
it("exchange function uses parseOAuthCallback internally (code#state still works)", async () => {
|
|
305
|
-
const mockFetch = vi.fn().mockResolvedValueOnce({
|
|
306
|
-
ok: true,
|
|
307
|
-
status: 200,
|
|
308
|
-
headers: { get: () => null },
|
|
309
|
-
json: async () => ({ access_token: "test_token", token_type: "bearer" }),
|
|
310
|
-
text: async () => '{"access_token":"test_token","token_type":"bearer"}',
|
|
311
|
-
});
|
|
312
|
-
vi.stubGlobal("fetch", mockFetch);
|
|
313
|
-
try {
|
|
314
|
-
await exchange("CODE_VALUE#STATE_VALUE", "verifier123");
|
|
315
|
-
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
316
|
-
const bodyStr = mockFetch.mock.calls[0][1].body;
|
|
317
|
-
const body = JSON.parse(bodyStr);
|
|
318
|
-
expect(body.code).toBe("CODE_VALUE");
|
|
319
|
-
expect(body.state).toBe("STATE_VALUE");
|
|
320
|
-
} finally {
|
|
321
|
-
vi.unstubAllGlobals();
|
|
322
|
-
}
|
|
323
|
-
});
|
|
324
|
-
|
|
325
|
-
it("exchange omits state field in body when input is a plain code", async () => {
|
|
326
|
-
const mockFetch = vi.fn().mockResolvedValueOnce({
|
|
327
|
-
ok: false,
|
|
328
|
-
status: 400,
|
|
329
|
-
headers: { get: () => null },
|
|
330
|
-
text: async () => "",
|
|
331
|
-
});
|
|
332
|
-
vi.stubGlobal("fetch", mockFetch);
|
|
333
|
-
try {
|
|
334
|
-
await exchange("PLAINCODE", "verifier456");
|
|
335
|
-
const bodyStr = mockFetch.mock.calls[0][1].body;
|
|
336
|
-
const body = JSON.parse(bodyStr);
|
|
337
|
-
expect(body.code).toBe("PLAINCODE");
|
|
338
|
-
expect(Object.prototype.hasOwnProperty.call(body, "state")).toBe(false);
|
|
339
|
-
} finally {
|
|
340
|
-
vi.unstubAllGlobals();
|
|
341
|
-
}
|
|
342
|
-
});
|
|
343
|
-
});
|
package/lib/redact.test.mjs
DELETED
|
@@ -1,154 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from "vitest";
|
|
2
|
-
import { redactHeaders, redactSecrets, redactString } from "./redact.mjs";
|
|
3
|
-
|
|
4
|
-
describe("redactSecrets", () => {
|
|
5
|
-
it("masks authorization without retaining token content", () => {
|
|
6
|
-
const token = "sk-ant-oat01-XXXX";
|
|
7
|
-
const input = { Authorization: `Bearer ${token}` };
|
|
8
|
-
const result = redactHeaders(input);
|
|
9
|
-
|
|
10
|
-
expect(result.Authorization).toMatch(/^Bearer \[redacted sha256:[0-9a-f]{12}\]$/);
|
|
11
|
-
expect(result.Authorization).not.toContain(token);
|
|
12
|
-
expect(input.Authorization).toBe(`Bearer ${token}`);
|
|
13
|
-
});
|
|
14
|
-
|
|
15
|
-
it("masks cookie and API key header values", () => {
|
|
16
|
-
const result = redactHeaders({ "set-cookie": "session=secret", "x-api-key": "key-secret" });
|
|
17
|
-
|
|
18
|
-
expect(result["set-cookie"]).toMatch(/^\[redacted sha256:[0-9a-f]{12}\]$/);
|
|
19
|
-
expect(result["x-api-key"]).toMatch(/^\[redacted sha256:[0-9a-f]{12}\]$/);
|
|
20
|
-
expect(result["set-cookie"]).not.toContain("session=secret");
|
|
21
|
-
expect(result["x-api-key"]).not.toContain("key-secret");
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
it("masks nested account credentials", () => {
|
|
25
|
-
const input = { account: { refreshToken: "refresh-secret", access: "access-secret" } };
|
|
26
|
-
const result = redactSecrets(input);
|
|
27
|
-
|
|
28
|
-
expect(result.account.refreshToken).toMatch(/^\[redacted sha256:[0-9a-f]{12}\]$/);
|
|
29
|
-
expect(result.account.access).toMatch(/^\[redacted sha256:[0-9a-f]{12}\]$/);
|
|
30
|
-
expect(result).not.toBe(input);
|
|
31
|
-
expect(result.account).not.toBe(input.account);
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
it("leaves mimicry fingerprint headers byte-identical", () => {
|
|
35
|
-
const input = {
|
|
36
|
-
"anthropic-beta": "oauth-2025-04-20,prompt-caching-2024-07-31",
|
|
37
|
-
"user-agent": "claude-cli/2.1.0 (external, cli)",
|
|
38
|
-
"x-stainless-arch": "x64",
|
|
39
|
-
"anthropic-version": "2023-06-01",
|
|
40
|
-
};
|
|
41
|
-
|
|
42
|
-
expect(redactHeaders(input)).toEqual(input);
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
it("leaves generic non-secret fields intact", () => {
|
|
46
|
-
const input = {
|
|
47
|
-
model: "claude-sonnet-4-5",
|
|
48
|
-
temperature: 0.7,
|
|
49
|
-
user: "alice",
|
|
50
|
-
messages: [{ role: "user", content: "hi" }],
|
|
51
|
-
};
|
|
52
|
-
|
|
53
|
-
expect(redactSecrets(input)).toEqual(input);
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
it("masks an email local part and preserves its domain", () => {
|
|
57
|
-
expect(redactSecrets({ email: "marco@example.com" })).toEqual({ email: "a***@example.com" });
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
it("is idempotent", () => {
|
|
61
|
-
const input = {
|
|
62
|
-
authorization: "Bearer secret-token",
|
|
63
|
-
account: { refresh_token: "refresh-secret", email: "marco@example.com" },
|
|
64
|
-
};
|
|
65
|
-
const once = redactSecrets(input);
|
|
66
|
-
|
|
67
|
-
expect(redactSecrets(once)).toEqual(once);
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
it("handles null, undefined, circular, and large objects without throwing", () => {
|
|
71
|
-
const circular = { token: "secret" };
|
|
72
|
-
circular.self = circular;
|
|
73
|
-
const large = Object.fromEntries(Array.from({ length: 10_000 }, (_, index) => [`key${index}`, `value${index}`]));
|
|
74
|
-
const redactedCircular = redactSecrets(circular);
|
|
75
|
-
const redactedLarge = redactSecrets(large);
|
|
76
|
-
|
|
77
|
-
expect(() => redactSecrets(null)).not.toThrow();
|
|
78
|
-
expect(() => redactSecrets(undefined)).not.toThrow();
|
|
79
|
-
expect(() => redactSecrets(circular)).not.toThrow();
|
|
80
|
-
expect(() => redactSecrets(large)).not.toThrow();
|
|
81
|
-
expect(redactedCircular.self).toBe(redactedCircular);
|
|
82
|
-
expect(Object.keys(redactedLarge)).toHaveLength(10_000);
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
it("grep-proof: no known secret survives redactSecrets over a realistic header+body fixture", () => {
|
|
86
|
-
const fakeSecrets = {
|
|
87
|
-
bearer: "sk-ant-FAKEAUTH123.def-456",
|
|
88
|
-
cookie: "session=FAKECOOKIE123",
|
|
89
|
-
apiKey: "FAKEAPIKEY123",
|
|
90
|
-
refreshToken: "FAKEREFRESH123",
|
|
91
|
-
access: "FAKEACCESS123",
|
|
92
|
-
code: "FAKECODE123",
|
|
93
|
-
email: "fake.user@example.test",
|
|
94
|
-
oauthToken: "oat01FAKEOAUTH12345",
|
|
95
|
-
};
|
|
96
|
-
const fixture = {
|
|
97
|
-
headers: {
|
|
98
|
-
authorization: `Bearer ${fakeSecrets.bearer}`,
|
|
99
|
-
cookie: fakeSecrets.cookie,
|
|
100
|
-
"x-api-key": fakeSecrets.apiKey,
|
|
101
|
-
"x-stainless-lang": "js",
|
|
102
|
-
},
|
|
103
|
-
body: {
|
|
104
|
-
account: {
|
|
105
|
-
refreshToken: fakeSecrets.refreshToken,
|
|
106
|
-
access: fakeSecrets.access,
|
|
107
|
-
code: fakeSecrets.code,
|
|
108
|
-
email: fakeSecrets.email,
|
|
109
|
-
},
|
|
110
|
-
callbackUrl: `https://auth.example.test/callback?token=${fakeSecrets.oauthToken}`,
|
|
111
|
-
},
|
|
112
|
-
};
|
|
113
|
-
|
|
114
|
-
const serialized = JSON.stringify(redactSecrets(fixture));
|
|
115
|
-
|
|
116
|
-
expect(serialized).not.toMatch(/Bearer\s+[A-Za-z0-9._-]{6,}/);
|
|
117
|
-
expect(serialized).not.toMatch(/sk-ant-[A-Za-z0-9]{6,}/);
|
|
118
|
-
expect(serialized).not.toMatch(/\boat01[A-Za-z0-9]{8,}/);
|
|
119
|
-
for (const secret of Object.values(fakeSecrets)) {
|
|
120
|
-
expect(serialized).not.toContain(secret);
|
|
121
|
-
}
|
|
122
|
-
expect(serialized).toContain('"x-stainless-lang":"js"');
|
|
123
|
-
});
|
|
124
|
-
});
|
|
125
|
-
|
|
126
|
-
describe("redactString", () => {
|
|
127
|
-
it("removes bearer token markers from body dumps", () => {
|
|
128
|
-
const result = redactString("prefix Bearer sk-ant-oat01-abc.def suffix");
|
|
129
|
-
|
|
130
|
-
expect(result).not.toContain("sk-ant");
|
|
131
|
-
expect(result).not.toContain("oat01");
|
|
132
|
-
expect(result).toMatch(/Bearer \[redacted sha256:[0-9a-f]{12}\]/);
|
|
133
|
-
});
|
|
134
|
-
|
|
135
|
-
it("redacts a bearer/sk-ant/oat01 token embedded mid-string in a URL", () => {
|
|
136
|
-
const bearerCredential = "abc.def-123";
|
|
137
|
-
const skAntToken = "sk-ant-abc123.def-456";
|
|
138
|
-
const oauthToken = "oat01ABCDEFGH12345";
|
|
139
|
-
const bearerResult = redactString(`https://api.example.com/cb?x=1#Authorization=Bearer%20${bearerCredential}`);
|
|
140
|
-
const skAntResult = redactString(`prefix ${skAntToken} suffix`);
|
|
141
|
-
const oauthResult = redactString(`https://auth.example.com/cb#token=...${oauthToken}...`);
|
|
142
|
-
|
|
143
|
-
for (const [result, rawToken] of [
|
|
144
|
-
[bearerResult, bearerCredential],
|
|
145
|
-
[skAntResult, skAntToken],
|
|
146
|
-
[oauthResult, oauthToken],
|
|
147
|
-
]) {
|
|
148
|
-
expect(result).not.toContain(rawToken);
|
|
149
|
-
expect(result).toMatch(/\[redacted sha256:[0-9a-f]{12}\]/);
|
|
150
|
-
}
|
|
151
|
-
expect(bearerResult).toContain("https://api.example.com/cb?x=1#Authorization=Bearer%20");
|
|
152
|
-
expect(oauthResult).toContain("https://auth.example.com/cb#token=...");
|
|
153
|
-
});
|
|
154
|
-
});
|
|
@@ -1,131 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
2
|
-
import { promises as fs } from "node:fs";
|
|
3
|
-
import { join } from "node:path";
|
|
4
|
-
import { tmpdir } from "node:os";
|
|
5
|
-
|
|
6
|
-
const itPosix = process.platform === "win32" ? it.skip : it;
|
|
7
|
-
const baseDir = join(tmpdir(), `opencode-refresh-lock-test-${process.pid}`);
|
|
8
|
-
const storagePath = join(baseDir, "anthropic-accounts.json");
|
|
9
|
-
|
|
10
|
-
vi.mock("./storage.mjs", () => ({
|
|
11
|
-
getStoragePath: () => storagePath,
|
|
12
|
-
}));
|
|
13
|
-
|
|
14
|
-
import { acquireRefreshLock, releaseRefreshLock } from "./refresh-lock.mjs";
|
|
15
|
-
|
|
16
|
-
describe("refresh-lock", () => {
|
|
17
|
-
beforeEach(async () => {
|
|
18
|
-
await fs.rm(baseDir, { recursive: true, force: true });
|
|
19
|
-
await fs.mkdir(baseDir, { recursive: true });
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
afterEach(async () => {
|
|
23
|
-
await fs.rm(baseDir, { recursive: true, force: true });
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
it("acquires the lock on the first attempt", async () => {
|
|
27
|
-
const lock = await acquireRefreshLock("acc-first-acquire");
|
|
28
|
-
|
|
29
|
-
expect(lock.acquired).toBe(true);
|
|
30
|
-
expect(lock.lockPath).toMatch(/[\\/]locks[\\/]refresh-[0-9a-f]{24}\.lock$/);
|
|
31
|
-
expect(lock.owner).toMatch(/^[0-9a-f]{24}$/);
|
|
32
|
-
expect(lock.lockInode).toEqual(expect.any(Number));
|
|
33
|
-
|
|
34
|
-
await releaseRefreshLock(lock);
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
it("allows exactly one concurrent caller to acquire the lock", async () => {
|
|
38
|
-
const attempts = await Promise.all([
|
|
39
|
-
acquireRefreshLock("acc-concurrent", { timeoutMs: 150, backoffMs: 5, staleMs: 60_000 }),
|
|
40
|
-
acquireRefreshLock("acc-concurrent", { timeoutMs: 150, backoffMs: 5, staleMs: 60_000 }),
|
|
41
|
-
]);
|
|
42
|
-
const winners = attempts.filter((attempt) => attempt.acquired);
|
|
43
|
-
const losers = attempts.filter((attempt) => !attempt.acquired);
|
|
44
|
-
|
|
45
|
-
expect(winners).toHaveLength(1);
|
|
46
|
-
expect(losers).toEqual([{ acquired: false, lockPath: null, owner: null, lockInode: null }]);
|
|
47
|
-
|
|
48
|
-
await releaseRefreshLock(winners[0]);
|
|
49
|
-
|
|
50
|
-
const reacquired = await acquireRefreshLock("acc-concurrent", { timeoutMs: 50 });
|
|
51
|
-
expect(reacquired.acquired).toBe(true);
|
|
52
|
-
await releaseRefreshLock(reacquired);
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
it("rejects when opening the lock file fails with a non-contention error", async () => {
|
|
56
|
-
const error = Object.assign(new Error("permission denied"), { code: "EACCES" });
|
|
57
|
-
const openSpy = vi.spyOn(fs, "open").mockRejectedValueOnce(error);
|
|
58
|
-
|
|
59
|
-
try {
|
|
60
|
-
await expect(acquireRefreshLock("acc-io-failure")).rejects.toMatchObject({ code: "EACCES" });
|
|
61
|
-
expect(openSpy).toHaveBeenCalledWith(expect.stringMatching(/refresh-[0-9a-f]{24}\.lock$/), "wx", 0o600);
|
|
62
|
-
} finally {
|
|
63
|
-
openSpy.mockRestore();
|
|
64
|
-
}
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
itPosix("creates the lock file with owner-only POSIX permissions", async () => {
|
|
68
|
-
const lock = await acquireRefreshLock("acc-posix-permissions");
|
|
69
|
-
|
|
70
|
-
expect(lock.acquired).toBe(true);
|
|
71
|
-
const stat = await fs.stat(lock.lockPath);
|
|
72
|
-
expect(stat.mode & 0o777).toBe(0o600);
|
|
73
|
-
|
|
74
|
-
await releaseRefreshLock(lock);
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
it("does not release lock with mismatched owner", async () => {
|
|
78
|
-
const lock = await acquireRefreshLock("acc-1");
|
|
79
|
-
expect(lock.acquired).toBe(true);
|
|
80
|
-
expect(lock.lockPath).toBeTruthy();
|
|
81
|
-
|
|
82
|
-
await releaseRefreshLock({ lockPath: lock.lockPath, owner: "wrong-owner" });
|
|
83
|
-
|
|
84
|
-
await expect(fs.stat(lock.lockPath)).resolves.toBeTruthy();
|
|
85
|
-
|
|
86
|
-
await releaseRefreshLock(lock);
|
|
87
|
-
await expect(fs.stat(lock.lockPath)).rejects.toMatchObject({ code: "ENOENT" });
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
it("acquires a new lock after stale lock timeout", async () => {
|
|
91
|
-
const first = await acquireRefreshLock("acc-2", { timeoutMs: 50, staleMs: 10_000 });
|
|
92
|
-
expect(first.acquired).toBe(true);
|
|
93
|
-
|
|
94
|
-
const old = Date.now() / 1000 - 120;
|
|
95
|
-
await fs.utimes(first.lockPath, old, old);
|
|
96
|
-
|
|
97
|
-
const second = await acquireRefreshLock("acc-2", { timeoutMs: 200, backoffMs: 5, staleMs: 20 });
|
|
98
|
-
expect(second.acquired).toBe(true);
|
|
99
|
-
expect(second.owner).not.toBe(first.owner);
|
|
100
|
-
|
|
101
|
-
await releaseRefreshLock(second);
|
|
102
|
-
});
|
|
103
|
-
|
|
104
|
-
it("returns not acquired when lock remains busy", async () => {
|
|
105
|
-
const first = await acquireRefreshLock("acc-3", { timeoutMs: 50 });
|
|
106
|
-
expect(first.acquired).toBe(true);
|
|
107
|
-
|
|
108
|
-
const second = await acquireRefreshLock("acc-3", { timeoutMs: 30, backoffMs: 5, staleMs: 60_000 });
|
|
109
|
-
expect(second.acquired).toBe(false);
|
|
110
|
-
|
|
111
|
-
await releaseRefreshLock(first);
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
it("does not release when inode changed even if owner matches", async () => {
|
|
115
|
-
const first = await acquireRefreshLock("acc-4");
|
|
116
|
-
expect(first.acquired).toBe(true);
|
|
117
|
-
|
|
118
|
-
// Replace lock file with a new inode that reuses owner text.
|
|
119
|
-
await fs.unlink(first.lockPath);
|
|
120
|
-
await fs.writeFile(first.lockPath, JSON.stringify({ owner: first.owner, createdAt: Date.now() }), {
|
|
121
|
-
encoding: "utf-8",
|
|
122
|
-
mode: 0o600,
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
await releaseRefreshLock(first);
|
|
126
|
-
|
|
127
|
-
await expect(fs.stat(first.lockPath)).resolves.toBeTruthy();
|
|
128
|
-
|
|
129
|
-
await fs.unlink(first.lockPath);
|
|
130
|
-
});
|
|
131
|
-
});
|