From 6e09a1d9041880e5b4a55ed756c8ea9a51b94e0d Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 19 Mar 2026 16:18:28 -0400 Subject: [PATCH] fix(account): handle pending console login polling (#18281) --- packages/opencode/src/account/effect.ts | 8 +- .../opencode/test/account/service.test.ts | 85 ++++++++++++++++--- 2 files changed, 82 insertions(+), 11 deletions(-) diff --git a/packages/opencode/src/account/effect.ts b/packages/opencode/src/account/effect.ts index 444676046..2f1304d50 100644 --- a/packages/opencode/src/account/effect.ts +++ b/packages/opencode/src/account/effect.ts @@ -148,6 +148,12 @@ export namespace AccountEffect { mapAccountServiceError("HTTP request failed"), ) + const executeEffect = (request: Effect.Effect) => + request.pipe( + Effect.flatMap((req) => http.execute(req)), + mapAccountServiceError("HTTP request failed"), + ) + const resolveToken = Effect.fnUntraced(function* (row: AccountRow) { const now = yield* Clock.currentTimeMillis if (row.token_expiry && row.token_expiry > now) return row.access_token @@ -290,7 +296,7 @@ export namespace AccountEffect { }) const poll = Effect.fn("Account.poll")(function* (input: Login) { - const response = yield* executeEffectOk( + const response = yield* executeEffect( HttpClientRequest.post(`${input.server}/auth/device/token`).pipe( HttpClientRequest.acceptJson, HttpClientRequest.schemaBodyJson(DeviceTokenRequest)( diff --git a/packages/opencode/test/account/service.test.ts b/packages/opencode/test/account/service.test.ts index 94cd9eb94..f87eed64e 100644 --- a/packages/opencode/test/account/service.test.ts +++ b/packages/opencode/test/account/service.test.ts @@ -34,6 +34,24 @@ const encodeOrg = Schema.encodeSync(Org) const org = (id: string, name: string) => encodeOrg(new Org({ id: OrgID.make(id), name })) +const login = () => + new Login({ + code: DeviceCode.make("device-code"), + user: UserCode.make("user-code"), + url: "https://one.example.com/verify", + server: "https://one.example.com", + expiry: Duration.seconds(600), + interval: Duration.seconds(5), + }) + +const deviceTokenClient = (body: unknown, status = 400) => + HttpClient.make((req) => + Effect.succeed(req.url === "https://one.example.com/auth/device/token" ? json(req, body, status) : json(req, {}, 404)), + ) + +const poll = (body: unknown, status = 400) => + AccountEffect.Service.use((s) => s.poll(login())).pipe(Effect.provide(live(deviceTokenClient(body, status)))) + it.effect("orgsByAccount groups orgs per account", () => Effect.gen(function* () { yield* AccountRepo.use((r) => @@ -172,15 +190,6 @@ it.effect("config sends the selected org header", () => it.effect("poll stores the account and first org on success", () => Effect.gen(function* () { - const login = new Login({ - code: DeviceCode.make("device-code"), - user: UserCode.make("user-code"), - url: "https://one.example.com/verify", - server: "https://one.example.com", - expiry: Duration.seconds(600), - interval: Duration.seconds(5), - }) - const client = HttpClient.make((req) => Effect.succeed( req.url === "https://one.example.com/auth/device/token" @@ -198,7 +207,7 @@ it.effect("poll stores the account and first org on success", () => ), ) - const res = yield* AccountEffect.Service.use((s) => s.poll(login)).pipe(Effect.provide(live(client))) + const res = yield* AccountEffect.Service.use((s) => s.poll(login())).pipe(Effect.provide(live(client))) expect(res._tag).toBe("PollSuccess") if (res._tag === "PollSuccess") { @@ -215,3 +224,59 @@ it.effect("poll stores the account and first org on success", () => ) }), ) + +for (const [name, body, expectedTag] of [ + [ + "pending", + { + error: "authorization_pending", + error_description: "The authorization request is still pending", + }, + "PollPending", + ], + [ + "slow", + { + error: "slow_down", + error_description: "Polling too frequently, please slow down", + }, + "PollSlow", + ], + [ + "denied", + { + error: "access_denied", + error_description: "The authorization request was denied", + }, + "PollDenied", + ], + [ + "expired", + { + error: "expired_token", + error_description: "The device code has expired", + }, + "PollExpired", + ], +] as const) { + it.effect(`poll returns ${name} for ${body.error}`, () => + Effect.gen(function* () { + const result = yield* poll(body) + expect(result._tag).toBe(expectedTag) + }), + ) +} + +it.effect("poll returns poll error for other OAuth errors", () => + Effect.gen(function* () { + const result = yield* poll({ + error: "server_error", + error_description: "An unexpected error occurred", + }) + + expect(result._tag).toBe("PollError") + if (result._tag === "PollError") { + expect(String(result.cause)).toContain("server_error") + } + }), +)