summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorKit Langton <[email protected]>2026-03-19 16:18:28 -0400
committerGitHub <[email protected]>2026-03-19 16:18:28 -0400
commit6e09a1d9041880e5b4a55ed756c8ea9a51b94e0d (patch)
tree31a5325be2d760f90d259a6ccdb45919eb24e465
parent4f21757e0db7eca318b24fd05c5e0fe3aad477b0 (diff)
downloadopencode-6e09a1d9041880e5b4a55ed756c8ea9a51b94e0d.tar.gz
opencode-6e09a1d9041880e5b4a55ed756c8ea9a51b94e0d.zip
fix(account): handle pending console login polling (#18281)
-rw-r--r--packages/opencode/src/account/effect.ts8
-rw-r--r--packages/opencode/test/account/service.test.ts85
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 = <E>(request: Effect.Effect<HttpClientRequest.HttpClientRequest, E>) =>
+ 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")
+ }
+ }),
+)