Compare commits

...
Author SHA1 Message Date
Kit Langton 9545bb3a89 fix(core): refresh OAuth credentials during batched activation 2026-08-27 22:55:13 -04:00
5 changed files with 124 additions and 2 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@opencode-ai/core": patch
---
Refresh expired OAuth credentials resolved during batched plugin activation. Integration connection resolution now materializes registrations deferred by the activation batch before consulting refresh implementations, so a just-registered OAuth method is no longer skipped and an expired token no longer used as-is.
+4
View File
@@ -684,6 +684,10 @@ const layer = Layer.effect(
const credential = yield* credentials.get(connection.id)
if (!credential) return undefined
if (credential.value.type === "key") return credential.value
// Plugin activation batches registrations: a plugin resolving during its own
// setup must see the refresh implementation it just registered, or an expired
// credential is silently returned without refreshing.
yield* state.flush()
const implementation = state
.get()
.integrations.get(credential.integrationID)
+20 -2
View File
@@ -75,6 +75,12 @@ export interface Options<State, DraftApi> {
export interface Interface<State, DraftApi> extends Transformable<DraftApi> {
readonly get: () => State
/**
* Materializes registrations and disposals deferred by an active batch so
* subsequent reads observe them. No-op when nothing is pending; never waits
* out the reload debounce.
*/
readonly flush: () => Effect.Effect<void>
}
export function create<State, DraftApi>(options: Options<State, DraftApi>): Interface<State, DraftApi> {
@@ -84,6 +90,7 @@ export function create<State, DraftApi>(options: Options<State, DraftApi>): Inte
let requestedAt = 0
let running = false
let closed = false
let dirty = false
let waiters: { generation: number; done: Deferred.Deferred<void> }[] = []
const semaphore = Semaphore.makeUnsafe(1)
@@ -93,6 +100,7 @@ export function create<State, DraftApi>(options: Options<State, DraftApi>): Inte
})
const materialize = Effect.fnUntraced(function* () {
dirty = false
if (closed) return
const next = options.initial()
const api = options.draft(next)
@@ -162,6 +170,7 @@ export function create<State, DraftApi>(options: Options<State, DraftApi>): Inte
closed = true
return
}
dirty = true
batch.reloads.add(materializeReload)
return
}
@@ -177,12 +186,21 @@ export function create<State, DraftApi>(options: Options<State, DraftApi>): Inte
)
yield* Scope.addFinalizer(scope, dispose)
const batch = yield* CurrentBatch
if (batch?.active) batch.reloads.add(materializeReload)
else yield* materializeReload()
if (batch?.active) {
dirty = true
batch.reloads.add(materializeReload)
} else yield* materializeReload()
return { dispose }
}),
)
}),
reload,
flush: Effect.fnUntraced(function* () {
if (!dirty) return
// Flushing from inside the deferring batch replaces its queued rebuild.
const batch = yield* CurrentBatch
batch?.reloads.delete(materializeReload)
yield* materializeReload()
}),
}
}
+36
View File
@@ -7,6 +7,7 @@ import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Bus } from "@opencode-ai/core/bus"
import { Integration } from "@opencode-ai/core/integration"
import { State } from "@opencode-ai/core/state"
import { testEffect } from "./lib/effect"
const it = testEffect(AppNodeBuilder.build(LayerNode.group([Integration.node, Credential.node, Bus.node])))
@@ -262,6 +263,41 @@ describe("Integration", () => {
}),
)
it.effect("refreshes an expired OAuth credential registered in the same batch", () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
const credentials = yield* Credential.Service
const integrationID = Integration.ID.make("opencode")
const methodID = Integration.MethodID.make("device")
const stored = yield* credentials.create({
integrationID,
label: "Work",
value: Credential.OAuth.make({ type: "oauth", methodID, access: "expired", refresh: "refresh", expires: 1 }),
})
// Plugin activation batches setup, deferring method registration until
// every plugin finishes. A plugin resolving its connection during setup
// must still reach the refresh implementation it just registered.
const resolved = yield* State.batch(
Effect.gen(function* () {
yield* integrations.transform((editor) =>
editor.method.update({
integrationID,
method: { id: methodID, type: "oauth", label: "Device" },
authorize: () => Effect.die(new Error("unused authorize")),
refresh: (credential) =>
Effect.succeed({ ...credential, access: "fresh", expires: Number.MAX_SAFE_INTEGER }),
}),
)
return yield* integrations.connection.resolve({ type: "credential", id: stored.id, label: "Work" })
}),
)
expect(resolved).toMatchObject({ type: "oauth", access: "fresh" })
expect((yield* credentials.get(stored.id))?.value).toMatchObject({ access: "fresh" })
}),
)
it.effect("completes code OAuth once and stores the credential", () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
+59
View File
@@ -133,6 +133,65 @@ describe("State", () => {
}),
)
it.effect("flushes registrations deferred by a batch", () =>
Effect.gen(function* () {
let finalized = 0
const state = State.create({
initial: () => ({ values: [] as string[] }),
draft: (draft) => ({ add: (item: string) => draft.values.push(item) }),
finalize: () => Effect.sync(() => finalized++),
})
yield* State.batch(
Effect.gen(function* () {
yield* state.transform((draft) => {
draft.add("first")
})
expect(state.get().values).toEqual([])
yield* state.flush()
expect(state.get().values).toEqual(["first"])
expect(finalized).toBe(1)
// Nothing pending: flush does not rebuild again.
yield* state.flush()
expect(finalized).toBe(1)
yield* state.transform((draft) => {
draft.add("second")
})
}),
)
// Flushing absorbed the queued batch rebuild; only the later registration
// rebuilds at batch completion.
expect(state.get().values).toEqual(["first", "second"])
expect(finalized).toBe(2)
}),
)
it.effect("flushes disposals deferred by a batch", () =>
Effect.gen(function* () {
const state = State.create({
initial: () => ({ values: [] as string[] }),
draft: (draft) => ({ add: (item: string) => draft.values.push(item) }),
})
const scope = yield* Scope.make()
yield* state.transform((draft) => draft.add("value")).pipe(Scope.provide(scope))
expect(state.get().values).toEqual(["value"])
yield* State.batch(
Effect.gen(function* () {
yield* Scope.close(scope, Exit.void)
expect(state.get().values).toEqual(["value"])
yield* state.flush()
expect(state.get().values).toEqual([])
}),
)
}),
)
it.effect("discards teardown rebuilds and pending reloads while still running cleanup", () =>
Effect.gen(function* () {
let finalized = 0