clanka 0.6.2 → 0.7.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.
@@ -0,0 +1,495 @@
1
+ import { assert, describe, it } from "@effect/vitest"
2
+ import * as Deferred from "effect/Deferred"
3
+ import * as Effect from "effect/Effect"
4
+ import * as Fiber from "effect/Fiber"
5
+ import * as Option from "effect/Option"
6
+ import * as TestClock from "effect/testing/TestClock"
7
+ import {
8
+ HttpClient,
9
+ HttpClientError,
10
+ type HttpClientRequest,
11
+ HttpClientResponse,
12
+ } from "effect/unstable/http"
13
+ import * as KeyValueStore from "effect/unstable/persistence/KeyValueStore"
14
+ import { DeviceCodeHandler } from "./DeviceCodeHandler.ts"
15
+ import { XaiAuth, TokenData, toTokenStore } from "./XaiAuth.ts"
16
+
17
+ const clientId = "b1a00492-073a-47ea-816f-4c329264a828"
18
+ const deviceUrl = "https://auth.x.ai/oauth2/device/code"
19
+ const tokenUrl = "https://auth.x.ai/oauth2/token"
20
+ const device = {
21
+ device_code: "private-code",
22
+ user_code: "ABCD-EFGH",
23
+ verification_uri: "https://auth.x.ai/activate",
24
+ expires_in: 600,
25
+ interval: 1,
26
+ }
27
+ const tokens = {
28
+ access_token: "access",
29
+ refresh_token: "refresh",
30
+ expires_in: 3600,
31
+ }
32
+ const json = (body: unknown, status = 200) =>
33
+ new Response(JSON.stringify(body), {
34
+ status,
35
+ headers: { "content-type": "application/json" },
36
+ })
37
+ const body = (request: HttpClientRequest.HttpClientRequest) => {
38
+ if (request.body._tag !== "Uint8Array")
39
+ throw new Error("Expected encoded request body")
40
+ const text = new TextDecoder().decode(request.body.body)
41
+ return request.headers["content-type"]?.includes("application/json")
42
+ ? JSON.parse(text)
43
+ : Object.fromEntries(new URLSearchParams(text))
44
+ }
45
+ const setup = Effect.fn(function* (
46
+ respond: (
47
+ request: HttpClientRequest.HttpClientRequest,
48
+ ) => Effect.Effect<Response, HttpClientError.HttpClientError>,
49
+ ) {
50
+ const requests: Array<HttpClientRequest.HttpClientRequest> = []
51
+ const codes: Array<{ verifyUrl: string; deviceCode: string }> = []
52
+ const client = HttpClient.make((request) =>
53
+ Effect.gen(function* () {
54
+ requests.push(request)
55
+ return HttpClientResponse.fromWeb(request, yield* respond(request))
56
+ }),
57
+ )
58
+ const auth = yield* XaiAuth.make.pipe(
59
+ Effect.provideService(HttpClient.HttpClient, client),
60
+ Effect.provideService(DeviceCodeHandler, {
61
+ onCode: (code) =>
62
+ Effect.sync(() => {
63
+ codes.push(code)
64
+ }),
65
+ }),
66
+ )
67
+ return { auth, requests, codes }
68
+ })
69
+ const seed = Effect.fn(function* (expires: number) {
70
+ const store = toTokenStore(yield* KeyValueStore.KeyValueStore)
71
+ yield* store
72
+ .set(
73
+ "token",
74
+ new TokenData({ access: "old-access", refresh: "old-refresh", expires }),
75
+ )
76
+ .pipe(Effect.orDie)
77
+ })
78
+
79
+ describe("XaiAuth", () => {
80
+ for (const refresh of [false, true]) {
81
+ it.effect(
82
+ "defaults omitted token expiry to one hour during " +
83
+ (refresh ? "refresh" : "login"),
84
+ () =>
85
+ Effect.gen(function* () {
86
+ if (refresh) yield* seed(1)
87
+ const { auth, requests, codes } = yield* setup((request) =>
88
+ Effect.succeed(
89
+ json(
90
+ request.url === deviceUrl
91
+ ? device
92
+ : {
93
+ access_token: tokens.access_token,
94
+ refresh_token: tokens.refresh_token,
95
+ },
96
+ ),
97
+ ),
98
+ )
99
+ const before = Date.now()
100
+ const token = yield* auth.get
101
+ assert.strictEqual(token.access, "access")
102
+ assert.strictEqual(token.refresh, "refresh")
103
+ assert.isAtLeast(token.expires, before + 3600000)
104
+ assert.isAtMost(token.expires, Date.now() + 3600000)
105
+ const stored = yield* toTokenStore(yield* KeyValueStore.KeyValueStore)
106
+ .get("token")
107
+ .pipe(Effect.orDie)
108
+ assert.deepStrictEqual(Option.getOrThrow(stored), token)
109
+ yield* auth.get
110
+ assert.lengthOf(requests, refresh ? 1 : 2)
111
+ assert.lengthOf(codes, refresh ? 0 : 1)
112
+ }).pipe(Effect.provide(KeyValueStore.layerMemory)),
113
+ )
114
+ }
115
+
116
+ it.effect(
117
+ "preserves and persists the existing refresh token when rotation is omitted",
118
+ () =>
119
+ Effect.gen(function* () {
120
+ yield* seed(1)
121
+ const { auth, requests, codes } = yield* setup((request) =>
122
+ Effect.succeed(
123
+ json(
124
+ request.url === deviceUrl
125
+ ? device
126
+ : {
127
+ access_token: "access",
128
+ expires_in: 3600,
129
+ },
130
+ ),
131
+ ),
132
+ )
133
+ const token = yield* auth.get
134
+ assert.strictEqual(token.access, "access")
135
+ assert.strictEqual(token.refresh, "old-refresh")
136
+ assert.lengthOf(requests, 1)
137
+ assert.strictEqual(body(requests[0]!).refresh_token, "old-refresh")
138
+ assert.lengthOf(codes, 0)
139
+ const stored = yield* toTokenStore(yield* KeyValueStore.KeyValueStore)
140
+ .get("token")
141
+ .pipe(Effect.orDie)
142
+ assert.deepStrictEqual(Option.getOrThrow(stored), token)
143
+ yield* auth.get
144
+ assert.lengthOf(requests, 1)
145
+ }).pipe(Effect.provide(KeyValueStore.layerMemory)),
146
+ )
147
+
148
+ it.effect(
149
+ "accepts an initial login without a refresh token and persists an empty fallback",
150
+ () =>
151
+ Effect.gen(function* () {
152
+ const { auth, requests } = yield* setup((request) =>
153
+ Effect.succeed(
154
+ json(
155
+ request.url === deviceUrl
156
+ ? device
157
+ : {
158
+ access_token: "access",
159
+ expires_in: 3600,
160
+ },
161
+ ),
162
+ ),
163
+ )
164
+ const token = yield* auth.get
165
+ assert.strictEqual(token.access, "access")
166
+ assert.strictEqual(token.refresh, "")
167
+ assert.isFalse(token.isExpired())
168
+ const stored = yield* toTokenStore(yield* KeyValueStore.KeyValueStore)
169
+ .get("token")
170
+ .pipe(Effect.orDie)
171
+ assert.deepStrictEqual(Option.getOrThrow(stored), token)
172
+ yield* auth.get
173
+ assert.lengthOf(requests, 2)
174
+ }).pipe(Effect.provide(KeyValueStore.layerMemory)),
175
+ )
176
+
177
+ for (const expires of [0, -1]) {
178
+ it.effect("uses a fallback lifetime for device expiry " + expires, () =>
179
+ Effect.gen(function* () {
180
+ let polls = 0
181
+ const { auth, requests } = yield* setup((request) =>
182
+ Effect.sync(() => {
183
+ if (request.url === deviceUrl)
184
+ return json({ ...device, expires_in: expires })
185
+ return ++polls === 1
186
+ ? json({ error: "authorization_pending" }, 400)
187
+ : json(tokens)
188
+ }),
189
+ )
190
+ const fiber = yield* auth.get.pipe(
191
+ Effect.forkChild({ startImmediately: true }),
192
+ )
193
+ yield* TestClock.adjust(4000)
194
+ assert.strictEqual((yield* Fiber.join(fiber)).access, "access")
195
+ assert.strictEqual(polls, 2)
196
+ assert.lengthOf(requests, 3)
197
+ }).pipe(Effect.provide(KeyValueStore.layerMemory)),
198
+ )
199
+ }
200
+
201
+ for (const refresh of [false, true]) {
202
+ for (const transient of ["transport", "502"] as const) {
203
+ it.effect(
204
+ "recovers from transient " +
205
+ transient +
206
+ " during " +
207
+ (refresh ? "refresh" : "polling"),
208
+ () =>
209
+ Effect.gen(function* () {
210
+ if (refresh) yield* seed(1)
211
+ let attempts = 0
212
+ const { auth, requests, codes } = yield* setup((request) =>
213
+ Effect.gen(function* () {
214
+ if (request.url === deviceUrl) return json(device)
215
+ attempts++
216
+ if (attempts === 1) {
217
+ if (transient === "502")
218
+ return new Response("Bad Gateway", { status: 502 })
219
+ return yield* new HttpClientError.HttpClientError({
220
+ reason: new HttpClientError.TransportError({
221
+ request,
222
+ cause: new Error("Connection reset"),
223
+ }),
224
+ })
225
+ }
226
+ // An OAuth 400 after recovery must still reach the polling state machine.
227
+ if (!refresh && attempts === 2)
228
+ return json({ error: "authorization_pending" }, 400)
229
+ return json(tokens)
230
+ }),
231
+ )
232
+ const fiber = yield* auth.get.pipe(
233
+ Effect.forkChild({ startImmediately: true }),
234
+ )
235
+ yield* TestClock.adjust("1 minute")
236
+ const token = yield* Fiber.join(fiber)
237
+ assert.strictEqual(token.access, "access")
238
+ assert.strictEqual(attempts, refresh ? 2 : 3)
239
+ assert.lengthOf(codes, refresh ? 0 : 1)
240
+ assert.deepStrictEqual(
241
+ requests.map((request) => request.url),
242
+ refresh
243
+ ? [tokenUrl, tokenUrl]
244
+ : [deviceUrl, tokenUrl, tokenUrl, tokenUrl],
245
+ )
246
+ for (const request of requests.filter(
247
+ (request) => request.url === tokenUrl,
248
+ )) {
249
+ assert.strictEqual(
250
+ body(request).grant_type,
251
+ refresh
252
+ ? "refresh_token"
253
+ : "urn:ietf:params:oauth:grant-type:device_code",
254
+ )
255
+ }
256
+ const stored = yield* toTokenStore(
257
+ yield* KeyValueStore.KeyValueStore,
258
+ )
259
+ .get("token")
260
+ .pipe(Effect.orDie)
261
+ assert.deepStrictEqual(Option.getOrThrow(stored), token)
262
+ }).pipe(Effect.provide(KeyValueStore.layerMemory)),
263
+ )
264
+ }
265
+ }
266
+
267
+ it("treats zero and near-expiry tokens as expired, unlike Copilot", () => {
268
+ for (const expires of [0, Date.now() - 1000, Date.now() + 1000]) {
269
+ assert.isTrue(
270
+ new TokenData({ access: "a", refresh: "r", expires }).isExpired(),
271
+ )
272
+ }
273
+ assert.isFalse(
274
+ new TokenData({
275
+ access: "a",
276
+ refresh: "r",
277
+ expires: Date.now() + 3600000,
278
+ }).isExpired(),
279
+ )
280
+ })
281
+
282
+ it.effect(
283
+ "logs in lazily, sends the public client and scope, and persists expiring credentials",
284
+ () =>
285
+ Effect.gen(function* () {
286
+ const { auth, requests, codes } = yield* setup((request) =>
287
+ Effect.succeed(json(request.url === deviceUrl ? device : tokens)),
288
+ )
289
+ assert.lengthOf(requests, 0)
290
+ assert.lengthOf(codes, 0)
291
+ const before = Date.now()
292
+ const token = yield* auth.get
293
+ assert.strictEqual(token.access, "access")
294
+ assert.strictEqual(token.refresh, "refresh")
295
+ assert.isAtLeast(token.expires, before + 3600000)
296
+ assert.isAtMost(token.expires, Date.now() + 3600000)
297
+ assert.deepStrictEqual(codes, [
298
+ { verifyUrl: device.verification_uri, deviceCode: device.user_code },
299
+ ])
300
+ assert.deepStrictEqual(
301
+ requests.map((r) => [r.method, r.url]),
302
+ [
303
+ ["POST", deviceUrl],
304
+ ["POST", tokenUrl],
305
+ ],
306
+ )
307
+ assert.deepInclude(body(requests[0]!), {
308
+ client_id: clientId,
309
+ scope:
310
+ "openid profile email offline_access grok-cli:access api:access",
311
+ referrer: "clanka",
312
+ })
313
+ assert.deepInclude(body(requests[1]!), {
314
+ client_id: clientId,
315
+ device_code: device.device_code,
316
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code",
317
+ })
318
+ for (const request of requests) {
319
+ assert.match(request.headers["user-agent"]!, /clanka/i)
320
+ assert.notMatch(request.headers["user-agent"]!, /opencode/i)
321
+ assert.isUndefined(request.headers.authorization)
322
+ }
323
+ const kvs = yield* KeyValueStore.KeyValueStore
324
+ const raw = yield* kvs.get("xai.auth/token").pipe(Effect.orDie)
325
+ assert.deepInclude(JSON.parse(raw!), {
326
+ access: "access",
327
+ refresh: "refresh",
328
+ expires: token.expires,
329
+ })
330
+ assert.isUndefined(yield* kvs.get("token").pipe(Effect.orDie))
331
+ yield* auth.get
332
+ assert.lengthOf(requests, 2)
333
+ yield* auth.logout
334
+ assert.isUndefined(yield* kvs.get("xai.auth/token").pipe(Effect.orDie))
335
+ yield* auth.get
336
+ assert.lengthOf(requests, 4)
337
+ }).pipe(Effect.provide(KeyValueStore.layerMemory)),
338
+ )
339
+
340
+ it.effect("reuses valid persisted credentials without login or refresh", () =>
341
+ Effect.gen(function* () {
342
+ yield* seed(Date.now() + 3600000)
343
+ const { auth, requests, codes } = yield* setup(() =>
344
+ Effect.die("Unexpected HTTP request"),
345
+ )
346
+ assert.strictEqual((yield* auth.get).access, "old-access")
347
+ assert.lengthOf(requests, 0)
348
+ assert.lengthOf(codes, 0)
349
+ }).pipe(Effect.provide(KeyValueStore.layerMemory)),
350
+ )
351
+
352
+ for (const expires of [0, 1]) {
353
+ it.effect(
354
+ "refreshes expired credentials (expires=" +
355
+ expires +
356
+ ") and persists rotation",
357
+ () =>
358
+ Effect.gen(function* () {
359
+ yield* seed(expires)
360
+ const { auth, requests, codes } = yield* setup(() =>
361
+ Effect.succeed(json(tokens)),
362
+ )
363
+ const token = yield* auth.get
364
+ assert.strictEqual(token.access, "access")
365
+ assert.strictEqual(token.refresh, "refresh")
366
+ assert.isAbove(token.expires, Date.now() + 3500000)
367
+ assert.lengthOf(requests, 1)
368
+ assert.strictEqual(requests[0]!.url, tokenUrl)
369
+ assert.deepInclude(body(requests[0]!), {
370
+ client_id: clientId,
371
+ grant_type: "refresh_token",
372
+ refresh_token: "old-refresh",
373
+ })
374
+ assert.lengthOf(codes, 0)
375
+ const stored = yield* toTokenStore(yield* KeyValueStore.KeyValueStore)
376
+ .get("token")
377
+ .pipe(Effect.orDie)
378
+ assert.deepStrictEqual(Option.getOrThrow(stored), token)
379
+ yield* auth.get
380
+ assert.lengthOf(requests, 1)
381
+ }).pipe(Effect.provide(KeyValueStore.layerMemory)),
382
+ )
383
+ }
384
+
385
+ it.effect("falls back to device login after a rejected refresh", () =>
386
+ Effect.gen(function* () {
387
+ yield* seed(1)
388
+ const { auth, requests, codes } = yield* setup((request) =>
389
+ Effect.succeed(
390
+ json(
391
+ request.url === deviceUrl
392
+ ? device
393
+ : body(request).grant_type === "refresh_token"
394
+ ? { error: "invalid_grant" }
395
+ : tokens,
396
+ request.url === tokenUrl &&
397
+ body(request).grant_type === "refresh_token"
398
+ ? 400
399
+ : 200,
400
+ ),
401
+ ),
402
+ )
403
+ assert.strictEqual((yield* auth.get).access, "access")
404
+ assert.deepStrictEqual(
405
+ requests.map((r) => r.url),
406
+ [tokenUrl, deviceUrl, tokenUrl],
407
+ )
408
+ assert.lengthOf(codes, 1)
409
+ }).pipe(Effect.provide(KeyValueStore.layerMemory)),
410
+ )
411
+
412
+ it.effect(
413
+ "waits on authorization_pending and increases the delay on slow_down",
414
+ () =>
415
+ Effect.gen(function* () {
416
+ let polls = 0
417
+ const { auth } = yield* setup((request) =>
418
+ Effect.sync(() => {
419
+ if (request.url === deviceUrl) return json(device)
420
+ polls++
421
+ return polls === 1
422
+ ? json({ error: "authorization_pending" }, 400)
423
+ : polls === 2
424
+ ? json({ error: "slow_down", interval: 1 }, 400)
425
+ : json(tokens)
426
+ }),
427
+ )
428
+ const fiber = yield* auth.get.pipe(
429
+ Effect.forkChild({ startImmediately: true }),
430
+ )
431
+ yield* TestClock.adjust(3999)
432
+ assert.strictEqual(polls, 1)
433
+ yield* TestClock.adjust(1)
434
+ assert.strictEqual(polls, 2)
435
+ yield* TestClock.adjust(8999)
436
+ assert.strictEqual(polls, 2)
437
+ yield* TestClock.adjust(1)
438
+ assert.strictEqual((yield* Fiber.join(fiber)).access, "access")
439
+ assert.strictEqual(polls, 3)
440
+ }).pipe(Effect.provide(KeyValueStore.layerMemory)),
441
+ )
442
+
443
+ for (const error of ["access_denied", "expired_token"]) {
444
+ it.effect("stops polling on " + error + " without storing a token", () =>
445
+ Effect.gen(function* () {
446
+ const { auth, requests } = yield* setup((request) =>
447
+ Effect.succeed(
448
+ request.url === deviceUrl ? json(device) : json({ error }, 400),
449
+ ),
450
+ )
451
+ const failure = yield* auth.get.pipe(Effect.flip)
452
+ assert.include(failure.message, error)
453
+ assert.lengthOf(requests, 2)
454
+ const stored = yield* toTokenStore(yield* KeyValueStore.KeyValueStore)
455
+ .get("token")
456
+ .pipe(Effect.orDie)
457
+ assert.isTrue(Option.isNone(stored))
458
+ }).pipe(Effect.provide(KeyValueStore.layerMemory)),
459
+ )
460
+ }
461
+
462
+ for (const refresh of [false, true]) {
463
+ it.effect(
464
+ "serializes concurrent " + (refresh ? "refresh" : "login") + " requests",
465
+ () =>
466
+ Effect.gen(function* () {
467
+ if (refresh) yield* seed(1)
468
+ const started = yield* Deferred.make<void>()
469
+ const release = yield* Deferred.make<void>()
470
+ const { auth, requests } = yield* setup((request) =>
471
+ request.url === deviceUrl
472
+ ? Effect.succeed(json(device))
473
+ : Effect.gen(function* () {
474
+ yield* Deferred.succeed(started, undefined)
475
+ yield* Deferred.await(release)
476
+ return json(tokens)
477
+ }),
478
+ )
479
+ const first = yield* auth.get.pipe(
480
+ Effect.forkChild({ startImmediately: true }),
481
+ )
482
+ yield* Deferred.await(started)
483
+ const second = yield* auth.get.pipe(
484
+ Effect.forkChild({ startImmediately: true }),
485
+ )
486
+ yield* Effect.yieldNow
487
+ assert.lengthOf(requests, refresh ? 1 : 2)
488
+ yield* Deferred.succeed(release, undefined)
489
+ assert.strictEqual((yield* Fiber.join(first)).access, "access")
490
+ assert.strictEqual((yield* Fiber.join(second)).access, "access")
491
+ assert.lengthOf(requests, refresh ? 1 : 2)
492
+ }).pipe(Effect.provide(KeyValueStore.layerMemory)),
493
+ )
494
+ }
495
+ })