From c22e34853df71b4d31825614bea61e7c9184f0ba Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 13 Apr 2026 12:31:43 -0400 Subject: [PATCH] refactor(auth): remove async auth facade exports (#22306) --- packages/opencode/src/auth/index.ts | 19 --- packages/opencode/src/cli/cmd/providers.ts | 50 +++++-- packages/opencode/src/server/control/index.ts | 16 ++- packages/opencode/src/session/llm.ts | 26 ++-- packages/opencode/test/auth/auth.test.ts | 132 +++++++++++------- packages/opencode/test/bus/bus-effect.test.ts | 6 +- packages/opencode/test/skill/skill.test.ts | 6 +- packages/opencode/test/tool/registry.test.ts | 6 +- packages/opencode/test/tool/skill.test.ts | 6 +- 9 files changed, 158 insertions(+), 109 deletions(-) diff --git a/packages/opencode/src/auth/index.ts b/packages/opencode/src/auth/index.ts index 2e83fe287e..b1502da78c 100644 --- a/packages/opencode/src/auth/index.ts +++ b/packages/opencode/src/auth/index.ts @@ -1,6 +1,5 @@ import path from "path" import { Effect, Layer, Record, Result, Schema, Context } from "effect" -import { makeRuntime } from "@/effect/run-service" import { zod } from "@/util/effect-zod" import { Global } from "../global" import { AppFileSystem } from "../filesystem" @@ -89,22 +88,4 @@ export namespace Auth { ) export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer)) - - const { runPromise } = makeRuntime(Service, defaultLayer) - - export async function get(providerID: string) { - return runPromise((service) => service.get(providerID)) - } - - export async function all(): Promise> { - return runPromise((service) => service.all()) - } - - export async function set(key: string, info: Info) { - return runPromise((service) => service.set(key, info)) - } - - export async function remove(key: string) { - return runPromise((service) => service.remove(key)) - } } diff --git a/packages/opencode/src/cli/cmd/providers.ts b/packages/opencode/src/cli/cmd/providers.ts index 52da441904..829e4e1b42 100644 --- a/packages/opencode/src/cli/cmd/providers.ts +++ b/packages/opencode/src/cli/cmd/providers.ts @@ -1,4 +1,5 @@ import { Auth } from "../../auth" +import { AppRuntime } from "../../effect/app-runtime" import { cmd } from "./cmd" import * as prompts from "@clack/prompts" import { UI } from "../ui" @@ -13,9 +14,18 @@ import { Instance } from "../../project/instance" import type { Hooks } from "@opencode-ai/plugin" import { Process } from "../../util/process" import { text } from "node:stream/consumers" +import { Effect } from "effect" type PluginAuth = NonNullable +const put = (key: string, info: Auth.Info) => + AppRuntime.runPromise( + Effect.gen(function* () { + const auth = yield* Auth.Service + yield* auth.set(key, info) + }), + ) + async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string, methodName?: string): Promise { let index = 0 if (methodName) { @@ -93,7 +103,7 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string, const saveProvider = result.provider ?? provider if ("refresh" in result) { const { type: _, provider: __, refresh, access, expires, ...extraFields } = result - await Auth.set(saveProvider, { + await put(saveProvider, { type: "oauth", refresh, access, @@ -102,7 +112,7 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string, }) } if ("key" in result) { - await Auth.set(saveProvider, { + await put(saveProvider, { type: "api", key: result.key, }) @@ -125,7 +135,7 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string, const saveProvider = result.provider ?? provider if ("refresh" in result) { const { type: _, provider: __, refresh, access, expires, ...extraFields } = result - await Auth.set(saveProvider, { + await put(saveProvider, { type: "oauth", refresh, access, @@ -134,7 +144,7 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string, }) } if ("key" in result) { - await Auth.set(saveProvider, { + await put(saveProvider, { type: "api", key: result.key, }) @@ -161,7 +171,7 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string, } if (result.type === "success") { const saveProvider = result.provider ?? provider - await Auth.set(saveProvider, { + await put(saveProvider, { type: "api", key: result.key ?? key, }) @@ -221,7 +231,12 @@ export const ProvidersListCommand = cmd({ const homedir = os.homedir() const displayPath = authPath.startsWith(homedir) ? authPath.replace(homedir, "~") : authPath prompts.intro(`Credentials ${UI.Style.TEXT_DIM}${displayPath}`) - const results = Object.entries(await Auth.all()) + const results = await AppRuntime.runPromise( + Effect.gen(function* () { + const auth = yield* Auth.Service + return Object.entries(yield* auth.all()) + }), + ) const database = await ModelsDev.get() for (const [providerID, result] of results) { @@ -300,7 +315,7 @@ export const ProvidersLoginCommand = cmd({ prompts.outro("Done") return } - await Auth.set(url, { + await put(url, { type: "wellknown", key: wellknown.auth.env, token: token.trim(), @@ -447,7 +462,7 @@ export const ProvidersLoginCommand = cmd({ validate: (x) => (x && x.length > 0 ? undefined : "Required"), }) if (prompts.isCancel(key)) throw new UI.CancelledError() - await Auth.set(provider, { + await put(provider, { type: "api", key, }) @@ -463,22 +478,33 @@ export const ProvidersLogoutCommand = cmd({ describe: "log out from a configured provider", async handler(_args) { UI.empty() - const credentials = await Auth.all().then((x) => Object.entries(x)) + const credentials: Array<[string, Auth.Info]> = await AppRuntime.runPromise( + Effect.gen(function* () { + const auth = yield* Auth.Service + return Object.entries(yield* auth.all()) + }), + ) prompts.intro("Remove credential") if (credentials.length === 0) { prompts.log.error("No credentials found") return } const database = await ModelsDev.get() - const providerID = await prompts.select({ + const selected = await prompts.select({ message: "Select provider", options: credentials.map(([key, value]) => ({ label: (database[key]?.name || key) + UI.Style.TEXT_DIM + " (" + value.type + ")", value: key, })), }) - if (prompts.isCancel(providerID)) throw new UI.CancelledError() - await Auth.remove(providerID) + if (prompts.isCancel(selected)) throw new UI.CancelledError() + const providerID = selected as string + await AppRuntime.runPromise( + Effect.gen(function* () { + const auth = yield* Auth.Service + yield* auth.remove(providerID) + }), + ) prompts.outro("Logout successful") }, }) diff --git a/packages/opencode/src/server/control/index.ts b/packages/opencode/src/server/control/index.ts index aae77f2f05..cf8949c954 100644 --- a/packages/opencode/src/server/control/index.ts +++ b/packages/opencode/src/server/control/index.ts @@ -1,5 +1,7 @@ import { Auth } from "@/auth" +import { AppRuntime } from "@/effect/app-runtime" import { Log } from "@/util/log" +import { Effect } from "effect" import { ProviderID } from "@/provider/schema" import { Hono } from "hono" import { describeRoute, resolver, validator, openAPIRouteHandler } from "hono-openapi" @@ -39,7 +41,12 @@ export function ControlPlaneRoutes(): Hono { async (c) => { const providerID = c.req.valid("param").providerID const info = c.req.valid("json") - await Auth.set(providerID, info) + await AppRuntime.runPromise( + Effect.gen(function* () { + const auth = yield* Auth.Service + yield* auth.set(providerID, info) + }), + ) return c.json(true) }, ) @@ -69,7 +76,12 @@ export function ControlPlaneRoutes(): Hono { ), async (c) => { const providerID = c.req.valid("param").providerID - await Auth.remove(providerID) + await AppRuntime.runPromise( + Effect.gen(function* () { + const auth = yield* Auth.Service + yield* auth.remove(providerID) + }), + ) return c.json(true) }, ) diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index f6e5c9a3f2..c3607e1770 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -94,14 +94,24 @@ export namespace LLM { modelID: input.model.id, providerID: input.model.providerID, }) - const [language, cfg, provider, auth] = await Promise.all([ - Provider.getLanguage(input.model), - Config.get(), - Provider.getProvider(input.model.providerID), - Auth.get(input.model.providerID), - ]) + const [language, cfg, provider, info] = await Effect.runPromise( + Effect.gen(function* () { + const auth = yield* Auth.Service + const cfg = yield* Config.Service + const provider = yield* Provider.Service + return yield* Effect.all( + [ + provider.getLanguage(input.model), + cfg.get(), + provider.getProvider(input.model.providerID), + auth.get(input.model.providerID), + ], + { concurrency: "unbounded" }, + ) + }).pipe(Effect.provide(Layer.mergeAll(Auth.defaultLayer, Config.defaultLayer, Provider.defaultLayer))), + ) // TODO: move this to a proper hook - const isOpenaiOauth = provider.id === "openai" && auth?.type === "oauth" + const isOpenaiOauth = provider.id === "openai" && info?.type === "oauth" const system: string[] = [] system.push( @@ -200,7 +210,7 @@ export namespace LLM { }, ) - const tools = await resolveTools(input) + const tools = resolveTools(input) // LiteLLM and some Anthropic proxies require the tools parameter to be present // when message history contains tool calls, even if no tools are being used. diff --git a/packages/opencode/test/auth/auth.test.ts b/packages/opencode/test/auth/auth.test.ts index a569c71139..864649d7ae 100644 --- a/packages/opencode/test/auth/auth.test.ts +++ b/packages/opencode/test/auth/auth.test.ts @@ -1,58 +1,86 @@ -import { test, expect } from "bun:test" +import { describe, expect } from "bun:test" +import { Effect, Layer } from "effect" import { Auth } from "../../src/auth" +import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" +import { provideTmpdirInstance } from "../fixture/fixture" +import { testEffect } from "../lib/effect" -test("set normalizes trailing slashes in keys", async () => { - await Auth.set("https://example.com/", { - type: "wellknown", - key: "TOKEN", - token: "abc", - }) - const data = await Auth.all() - expect(data["https://example.com"]).toBeDefined() - expect(data["https://example.com/"]).toBeUndefined() -}) +const node = CrossSpawnSpawner.defaultLayer -test("set cleans up pre-existing trailing-slash entry", async () => { - // Simulate a pre-fix entry with trailing slash - await Auth.set("https://example.com/", { - type: "wellknown", - key: "TOKEN", - token: "old", - }) - // Re-login with normalized key (as the CLI does post-fix) - await Auth.set("https://example.com", { - type: "wellknown", - key: "TOKEN", - token: "new", - }) - const data = await Auth.all() - const keys = Object.keys(data).filter((k) => k.includes("example.com")) - expect(keys).toEqual(["https://example.com"]) - const entry = data["https://example.com"]! - expect(entry.type).toBe("wellknown") - if (entry.type === "wellknown") expect(entry.token).toBe("new") -}) +const it = testEffect(Layer.mergeAll(Auth.defaultLayer, node)) -test("remove deletes both trailing-slash and normalized keys", async () => { - await Auth.set("https://example.com", { - type: "wellknown", - key: "TOKEN", - token: "abc", - }) - await Auth.remove("https://example.com/") - const data = await Auth.all() - expect(data["https://example.com"]).toBeUndefined() - expect(data["https://example.com/"]).toBeUndefined() -}) +describe("Auth", () => { + it.live("set normalizes trailing slashes in keys", () => + provideTmpdirInstance(() => + Effect.gen(function* () { + const auth = yield* Auth.Service + yield* auth.set("https://example.com/", { + type: "wellknown", + key: "TOKEN", + token: "abc", + }) + const data = yield* auth.all() + expect(data["https://example.com"]).toBeDefined() + expect(data["https://example.com/"]).toBeUndefined() + }), + ), + ) -test("set and remove are no-ops on keys without trailing slashes", async () => { - await Auth.set("anthropic", { - type: "api", - key: "sk-test", - }) - const data = await Auth.all() - expect(data["anthropic"]).toBeDefined() - await Auth.remove("anthropic") - const after = await Auth.all() - expect(after["anthropic"]).toBeUndefined() + it.live("set cleans up pre-existing trailing-slash entry", () => + provideTmpdirInstance(() => + Effect.gen(function* () { + const auth = yield* Auth.Service + yield* auth.set("https://example.com/", { + type: "wellknown", + key: "TOKEN", + token: "old", + }) + yield* auth.set("https://example.com", { + type: "wellknown", + key: "TOKEN", + token: "new", + }) + const data = yield* auth.all() + const keys = Object.keys(data).filter((key) => key.includes("example.com")) + expect(keys).toEqual(["https://example.com"]) + const entry = data["https://example.com"]! + expect(entry.type).toBe("wellknown") + if (entry.type === "wellknown") expect(entry.token).toBe("new") + }), + ), + ) + + it.live("remove deletes both trailing-slash and normalized keys", () => + provideTmpdirInstance(() => + Effect.gen(function* () { + const auth = yield* Auth.Service + yield* auth.set("https://example.com", { + type: "wellknown", + key: "TOKEN", + token: "abc", + }) + yield* auth.remove("https://example.com/") + const data = yield* auth.all() + expect(data["https://example.com"]).toBeUndefined() + expect(data["https://example.com/"]).toBeUndefined() + }), + ), + ) + + it.live("set and remove are no-ops on keys without trailing slashes", () => + provideTmpdirInstance(() => + Effect.gen(function* () { + const auth = yield* Auth.Service + yield* auth.set("anthropic", { + type: "api", + key: "sk-test", + }) + const data = yield* auth.all() + expect(data["anthropic"]).toBeDefined() + yield* auth.remove("anthropic") + const after = yield* auth.all() + expect(after["anthropic"]).toBeUndefined() + }), + ), + ) }) diff --git a/packages/opencode/test/bus/bus-effect.test.ts b/packages/opencode/test/bus/bus-effect.test.ts index 6f3bcbcfab..6f96a89c87 100644 --- a/packages/opencode/test/bus/bus-effect.test.ts +++ b/packages/opencode/test/bus/bus-effect.test.ts @@ -1,10 +1,10 @@ -import { NodeChildProcessSpawner, NodeFileSystem, NodePath } from "@effect/platform-node" import { describe, expect } from "bun:test" import { Deferred, Effect, Layer, Stream } from "effect" import z from "zod" import { Bus } from "../../src/bus" import { BusEvent } from "../../src/bus/bus-event" import { Instance } from "../../src/project/instance" +import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" import { provideInstance, provideTmpdirInstance, tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" @@ -13,9 +13,7 @@ const TestEvent = { Pong: BusEvent.define("test.effect.pong", z.object({ message: z.string() })), } -const node = NodeChildProcessSpawner.layer.pipe( - Layer.provideMerge(Layer.mergeAll(NodeFileSystem.layer, NodePath.layer)), -) +const node = CrossSpawnSpawner.defaultLayer const live = Layer.mergeAll(Bus.layer, node) diff --git a/packages/opencode/test/skill/skill.test.ts b/packages/opencode/test/skill/skill.test.ts index 0a14e30b7d..21c6c7e651 100644 --- a/packages/opencode/test/skill/skill.test.ts +++ b/packages/opencode/test/skill/skill.test.ts @@ -1,15 +1,13 @@ -import { NodeChildProcessSpawner, NodeFileSystem, NodePath } from "@effect/platform-node" import { describe, expect } from "bun:test" import { Effect, Layer } from "effect" import { Skill } from "../../src/skill" +import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" import { provideInstance, provideTmpdirInstance, tmpdir } from "../fixture/fixture" import { testEffect } from "../lib/effect" import path from "path" import fs from "fs/promises" -const node = NodeChildProcessSpawner.layer.pipe( - Layer.provideMerge(Layer.mergeAll(NodeFileSystem.layer, NodePath.layer)), -) +const node = CrossSpawnSpawner.defaultLayer const it = testEffect(Layer.mergeAll(Skill.defaultLayer, node)) diff --git a/packages/opencode/test/tool/registry.test.ts b/packages/opencode/test/tool/registry.test.ts index 5b59e314e1..dea84bdcd4 100644 --- a/packages/opencode/test/tool/registry.test.ts +++ b/packages/opencode/test/tool/registry.test.ts @@ -1,16 +1,14 @@ -import { NodeChildProcessSpawner, NodeFileSystem, NodePath } from "@effect/platform-node" import { afterEach, describe, expect } from "bun:test" import path from "path" import fs from "fs/promises" import { Effect, Layer } from "effect" import { Instance } from "../../src/project/instance" +import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" import { ToolRegistry } from "../../src/tool/registry" import { provideTmpdirInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" -const node = NodeChildProcessSpawner.layer.pipe( - Layer.provideMerge(Layer.mergeAll(NodeFileSystem.layer, NodePath.layer)), -) +const node = CrossSpawnSpawner.defaultLayer const it = testEffect(Layer.mergeAll(ToolRegistry.defaultLayer, node)) diff --git a/packages/opencode/test/tool/skill.test.ts b/packages/opencode/test/tool/skill.test.ts index 1cebf342db..b8b1394edf 100644 --- a/packages/opencode/test/tool/skill.test.ts +++ b/packages/opencode/test/tool/skill.test.ts @@ -1,7 +1,7 @@ -import { NodeChildProcessSpawner, NodeFileSystem, NodePath } from "@effect/platform-node" import { Effect, Layer, ManagedRuntime } from "effect" import { Agent } from "../../src/agent/agent" import { Skill } from "../../src/skill" +import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" import { Ripgrep } from "../../src/file/ripgrep" import { Truncate } from "../../src/tool/truncate" import { afterEach, describe, expect, test } from "bun:test" @@ -30,9 +30,7 @@ afterEach(async () => { await Instance.disposeAll() }) -const node = NodeChildProcessSpawner.layer.pipe( - Layer.provideMerge(Layer.mergeAll(NodeFileSystem.layer, NodePath.layer)), -) +const node = CrossSpawnSpawner.defaultLayer const it = testEffect(Layer.mergeAll(ToolRegistry.defaultLayer, node))