Compare commits

...

4 Commits

Author SHA1 Message Date
Dax Raad 9b1b520525 sync 2026-05-19 12:30:34 -04:00
Dax Raad a8e1138200 Handle v2 account activation hooks 2026-05-18 19:29:16 -04:00
Dax Raad ef4cafc053 Rename v2 account plugin 2026-05-18 19:20:36 -04:00
Dax Raad 85e1a8588e Rename v2 auth service to account 2026-05-18 19:19:19 -04:00
26 changed files with 906 additions and 205 deletions
@@ -4,26 +4,25 @@ import { Identifier } from "./util/identifier"
import { NonNegativeInt, withStatics } from "./schema"
import { Global } from "./global"
import { AppFileSystem } from "./filesystem"
import { PluginV2 } from "./plugin"
export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key"
const AccountID = Schema.String.pipe(
Schema.brand("AccountID"),
export const ID = Schema.String.pipe(
Schema.brand("AccountV2.ID"),
withStatics((schema) => ({ create: () => schema.make("acc_" + Identifier.ascending()) })),
)
export type AccountID = typeof AccountID.Type
export type ID = typeof ID.Type
export const ServiceID = Schema.String.pipe(Schema.brand("ServiceID"))
export type ServiceID = typeof ServiceID.Type
export class OAuthCredential extends Schema.Class<OAuthCredential>("AuthV2.OAuthCredential")({
export class OAuthCredential extends Schema.Class<OAuthCredential>("AccountV2.OAuthCredential")({
type: Schema.Literal("oauth"),
refresh: Schema.String,
access: Schema.String,
expires: NonNegativeInt,
}) {}
export class ApiKeyCredential extends Schema.Class<ApiKeyCredential>("AuthV2.ApiKeyCredential")({
export class ApiKeyCredential extends Schema.Class<ApiKeyCredential>("AccountV2.ApiKeyCredential")({
type: Schema.Literal("api"),
key: Schema.String,
metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)),
@@ -32,87 +31,85 @@ export class ApiKeyCredential extends Schema.Class<ApiKeyCredential>("AuthV2.Api
export const Credential = Schema.Union([OAuthCredential, ApiKeyCredential])
.pipe(Schema.toTaggedUnion("type"))
.annotate({
identifier: "AuthV2.Credential",
identifier: "AccountV2.Credential",
})
export type Credential = Schema.Schema.Type<typeof Credential>
export class Account extends Schema.Class<Account>("AuthV2.Account")({
id: AccountID,
export class Info extends Schema.Class<Info>("AccountV2.Info")({
id: ID,
serviceID: ServiceID,
description: Schema.String,
credential: Credential,
}) {}
export class AuthFileWriteError extends Schema.TaggedErrorClass<AuthFileWriteError>()("AuthV2.FileWriteError", {
export class FileWriteError extends Schema.TaggedErrorClass<FileWriteError>()("AccountV2.FileWriteError", {
operation: Schema.Union([Schema.Literal("migrate"), Schema.Literal("write")]),
cause: Schema.Defect,
}) {}
export type AuthError = AuthFileWriteError
export type Error = FileWriteError
interface Writable {
version: 2
accounts: Record<string, Account>
active: Record<string, AccountID>
accounts: Record<string, Info>
active: Record<string, ID>
}
const decodeV1 = Schema.decodeUnknownOption(Schema.Record(Schema.String, Credential))
function migrate(old: Record<string, unknown>): Writable {
const accounts: Record<string, Account> = {}
const active: Record<string, AccountID> = {}
const accounts: Record<string, Info> = {}
const active: Record<string, ID> = {}
for (const [serviceID, value] of Object.entries(old)) {
const decoded = Option.getOrElse(decodeV1({ [serviceID]: value }), () => ({}))
const parsed = (decoded as Record<string, Credential>)[serviceID]
if (!parsed) continue
const id = Identifier.ascending()
const accountID = AccountID.make(id)
const account = ID.make(id)
const brandedServiceID = ServiceID.make(serviceID)
accounts[id] = new Account({
id: accountID,
accounts[id] = new Info({
id: account,
serviceID: brandedServiceID,
description: "default",
credential: parsed,
})
active[brandedServiceID] = accountID
active[brandedServiceID] = account
}
return { version: 2, accounts, active }
}
export interface Interface {
readonly get: (accountID: AccountID) => Effect.Effect<Account | undefined, AuthError>
readonly all: () => Effect.Effect<Account[], AuthError>
readonly get: (id: ID) => Effect.Effect<Info | undefined, Error>
readonly all: () => Effect.Effect<Info[], Error>
readonly create: (input: {
serviceID: ServiceID
credential: Credential
description?: string
active?: boolean
}) => Effect.Effect<Account, AuthError>
readonly update: (
accountID: AccountID,
updates: Partial<Pick<Account, "description" | "credential">>,
) => Effect.Effect<void, AuthError>
readonly remove: (accountID: AccountID) => Effect.Effect<void, AuthError>
readonly activate: (accountID: AccountID) => Effect.Effect<void, AuthError>
readonly active: (serviceID: ServiceID) => Effect.Effect<Account | undefined, AuthError>
readonly forService: (serviceID: ServiceID) => Effect.Effect<Account[], AuthError>
}) => Effect.Effect<Info | undefined, Error>
readonly update: (id: ID, updates: Partial<Pick<Info, "description" | "credential">>) => Effect.Effect<void, Error>
readonly remove: (id: ID) => Effect.Effect<void, Error>
readonly activate: (id: ID) => Effect.Effect<void, Error>
readonly active: (serviceID: ServiceID) => Effect.Effect<Info | undefined, Error>
readonly forService: (serviceID: ServiceID) => Effect.Effect<Info[], Error>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Auth") {}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Account") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fsys = yield* AppFileSystem.Service
const global = yield* Global.Service
const file = path.join(global.data, "auth-v2.json")
const plugin = yield* PluginV2.Service
const file = path.join(global.data, "account.json")
const legacyFile = path.join(global.data, "auth.json")
const writeMigrated = Effect.fnUntraced(function* (raw: Record<string, unknown>) {
const migrated = migrate(raw)
yield* fsys
.writeJson(file, migrated, 0o600)
.pipe(Effect.mapError((cause) => new AuthFileWriteError({ operation: "migrate", cause })))
.pipe(Effect.mapError((cause) => new FileWriteError({ operation: "migrate", cause })))
return migrated
})
@@ -122,7 +119,7 @@ export const layer = Layer.effect(
} catch {}
}
const load: () => Effect.Effect<Writable, AuthError> = Effect.fnUntraced(function* () {
const load: () => Effect.Effect<Writable, Error> = Effect.fnUntraced(function* () {
if (process.env.OPENCODE_AUTH_CONTENT) {
const raw = parseAuthContent()
if (raw && typeof raw === "object") {
@@ -148,40 +145,47 @@ export const layer = Layer.effect(
const write = (data: Writable) =>
fsys
.writeJson(file, data, 0o600)
.pipe(Effect.mapError((cause) => new AuthFileWriteError({ operation: "write", cause })))
.pipe(Effect.mapError((cause) => new FileWriteError({ operation: "write", cause })))
const state = SynchronizedRef.makeUnsafe(yield* load())
const result: Interface = {
get: Effect.fn("AuthV2.get")(function* (accountID) {
return (yield* SynchronizedRef.get(state)).accounts[accountID]
get: Effect.fn("AccountV2.get")(function* (id) {
return (yield* SynchronizedRef.get(state)).accounts[id]
}),
all: Effect.fn("AuthV2.all")(function* () {
all: Effect.fn("AccountV2.all")(function* () {
return Object.values((yield* SynchronizedRef.get(state)).accounts)
}),
active: Effect.fn("AuthV2.active")(function* (serviceID) {
active: Effect.fn("AccountV2.active")(function* (serviceID) {
const data = yield* SynchronizedRef.get(state)
return (
data.accounts[data.active[serviceID]] ?? Object.values(data.accounts).find((a) => a.serviceID === serviceID)
)
}),
forService: Effect.fn("AuthV2.list")(function* (serviceID) {
forService: Effect.fn("AccountV2.list")(function* (serviceID) {
return Object.values((yield* SynchronizedRef.get(state)).accounts).filter((a) => a.serviceID === serviceID)
}),
create: Effect.fn("AuthV2.add")(function* (input) {
create: Effect.fn("AccountV2.add")(function* (input) {
const id = ID.make(Identifier.ascending())
const updated = yield* plugin.trigger(
"account.update",
{ id, serviceID: input.serviceID },
{ description: input.description ?? "default", credential: input.credential, cancel: false },
)
if (updated.cancel) return undefined
const account = new Info({
id,
serviceID: input.serviceID,
description: updated.description,
credential: updated.credential,
})
return yield* SynchronizedRef.modifyEffect(
state,
Effect.fnUntraced(function* (data) {
const account = new Account({
id: AccountID.make(Identifier.ascending()),
serviceID: input.serviceID,
description: input.description ?? "default",
credential: input.credential,
})
const next = {
...data,
accounts: { ...data.accounts, [account.id]: account },
@@ -197,22 +201,33 @@ export const layer = Layer.effect(
)
}),
update: Effect.fn("AuthV2.update")(function* (accountID, updates) {
update: Effect.fn("AccountV2.update")(function* (id, updates) {
const existing = (yield* SynchronizedRef.get(state)).accounts[id]
if (!existing) return
const updated = yield* plugin.trigger(
"account.update",
{ id, serviceID: existing.serviceID },
{
description: updates.description ?? existing.description,
credential: updates.credential ?? existing.credential,
cancel: false,
},
)
if (updated.cancel) return
yield* SynchronizedRef.modifyEffect(
state,
Effect.fnUntraced(function* (data) {
const existing = data.accounts[accountID]
if (!existing) return [undefined, data] as const
if (!data.accounts[id]) return [undefined, data] as const
const next = {
...data,
accounts: {
...data.accounts,
[accountID]: new Account({
id: accountID,
[id]: new Info({
id,
serviceID: existing.serviceID,
description: updates.description ?? existing.description,
credential: updates.credential ?? existing.credential,
description: updated.description,
credential: updated.credential,
}),
},
}
@@ -223,15 +238,18 @@ export const layer = Layer.effect(
)
}),
remove: Effect.fn("AuthV2.remove")(function* (accountID) {
remove: Effect.fn("AccountV2.remove")(function* (id) {
const account = (yield* SynchronizedRef.get(state)).accounts[id]
if (!account) return
if ((yield* plugin.trigger("account.remove", { account }, { cancel: false })).cancel) return
yield* SynchronizedRef.modifyEffect(
state,
Effect.fnUntraced(function* (data) {
const accounts = { ...data.accounts }
const active = { ...data.active }
if (accounts[accountID] && active[accounts[accountID].serviceID] === accountID)
delete active[accounts[accountID].serviceID]
delete accounts[accountID]
if (!accounts[id]) return [undefined, data] as const
if (accounts[id] && active[accounts[id].serviceID] === id) delete active[accounts[id].serviceID]
delete accounts[id]
const next = { ...data, accounts, active }
yield* write(next)
@@ -240,18 +258,28 @@ export const layer = Layer.effect(
)
}),
activate: Effect.fn("AuthV2.activate")(function* (accountID) {
yield* SynchronizedRef.modifyEffect(
activate: Effect.fn("AccountV2.activate")(function* (id) {
const data = yield* SynchronizedRef.get(state)
const account = data.accounts[id]
if (!account) return
const updated = yield* plugin.trigger(
"account.activate",
{},
{ from: data.active[account.serviceID], to: id, cancel: false },
)
if (updated.cancel) return
const activated = yield* SynchronizedRef.modifyEffect(
state,
Effect.fnUntraced(function* (data) {
const account = data.accounts[accountID]
if (!account) return [undefined, data] as const
const nextAccount = data.accounts[updated.to]
if (!nextAccount) return [undefined, data] as const
const next = { ...data, active: { ...data.active, [account.serviceID]: accountID } }
const next = { ...data, active: { ...data.active, [nextAccount.serviceID]: updated.to } }
yield* write(next)
return [undefined, next] as const
return [{ from: updated.from, to: updated.to }, next] as const
}),
)
if (activated) yield* plugin.trigger("account.activated", activated, {})
}),
}
@@ -259,6 +287,10 @@ export const layer = Layer.effect(
}),
)
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Global.defaultLayer))
export const defaultLayer = layer.pipe(
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(Global.defaultLayer),
Layer.provideMerge(PluginV2.defaultLayer),
)
export * as AuthV2 from "./auth"
export * as AccountV2 from "./account"
+147
View File
@@ -0,0 +1,147 @@
export * as AgentV2 from "./agent"
import { Context, Effect, HashMap, Layer, Option, Order, pipe, Schema, Array } from "effect"
import { produce, type Draft } from "immer"
import { ModelV2 } from "./model"
import { PermissionV2 } from "./permission"
import { PluginV2 } from "./plugin"
import { ProviderV2 } from "./provider"
export const ID = Schema.String.pipe(Schema.brand("AgentV2.ID"))
export type ID = typeof ID.Type
export const Mode = Schema.Literals(["subagent", "primary", "all"]).annotate({ identifier: "AgentV2.Mode" })
export type Mode = typeof Mode.Type
export const Info = Schema.Struct({
name: ID,
description: Schema.optional(Schema.String),
mode: Mode,
hidden: Schema.Boolean.pipe(Schema.optional),
color: Schema.String.pipe(Schema.optional),
permission: PermissionV2.Ruleset,
model: ModelV2.Ref.pipe(Schema.optional),
system: Schema.String.pipe(Schema.optional),
options: ProviderV2.Options.pipe(Schema.optional),
steps: Schema.Int.pipe(Schema.optional),
}).annotate({ identifier: "AgentV2.Info" })
export type Info = typeof Info.Type
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("AgentV2.NotFound", {
agent: ID,
}) {}
export class InvalidDefaultError extends Schema.TaggedErrorClass<InvalidDefaultError>()("AgentV2.InvalidDefault", {
agent: ID,
reason: Schema.Literals(["missing", "subagent", "hidden"]),
}) {}
export class NoDefaultError extends Schema.TaggedErrorClass<NoDefaultError>()("AgentV2.NoDefault", {}) {}
export interface Interface {
readonly get: (agent: ID) => Effect.Effect<Info, NotFoundError>
readonly list: () => Effect.Effect<Info[]>
readonly update: (agent: ID, fn: (agent: Draft<Info>) => void) => Effect.Effect<void>
readonly remove: (agent: ID) => Effect.Effect<void>
readonly defaultInfo: () => Effect.Effect<Info, InvalidDefaultError | NoDefaultError>
readonly defaultAgent: () => Effect.Effect<ID, InvalidDefaultError | NoDefaultError>
readonly setDefault: (agent: ID) => Effect.Effect<void, NotFoundError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Agent") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
let agents = HashMap.empty<ID, Info>()
let defaultAgent: ID | undefined
const result: Interface = {
get: Effect.fn("AgentV2.get")(function* (agent) {
const match = HashMap.get(agents, agent)
if (!match.valueOrUndefined) return yield* new NotFoundError({ agent })
return match.value
}),
list: Effect.fn("AgentV2.list")(function* () {
return pipe(
HashMap.toValues(agents),
Array.sortWith((agent) => agent.name, Order.String),
)
}),
update: Effect.fnUntraced(function* (agent, fn) {
const next = produce(
HashMap.get(agents, agent).pipe(
Option.getOrElse(
() =>
({
name: agent,
mode: "all",
permission: [],
options: {
headers: {},
body: {},
aisdk: {
provider: {},
request: {},
},
},
}) satisfies Info,
),
),
fn,
)
const updated = yield* plugin.trigger("agent.update", {}, { agent: next, cancel: false })
if (updated.cancel) return
agents = HashMap.set(agents, agent, { ...updated.agent, name: agent })
}),
remove: Effect.fn("AgentV2.remove")(function* (agent) {
const existing = Option.getOrUndefined(HashMap.get(agents, agent))
if (!existing) return
if ((yield* plugin.trigger("agent.remove", { agent: existing }, { cancel: false })).cancel) return
agents = HashMap.remove(agents, agent)
if (defaultAgent === agent) defaultAgent = undefined
}),
defaultInfo: Effect.fn("AgentV2.defaultInfo")(function* () {
const updated = yield* plugin.trigger("agent.default", {}, { agent: defaultAgent })
const selected = updated.agent
if (selected) {
const agent = yield* result
.get(selected)
.pipe(
Effect.catchTag("AgentV2.NotFound", () =>
Effect.fail(new InvalidDefaultError({ agent: selected, reason: "missing" })),
),
)
if (agent.mode === "subagent") return yield* new InvalidDefaultError({ agent: selected, reason: "subagent" })
if (agent.hidden === true) return yield* new InvalidDefaultError({ agent: selected, reason: "hidden" })
return agent
}
const visible = pipe(
yield* result.list(),
Array.findFirst((agent) => agent.mode !== "subagent" && agent.hidden !== true),
)
if (Option.isSome(visible)) return visible.value
return yield* new NoDefaultError()
}),
defaultAgent: Effect.fn("AgentV2.defaultAgent")(function* () {
return (yield* result.defaultInfo()).name
}),
setDefault: Effect.fn("AgentV2.setDefault")(function* (agent) {
yield* result.get(agent)
defaultAgent = agent
}),
}
return Service.of(result)
}),
)
export const defaultLayer = layer.pipe(Layer.provide(PluginV2.defaultLayer))
+45
View File
@@ -0,0 +1,45 @@
export * as PermissionV2 from "./permission"
import { Schema } from "effect"
import { Wildcard } from "./util/wildcard"
export const Action = Schema.Literals(["allow", "deny", "ask"]).annotate({ identifier: "PermissionV2.Action" })
export type Action = typeof Action.Type
export const Rule = Schema.Struct({
permission: Schema.String,
pattern: Schema.String,
action: Action,
}).annotate({ identifier: "PermissionV2.Rule" })
export type Rule = typeof Rule.Type
export const Ruleset = Schema.Array(Rule).annotate({ identifier: "PermissionV2.Ruleset" })
export type Ruleset = typeof Ruleset.Type
const EDIT_TOOLS = ["edit", "write", "apply_patch"]
export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule {
return (
rulesets
.flat()
.findLast((rule) => Wildcard.match(permission, rule.permission) && Wildcard.match(pattern, rule.pattern)) ?? {
action: "ask",
permission,
pattern: "*",
}
)
}
export function merge(...rulesets: Ruleset[]): Ruleset {
return rulesets.flat()
}
export function disabled(tools: string[], ruleset: Ruleset): Set<string> {
return new Set(
tools.filter((tool) => {
const permission = EDIT_TOOLS.includes(tool) ? "edit" : tool
const rule = ruleset.findLast((rule) => Wildcard.match(permission, rule.permission))
return rule?.pattern === "*" && rule.action === "deny"
}),
)
}
+57
View File
@@ -5,11 +5,47 @@ import type { LanguageModelV3 } from "@ai-sdk/provider"
import { type ProviderV2 } from "./provider"
import { Context, Effect, Layer, Schema } from "effect"
import type { ModelV2 } from "./model"
import type { AccountV2 } from "./account"
import type { AgentV2 } from "./agent"
export const ID = Schema.String.pipe(Schema.brand("Plugin.ID"))
export type ID = typeof ID.Type
type HookSpec = {
"account.update": {
input: {
id: AccountV2.ID
serviceID: AccountV2.ServiceID
}
output: {
description: string
credential: AccountV2.Credential
cancel: boolean
}
}
"account.remove": {
input: {
account: AccountV2.Info
}
output: {
cancel: boolean
}
}
"account.activate": {
input: {}
output: {
from?: AccountV2.ID
to: AccountV2.ID
cancel: boolean
}
}
"account.activated": {
input: {
from?: AccountV2.ID
to: AccountV2.ID
}
output: {}
}
"provider.update": {
input: {}
output: {
@@ -44,6 +80,27 @@ type HookSpec = {
sdk?: any
}
}
"agent.update": {
input: {}
output: {
agent: AgentV2.Info
cancel: boolean
}
}
"agent.remove": {
input: {
agent: AgentV2.Info
}
output: {
cancel: boolean
}
}
"agent.default": {
input: {}
output: {
agent?: AgentV2.ID
}
}
}
export type Hooks = {
+46
View File
@@ -0,0 +1,46 @@
import type { Draft } from "immer"
import { Effect } from "effect"
import { AccountV2 } from "../account"
import { Catalog } from "../catalog"
import { PluginV2 } from "../plugin"
import { ProviderV2 } from "../provider"
export const AccountPlugin = PluginV2.define({
id: PluginV2.ID.make("account"),
effect: Effect.gen(function* () {
const accounts = yield* AccountV2.Service
const catalog = yield* Catalog.Service
const apply = (provider: Draft<ProviderV2.Info>, account: AccountV2.Info) => {
provider.enabled = {
via: "account",
service: account.serviceID,
}
if (account.credential.type === "api") {
provider.options.aisdk.provider.apiKey = account.credential.key
Object.assign(provider.options.aisdk.provider, account.credential.metadata ?? {})
}
if (account.credential.type === "oauth") {
provider.options.aisdk.provider.apiKey = account.credential.access
}
}
return {
"account.activated": Effect.fn(function* (evt) {
const next = yield* accounts.get(evt.to).pipe(Effect.orDie)
if (!next) return
const previous = evt.from ? yield* accounts.get(evt.from).pipe(Effect.orDie) : undefined
if (previous && previous.serviceID !== next.serviceID) {
yield* catalog.provider.update(ProviderV2.ID.make(previous.serviceID), (provider) => {
provider.enabled = false
})
}
yield* catalog.provider.update(ProviderV2.ID.make(next.serviceID), (provider) => apply(provider, next))
}),
"provider.update": Effect.fn(function* (evt) {
const account = yield* accounts.active(AccountV2.ServiceID.make(evt.provider.id)).pipe(Effect.orDie)
if (!account) return
apply(evt.provider, account)
}),
}
}),
})
-27
View File
@@ -1,27 +0,0 @@
import { Effect } from "effect"
import { AuthV2 } from "../auth"
import { PluginV2 } from "../plugin"
export const AuthPlugin = PluginV2.define({
id: PluginV2.ID.make("auth"),
effect: Effect.gen(function* () {
const auth = yield* AuthV2.Service
return {
"provider.update": Effect.fn(function* (evt) {
const account = yield* auth.active(AuthV2.ServiceID.make(evt.provider.id)).pipe(Effect.orDie)
if (!account) return
evt.provider.enabled = {
via: "auth",
service: account.serviceID,
}
if (account.credential.type === "api") {
evt.provider.options.aisdk.provider.apiKey = account.credential.key
Object.assign(evt.provider.options.aisdk.provider, account.credential.metadata ?? {})
}
if (account.credential.type === "oauth") {
evt.provider.options.aisdk.provider.apiKey = account.credential.access
}
}),
}
}),
})
+20 -8
View File
@@ -1,18 +1,23 @@
export * as PluginBoot from "./boot"
import { Context, Deferred, Effect, Layer } from "effect"
import { AuthV2 } from "../auth"
import { AccountV2 } from "../account"
import { AgentV2 } from "../agent"
import { Catalog } from "../catalog"
import { Npm } from "../npm"
import { PluginV2 } from "../plugin"
import { AuthPlugin } from "./auth"
import { AccountPlugin } from "./account"
import { EnvPlugin } from "./env"
import { ModelsDevPlugin } from "./models-dev"
import { ProviderPlugins } from "./provider"
type Plugin = {
id: PluginV2.ID
effect: Effect.Effect<PluginV2.HookFunctions | void, never, Catalog.Service | AuthV2.Service | Npm.Service>
effect: Effect.Effect<
PluginV2.HookFunctions | void,
never,
AgentV2.Service | Catalog.Service | AccountV2.Service | Npm.Service
>
}
export interface Interface {
@@ -21,13 +26,18 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/PluginBoot") {}
export const layer: Layer.Layer<Service, never, Catalog.Service | PluginV2.Service | AuthV2.Service | Npm.Service> =
export const layer: Layer.Layer<
Service,
never,
AgentV2.Service | Catalog.Service | PluginV2.Service | AccountV2.Service | Npm.Service
> =
Layer.effect(
Service,
Effect.gen(function* () {
const agent = yield* AgentV2.Service
const catalog = yield* Catalog.Service
const plugin = yield* PluginV2.Service
const auth = yield* AuthV2.Service
const accounts = yield* AccountV2.Service
const npm = yield* Npm.Service
const done = yield* Deferred.make<void>()
@@ -36,7 +46,8 @@ export const layer: Layer.Layer<Service, never, Catalog.Service | PluginV2.Servi
id: input.id,
effect: input.effect.pipe(
Effect.provideService(Catalog.Service, catalog),
Effect.provideService(AuthV2.Service, auth),
Effect.provideService(AgentV2.Service, agent),
Effect.provideService(AccountV2.Service, accounts),
Effect.provideService(Npm.Service, npm),
),
})
@@ -44,7 +55,7 @@ export const layer: Layer.Layer<Service, never, Catalog.Service | PluginV2.Servi
const boot = Effect.gen(function* () {
yield* add(EnvPlugin)
yield* add(AuthPlugin)
yield* add(AccountPlugin)
for (const item of ProviderPlugins) {
yield* add(item)
}
@@ -64,8 +75,9 @@ export const layer: Layer.Layer<Service, never, Catalog.Service | PluginV2.Servi
)
export const defaultLayer = layer.pipe(
Layer.provide(AgentV2.defaultLayer),
Layer.provide(Catalog.defaultLayer),
Layer.provide(PluginV2.defaultLayer),
Layer.provide(Layer.orDie(AuthV2.defaultLayer)),
Layer.provide(Layer.orDie(AccountV2.defaultLayer)),
Layer.provide(Npm.defaultLayer),
)
@@ -45,7 +45,7 @@ const decodeJson = Schema.decodeUnknownOption(Schema.UnknownFromJsonString)
function gatewayConfig(options: Record<string, unknown>): GatewayConfig | undefined {
const accountId = process.env.CLOUDFLARE_ACCOUNT_ID ?? stringOption(options, "accountId")
// AuthPlugin copies CLI prompt metadata into options. The prompt stores the
// AccountPlugin copies CLI prompt metadata into options. The prompt stores the
// gateway as gatewayId, while older config examples may use gateway.
const gatewayId =
process.env.CLOUDFLARE_GATEWAY_ID ?? stringOption(options, "gatewayId") ?? stringOption(options, "gateway")
@@ -13,7 +13,7 @@ export const OpencodePlugin = PluginV2.define({
process.env.OPENCODE_API_KEY ||
evt.provider.env.some((item) => process.env[item]) ||
evt.provider.options.aisdk.provider.apiKey ||
(evt.provider.enabled && evt.provider.enabled.via === "auth"),
(evt.provider.enabled && evt.provider.enabled.via === "account"),
)
if (!hasKey) evt.provider.options.aisdk.provider.apiKey = "public"
}),
+1 -1
View File
@@ -86,7 +86,7 @@ export class Info extends Schema.Class<Info>("ProviderV2.Info")({
name: Schema.String,
}),
Schema.Struct({
via: Schema.Literal("auth"),
via: Schema.Literal("account"),
service: Schema.String,
}),
Schema.Struct({
+14
View File
@@ -0,0 +1,14 @@
export * as Wildcard from "./wildcard"
export function match(input: string, pattern: string) {
const normalized = input.replaceAll("\\", "/")
let escaped = pattern
.replaceAll("\\", "/")
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
.replace(/\*/g, ".*")
.replace(/\?/g, ".")
if (escaped.endsWith(" .*")) escaped = escaped.slice(0, -3) + "( .*)?"
return new RegExp("^" + escaped + "$", process.platform === "win32" ? "si" : "s").test(normalized)
}
+190
View File
@@ -0,0 +1,190 @@
import path from "path"
import { describe, expect } from "bun:test"
import { produce } from "immer"
import { Effect, Layer, Option } from "effect"
import { AccountV2 } from "@opencode-ai/core/account"
import { Catalog } from "@opencode-ai/core/catalog"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Global } from "@opencode-ai/core/global"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { AccountPlugin } from "@opencode-ai/core/plugin/account"
import { ModelV2 } from "@opencode-ai/core/model"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
const it = testEffect(PluginV2.defaultLayer)
function testLayer(dir: string) {
return AccountV2.layer.pipe(
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(
Global.layerWith({
data: dir,
cache: path.join(dir, "cache"),
config: path.join(dir, "config"),
state: path.join(dir, "state"),
tmp: path.join(dir, "tmp"),
bin: path.join(dir, "bin"),
log: path.join(dir, "log"),
repos: path.join(dir, "repos"),
}),
),
)
}
describe("AccountV2", () => {
it.live("runs account lifecycle hooks", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const accounts = yield* AccountV2.Service
const plugin = yield* PluginV2.Service
let blocked: AccountV2.ID | undefined
yield* plugin.add({
id: PluginV2.ID.make("test.account"),
effect: Effect.succeed({
"account.update": (evt) =>
Effect.gen(function* () {
if (evt.description === "cancel") {
evt.cancel = true
return
}
const existing = yield* accounts.get(evt.id).pipe(Effect.orDie)
evt.description = existing ? `${evt.description}:updated` : `created:${evt.serviceID}`
if (evt.credential.type === "api") evt.credential.key = existing ? "updated-key" : "created-key"
}),
"account.remove": (evt) =>
Effect.sync(() => {
if (evt.account.description.includes("keep")) evt.cancel = true
}),
"account.activate": (evt) =>
Effect.sync(() => {
if (blocked && evt.to === blocked) evt.cancel = true
}),
}),
})
const first = yield* accounts.create({
serviceID: AccountV2.ServiceID.make("provider"),
credential: new AccountV2.ApiKeyCredential({ type: "api", key: "raw-key" }),
})
expect(first).toBeDefined()
if (!first) return
expect(first.description).toBe("created:provider")
expect(first.credential.type).toBe("api")
if (first.credential.type === "api") expect(first.credential.key).toBe("created-key")
yield* accounts.update(first.id, { description: "keep" })
const updated = yield* accounts.get(first.id)
expect(updated?.description).toBe("keep:updated")
expect(updated?.credential.type).toBe("api")
if (updated?.credential.type === "api") expect(updated.credential.key).toBe("updated-key")
yield* accounts.update(first.id, { description: "cancel" })
expect((yield* accounts.get(first.id))?.description).toBe("keep:updated")
const cancelled = yield* accounts.create({
serviceID: AccountV2.ServiceID.make("provider"),
credential: new AccountV2.ApiKeyCredential({ type: "api", key: "cancel-key" }),
description: "cancel",
})
expect(cancelled).toBeUndefined()
yield* accounts.remove(first.id)
expect(yield* accounts.get(first.id)).toBeDefined()
const second = yield* accounts.create({
serviceID: AccountV2.ServiceID.make("provider"),
credential: new AccountV2.ApiKeyCredential({ type: "api", key: "second-key" }),
active: false,
})
expect(second).toBeDefined()
if (!second) return
blocked = second.id
yield* accounts.activate(second.id)
expect((yield* accounts.active(AccountV2.ServiceID.make("provider")))?.id).toBe(first.id)
}).pipe(Effect.provide(testLayer(tmp.path))),
),
),
)
it.live("account plugin refreshes providers on activation", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const accounts = yield* AccountV2.Service
const plugin = yield* PluginV2.Service
const updates: Array<{ id: ProviderV2.ID; enabled: ProviderV2.Info["enabled"]; apiKey?: string }> = []
const catalog = Catalog.Service.of({
provider: {
get: () => Effect.die("unexpected provider.get"),
update: (providerID, fn) =>
Effect.sync(() => {
const provider = produce(ProviderV2.Info.empty(providerID), fn)
updates.push({
id: providerID,
enabled: provider.enabled,
apiKey:
typeof provider.options.aisdk.provider.apiKey === "string"
? provider.options.aisdk.provider.apiKey
: undefined,
})
}),
all: () => Effect.succeed([]),
available: () => Effect.succeed([]),
},
model: {
get: () => Effect.die("unexpected model.get"),
update: () => Effect.die("unexpected model.update"),
all: () => Effect.succeed([]),
available: () => Effect.succeed([]),
default: () => Effect.succeed(Option.none<ModelV2.Info>()),
setDefault: () => Effect.die("unexpected model.setDefault"),
small: () => Effect.succeed(Option.none<ModelV2.Info>()),
},
})
yield* plugin.add({
...AccountPlugin,
effect: AccountPlugin.effect.pipe(
Effect.provideService(AccountV2.Service, accounts),
Effect.provideService(Catalog.Service, catalog),
),
})
const previous = yield* accounts.create({
serviceID: AccountV2.ServiceID.make("old-provider"),
credential: new AccountV2.ApiKeyCredential({ type: "api", key: "old-key" }),
})
const next = yield* accounts.create({
serviceID: AccountV2.ServiceID.make("new-provider"),
credential: new AccountV2.ApiKeyCredential({ type: "api", key: "new-key" }),
active: false,
})
expect(previous).toBeDefined()
expect(next).toBeDefined()
if (!previous || !next) return
yield* plugin.trigger("account.activated", { from: previous.id, to: next.id }, {})
expect(updates).toEqual([
{ id: ProviderV2.ID.make("old-provider"), enabled: false, apiKey: undefined },
{
id: ProviderV2.ID.make("new-provider"),
enabled: { via: "account", service: AccountV2.ServiceID.make("new-provider") },
apiKey: "new-key",
},
])
}).pipe(Effect.provide(testLayer(tmp.path))),
),
),
)
})
@@ -1,13 +1,14 @@
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { AuthV2 } from "@opencode-ai/core/auth"
import { AccountV2 } from "@opencode-ai/core/account"
import { Catalog } from "@opencode-ai/core/catalog"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { AuthPlugin } from "@opencode-ai/core/plugin/auth"
import { AccountPlugin } from "@opencode-ai/core/plugin/account"
import { AzurePlugin } from "@opencode-ai/core/plugin/provider/azure"
import { testEffect } from "../lib/effect"
import { fakeSelectorSdk, it, model, npmLayer, provider, withEnv } from "./provider-helper"
import { catalogLayer, fakeSelectorSdk, it, model, npmLayer, provider, withEnv } from "./provider-helper"
const itWithAuth = testEffect(Layer.mergeAll(PluginV2.defaultLayer, AuthV2.defaultLayer, npmLayer))
const itWithAccount = testEffect(Layer.mergeAll(PluginV2.defaultLayer, AccountV2.defaultLayer, catalogLayer, npmLayer))
describe("AzurePlugin", () => {
it.effect("resolves resourceName from env", () =>
@@ -43,7 +44,7 @@ describe("AzurePlugin", () => {
),
)
itWithAuth.effect("prefers auth resourceName over env", () =>
itWithAccount.effect("prefers account resourceName over env", () =>
withEnv(
{
AZURE_RESOURCE_NAME: "from-env",
@@ -51,23 +52,27 @@ describe("AzurePlugin", () => {
() =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const auth = yield* AuthV2.Service
yield* auth.create({
serviceID: AuthV2.ServiceID.make("azure"),
credential: new AuthV2.ApiKeyCredential({
const accounts = yield* AccountV2.Service
const catalog = yield* Catalog.Service
yield* accounts.create({
serviceID: AccountV2.ServiceID.make("azure"),
credential: new AccountV2.ApiKeyCredential({
type: "api",
key: "key",
metadata: { resourceName: "from-auth" },
metadata: { resourceName: "from-account" },
}),
active: true,
})
yield* plugin.add({
...AuthPlugin,
effect: AuthPlugin.effect.pipe(Effect.provideService(AuthV2.Service, auth)),
...AccountPlugin,
effect: AccountPlugin.effect.pipe(
Effect.provideService(AccountV2.Service, accounts),
Effect.provideService(Catalog.Service, catalog),
),
})
yield* plugin.add(AzurePlugin)
const result = yield* plugin.trigger("provider.update", {}, { provider: provider("azure"), cancel: false })
expect(result.provider.options.aisdk.provider.resourceName).toBe("from-auth")
expect(result.provider.options.aisdk.provider.resourceName).toBe("from-account")
}),
),
)
@@ -1,14 +1,15 @@
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { AuthV2 } from "@opencode-ai/core/auth"
import { AccountV2 } from "@opencode-ai/core/account"
import { Catalog } from "@opencode-ai/core/catalog"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { AuthPlugin } from "@opencode-ai/core/plugin/auth"
import { AccountPlugin } from "@opencode-ai/core/plugin/account"
import { CloudflareWorkersAIPlugin } from "@opencode-ai/core/plugin/provider/cloudflare-workers-ai"
import { testEffect } from "../lib/effect"
import { fakeSelectorSdk, it, model, npmLayer, provider, withEnv } from "./provider-helper"
import { catalogLayer, fakeSelectorSdk, it, model, npmLayer, provider, withEnv } from "./provider-helper"
const itWithAuth = testEffect(Layer.mergeAll(PluginV2.defaultLayer, AuthV2.defaultLayer, npmLayer))
const itWithAccount = testEffect(Layer.mergeAll(PluginV2.defaultLayer, AccountV2.defaultLayer, catalogLayer, npmLayer))
function cloudflareLanguage(sdk: unknown, modelID = "@cf/model") {
return (sdk as { languageModel: (id: string) => { config: CloudflareConfig; provider: string } }).languageModel(
@@ -104,7 +105,7 @@ describe("CloudflareWorkersAIPlugin", () => {
),
)
itWithAuth.effect("falls back to auth account metadata when account env is absent", () =>
itWithAccount.effect("falls back to account metadata when account env is absent", () =>
withEnv(
{
CLOUDFLARE_ACCOUNT_ID: undefined,
@@ -113,19 +114,23 @@ describe("CloudflareWorkersAIPlugin", () => {
() =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const auth = yield* AuthV2.Service
yield* auth.create({
serviceID: AuthV2.ServiceID.make("cloudflare-workers-ai"),
credential: new AuthV2.ApiKeyCredential({
const accounts = yield* AccountV2.Service
const catalog = yield* Catalog.Service
yield* accounts.create({
serviceID: AccountV2.ServiceID.make("cloudflare-workers-ai"),
credential: new AccountV2.ApiKeyCredential({
type: "api",
key: "auth-key",
metadata: { accountId: "auth-acct" },
key: "account-key",
metadata: { accountId: "account-acct" },
}),
active: true,
})
yield* plugin.add({
...AuthPlugin,
effect: AuthPlugin.effect.pipe(Effect.provideService(AuthV2.Service, auth)),
...AccountPlugin,
effect: AccountPlugin.effect.pipe(
Effect.provideService(AccountV2.Service, accounts),
Effect.provideService(Catalog.Service, catalog),
),
})
yield* plugin.add(CloudflareWorkersAIPlugin)
const updated = yield* plugin.trigger(
@@ -136,7 +141,7 @@ describe("CloudflareWorkersAIPlugin", () => {
expect(updated.provider.endpoint).toEqual({
type: "aisdk",
package: "test-provider",
url: "https://api.cloudflare.com/client/v4/accounts/auth-acct/ai/v1",
url: "https://api.cloudflare.com/client/v4/accounts/account-acct/ai/v1",
})
}),
),
@@ -1,11 +1,12 @@
import { describe, expect, mock } from "bun:test"
import { Effect, Layer } from "effect"
import { AuthV2 } from "@opencode-ai/core/auth"
import { AccountV2 } from "@opencode-ai/core/account"
import { Catalog } from "@opencode-ai/core/catalog"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { AuthPlugin } from "@opencode-ai/core/plugin/auth"
import { AccountPlugin } from "@opencode-ai/core/plugin/account"
import { GitLabPlugin } from "@opencode-ai/core/plugin/provider/gitlab"
import { testEffect } from "../lib/effect"
import { it, model, npmLayer, provider, withEnv } from "./provider-helper"
import { catalogLayer, it, model, npmLayer, provider, withEnv } from "./provider-helper"
const gitlabSDKOptions: Record<string, unknown>[] = []
@@ -22,7 +23,7 @@ void mock.module("gitlab-ai-provider", () => ({
isWorkflowModel: (id: string) => id === "duo-workflow" || id === "duo-workflow-exact",
}))
const itWithAuth = testEffect(Layer.mergeAll(PluginV2.defaultLayer, AuthV2.defaultLayer, npmLayer))
const itWithAccount = testEffect(Layer.mergeAll(PluginV2.defaultLayer, AccountV2.defaultLayer, catalogLayer, npmLayer))
describe("GitLabPlugin", () => {
it.effect("creates SDKs with legacy default instance URL, token env, headers, and feature flags", () =>
@@ -141,7 +142,7 @@ describe("GitLabPlugin", () => {
}),
)
itWithAuth.effect("uses active API auth token over GITLAB_TOKEN", () =>
itWithAccount.effect("uses active account API token over GITLAB_TOKEN", () =>
withEnv(
{
GITLAB_TOKEN: "env-token",
@@ -150,15 +151,19 @@ describe("GitLabPlugin", () => {
Effect.gen(function* () {
gitlabSDKOptions.length = 0
const plugin = yield* PluginV2.Service
const auth = yield* AuthV2.Service
yield* auth.create({
serviceID: AuthV2.ServiceID.make("gitlab"),
credential: new AuthV2.ApiKeyCredential({ type: "api", key: "auth-token" }),
const accounts = yield* AccountV2.Service
const catalog = yield* Catalog.Service
yield* accounts.create({
serviceID: AccountV2.ServiceID.make("gitlab"),
credential: new AccountV2.ApiKeyCredential({ type: "api", key: "account-token" }),
active: true,
})
yield* plugin.add({
...AuthPlugin,
effect: AuthPlugin.effect.pipe(Effect.provideService(AuthV2.Service, auth)),
...AccountPlugin,
effect: AccountPlugin.effect.pipe(
Effect.provideService(AccountV2.Service, accounts),
Effect.provideService(Catalog.Service, catalog),
),
})
yield* plugin.add(GitLabPlugin)
const updated = yield* plugin.trigger("provider.update", {}, { provider: provider("gitlab"), cancel: false })
@@ -171,12 +176,12 @@ describe("GitLabPlugin", () => {
},
{},
)
expect(gitlabSDKOptions[0].apiKey).toBe("auth-token")
expect(gitlabSDKOptions[0].apiKey).toBe("account-token")
}),
),
)
itWithAuth.effect("uses active OAuth access token when no API auth exists", () =>
itWithAccount.effect("uses active account OAuth access token when no API token exists", () =>
withEnv(
{
GITLAB_TOKEN: undefined,
@@ -185,20 +190,24 @@ describe("GitLabPlugin", () => {
Effect.gen(function* () {
gitlabSDKOptions.length = 0
const plugin = yield* PluginV2.Service
const auth = yield* AuthV2.Service
yield* auth.create({
serviceID: AuthV2.ServiceID.make("gitlab"),
credential: new AuthV2.OAuthCredential({
const accounts = yield* AccountV2.Service
const catalog = yield* Catalog.Service
yield* accounts.create({
serviceID: AccountV2.ServiceID.make("gitlab"),
credential: new AccountV2.OAuthCredential({
type: "oauth",
refresh: "refresh-token",
access: "oauth-token",
access: "account-oauth-token",
expires: 9999999999999,
}),
active: true,
})
yield* plugin.add({
...AuthPlugin,
effect: AuthPlugin.effect.pipe(Effect.provideService(AuthV2.Service, auth)),
...AccountPlugin,
effect: AccountPlugin.effect.pipe(
Effect.provideService(AccountV2.Service, accounts),
Effect.provideService(Catalog.Service, catalog),
),
})
yield* plugin.add(GitLabPlugin)
const updated = yield* plugin.trigger("provider.update", {}, { provider: provider("gitlab"), cancel: false })
@@ -211,7 +220,7 @@ describe("GitLabPlugin", () => {
},
{},
)
expect(gitlabSDKOptions[0].apiKey).toBe("oauth-token")
expect(gitlabSDKOptions[0].apiKey).toBe("account-oauth-token")
}),
),
)
@@ -2,6 +2,7 @@ import { Npm } from "@opencode-ai/core/npm"
import type { LanguageModelV3 } from "@ai-sdk/provider"
import { expect } from "bun:test"
import { Effect, Layer, Option } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { ProviderV2 } from "@opencode-ai/core/provider"
@@ -18,6 +19,27 @@ export const npmLayer = Layer.succeed(
}),
)
export const catalogLayer = Layer.succeed(
Catalog.Service,
Catalog.Service.of({
provider: {
get: () => Effect.die("unexpected provider.get"),
update: () => Effect.void,
all: () => Effect.succeed([]),
available: () => Effect.succeed([]),
},
model: {
get: () => Effect.die("unexpected model.get"),
update: () => Effect.die("unexpected model.update"),
all: () => Effect.succeed([]),
available: () => Effect.succeed([]),
default: () => Effect.succeed(Option.none<ModelV2.Info>()),
setDefault: () => Effect.die("unexpected model.setDefault"),
small: () => Effect.succeed(Option.none<ModelV2.Info>()),
},
}),
)
export const it = testEffect(Layer.mergeAll(PluginV2.defaultLayer, npmLayer))
export function provider(providerID: string, options?: Partial<ProviderV2.Info>) {
@@ -140,7 +140,7 @@ describe("OpencodePlugin", () => {
const updated = yield* plugin.trigger(
"provider.update",
{},
{ provider: provider("opencode", { enabled: { via: "auth", service: "opencode" } }), cancel: false },
{ provider: provider("opencode", { enabled: { via: "account", service: "opencode" } }), cancel: false },
)
const paid = yield* plugin.trigger(
"model.update",
+2 -2
View File
@@ -458,7 +458,7 @@ export const RunCommand = effectCmd({
const name = title()
const result = await sdk.session.create({
title: name,
permission: rules,
permission: [...rules],
})
const id = result.data?.id
if (!id) {
@@ -501,7 +501,7 @@ export const RunCommand = effectCmd({
variant: input.variant,
}
: undefined,
permission: rules,
permission: [...rules],
})
const id = result.data?.id
if (!id) {
+1 -15
View File
@@ -1,15 +1 @@
import { Wildcard } from "@/util/wildcard"
type Rule = {
permission: string
pattern: string
action: "allow" | "deny" | "ask"
}
export function evaluate(permission: string, pattern: string, ...rulesets: Rule[][]): Rule {
const rules = rulesets.flat()
const match = rules.findLast(
(rule) => Wildcard.match(permission, rule.permission) && Wildcard.match(pattern, rule.pattern),
)
return match ?? { action: "ask", permission, pattern: "*" }
}
export { evaluate } from "@opencode-ai/core/permission"
+11 -20
View File
@@ -8,15 +8,15 @@ import { PermissionTable } from "@/session/session.sql"
import { Database } from "@/storage/db"
import { eq } from "drizzle-orm"
import * as Log from "@opencode-ai/core/util/log"
import { Wildcard } from "@/util/wildcard"
import { Wildcard } from "@opencode-ai/core/util/wildcard"
import { Deferred, Effect, Layer, Schema, Context } from "effect"
import os from "os"
import { evaluate as evalRule } from "./evaluate"
import { PermissionV2 } from "@opencode-ai/core/permission"
import { PermissionID } from "./schema"
const log = Log.create({ service: "permission" })
export const Action = Schema.Literals(["allow", "deny", "ask"]).annotate({ identifier: "PermissionAction" })
export const Action = PermissionV2.Action.annotate({ identifier: "PermissionAction" })
export type Action = Schema.Schema.Type<typeof Action>
export const Rule = Schema.Struct({
@@ -26,7 +26,7 @@ export const Rule = Schema.Struct({
}).annotate({ identifier: "PermissionRule" })
export type Rule = Schema.Schema.Type<typeof Rule>
export const Ruleset = Schema.mutable(Schema.Array(Rule)).annotate({ identifier: "PermissionRuleset" })
export const Ruleset = Schema.Array(Rule).annotate({ identifier: "PermissionRuleset" })
export type Ruleset = Schema.Schema.Type<typeof Ruleset>
export class Request extends Schema.Class<Request>("PermissionRequest")({
@@ -122,11 +122,11 @@ interface PendingEntry {
interface State {
pending: Map<PermissionID, PendingEntry>
approved: Ruleset
approved: Rule[]
}
export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule {
return evalRule(permission, pattern, ...rulesets)
return PermissionV2.evaluate(permission, pattern, ...rulesets)
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Permission") {}
@@ -142,7 +142,7 @@ export const layer = Layer.effect(
)
const state = {
pending: new Map<PermissionID, PendingEntry>(),
approved: row?.data ?? [],
approved: [...(row?.data ?? [])],
}
yield* Effect.addFinalizer(() =>
@@ -271,7 +271,7 @@ function expand(pattern: string): string {
}
export function fromConfig(permission: ConfigPermission.Info) {
const ruleset: Ruleset = []
const ruleset: Rule[] = []
for (const [key, value] of Object.entries(permission)) {
if (typeof value === "string") {
ruleset.push({ permission: key, action: value, pattern: "*" })
@@ -284,21 +284,12 @@ export function fromConfig(permission: ConfigPermission.Info) {
return ruleset
}
export function merge(...rulesets: Ruleset[]): Ruleset {
return rulesets.flat()
export function merge(...rulesets: Ruleset[]): Rule[] {
return [...PermissionV2.merge(...rulesets)]
}
const EDIT_TOOLS = ["edit", "write", "apply_patch"]
export function disabled(tools: string[], ruleset: Ruleset): Set<string> {
const result = new Set<string>()
for (const tool of tools) {
const permission = EDIT_TOOLS.includes(tool) ? "edit" : tool
const rule = ruleset.findLast((rule) => Wildcard.match(permission, rule.permission))
if (!rule) continue
if (rule.pattern === "*" && rule.action === "deny") result.add(tool)
}
return result
return PermissionV2.disabled(tools, ruleset)
}
export const defaultLayer = layer.pipe(Layer.provide(Bus.layer))
@@ -159,9 +159,15 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
if (body.trim().length === 0) return yield* create({})
const json = yield* tryParseJson(body)
const payload = yield* Schema.decodeUnknownEffect(Session.CreateInput)(json).pipe(
const decoded = yield* Schema.decodeUnknownEffect(Session.CreateInput)(json).pipe(
Effect.mapError(() => new HttpApiError.BadRequest({})),
)
const payload = decoded
? {
...decoded,
permission: decoded.permission ? [...decoded.permission] : undefined,
}
: decoded
return yield* create({ payload })
})
+1 -1
View File
@@ -1216,7 +1216,7 @@ export const layer = Layer.effect(
const message = yield* createUserMessage(input)
yield* sessions.touch(input.sessionID)
const permissions: Permission.Ruleset = []
const permissions: Permission.Rule[] = []
for (const [t, enabled] of Object.entries(input.tools ?? {})) {
permissions.push({ permission: t, action: enabled ? "allow" : "deny", pattern: "*" })
}
+3 -3
View File
@@ -101,7 +101,7 @@ export function fromRow(row: SessionRow): Info {
},
share,
revert,
permission: row.permission ?? undefined,
permission: row.permission ? [...row.permission] : undefined,
time: {
created: row.time_created,
updated: row.time_updated,
@@ -542,7 +542,7 @@ export const layer: Layer.Layer<
title: input.title ?? createDefaultTitle(!!input.parentID),
agent: input.agent,
model: input.model,
permission: input.permission,
permission: input.permission ? [...input.permission] : undefined,
cost: 0,
tokens: EmptyTokens,
time: {
@@ -734,7 +734,7 @@ export const layer: Layer.Layer<
sessionID: SessionID
permission: Permission.Ruleset
}) {
yield* patch(input.sessionID, { permission: input.permission, time: { updated: Date.now() } })
yield* patch(input.sessionID, { permission: [...input.permission], time: { updated: Date.now() } })
})
const setRevert = Effect.fn("Session.setRevert")(function* (input: {
+1 -1
View File
@@ -3496,7 +3496,7 @@ export type ProviderV2Info = {
name: string
}
| {
via: "auth"
via: "account"
service: string
}
| {
+121
View File
@@ -0,0 +1,121 @@
# V2 Core Instructions
These notes describe how to work on `packages/core` during the v2 port.
## Direction
Move behavior out of large application services and into plugins. Core services should become small, typed containers that own state, expose simple operations, and trigger hooks where policy or integration-specific logic belongs.
The target shape is:
- `packages/core` contains domain schemas, typed errors, state containers, events, and plugin hook contracts.
- Plugins implement provider-specific, config-specific, auth-specific, model-discovery, and generation behavior.
- Services are hot-reloadable by design: updates are granular, observable, and do not require tearing down the whole process.
- `packages/opencode` becomes thinner over time: UI, server routes, CLI, storage glue, and legacy compatibility should call the core services instead of owning domain logic directly.
## Service Shape
Core services should look like `Catalog`, `AccountV2`, and `AgentV2`:
- define schemas and branded ids at the top of the module
- define typed `Schema.TaggedErrorClass` errors for expected failures
- define an `Interface` with small operations
- expose a `Context.Service`
- implement `layer` with private in-memory state
- expose `defaultLayer` with explicit dependencies
- self-export with `export * as Name from "./file"`
Prefer a dumb container API:
- `get`, `all`, `available`, `default`, `update`, `remove`, `activate`, or other small domain verbs
- `update(id, draft => ...)` for registration and mutation
- hook calls before committing mutations when plugins need to enrich, cancel, or validate changes
- events after committing mutations when other services or frontends need to react
Avoid putting application policy directly in core services unless it is a domain invariant. For example, resolving model endpoint inheritance is catalog-owned; deciding which providers to register is plugin-owned.
## Plugin Hooks
Plugins are the extension boundary for v2. Add hooks to `PluginV2.HookSpec` when logic should be provided by integrations instead of the container itself.
Hook conventions:
- hooks receive immutable input plus mutable output
- mutable object outputs are exposed as Immer drafts
- include `cancel: boolean` when plugins can prevent a mutation
- trigger hooks sequentially so ordering remains deterministic
- keep hook names domain-oriented, like `provider.update`, `model.update`, `account.activate`, `agent.generate`
- keep hook payloads small and typed with core schemas
Use hooks for:
- registering providers and models
- applying env/account/config-derived enablement
- transforming SDK/provider options
- implementing generated behavior such as agent generation
- choosing defaults when the choice is policy rather than state
Do not use hooks as a dumping ground for transport concerns, UI behavior, or compatibility shims.
## Plugin Boot
Built-in core plugins are registered by `packages/core/src/plugin/boot.ts`.
When a new core service is intended to be available to plugins:
- add the service to the boot layer dependency type
- yield the service inside the layer
- provide it to each plugin effect in `add`
- add its default layer to `PluginBoot.defaultLayer` only when that does not create a cycle
Keep boot as composition only. It should not contain provider, account, agent, or model policy itself.
## Boundaries
Core should not import from `packages/opencode`. If a type or concept is needed by core, move or remodel the domain shape in core first.
Avoid moving legacy services over wholesale. Port the domain shape and the container API, then leave specific behavior behind hooks for plugins to implement.
When porting an opencode service:
- identify the state it owns
- identify the operations callers actually need
- identify which branches are policy or integration behavior
- model state and operations in `packages/core`
- add hooks for the policy/integration branches
- keep old package code working until callers can migrate incrementally
## Schemas And Types
Use Effect schemas as the public contract:
- branded schemas for ids
- `Schema.Class` or `Schema.Struct` for domain data
- `Schema.TaggedErrorClass` for expected errors
- existing core helpers like `DeepMutable`, `withStatics`, and integer schemas where appropriate
Prefer `Info` objects as the stored domain records. Add static `empty(...)` constructors when update APIs need to create records on first mutation.
Keep schemas stable and explicit. Do not rely on opencode config shapes as core domain shapes unless the config shape is actually the domain model.
## State And Events
Keep state private to the service layer. Use immutable replacement or Effect refs when persistence/concurrency requires it.
Publish events for committed domain changes, not for attempted mutations. Event names should describe domain facts, for example `catalog.model.updated`.
The v2 goal is granular reconfiguration. A model update should let dependents react to that model update; it should not require global reloads.
## Style
Follow the local core style:
- `Effect.gen(function* () { ... })` for composition
- `Effect.fn("Domain.method")` for public service methods
- `Effect.fnUntraced` for small internal mutation helpers
- `yield* new ErrorClass(...)` for typed failures
- minimal helpers unless they name a real concept
- no `any` unless an existing plugin boundary requires it
- no compatibility code without a concrete persisted or external-consumer need
Prefer the smallest correct port. The goal is to make services easier to replace and reason about, not to recreate the old architecture in a new package.
+48 -8
View File
@@ -232,7 +232,7 @@ export interface Interface {
}
```
`ProviderV2.Info.enabled` is stored provider state. Provider plugins set this field after checking env, auth, config, or provider-specific availability.
`ProviderV2.Info.enabled` is stored provider state. Provider plugins set this field after checking env, account, config, or provider-specific availability.
`ProviderV2.Endpoint` includes `{ type: "unknown" }`. `CatalogV2.model.get()` and `CatalogV2.model.all()` resolve `unknown` endpoints from the provider before returning models.
@@ -256,6 +256,46 @@ const available = provider.enabled && model.status !== "deprecated"
## Plugin Interface
```ts
type HookSpec = {
"account.update": {
input: {
id: AccountV2.ID
serviceID: AccountV2.ServiceID
}
output: {
description: string
credential: AccountV2.Credential
cancel: boolean
}
}
"account.remove": {
input: {
account: AccountV2.Info
}
output: {
cancel: boolean
}
}
"account.activate": {
input: {}
output: {
from?: AccountV2.ID
to: AccountV2.ID
cancel: boolean
}
}
"account.activated": {
input: {
from?: AccountV2.ID
to: AccountV2.ID
}
output: {}
}
}
export type Definition<R = never> = Effect.Effect<
{
readonly order: number
@@ -280,7 +320,7 @@ export interface Interface {
export const Order = {
modelsDev: 0,
env: 10,
auth: 20,
account: 20,
provider: 30,
config: 40,
discovery: 50,
@@ -294,21 +334,21 @@ export const ModelsDevPlugin: PluginV2.Definition<ProviderV2.Service | ModelV2.S
export const EnvPlugin: PluginV2.Definition<ProviderV2.Service | Env.Service>
export const AuthPlugin: PluginV2.Definition<ProviderV2.Service | AuthV2.Service>
export const AccountPlugin: PluginV2.Definition<ProviderV2.Service | AccountV2.Service>
export const ConfigPlugin: PluginV2.Definition<ProviderV2.Service | ModelV2.Service | Config.Service>
export const AnthropicPlugin: PluginV2.Definition<ProviderV2.Service | AuthV2.Service>
export const AnthropicPlugin: PluginV2.Definition<ProviderV2.Service | AccountV2.Service>
export const OpenRouterPlugin: PluginV2.Definition<ProviderV2.Service>
export const AmazonBedrockPlugin: PluginV2.Definition<ProviderV2.Service | AuthV2.Service | Env.Service>
export const AmazonBedrockPlugin: PluginV2.Definition<ProviderV2.Service | AccountV2.Service | Env.Service>
export const GoogleVertexPlugin: PluginV2.Definition<ProviderV2.Service | AuthV2.Service | Env.Service>
export const GoogleVertexPlugin: PluginV2.Definition<ProviderV2.Service | AccountV2.Service | Env.Service>
export const GitLabPlugin: PluginV2.Definition<ProviderV2.Service | AuthV2.Service | Env.Service>
export const GitLabPlugin: PluginV2.Definition<ProviderV2.Service | AccountV2.Service | Env.Service>
export const GitLabDiscoveryPlugin: PluginV2.Definition<ProviderV2.Service | ModelV2.Service | AuthV2.Service>
export const GitLabDiscoveryPlugin: PluginV2.Definition<ProviderV2.Service | ModelV2.Service | AccountV2.Service>
```
## Plugin Hooks