Compare commits

..

3 Commits

Author SHA1 Message Date
James Long faa92ff5ea feat(sdk): import local credentials into embedded host 2026-08-11 14:24:44 +00:00
opencode-agent[bot] 1dbfc7cfab chore: generate 2026-08-11 14:12:14 +00:00
Kit Langton 3441b95afc fix(core): models.dev catalog population must survive KV cache write failures (#41735) 2026-08-11 10:09:37 -04:00
10 changed files with 205 additions and 40 deletions
+45 -1
View File
@@ -1,9 +1,12 @@
export * as Credential from "./credential"
import { asc, eq } from "drizzle-orm"
import { asc, eq, sql } from "drizzle-orm"
import { Context, Effect, Layer, Schema } from "effect"
import fs from "fs/promises"
import path from "path"
import { Credential } from "@opencode-ai/schema/credential"
import { Integration } from "@opencode-ai/schema/integration"
import { Global } from "@opencode-ai/util/global"
import { Database } from "./database/database"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { CredentialTable } from "./credential/sql"
@@ -48,6 +51,47 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/Credential") {}
export const importFromDatabase = Effect.fn("Credential.importFromDatabase")(function* (input: {
readonly path: string
}) {
const database = yield* Database.Service
const filename = path.isAbsolute(input.path) ? input.path : path.join(Global.Path.data, input.path)
yield* Effect.promise(() => fs.access(filename))
yield* database.db.run(sql`ATTACH DATABASE ${filename} AS credential_snapshot`).pipe(Effect.orDie)
yield* Effect.gen(function* () {
yield* database.db.run(sql`DELETE FROM credential`)
// Snapshot OAuth credentials are access-token-only so the embedded host never rotates the user's refresh token.
yield* database.db.run(sql`
INSERT INTO credential (
id,
integration_id,
label,
value,
connector_id,
method_id,
active,
time_created,
time_updated
)
SELECT
id,
integration_id,
label,
CASE
WHEN json_extract(value, '$.type') = 'oauth'
THEN json_set(value, '$.refresh', '', '$.expires', 8640000000000000)
ELSE value
END,
connector_id,
method_id,
active,
time_created,
time_updated
FROM credential_snapshot.credential
`)
}).pipe(Effect.orDie, Effect.ensuring(database.db.run(sql`DETACH DATABASE credential_snapshot`).pipe(Effect.orDie)))
})
const layer = Layer.effect(
Service,
Effect.gen(function* () {
-9
View File
@@ -13,7 +13,6 @@ const lock = Semaphore.makeUnsafe(1)
export type Migration = {
id: string
aliases?: string[]
foreignKeys?: boolean
up: (tx: Transaction) => Effect.Effect<void, unknown, Global.Service>
}
@@ -78,14 +77,6 @@ export function applyOnly(db: Database, input: Migration[]) {
for (const migration of input) {
if (completed.has(migration.id)) continue
const alias = migration.aliases?.find((id) => completed.has(id))
if (alias) {
yield* db.run(
sql`INSERT INTO ${sql.identifier("migration")} (id, time_completed) VALUES (${migration.id}, ${Date.now()})`,
)
yield* Effect.logInfo("database migration adopted", { migration: migration.id, alias })
continue
}
const started = Date.now()
yield* Effect.logInfo("database migration started", { migration: migration.id })
const apply = db.transaction((tx) =>
@@ -3,7 +3,6 @@ import type { DatabaseMigration } from "../migration"
const migration: DatabaseMigration.Migration = {
id: "20260804233008_loose_psylocke",
aliases: ["20260730195856_optional_session_title"],
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`
+11 -2
View File
@@ -1,4 +1,4 @@
import { Context, Duration, Effect, Layer, Option, Schedule, Schema, Semaphore } from "effect"
import { Cause, Context, Duration, Effect, Layer, Option, Schedule, Schema, Semaphore } from "effect"
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
import { ModelsDev } from "@opencode-ai/schema/models-dev"
import { Money } from "@opencode-ai/schema/money"
@@ -612,7 +612,16 @@ export const layer = (options?: Options) =>
const fetchAndWrite = Effect.fn("ModelsDev.fetchAndWrite")(function* () {
const text = yield* fetchApi()
const catalog = (yield* Schema.decodeUnknownEffect(CatalogJson)(text)) as Record<string, SourceProvider>
yield* kv.set(key, { updatedAt: Date.now(), body: text })
// Best-effort: a cache-write failure must never kill catalog
// population. The payload has outgrown some KV backends' per-value
// limits (Durable Object SQLite caps values at 2 MB and api.json
// passed it in Aug 2026); a boot without a cache hit just refetches.
yield* kv.set(key, { updatedAt: Date.now(), body: text }).pipe(
Effect.catchCauseIf(
(cause) => !Cause.hasInterruptsOnly(cause),
(cause) => Effect.logWarning("Failed to cache models.dev catalog", { cause }),
),
)
return catalog
})
+57 -3
View File
@@ -1,11 +1,15 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { Effect, Layer } from "effect"
import { Credential } from "@opencode-ai/core/credential"
import { Database } from "@opencode-ai/core/database/database"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Integration } from "@opencode-ai/core/integration"
import { testEffect } from "./lib/effect"
import { tmpdir } from "./fixture/tmpdir"
import path from "path"
const it = testEffect(LayerNode.compile(Credential.node))
const it = testEffect(Layer.empty)
const credentialLayer = LayerNode.compile(Credential.node)
describe("Credential", () => {
it.effect("stores, updates, lists, and removes credentials", () =>
@@ -31,6 +35,56 @@ describe("Credential", () => {
yield* credentials.remove(replacement.id)
expect(yield* credentials.list(integrationID)).toEqual([])
}),
}).pipe(Effect.provide(credentialLayer)),
)
it.effect("imports a read-only credential snapshot", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir("opencode-credential-snapshot-")),
(directory) => Effect.promise(() => directory[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((directory) => {
const filename = path.join(directory.path, "source.sqlite")
const source = LayerNode.compile(Credential.node, [[Database.node, Database.configured({ path: filename })]])
const keyIntegration = Integration.ID.make("openai")
const oauthIntegration = Integration.ID.make("github-copilot")
return Effect.gen(function* () {
yield* Effect.gen(function* () {
const credentials = yield* Credential.Service
yield* credentials.create({
integrationID: keyIntegration,
value: Credential.Key.make({ type: "key", key: "secret" }),
})
yield* credentials.create({
integrationID: oauthIntegration,
value: Credential.OAuth.make({
type: "oauth",
methodID: Integration.MethodID.make("oauth"),
access: "access",
refresh: "refresh",
expires: 1,
}),
})
}).pipe(Effect.provide(source), Effect.scoped)
yield* Effect.gen(function* () {
yield* Credential.importFromDatabase({ path: filename })
const credentials = yield* Credential.Service
expect((yield* credentials.list(keyIntegration))[0]?.value).toEqual(
Credential.Key.make({ type: "key", key: "secret" }),
)
expect((yield* credentials.list(oauthIntegration))[0]?.value).toEqual(
Credential.OAuth.make({
type: "oauth",
methodID: Integration.MethodID.make("oauth"),
access: "access",
refresh: "",
expires: 8640000000000000,
}),
)
}).pipe(Effect.provide(LayerNode.compile(LayerNode.group([Credential.node, Database.node]))), Effect.scoped)
})
}),
),
)
})
@@ -12,7 +12,6 @@ import { Database } from "@opencode-ai/core/database/database"
import { tmpdir } from "./fixture/tmpdir"
import type { SqlClient } from "effect/unstable/sql/SqlClient"
import legacyCredentialsMigration from "@opencode-ai/core/database/migration/20260805200742_import_legacy_credentials"
import consolidatedV2Migration from "@opencode-ai/core/database/migration/20260804233008_loose_psylocke"
import { Global } from "@opencode-ai/util/global"
const run = <A, E>(
@@ -115,28 +114,6 @@ describe("DatabaseMigration", () => {
)
})
test("adopts the consolidated V2 migration after the superseded chain", async () => {
await run(
Effect.gen(function* () {
const db = yield* makeDb
yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY)`)
yield* db.run(sql`CREATE TABLE event (id text PRIMARY KEY, created integer DEFAULT 0 NOT NULL)`)
yield* db.run(sql`CREATE TABLE migration (id text PRIMARY KEY, time_completed integer NOT NULL)`)
yield* db.run(sql`
INSERT INTO migration (id, time_completed)
VALUES ('20260730195856_optional_session_title', 1)
`)
yield* DatabaseMigration.applyOnly(db, [consolidatedV2Migration])
expect(yield* db.get(sql`SELECT id FROM migration WHERE id = ${consolidatedV2Migration.id}`)).toEqual({
id: consolidatedV2Migration.id,
})
expect(yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'kv'`)).toBeUndefined()
}),
)
})
test("imports legacy JSON credentials without changing the source file or existing credentials", async () => {
await using tmp = await tmpdir()
const source = path.join(tmp.path, "auth.json")
+28
View File
@@ -185,6 +185,15 @@ const buildLayer = (state: Ref.Ref<MockState>, cache: MockCache, options: Models
]),
)
// Mirrors production KV backends whose writes die as defects (e.g. Durable
// Object SQLite rejecting values over its 2 MB cap with EffectDrizzleQueryError).
const makeFailingWriteKV = (cache: MockCache) =>
Layer.mock(KV.Service, {
get: (key) => Effect.sync(() => cache.values.get(key)),
set: () => Effect.die(new Error('Failed query: insert into "kv"')),
remove: (key) => Effect.sync(() => cache.values.delete(key)).pipe(Effect.asVoid),
})
const makeCache = (): MockCache => ({ values: new Map() })
const writeCacheText = (cache: MockCache, text: string, updatedAt = Date.now()) =>
@@ -248,6 +257,25 @@ describe("ModelsDev Service", () => {
}),
)
it.live("get() still populates the catalog when the KV cache write fails", () =>
Effect.gen(function* () {
const cache = makeCache()
const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) })
const layer = Layer.fresh(
AppNodeBuilder.build(ModelsDev.node, [
[ModelsDev.node, ModelsDev.configured({ fetch: true })],
[LayerNodePlatform.httpClient, Layer.succeed(HttpClient.HttpClient, makeMockClient(state))],
[KV.node, makeFailingWriteKV(cache)],
]),
)
const result = yield* ModelsDev.Service.use((s) => s.get()).pipe(Effect.provide(layer))
expect(result).toEqual(fixture2Snapshot)
expect(cache.values.has(cacheKey)).toBe(false)
const final = yield* Ref.get(state)
expect(final.calls.length).toBe(1)
}),
)
it.live("uses the default models URL when the configured URL is empty", () =>
Effect.gen(function* () {
const cache = makeCache()
+16
View File
@@ -13,6 +13,22 @@ const session = yield * opencode.sessions.get({ sessionID })
It also exports `Tool` for plugins that add tools with `ctx.tool.transform(...)`. Embedded plugins run through the ordinary discovery flow and register tools into each Location's `ToolRegistry` through the normal `Tools.Service.register(...)` path. Closing the owning Effect Scope releases router resources, location services, fibers, and scoped tool registrations.
Use credentials already stored by the local OpenCode installation while keeping
SDK sessions in memory:
```ts
const opencode =
yield *
OpenCode.create({
credentials: OpenCode.Credentials.fromLocalDatabase(),
})
```
The SDK copies credentials from `OPENCODE_DB` or `opencode.db` in OpenCode's
data directory. The source database is never updated.
OAuth credentials are copied without a usable refresh token; if the access
token is rejected, re-authenticate with OpenCode and create a new SDK host.
`sessions.events({ sessionID, after })` replays durable events after the optional aggregate sequence, then emits newly committed durable events. `sessions.interrupt(...)` targets execution owned by this host, and `sessions.message(...)` retrieves one projected Session message.
The same constructor is available as a service Layer:
+21 -1
View File
@@ -1,11 +1,27 @@
import { OpenCode } from "@opencode-ai/client/effect"
import { Credential } from "@opencode-ai/core/credential"
import { Database } from "@opencode-ai/core/database/database"
import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
import { createEmbeddedRoutes } from "@opencode-ai/server/routes"
import type { ServerOptions } from "@opencode-ai/server/options"
import { Context, Effect, Layer, ManagedRuntime } from "effect"
import { FetchHttpClient, HttpEffect, HttpRouter, HttpServer } from "effect/unstable/http"
export const create = Effect.fn("OpenCode.create")(function* (options: ServerOptions = {}) {
export type CredentialSource = {
readonly path: string
}
export const Credentials = {
fromLocalDatabase: (input: Partial<CredentialSource> = {}): CredentialSource => ({
path: input.path ?? process.env.OPENCODE_DB ?? "opencode.db",
}),
}
export type CreateOptions = ServerOptions & {
readonly credentials?: CredentialSource
}
export const create = Effect.fn("OpenCode.create")(function* (options: CreateOptions = {}) {
const runtime = yield* Effect.acquireRelease(
Effect.sync(() =>
ManagedRuntime.make(
@@ -19,6 +35,10 @@ export const create = Effect.fn("OpenCode.create")(function* (options: ServerOpt
(runtime) => runtime.disposeEffect,
)
const context = yield* runtime.contextEffect
if (options.credentials) {
const database = Context.get(context, Database.Service)
yield* Credential.importFromDatabase(options.credentials).pipe(Effect.provideService(Database.Service, database))
}
const plugins = Context.get(context, SdkPlugins.Service)
const router = Context.get(context, HttpRouter.HttpRouter)
const handler = HttpEffect.toWebHandler(router.asHttpEffect())
+27
View File
@@ -2,6 +2,9 @@ import fs from "fs/promises"
import path from "path"
import { expect } from "bun:test"
import { Deferred, Effect, Latch, Layer, Option, Ref, Schema, Stream } from "effect"
import { Credential } from "@opencode-ai/core/credential"
import { Database } from "@opencode-ai/core/database/database"
import { LayerNode } from "../../util/src/effect/layer-node"
import { testEffect } from "../../core/test/lib/effect"
import { tmpdir } from "../../core/test/fixture/tmpdir"
import type { OpenCodeEvent } from "../src"
@@ -25,6 +28,30 @@ const sessionID = (fixture: Fixture) => fixture.sdk.Session.ID.create()
const location = (fixture: Fixture) =>
fixture.sdk.Location.Ref.make({ directory: fixture.sdk.AbsolutePath.make(fixture.directory) })
it.live("starts with credentials copied from the local database", () =>
withEmbedded("opencode-embedded-credentials-", (fixture) =>
Effect.gen(function* () {
const filename = path.join(fixture.directory, "credentials.sqlite")
const integrationID = fixture.sdk.Integration.ID.make("snapshot-test")
yield* Effect.gen(function* () {
const credentials = yield* Credential.Service
yield* credentials.create({
integrationID,
label: "Local",
value: Credential.Key.make({ type: "key", key: "secret" }),
})
}).pipe(
Effect.provide(LayerNode.compile(Credential.node, [[Database.node, Database.configured({ path: filename })]])),
Effect.scoped,
)
const opencode = yield* fixture.sdk.OpenCode.create({
credentials: fixture.sdk.OpenCode.Credentials.fromLocalDatabase({ path: filename }),
})
expect((yield* opencode.health.get()).healthy).toBe(true)
}),
),
)
it.live("exposes app metadata to plugins", () =>
withEmbedded("opencode-embedded-app-", (fixture) =>
Effect.gen(function* () {