feat(core): tell users how to sign back in to OpenCode Console (#51847)

This commit is contained in:
Victor Navarro
2026-09-29 10:58:45 +02:00
committed by GitHub
parent b30c4d00d1
commit d76f968c60
15 changed files with 520 additions and 38 deletions
+12 -4
View File
@@ -235,9 +235,7 @@ export type IntegrationCommandMethod = { id: string; type: "command"; label: str
export type IntegrationEnvMethod = { type: "env"; names: Array<string> }
export type ConnectionCredentialInfo = { type: "credential"; id: string; label: string; method: "key" | "oauth" }
export type ConnectionEnvInfo = { type: "env"; name: string }
export type ConnectionStatus = { status: "needs_auth"; message: string; url?: string }
export type IntegrationAttempt = {
attemptID: string
@@ -1469,7 +1467,15 @@ export type ModelCost = {
cache: { read: MoneyUSDPerMillionTokens; write: MoneyUSDPerMillionTokens }
}
export type ConnectionInfo = ConnectionCredentialInfo | ConnectionEnvInfo
export type ConnectionCredentialInfo = {
type: "credential"
id: string
label: string
method: "key" | "oauth"
status?: ConnectionStatus
}
export type ConnectionEnvInfo = { type: "env"; name: string; status?: ConnectionStatus }
export type McpServer = {
name: string
@@ -1884,6 +1890,8 @@ export type FormField =
export type FormState = { status: "pending" } | { status: "answered"; answer: FormAnswer } | { status: "cancelled" }
export type ConnectionInfo = ConnectionCredentialInfo | ConnectionEnvInfo
export type FormField1 =
| FormStringField1
| FormNumberField1
+22 -1
View File
@@ -7,6 +7,7 @@ import {
Context,
Duration,
Effect,
Equal,
Exit,
Fiber,
Layer,
@@ -188,6 +189,12 @@ export interface Interface extends State.Transformable<Editor> {
) => Effect.Effect<void>
/** Removes a stored credential connection. */
readonly remove: (credentialID: Credential.ID) => Effect.Effect<void>
/** Reports or clears a runtime problem with a connection; `get` and `list` project it onto the connection. */
readonly status: (input: {
readonly integrationID: ID
readonly connection: IntegrationConnection.Info
readonly status: IntegrationConnection.Status | undefined
}) => Effect.Effect<void>
}
readonly oauth: {
/** Starts a stateful OAuth attempt. */
@@ -279,6 +286,10 @@ const layer = Layer.effect(
const scope = yield* Scope.Scope
const attempts = SynchronizedRef.makeUnsafe(new Map<AttemptID, AttemptEntry>())
const commandAttempts = SynchronizedRef.makeUnsafe(new Map<AttemptID, CommandAttemptEntry>())
// Runtime-only: statuses describe the current process's view of a connection and are not persisted.
const statuses = new Map<string, IntegrationConnection.Status>()
const statusKey = (integrationID: ID, connection: IntegrationConnection.Info) =>
`${integrationID}:${IntegrationConnection.key(connection)}`
const state = State.create<Data, Editor>({
name: "integration",
initial: () => ({ integrations: new Map<ID, Entry>() }),
@@ -377,7 +388,10 @@ const layer = Layer.effect(
name: entry.ref.name,
...(entry.ref.metadata === undefined ? {} : { metadata: entry.ref.metadata }),
methods: entry.methods,
connections,
connections: connections.map((connection) => {
const status = statuses.get(statusKey(entry.ref.id, connection))
return status ? { ...connection, status } : connection
}),
})
const authorize = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
@@ -727,6 +741,13 @@ const layer = Layer.effect(
credentials.update(credentialID, updates),
),
remove: Effect.fn("Integration.connection.remove")((credentialID) => credentials.remove(credentialID)),
status: Effect.fn("Integration.connection.status")(function* (input) {
const key = statusKey(input.integrationID, input.connection)
if (Equal.equals(statuses.get(key), input.status)) return
if (input.status) statuses.set(key, input.status)
if (!input.status) statuses.delete(key)
yield* bus.publish(Integration.Event.Updated, {})
}),
},
oauth: {
connect: connectOAuth,
@@ -11,6 +11,9 @@ export type EnvInfo = Connection.EnvInfo
export const Info = Connection.Info
export type Info = Connection.Info
export const Status = Connection.Status
export type Status = Connection.Status
/** Identity of an access choice; labels and refreshed token values do not identify a new connection. */
export function key(
connection:
+9
View File
@@ -345,6 +345,15 @@ export const make = Effect.fn("PluginHost.make")(function* (
integration.connection.resolve(
connection.type === "credential" ? { ...connection, id: Credential.ID.make(connection.id) } : connection,
),
status: (input) =>
integration.connection.status({
integrationID: Integration.ID.make(input.integrationID),
connection:
input.connection.type === "credential"
? { ...input.connection, id: Credential.ID.make(input.connection.id) }
: input.connection,
status: input.status,
}),
},
transform: (callback) =>
integration.transform((editor) => {
+158 -31
View File
@@ -1,7 +1,7 @@
import { Duration, Effect, Equal, Schema, Semaphore, Stream } from "effect"
import type { Scope } from "effect"
import { Duration, Effect, Equal, Option, Schema, Scope, Semaphore, Stream } from "effect"
import type { IntegrationOAuthMethodRegistration } from "@opencode/plugin/effect/integration"
import { define } from "@opencode/plugin/effect/plugin"
import type { SessionHttpResponse } from "@opencode/plugin/effect/session"
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { App } from "../../app.js"
import { Bus } from "../../bus.js"
@@ -55,6 +55,34 @@ const TokenPending = Schema.Struct({ error: Schema.String })
const DeviceToken = Schema.Union([Token, TokenPending])
const User = Schema.Struct({ id: Schema.String, email: Schema.String })
const Org = Schema.Struct({ id: Schema.String, name: Schema.String })
// Console's 403 when the organization enforces SSO and the user's SSO proof is missing or expired.
const SsoRequired = Schema.Struct({
_tag: Schema.Literal("SsoRequired"),
orgId: Schema.String,
connectionId: Schema.String,
})
const InvalidGrant = Schema.Struct({ error: Schema.Literal("invalid_grant") })
// Console's 401 when the session behind the access token was revoked or expired.
const Unauthorized = Schema.Struct({ _tag: Schema.Literal("Unauthorized") })
const signedOutMessage = "Reconnect OpenCode Console to continue"
const ssoMessage = (organization: string | undefined) =>
`Sign in with SSO again to use ${organization ?? "your OpenCode Console organization"}`
class SsoRequiredError extends Schema.TaggedError<SsoRequiredError>()("OpencodeConsole.SsoRequired", {
organization: Schema.optional(Schema.String),
url: Schema.String,
}) {
override get message() {
return `${ssoMessage(this.organization)}: ${this.url}`
}
}
// The Console session or refresh token was revoked or expired; only a new device login recovers.
class SignedOutError extends Schema.TaggedError<SignedOutError>()("OpencodeConsole.SignedOut", {}) {
override get message() {
return `${signedOutMessage}.`
}
}
function oauth(http: HttpClient.HttpClient) {
return {
@@ -100,11 +128,17 @@ function oauth(http: HttpClient.HttpClient) {
}),
refresh: (credential) =>
Effect.gen(function* () {
const token = yield* post(
http,
`${serverUrl(credential)}/auth/device/token`,
{ grant_type: "refresh_token", refresh_token: credential.refresh, client_id: clientID },
Token,
const response = yield* send(http, `${serverUrl(credential)}/auth/device/token`, {
grant_type: "refresh_token",
refresh_token: credential.refresh,
client_id: clientID,
})
if (response.status === 400) {
const body = yield* HttpClientResponse.schemaBodyJson(InvalidGrant)(response).pipe(Effect.option)
if (Option.isSome(body)) return yield* new SignedOutError()
}
const token = yield* HttpClientResponse.filterStatusOk(response).pipe(
Effect.flatMap(HttpClientResponse.schemaBodyJson(Token)),
)
// Persist rotated tokens without depending on discovery requests.
return {
@@ -138,6 +172,7 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Manag
// and whether it evaluates the policies it is being sent.
const http = HttpClient.mapRequest(client, HttpClientRequest.setHeader("User-Agent", App.useragent(ctx.app)))
const managed = yield* ManagedPolicy.Service
const scope = yield* Scope.Scope
const loading = Semaphore.makeUnsafe(1)
type ActiveConnection = Effect.Success<ReturnType<typeof ctx.integration.connection.active>>
let snapshot: {
@@ -149,10 +184,20 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Manag
| { servers: NonNullable<typeof RemoteResponse.Type.mcp>["servers"]; headers: Record<string, string> }
| undefined
} = { config: undefined, connection: undefined, organization: undefined, mcp: undefined }
// Status last reported for the active connection, so inference failures can name the fix.
let reported: IntegrationConnection.Status | undefined
let explainers: Effect.Success<ReturnType<typeof ctx.session.hook>>[] = []
const load = Effect.fn("OpencodePlugin.load")(function* () {
const connection = yield* ctx.integration.connection.active("opencode")
if (!connection) return { config: undefined, connection, organization: undefined, mcp: undefined }
if (!connection) {
reported = undefined
return { config: undefined, connection, organization: undefined, mcp: undefined }
}
const status = (status: IntegrationConnection.Status | undefined) => {
reported = status
return ctx.integration.connection.status({ integrationID: "opencode", connection, status })
}
return yield* ctx.integration.connection.resolve(connection).pipe(
Effect.flatMap((credential) => {
if (!credential)
@@ -161,21 +206,24 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Manag
Effect.map((config) => ({
config,
connection,
organization: typeof credential.metadata?.orgName === "string" ? credential.metadata.orgName : undefined,
organization: organizationName(credential),
mcp: config?.mcp && { servers: config.mcp.servers, headers: credentialHeaders(credential) },
})),
)
}),
Effect.tap(() => status(undefined)),
Effect.catch((cause) =>
Effect.logWarning("failed to load OpenCode provider config", { cause }).pipe(
Effect.gen(function* () {
// Only well-formed Console errors change the connection status; anything else may be transient.
const next = connectionStatus(cause)
if (next) yield* status(next)
yield* Effect.logWarning("failed to load OpenCode provider config", { cause })
// A load that fails for the connection already in place keeps its last config: dropping it
// would lift organization policy while personal credentials keep working.
Effect.as(
IntegrationConnection.key(connection) === IntegrationConnection.key(snapshot.connection)
? { config: snapshot.config, connection, organization: snapshot.organization, mcp: snapshot.mcp }
: { config: undefined, connection, organization: undefined, mcp: undefined },
),
),
return IntegrationConnection.key(connection) === IntegrationConnection.key(snapshot.connection)
? { config: snapshot.config, connection, organization: snapshot.organization, mcp: snapshot.mcp }
: { config: undefined, connection, organization: undefined, mcp: undefined }
}),
),
)
})
@@ -367,12 +415,47 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Manag
const apply = Effect.fn("OpencodePlugin.apply")(function* (next: typeof snapshot) {
snapshot = next
yield* publish(next)
yield* guard(next)
yield* Effect.all([ctx.provider.reload(), ctx.websearch.reload(), ctx.mcp.reload()], {
concurrency: 3,
discard: true,
})
})
const refresh = () => loading.withPermit(load().pipe(Effect.andThen(apply)))
// Annotated: `apply` re-registers the hooks that call `check`, which would otherwise make inference circular.
const check = (): Effect.Effect<void> =>
loading.withPermit(
load().pipe(Effect.flatMap((next) => (Equal.equals(snapshot, next) ? Effect.void : apply(next)))),
)
// Console inference rejects a missing SSO sign-in with a generic 401/403. Re-check the Console config, which
// reports the reason, and replace the response with instructions when the connection needs the user.
const explain = (event: SessionHttpResponse) =>
Effect.gen(function* () {
if (event.response.status !== 401 && event.response.status !== 403) return
yield* check()
const status = reported
if (!status) return
void event.response.body?.cancel()
event.response = Response.json(
{
error: {
type: "authentication_error",
message: status.url ? `${status.message}: ${status.url}` : `${status.message}.`,
},
},
{ status: event.response.status },
)
})
// Hooks are scoped per provider so requests to unrelated providers skip the HTTP hook path.
const guard = Effect.fn("OpencodePlugin.guard")(function* (next: typeof snapshot) {
yield* Effect.forEach(explainers, (registration) => registration.dispose, { discard: true })
explainers = yield* Effect.forEach(
next.connection ? new Set([Provider.ID.opencode, ...Object.keys(next.config?.providers ?? {})]) : [],
(providerID) => ctx.session.hook("http.response", explain, { providerID }).pipe(Scope.provide(scope)),
)
})
yield* guard(snapshot)
yield* bus.subscribe(Credential.Event.Switched).pipe(
Stream.filter((event) => event.data.integrationID === Integration.ID.make("opencode")),
Stream.runForEach(refresh),
@@ -382,11 +465,7 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Manag
// Console config can change independently of local credential activity, so re-fetch
// periodically and only rebuild the catalog and search providers when the snapshot differs.
yield* Effect.sleep(Duration.minutes(1)).pipe(
Effect.andThen(
loading.withPermit(
load().pipe(Effect.flatMap((next) => (Equal.equals(snapshot, next) ? Effect.void : apply(next)))),
),
),
Effect.andThen(check()),
Effect.forever,
Effect.forkScoped,
)
@@ -402,15 +481,58 @@ function fetchConfig(http: HttpClient.HttpClient, value: Credential.Value) {
),
)
.pipe(
Effect.flatMap((response) => {
if (response.status === 404) return Effect.undefined
return HttpClientResponse.filterStatusOk(response).pipe(
Effect.flatMap(HttpClientResponse.schemaBodyJson(RemoteResponse)),
)
}),
Effect.flatMap((response) =>
Effect.gen(function* () {
if (response.status === 404) return undefined
if (response.status === 401) {
const body = yield* HttpClientResponse.schemaBodyJson(Unauthorized)(response).pipe(Effect.option)
if (Option.isSome(body)) return yield* new SignedOutError()
}
if (response.status === 403) {
const body = yield* HttpClientResponse.schemaBodyJson(SsoRequired)(response).pipe(Effect.option)
if (Option.isSome(body))
return yield* new SsoRequiredError({
organization: organizationName(value),
url: yield* ssoUrl(serverUrl(value), body.value.connectionId),
})
}
return yield* HttpClientResponse.filterStatusOk(response).pipe(
Effect.flatMap(HttpClientResponse.schemaBodyJson(RemoteResponse)),
)
}),
),
)
}
function ssoUrl(server: string, connectionID: string) {
return normalizeServer(server).pipe(
Effect.map((base) => {
// Console only redirects to paths under its own base path after SSO.
const redirectTo = `${new URL(base).pathname.replace(/\/+$/, "")}/`
return `${base}/auth/sso/${encodeURIComponent(connectionID)}/start?redirectTo=${encodeURIComponent(redirectTo)}`
}),
)
}
function connectionStatus(cause: unknown): IntegrationConnection.Status | undefined {
if (cause instanceof SsoRequiredError)
return {
status: "needs_auth",
message: ssoMessage(cause.organization),
url: cause.url,
}
if (
cause instanceof SignedOutError ||
(cause instanceof Integration.AuthorizationError && cause.cause instanceof SignedOutError)
)
return { status: "needs_auth", message: signedOutMessage }
return undefined
}
function organizationName(credential: Credential.Value) {
return typeof credential.metadata?.orgName === "string" ? credential.metadata.orgName : undefined
}
function credentialHeaders(value: Credential.Value): Record<string, string> {
const orgID = value.metadata?.orgID
return {
@@ -514,11 +636,16 @@ function post<S extends Schema.Top>(
schema: S,
statusOk = true,
) {
return HttpClientRequest.post(url).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.schemaBodyJson(Schema.Record(Schema.String, Schema.Union([Schema.String, Schema.Boolean])))(body),
Effect.flatMap((request) => http.execute(request)),
return send(http, url, body).pipe(
Effect.flatMap((response) => (statusOk ? HttpClientResponse.filterStatusOk(response) : Effect.succeed(response))),
Effect.flatMap(HttpClientResponse.schemaBodyJson(schema)),
)
}
function send(http: HttpClient.HttpClient, url: string, body: Record<string, string | boolean>) {
return HttpClientRequest.post(url).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.schemaBodyJson(Schema.Record(Schema.String, Schema.Union([Schema.String, Schema.Boolean])))(body),
Effect.flatMap((request) => http.execute(request)),
)
}
+1
View File
@@ -35,6 +35,7 @@ const integrations = Layer.mock(Integration.Service, {
activate: () => Effect.die("unused"),
update: () => Effect.die("unused"),
remove: () => Effect.die("unused"),
status: () => Effect.die("unused"),
},
oauth: {
connect: () => Effect.die("unused"),
+1
View File
@@ -333,6 +333,7 @@ function resourceMcpLayer(
activate: unusedIntegration,
update: unusedIntegration,
remove: unusedIntegration,
status: unusedIntegration,
},
oauth: {
connect: unusedIntegration,
@@ -375,6 +375,7 @@ describe("ModelResolver", () => {
activate: () => Effect.die("unused"),
update: () => Effect.die("unused"),
remove: () => Effect.die("unused"),
status: () => Effect.die("unused"),
},
oauth: {
connect: () => Effect.die("unused"),
+10
View File
@@ -95,6 +95,7 @@ export function host(overrides: Overrides = {}): Plugin.Context {
connection: {
active: () => Effect.die("unused integration.connection.active"),
resolve: () => Effect.die("unused integration.connection.resolve"),
status: () => Effect.die("unused integration.connection.status"),
},
},
mcp: overrides.mcp ?? {
@@ -313,6 +314,15 @@ export function integrationHost(integration: Integration.Interface): Plugin.Cont
integration.connection.resolve(
connection.type === "credential" ? { ...connection, id: Credential.ID.make(connection.id) } : connection,
),
status: (input) =>
integration.connection.status({
integrationID: Integration.ID.make(input.integrationID),
connection:
input.connection.type === "credential"
? { ...input.connection, id: Credential.ID.make(input.connection.id) }
: input.connection,
status: input.status,
}),
},
transform: (callback) =>
integration.transform((editor) =>
@@ -6,6 +6,7 @@ import { Money } from "@opencode/schema/money"
import { Effect, Layer, Stream } from "effect"
import { TestClock } from "effect/testing"
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { Agent } from "@opencode/core/agent"
import { App } from "@opencode/core/app"
import { Config } from "@opencode/core/config"
import { ConfigPolicyPlugin } from "@opencode/core/config/plugin/policy"
@@ -16,9 +17,11 @@ import { Mcp } from "@opencode/core/mcp/index"
import { Model } from "@opencode/core/model"
import { ModelResolver } from "@opencode/core/model-resolver"
import { Plugin } from "@opencode/core/plugin"
import { PluginHooks } from "@opencode/core/plugin/hooks"
import { PluginHost } from "@opencode/core/plugin/host"
import { OpencodePlugin } from "@opencode/core/plugin/provider/opencode"
import { Provider } from "@opencode/core/provider"
import { Session } from "@opencode/core/session"
import { WebSearch } from "@opencode/core/websearch"
import { withEnv } from "../fixture/env"
import { emptyMcp } from "../fixture/mcp"
@@ -900,6 +903,266 @@ describe("OpencodePlugin", () => {
),
)
it.effect("reports Console SSO sign-in, keeps the last config, and clears once Console accepts again", () =>
Effect.acquireUseRelease(
Effect.sync(() => {
const state = { sso: false }
const server = Bun.serve({
port: 0,
fetch: () => {
if (state.sso)
return Response.json(
{ _tag: "SsoRequired", orgId: "org_acme", connectionId: "ssoconn/1" },
{ status: 403 },
)
return Response.json({
providers: {},
experimental: { policies: [{ action: "permission", resource: "shell:sudo *", effect: "deny" }] },
})
},
})
return { server, state }
}),
({ server, state }) =>
Effect.gen(function* () {
const credentials = yield* Credential.Service
const integrations = yield* Integration.Service
const managed = yield* ManagedPolicy.Service
const status = () =>
integrations
.get(Integration.ID.make("opencode"))
.pipe(Effect.map((integration) => integration?.connections[0]?.status))
const sudo: ConfigPolicy.Info = { action: "permission", resource: "shell:sudo *", effect: "deny" }
yield* credentials.create({
integrationID: Integration.ID.make("opencode"),
value: Credential.Key.make({
type: "key",
key: "secret",
metadata: { server: `${server.url.origin}/console`, orgID: "org_acme", orgName: "Acme" },
}),
})
yield* addPlugin()
yield* drain
expect(yield* status()).toBeUndefined()
state.sso = true
yield* TestClock.adjust("1 minute")
yield* drain
const url = `${server.url.origin}/console/auth/sso/ssoconn%2F1/start?redirectTo=%2Fconsole%2F`
expect(yield* status()).toEqual({ status: "needs_auth", message: "Sign in with SSO again to use Acme", url })
expect(managed.current()).toEqual({ statements: [sudo], organization: "Acme" })
state.sso = false
yield* TestClock.adjust("1 minute")
yield* drain
expect(yield* status()).toBeUndefined()
expect(managed.current()).toEqual({ statements: [sudo], organization: "Acme" })
}),
({ server }) => Effect.promise(() => server.stop(true)),
),
)
it.live("explains rejected Console inference with the sign-in the connection needs", () =>
Effect.acquireUseRelease(
Effect.sync(() => {
const state = { sso: false }
const server = Bun.serve({
port: 0,
fetch: () => {
if (state.sso)
return Response.json(
{ _tag: "SsoRequired", orgId: "org_acme", connectionId: "ssoconn_1" },
{ status: 403 },
)
return Response.json({ providers: { remote: { canonical: "openai" } } })
},
})
return { server, state }
}),
({ server, state }) =>
Effect.gen(function* () {
const credentials = yield* Credential.Service
const hooks = yield* PluginHooks.Service
yield* credentials.create({
integrationID: Integration.ID.make("opencode"),
value: Credential.Key.make({
type: "key",
key: "secret",
metadata: { server: server.url.origin, orgID: "org_acme", orgName: "Acme" },
}),
})
yield* addPlugin()
const rejected = (providerID: Provider.ID) =>
hooks
.trigger("session", "http.response", {
sessionID: Session.ID.make("ses_console"),
agent: Agent.ID.make("build"),
model: Model.Ref.make({ providerID, id: Model.ID.make("model") }),
kind: "primary",
request: new Request("https://inference.example/v1/responses"),
response: Response.json({ error: { message: "Workspace access denied" } }, { status: 403 }),
})
.pipe(
Effect.flatMap((event) =>
Effect.promise(async () => ({ status: event.response.status, body: await event.response.json() })),
),
)
const denied = { status: 403, body: { error: { message: "Workspace access denied" } } }
expect(yield* rejected(Provider.ID.make("remote"))).toEqual(denied)
state.sso = true
const explained = {
status: 403,
body: {
error: {
type: "authentication_error",
message: `Sign in with SSO again to use Acme: ${server.url.origin}/auth/sso/ssoconn_1/start?redirectTo=%2F`,
},
},
}
expect(yield* rejected(Provider.ID.make("remote"))).toEqual(explained)
expect(yield* rejected(Provider.ID.opencode)).toEqual(explained)
expect(yield* rejected(Provider.ID.anthropic)).toEqual(denied)
}),
({ server }) => Effect.promise(() => server.stop(true)),
),
)
it.effect("ignores 403 responses that are not a Console SSO error", () =>
Effect.acquireUseRelease(
Effect.sync(() => {
const state: { body: Response | undefined } = { body: undefined }
const server = Bun.serve({
port: 0,
fetch: () =>
state.body ??
Response.json({
providers: {},
experimental: { policies: [{ action: "permission", resource: "shell:sudo *", effect: "deny" }] },
}),
})
return { server, state }
}),
({ server, state }) =>
Effect.gen(function* () {
const credentials = yield* Credential.Service
const integrations = yield* Integration.Service
const managed = yield* ManagedPolicy.Service
const sudo: ConfigPolicy.Info = { action: "permission", resource: "shell:sudo *", effect: "deny" }
yield* credentials.create({
integrationID: Integration.ID.make("opencode"),
value: Credential.Key.make({ type: "key", key: "secret", metadata: { server: server.url.origin } }),
})
yield* addPlugin()
yield* drain
for (const body of [
() => new Response("<html>Forbidden</html>", { status: 403, headers: { "content-type": "text/html" } }),
() => Response.json({ _tag: "Forbidden", orgId: "org", connectionId: "ssoconn" }, { status: 403 }),
]) {
state.body = body()
yield* TestClock.adjust("1 minute")
yield* drain
expect((yield* integrations.get(Integration.ID.make("opencode")))?.connections[0]?.status).toBeUndefined()
expect(managed.current().statements).toEqual([sudo])
}
}),
({ server }) => Effect.promise(() => server.stop(true)),
),
)
it.effect("reports a rejected Console refresh token as signed out without removing the credential", () =>
Effect.acquireUseRelease(
Effect.sync(() =>
Bun.serve({
port: 0,
fetch: (request) => {
if (new URL(request.url).pathname === "/auth/device/token")
return Response.json(
{ error: "invalid_grant", error_description: "refresh token revoked" },
{ status: 400 },
)
return Response.json({ providers: {} })
},
}),
),
(server) =>
Effect.gen(function* () {
const credentials = yield* Credential.Service
const integrations = yield* Integration.Service
const credential = yield* credentials.create({
integrationID: Integration.ID.make("opencode"),
value: Credential.OAuth.make({
type: "oauth",
methodID: Integration.MethodID.make("device"),
access: "expired",
refresh: "revoked",
expires: 0,
metadata: { server: server.url.origin, orgID: "org_acme", orgName: "Acme" },
}),
})
yield* addPlugin()
yield* drain
expect((yield* integrations.get(Integration.ID.make("opencode")))?.connections[0]?.status).toEqual({
status: "needs_auth",
message: "Reconnect OpenCode Console to continue",
})
expect((yield* credentials.get(credential.id))?.value).toMatchObject({ refresh: "revoked" })
}),
(server) => Effect.promise(() => server.stop(true)),
),
)
it.effect("reports a Console session rejected before its token expires as signed out", () =>
Effect.acquireUseRelease(
Effect.sync(() => {
const state = { revoked: true, refreshes: 0 }
const server = Bun.serve({
port: 0,
fetch: (request) => {
if (new URL(request.url).pathname === "/auth/device/token") state.refreshes++
if (state.revoked)
return Response.json({ _tag: "Unauthorized", message: "Invalid or expired session" }, { status: 401 })
return Response.json({ providers: {} })
},
})
return { server, state }
}),
({ server, state }) =>
Effect.gen(function* () {
const credentials = yield* Credential.Service
const integrations = yield* Integration.Service
const credential = yield* credentials.create({
integrationID: Integration.ID.make("opencode"),
value: Credential.OAuth.make({
type: "oauth",
methodID: Integration.MethodID.make("device"),
access: "revoked",
refresh: "refresh",
expires: Number.MAX_SAFE_INTEGER,
metadata: { server: server.url.origin, orgID: "org_acme", orgName: "Acme" },
}),
})
const status = () =>
integrations.get(Integration.ID.make("opencode")).pipe(Effect.map((item) => item?.connections[0]?.status))
yield* addPlugin()
yield* drain
expect(yield* status()).toEqual({ status: "needs_auth", message: "Reconnect OpenCode Console to continue" })
expect((yield* credentials.get(credential.id))?.value).toMatchObject({ access: "revoked" })
state.revoked = false
yield* TestClock.adjust("1 minute")
yield* drain
expect(yield* status()).toBeUndefined()
expect(state.refreshes).toBe(0)
}),
({ server }) => Effect.promise(() => server.stop(true)),
),
)
it.live("loads and executes hosted web search from the connected OpenCode server", () =>
Effect.acquireUseRelease(
Effect.sync(() => {
@@ -1,5 +1,6 @@
import type { ConnectionInfo } from "@opencode/client"
import type { IntegrationApi } from "@opencode/client/effect/api"
import { Connection } from "@opencode/schema/connection"
import { Credential } from "@opencode/schema/credential"
import { Form } from "@opencode/schema/form"
import type { Effect, Scope } from "effect"
@@ -92,5 +93,11 @@ export interface IntegrationDomain extends Omit<IntegrationApi<unknown>, "wellkn
readonly connection: {
readonly active: (integrationID: string) => Effect.Effect<ConnectionInfo | undefined>
readonly resolve: (connection: ConnectionInfo) => Effect.Effect<Credential.Value | undefined, unknown>
/** Reports a problem with a connection, such as a required sign-in; `undefined` clears it. */
readonly status: (input: {
readonly integrationID: string
readonly connection: ConnectionInfo
readonly status: Connection.Status | undefined
}) => Effect.Effect<void>
}
}
+1
View File
@@ -427,6 +427,7 @@ export function fromPromise(plugin: Plugin) {
connection: {
active: (id) => Effect.runPromiseWith(runtime)(host.integration.connection.active(id)),
resolve: (connection) => Effect.runPromiseWith(runtime)(host.integration.connection.resolve(connection)),
status: (input) => run(host.integration.connection.status(input)),
},
},
mcp: {
@@ -1,5 +1,6 @@
import type { ConnectionInfo } from "@opencode/client"
import type { IntegrationApi } from "@opencode/client/promise/api"
import { Connection } from "@opencode/schema/connection"
import { Credential } from "@opencode/schema/credential"
import { Form } from "@opencode/schema/form"
import type { Transform } from "./registration.js"
@@ -87,5 +88,11 @@ export interface IntegrationDomain extends Omit<IntegrationApi, "wellknown"> {
readonly connection: {
readonly active: (integrationID: string) => Promise<ConnectionInfo | undefined>
readonly resolve: (connection: ConnectionInfo) => Promise<Credential.Value | undefined>
/** Reports a problem with a connection, such as a required sign-in; `undefined` clears it. */
readonly status: (input: {
readonly integrationID: string
readonly connection: ConnectionInfo
readonly status: Connection.Status | undefined
}) => Promise<void>
}
}
+14
View File
@@ -2,6 +2,18 @@ export * as Connection from "./connection.js"
import { Schema } from "effect"
import { Credential } from "./credential.js"
import { optional } from "./schema.js"
/**
* Runtime problem an integration reported for a connection. `needs_auth` asks the user to sign in again:
* at `url` when set (the credential itself stays valid), otherwise by reconnecting the integration.
*/
export interface Status extends Schema.Schema.Type<typeof Status> {}
export const Status = Schema.Struct({
status: Schema.Literals(["needs_auth"]),
message: Schema.String,
url: optional(Schema.String),
}).annotate({ identifier: "Connection.Status" })
export interface CredentialInfo extends Schema.Schema.Type<typeof CredentialInfo> {}
export const CredentialInfo = Schema.Struct({
@@ -10,12 +22,14 @@ export const CredentialInfo = Schema.Struct({
label: Schema.String,
/** How the credential was obtained: a stored key or an OAuth grant. */
method: Schema.Literals(["key", "oauth"]),
status: optional(Status),
}).annotate({ identifier: "Connection.CredentialInfo" })
export interface EnvInfo extends Schema.Schema.Type<typeof EnvInfo> {}
export const EnvInfo = Schema.Struct({
type: Schema.Literal("env"),
name: Schema.String,
status: optional(Status),
}).annotate({ identifier: "Connection.EnvInfo" })
export const Info = Schema.Union([CredentialInfo, EnvInfo])
@@ -106,15 +106,19 @@ export function DialogIntegration(
let category = "Services"
if (integration.id in INTEGRATION_PRIORITY) category = "Popular"
if (integration.metadata?.source === "mcp") category = "MCP"
const status = integration.connections[0]?.status
return {
title: integration.name,
value: integration.id,
description: methods.length === 0 ? "Environment only" : undefined,
footer: connectionSummary(integration) || undefined,
footer: status ? "Sign in required →" : connectionSummary(integration) || undefined,
footerColor: status ? theme.text.feedback.warning.base : undefined,
category,
disabled: methods.length === 0 && credentials.length === 0,
gutter:
integration.connections.length > 0 ? () => <text fg={theme.text.feedback.success.base}>✓</text> : undefined,
integration.connections.length > 0 && !status
? () => <text fg={theme.text.feedback.success.base}>✓</text>
: undefined,
onSelect: () => {
if (credentials.length) return manageConnections(integration, methods, location, dialog, props.onConnected)
return selectMethod(integration, methods, location, dialog, props.onConnected)
@@ -189,10 +193,15 @@ function manageConnections(
? `Press ${shortcuts.get("dialog.integration.delete")} again to confirm`
: connection.label,
value: connection.id,
footer: connection.status ? "Sign in required →" : undefined,
footerColor: connection.status ? theme.text.feedback.warning.base : undefined,
category: "Connected accounts",
bg: confirming ? theme.background.action.destructive.focused : undefined,
fg: confirming ? theme.text.action.destructive.focused : undefined,
onSelect: () => {
if (connection.status?.url) return void openUrl(connection.status.url).catch(toast.error)
if (connection.status)
return selectMethod(current() ?? integration, methods, location, dialog, onConnected)
if (credentialConnections(current() ?? integration)[0]?.id === connection.id) return
void client.api.credential.activate({ credentialID: connection.id }).catch(toast.error)
},