feat(sdk): import local credentials into embedded host
This commit is contained in:
@@ -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* () {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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* () {
|
||||
|
||||
Reference in New Issue
Block a user