diff --git a/packages/core/src/credential.ts b/packages/core/src/credential.ts index 47f35c809b..4670c7f653 100644 --- a/packages/core/src/credential.ts +++ b/packages/core/src/credential.ts @@ -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()("@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* () { diff --git a/packages/core/test/credential.test.ts b/packages/core/test/credential.test.ts index 3b74df5c2c..1339ea5424 100644 --- a/packages/core/test/credential.test.ts +++ b/packages/core/test/credential.test.ts @@ -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) + }) + }), + ), ) }) diff --git a/packages/sdk-next/README.md b/packages/sdk-next/README.md index a349afb774..da8f1cd41e 100644 --- a/packages/sdk-next/README.md +++ b/packages/sdk-next/README.md @@ -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: diff --git a/packages/sdk-next/src/opencode.ts b/packages/sdk-next/src/opencode.ts index 90495c964a..f5850e2fa2 100644 --- a/packages/sdk-next/src/opencode.ts +++ b/packages/sdk-next/src/opencode.ts @@ -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 => ({ + 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()) diff --git a/packages/sdk-next/test/embedded.test.ts b/packages/sdk-next/test/embedded.test.ts index df395cb6eb..723c621224 100644 --- a/packages/sdk-next/test/embedded.test.ts +++ b/packages/sdk-next/test/embedded.test.ts @@ -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* () {