Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9b1b520525 | |||
| a8e1138200 | |||
| ef4cafc053 | |||
| 85e1a8588e |
@@ -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"
|
||||
@@ -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))
|
||||
@@ -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"
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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)
|
||||
}),
|
||||
}
|
||||
}),
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}),
|
||||
}
|
||||
}),
|
||||
})
|
||||
@@ -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"
|
||||
}),
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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 +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"
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
|
||||
|
||||
@@ -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: "*" })
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -3496,7 +3496,7 @@ export type ProviderV2Info = {
|
||||
name: string
|
||||
}
|
||||
| {
|
||||
via: "auth"
|
||||
via: "account"
|
||||
service: string
|
||||
}
|
||||
| {
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user