feat(v2/cli): add console login (#35969)

This commit is contained in:
Simon Klee 2026-07-09 00:00:09 +02:00 committed by GitHub
commit 19f42f7102
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 584 additions and 28 deletions

View file

@ -1,14 +1,33 @@
import { describe, expect } from "bun:test"
import { Duration, Effect, Exit, Fiber, Scope, Stream } from "effect"
import { Cause, Duration, Effect, Exit, Fiber, Layer, Scope, Stream } from "effect"
import * as TestClock from "effect/testing/TestClock"
import { Credential } from "@opencode-ai/core/credential"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { makeGlobalNode } from "@opencode-ai/core/effect/app-node"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { EventV2 } from "@opencode-ai/core/event"
import { Integration } from "@opencode-ai/core/integration"
import { testEffect } from "./lib/effect"
const it = testEffect(AppNodeBuilder.build(LayerNode.group([Integration.node, Credential.node, EventV2.node])))
const failingCredentialNode = makeGlobalNode({
service: Credential.Service,
layer: Layer.succeed(
Credential.Service,
Credential.Service.of({
all: () => Effect.succeed([]),
list: () => Effect.succeed([]),
get: () => Effect.succeed(undefined),
create: () => Effect.die(new Error("credential persistence failed")),
update: () => Effect.void,
remove: () => Effect.void,
}),
),
deps: [],
})
const failingIt = testEffect(
AppNodeBuilder.build(LayerNode.group([Integration.node, EventV2.node]), [[Credential.node, failingCredentialNode]]),
)
describe("Integration", () => {
it.effect("registers integrations through the editor", () =>
@ -254,6 +273,47 @@ describe("Integration", () => {
}),
)
failingIt.effect("fails the attempt when credential persistence fails", () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
const integrationID = Integration.ID.make("openai")
const methodID = Integration.MethodID.make("chatgpt")
yield* integrations.transform((editor) =>
editor.method.update({
integrationID,
method: { id: methodID, type: "oauth", label: "ChatGPT" },
authorize: () =>
Effect.succeed({
mode: "code" as const,
url: "https://example.com/authorize",
instructions: "Paste the code",
callback: () =>
Effect.succeed(
Credential.OAuth.make({
type: "oauth",
methodID,
access: "access",
refresh: "refresh",
expires: 1,
}),
),
}),
}),
)
const attempt = yield* integrations.connection.oauth({ integrationID, methodID, inputs: {} })
const exit = yield* integrations.attempt
.complete({ attemptID: attempt.attemptID, code: "1234" })
.pipe(Effect.exit)
expect(Exit.isFailure(exit) && Cause.hasDies(exit.cause)).toBe(true)
expect(yield* integrations.attempt.status(attempt.attemptID)).toEqual({
status: "failed",
message: "credential persistence failed",
time: attempt.time,
})
}),
)
it.effect("expires abandoned OAuth attempts", () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service

View file

@ -92,6 +92,73 @@ describe("OpencodePlugin", () => {
}),
)
it.live("uses a canonical custom server throughout device authorization", () =>
Effect.acquireUseRelease(
Effect.sync(() => {
const requests: string[] = []
const server = Bun.serve({
port: 0,
fetch: (request) => {
const url = new URL(request.url)
requests.push(`${request.method} ${url.pathname}`)
if (url.pathname.endsWith("/auth/device/code")) {
return Response.json({
device_code: "device",
user_code: "user",
verification_uri_complete: `${url.origin}/verify`,
expires_in: 60,
interval: 0,
})
}
if (url.pathname.endsWith("/auth/device/token")) {
return Response.json({ access_token: "access", refresh_token: "refresh", expires_in: 600 })
}
if (url.pathname.endsWith("/api/user")) return Response.json({ id: "user", email: "user@example.com" })
if (url.pathname.endsWith("/api/orgs")) return Response.json([{ id: "org", name: "Org" }])
return new Response("Not found", { status: 404 })
},
})
return { requests, server }
}),
({ requests, server }) =>
Effect.gen(function* () {
yield* addPlugin()
const integrations = yield* Integration.Service
const attempt = yield* integrations.connection.oauth({
integrationID: Integration.ID.make("opencode"),
methodID: Integration.MethodID.make("device"),
inputs: { server: `${server.url.origin}/console///?ignored=true#ignored` },
})
expect(attempt.url).toBe(`${server.url.origin}/verify`)
yield* eventually(integrations.attempt.status(attempt.attemptID), (status) => status.status === "complete")
expect(requests).toContain("POST /console/auth/device/code")
expect(requests).toContain("POST /console/auth/device/token")
expect(requests).toContain("GET /console/api/user")
expect(requests).toContain("GET /console/api/orgs")
expect((yield* (yield* Credential.Service).list(Integration.ID.make("opencode")))[0]?.value).toMatchObject({
metadata: { server: `${server.url.origin}/console` },
})
}),
({ server }) => Effect.promise(() => server.stop(true)),
),
)
it.effect("rejects non-HTTP OpenCode servers", () =>
Effect.gen(function* () {
yield* addPlugin()
const error = yield* (yield* Integration.Service).connection
.oauth({
integrationID: Integration.ID.make("opencode"),
methodID: Integration.MethodID.make("device"),
inputs: { server: "ftp://console.example.com" },
})
.pipe(Effect.flip)
expect(error).toBeInstanceOf(Integration.AuthorizationError)
expect(String(error.cause)).toContain("Invalid OpenCode server URL: expected HTTP(S)")
}),
)
it.live("loads providers and models from the connected OpenCode server", () =>
Effect.acquireUseRelease(
Effect.sync(() => {