mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-16 10:30:01 +08:00
feat(core): tell users how to sign back in to OpenCode Console (#51847)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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)),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user