clanka 0.0.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 (72) hide show
  1. package/README.md +3 -0
  2. package/dist/Agent.d.ts +119 -0
  3. package/dist/Agent.d.ts.map +1 -0
  4. package/dist/Agent.js +240 -0
  5. package/dist/Agent.js.map +1 -0
  6. package/dist/AgentTools.d.ts +246 -0
  7. package/dist/AgentTools.d.ts.map +1 -0
  8. package/dist/AgentTools.js +374 -0
  9. package/dist/AgentTools.js.map +1 -0
  10. package/dist/AgentTools.test.d.ts +2 -0
  11. package/dist/AgentTools.test.d.ts.map +1 -0
  12. package/dist/AgentTools.test.js +147 -0
  13. package/dist/AgentTools.test.js.map +1 -0
  14. package/dist/ApplyPatch.d.ts +27 -0
  15. package/dist/ApplyPatch.d.ts.map +1 -0
  16. package/dist/ApplyPatch.js +343 -0
  17. package/dist/ApplyPatch.js.map +1 -0
  18. package/dist/ApplyPatch.test.d.ts +2 -0
  19. package/dist/ApplyPatch.test.d.ts.map +1 -0
  20. package/dist/ApplyPatch.test.js +99 -0
  21. package/dist/ApplyPatch.test.js.map +1 -0
  22. package/dist/Codex.d.ts +11 -0
  23. package/dist/Codex.d.ts.map +1 -0
  24. package/dist/Codex.js +14 -0
  25. package/dist/Codex.js.map +1 -0
  26. package/dist/CodexAuth.d.ts +68 -0
  27. package/dist/CodexAuth.d.ts.map +1 -0
  28. package/dist/CodexAuth.js +270 -0
  29. package/dist/CodexAuth.js.map +1 -0
  30. package/dist/CodexAuth.test.d.ts +2 -0
  31. package/dist/CodexAuth.test.d.ts.map +1 -0
  32. package/dist/CodexAuth.test.js +425 -0
  33. package/dist/CodexAuth.test.js.map +1 -0
  34. package/dist/Executor.d.ts +20 -0
  35. package/dist/Executor.d.ts.map +1 -0
  36. package/dist/Executor.js +76 -0
  37. package/dist/Executor.js.map +1 -0
  38. package/dist/OutputFormatter.d.ts +11 -0
  39. package/dist/OutputFormatter.d.ts.map +1 -0
  40. package/dist/OutputFormatter.js +5 -0
  41. package/dist/OutputFormatter.js.map +1 -0
  42. package/dist/ToolkitRenderer.d.ts +17 -0
  43. package/dist/ToolkitRenderer.d.ts.map +1 -0
  44. package/dist/ToolkitRenderer.js +25 -0
  45. package/dist/ToolkitRenderer.js.map +1 -0
  46. package/dist/TypeBuilder.d.ts +11 -0
  47. package/dist/TypeBuilder.d.ts.map +1 -0
  48. package/dist/TypeBuilder.js +383 -0
  49. package/dist/TypeBuilder.js.map +1 -0
  50. package/dist/TypeBuilder.test.d.ts +2 -0
  51. package/dist/TypeBuilder.test.d.ts.map +1 -0
  52. package/dist/TypeBuilder.test.js +243 -0
  53. package/dist/TypeBuilder.test.js.map +1 -0
  54. package/dist/index.d.ts +25 -0
  55. package/dist/index.d.ts.map +1 -0
  56. package/dist/index.js +25 -0
  57. package/dist/index.js.map +1 -0
  58. package/package.json +72 -0
  59. package/src/Agent.ts +398 -0
  60. package/src/AgentTools.test.ts +215 -0
  61. package/src/AgentTools.ts +507 -0
  62. package/src/ApplyPatch.test.ts +154 -0
  63. package/src/ApplyPatch.ts +473 -0
  64. package/src/Codex.ts +14 -0
  65. package/src/CodexAuth.test.ts +729 -0
  66. package/src/CodexAuth.ts +571 -0
  67. package/src/Executor.ts +129 -0
  68. package/src/OutputFormatter.ts +17 -0
  69. package/src/ToolkitRenderer.ts +39 -0
  70. package/src/TypeBuilder.test.ts +508 -0
  71. package/src/TypeBuilder.ts +670 -0
  72. package/src/index.ts +29 -0
@@ -0,0 +1,729 @@
1
+ import { assert, describe, it } from "@effect/vitest"
2
+ import { Deferred, Effect, Encoding, Fiber, Option, Ref } from "effect"
3
+ import {
4
+ HttpClient,
5
+ HttpClientRequest,
6
+ HttpClientResponse,
7
+ } from "effect/unstable/http"
8
+ import { KeyValueStore } from "effect/unstable/persistence"
9
+ import {
10
+ CodexAuth,
11
+ CodexAuthError,
12
+ ISSUER,
13
+ STORE_PREFIX,
14
+ STORE_TOKEN_KEY,
15
+ TOKEN_EXPIRY_BUFFER_MS,
16
+ TokenData,
17
+ extractAccountIdFromClaims,
18
+ extractAccountIdFromToken,
19
+ parseJwtClaims,
20
+ toCodexAuthKeyValueStore,
21
+ toTokenStore,
22
+ } from "./CodexAuth.ts"
23
+
24
+ const createJwt = (payload: string): string =>
25
+ `${Encoding.encodeBase64Url(JSON.stringify({ alg: "none" }))}.${Encoding.encodeBase64Url(payload)}.sig`
26
+
27
+ const createTestJwt = (payload: Record<string, unknown>): string =>
28
+ createJwt(JSON.stringify(payload))
29
+
30
+ const jsonResponse = (body: unknown, status = 200): Response =>
31
+ new Response(JSON.stringify(body), {
32
+ status,
33
+ headers: {
34
+ "content-type": "application/json",
35
+ },
36
+ })
37
+
38
+ const getBody = (request: HttpClientRequest.HttpClientRequest): string => {
39
+ if (request.body._tag !== "Uint8Array") {
40
+ throw new Error("Expected request body to be a Uint8Array payload")
41
+ }
42
+
43
+ return new TextDecoder().decode(request.body.body)
44
+ }
45
+
46
+ const makeClient = Effect.fn("makeClient")(function* (
47
+ handler: (
48
+ request: HttpClientRequest.HttpClientRequest,
49
+ attempt: number,
50
+ ) => Response,
51
+ ) {
52
+ const attempts = yield* Ref.make(0)
53
+ const requests = yield* Ref.make<Array<HttpClientRequest.HttpClientRequest>>(
54
+ [],
55
+ )
56
+ const client = HttpClient.make((request) =>
57
+ Effect.gen(function* () {
58
+ const attempt = yield* Ref.updateAndGet(attempts, (count) => count + 1)
59
+ yield* Ref.update(requests, (current) => [...current, request])
60
+ return HttpClientResponse.fromWeb(request, handler(request, attempt))
61
+ }),
62
+ )
63
+
64
+ return {
65
+ attempts,
66
+ client,
67
+ requests,
68
+ } as const
69
+ })
70
+
71
+ const makeEffectClient = Effect.fn("makeEffectClient")(function* (
72
+ handler: (
73
+ request: HttpClientRequest.HttpClientRequest,
74
+ attempt: number,
75
+ ) => Effect.Effect<Response>,
76
+ ) {
77
+ const attempts = yield* Ref.make(0)
78
+ const requests = yield* Ref.make<Array<HttpClientRequest.HttpClientRequest>>(
79
+ [],
80
+ )
81
+ const client = HttpClient.make((request) =>
82
+ Effect.gen(function* () {
83
+ const attempt = yield* Ref.updateAndGet(attempts, (count) => count + 1)
84
+ yield* Ref.update(requests, (current) => [...current, request])
85
+ return HttpClientResponse.fromWeb(
86
+ request,
87
+ yield* handler(request, attempt),
88
+ )
89
+ }),
90
+ )
91
+
92
+ return {
93
+ attempts,
94
+ client,
95
+ requests,
96
+ } as const
97
+ })
98
+
99
+ describe("CodexAuth", () => {
100
+ it.effect(
101
+ "persists token data through the prefixed schema store",
102
+ Effect.fn(function* () {
103
+ const kvs = yield* KeyValueStore.KeyValueStore
104
+ const tokenStore = toTokenStore(kvs)
105
+ const token = new TokenData({
106
+ access: "access-token",
107
+ refresh: "refresh-token",
108
+ expires: 1_700_000_000_000,
109
+ accountId: Option.some("account_123"),
110
+ })
111
+
112
+ yield* Effect.orDie(tokenStore.set(STORE_TOKEN_KEY, token))
113
+
114
+ const stored = yield* Effect.orDie(tokenStore.get(STORE_TOKEN_KEY))
115
+
116
+ assert.strictEqual(Option.isSome(stored), true)
117
+ if (Option.isNone(stored)) {
118
+ return
119
+ }
120
+
121
+ assert.strictEqual(stored.value.access, token.access)
122
+ assert.strictEqual(stored.value.refresh, token.refresh)
123
+ assert.strictEqual(stored.value.expires, token.expires)
124
+ assert.strictEqual(Option.isSome(stored.value.accountId), true)
125
+ if (Option.isSome(stored.value.accountId)) {
126
+ assert.strictEqual(stored.value.accountId.value, "account_123")
127
+ }
128
+
129
+ const rawValue = yield* Effect.orDie(
130
+ kvs.get(`${STORE_PREFIX}${STORE_TOKEN_KEY}`),
131
+ )
132
+ const unprefixedValue = yield* Effect.orDie(kvs.get(STORE_TOKEN_KEY))
133
+
134
+ assert.strictEqual(typeof rawValue, "string")
135
+ assert.strictEqual(unprefixedValue, undefined)
136
+ }, Effect.provide(KeyValueStore.layerMemory)),
137
+ )
138
+
139
+ it.effect(
140
+ "round-trips missing account ids as Option.none",
141
+ Effect.fn(function* () {
142
+ const kvs = yield* KeyValueStore.KeyValueStore
143
+ const prefixedStore = toCodexAuthKeyValueStore(kvs)
144
+ const tokenStore = toTokenStore(kvs)
145
+ const token = new TokenData({
146
+ access: "access-token",
147
+ refresh: "refresh-token",
148
+ expires: 1_700_000_000_000,
149
+ accountId: Option.none(),
150
+ })
151
+
152
+ yield* Effect.orDie(tokenStore.set(STORE_TOKEN_KEY, token))
153
+
154
+ const stored = yield* Effect.orDie(tokenStore.get(STORE_TOKEN_KEY))
155
+
156
+ assert.strictEqual(Option.isSome(stored), true)
157
+ if (Option.isNone(stored)) {
158
+ return
159
+ }
160
+
161
+ assert.strictEqual(Option.isNone(stored.value.accountId), true)
162
+ assert.strictEqual(
163
+ yield* Effect.orDie(prefixedStore.has(STORE_TOKEN_KEY)),
164
+ true,
165
+ )
166
+ }, Effect.provide(KeyValueStore.layerMemory)),
167
+ )
168
+
169
+ it("marks tokens expired using the refresh buffer", () => {
170
+ const now = Date.now()
171
+ const expiredSoon = new TokenData({
172
+ access: "access-token",
173
+ refresh: "refresh-token",
174
+ expires: now + TOKEN_EXPIRY_BUFFER_MS - 1_000,
175
+ accountId: Option.none(),
176
+ })
177
+ const stillValid = new TokenData({
178
+ access: "access-token",
179
+ refresh: "refresh-token",
180
+ expires: now + TOKEN_EXPIRY_BUFFER_MS + 60_000,
181
+ accountId: Option.none(),
182
+ })
183
+
184
+ assert.strictEqual(expiredSoon.isExpired(), true)
185
+ assert.strictEqual(stillValid.isExpired(), false)
186
+ })
187
+
188
+ it("constructs CodexAuthError with the expected tagged shape", () => {
189
+ const error = new CodexAuthError({
190
+ reason: "RefreshFailed",
191
+ message: "Could not refresh the token",
192
+ })
193
+
194
+ assert.strictEqual(error._tag, "CodexAuthError")
195
+ assert.strictEqual(error.reason, "RefreshFailed")
196
+ assert.strictEqual(error.message, "Could not refresh the token")
197
+ })
198
+
199
+ it("parses valid JWT claims from a base64url payload", () => {
200
+ assert.deepStrictEqual(
201
+ Option.getOrUndefined(
202
+ parseJwtClaims(
203
+ createTestJwt({
204
+ email: "test@example.com",
205
+ chatgpt_account_id: "acc-123",
206
+ }),
207
+ ),
208
+ ),
209
+ {
210
+ chatgpt_account_id: "acc-123",
211
+ },
212
+ )
213
+ })
214
+
215
+ it("returns none for JWTs without three parts", () => {
216
+ assert.strictEqual(Option.isNone(parseJwtClaims("invalid")), true)
217
+ assert.strictEqual(Option.isNone(parseJwtClaims("only.two")), true)
218
+ })
219
+
220
+ it("returns none for invalid base64url payloads", () => {
221
+ assert.strictEqual(Option.isNone(parseJwtClaims("a.!!!invalid!!!.b")), true)
222
+ })
223
+
224
+ it("returns none for invalid JSON payloads", () => {
225
+ assert.strictEqual(
226
+ Option.isNone(parseJwtClaims(createJwt("not json"))),
227
+ true,
228
+ )
229
+ })
230
+
231
+ it("ignores malformed claim types instead of failing the parse", () => {
232
+ assert.deepStrictEqual(
233
+ Option.getOrUndefined(
234
+ parseJwtClaims(createTestJwt({ chatgpt_account_id: 123 })),
235
+ ),
236
+ {},
237
+ )
238
+ })
239
+
240
+ it("keeps valid account ids when other claim locations are malformed", () => {
241
+ assert.strictEqual(
242
+ Option.getOrUndefined(
243
+ extractAccountIdFromToken(
244
+ createTestJwt({
245
+ chatgpt_account_id: "acc-root",
246
+ "https://api.openai.com/auth": "invalid",
247
+ }),
248
+ ),
249
+ ),
250
+ "acc-root",
251
+ )
252
+ assert.strictEqual(
253
+ Option.getOrUndefined(
254
+ extractAccountIdFromToken(
255
+ createTestJwt({
256
+ "https://api.openai.com/auth": {
257
+ chatgpt_account_id: "acc-nested",
258
+ },
259
+ organizations: [123],
260
+ }),
261
+ ),
262
+ ),
263
+ "acc-nested",
264
+ )
265
+ })
266
+
267
+ it("extracts account ids from the documented claim locations", () => {
268
+ assert.strictEqual(
269
+ Option.getOrUndefined(
270
+ extractAccountIdFromClaims({ chatgpt_account_id: "acc-root" }),
271
+ ),
272
+ "acc-root",
273
+ )
274
+ assert.strictEqual(
275
+ Option.getOrUndefined(
276
+ extractAccountIdFromClaims({
277
+ "https://api.openai.com/auth": {
278
+ chatgpt_account_id: "acc-nested",
279
+ },
280
+ }),
281
+ ),
282
+ "acc-nested",
283
+ )
284
+ assert.strictEqual(
285
+ Option.getOrUndefined(
286
+ extractAccountIdFromClaims({
287
+ organizations: [{ id: "org-123" }, { id: "org-456" }],
288
+ }),
289
+ ),
290
+ "org-123",
291
+ )
292
+ })
293
+
294
+ it("prefers root claims over nested and organization fallbacks", () => {
295
+ assert.strictEqual(
296
+ Option.getOrUndefined(
297
+ extractAccountIdFromClaims({
298
+ chatgpt_account_id: "acc-root",
299
+ "https://api.openai.com/auth": {
300
+ chatgpt_account_id: "acc-nested",
301
+ },
302
+ organizations: [{ id: "org-123" }],
303
+ }),
304
+ ),
305
+ "acc-root",
306
+ )
307
+ })
308
+
309
+ it("treats empty root and nested account ids as missing", () => {
310
+ assert.strictEqual(
311
+ Option.getOrUndefined(
312
+ extractAccountIdFromClaims({
313
+ chatgpt_account_id: "",
314
+ "https://api.openai.com/auth": {
315
+ chatgpt_account_id: "acc-nested",
316
+ },
317
+ }),
318
+ ),
319
+ "acc-nested",
320
+ )
321
+ assert.strictEqual(
322
+ Option.getOrUndefined(
323
+ extractAccountIdFromClaims({
324
+ chatgpt_account_id: "",
325
+ "https://api.openai.com/auth": {
326
+ chatgpt_account_id: "",
327
+ },
328
+ organizations: [{ id: "org-123" }],
329
+ }),
330
+ ),
331
+ "org-123",
332
+ )
333
+ })
334
+
335
+ it("treats empty organization ids as missing", () => {
336
+ assert.strictEqual(
337
+ Option.isNone(
338
+ extractAccountIdFromClaims({
339
+ organizations: [{ id: "" }],
340
+ }),
341
+ ),
342
+ true,
343
+ )
344
+ })
345
+
346
+ it("returns none when no account id claim is present", () => {
347
+ assert.strictEqual(
348
+ Option.isNone(
349
+ extractAccountIdFromClaims({
350
+ organizations: [],
351
+ }),
352
+ ),
353
+ true,
354
+ )
355
+ })
356
+
357
+ it("extracts account ids directly from JWTs", () => {
358
+ assert.strictEqual(
359
+ Option.getOrUndefined(
360
+ extractAccountIdFromToken(
361
+ createTestJwt({
362
+ "https://api.openai.com/auth": {
363
+ chatgpt_account_id: "acc-token",
364
+ },
365
+ }),
366
+ ),
367
+ ),
368
+ "acc-token",
369
+ )
370
+ assert.strictEqual(
371
+ Option.isNone(extractAccountIdFromToken("invalid")),
372
+ true,
373
+ )
374
+ })
375
+
376
+ it.effect(
377
+ "preserves the stored account id when refreshed tokens omit parseable claims",
378
+ () =>
379
+ Effect.gen(function* () {
380
+ const kvs = yield* KeyValueStore.KeyValueStore
381
+ const tokenStore = toTokenStore(kvs)
382
+ yield* Effect.orDie(
383
+ tokenStore.set(
384
+ STORE_TOKEN_KEY,
385
+ new TokenData({
386
+ access: "stale-access-token",
387
+ refresh: "stale-refresh-token",
388
+ expires: Date.now() - 60_000,
389
+ accountId: Option.some("persisted-account"),
390
+ }),
391
+ ),
392
+ )
393
+
394
+ const { attempts, client } = yield* makeClient(() =>
395
+ jsonResponse({
396
+ id_token: "invalid",
397
+ access_token: "also-invalid",
398
+ refresh_token: "next-refresh-token",
399
+ expires_in: 120,
400
+ }),
401
+ )
402
+
403
+ const auth = yield* CodexAuth.make.pipe(
404
+ Effect.provideService(HttpClient.HttpClient, client),
405
+ )
406
+
407
+ const token = yield* auth.get
408
+
409
+ assert.strictEqual(token.refresh, "next-refresh-token")
410
+ assert.strictEqual(
411
+ Option.getOrUndefined(token.accountId),
412
+ "persisted-account",
413
+ )
414
+ assert.strictEqual(yield* Ref.get(attempts), 1)
415
+
416
+ const stored = yield* Effect.orDie(tokenStore.get(STORE_TOKEN_KEY))
417
+ assert.strictEqual(Option.isSome(stored), true)
418
+ if (Option.isNone(stored)) {
419
+ return
420
+ }
421
+
422
+ assert.strictEqual(
423
+ Option.getOrUndefined(stored.value.accountId),
424
+ "persisted-account",
425
+ )
426
+ }).pipe(Effect.provide(KeyValueStore.layerMemory)),
427
+ )
428
+
429
+ it.effect(
430
+ "falls back to device auth when refreshing an expired token fails",
431
+ () =>
432
+ Effect.gen(function* () {
433
+ const kvs = yield* KeyValueStore.KeyValueStore
434
+ const tokenStore = toTokenStore(kvs)
435
+ yield* Effect.orDie(
436
+ tokenStore.set(
437
+ STORE_TOKEN_KEY,
438
+ new TokenData({
439
+ access: "stale-access-token",
440
+ refresh: "stale-refresh-token",
441
+ expires: Date.now() - 60_000,
442
+ accountId: Option.some("stale-account"),
443
+ }),
444
+ ),
445
+ )
446
+
447
+ const { client, requests } = yield* makeClient((request) => {
448
+ if (request.url === `${ISSUER}/api/accounts/deviceauth/usercode`) {
449
+ return jsonResponse({
450
+ device_auth_id: "device-auth-id",
451
+ user_code: "WXYZ-9876",
452
+ interval: "1",
453
+ })
454
+ }
455
+
456
+ if (request.url === `${ISSUER}/api/accounts/deviceauth/token`) {
457
+ return jsonResponse({
458
+ authorization_code: "authorization-code",
459
+ code_verifier: "code-verifier",
460
+ })
461
+ }
462
+
463
+ if (request.url === `${ISSUER}/oauth/token`) {
464
+ const body = new URLSearchParams(getBody(request))
465
+ if (body.get("grant_type") === "refresh_token") {
466
+ return new Response(null, { status: 401 })
467
+ }
468
+
469
+ return jsonResponse({
470
+ id_token: createTestJwt({
471
+ chatgpt_account_id: "account-from-id",
472
+ }),
473
+ access_token: createTestJwt({
474
+ chatgpt_account_id: "account-from-access",
475
+ }),
476
+ refresh_token: "next-refresh-token",
477
+ expires_in: 120,
478
+ })
479
+ }
480
+
481
+ return new Response(null, { status: 500 })
482
+ })
483
+
484
+ const auth = yield* CodexAuth.make.pipe(
485
+ Effect.provideService(HttpClient.HttpClient, client),
486
+ )
487
+
488
+ const token = yield* auth.get
489
+
490
+ assert.strictEqual(token.refresh, "next-refresh-token")
491
+ assert.strictEqual(
492
+ Option.getOrUndefined(token.accountId),
493
+ "account-from-id",
494
+ )
495
+
496
+ const stored = yield* Effect.orDie(tokenStore.get(STORE_TOKEN_KEY))
497
+ assert.strictEqual(Option.isSome(stored), true)
498
+ if (Option.isSome(stored)) {
499
+ assert.strictEqual(stored.value.refresh, "next-refresh-token")
500
+ }
501
+
502
+ const seenRequests = yield* Ref.get(requests)
503
+ assert.strictEqual(seenRequests.length, 4)
504
+ assert.strictEqual(seenRequests[0]?.url, `${ISSUER}/oauth/token`)
505
+ assert.strictEqual(
506
+ new URLSearchParams(getBody(seenRequests[0]!)).get("grant_type"),
507
+ "refresh_token",
508
+ )
509
+ assert.strictEqual(
510
+ new URLSearchParams(getBody(seenRequests[3]!)).get("grant_type"),
511
+ "authorization_code",
512
+ )
513
+ }).pipe(Effect.provide(KeyValueStore.layerMemory)),
514
+ )
515
+
516
+ it.effect("clears corrupted persisted tokens before re-authenticating", () =>
517
+ Effect.gen(function* () {
518
+ const kvs = yield* KeyValueStore.KeyValueStore
519
+ yield* Effect.orDie(
520
+ kvs.set(`${STORE_PREFIX}${STORE_TOKEN_KEY}`, "not-json"),
521
+ )
522
+
523
+ const { attempts, client } = yield* makeClient((request) => {
524
+ if (request.url === `${ISSUER}/api/accounts/deviceauth/usercode`) {
525
+ return jsonResponse({
526
+ device_auth_id: "device-auth-id",
527
+ user_code: "ABCD-EFGH",
528
+ interval: "1",
529
+ })
530
+ }
531
+
532
+ if (request.url === `${ISSUER}/api/accounts/deviceauth/token`) {
533
+ return jsonResponse({
534
+ authorization_code: "authorization-code",
535
+ code_verifier: "code-verifier",
536
+ })
537
+ }
538
+
539
+ if (request.url === `${ISSUER}/oauth/token`) {
540
+ return jsonResponse({
541
+ access_token: createTestJwt({
542
+ chatgpt_account_id: "fresh-account",
543
+ }),
544
+ refresh_token: "fresh-refresh-token",
545
+ expires_in: 120,
546
+ })
547
+ }
548
+
549
+ return new Response(null, { status: 500 })
550
+ })
551
+
552
+ const auth = yield* CodexAuth.make.pipe(
553
+ Effect.provideService(HttpClient.HttpClient, client),
554
+ )
555
+
556
+ assert.strictEqual(
557
+ yield* Effect.orDie(kvs.get(`${STORE_PREFIX}${STORE_TOKEN_KEY}`)),
558
+ undefined,
559
+ )
560
+ assert.strictEqual(yield* Ref.get(attempts), 0)
561
+
562
+ const token = yield* auth.get
563
+
564
+ assert.strictEqual(token.refresh, "fresh-refresh-token")
565
+ assert.strictEqual(
566
+ Option.getOrUndefined(token.accountId),
567
+ "fresh-account",
568
+ )
569
+ assert.strictEqual(yield* Ref.get(attempts), 3)
570
+ }).pipe(Effect.provide(KeyValueStore.layerMemory)),
571
+ )
572
+
573
+ it.effect("serializes concurrent get calls behind one refresh", () =>
574
+ Effect.gen(function* () {
575
+ const kvs = yield* KeyValueStore.KeyValueStore
576
+ const tokenStore = toTokenStore(kvs)
577
+ yield* Effect.orDie(
578
+ tokenStore.set(
579
+ STORE_TOKEN_KEY,
580
+ new TokenData({
581
+ access: "expired-access-token",
582
+ refresh: "refresh-token",
583
+ expires: Date.now() - 60_000,
584
+ accountId: Option.none(),
585
+ }),
586
+ ),
587
+ )
588
+
589
+ const refreshStarted = yield* Deferred.make<void>()
590
+ const releaseRefresh = yield* Deferred.make<void>()
591
+ const { attempts, client } = yield* makeEffectClient((request) => {
592
+ if (request.url !== `${ISSUER}/oauth/token`) {
593
+ return Effect.succeed(new Response(null, { status: 500 }))
594
+ }
595
+
596
+ const body = new URLSearchParams(getBody(request))
597
+ if (body.get("grant_type") !== "refresh_token") {
598
+ return Effect.succeed(new Response(null, { status: 500 }))
599
+ }
600
+
601
+ return Effect.gen(function* () {
602
+ yield* Deferred.succeed(refreshStarted, void 0)
603
+ yield* Deferred.await(releaseRefresh)
604
+ return jsonResponse({
605
+ access_token: createTestJwt({
606
+ chatgpt_account_id: "fresh-account",
607
+ }),
608
+ refresh_token: "fresh-refresh-token",
609
+ expires_in: 120,
610
+ })
611
+ })
612
+ })
613
+
614
+ const auth = yield* CodexAuth.make.pipe(
615
+ Effect.provideService(HttpClient.HttpClient, client),
616
+ )
617
+
618
+ const firstFiber = yield* auth.get.pipe(
619
+ Effect.forkChild({ startImmediately: true }),
620
+ )
621
+ yield* Deferred.await(refreshStarted)
622
+
623
+ const secondFiber = yield* auth.get.pipe(
624
+ Effect.forkChild({ startImmediately: true }),
625
+ )
626
+ yield* Effect.yieldNow
627
+
628
+ assert.strictEqual(yield* Ref.get(attempts), 1)
629
+
630
+ yield* Deferred.succeed(releaseRefresh, void 0)
631
+
632
+ const first = yield* Fiber.join(firstFiber)
633
+ const second = yield* Fiber.join(secondFiber)
634
+
635
+ assert.strictEqual(yield* Ref.get(attempts), 1)
636
+ assert.strictEqual(first.refresh, "fresh-refresh-token")
637
+ assert.strictEqual(second.refresh, "fresh-refresh-token")
638
+ assert.strictEqual(
639
+ Option.getOrUndefined(first.accountId),
640
+ "fresh-account",
641
+ )
642
+ assert.strictEqual(
643
+ Option.getOrUndefined(second.accountId),
644
+ "fresh-account",
645
+ )
646
+ }).pipe(Effect.provide(KeyValueStore.layerMemory)),
647
+ )
648
+
649
+ it.effect(
650
+ "forces device auth on authenticate and clears cache plus storage on logout",
651
+ () =>
652
+ Effect.gen(function* () {
653
+ const kvs = yield* KeyValueStore.KeyValueStore
654
+ const tokenStore = toTokenStore(kvs)
655
+ yield* Effect.orDie(
656
+ tokenStore.set(
657
+ STORE_TOKEN_KEY,
658
+ new TokenData({
659
+ access: "cached-access-token",
660
+ refresh: "cached-refresh-token",
661
+ expires: Date.now() + 600_000,
662
+ accountId: Option.some("cached-account"),
663
+ }),
664
+ ),
665
+ )
666
+
667
+ let deviceFlowCount = 0
668
+ const { attempts, client } = yield* makeClient((request) => {
669
+ if (request.url === `${ISSUER}/api/accounts/deviceauth/usercode`) {
670
+ deviceFlowCount += 1
671
+ return jsonResponse({
672
+ device_auth_id: `device-auth-${deviceFlowCount}`,
673
+ user_code: `CODE-${deviceFlowCount}`,
674
+ interval: "1",
675
+ })
676
+ }
677
+
678
+ if (request.url === `${ISSUER}/api/accounts/deviceauth/token`) {
679
+ return jsonResponse({
680
+ authorization_code: `authorization-code-${deviceFlowCount}`,
681
+ code_verifier: `code-verifier-${deviceFlowCount}`,
682
+ })
683
+ }
684
+
685
+ if (request.url === `${ISSUER}/oauth/token`) {
686
+ return jsonResponse({
687
+ access_token: createTestJwt({
688
+ chatgpt_account_id: `device-account-${deviceFlowCount}`,
689
+ }),
690
+ refresh_token: `device-refresh-${deviceFlowCount}`,
691
+ expires_in: 120,
692
+ })
693
+ }
694
+
695
+ return new Response(null, { status: 500 })
696
+ })
697
+
698
+ const auth = yield* CodexAuth.make.pipe(
699
+ Effect.provideService(HttpClient.HttpClient, client),
700
+ )
701
+
702
+ const cachedToken = yield* auth.get
703
+ assert.strictEqual(cachedToken.refresh, "cached-refresh-token")
704
+ assert.strictEqual(yield* Ref.get(attempts), 0)
705
+
706
+ const authenticated = yield* auth.authenticate
707
+ assert.strictEqual(authenticated.refresh, "device-refresh-1")
708
+ assert.strictEqual(
709
+ Option.getOrUndefined(authenticated.accountId),
710
+ "device-account-1",
711
+ )
712
+ assert.strictEqual(yield* Ref.get(attempts), 3)
713
+
714
+ yield* auth.logout
715
+ const storedAfterLogout = yield* Effect.orDie(
716
+ tokenStore.get(STORE_TOKEN_KEY),
717
+ )
718
+ assert.strictEqual(Option.isNone(storedAfterLogout), true)
719
+
720
+ const tokenAfterLogout = yield* auth.get
721
+ assert.strictEqual(tokenAfterLogout.refresh, "device-refresh-2")
722
+ assert.strictEqual(
723
+ Option.getOrUndefined(tokenAfterLogout.accountId),
724
+ "device-account-2",
725
+ )
726
+ assert.strictEqual(yield* Ref.get(attempts), 6)
727
+ }).pipe(Effect.provide(KeyValueStore.layerMemory)),
728
+ )
729
+ })