Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 45 additions & 7 deletions packages/core/src/plugin/provider/opencode.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Duration, Effect, Equal, Option, Schema, SchemaGetter, Scope, Semaphore, Stream } from "effect"
import { Clock, Duration, Effect, Equal, Option, Schema, SchemaGetter, 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"
Expand Down Expand Up @@ -82,6 +82,21 @@ const signedOutMessage = "Reconnect OpenCode Console to continue"
const ssoMessage = (organization: string | undefined) =>
`Sign in with SSO again to use ${organization ?? "your OpenCode Console organization"}`

type Fetched = {
readonly key: string | undefined
readonly at: number
readonly config: typeof RemoteResponse.Type | undefined
readonly organization: string | undefined
// Console MCP servers carry the credential in their headers, so a rotated token changes the snapshot.
readonly mcp:
| { servers: NonNullable<typeof RemoteResponse.Type.mcp>["servers"]; headers: Record<string, string> }
| undefined
}
// The supervisor activates this plugin on its own before the full plugin set, and the full activation starts it
// again. Keyed by the shared policy service so that second start reuses the config fetched moments earlier.
const fetched = new WeakMap<ManagedPolicy.Interface, Fetched>()
const reuseFor = Duration.seconds(30)

class SsoRequiredError extends Schema.TaggedError<SsoRequiredError>()("OpencodeConsole.SsoRequired", {
organization: Schema.optional(Schema.String),
url: Schema.String,
Expand Down Expand Up @@ -190,13 +205,10 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Manag
const loading = Semaphore.makeUnsafe(1)
type ActiveConnection = Effect.Success<ReturnType<typeof ctx.integration.connection.active>>
let snapshot: {
config: typeof RemoteResponse.Type | undefined
config: Fetched["config"]
connection: ActiveConnection
organization: string | undefined
// Console MCP servers carry the credential in their headers, so a rotated token changes the snapshot.
mcp:
| { servers: NonNullable<typeof RemoteResponse.Type.mcp>["servers"]; headers: Record<string, string> }
| undefined
mcp: Fetched["mcp"]
} = { 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
Expand All @@ -223,6 +235,19 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Manag
organization: organizationName(credential),
mcp: config?.mcp && { servers: config.mcp.servers, headers: credentialHeaders(credential) },
})),
Effect.tap((next) =>
Clock.currentTimeMillis.pipe(
Effect.map((at) =>
fetched.set(managed, {
key: IntegrationConnection.key(connection),
at,
config: next.config,
organization: next.organization,
mcp: next.mcp,
}),
),
),
),
)
}),
Effect.tap(() => status(undefined)),
Expand All @@ -241,6 +266,19 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Manag
),
)
})
const initial = Effect.fn("OpencodePlugin.initial")(function* () {
const connection = yield* ctx.integration.connection.active("opencode")
const previous = fetched.get(managed)
const now = yield* Clock.currentTimeMillis
if (
connection &&
previous &&
previous.key === IntegrationConnection.key(connection) &&
now - previous.at < Duration.toMillis(reuseFor)
)
return { config: previous.config, connection, organization: previous.organization, mcp: previous.mcp }
return yield* load()
})
// Statements ride on the snapshot, so a credential switch, disconnect, or 404 replaces them too.
const publish = (next: typeof snapshot) =>
managed.set({ statements: next.config?.experimental?.policies ?? [], organization: next.organization })
Expand All @@ -253,7 +291,7 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Manag
editor.method.update({ integrationID: "opencode", method: { type: "key", label: "API key (service account)" } })
})

snapshot = yield* load()
snapshot = yield* initial()
yield* publish(snapshot)
yield* ctx.provider.transform((providers) => {
for (const [providerID, item] of Object.entries(snapshot.config?.providers ?? {})) {
Expand Down
34 changes: 34 additions & 0 deletions packages/core/test/plugin/provider-opencode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -647,6 +647,40 @@ describe("OpencodePlugin", () => {
),
)

it.effect("reuses a just-fetched Console config when the plugin starts again", () =>
Effect.acquireUseRelease(
Effect.sync(() => {
const state = { requests: 0 }
const server = Bun.serve({
port: 0,
fetch: () => {
state.requests++
return Response.json({ providers: {} })
},
})
return { server, state }
}),
({ server, state }) =>
Effect.gen(function* () {
const credentials = yield* Credential.Service
yield* credentials.create({
integrationID: Integration.ID.make("opencode"),
value: Credential.Key.make({ type: "key", key: "secret", metadata: { server: server.url.origin } }),
})
yield* addPlugin().pipe(Effect.scoped)
expect(state.requests).toBe(1)

yield* addPlugin().pipe(Effect.scoped)
expect(state.requests).toBe(1)

yield* TestClock.adjust("30 seconds")
yield* addPlugin().pipe(Effect.scoped)
expect(state.requests).toBe(2)
}),
({ server }) => Effect.promise(() => server.stop(true)),
),
)

it.effect("registers the Console's MCP servers as sent, attaching the credential only where asked", () =>
Effect.acquireUseRelease(
Effect.sync(() => {
Expand Down
Loading