Skip to content
Closed
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
26 changes: 24 additions & 2 deletions packages/core/src/credential.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { makeGlobalNode } from "@opencode/util/effect/app-node"
import { EffectFlock } from "@opencode/util/effect-flock"
import { CredentialTable } from "./credential/sql.js"
import { ErrorSummary } from "./util/error-summary.js"
import { WellKnown } from "./wellknown.js"

export const ID = Credential.ID
export type ID = Credential.ID
Expand Down Expand Up @@ -74,6 +75,7 @@ const layer = Layer.effect(
Effect.gen(function* () {
const db = (yield* Database.Service).db
const bus = yield* Bus.Service
const wellknown = yield* WellKnown.Service
const flock = yield* EffectFlock.Service
const scope = yield* Effect.scope
const pending = new Map<ID, Fiber.Fiber<OAuth | undefined, unknown>>()
Expand All @@ -93,6 +95,15 @@ const layer = Layer.effect(
const credential = stored(row)
return credential ? [credential] : []
})
const list = Effect.fn("Credential.list")((integrationID: Integration.ID) =>
db
.select()
.from(CredentialTable)
.where(eq(CredentialTable.integration_id, integrationID))
.orderBy(asc(CredentialTable.active), asc(CredentialTable.time_created), asc(CredentialTable.id))
.all()
.pipe(Effect.orDie, Effect.map(storedRows)),
)
const rows = (where?: SQL) =>
db
.select()
Expand Down Expand Up @@ -145,7 +156,7 @@ const layer = Layer.effect(
),
),
all: () => rows(),
list: Effect.fn("Credential.list")((integrationID) => rows(eq(CredentialTable.integration_id, integrationID))),
list,
get: Effect.fn("Credential.get")(function* (id) {
const row = yield* db.select().from(CredentialTable).where(eq(CredentialTable.id, id)).get().pipe(Effect.orDie)
return row ? stored(row) : undefined
Expand Down Expand Up @@ -290,9 +301,20 @@ const layer = Layer.effect(
{ integrationID: removed.integrationID, credentialID: removed.credentialID },
{ global: true },
)
// A well-known source with no credential left cannot authenticate, and the config loader keeps its last
// cached remote config until the source is removed. Drop the source so every credential caller, not just
// the HTTP handler, drops the config with the last credential.
if (!removed.integrationID || (yield* list(removed.integrationID)).length) return
// The persisted source list, not the loaded cache, so this also holds before config has loaded the sources.
if (!(yield* wellknown.has(removed.integrationID))) return
yield* wellknown.remove(removed.integrationID)
}),
})
}),
)

export const node = makeGlobalNode({ service: Service, layer, deps: [Database.node, Bus.node, EffectFlock.node] })
export const node = makeGlobalNode({
service: Service,
layer,
deps: [Database.node, Bus.node, EffectFlock.node, WellKnown.node],
})
7 changes: 7 additions & 0 deletions packages/core/src/wellknown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ export interface Entry {
export interface Interface {
readonly entries: () => Effect.Effect<readonly Entry[], Error>
readonly snapshot: () => readonly Entry[]
/** Reports whether an integration is a registered source, reading the persisted list instead of the loaded cache. */
readonly has: (integrationID: Integration.ID) => Effect.Effect<boolean>
readonly refresh: () => Effect.Effect<boolean, Error>
readonly add: (origin: string) => Effect.Effect<Entry, Error>
readonly remove: (origin: string) => Effect.Effect<void>
Expand Down Expand Up @@ -168,6 +170,11 @@ const layer = Layer.effect(
return Service.of({
entries: load,
snapshot: () => Array.from(Ref.getUnsafe(cache).values()),
has: Effect.fn("WellKnown.has")(function* (integrationID) {
const value = yield* kv.get(sourcesKey)
const origins = Schema.is(Sources)(value) ? value : []
return origins.some((origin) => Integration.ID.make(origin) === integrationID)
}),
refresh,
add: Effect.fn("WellKnown.add")(
function* (value) {
Expand Down
2 changes: 2 additions & 0 deletions packages/core/test/config/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -930,6 +930,7 @@ describe("Config", () => {
WellKnown.Service.of({
entries: () => Effect.succeed([entry]),
snapshot: () => [entry],
has: () => Effect.succeed(false),
refresh: () => Effect.succeed(false),
add: () => Effect.die("unused Wellknown.add"),
remove: () => Effect.die("unused Wellknown.remove"),
Expand Down Expand Up @@ -1161,6 +1162,7 @@ describe("Config", () => {
WellKnown.Service.of({
entries: () => Effect.succeed([entry]),
snapshot: () => [entry],
has: () => Effect.succeed(false),
refresh: () => Effect.succeed(false),
add: () => Effect.die("unused Wellknown.add"),
remove: () => Effect.die("unused Wellknown.remove"),
Expand Down
44 changes: 43 additions & 1 deletion packages/core/test/credential.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { describe, expect } from "bun:test"
import { eq, sql } from "drizzle-orm"
import { Effect } from "effect"
import { Effect, Layer } from "effect"
import { Bus } from "@opencode/core/bus"
import { Credential } from "@opencode/core/credential"
import { CredentialTable } from "@opencode/core/credential/sql"
import { Database } from "@opencode/core/database/database"
import { Location } from "@opencode/core/location"
import { AbsolutePath } from "@opencode/core/schema"
import { WellKnown } from "@opencode/core/wellknown"
import { Workspace } from "@opencode/core/workspace"
import { Event } from "@opencode/schema/event"
import { LayerNode } from "@opencode/util/effect/layer-node"
Expand All @@ -16,6 +17,26 @@ import { testEffect } from "./lib/effect"

const it = testEffect(LayerNode.compile(LayerNode.group([Credential.node, Bus.node, Database.node])))

const sourceRemovals: string[] = []
const itWithSource = testEffect(
LayerNode.compile(Credential.node, {
replacements: [
WellKnown.node.replace(
Layer.mock(WellKnown.Service, {
// Deliberately empty: the cleanup must not depend on the loaded cache.
snapshot: () => [],
has: (integrationID: Integration.ID) =>
Effect.succeed(integrationID === Integration.ID.make("https://wellknown.test")),
remove: (origin: string) =>
Effect.sync(() => {
sourceRemovals.push(origin)
}),
}),
),
],
}),
)

describe("Credential", () => {
it.effect("stores, updates, lists, and removes credentials", () =>
Effect.gen(function* () {
Expand Down Expand Up @@ -268,4 +289,25 @@ describe("Credential", () => {
])
}),
)

itWithSource.effect("removes a well-known source when its last credential is gone", () =>
Effect.gen(function* () {
sourceRemovals.length = 0
const credentials = yield* Credential.Service
const integrationID = Integration.ID.make("https://wellknown.test")
const value = Credential.Key.make({ type: "key", key: "secret" })
const other = yield* credentials.create({ integrationID: Integration.ID.make("openai"), value })
yield* credentials.remove(other.id)
expect(sourceRemovals).toEqual([])

const first = yield* credentials.create({ integrationID, value })
const second = yield* credentials.create({ integrationID, value })

yield* credentials.remove(first.id)
expect(sourceRemovals).toEqual([])

yield* credentials.remove(second.id)
expect(sourceRemovals).toEqual(["https://wellknown.test"])
}),
)
})
1 change: 1 addition & 0 deletions packages/core/test/fixture/config-nodes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export const emptyWellknownNode = makeGlobalNode({
WellKnown.Service.of({
entries: () => Effect.succeed([]),
snapshot: () => [],
has: () => Effect.succeed(false),
refresh: () => Effect.succeed(false),
add: () => Effect.die("unused Wellknown.add"),
remove: () => Effect.die("unused Wellknown.remove"),
Expand Down
3 changes: 3 additions & 0 deletions packages/core/test/wellknown.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { FetchHttpClient } from "effect/http"
import { KV } from "@opencode/core/kv"
import { LayerNode } from "@opencode/util/effect/layer-node"
import { Bus } from "@opencode/core/bus"
import { Integration } from "@opencode/core/integration"
import { Location } from "@opencode/core/location"
import { AbsolutePath } from "@opencode/core/schema"
import { WellKnown } from "@opencode/core/wellknown"
Expand Down Expand Up @@ -78,11 +79,13 @@ serviceIt.live("persists sources in one KV value", () =>
expect(entry.origin).toBe(server.url.origin)
expect(yield* kv.get("wellknown:sources")).toEqual([server.url.origin])
expect(yield* wellknown.entries()).toEqual([entry])
expect(yield* wellknown.has(Integration.ID.make(server.url.origin))).toBe(true)
expect(yield* Fiber.join(changed)).toHaveLength(1)

yield* wellknown.remove(server.url.origin)
expect(yield* kv.get("wellknown:sources")).toEqual([])
expect(yield* wellknown.entries()).toEqual([])
expect(yield* wellknown.has(Integration.ID.make(server.url.origin))).toBe(false)
}),
(server) => Effect.promise(() => server.stop(true)),
),
Expand Down
99 changes: 99 additions & 0 deletions packages/server/test/credential.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { expect } from "bun:test"
import { Config } from "@opencode/schema/config"
import { Effect, Schema } from "effect"
import { tmpdir } from "../../core/test/fixture/tmpdir"
import { it } from "../../core/test/lib/effect"
import { startServer } from "./fixture/server"

const Decode = {
integrations: Schema.decodeUnknownSync(Schema.Struct({ data: Schema.Array(Schema.Struct({ id: Schema.String })) })),
credential: Schema.decodeUnknownSync(Schema.Struct({ data: Schema.Struct({ id: Schema.String }) })),
config: Schema.decodeUnknownSync(Schema.Array(Config.Entry)),
}

it.live("removing the last credential removes its well-known source", () =>
Effect.acquireUseRelease(
Effect.sync(() =>
Bun.serve({
port: 0,
fetch: () => Response.json({ auth: { command: ["login"], env: "TOKEN" }, config: { model: "embedded/model" } }),
}),
),
(manifest) =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-credential-wellknown-")))
const server = yield* startServer(tmp.path)
const origin = manifest.url.origin
const directory = new URLSearchParams({ "location[directory]": tmp.path })
const request = (path: string, init: RequestInit) =>
Effect.promise(() =>
fetch(new URL(`${path}?${directory}`, server.base), {
...init,
headers: { ...server.headers, "content-type": "application/json" },
}),
)
const json = (response: Response) => Effect.promise(() => response.json())
const integrations = () =>
request("/api/integration", { method: "GET" }).pipe(
Effect.flatMap(json),
Effect.map((body) => Decode.integrations(body).data.map((item) => item.id)),
)
const models = () =>
request("/api/config", { method: "GET" }).pipe(
Effect.flatMap(json),
Effect.map((body) => Decode.config(body)),
Effect.map((entries) =>
entries.flatMap((entry) =>
entry.type === "document" && entry.info.model
? [`${entry.info.model.providerID}/${entry.info.model.model}`]
: [],
),
),
)
const create = (label: string) =>
request("/api/credential", {
method: "POST",
body: JSON.stringify({ integrationID: origin, label, value: { type: "key", key: `key-${label}` } }),
}).pipe(
Effect.flatMap(json),
Effect.map((body) => Decode.credential(body).data.id),
)
const remove = (id: string) =>
request(`/api/credential/${id}`, { method: "DELETE" }).pipe(Effect.map((response) => response.status))

expect(
(yield* request("/api/experimental/integration/wellknown", {
method: "POST",
body: JSON.stringify({ url: origin }),
})).status,
).toBe(204)
expect(yield* integrations()).toContain(origin)

const first = yield* create("first")
const second = yield* create("second")
expect(
yield* eventually(() => models().pipe(Effect.map((ids) => ids.includes("embedded/model")))),
).toBe(true)

expect(yield* remove(first)).toBe(204)
expect(yield* integrations()).toContain(origin)
expect(yield* models()).toContain("embedded/model")

expect(yield* remove(second)).toBe(204)
expect(yield* eventually(() => integrations().pipe(Effect.map((ids) => !ids.includes(origin))))).toBe(true)
expect(
yield* eventually(() => models().pipe(Effect.map((ids) => !ids.includes("embedded/model")))),
).toBe(true)
}),
(manifest) => Effect.promise(() => manifest.stop(true)),
),
)

const eventually = (condition: () => Effect.Effect<boolean>) =>
Effect.gen(function* () {
for (let attempt = 0; attempt < 50; attempt++) {
if (yield* condition()) return true
yield* Effect.sleep("100 millis")
}
return false
})
Loading