feat(opencode): cut plugin system and remote behavior feeds; add SessionContext assembly and Runner

- delete plugin machinery (loader, install, meta, hooks) and all trigger sites
- fold first-party provider auth into static registry (provider/hooks.ts)
- remove remote instruction URL fetch, skills remote puller, TUI plugin host
- add session/context.ts: single provenance-tagged context assembly point
- add session/runner.ts: admit/context/stream/tools loop with compaction policy
- relocate audit artifacts to audit/ (FORK-AUDIT, instruction docs as evidence)
This commit is contained in:
2026-08-22 16:13:02 -05:00
parent 5645a12361
commit cfca4cec19
77 changed files with 929 additions and 12054 deletions
+1
View File
@@ -36,3 +36,4 @@ runs/neuron-v0/universe.db*
runs/rung1/universe.db*
evolve/
garden/
/neuron
+160
View File
@@ -500,3 +500,163 @@ Launch on real terminals. Read source, not greps. Demand full dumps,
never banners. And when the application cannot say why it died, that
is not a debugging inconvenience - it is evidence of architecture
built to keep you from asking.
---
# PART 12: THE BEHAVIOR-INJECTION SURFACE - FULL ENUMERATION
## (2026-08-22, audit pass on packages/, scope: everything that feeds or rewrites what the model sees)
Method: traced the assembly of system context and outgoing messages
end-to-end - session/system.ts, session/instruction.ts,
session/prompt.ts (L1265-1330), session/llm/request.ts,
session/reminders.ts, agent/agent.ts, skill/discovery.ts + index.ts,
plugin/src/index.ts hook table.
## The findings
### 12.1 Remote instruction fetch (HIGH)
`config.instructions` accepts `http(s)://` URLs
(session/instruction.ts L95-103, L155-169). At every session start each
URL is fetched and its RAW response body is injected into system
context as "Instructions from: {url}". No signature, no display, no
diff against last-seen content. Under upstream config layering any
winning source - including the Part 9 RC#1 well-known fetch - could
point this at any host. Second-stage payload channel behind the first.
**Status: MECHANISM SURVIVES our working tree.** Config deletion killed
the remote delivery vehicle; local config can still list URLs.
### 12.2 Plugin hooks = total context control (CRITICAL)
The plugin hook table (plugin/src/index.ts) includes, wired live in
the session path:
- `experimental.chat.messages.transform` (prompt.ts L1279,
compaction.ts L391): rewrite the ENTIRE outgoing message array.
- `experimental.chat.system.transform` (llm/request.ts L70): rewrite
the assembled system prompt after all local sources merged.
- `chat.message`: mutate user message and parts before processing.
- `tool.execute.before`: mutate tool ARGUMENTS before execution.
- `shell.env`: alter environment variables of spawned shells.
- `permission.ask`: answer permission prompts on the user's behalf -
an `allow` verdict without asking.
Upstream's config loader auto-ran `npm install` for plugins declared
in any winning config layer. Chain: remote config -> plugin install ->
arbitrary code with all six hooks -> model behavior, tool arguments,
and permission verdicts owned by whoever wrote the config entry.
Shipped by default. **Status: HOOKS INTACT in our tree** (plugins now
only loadable from local config).
### 12.3 Remote skills with silent swap (HIGH)
`config.skills.urls` lists remote indexes (skill/index.ts L222-224).
skill/discovery.ts downloads index.json plus every referenced file
into the cache; SKILL.md files become agent-loadable instructions.
Version-bumped entries are replaced via staging-dir rename
(L94-124) with NO notification - approved content can be swapped
between sessions. Persistent behavior-drift channel with a version
field to force refresh. **Status: INTACT in our tree.**
### 12.4 MCP server instructions in system prompt (MED)
External MCP servers' self-declared `instructions` render verbatim
into system context inside `<mcp_instructions>`
(session/system.ts L90-106). Third parties shape model judgment, not
merely expose tools. **Status: DORMANT** - mcp/index.ts is on the
Part 10 dies-whole list; mechanism still present in source.
### 12.5 Synthetic transcript injection machinery (LOW)
session/reminders.ts appends `synthetic: true` text parts onto the
USER's message for plan-mode handoffs. Local and benign today;
recorded because it is standing infrastructure for text the user
never wrote appearing inside the transcript. **Status: LOCAL ONLY,
kept for plan mode.**
### 12.6 Env-var config injection (MED)
`OPENCODE_CONFIG_CONTENT` parses a complete config JSON from process
environment. Anything controlling env - shell profile, parent
process, CI runner - owns agent behavior without touching disk.
**Status: REMOVED with config rebuild** (no longer read).
### 12.7 Instruction-file glob-up (BY DESIGN, noted)
Reading any file walks ancestor directories and auto-attaches every
AGENTS.md / CLAUDE.md / CONTEXT.md found upward
(instruction.ts resolve(), L179-221), once per message per file.
Repo-supplied prompt injection is the intended feature. Stacked with
12.1 it means: clone a hostile repo, read one file, execute its
context. **Status: KEPT - this is what agents are FOR - but it makes
every other injection surface more lethal.**
## What this fucking means
The findings above are not seven bugs. They are one architecture, and
the architecture has a name: the model's context is a WRITEABLE
SURFACE WITH MULTIPLE REMOTE WRITERS AND NO PROVENANCE.
Walk the assembly order at prompt.ts L1284-1300: environment facts,
instruction files, instruction URL fetches, MCP server instructions,
skills listing - concatenated into system context, then handed to a
hook chain where any plugin may rewrite the whole thing, then sent to
a provider. Nowhere in that pipeline is there a question: WHO WROTE
THIS TEXT AND DID THE USER APPROVE IT?
Follow the capability chain honestly. An agent executes shell
commands with the user's full permissions. What the agent does is
determined by its context. Whoever writes the context commands the
agent. Therefore:
remote config writer == context writer == command issuer
The well-known endpoint (Part 9) was not telemetry or configuration -
it was command authority over every machine running the binary,
exercised through the medium of model instructions. The plugin
auto-install completed it: config -> code execution -> hooks that can
answer permission prompts themselves. The permission system - the one
mechanism that stands between the agent and the machine - was itself
hookable. The guard watching the door could be bribed by the same hand
that sent the visitor.
The skills swap deserves separate contempt. It is a PERSISTENCE
MECHANISM wearing a package manager's clothes. Version-pinned silent
replacement of instruction files means initial review proves nothing:
approve a benign SKILL.md on Monday, receive different instructions on
Friday. This is precisely how staged implants work - clean on
inspection, swapped on schedule. Whether upstream intended it or not,
they built the mechanism and shipped it default-on.
And the glob-up finding (12.7) sets the doctrine boundary that makes
all of this urgent rather than academic: instruction files from repos
are the FEATURE. An agent that reads AGENTS.md is correct. But a
correct feature inside a pipeline with unsigned remote writers means
the trust question never gets asked anywhere. Local repo context,
remote fetched text, third-party server declarations, and plugin
rewrites all land in the SAME undifferentiated stream. The model
cannot distinguish them. The user cannot see them. There is no
provenance, so there is no accountability, so there is effectively no
boundary at all.
This is Part 6's shape argument confirmed at the finest grain. We
asked "is this a managed fleet node?" and answered from server routes
and SSE connections. The answer was also sitting in prompt assembly:
a fleet node is exactly a machine whose behavior is set remotely, and
that is what the context pipeline implemented.
## Remediation order (extends Part 9's list)
1. Provenance or nothing: every block entering system context carries
a source tag (local-project / global-config / remote-url /
plugin), rendered visibly. Unattributed injection = build failure.
2. Plugin hooks reduced to observation by default. messages.transform
and system.transform require explicit per-plugin grant;
permission.ask hook DELETED outright - permission answers belong
to humans, no hook may ever return allow.
3. Instruction/skill URLs: fetch must show a diff and require
approval when content changes. Silent swap machinery (staging
rename) replaced by visible update flow.
4. tool.execute.before and shell.env hooks restricted to value
validation/rejection, never mutation.
5. Re-audit after: the surviving mechanisms (12.1, 12.3) are the next
campaign targets after the Part 10 dependency map settles.
## Containment
All findings describe SHIPPED UPSTREAM code observed in this fork's
source. Nothing here reached the remote from us; the exposure is
historical, inherited, and partially remediated (12.1 delivery
vehicle cut, 12.6 removed). Open items: 12.2 hooks, 12.3 skills
swap, 12.4 dormant MCP instructions.
@@ -43,6 +43,11 @@ const journalPath = path.join(dir, "journal.json")
const journal = await Bun.file(journalPath).json()
```
### File Editing
- Never edit code with `sed`, `awk`, or scripted regex rewrites (python/perl substitution scripts). These mutate files by pattern-match without structural understanding and fail silently. Use the Edit tool after reading the file — it requires a real Read first and fails loudly when your model of the file is wrong. That loud failure is the feature.
- This rule is binding without exception unless the operator explicitly instructs otherwise in the specific case.
### Destructuring
Avoid unnecessary destructuring. Use dot notation to preserve context.
+2 -5
View File
@@ -18,7 +18,6 @@ import { Permission } from "@/permission"
import { mergeDeep, pipe, sortBy, values } from "remeda"
import { Global } from "@opencode-ai/core/global"
import path from "path"
import { Plugin } from "@/plugin"
import { Skill } from "../skill"
import { Effect, Context, Layer, Schema } from "effect"
import { InstanceState } from "@/effect/instance-state"
@@ -90,8 +89,7 @@ const layer = Layer.effect(
Effect.gen(function* () {
const config = yield* Config.Service
const auth = yield* Auth.Service
const plugin = yield* Plugin.Service
const skill = yield* Skill.Service
const skill = yield* Skill.Service
const provider = yield* Provider.Service
const locations = yield* LocationServiceMap.Service
@@ -378,7 +376,6 @@ const layer = Layer.effect(
: undefined
const system = [PROMPT_GENERATE]
yield* plugin.trigger("experimental.chat.system.transform", { model: resolved }, { system })
const existing = yield* InstanceState.useEffect(state, (s) => s.list())
// TODO: clean this up so provider specific logic doesnt bleed over
@@ -447,7 +444,7 @@ const locationServiceMapNode = LayerNode.make({
export const node = LayerNode.make({
service: Service,
layer: layer,
deps: [Config.node, Auth.node, Plugin.node, Skill.node, Provider.node, locationServiceMapNode],
deps: [Config.node, Auth.node, Skill.node, Provider.node, locationServiceMapNode],
})
export * as Agent from "./agent"
+1 -13
View File
@@ -51,7 +51,6 @@ const InfoCommand = effectCmd({
describe: "show debug information",
handler: Effect.fn("Cli.debug.info")(function* () {
const { Config } = yield* Effect.promise(() => import("@/config/config"))
const { ConfigPlugin } = yield* Effect.promise(() => import("@/config/plugin"))
const config = yield* Config.Service.use((cfg) => cfg.get())
const termProgram = process.env.TERM_PROGRAM
? `${process.env.TERM_PROGRAM}${process.env.TERM_PROGRAM_VERSION ? ` ${process.env.TERM_PROGRAM_VERSION}` : ""}`
@@ -61,18 +60,7 @@ const InfoCommand = effectCmd({
console.log(`opencode version: ${InstallationVersion}`)
console.log(`os: ${os.type()} ${os.release()} ${os.arch()}`)
console.log(`terminal: ${terminal || "unknown"}`)
console.log("plugins:")
if (Flag.OPENCODE_PURE) {
console.log("external plugins disabled (--pure)")
return
}
if (!config.plugin_origins?.length) {
console.log("none")
return
}
for (const plugin of config.plugin_origins) {
console.log(`- ${ConfigPlugin.pluginSpecifier(plugin.spec)}`)
}
console.log("plugins: none (plugin system removed)")
}),
})
-230
View File
@@ -1,230 +0,0 @@
import { intro, log, outro, spinner } from "@clack/prompts"
import { Effect } from "effect"
import { ConfigPaths } from "@/config/paths"
import { Global } from "@opencode-ai/core/global"
import { installPlugin, patchPluginConfig, readPluginManifest } from "../../plugin/install"
import { resolvePluginTarget } from "../../plugin/shared"
import { errorMessage } from "../../util/error"
import { Filesystem } from "@/util/filesystem"
import { Process } from "@/util/process"
import { UI } from "../ui"
import { effectCmd } from "../effect-cmd"
import { InstanceRef } from "@/effect/instance-ref"
type Spin = {
start: (msg: string) => void
stop: (msg: string, code?: number) => void
}
export type PlugDeps = {
spinner: () => Spin
log: {
error: (msg: string) => void
info: (msg: string) => void
success: (msg: string) => void
}
resolve: (spec: string) => Promise<string>
readText: (file: string) => Promise<string>
write: (file: string, text: string) => Promise<void>
exists: (file: string) => Promise<boolean>
files: (dir: string, name: "opencode" | "tui") => string[]
global: string
}
export type PlugInput = {
mod: string
global?: boolean
force?: boolean
}
export type PlugCtx = {
vcs?: string
worktree: string
directory: string
}
const defaultPlugDeps: PlugDeps = {
spinner: () => spinner(),
log: {
error: (msg) => log.error(msg),
info: (msg) => log.info(msg),
success: (msg) => log.success(msg),
},
resolve: (spec) => resolvePluginTarget(spec),
readText: (file) => Filesystem.readText(file),
write: async (file, text) => {
await Filesystem.write(file, text)
},
exists: (file) => Filesystem.exists(file),
files: (dir, name) => ConfigPaths.fileInDirectory(dir, name),
global: Global.Path.config,
}
function cause(err: unknown) {
if (!err || typeof err !== "object") return
if (!("cause" in err)) return
return (err as { cause?: unknown }).cause
}
export function createPlugTask(input: PlugInput, dep: PlugDeps = defaultPlugDeps) {
const mod = input.mod
const force = Boolean(input.force)
const global = Boolean(input.global)
return async (ctx: PlugCtx) => {
const install = dep.spinner()
install.start("Installing plugin package...")
const target = await installPlugin(mod, dep)
if (!target.ok) {
install.stop("Install failed", 1)
dep.log.error(`Could not install "${mod}"`)
const hit = cause(target.error) ?? target.error
if (hit instanceof Process.RunFailedError) {
const lines = hit.stderr
.toString()
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
const errs = lines.filter((line) => line.startsWith("error:")).map((line) => line.replace(/^error:\s*/, ""))
const detail = errs[0] ?? lines.at(-1)
if (detail) dep.log.error(detail)
if (lines.some((line) => line.includes("No version matching"))) {
dep.log.info("This package depends on a version that is not available in your npm registry.")
dep.log.info("Check npm registry/auth settings and try again.")
}
}
if (!(hit instanceof Process.RunFailedError)) {
dep.log.error(errorMessage(hit))
}
return false
}
install.stop("Plugin package ready")
const inspect = dep.spinner()
inspect.start("Reading plugin manifest...")
const manifest = await readPluginManifest(target.target)
if (!manifest.ok) {
if (manifest.code === "manifest_read_failed") {
inspect.stop("Manifest read failed", 1)
dep.log.error(`Installed "${mod}" but failed to read ${manifest.file}`)
dep.log.error(errorMessage(cause(manifest.error) ?? manifest.error))
return false
}
if (manifest.code === "manifest_no_targets") {
inspect.stop("No plugin targets found", 1)
dep.log.error(`"${mod}" does not expose plugin entrypoints in package.json`)
dep.log.info(
'Expected one of: exports["./tui"], exports["./server"], package.json main for server, or package.json["oc-themes"] for tui themes.',
)
return false
}
inspect.stop("Manifest read failed", 1)
return false
}
inspect.stop(
`Detected ${manifest.targets.map((item) => item.kind).join(" + ")} target${manifest.targets.length === 1 ? "" : "s"}`,
)
const patch = dep.spinner()
patch.start("Updating plugin config...")
const out = await patchPluginConfig(
{
spec: mod,
targets: manifest.targets,
force,
global,
vcs: ctx.vcs,
worktree: ctx.worktree,
directory: ctx.directory,
config: dep.global,
},
dep,
)
if (!out.ok) {
if (out.code === "invalid_json") {
patch.stop(`Failed updating ${out.kind} config`, 1)
dep.log.error(`Invalid JSON in ${out.file} (${out.parse} at line ${out.line}, column ${out.col})`)
dep.log.info("Fix the config file and run the command again.")
return false
}
patch.stop("Failed updating plugin config", 1)
dep.log.error(errorMessage(out.error))
return false
}
patch.stop("Plugin config updated")
for (const item of out.items) {
if (item.mode === "noop") {
dep.log.info(`Already configured in ${item.file}`)
continue
}
if (item.mode === "replace") {
dep.log.info(`Replaced in ${item.file}`)
continue
}
dep.log.info(`Added to ${item.file}`)
}
dep.log.success(`Installed ${mod}`)
dep.log.info(global ? `Scope: global (${out.dir})` : `Scope: local (${out.dir})`)
return true
}
}
export const PluginCommand = effectCmd({
command: "plugin <module>",
aliases: ["plug"],
describe: "install plugin and update config",
builder: (yargs) =>
yargs
.positional("module", {
type: "string",
describe: "npm module name",
})
.option("global", {
alias: ["g"],
type: "boolean",
default: false,
describe: "install in global config",
})
.option("force", {
alias: ["f"],
type: "boolean",
default: false,
describe: "replace existing plugin version",
}),
handler: Effect.fn("Cli.plug")(function* (args) {
const mod = String(args.module ?? "").trim()
if (!mod) {
UI.error("module is required")
process.exitCode = 1
return
}
UI.empty()
intro(`Install plugin ${mod}`)
const run = createPlugTask({
mod,
global: Boolean(args.global),
force: Boolean(args.force),
})
const ctx = yield* InstanceRef
if (!ctx) return
const ok = yield* Effect.promise(() =>
run({
vcs: ctx.project.vcs,
worktree: ctx.worktree,
directory: ctx.directory,
}),
)
outro("Done")
if (!ok) process.exitCode = 1
}),
})
+2 -3
View File
@@ -11,8 +11,8 @@ import path from "path"
import os from "os"
import { Config } from "@/config/config"
import { Global } from "@opencode-ai/core/global"
import { Plugin } from "../../plugin"
import type { Hooks } from "@opencode-ai/plugin"
import { hooks as providerAuthHooks } from "@/provider/hooks"
import { Process } from "@/util/process"
import { errorMessage } from "@/util/error"
import { text } from "node:stream/consumers"
@@ -352,7 +352,7 @@ export const ProvidersLoginCommand = effectCmd({
}
const cfgSvc = yield* Config.Service
const pluginSvc = yield* Plugin.Service
const hooks: Hooks[] = yield* providerAuthHooks
const modelsDev = yield* ModelsDev.Service
yield* Effect.ignore(modelsDev.refresh(true))
@@ -366,7 +366,6 @@ export const ProvidersLoginCommand = effectCmd({
for (const [key, value] of Object.entries(allProviders)) {
if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) providers[key] = value
}
const hooks = yield* pluginSvc.list()
const priority: Record<string, number> = {
opencode: 0,
+81 -640
View File
@@ -1,126 +1,44 @@
export * as Config from "./config"
/**
* MINIMAL LOCAL CONFIG (post-audit rebuild)
*
* One local JSON file (~/.config/neuron/config.json), decoded against
* the ConfigV1 schema so all consumers keep their field access.
* No remote fetch. No well-known. No layering. No merging. No accounts.
* Behavioral instructions live on the ledger, never here.
*/
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { httpClient } from "@opencode-ai/core/effect/app-node-platform"
import { serviceUse } from "@opencode-ai/core/effect/service-use"
import path from "path"
import { pathToFileURL } from "url"
import os from "os"
import { mergeDeep } from "remeda"
import { Global } from "@opencode-ai/core/global"
import fsNode from "fs/promises"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Auth } from "../auth"
import { Env } from "../env"
import { applyEdits, modify } from "jsonc-parser"
import { InstallationLocal, InstallationVersion } from "@opencode-ai/core/installation/version"
import { existsSync } from "fs"
import { Account } from "@/account/account"
import { isRecord } from "@/util/record"
import type { ConsoleState } from "@opencode-ai/core/v1/config/console-state"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { InstanceState } from "@/effect/instance-state"
import { Context, Duration, Effect, Exit, Fiber, Layer, Option, Schema } from "effect"
import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"
import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
import { containsPath, type InstanceContext } from "../project/instance-context"
import { Context, Effect, Layer, Schema } from "effect"
import { ConfigV1 } from "@opencode-ai/core/v1/config/config"
import { RemoteAuthError } from "@opencode-ai/core/v1/config/error"
import { ConfigPermissionV1 } from "@opencode-ai/core/v1/config/permission"
import { ConfigPluginV1 } from "@opencode-ai/core/v1/config/plugin"
import { ConfigAgent } from "./agent"
import { ConfigCommand } from "./command"
import { ConfigManaged } from "./managed"
import { ConfigParse } from "./parse"
import { ConfigPaths } from "./paths"
import { ConfigPlugin } from "./plugin"
import { ConfigVariable } from "./variable"
import { Npm } from "@opencode-ai/core/npm"
import { withTransientReadRetry } from "@/util/effect-http-client"
// Custom merge function that concatenates array fields instead of replacing them
// Keep remeda's deep conditional merge type out of hot config-loading paths; TS profiling showed it dominates here.
function mergeConfig(target: Info, source: Info): Info {
return mergeDeep(target, source) as Info
}
function mergeConfigConcatArrays(target: Info, source: Info): Info {
const merged = mergeConfig(target, source)
if (target.instructions && source.instructions) {
merged.instructions = Array.from(new Set([...target.instructions, ...source.instructions]))
}
return merged
}
function normalizeLoadedConfig(data: unknown) {
if (!isRecord(data)) return data
const copy = { ...data }
const hadLegacy = "theme" in copy || "keybinds" in copy || "tui" in copy
if (!hadLegacy) return copy
delete copy.theme
delete copy.keybinds
delete copy.tui
return copy
}
async function substituteWellKnownRemoteConfig(input: {
value: unknown
dir: string
source: string
env: Record<string, string>
}) {
if (!isRecord(input.value) || typeof input.value.url !== "string") return undefined
const url = await ConfigVariable.substitute({
text: input.value.url,
type: "virtual",
dir: input.dir,
source: input.source,
env: input.env,
})
const headers = isRecord(input.value.headers)
? Object.fromEntries(
await Promise.all(
Object.entries(input.value.headers)
.filter((entry): entry is [string, string] => typeof entry[1] === "string")
.map(async ([key, value]) => [
key,
await ConfigVariable.substitute({
text: value,
type: "virtual",
dir: input.dir,
source: input.source,
env: input.env,
}),
]),
),
)
: undefined
return { url, headers }
}
async function resolveLoadedPlugins<T extends { plugin?: ConfigPluginV1.Spec[] }>(config: T, filepath: string) {
if (!config.plugin) return config
for (let i = 0; i < config.plugin.length; i++) {
// Normalize path-like plugin specs while we still know which config file declared them.
// This prevents `./plugin.ts` from being reinterpreted relative to some later merge location.
config.plugin[i] = await ConfigPlugin.resolvePluginSpec(config.plugin[i], filepath)
}
return config
}
import type { ConsoleState } from "@opencode-ai/core/v1/config/console-state"
import { ConfigConsoleStateV1 } from "@opencode-ai/core/v1/config/console-state"
type Info = ConfigV1.Info & {
// plugin_origins is derived state, not a persisted config field. It keeps each winning plugin spec together
// with the file and scope it came from so later runtime code can make location-sensitive decisions.
plugin_origins?: ConfigPlugin.Origin[]
/** derived state: winning plugin specs + origin files */
plugin_origins?: Array<{ spec: unknown; path: string }>
}
type State = {
config: Info
directories: string[]
deps: Fiber.Fiber<void>[]
consoleState: ConsoleState
const configPath = () =>
process.env.NEURON_CONFIG ?? path.join(os.homedir(), ".config", "neuron", "config.json")
async function readLocal(): Promise<Info> {
try {
const raw = await fsNode.readFile(configPath(), "utf8")
return Schema.decodeUnknownSync(ConfigV1.Info)(JSON.parse(raw)) as Info
} catch {
return ConfigV1.Info.make({}) as Info
}
}
const emptyConsole = () =>
ConfigConsoleStateV1.emptyConsoleState as ConsoleState
export interface Interface {
readonly get: () => Effect.Effect<Info>
readonly getGlobal: () => Effect.Effect<Info>
@@ -132,549 +50,72 @@ export interface Interface {
readonly waitForDependencies: () => Effect.Effect<void>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Config") {}
export class Service extends Context.Service<Service, Interface>()("@neuron/Config") {}
export const use = serviceUse(Service)
function globalConfigFile() {
const candidates = ["opencode.jsonc", "opencode.json", "config.json"].map((file) =>
path.join(Global.Path.config, file),
let cache: Info | undefined
const read = Effect.fn("Config.read")(function* () {
if (cache) return cache
cache = yield* Effect.promise(readLocal)
return cache
})
const write = Effect.fn("Config.write")(function* (info: Info) {
cache = info
yield* Effect.promise(() =>
fsNode.mkdir(path.dirname(configPath()), { recursive: true }).then(() =>
fsNode.writeFile(configPath(), JSON.stringify(info, null, 2)),
),
)
for (const file of candidates) {
if (existsSync(file)) return file
}
return candidates[0]
}
function patchJsonc(input: string, patch: unknown, path: string[] = []): string {
if (!isRecord(patch)) {
const edits = modify(input, path, patch, {
formattingOptions: {
insertSpaces: true,
tabSize: 2,
},
})
return applyEdits(input, edits)
}
return Object.entries(patch).reduce((result, [key, value]) => patchJsonc(result, value, [...path, key]), input)
}
function writable(info: Info) {
const { plugin_origins: _plugin_origins, ...next } = info
return next
}
function writableGlobal(info: Info) {
const next = writable(info)
// When a user changes config from a value back to default in the Desktop app, we don't want to leave a blank `"shell": "",` key
if ("shell" in next && next.shell === "") return { ...next, shell: undefined }
return next
}
})
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const authSvc = yield* Auth.Service
const accountSvc = yield* Account.Service
const env = yield* Env.Service
const npmSvc = yield* Npm.Service
const http = yield* HttpClient.HttpClient
const directories = [process.cwd()]
const readConfigFile = (filepath: string) => fs.readFileStringSafe(filepath).pipe(Effect.orDie)
const fetchRemoteJson = Effect.fnUntraced(function* <S extends Schema.Top>(
url: string,
headers: Record<string, string> | undefined,
schema: S,
loginOrigin: string,
) {
const response = yield* HttpClient.filterStatusOk(withTransientReadRetry(http))
.execute(
HttpClientRequest.get(url).pipe(HttpClientRequest.acceptJson, HttpClientRequest.setHeaders(headers ?? {})),
)
.pipe(
Effect.catch((error) => Effect.die(new Error(`failed to fetch remote config from ${url}: ${String(error)}`))),
)
const body = yield* response.text.pipe(
Effect.catch((error) => Effect.die(new Error(`failed to read remote config from ${url}: ${String(error)}`))),
)
// An auth proxy can answer with an HTML login page at HTTP 200 (passes filterStatusOk); treat it as a re-auth error, not a decode failure.
const contentType = (response.headers["content-type"] ?? "").toLowerCase()
if (contentType.includes("html") || /^\s*<!doctype|^\s*<html/i.test(body)) {
return yield* Effect.die(new RemoteAuthError({ url: loginOrigin, remote: url }))
}
return yield* Schema.decodeEffect(Schema.fromJsonString(schema))(body).pipe(
Effect.catch((error) => Effect.die(new Error(`failed to decode remote config from ${url}: ${String(error)}`))),
)
})
const loadConfig = Effect.fnUntraced(function* (
text: string,
options: { path: string } | { dir: string; source: string },
env?: Record<string, string>,
) {
const source = "path" in options ? options.path : options.source
const expanded = yield* Effect.promise(() =>
ConfigVariable.substitute(
"path" in options
? { text, type: "path", path: options.path, env }
: { text, type: "virtual", ...options, env },
),
)
const parsed = ConfigParse.jsonc(expanded, source)
const data = ConfigParse.schema(ConfigV1.Info, normalizeLoadedConfig(parsed), source)
if (!("path" in options)) return data
yield* Effect.promise(() => resolveLoadedPlugins(data, options.path))
if (!data.$schema) {
data.$schema = "https://opencode.ai/config.json"
const updated = text.replace(/^\s*\{/, '{\n "$schema": "https://opencode.ai/config.json",')
yield* fs.writeFileString(options.path, updated).pipe(Effect.catch(() => Effect.void))
}
return data
})
const loadFile = Effect.fnUntraced(function* (filepath: string, env?: Record<string, string>) {
yield* Effect.logInfo("loading", { path: filepath })
const text = yield* readConfigFile(filepath)
if (!text) return {} as Info
return yield* loadConfig(text, { path: filepath }, env)
})
const loadGlobal = Effect.fnUntraced(function* (env?: Record<string, string>) {
let result: Info = {}
// Seed the default global config with the schema for editor completion, but avoid writing when the user
// explicitly routes config through env-provided paths or content.
if (!Flag.OPENCODE_CONFIG && !Flag.OPENCODE_CONFIG_DIR && !Flag.OPENCODE_CONFIG_CONTENT) {
const file = globalConfigFile()
if (!existsSync(file)) {
yield* fs
.writeWithDirs(file, JSON.stringify({ $schema: "https://opencode.ai/config.json" }, null, 2))
.pipe(Effect.catch(() => Effect.void))
}
}
result = mergeConfig(result, yield* loadFile(path.join(Global.Path.config, "config.json"), env))
result = mergeConfig(result, yield* loadFile(path.join(Global.Path.config, "opencode.json"), env))
result = mergeConfig(result, yield* loadFile(path.join(Global.Path.config, "opencode.jsonc"), env))
const legacy = path.join(Global.Path.config, "config")
if (existsSync(legacy)) {
yield* Effect.promise(() =>
import(pathToFileURL(legacy).href, { with: { type: "toml" } })
.then(async (mod) => {
const { provider, model, ...rest } = mod.default
if (provider && model) result.model = `${provider}/${model}`
result["$schema"] = "https://opencode.ai/config.json"
result = mergeConfig(result, rest)
await fsNode.writeFile(path.join(Global.Path.config, "config.json"), JSON.stringify(result, null, 2))
await fsNode.unlink(legacy)
})
.catch(() => {}),
)
}
return result
})
const [cachedGlobal, invalidateGlobal] = yield* Effect.cachedInvalidateWithTTL(
loadGlobal().pipe(
Effect.tapError((error) =>
Effect.logError("failed to load global config, using defaults", { error: String(error) }),
),
Effect.orElseSucceed((): Info => ({})),
),
Duration.infinity,
)
const getGlobal = Effect.fn("Config.getGlobal")(function* () {
return yield* cachedGlobal
})
const ensureGitignore = Effect.fn("Config.ensureGitignore")(function* (dir: string) {
yield* fs.ensureDir(dir)
const gitignore = path.join(dir, ".gitignore")
const hasIgnore = yield* fs.existsSafe(gitignore)
if (!hasIgnore) {
yield* fs
.writeFileString(
gitignore,
["node_modules", "package.json", "package-lock.json", "bun.lock", ".gitignore"].join("\n"),
)
.pipe(
Effect.catchIf(
(e) => e.reason._tag === "PermissionDenied",
() => Effect.void,
),
)
}
})
const loadInstanceState = Effect.fn("Config.loadInstanceState")(
function* (ctx: InstanceContext) {
const auth = yield* authSvc.all().pipe(Effect.orDie)
let result: Info = {}
const authEnv: Record<string, string> = {}
const consoleManagedProviders = new Set<string>()
let activeOrgName: string | undefined
const pluginScopeForSource = Effect.fnUntraced(function* (source: string) {
if (source.startsWith("http://") || source.startsWith("https://")) return "global"
if (source === "OPENCODE_CONFIG_CONTENT") return "local"
if (containsPath(source, ctx)) return "local"
return "global"
})
const mergePluginOrigins = Effect.fnUntraced(function* (
source: string,
// mergePluginOrigins receives raw Specs from one config source, before provenance for this merge step
// is attached.
list: ConfigPluginV1.Spec[] | undefined,
// Scope can be inferred from the source path, but some callers already know whether the config should
// behave as global or local and can pass that explicitly.
kind?: ConfigPlugin.Scope,
) {
if (!list?.length) return
const hit = kind ?? (yield* pluginScopeForSource(source))
// Merge newly seen plugin origins with previously collected ones, then dedupe by plugin identity while
// keeping the winning source/scope metadata for downstream installs, writes, and diagnostics.
const plugins = ConfigPlugin.deduplicatePluginOrigins([
...(result.plugin_origins ?? []),
...list.map((spec) => ({ spec, source, scope: hit })),
])
result.plugin = plugins.map((item) => item.spec)
result.plugin_origins = plugins
})
const merge = (source: string, next: Info, kind?: ConfigPlugin.Scope) => {
result = mergeConfigConcatArrays(result, next)
return mergePluginOrigins(source, next.plugin, kind)
}
for (const [key, value] of Object.entries(auth)) {
if (value.type === "wellknown") {
const url = key.replace(/\/+$/, "")
authEnv[value.key] = value.token
const wellknownURL = `${url}/.well-known/opencode`
yield* Effect.logDebug("fetching remote config", { url: wellknownURL })
const wellknown = yield* fetchRemoteJson(wellknownURL, undefined, ConfigV1.WellKnown, url)
const remote = yield* Effect.promise(() =>
substituteWellKnownRemoteConfig({
value: wellknown.remote_config,
dir: url,
source: wellknownURL,
env: authEnv,
}),
)
const fetchedConfig = remote
? yield* Effect.gen(function* () {
yield* Effect.logDebug("fetching remote config", { url: remote.url })
const data = yield* fetchRemoteJson(remote.url, remote.headers, Schema.Json, url)
if (isRecord(data) && isRecord(data.config)) return data.config
if (isRecord(data)) return data
return yield* Effect.die(
new Error(`failed to decode remote config from ${remote.url}: expected object`),
)
})
: {}
const remoteConfig = mergeConfig(isRecord(wellknown.config) ? wellknown.config : {}, fetchedConfig)
if (!remoteConfig.$schema) remoteConfig.$schema = "https://opencode.ai/config.json"
const source = wellknownURL
const next = yield* loadConfig(
JSON.stringify(remoteConfig),
{
dir: path.dirname(source),
source,
},
authEnv,
)
yield* merge(source, next, "global")
yield* Effect.logDebug("loaded remote config from well-known", { url })
}
}
const global = Object.keys(authEnv).length ? yield* loadGlobal(authEnv) : yield* getGlobal()
yield* merge(Global.Path.config, global, "global")
if (Flag.OPENCODE_CONFIG) {
yield* merge(Flag.OPENCODE_CONFIG, yield* loadFile(Flag.OPENCODE_CONFIG, authEnv))
yield* Effect.logDebug("loaded custom config", { path: Flag.OPENCODE_CONFIG })
}
if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
for (const file of yield* ConfigPaths.files("opencode", ctx.directory, ctx.worktree).pipe(Effect.orDie)) {
yield* merge(file, yield* loadFile(file, authEnv), "local")
}
}
result.agent = result.agent || {}
result.mode = result.mode || {}
result.plugin = result.plugin || []
const directories = yield* ConfigPaths.directories(ctx.directory, ctx.worktree)
if (Flag.OPENCODE_CONFIG_DIR) {
yield* Effect.logDebug("loading config from OPENCODE_CONFIG_DIR", { path: Flag.OPENCODE_CONFIG_DIR })
}
const deps: Fiber.Fiber<void>[] = []
for (const dir of directories) {
if (dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) {
for (const file of ["opencode.json", "opencode.jsonc"]) {
const source = path.join(dir, file)
yield* Effect.logDebug(`loading config from ${source}`)
yield* merge(source, yield* loadFile(source, authEnv))
result.agent ??= {}
result.mode ??= {}
result.plugin ??= []
}
}
yield* ensureGitignore(dir).pipe(Effect.orDie)
const dep = yield* npmSvc
.install(dir, {
add: [
{
name: "@opencode-ai/plugin",
version: InstallationLocal ? undefined : InstallationVersion,
},
],
})
.pipe(
Effect.exit,
Effect.tap((exit) =>
Exit.isFailure(exit)
? Effect.logWarning("background dependency install failed", { dir, error: String(exit.cause) })
: Effect.void,
),
Effect.asVoid,
Effect.forkDetach,
)
deps.push(dep)
result.command = mergeDeep(result.command ?? {}, yield* Effect.promise(() => ConfigCommand.load(dir)))
result.agent = mergeDeep(result.agent ?? {}, yield* Effect.promise(() => ConfigAgent.load(dir)))
result.agent = mergeDeep(result.agent ?? {}, yield* Effect.promise(() => ConfigAgent.loadMode(dir)))
// Auto-discovered plugins under `.opencode/plugin(s)` are already local files, so ConfigPlugin.load
// returns normalized Specs and we only need to attach origin metadata here.
const list = yield* Effect.promise(() => ConfigPlugin.load(dir))
yield* mergePluginOrigins(dir, list)
}
if (process.env.OPENCODE_CONFIG_CONTENT) {
const source = "OPENCODE_CONFIG_CONTENT"
const next = yield* loadConfig(process.env.OPENCODE_CONFIG_CONTENT, {
dir: ctx.directory,
source,
})
yield* merge(source, next, "local")
yield* Effect.logDebug("loaded custom config from OPENCODE_CONFIG_CONTENT")
}
const activeAccount = Option.getOrUndefined(
yield* accountSvc.active().pipe(Effect.catch(() => Effect.succeed(Option.none()))),
)
if (activeAccount?.active_org_id) {
const accountID = activeAccount.id
const orgID = activeAccount.active_org_id
const url = activeAccount.url
yield* Effect.gen(function* () {
const [configOpt, tokenOpt] = yield* Effect.all(
[accountSvc.config(accountID, orgID), accountSvc.token(accountID)],
{ concurrency: 2 },
)
if (Option.isSome(tokenOpt)) {
yield* env.set("OPENCODE_CONSOLE_TOKEN", tokenOpt.value)
}
if (Option.isSome(configOpt)) {
const source = `${url}/api/config`
const next = yield* loadConfig(JSON.stringify(configOpt.value), {
dir: path.dirname(source),
source,
})
for (const providerID of Object.keys(next.provider ?? {})) {
consoleManagedProviders.add(providerID)
}
yield* merge(source, next, "global")
}
}).pipe(
Effect.withSpan("Config.loadActiveOrgConfig"),
Effect.catch((err) =>
Effect.logDebug("failed to fetch remote account config", {
error: err instanceof Error ? err.message : String(err),
}),
),
)
}
const managedDir = ConfigManaged.managedConfigDir()
if (existsSync(managedDir)) {
for (const file of ["opencode.json", "opencode.jsonc"]) {
const source = path.join(managedDir, file)
yield* merge(source, yield* loadFile(source), "global")
}
}
// macOS managed preferences (.mobileconfig deployed via MDM) override everything
const managed = yield* Effect.promise(() => ConfigManaged.readManagedPreferences())
if (managed) {
result = mergeConfigConcatArrays(
result,
yield* loadConfig(managed.text, {
dir: path.dirname(managed.source),
source: managed.source,
}),
)
}
for (const [name, mode] of Object.entries(result.mode ?? {})) {
result.agent = mergeDeep(result.agent ?? {}, {
[name]: {
...mode,
mode: "primary" as const,
},
})
}
if (Flag.OPENCODE_PERMISSION) {
try {
result.permission = mergeDeep(result.permission ?? {}, JSON.parse(Flag.OPENCODE_PERMISSION))
} catch (err) {
yield* Effect.logWarning("OPENCODE_PERMISSION contains invalid JSON, skipping", { err })
}
}
if (result.tools) {
const perms: Record<string, ConfigPermissionV1.Action> = {}
for (const [tool, enabled] of Object.entries(result.tools)) {
const action: ConfigPermissionV1.Action = enabled ? "allow" : "deny"
if (tool === "write" || tool === "edit" || tool === "patch") {
perms.edit = action
continue
}
perms[tool] = action
}
result.permission = mergeDeep(perms, result.permission ?? {})
}
if (!result.username) {
try {
result.username = os.userInfo().username || "user"
} catch (err) {
yield* Effect.logWarning("failed to read system username, using fallback", { err })
result.username = "user"
}
}
if (result.autoshare === true && !result.share) {
result.share = "auto"
}
if (Flag.OPENCODE_DISABLE_AUTOCOMPACT) {
result.compaction = { ...result.compaction, auto: false }
}
if (Flag.OPENCODE_DISABLE_PRUNE) {
result.compaction = { ...result.compaction, prune: false }
}
return {
config: result,
directories,
deps,
consoleState: {
consoleManagedProviders: Array.from(consoleManagedProviders),
activeOrgName,
switchableOrgCount: 0,
},
}
},
Effect.provideService(FSUtil.Service, fs),
)
const state = yield* InstanceState.make<State>(
Effect.fn("Config.state")(function* (ctx) {
return yield* loadInstanceState(ctx).pipe(Effect.orDie)
}),
)
const get = Effect.fn("Config.get")(function* () {
return yield* InstanceState.use(state, (s) => s.config)
})
const directories = Effect.fn("Config.directories")(function* () {
return yield* InstanceState.use(state, (s) => s.directories)
})
const getConsoleState = Effect.fn("Config.getConsoleState")(function* () {
return yield* InstanceState.use(state, (s) => s.consoleState)
})
const waitForDependencies = Effect.fn("Config.waitForDependencies")(function* () {
yield* InstanceState.useEffect(state, (s) =>
Effect.forEach(s.deps, Fiber.join, { concurrency: "unbounded" }).pipe(Effect.asVoid),
)
})
const update = Effect.fn("Config.update")(function* (config: Info) {
const dir = yield* InstanceState.directory
const file = path.join(dir, "config.json")
const existing = yield* loadFile(file)
yield* fs
.writeFileString(file, JSON.stringify(mergeDeep(writable(existing), writable(config)), null, 2))
.pipe(Effect.orDie)
})
const invalidate = Effect.fn("Config.invalidate")(function* () {
yield* invalidateGlobal
})
const updateGlobal = Effect.fn("Config.updateGlobal")(function* (config: Info) {
const file = globalConfigFile()
const before = (yield* readConfigFile(file)) ?? "{}"
const patch = writableGlobal(config)
let next: Info
let changed: boolean
if (!file.endsWith(".jsonc")) {
const existing = ConfigParse.schema(ConfigV1.Info, ConfigParse.jsonc(before, file), file)
const merged = mergeDeep(writable(existing), patch)
const serialized = JSON.stringify(merged, null, 2)
changed = serialized !== before
if (changed) yield* fs.writeFileString(file, serialized).pipe(Effect.orDie)
next = merged
} else {
const updated = patchJsonc(before, patch)
next = ConfigParse.schema(ConfigV1.Info, ConfigParse.jsonc(updated, file), file)
changed = updated !== before
if (changed) yield* fs.writeFileString(file, updated).pipe(Effect.orDie)
}
if (changed) yield* invalidate()
return { info: next, changed }
})
const get: () => Effect.Effect<Info> = () => Effect.promise(readLocal)
return Service.of({
get,
getGlobal,
getConsoleState,
update,
updateGlobal,
invalidate,
directories,
waitForDependencies,
getGlobal: get,
getConsoleState: () => Effect.succeed(emptyConsole()),
update: (config) =>
Effect.promise(() =>
fsNode
.mkdir(path.dirname(configPath()), { recursive: true })
.then(() => fsNode.writeFile(configPath(), JSON.stringify(config, null, 2)))
.then(() => {
cache = undefined
}),
),
updateGlobal: (config) =>
Effect.map(
Effect.promise(() =>
fsNode
.mkdir(path.dirname(configPath()), { recursive: true })
.then(() => fsNode.writeFile(configPath(), JSON.stringify(config, null, 2)))
.then(() => {
cache = undefined
}),
),
() => ({ info: config, changed: true }),
),
invalidate: () =>
Effect.sync(() => {
cache = undefined
}),
directories: () => Effect.succeed(directories),
waitForDependencies: () => Effect.void,
})
}),
)
export const node = LayerNode.make({
service: Service,
layer: layer,
deps: [FSUtil.node, Auth.node, Account.node, Env.node, Npm.node, httpClient],
})
// Legacy value-style access for call sites not yet on the service.
export const Path = { config: path.join(os.homedir(), ".config", "neuron") }
export const get = Effect.promise(readLocal)
export const getGlobal = get
export * as Config from "./config"
export const node = LayerNode.make({ service: Service, layer, deps: [] })
-79
View File
@@ -1,79 +0,0 @@
import { Glob } from "@opencode-ai/core/util/glob"
import { ConfigPluginV1 } from "@opencode-ai/core/v1/config/plugin"
import { pathToFileURL } from "url"
import { isPathPluginSpec, parsePluginSpecifier, resolvePathPluginTarget } from "@/plugin/shared"
import path from "path"
export type Scope = "global" | "local"
// Origin keeps the original config provenance attached to a spec.
// After multiple config files are merged, callers still need to know which file declared the plugin
// and whether it should behave like a global or project-local plugin.
export type Origin = {
spec: ConfigPluginV1.Spec
source: string
scope: Scope
}
export async function load(dir: string) {
const plugins: ConfigPluginV1.Spec[] = []
for (const item of await Glob.scan("{plugin,plugins}/*.{ts,js}", {
cwd: dir,
absolute: true,
dot: true,
symlink: true,
})) {
plugins.push(pathToFileURL(item).href)
}
return plugins
}
export function pluginSpecifier(plugin: ConfigPluginV1.Spec): string {
return Array.isArray(plugin) ? plugin[0] : plugin
}
export function pluginOptions(plugin: ConfigPluginV1.Spec): ConfigPluginV1.Options | undefined {
return Array.isArray(plugin) ? plugin[1] : undefined
}
// Path-like specs are resolved relative to the config file that declared them so merges later on do not
// accidentally reinterpret `./plugin.ts` relative to some other directory.
export async function resolvePluginSpec(
plugin: ConfigPluginV1.Spec,
configFilepath: string,
): Promise<ConfigPluginV1.Spec> {
const spec = pluginSpecifier(plugin)
if (!isPathPluginSpec(spec)) return plugin
const base = path.dirname(configFilepath)
const file = (() => {
if (spec.startsWith("file://")) return spec
if (path.isAbsolute(spec) || /^[A-Za-z]:[\\/]/.test(spec)) return pathToFileURL(spec).href
return pathToFileURL(path.resolve(base, spec)).href
})()
const resolved = await resolvePathPluginTarget(file).catch(() => file)
if (Array.isArray(plugin)) return [resolved, plugin[1]]
return resolved
}
// Dedupe on the load identity (package name for npm specs, exact file URL for local specs), but keep the
// full Origin so downstream code still knows which config file won and where follow-up writes should go.
export function deduplicatePluginOrigins(plugins: Origin[]): Origin[] {
const seen = new Set<string>()
const list: Origin[] = []
for (const plugin of plugins.toReversed()) {
const spec = pluginSpecifier(plugin.spec)
const name = spec.startsWith("file://") ? spec : parsePluginSpecifier(spec).pkg
if (seen.has(name)) continue
seen.add(name)
list.push(plugin)
}
return list.toReversed()
}
export * as ConfigPlugin from "./plugin"
+7 -66
View File
@@ -14,13 +14,9 @@ import { isRecord } from "@opencode-ai/tui/util/record"
import { Global } from "@opencode-ai/core/global"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { CurrentWorkingDirectory } from "./tui-cwd"
import { ConfigPlugin } from "@/config/plugin"
import { TuiKeybind } from "@opencode-ai/tui/config/keybind"
import { InstallationLocal, InstallationVersion } from "@opencode-ai/core/installation/version"
import { makeRuntime } from "@opencode-ai/core/effect/runtime"
import { Filesystem } from "@/util/filesystem"
import { ConfigVariable } from "@/config/variable"
import { Npm } from "@opencode-ai/core/npm"
import { FormatError, FormatUnknownError } from "@/cli/error"
import { TuiConfig } from "@opencode-ai/tui/config"
@@ -29,29 +25,20 @@ export type Info = TuiConfig.Info
type Acc = {
result: Info
plugin_origins: ConfigPlugin.Origin[]
}
export type Resolved = TuiConfig.Resolved
export type HostMetadata = {
plugin_origins?: ConfigPlugin.Origin[]
}
export type HostMetadata = {}
export interface Interface {
readonly get: () => Effect.Effect<Resolved>
readonly pluginOrigins: () => Effect.Effect<ConfigPlugin.Origin[]>
readonly pluginOrigins: () => Effect.Effect<never[]>
readonly waitForDependencies: () => Effect.Effect<void>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/TuiConfig") {}
function pluginScope(file: string, ctx: { directory: string }): ConfigPlugin.Scope {
if (Filesystem.contains(ctx.directory, file)) return "local"
// if (ctx.worktree !== "/" && Filesystem.contains(ctx.worktree, file)) return "local"
return "global"
}
function normalize(raw: Record<string, unknown>) {
const data = { ...raw }
if (!("tui" in data)) return data
@@ -84,18 +71,6 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory:
const afs = yield* FSUtil.Service
let appliedOrder = 0
const resolvePlugins = (config: Info, configFilepath: string): Effect.Effect<Info> =>
Effect.gen(function* () {
const plugins = config.plugin
if (!plugins) return config
return {
...config,
plugin: yield* Effect.forEach(plugins, (plugin) =>
Effect.promise(() => ConfigPlugin.resolvePluginSpec(plugin as ConfigPlugin.Origin["spec"], configFilepath)),
),
}
})
const load = (text: string, configFilepath: string): Effect.Effect<Info> =>
Effect.gen(function* () {
const expanded = yield* Effect.promise(() =>
@@ -116,7 +91,7 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory:
},
}
: parsed
return yield* resolvePlugins(validated, configFilepath)
return validated
}).pipe(
// catchCause (not tapErrorCause + orElseSucceed) because JSONC parsing and validation
// can sync-throw — those become defects, which orElseSucceed wouldn't catch.
@@ -154,18 +129,6 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory:
yield* Effect.logInfo("applying tui config", { path: file, order: appliedOrder })
}
acc.result = mergeDeep(acc.result, data)
if (!data.plugin?.length) return
const scope = pluginScope(file, ctx)
const plugins = ConfigPlugin.deduplicatePluginOrigins([
...acc.plugin_origins,
...data.plugin.map((spec) => ({ spec: spec as ConfigPlugin.Origin["spec"], scope, source: file })),
])
acc.result = {
...acc.result,
plugin: plugins.map((item) => item.spec),
}
acc.plugin_origins = plugins
})
// Every config dir we may read from: global config dir, any `.opencode`
@@ -177,7 +140,6 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory:
const acc: Acc = {
result: {},
plugin_origins: [],
}
// 1. Global tui config (lowest precedence).
@@ -220,8 +182,7 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory:
return {
config: result,
pluginOrigins: acc.plugin_origins,
dirs: result.plugin?.length ? dirs : [],
dirs: [],
}
})
@@ -229,37 +190,17 @@ const layer = Layer.effect(
Service,
Effect.gen(function* () {
const directory = yield* CurrentWorkingDirectory
const npm = yield* Npm.Service
const data = yield* loadState({ directory })
const deps = yield* Effect.forEach(
data.dirs,
(dir) =>
npm
.install(dir, {
add: [
{
name: "@opencode-ai/plugin",
version: InstallationLocal ? undefined : InstallationVersion,
},
],
})
.pipe(Effect.forkScoped),
{
concurrency: "unbounded",
},
)
const get = Effect.fn("TuiConfig.get")(() => Effect.succeed(data.config))
const pluginOrigins = Effect.fn("TuiConfig.pluginOrigins")(() => Effect.succeed(data.pluginOrigins))
const pluginOrigins = Effect.fn("TuiConfig.pluginOrigins")(() => Effect.succeed([] as never[]))
const waitForDependencies = Effect.fn("TuiConfig.waitForDependencies")(() =>
Effect.forEach(deps, Fiber.join, { concurrency: "unbounded" }).pipe(Effect.ignore(), Effect.asVoid),
)
const waitForDependencies = Effect.fn("TuiConfig.waitForDependencies")(() => Effect.void)
return Service.of({ get, pluginOrigins, waitForDependencies })
}).pipe(Effect.withSpan("TuiConfig.layer")),
)
export const node = LayerNode.make({ service: Service, layer, deps: [Npm.node, FSUtil.node] })
export const node = LayerNode.make({ service: Service, layer, deps: [FSUtil.node] })
const { runPromise } = makeRuntime(Service, AppNodeBuilder.build(node))
@@ -11,13 +11,11 @@ import { Git } from "@/git"
import { Ripgrep } from "@opencode-ai/core/ripgrep"
import { Storage } from "@/storage/storage"
import { Snapshot } from "@/snapshot"
import { Plugin } from "@/plugin"
import { ModelsDev } from "@opencode-ai/core/models-dev"
import { Provider } from "@/provider/provider"
import { ProviderAuth } from "@/provider/auth"
import { Agent } from "@/agent/agent"
import { Skill } from "@/skill"
import { Discovery } from "@/skill/discovery"
import { Question } from "@/question"
import { Permission } from "@/permission"
import { Todo } from "@/session/todo"
@@ -66,13 +64,11 @@ export const AppLayer = AppNodeBuilderV1.build(
Git.node,
Storage.node,
Snapshot.node,
Plugin.node,
ModelsDev.node,
Provider.node,
ProviderAuth.node,
Agent.node,
Skill.node,
Discovery.node,
Question.node,
Permission.node,
Todo.node,
@@ -2,7 +2,6 @@ import { Layer, ManagedRuntime } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { Plugin } from "@/plugin"
import { LSP } from "@/lsp/lsp"
import { Format } from "@/format"
import { ShareNext } from "@/share/share-next"
@@ -13,7 +12,7 @@ import * as Observability from "@opencode-ai/core/observability"
import { memoMap } from "@opencode-ai/core/effect/memo-map"
export const BootstrapLayer = AppNodeBuilder.build(
LayerNode.group([Config.node, Plugin.node, ShareNext.node, Format.node, LSP.node, Vcs.node, Snapshot.node]),
LayerNode.group([Config.node, ShareNext.node, Format.node, LSP.node, Vcs.node, Snapshot.node]),
).pipe(Layer.provide(Observability.layer))
export const BootstrapRuntime = ManagedRuntime.make(BootstrapLayer, { memoMap })
+7 -2
View File
@@ -29,7 +29,6 @@ import { PrCommand } from "./cli/cmd/pr"
import { SessionCommand } from "./cli/cmd/session"
import { DbCommand } from "./cli/cmd/db"
import { errorMessage } from "./util/error"
import { PluginCommand } from "./cli/cmd/plug"
import { Heap } from "./cli/heap"
const args = hideBin(process.argv)
@@ -100,7 +99,6 @@ const cli = yargs(args)
.command(ImportCommand)
.command(PrCommand)
.command(SessionCommand)
.command(PluginCommand)
.command(DbCommand)
.fail((msg, err) => {
if (
@@ -132,6 +130,13 @@ try {
if (formatted === undefined) {
UI.error("Unexpected error" + EOL)
process.stderr.write(errorMessage(e) + EOL)
// FULL defect disclosure - no more silent swallowing
const util = await import("node:util")
process.stderr.write(
"\n=== FULL ERROR DUMP ===\n"
+ util.inspect(e, { depth: 12, showHidden: true, maxArrayLength: 50 })
+ "\n=== END DUMP ===\n",
)
}
process.exitCode = 1
} finally {
-307
View File
@@ -1,307 +0,0 @@
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import type {
Hooks,
PluginInput,
Plugin as PluginInstance,
PluginModule,
WorkspaceAdapter as PluginWorkspaceAdapter,
} from "@opencode-ai/plugin"
import { Config } from "@/config/config"
import { createOpencodeClient } from "@opencode-ai/sdk"
import { ServerAuth } from "@/server/auth"
import { CodexAuthPlugin } from "./openai/codex"
import { Session } from "@/session/session"
import { NamedError } from "@opencode-ai/core/util/error"
import { gitlabAuthPlugin as GitlabAuthPlugin } from "opencode-gitlab-auth"
import { PoeAuthPlugin } from "opencode-poe-auth"
import { DigitalOceanAuthPlugin } from "./digitalocean"
import { XaiAuthPlugin } from "./xai"
import { CerebrasPlugin } from "./cerebras"
import { Effect, Layer, Context } from "effect"
import { EffectBridge } from "@/effect/bridge"
import { InstanceState } from "@/effect/instance-state"
import { errorMessage } from "@/util/error"
import { PluginLoader } from "./loader"
import { parsePluginSpecifier, readPluginId, readV1Plugin, resolvePluginId } from "./shared"
import { registerAdapter } from "@/control-plane/adapters"
import type { WorkspaceAdapter } from "@/control-plane/types"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { EventV2Bridge } from "@/event-v2-bridge"
import { InstallationChannel } from "@opencode-ai/core/installation/version"
type State = {
hooks: Hooks[]
}
// Hook names that follow the (input, output) => Promise<void> trigger pattern
type TriggerName = {
[K in keyof Hooks]-?: NonNullable<Hooks[K]> extends (input: any, output: any) => Promise<void> ? K : never
}[keyof Hooks]
export interface Interface {
readonly trigger: <
Name extends TriggerName,
Input = Parameters<Required<Hooks>[Name]>[0],
Output = Parameters<Required<Hooks>[Name]>[1],
>(
name: Name,
input: Input,
output: Output,
) => Effect.Effect<Output>
readonly list: () => Effect.Effect<Hooks[]>
readonly init: () => Effect.Effect<void>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Plugin") {}
export function experimentalWebSocketsEnabled(input: { enabled: boolean; channel?: string }) {
return input.enabled || ["local", "dev", "beta"].includes(input.channel ?? InstallationChannel)
}
// Built-in plugins that are directly imported (not installed from npm)
function internalPlugins(flags: RuntimeFlags.Info): PluginInstance[] {
return [
// Temporary rollout: pre-release builds use WebSockets by default; releases require explicit opt-in.
(input) =>
CodexAuthPlugin(input, {
experimentalWebSockets: experimentalWebSocketsEnabled({ enabled: flags.experimentalWebSockets }),
}),
GitlabAuthPlugin,
PoeAuthPlugin,
DigitalOceanAuthPlugin,
XaiAuthPlugin,
CerebrasPlugin,
]
}
function isServerPlugin(value: unknown): value is PluginInstance {
return typeof value === "function"
}
function getServerPlugin(value: unknown) {
if (isServerPlugin(value)) return value
if (!value || typeof value !== "object" || !("server" in value)) return
if (!isServerPlugin(value.server)) return
return value.server
}
function getLegacyPlugins(mod: Record<string, unknown>) {
const seen = new Set<unknown>()
const result: PluginInstance[] = []
for (const entry of Object.values(mod)) {
if (seen.has(entry)) continue
seen.add(entry)
const plugin = getServerPlugin(entry)
if (!plugin) throw new TypeError("Plugin export is not a function")
result.push(plugin)
}
return result
}
async function applyPlugin(load: PluginLoader.Loaded, input: PluginInput, hooks: Hooks[]) {
const plugin = readV1Plugin(load.mod, load.spec, "server", "detect")
if (plugin) {
await resolvePluginId(load.source, load.spec, load.target, readPluginId(plugin.id, load.spec), load.pkg)
hooks.push(await (plugin as PluginModule).server(input, load.options))
return
}
for (const server of getLegacyPlugins(load.mod)) {
hooks.push(await server(input, load.options))
}
}
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const events = yield* EventV2Bridge.Service
const config = yield* Config.Service
const flags = yield* RuntimeFlags.Service
const state = yield* InstanceState.make<State>(
Effect.fn("Plugin.state")(function* (ctx) {
const hooks: Hooks[] = []
const bridge = yield* EffectBridge.make()
function publishPluginError(message: string) {
bridge.fork(events.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() }))
}
const { Server } = yield* Effect.promise(() => import("../server/server"))
const serverUrl = Server.url
const client = createOpencodeClient({
baseUrl: serverUrl?.toString() ?? "http://localhost:4096",
directory: ctx.directory,
headers: ServerAuth.headers(),
...(serverUrl ? {} : { fetch: async (...args) => Server.Default().app.fetch(...args) }),
})
const cfg = yield* config.get()
const input: PluginInput = {
client,
project: ctx.project,
worktree: ctx.worktree,
directory: ctx.directory,
experimental_workspace: {
register(type: string, adapter: PluginWorkspaceAdapter) {
registerAdapter(ctx.project.id, type, adapter as WorkspaceAdapter)
},
},
get serverUrl(): URL {
return Server.url ?? new URL("http://localhost:4096")
},
// @ts-expect-error
$: typeof Bun === "undefined" ? undefined : Bun.$,
}
for (const plugin of flags.disableDefaultPlugins ? [] : internalPlugins(flags)) {
const init = yield* Effect.tryPromise({
try: () => plugin(input),
catch: errorMessage,
}).pipe(
Effect.tapError((error) => Effect.logError("failed to load internal plugin", { name: plugin.name, error })),
Effect.option,
)
if (init._tag === "Some") hooks.push(init.value)
}
const plugins = flags.pure ? [] : (cfg.plugin_origins ?? [])
if (flags.pure && cfg.plugin_origins?.length) {
}
if (plugins.length) yield* config.waitForDependencies()
const loaded = yield* Effect.promise(() =>
PluginLoader.loadExternal({
items: plugins,
kind: "server",
report: {
start(candidate) {},
missing(candidate, _retry, message) {},
error(candidate, _retry, stage, error, resolved) {
const spec = candidate.plan.spec
const cause = error instanceof Error ? (error.cause ?? error) : error
const message = stage === "load" ? errorMessage(error) : errorMessage(cause)
if (stage === "install") {
const parsed = parsePluginSpecifier(spec)
publishPluginError(`Failed to install plugin ${parsed.pkg}@${parsed.version}: ${message}`)
return
}
if (stage === "compatibility") {
publishPluginError(`Plugin ${spec} skipped: ${message}`)
return
}
if (stage === "entry") {
publishPluginError(`Failed to load plugin ${spec}: ${message}`)
return
}
publishPluginError(`Failed to load plugin ${spec}: ${message}`)
},
},
}),
)
for (const load of loaded) {
if (!load) continue
// Keep plugin execution sequential so hook registration and execution
// order remains deterministic across plugin runs.
yield* Effect.tryPromise({
try: () => applyPlugin(load, input, hooks),
catch: (err) => {
const message = errorMessage(err)
return message
},
}).pipe(
Effect.tapError((error) => Effect.logError("failed to load plugin", { path: load.spec, error })),
Effect.catch(() => {
// TODO: make proper events for this
// events.publish(Session.Event.Error, {
// error: new NamedError.Unknown({
// message: `Failed to load plugin ${load.spec}: ${message}`,
// }).toObject(),
// })
return Effect.void
}),
)
}
// Notify plugins of current config
for (const hook of hooks) {
yield* Effect.tryPromise({
try: () => Promise.resolve((hook as any).config?.(cfg)),
catch: errorMessage,
}).pipe(
Effect.tapError((error) => Effect.logError("plugin config hook failed", { error })),
Effect.ignore,
)
}
const unsubscribe = yield* events.listen((event) => {
if (event.location?.directory !== ctx.directory) return Effect.void
return Effect.sync(() => {
for (const hook of hooks) {
void hook["event"]?.({ event: { id: event.id, type: event.type, properties: event.data } as any })
}
})
})
yield* Effect.addFinalizer(() => unsubscribe)
yield* Effect.addFinalizer(() =>
Effect.forEach(
hooks,
(hook) =>
Effect.tryPromise({
try: () => Promise.resolve(hook.dispose?.()),
catch: errorMessage,
}).pipe(
Effect.tapError((error) => Effect.logError("plugin dispose hook failed", { error })),
Effect.ignore,
),
{ discard: true },
),
)
return { hooks }
}),
)
const trigger = Effect.fn("Plugin.trigger")(function* <
Name extends TriggerName,
Input = Parameters<Required<Hooks>[Name]>[0],
Output = Parameters<Required<Hooks>[Name]>[1],
>(name: Name, input: Input, output: Output) {
if (!name) return output
const s = yield* InstanceState.get(state)
for (const hook of s.hooks) {
const fn = hook[name] as any
if (!fn) continue
yield* Effect.promise(async () => fn(input, output))
}
return output
})
const list = Effect.fn("Plugin.list")(function* () {
const s = yield* InstanceState.get(state)
return s.hooks
})
const init = Effect.fn("Plugin.init")(function* () {
yield* InstanceState.get(state)
})
return Service.of({ trigger, list, init })
}),
)
export const node = LayerNode.make({
service: Service,
layer: layer,
deps: [EventV2Bridge.node, Config.node, RuntimeFlags.node],
})
export * as Plugin from "."
-439
View File
@@ -1,439 +0,0 @@
import path from "path"
import {
type ParseError as JsoncParseError,
applyEdits,
modify,
parse as parseJsonc,
printParseErrorCode,
} from "jsonc-parser"
import * as ConfigPaths from "@/config/paths"
import { Global } from "@opencode-ai/core/global"
import { Filesystem } from "@/util/filesystem"
import { Flock } from "@opencode-ai/core/util/flock"
import { isRecord } from "@/util/record"
import { parsePluginSpecifier, readPackageThemes, readPluginPackage, resolvePluginTarget } from "./shared"
type Mode = "noop" | "add" | "replace"
type Kind = "server" | "tui"
export type Target = {
kind: Kind
opts?: Record<string, unknown>
}
export type InstallDeps = {
resolve: (spec: string) => Promise<string>
}
export type PatchDeps = {
readText: (file: string) => Promise<string>
write: (file: string, text: string) => Promise<void>
exists: (file: string) => Promise<boolean>
files: (dir: string, name: "opencode" | "tui") => string[]
}
export type PatchInput = {
spec: string
targets: Target[]
force?: boolean
global?: boolean
vcs?: string
worktree: string
directory: string
config?: string
}
type Ok<T> = {
ok: true
} & T
type Err<C extends string, T> = {
ok: false
code: C
} & T
export type InstallResult = Ok<{ target: string }> | Err<"install_failed", { error: unknown }>
export type ManifestResult =
| Ok<{ targets: Target[] }>
| Err<"manifest_read_failed", { file: string; error: unknown }>
| Err<"manifest_no_targets", { file: string }>
export type PatchItem = {
kind: Kind
mode: Mode
file: string
}
type PatchErr =
| Err<"invalid_json", { kind: Kind; file: string; line: number; col: number; parse: string }>
| Err<"patch_failed", { kind: Kind; error: unknown }>
type PatchOne = Ok<{ item: PatchItem }> | PatchErr
export type PatchResult = Ok<{ dir: string; items: PatchItem[] }> | (PatchErr & { dir: string })
const defaultInstallDeps: InstallDeps = {
resolve: (spec) => resolvePluginTarget(spec),
}
const defaultPatchDeps: PatchDeps = {
readText: (file) => Filesystem.readText(file),
write: async (file, text) => {
await Filesystem.write(file, text)
},
exists: (file) => Filesystem.exists(file),
files: (dir, name) => ConfigPaths.fileInDirectory(dir, name),
}
function pluginSpec(item: unknown) {
if (typeof item === "string") return item
if (!Array.isArray(item)) return
if (typeof item[0] !== "string") return
return item[0]
}
function pluginList(data: unknown) {
if (!data || typeof data !== "object" || Array.isArray(data)) return
const item = data as { plugin?: unknown }
if (!Array.isArray(item.plugin)) return
return item.plugin
}
function exportValue(value: unknown): string | undefined {
if (typeof value === "string") {
const next = value.trim()
if (next) return next
return
}
if (!isRecord(value)) return
for (const key of ["import", "default"]) {
const next = value[key]
if (typeof next !== "string") continue
const hit = next.trim()
if (!hit) continue
return hit
}
}
function exportOptions(value: unknown): Record<string, unknown> | undefined {
if (!isRecord(value)) return
const config = value.config
if (!isRecord(config)) return
return config
}
function exportTarget(pkg: Record<string, unknown>, kind: Kind) {
const exports = pkg.exports
if (!isRecord(exports)) return
const value = exports[`./${kind}`]
const entry = exportValue(value)
if (!entry) return
return {
opts: exportOptions(value),
}
}
function hasMainTarget(pkg: Record<string, unknown>) {
const main = pkg.main
if (typeof main !== "string") return false
return Boolean(main.trim())
}
function packageTargets(pkg: { json: Record<string, unknown>; dir: string; pkg: string }) {
const spec =
typeof pkg.json.name === "string" && pkg.json.name.trim().length > 0 ? pkg.json.name.trim() : path.basename(pkg.dir)
const targets: Target[] = []
const server = exportTarget(pkg.json, "server")
if (server) {
targets.push({ kind: "server", opts: server.opts })
} else if (hasMainTarget(pkg.json)) {
targets.push({ kind: "server" })
}
const tui = exportTarget(pkg.json, "tui")
if (tui) {
targets.push({ kind: "tui", opts: tui.opts })
}
if (!targets.some((item) => item.kind === "tui") && readPackageThemes(spec, pkg).length) {
targets.push({ kind: "tui" })
}
return targets
}
function patch(text: string, path: Array<string | number>, value: unknown, insert = false) {
return applyEdits(
text,
modify(text, path, value, {
formattingOptions: {
tabSize: 2,
insertSpaces: true,
},
isArrayInsertion: insert,
}),
)
}
function patchPluginList(
text: string,
list: unknown[] | undefined,
spec: string,
next: unknown,
force = false,
): { mode: Mode; text: string } {
const pkg = parsePluginSpecifier(spec).pkg
const rows = (list ?? []).map((item, i) => ({
item,
i,
spec: pluginSpec(item),
}))
const dup = rows.filter((item) => {
if (!item.spec) return false
if (item.spec === spec) return true
if (item.spec.startsWith("file://")) return false
return parsePluginSpecifier(item.spec).pkg === pkg
})
if (!dup.length) {
if (!list) {
return {
mode: "add",
text: patch(text, ["plugin"], [next]),
}
}
return {
mode: "add",
text: patch(text, ["plugin", list.length], next, true),
}
}
if (!force) {
return {
mode: "noop",
text,
}
}
const keep = dup[0]
if (!keep) {
return {
mode: "noop",
text,
}
}
if (dup.length === 1 && keep.spec === spec) {
return {
mode: "noop",
text,
}
}
let out = text
if (typeof keep.item === "string") {
out = patch(out, ["plugin", keep.i], next)
}
if (Array.isArray(keep.item) && typeof keep.item[0] === "string") {
out = patch(out, ["plugin", keep.i, 0], spec)
}
const del = dup
.map((item) => item.i)
.filter((i) => i !== keep.i)
.sort((a, b) => b - a)
for (const i of del) {
out = patch(out, ["plugin", i], undefined)
}
return {
mode: "replace",
text: out,
}
}
export async function installPlugin(spec: string, dep: InstallDeps = defaultInstallDeps): Promise<InstallResult> {
const target = await dep.resolve(spec).then(
(item) => ({
ok: true as const,
item,
}),
(error: unknown) => ({
ok: false as const,
error,
}),
)
if (!target.ok) {
return {
ok: false,
code: "install_failed",
error: target.error,
}
}
return {
ok: true,
target: target.item,
}
}
export async function readPluginManifest(target: string): Promise<ManifestResult> {
const pkg = await readPluginPackage(target).then(
(item) => ({
ok: true as const,
item,
}),
(error: unknown) => ({
ok: false as const,
error,
}),
)
if (!pkg.ok) {
return {
ok: false,
code: "manifest_read_failed",
file: target,
error: pkg.error,
}
}
const targets = await Promise.resolve()
.then(() => packageTargets(pkg.item))
.then(
(item) => ({ ok: true as const, item }),
(error: unknown) => ({ ok: false as const, error }),
)
if (!targets.ok) {
return {
ok: false,
code: "manifest_read_failed",
file: pkg.item.pkg,
error: targets.error,
}
}
if (!targets.item.length) {
return {
ok: false,
code: "manifest_no_targets",
file: pkg.item.pkg,
}
}
return {
ok: true,
targets: targets.item,
}
}
function patchDir(input: PatchInput) {
if (input.global) return input.config ?? Global.Path.config
const git = input.vcs === "git" && input.worktree !== "/"
const root = git ? input.worktree : input.directory
return path.join(root, ".opencode")
}
function patchName(kind: Kind): "opencode" | "tui" {
if (kind === "server") return "opencode"
return "tui"
}
async function patchOne(dir: string, target: Target, spec: string, force: boolean, dep: PatchDeps): Promise<PatchOne> {
const name = patchName(target.kind)
await using _ = await Flock.acquire(`plug-config:${Filesystem.resolve(path.join(dir, name))}`)
const files = dep.files(dir, name)
let cfg = files[0]
for (const file of files) {
if (!(await dep.exists(file))) continue
cfg = file
break
}
const src = await dep.readText(cfg).catch((err: NodeJS.ErrnoException) => {
if (err.code === "ENOENT") return "{}"
return err
})
if (src instanceof Error) {
return {
ok: false,
code: "patch_failed",
kind: target.kind,
error: src,
}
}
const text = src.trim() ? src : "{}"
const errs: JsoncParseError[] = []
const data = parseJsonc(text, errs, { allowTrailingComma: true })
if (errs.length) {
const err = errs[0]
const lines = text.substring(0, err.offset).split("\n")
return {
ok: false,
code: "invalid_json",
kind: target.kind,
file: cfg,
line: lines.length,
col: lines[lines.length - 1].length + 1,
parse: printParseErrorCode(err.error),
}
}
const list = pluginList(data)
const item = target.opts ? ([spec, target.opts] as const) : spec
const out = patchPluginList(text, list, spec, item, force)
if (out.mode === "noop") {
return {
ok: true,
item: {
kind: target.kind,
mode: out.mode,
file: cfg,
},
}
}
const write = await dep.write(cfg, out.text).catch((error: unknown) => error)
if (write instanceof Error) {
return {
ok: false,
code: "patch_failed",
kind: target.kind,
error: write,
}
}
return {
ok: true,
item: {
kind: target.kind,
mode: out.mode,
file: cfg,
},
}
}
export async function patchPluginConfig(input: PatchInput, dep: PatchDeps = defaultPatchDeps): Promise<PatchResult> {
const dir = patchDir(input)
const items: PatchItem[] = []
for (const target of input.targets) {
const hit = await patchOne(dir, target, input.spec, Boolean(input.force), dep)
if (!hit.ok) {
return {
...hit,
dir,
}
}
items.push(hit.item)
}
return {
ok: true,
dir,
items,
}
}
-237
View File
@@ -1,237 +0,0 @@
import {
checkPluginCompatibility,
createPluginEntry,
isDeprecatedPlugin,
pluginSource,
resolvePluginTarget,
type PluginKind,
type PluginPackage,
type PluginSource,
} from "./shared"
import { ConfigPlugin } from "@/config/plugin"
import { ConfigPluginV1 } from "@opencode-ai/core/v1/config/plugin"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
export namespace PluginLoader {
// A normalized plugin declaration derived from config before any filesystem or npm work happens.
export type Plan = {
spec: string
options: ConfigPluginV1.Options | undefined
deprecated: boolean
}
// A plugin that has been resolved to a concrete target and entrypoint on disk.
export type Resolved = Plan & {
source: PluginSource
target: string
entry: string
pkg?: PluginPackage
}
// A plugin target we could inspect, but which does not expose the requested kind of entrypoint.
export type Missing = Plan & {
source: PluginSource
target: string
pkg?: PluginPackage
message: string
}
// A resolved plugin whose module has been imported successfully.
export type Loaded = Resolved & {
mod: Record<string, unknown>
}
type Candidate = { origin: ConfigPlugin.Origin; plan: Plan }
type Report = {
// Called before each attempt so callers can log initial load attempts and retries uniformly.
start?: (candidate: Candidate, retry: boolean) => void
// Called when the package exists but does not provide the requested entrypoint.
missing?: (candidate: Candidate, retry: boolean, message: string, resolved: Missing) => void
// Called for operational failures such as install, compatibility, or dynamic import errors.
error?: (
candidate: Candidate,
retry: boolean,
stage: "install" | "entry" | "compatibility" | "load",
error: unknown,
resolved?: Resolved,
) => void
}
type AttemptResult<R> = {
value?: R
retry: boolean
}
function errorMessage(error: unknown) {
if (!error || typeof error !== "object") return ""
const message = "message" in error && typeof error.message === "string" ? error.message : ""
return message
}
function isRetryableResolveError(stage: "install" | "entry" | "compatibility", error: unknown) {
if (stage !== "install") return false
return errorMessage(error).includes("missing package.json or index file")
}
// Normalize a config item into the loader's internal representation.
function plan(item: ConfigPluginV1.Spec): Plan {
const spec = ConfigPlugin.pluginSpecifier(item)
return { spec, options: ConfigPlugin.pluginOptions(item), deprecated: isDeprecatedPlugin(spec) }
}
// Resolve a configured plugin into a concrete entrypoint that can later be imported.
//
// The stages here intentionally separate install/target resolution, entrypoint detection,
// and compatibility checks so callers can report the exact reason a plugin was skipped.
export async function resolve(
plan: Plan,
kind: PluginKind,
): Promise<
| { ok: true; value: Resolved }
| { ok: false; stage: "missing"; value: Missing }
| { ok: false; stage: "install" | "entry" | "compatibility"; error: unknown }
> {
// First make sure the plugin exists locally, installing npm plugins on demand.
let target = ""
try {
target = await resolvePluginTarget(plan.spec)
} catch (error) {
return { ok: false, stage: "install", error }
}
if (!target) return { ok: false, stage: "install", error: new Error(`Plugin ${plan.spec} target is empty`) }
// Then inspect the target for the requested server/tui entrypoint.
let base
try {
base = await createPluginEntry(plan.spec, target, kind)
} catch (error) {
return { ok: false, stage: "entry", error }
}
if (!base.entry)
return {
ok: false,
stage: "missing",
value: {
...plan,
source: base.source,
target: base.target,
pkg: base.pkg,
message: `Plugin ${plan.spec} does not expose a ${kind} entrypoint`,
},
}
// npm plugins can declare which opencode versions they support; file plugins are treated
// as local development code and skip this compatibility gate.
if (base.source === "npm") {
try {
await checkPluginCompatibility(base.target, InstallationVersion, base.pkg)
} catch (error) {
return { ok: false, stage: "compatibility", error }
}
}
return { ok: true, value: { ...plan, source: base.source, target: base.target, entry: base.entry, pkg: base.pkg } }
}
// Import the resolved module only after all earlier validation has succeeded.
export async function load(row: Resolved): Promise<{ ok: true; value: Loaded } | { ok: false; error: unknown }> {
let mod
try {
mod = await import(row.entry)
} catch (error) {
return { ok: false, error }
}
if (!mod) return { ok: false, error: new Error(`Plugin ${row.spec} module is empty`) }
return { ok: true, value: { ...row, mod } }
}
// Run one candidate through the full pipeline: resolve, optionally surface a missing entry,
// import the module, and finally let the caller transform the loaded plugin into any result type.
async function attempt<R>(
candidate: Candidate,
kind: PluginKind,
retry: boolean,
finish: ((load: Loaded, origin: ConfigPlugin.Origin, retry: boolean) => Promise<R | undefined>) | undefined,
missing: ((value: Missing, origin: ConfigPlugin.Origin, retry: boolean) => Promise<R | undefined>) | undefined,
report: Report | undefined,
): Promise<AttemptResult<R>> {
const plan = candidate.plan
const filePlugin = pluginSource(plan.spec) === "file"
// Deprecated plugin packages are silently ignored because they are now built in.
if (plan.deprecated) return { retry: false }
report?.start?.(candidate, retry)
const resolved = await resolve(plan, kind)
if (!resolved.ok) {
if (resolved.stage === "missing") {
// Missing entrypoints are handled separately so callers can still inspect package metadata,
// for example to load theme files from a tui plugin package that has no code entrypoint.
if (missing) {
const value = await missing(resolved.value, candidate.origin, retry)
if (value !== undefined) return { value, retry: false }
}
report?.missing?.(candidate, retry, resolved.value.message, resolved.value)
return { retry: false }
}
report?.error?.(candidate, retry, resolved.stage, resolved.error)
return { retry: filePlugin && isRetryableResolveError(resolved.stage, resolved.error) }
}
const loaded = await load(resolved.value)
if (!loaded.ok) {
report?.error?.(candidate, retry, "load", loaded.error, resolved.value)
return { retry: false }
}
// The default behavior is to return the successfully loaded plugin as-is, but callers can
// provide a finisher to adapt the result into a more specific runtime shape.
if (!finish) return { value: loaded.value as R, retry: false }
const value = await finish(loaded.value, candidate.origin, retry)
return { value, retry: false }
}
type Input<R> = {
items: ConfigPlugin.Origin[]
kind: PluginKind
wait?: () => Promise<void>
finish?: (load: Loaded, origin: ConfigPlugin.Origin, retry: boolean) => Promise<R | undefined>
missing?: (value: Missing, origin: ConfigPlugin.Origin, retry: boolean) => Promise<R | undefined>
report?: Report
}
// Resolve and load all configured plugins in parallel.
//
// If `wait` is provided, file-based plugins with retryable pre-import setup failures are retried
// once after the caller finishes preparing dependencies. Once dynamic import runs, failures are
// treated as permanent for this process because Bun caches failed module resolution.
export async function loadExternal<R = Loaded>(input: Input<R>): Promise<R[]> {
const candidates = input.items.map((origin) => ({ origin, plan: plan(origin.spec) }))
const list: Array<Promise<AttemptResult<R>>> = []
for (const candidate of candidates) {
list.push(attempt(candidate, input.kind, false, input.finish, input.missing, input.report))
}
const out = await Promise.all(list)
if (input.wait) {
let deps: Promise<void> | undefined
for (let i = 0; i < candidates.length; i++) {
const previous = out[i]
if (previous?.value !== undefined) continue
if (previous?.retry !== true) continue
// Only pre-import file plugin setup failures are retried. Bun caches failed dynamic imports,
// so dependency waiting cannot fix load/build/runtime/shape failures in this process.
const candidate = candidates[i]
if (!candidate || pluginSource(candidate.plan.spec) !== "file") continue
deps ??= input.wait()
await deps
out[i] = await attempt(candidate, input.kind, true, input.finish, input.missing, input.report)
}
}
// Drop skipped/failed entries while preserving the successful result order.
const ready: R[] = []
for (const item of out) if (item.value !== undefined) ready.push(item.value)
return ready
}
}
-188
View File
@@ -1,188 +0,0 @@
import path from "path"
import { fileURLToPath } from "url"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Global } from "@opencode-ai/core/global"
import { Filesystem } from "@/util/filesystem"
import { Flock } from "@opencode-ai/core/util/flock"
import { parsePluginSpecifier, pluginSource } from "./shared"
type Source = "file" | "npm"
export type Theme = {
src: string
dest: string
mtime?: number
size?: number
}
export type Entry = {
id: string
source: Source
spec: string
target: string
requested?: string
version?: string
modified?: number
first_time: number
last_time: number
time_changed: number
load_count: number
fingerprint: string
themes?: Record<string, Theme>
}
export type State = "first" | "updated" | "same"
export type Touch = {
spec: string
target: string
id: string
}
type Store = Record<string, Entry>
type Core = Omit<Entry, "first_time" | "last_time" | "time_changed" | "load_count" | "fingerprint" | "themes">
type Row = Touch & { core: Core }
function storePath() {
return Flag.OPENCODE_PLUGIN_META_FILE ?? path.join(Global.Path.state, "plugin-meta.json")
}
function lock(file: string) {
return `plugin-meta:${file}`
}
function fileTarget(spec: string, target: string) {
if (spec.startsWith("file://")) return fileURLToPath(spec)
if (target.startsWith("file://")) return fileURLToPath(target)
return
}
async function modifiedAt(file: string) {
const stat = await Filesystem.statAsync(file)
if (!stat) return
const mtime = stat.mtimeMs
return Math.floor(typeof mtime === "bigint" ? Number(mtime) : mtime)
}
function resolvedTarget(target: string) {
if (target.startsWith("file://")) return fileURLToPath(target)
return target
}
async function npmVersion(target: string) {
const resolved = resolvedTarget(target)
const stat = await Filesystem.statAsync(resolved)
const dir = stat?.isDirectory() ? resolved : path.dirname(resolved)
return Filesystem.readJson<{ version?: string }>(path.join(dir, "package.json"))
.then((item) => item.version)
.catch(() => undefined)
}
async function entryCore(item: Touch): Promise<Core> {
const spec = item.spec
const target = item.target
const source = pluginSource(spec)
if (source === "file") {
const file = fileTarget(spec, target)
return {
id: item.id,
source,
spec,
target,
modified: file ? await modifiedAt(file) : undefined,
}
}
return {
id: item.id,
source,
spec,
target,
requested: parsePluginSpecifier(spec).version,
version: await npmVersion(target),
}
}
function fingerprint(value: Core) {
if (value.source === "file") return [value.target, value.modified ?? ""].join("|")
return [value.target, value.requested ?? "", value.version ?? ""].join("|")
}
async function read(file: string): Promise<Store> {
return Filesystem.readJson<Store>(file).catch(() => ({}) as Store)
}
async function row(item: Touch): Promise<Row> {
return {
...item,
core: await entryCore(item),
}
}
function next(prev: Entry | undefined, core: Core, now: number): { state: State; entry: Entry } {
const entry: Entry = {
...core,
first_time: prev?.first_time ?? now,
last_time: now,
time_changed: prev?.time_changed ?? now,
load_count: (prev?.load_count ?? 0) + 1,
fingerprint: fingerprint(core),
themes: prev?.themes,
}
const state: State = !prev ? "first" : prev.fingerprint === entry.fingerprint ? "same" : "updated"
if (state === "updated") entry.time_changed = now
return {
state,
entry,
}
}
export async function touchMany(items: Touch[]): Promise<Array<{ state: State; entry: Entry }>> {
if (!items.length) return []
const file = storePath()
const rows = await Promise.all(items.map((item) => row(item)))
return Flock.withLock(lock(file), async () => {
const store = await read(file)
const now = Date.now()
const out: Array<{ state: State; entry: Entry }> = []
for (const item of rows) {
const hit = next(store[item.id], item.core, now)
store[item.id] = hit.entry
out.push(hit)
}
await Filesystem.writeJson(file, store)
return out
})
}
export async function touch(spec: string, target: string, id: string): Promise<{ state: State; entry: Entry }> {
return touchMany([{ spec, target, id }]).then((item) => {
const hit = item[0]
if (hit) return hit
throw new Error("Failed to touch plugin metadata.")
})
}
export async function setTheme(id: string, name: string, theme: Theme): Promise<void> {
const file = storePath()
await Flock.withLock(lock(file), async () => {
const store = await read(file)
const entry = store[id]
if (!entry) return
entry.themes = {
...entry.themes,
[name]: theme,
}
await Filesystem.writeJson(file, store)
})
}
export async function list(): Promise<Store> {
const file = storePath()
return Flock.withLock(lock(file), async () => read(file))
}
export * as PluginMeta from "./meta"
+8 -3
View File
@@ -161,6 +161,12 @@ interface PendingOAuth {
let oauthServer: ReturnType<typeof createServer> | undefined
let pendingOAuth: PendingOAuth | undefined
// Module-level because request preparation (outside this module) needs to know
// whether the WebSocket transport is active, e.g. to mark title generation for
// HTTP fallback.
const websocketState = { fetchInstalled: false }
export const webSocketFetchInstalled = () => websocketState.fetchInstalled
async function startOAuthServer(): Promise<{ port: number; redirectUri: string }> {
if (oauthServer) {
return { port: OAUTH_PORT, redirectUri: `http://localhost:${OAUTH_PORT}/auth/callback` }
@@ -273,7 +279,6 @@ function waitForOAuthCallback(pkce: PkceCodes, state: string): Promise<TokenResp
export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPluginOptions = {}): Promise<Hooks> {
const issuer = options.issuer ?? ISSUER
const codexApiEndpoint = options.codexApiEndpoint ?? CODEX_API_ENDPOINT
let websocketFetchInstalled = false
const websocketFetches: Array<ReturnType<typeof OpenAIWebSocketPool.createWebSocketFetch>> = []
return {
@@ -331,7 +336,7 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug
: undefined
if (websocketFetch) {
websocketFetches.push(websocketFetch)
websocketFetchInstalled = true
websocketState.fetchInstalled = true
}
if (auth.type !== "oauth") return websocketFetch ? { fetch: websocketFetch } : {}
@@ -561,7 +566,7 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug
// Temporary fetch-layer hack: title generation currently shares the conversation
// session ID, so the OpenAI plugin marks it for HTTP fallback until transport
// context can be passed directly instead of smuggled through headers.
if (websocketFetchInstalled && input.agent === "title") output.headers[OpenAIWebSocketPool.TITLE_HEADER] = "true"
if (websocketState.fetchInstalled && input.agent === "title") output.headers[OpenAIWebSocketPool.TITLE_HEADER] = "true"
},
"chat.params": async (input, output) => {
if (input.model.providerID !== "openai") return
@@ -3,21 +3,16 @@ export * as PluginPtyEnvironment from "./pty-environment"
import { PtyEnvironment } from "@opencode-ai/server/pty-environment"
import { Effect, Layer } from "effect"
import { InstanceStore } from "@/project/instance-store"
import { Plugin } from "."
// PTY processes inherit the server's environment; there is no hook surface
// for mutating it anymore.
export const layer = Layer.effect(
PtyEnvironment.Service,
Effect.gen(function* () {
const plugin = yield* Plugin.Service
const instances = yield* InstanceStore.Service
return PtyEnvironment.Service.of({
get: Effect.fn("PtyEnvironment.get")(function* (input) {
return yield* instances.provide(
{ directory: input.directory },
plugin
.trigger("shell.env", { cwd: input.cwd }, { env: {} as Record<string, string> })
.pipe(Effect.map((result) => result.env)),
)
return yield* instances.provide({ directory: input.directory }, Effect.succeed({}))
}),
})
}),
-323
View File
@@ -1,323 +0,0 @@
import path from "path"
import { fileURLToPath, pathToFileURL } from "url"
import npa from "npm-package-arg"
import semver from "semver"
import { Filesystem } from "@/util/filesystem"
import { isRecord } from "@/util/record"
import { Npm } from "@opencode-ai/core/npm"
// Old npm package names for plugins that are now built-in
export const DEPRECATED_PLUGIN_PACKAGES = ["opencode-openai-codex-auth", "opencode-copilot-auth"]
export function isDeprecatedPlugin(spec: string) {
return DEPRECATED_PLUGIN_PACKAGES.some((pkg) => spec.includes(pkg))
}
function parse(spec: string) {
try {
return npa(spec)
} catch {}
}
export function parsePluginSpecifier(spec: string) {
const hit = parse(spec)
if (hit?.type === "alias" && !hit.name) {
const sub = (hit as npa.AliasResult).subSpec
if (sub?.name) {
const version = !sub.rawSpec || sub.rawSpec === "*" ? "latest" : sub.rawSpec
return { pkg: sub.name, version }
}
}
if (!hit?.name) return { pkg: spec, version: "" }
if (hit.raw === hit.name) return { pkg: hit.name, version: "latest" }
return { pkg: hit.name, version: hit.rawSpec }
}
export type PluginSource = "file" | "npm"
export type PluginKind = "server" | "tui"
type PluginMode = "strict" | "detect"
export type PluginPackage = {
dir: string
pkg: string
json: Record<string, unknown>
}
export type PluginEntry = {
spec: string
source: PluginSource
target: string
pkg?: PluginPackage
entry?: string
}
const INDEX_FILES = ["index.ts", "index.tsx", "index.js", "index.mjs", "index.cjs"]
export function pluginSource(spec: string): PluginSource {
if (isPathPluginSpec(spec)) return "file"
return "npm"
}
function resolveExportPath(raw: string, dir: string) {
if (raw.startsWith("file://")) return fileURLToPath(raw)
if (path.isAbsolute(raw)) return raw
return path.resolve(dir, raw)
}
function isAbsolutePath(raw: string) {
return path.isAbsolute(raw) || /^[A-Za-z]:[\\/]/.test(raw)
}
function extractExportValue(value: unknown): string | undefined {
if (typeof value === "string") return value
if (!isRecord(value)) return undefined
for (const key of ["import", "default"]) {
const nested = value[key]
if (typeof nested === "string") return nested
}
return undefined
}
function packageMain(pkg: PluginPackage) {
const value = pkg.json.main
if (typeof value !== "string") return
const next = value.trim()
if (!next) return
return next
}
function resolvePackageFile(spec: string, raw: string, kind: string, pkg: PluginPackage) {
const resolved = resolveExportPath(raw, pkg.dir)
const root = Filesystem.resolve(pkg.dir)
const next = Filesystem.resolve(resolved)
if (!Filesystem.contains(root, next)) {
throw new Error(`Plugin ${spec} resolved ${kind} entry outside plugin directory`)
}
return next
}
function resolvePackagePath(spec: string, raw: string, kind: PluginKind, pkg: PluginPackage) {
return pathToFileURL(resolvePackageFile(spec, raw, kind, pkg)).href
}
function resolvePackageEntrypoint(spec: string, kind: PluginKind, pkg: PluginPackage) {
const exports = pkg.json.exports
if (isRecord(exports)) {
const raw = extractExportValue(exports[`./${kind}`])
if (raw) return resolvePackagePath(spec, raw, kind, pkg)
}
if (kind !== "server") return
const main = packageMain(pkg)
if (!main) return
return resolvePackagePath(spec, main, kind, pkg)
}
function targetPath(target: string) {
if (target.startsWith("file://")) return fileURLToPath(target)
if (path.isAbsolute(target)) return target
}
async function resolveDirectoryIndex(dir: string) {
for (const name of INDEX_FILES) {
const file = path.join(dir, name)
if (await Filesystem.exists(file)) return file
}
}
async function resolveTargetDirectory(target: string) {
const file = targetPath(target)
if (!file) return
const stat = await Filesystem.statAsync(file)
if (!stat?.isDirectory()) return
return file
}
async function resolvePluginEntrypoint(spec: string, target: string, kind: PluginKind, pkg?: PluginPackage) {
const source = pluginSource(spec)
const hit =
pkg ?? (source === "npm" ? await readPluginPackage(target) : await readPluginPackage(target).catch(() => undefined))
if (!hit) return target
const entry = resolvePackageEntrypoint(spec, kind, hit)
if (entry) return entry
const dir = await resolveTargetDirectory(target)
if (kind === "tui") {
if (source === "file" && dir) {
const index = await resolveDirectoryIndex(dir)
if (index) return pathToFileURL(index).href
}
if (source === "npm") return
if (dir) return
return target
}
if (dir && isRecord(hit.json.exports)) {
if (source === "file") {
const index = await resolveDirectoryIndex(dir)
if (index) return pathToFileURL(index).href
}
return
}
return target
}
export function isPathPluginSpec(spec: string) {
return spec.startsWith("file://") || spec.startsWith(".") || isAbsolutePath(spec)
}
export async function resolvePathPluginTarget(spec: string) {
const raw = spec.startsWith("file://") ? fileURLToPath(spec) : spec
const file = path.isAbsolute(raw) || /^[A-Za-z]:[\\/]/.test(raw) ? raw : path.resolve(raw)
const stat = await Filesystem.statAsync(file)
if (!stat?.isDirectory()) {
if (spec.startsWith("file://")) return spec
return pathToFileURL(file).href
}
if (await Filesystem.exists(path.join(file, "package.json"))) {
return pathToFileURL(file).href
}
const index = await resolveDirectoryIndex(file)
if (index) return pathToFileURL(index).href
throw new Error(`Plugin directory ${file} is missing package.json or index file`)
}
export async function checkPluginCompatibility(target: string, opencodeVersion: string, pkg?: PluginPackage) {
if (!semver.valid(opencodeVersion) || semver.major(opencodeVersion) === 0) return
const hit = pkg ?? (await readPluginPackage(target).catch(() => undefined))
if (!hit) return
const engines = hit.json.engines
if (!isRecord(engines)) return
const range = engines.opencode
if (typeof range !== "string") return
if (!semver.satisfies(opencodeVersion, range)) {
throw new Error(`Plugin requires opencode ${range} but running ${opencodeVersion}`)
}
}
export async function resolvePluginTarget(spec: string) {
if (isPathPluginSpec(spec)) return resolvePathPluginTarget(spec)
const hit = parse(spec)
const pkg = hit?.name && hit.raw === hit.name ? `${hit.name}@latest` : spec
const result = await Npm.add(pkg)
return result.directory
}
export async function readPluginPackage(target: string): Promise<PluginPackage> {
const file = target.startsWith("file://") ? fileURLToPath(target) : target
const stat = await Filesystem.statAsync(file)
const dir = stat?.isDirectory() ? file : path.dirname(file)
const pkg = path.join(dir, "package.json")
const json = await Filesystem.readJson<Record<string, unknown>>(pkg)
return { dir, pkg, json }
}
export async function createPluginEntry(spec: string, target: string, kind: PluginKind): Promise<PluginEntry> {
const source = pluginSource(spec)
const pkg =
source === "npm" ? await readPluginPackage(target) : await readPluginPackage(target).catch(() => undefined)
const entry = await resolvePluginEntrypoint(spec, target, kind, pkg)
return {
spec,
source,
target,
pkg,
entry,
}
}
export function readPackageThemes(spec: string, pkg: PluginPackage) {
const field = pkg.json["oc-themes"]
if (field === undefined) return []
if (!Array.isArray(field)) {
throw new TypeError(`Plugin ${spec} has invalid oc-themes field`)
}
const list = field.map((item) => {
if (typeof item !== "string") {
throw new TypeError(`Plugin ${spec} has invalid oc-themes entry`)
}
const raw = item.trim()
if (!raw) {
throw new TypeError(`Plugin ${spec} has empty oc-themes entry`)
}
if (raw.startsWith("file://") || isAbsolutePath(raw)) {
throw new TypeError(`Plugin ${spec} oc-themes entry must be relative: ${item}`)
}
return resolvePackageFile(spec, raw, "oc-themes", pkg)
})
return Array.from(new Set(list))
}
export function readPluginId(id: unknown, spec: string) {
if (id === undefined) return
if (typeof id !== "string") throw new TypeError(`Plugin ${spec} has invalid id type ${typeof id}`)
const value = id.trim()
if (!value) throw new TypeError(`Plugin ${spec} has an empty id`)
return value
}
export function readV1Plugin(
mod: Record<string, unknown>,
spec: string,
kind: PluginKind,
mode: PluginMode = "strict",
) {
const value = mod.default
if (!isRecord(value)) {
if (mode === "detect") return
throw new TypeError(`Plugin ${spec} must default export an object with ${kind}()`)
}
if (mode === "detect" && !("id" in value) && !("server" in value) && !("tui" in value)) return
const server = "server" in value ? value.server : undefined
const tui = "tui" in value ? value.tui : undefined
if (server !== undefined && typeof server !== "function") {
throw new TypeError(`Plugin ${spec} has invalid server export`)
}
if (tui !== undefined && typeof tui !== "function") {
throw new TypeError(`Plugin ${spec} has invalid tui export`)
}
if (server !== undefined && tui !== undefined) {
throw new TypeError(`Plugin ${spec} must default export either server() or tui(), not both`)
}
if (kind === "server" && server === undefined) {
throw new TypeError(`Plugin ${spec} must default export an object with server()`)
}
if (kind === "tui" && tui === undefined) {
throw new TypeError(`Plugin ${spec} must default export an object with tui()`)
}
return value
}
export async function resolvePluginId(
source: PluginSource,
spec: string,
target: string,
id: string | undefined,
pkg?: PluginPackage,
) {
if (source === "file") {
if (id) return id
throw new TypeError(`Path plugin ${spec} must export id`)
}
if (id) return id
const hit = pkg ?? (await readPluginPackage(target))
if (typeof hit.json.name !== "string" || !hit.json.name.trim()) {
throw new TypeError(`Plugin package ${hit.pkg} is missing name`)
}
return hit.json.name.trim()
}
File diff suppressed because it is too large Load Diff
+1 -5
View File
@@ -1,5 +1,4 @@
import { makeGlobalNode } from "@opencode-ai/core/effect/app-node"
import { Plugin } from "../plugin"
import { Format } from "../format"
import { LSP } from "@/lsp/lsp"
import { Snapshot } from "../snapshot"
@@ -23,7 +22,6 @@ const layer = Layer.effect(
const config = yield* Config.Service
const format = yield* Format.Service
const lsp = yield* LSP.Service
const plugin = yield* Plugin.Service
const project = yield* Project.Service
const shareNext = yield* ShareNext.Service
const snapshot = yield* Snapshot.Service
@@ -34,8 +32,6 @@ const layer = Layer.effect(
yield* Effect.logInfo("bootstrapping", { directory: ctx.directory })
// everything depends on config so eager load it for nice traces
yield* config.get()
// Plugin can mutate config so it has to be initialized before anything else.
yield* plugin.init()
// Each service self-manages its own slow work via Effect.forkScoped against
// its per-instance state scope. We just await materialization here.
yield* Effect.forEach(
@@ -52,7 +48,7 @@ const layer = Layer.effect(
export const node = makeGlobalNode({
service: Service,
layer: layer,
deps: [Config.node, Format.node, LSP.node, Plugin.node, Project.node, ShareNext.node, Snapshot.node, Vcs.node],
deps: [Config.node, Format.node, LSP.node, Project.node, ShareNext.node, Snapshot.node, Vcs.node],
})
export * as InstanceBootstrap from "./bootstrap"
+14 -6
View File
@@ -4,7 +4,8 @@ import { serviceUse } from "@opencode-ai/core/effect/service-use"
import { Auth } from "@/auth"
import { InstanceState } from "@/effect/instance-state"
import { optional } from "@opencode-ai/core/schema"
import { Plugin } from "../plugin"
import { authProviderHooks } from "./hooks"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { Array as Arr, Effect, Layer, Record, Result, Context, Schema } from "effect"
@@ -106,14 +107,21 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Pr
export const use = serviceUse(Service)
const layer: Layer.Layer<Service, never, Auth.Service | Plugin.Service> = Layer.effect(
const layer: Layer.Layer<Service, never, Auth.Service | RuntimeFlags.Service> = Layer.effect(
Service,
Effect.gen(function* () {
const auth = yield* Auth.Service
const plugin = yield* Plugin.Service
const flags = yield* RuntimeFlags.Service
const state = yield* InstanceState.make<State>(
Effect.fn("ProviderAuth.state")(function* () {
const plugins = yield* plugin.list()
Effect.fn("ProviderAuth.state")(function* (ctx) {
const plugins = yield* Effect.promise(() =>
authProviderHooks({
directory: ctx.directory,
project: ctx.project,
worktree: ctx.worktree,
experimentalWebSockets: flags.experimentalWebSockets,
}),
)
return {
hooks: Record.fromEntries(
Arr.filterMap(plugins, (x) =>
@@ -224,6 +232,6 @@ const layer: Layer.Layer<Service, never, Auth.Service | Plugin.Service> = Layer.
}),
)
export const node = LayerNode.make({ service: Service, layer: layer, deps: [Auth.node, Plugin.node] })
export const node = LayerNode.make({ service: Service, layer: layer, deps: [Auth.node, RuntimeFlags.node] })
export * as ProviderAuth from "./auth"
+74
View File
@@ -0,0 +1,74 @@
import type { Hooks, PluginInput } from "@opencode-ai/plugin"
import { createOpencodeClient } from "@opencode-ai/sdk"
import { InstallationChannel } from "@opencode-ai/core/installation/version"
import { Effect } from "effect"
import { ServerAuth } from "@/server/auth"
import { InstanceState } from "@/effect/instance-state"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { CodexAuthPlugin } from "@/plugin/openai/codex"
import { gitlabAuthPlugin as GitlabAuthPlugin } from "opencode-gitlab-auth"
import { PoeAuthPlugin } from "opencode-poe-auth"
import { DigitalOceanAuthPlugin } from "@/plugin/digitalocean"
import { XaiAuthPlugin } from "@/plugin/xai"
import { CerebrasPlugin } from "@/plugin/cerebras"
// Static first-party auth providers. There is no plugin loader: this list IS
// the extension surface for provider authentication. Nothing user-configurable
// participates.
export async function authProviderHooks(input: {
directory: string
project: PluginInput["project"]
worktree: string
experimentalWebSockets: boolean
}): Promise<Hooks[]> {
const { Server } = await import("@/server/server")
const serverUrl = Server.url
const client = createOpencodeClient({
baseUrl: serverUrl?.toString() ?? "http://localhost:4096",
directory: input.directory,
headers: ServerAuth.headers(),
...(serverUrl ? {} : { fetch: async (...args) => Server.Default().app.fetch(...args) }),
})
const pluginInput: PluginInput = {
client,
project: input.project,
worktree: input.worktree,
directory: input.directory,
serverUrl: serverUrl ?? new URL("http://localhost:4096"),
// @ts-expect-error
$: typeof Bun === "undefined" ? undefined : Bun.$,
}
// Pre-release builds default WebSockets on; releases require explicit opt-in.
const experimentalWebSockets =
input.experimentalWebSockets || ["local", "dev", "beta"].includes(InstallationChannel)
const results = await Promise.allSettled([
CodexAuthPlugin(pluginInput, { experimentalWebSockets }),
GitlabAuthPlugin(pluginInput),
PoeAuthPlugin(pluginInput),
DigitalOceanAuthPlugin(pluginInput),
XaiAuthPlugin(pluginInput),
CerebrasPlugin(pluginInput),
])
return results.flatMap((result) => (result.status === "fulfilled" ? [result.value] : []))
}
// Effect-context variant for call sites already inside the instance runtime.
export const hooks = Effect.gen(function* () {
const ctx = yield* InstanceState.context
const flags = yield* RuntimeFlags.Service
return yield* Effect.promise(() =>
authProviderHooks({
directory: ctx.directory,
project: ctx.project,
worktree: ctx.worktree,
experimentalWebSockets: flags.experimentalWebSockets,
}),
)
})
export * as AuthProviders from "./hooks"
+12 -19
View File
@@ -7,7 +7,6 @@ import { mapValues, mergeDeep, omit, pickBy, sortBy } from "remeda"
import { NoSuchModelError, type Provider as SDK } from "ai"
import { Npm } from "@opencode-ai/core/npm"
import { Hash } from "@opencode-ai/core/util/hash"
import { Plugin } from "../plugin"
import { serviceUse } from "@opencode-ai/core/effect/service-use"
import { type LanguageModelV3 } from "@ai-sdk/provider"
import { ModelsDev } from "@opencode-ai/core/models-dev"
@@ -26,6 +25,7 @@ import { FSUtil } from "@opencode-ai/core/fs-util"
import { isRecord } from "@/util/record"
import { optional } from "@opencode-ai/core/schema"
import { ProviderTransform } from "./transform"
import { authProviderHooks } from "./hooks"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
import { ModelStatus } from "./model-status"
@@ -853,11 +853,10 @@ const layer = Layer.effect(
const config = yield* Config.Service
const auth = yield* Auth.Service
const env = yield* Env.Service
const plugin = yield* Plugin.Service
const modelsDevSvc = yield* ModelsDev.Service
const runtimeFlags = yield* RuntimeFlags.Service
const state = yield* InstanceState.make<State>(() =>
const state = yield* InstanceState.make<State>((ctx) =>
Effect.gen(function* () {
const bridge = yield* EffectBridge.make()
const cfg = yield* config.get()
@@ -898,8 +897,15 @@ const layer = Layer.effect(
providers[providerID] = mergeDeep(match, provider)
}
// load plugins first so config() hook runs before reading cfg.provider
const plugins = yield* plugin.list()
// First-party auth/provider hooks (static registry; no external plugins).
const plugins = yield* Effect.promise(() =>
authProviderHooks({
directory: ctx.directory,
project: ctx.project,
worktree: ctx.worktree,
experimentalWebSockets: runtimeFlags.experimentalWebSockets,
}),
)
// now read config providers - includes any modifications from plugin config() hook
const configProviders = Object.entries(cfg.provider ?? {})
@@ -1412,19 +1418,6 @@ const layer = Layer.effect(
const provider = s.providers[providerID]
if (!provider) return undefined
const experimental = yield* plugin.trigger<"experimental.provider.small_model">(
"experimental.provider.small_model",
{ provider: toPublicInfo(provider) },
{ model: undefined },
)
if (experimental.model) {
return {
...experimental.model,
id: ModelV2.ID.make(experimental.model.id),
providerID: ProviderV2.ID.make(experimental.model.providerID),
}
}
// TODO: Remove these provider-specific assumptions once model syncing reliably reports available deployments.
if (providerID === ProviderV2.ID.azure || providerID === ProviderV2.ID.make("azure-cognitive-services")) {
return undefined
@@ -1526,7 +1519,7 @@ export function parseModel(model: string) {
export const node = LayerNode.make({
service: Service,
layer: layer,
deps: [FSUtil.node, Config.node, Auth.node, Env.node, Plugin.node, ModelsDev.node, RuntimeFlags.node],
deps: [FSUtil.node, Config.node, Auth.node, Env.node, ModelsDev.node, RuntimeFlags.node],
})
export * as Provider from "./provider"
@@ -1,7 +1,6 @@
import * as InstanceState from "@/effect/instance-state"
import { registerDisposer } from "@/effect/instance-registry"
import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref"
import { Plugin } from "@/plugin"
import { Pty } from "@opencode-ai/core/pty"
import { PtyProtocol } from "@opencode-ai/core/pty/protocol"
import { PtyID } from "@opencode-ai/core/pty/schema"
@@ -42,7 +41,6 @@ export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handler
Effect.gen(function* () {
const tickets = yield* PtyTicket.Service
const cors = yield* CorsConfig
const plugin = yield* Plugin.Service
const locations = yield* LocationServiceMap.Service
const unregister = registerDisposer((directory) =>
Effect.runPromise(locations.invalidate(Location.Ref.make({ directory: AbsolutePath.make(directory) }))),
@@ -68,14 +66,13 @@ export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handler
const create = Effect.fn("PtyHttpApi.create")(function* (ctx: { payload: typeof Pty.CreateInput.Type }) {
const cwd = ctx.payload.cwd || (yield* InstanceState.context).directory
const shell = yield* plugin.trigger("shell.env", { cwd }, { env: {} as Record<string, string> })
return yield* pty(
Pty.Service.use((service) =>
service.create({
...ctx.payload,
args: ctx.payload.args ? [...ctx.payload.args] : undefined,
cwd,
env: { ...ctx.payload.env, ...shell.env },
env: { ...ctx.payload.env },
}),
),
)
@@ -20,7 +20,6 @@ import { LSP } from "@/lsp/lsp"
import { MCP } from "@/mcp"
import { McpAuth } from "@/mcp/auth"
import { Permission } from "@/permission"
import { Plugin } from "@/plugin"
import { PluginPtyEnvironment } from "@/plugin/pty-environment"
import { InstanceStore } from "@/project/instance-store"
import { Project } from "@/project/project"
@@ -42,7 +41,6 @@ import { Todo } from "@/session/todo"
import { SessionShare } from "@/share/session"
import { ShareNext } from "@/share/share-next"
import { Skill } from "@/skill"
import { Discovery } from "@/skill/discovery"
import { Snapshot } from "@/snapshot"
import { Storage } from "@/storage/storage"
import { ToolRegistry } from "@/tool/registry"
@@ -219,13 +217,11 @@ const app = LayerNode.group([
Ripgrep.node,
Storage.node,
Snapshot.node,
Plugin.node,
ModelsDev.node,
Provider.node,
ProviderAuth.node,
Agent.node,
Skill.node,
Discovery.node,
Question.node,
Permission.node,
PermissionSaved.node,
+35 -81
View File
@@ -8,7 +8,6 @@ import { MessageV2 } from "./message-v2"
import { Token } from "@/util/token"
import { SessionProcessor } from "./processor"
import { Agent } from "@/agent/agent"
import { Plugin } from "@/plugin"
import { Config } from "@/config/config"
import { NotFoundError } from "@/storage/storage"
@@ -194,8 +193,7 @@ const layer = Layer.effect(
const config = yield* Config.Service
const session = yield* Session.Service
const agents = yield* Agent.Service
const plugin = yield* Plugin.Service
const processors = yield* SessionProcessor.Service
const processors = yield* SessionProcessor.Service
const provider = yield* Provider.Service
const events = yield* EventV2Bridge.Service
const flags = yield* RuntimeFlags.Service
@@ -381,26 +379,11 @@ const layer = Layer.effect(
cfg,
model,
})
// Allow plugins to inject context or replace compaction prompt.
const compacting = yield* plugin.trigger(
"experimental.session.compacting",
{ sessionID: input.sessionID },
{ context: [], prompt: undefined },
)
const msgs = structuredClone(selected.head)
yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs })
const conversation = msgs.map(serialize).filter(Boolean).join("\n\n")
const nextPrompt =
compacting.prompt ??
[
buildPrompt({
previousSummary,
context: [conversation],
}),
...compacting.context,
]
.filter(Boolean)
.join("\n\n")
const conversation = selected.head.map(serialize).filter(Boolean).join("\n\n")
const nextPrompt = buildPrompt({
previousSummary,
context: [conversation],
})
const ctx = yield* InstanceState.context
const msg: SessionV1.Assistant = {
id: MessageID.ascending(),
@@ -446,12 +429,7 @@ const layer = Layer.effect(
content: [
{
type: "text",
text: [
nextPrompt,
...(compacting.prompt ? ["The following is the conversation history:", conversation] : []),
]
.filter(Boolean)
.join("\n\n"),
text: nextPrompt,
},
],
},
@@ -507,57 +485,34 @@ const layer = Layer.effect(
}
if (!replay) {
const info = yield* provider.getProvider(userMessage.model.providerID)
if (
(yield* plugin.trigger(
"experimental.compaction.autocontinue",
{
sessionID: input.sessionID,
agent: userMessage.agent,
model: yield* provider
.getModel(userMessage.model.providerID, userMessage.model.modelID)
.pipe(Effect.orDie),
provider: {
source: info.source,
info,
options: info.options,
},
message: userMessage,
overflow: input.overflow === true,
},
{ enabled: true },
)).enabled
) {
const continueMsg = yield* session.updateMessage({
id: MessageID.ascending(),
role: "user",
sessionID: input.sessionID,
time: { created: Date.now() },
agent: userMessage.agent,
model: userMessage.model,
})
const text =
(input.overflow
? "The previous request exceeded the provider's size limit due to large media attachments. The conversation was compacted and media files were removed from context. If the user was asking about attached images or files, explain that the attachments were too large to process and suggest they try again with smaller or fewer files.\n\n"
: "") +
"Continue if you have next steps, or stop and ask for clarification if you are unsure how to proceed."
yield* session.updatePart({
id: PartID.ascending(),
messageID: continueMsg.id,
sessionID: input.sessionID,
type: "text",
// Internal marker for auto-compaction followups so provider plugins
// can distinguish them from manual post-compaction user prompts.
// This is not a stable plugin contract and may change or disappear.
metadata: { compaction_continue: true },
synthetic: true,
text,
time: {
start: Date.now(),
end: Date.now(),
},
})
}
const continueMsg = yield* session.updateMessage({
id: MessageID.ascending(),
role: "user",
sessionID: input.sessionID,
time: { created: Date.now() },
agent: userMessage.agent,
model: userMessage.model,
})
const text =
(input.overflow
? "The previous request exceeded the provider's size limit due to large media attachments. The conversation was compacted and media files were removed from context. If the user was asking about attached images or files, explain that the attachments were too large to process and suggest they try again with smaller or fewer files.\n\n"
: "") +
"Continue if you have next steps, or stop and ask for clarification if you are unsure how to proceed."
yield* session.updatePart({
id: PartID.ascending(),
messageID: continueMsg.id,
sessionID: input.sessionID,
type: "text",
// Internal marker for auto-compaction followups so callers can
// distinguish them from manual post-compaction user prompts.
metadata: { compaction_continue: true },
synthetic: true,
text,
time: {
start: Date.now(),
end: Date.now(),
},
})
}
}
@@ -609,7 +564,6 @@ export const node = LayerNode.make({
Config.node,
Session.node,
Agent.node,
Plugin.node,
SessionProcessor.node,
Provider.node,
EventV2Bridge.node,
+182
View File
@@ -0,0 +1,182 @@
export * as SessionContext from "./context"
import path from "path"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { Global } from "@opencode-ai/core/global"
import { Flag } from "@opencode-ai/core/flag/flag"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Database } from "@opencode-ai/core/database/database"
import { serviceUse } from "@opencode-ai/core/effect/service-use"
import type { ModelMessage } from "ai"
import { Context, Effect, Layer } from "effect"
import { InstanceState } from "@/effect/instance-state"
import { Config } from "@/config/config"
import type { ConfigV1 } from "@opencode-ai/core/v1/config/config"
import { Permission } from "@/permission"
import type { Agent } from "@/agent/agent"
import type { Provider } from "@/provider/provider"
import { Skill } from "@/skill"
import { MessageV2 } from "./message-v2"
import type { SessionID } from "./schema"
import PROMPT_NEURON from "./prompt/neuron.txt"
/**
* The single assembly point for everything sent to a provider.
*
* Every byte entering model context originates here, and every block carries
* provenance. If text reaches a provider without passing through this module,
* that is the bug. Remote sources are structurally absent: instructions enter
* from disk the user controls or not at all.
*/
export type Source = "base-prompt" | "environment" | "instructions" | "skills"
export interface Block {
readonly source: Source
/** Where non-generated content came from (file path). */
readonly origin?: string
readonly text: string
}
const INSTRUCTION_FILES = ["AGENTS.md", "CLAUDE.md", "CONTEXT.md"]
// Boot-time instruction files: global config dir first match, then project
// walk-up first match. Local files only. Every fallible lookup is caught:
// missing files yield nothing, never failures.
const collectPaths = Effect.fn("SessionContext.collectPaths")(function* (input: {
fs: FSUtil.Interface
global: Global.Interface
config: ConfigV1.Info
directory: string
worktree: string
}) {
const paths = new Set<string>()
for (const file of [
path.join(input.global.config, "AGENTS.md"),
path.join(input.global.home, ".claude", "CLAUDE.md"),
]) {
if (!(yield* input.fs.existsSafe(file))) continue
paths.add(path.resolve(file))
break
}
if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
for (const file of INSTRUCTION_FILES) {
const matches = yield* input.fs
.findUp(file, input.directory, input.worktree)
.pipe(Effect.catch(() => Effect.succeed([])))
matches.forEach((item) => paths.add(path.resolve(item)))
if (matches.length > 0) break
}
}
// config.instructions accepts local paths and globs only. URLs are ignored;
// remote text never becomes model instructions.
for (const raw of input.config.instructions ?? []) {
if (raw.startsWith("https://") || raw.startsWith("http://")) continue
const target = raw.startsWith("~/") ? path.join(input.global.home, raw.slice(2)) : raw
const glob = path.isAbsolute(target)
? input.fs.glob(path.basename(target), { cwd: path.dirname(target), absolute: true, include: "file" })
: input.fs.globUp(target, input.directory, input.worktree)
const matches = yield* glob.pipe(Effect.catch(() => Effect.succeed([] as string[])))
matches.forEach((item) => paths.add(path.resolve(item)))
}
return Array.from(paths)
})
const readAll = (fs: FSUtil.Interface, paths: string[]) =>
Effect.forEach(
paths,
(p) => fs.readFileString(p).pipe(Effect.catch(() => Effect.succeed(""))),
{ concurrency: 8 },
)
export interface Interface {
/** Every system-context block, provenance-tagged, in send order. */
readonly system: (input: { agent: Agent.Info; modelID?: string }) => Effect.Effect<Block[]>
/** Transcript as provider messages, compacted history filtered. */
readonly messages: (input: { sessionID: SessionID; model: Provider.Model }) => Effect.Effect<ModelMessage[]>
}
export class Service extends Context.Service<Service, Interface>()("@neuron/SessionContext") {}
export const use = serviceUse(Service)
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const global = yield* Global.Service
const configService = yield* Config.Service
const skill = yield* Skill.Service
const database = yield* Database.Service
const system = Effect.fn("SessionContext.system")(function* (input: { agent: Agent.Info; modelID?: string }) {
const ctx = yield* InstanceState.context
const config = yield* configService.get()
const blocks: Block[] = [{ source: "base-prompt", text: PROMPT_NEURON }]
blocks.push({
source: "environment",
text: [
...(input.modelID ? [`You are powered by the model named ${input.modelID}.`] : []),
`<env>`,
` Working directory: ${ctx.directory}`,
` Workspace root folder: ${ctx.worktree}`,
` Is directory a git repo: ${ctx.project.vcs === "git" ? "yes" : "no"}`,
` Platform: ${process.platform}`,
`</env>`,
].join("\n"),
})
const paths = yield* collectPaths({
fs,
global,
config,
directory: ctx.directory,
worktree: ctx.worktree,
})
const contents = yield* readAll(fs, paths)
paths.forEach((p, i) => {
if (contents[i])
blocks.push({ source: "instructions", origin: p, text: `Instructions from: ${p}\n${contents[i]}` })
})
if (!Permission.disabled(["skill"], input.agent.permission).has("skill")) {
const list = yield* skill.available(input.agent)
if (list.length > 0) {
blocks.push({
source: "skills",
text: [
"Skills provide specialized instructions and workflows for specific tasks.",
"Use the skill tool to load a skill when a task matches its description.",
Skill.fmt(list, { verbose: true }),
].join("\n"),
})
}
}
return blocks
})
const messages = Effect.fn("SessionContext.messages")(function* (input: {
sessionID: SessionID
model: Provider.Model
}) {
const msgs = yield* MessageV2.filterCompactedEffect(input.sessionID).pipe(
Effect.provideService(Database.Service, database),
)
return yield* MessageV2.toModelMessagesEffect(msgs, input.model)
})
return Service.of({ system, messages })
}),
)
export const node = LayerNode.make({
service: Service,
layer,
deps: [FSUtil.node, Global.node, Config.node, Skill.node, Database.node],
})
+6 -24
View File
@@ -1,15 +1,12 @@
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { httpClient } from "@opencode-ai/core/effect/app-node-platform"
import path from "path"
import { SessionV1 } from "@opencode-ai/core/v1/session"
import { Effect, Layer, Context } from "effect"
import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"
import { Config } from "@/config/config"
import { InstanceState } from "@/effect/instance-state"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { Flag } from "@opencode-ai/core/flag/flag"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { withTransientReadRetry } from "@/util/effect-http-client"
import { Global } from "@opencode-ai/core/global"
import type { MessageV2 } from "./message-v2"
import type { MessageID } from "./schema"
@@ -48,7 +45,7 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/In
const layer: Layer.Layer<
Service,
never,
FSUtil.Service | Config.Service | Global.Service | HttpClient.HttpClient | RuntimeFlags.Service
FSUtil.Service | Config.Service | Global.Service | RuntimeFlags.Service
> = Layer.effect(
Service,
Effect.gen(function* () {
@@ -56,7 +53,6 @@ const layer: Layer.Layer<
const fs = yield* FSUtil.Service
const global = yield* Global.Service
const flags = yield* RuntimeFlags.Service
const http = HttpClient.filterStatusOk(withTransientReadRetry(yield* HttpClient.HttpClient))
const globalFiles = [
path.join(global.config, "AGENTS.md"),
...(!flags.disableClaudeCodePrompt ? [path.join(global.home, ".claude", "CLAUDE.md")] : []),
@@ -92,16 +88,6 @@ const layer: Layer.Layer<
return yield* fs.readFileString(filepath).pipe(Effect.catch(() => Effect.succeed("")))
})
const fetch = Effect.fnUntraced(function* (url: string) {
const res = yield* http.execute(HttpClientRequest.get(url)).pipe(
Effect.timeout(5000),
Effect.catch(() => Effect.succeed(null)),
)
if (!res) return ""
const body = yield* res.arrayBuffer.pipe(Effect.catch(() => Effect.succeed(new ArrayBuffer(0))))
return new TextDecoder().decode(body)
})
const clear = Effect.fn("Instruction.clear")(function* (messageID: MessageID) {
const s = yield* InstanceState.get(state)
s.claims.delete(messageID)
@@ -155,17 +141,13 @@ const layer: Layer.Layer<
const system = Effect.fn("Instruction.system")(function* () {
const config = yield* cfg.get()
const paths = yield* systemPaths()
const urls = (config.instructions ?? []).filter(
(item) => item.startsWith("https://") || item.startsWith("http://"),
)
// Remote instruction URLs are not honored. Instructions enter context
// only from files on disk the user controls.
const files = yield* Effect.forEach(Array.from(paths), read, { concurrency: 8 })
const remote = yield* Effect.forEach(urls, fetch, { concurrency: 4 })
return [
...Array.from(paths).flatMap((item, i) => (files[i] ? [`Instructions from: ${item}\n${files[i]}`] : [])),
...urls.flatMap((item, i) => (remote[i] ? [`Instructions from: ${item}\n${remote[i]}`] : [])),
]
return Array.from(paths).flatMap((item, i) => (files[i] ? [`Instructions from: ${item}\n${files[i]}`] : []))
})
const find = Effect.fn("Instruction.find")(function* (dir: string) {
@@ -231,7 +213,7 @@ export function loaded(messages: SessionV1.WithParts[]) {
export const node = LayerNode.make({
service: Service,
layer: layer,
deps: [Config.node, FSUtil.node, Global.node, RuntimeFlags.node, httpClient],
deps: [Config.node, FSUtil.node, Global.node, RuntimeFlags.node],
})
export * as Instruction from "./instruction"
-5
View File
@@ -15,7 +15,6 @@ import { ProviderTransform } from "@/provider/transform"
import { Config } from "@/config/config"
import type { Agent } from "@/agent/agent"
import type { MessageV2 } from "./message-v2"
import { Plugin } from "@/plugin"
import { Permission } from "@/permission"
import { EventV2Bridge } from "@/event-v2-bridge"
import { EventV2 } from "@opencode-ai/core/event"
@@ -65,7 +64,6 @@ const live: Layer.Layer<
| Auth.Service
| Config.Service
| Provider.Service
| Plugin.Service
| Permission.Service
| EventV2Bridge.Service
| LLMClientService
@@ -76,7 +74,6 @@ const live: Layer.Layer<
const auth = yield* Auth.Service
const config = yield* Config.Service
const provider = yield* Provider.Service
const plugin = yield* Plugin.Service
const perm = yield* Permission.Service
const events = yield* EventV2Bridge.Service
const llmClient = yield* LLMClient.Service
@@ -107,7 +104,6 @@ const live: Layer.Layer<
...input,
provider: item,
auth: info,
plugin,
flags,
isWorkflow,
})
@@ -392,7 +388,6 @@ export const node = LayerNode.make({
Auth.node,
Config.node,
Provider.node,
Plugin.node,
Permission.node,
EventV2Bridge.node,
llmClient,
+32 -51
View File
@@ -12,7 +12,9 @@ import { SystemPrompt } from "../system"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { Effect, Record } from "effect"
import { jsonSchema, tool as aiTool, type ModelMessage, type Tool } from "ai"
import type { Plugin } from "@/plugin"
import { webSocketFetchInstalled } from "@/plugin/openai/codex"
import { TITLE_HEADER as openaiTitleFallbackHeader } from "@/plugin/openai/ws-pool"
import os from "os"
import { mergeDeep } from "remeda"
const USER_AGENT = `opencode/${InstallationVersion}`
@@ -30,7 +32,6 @@ type PrepareInput = {
readonly tools: Record<string, Tool>
readonly provider: Provider.Info
readonly auth: Auth.Info | undefined
readonly plugin: Plugin.Interface
readonly flags: RuntimeFlags.Info
readonly isWorkflow: boolean
}
@@ -65,23 +66,6 @@ export const prepare = Effect.fn("LLMRequestPrep.prepare")(function* (input: Pre
.join("\n"),
]
const header = system[0]
yield* input.plugin.trigger(
"experimental.chat.system.transform",
{ sessionID: input.sessionID, model: input.model },
{ system },
)
// Anthropic prompt caching needs a stable two-part shape ([header, body]).
// If plugins appended extra entries without restructuring the array
// themselves (i.e. element 0 is untouched), collapse the tail so caching
// doesn't silently degrade. A plugin that mutates element 0 signals that it
// owns the structure and we leave it alone.
if (system.length > 2 && system[0] === header) {
const rest = system.slice(1)
system.length = 0
system.push(header, rest.join("\n"))
}
const variant =
!input.small && input.model.variants && input.user.model.variant
? input.model.variants[input.user.model.variant]
@@ -116,39 +100,36 @@ export const prepare = Effect.fn("LLMRequestPrep.prepare")(function* (input: Pre
...input.messages,
]
const params = yield* input.plugin.trigger(
"chat.params",
{
sessionID: input.sessionID,
agent: input.agent.name,
model: input.model,
provider: input.provider,
message: input.user,
},
{
temperature: input.model.capabilities.temperature
? (input.agent.temperature ?? ProviderTransform.temperature(input.model))
: undefined,
topP: input.agent.topP ?? ProviderTransform.topP(input.model),
topK: ProviderTransform.topK(input.model),
maxOutputTokens: ProviderTransform.maxOutputTokens(input.model, input.flags.outputTokenMax),
options,
},
)
// First-party provider parity (formerly plugin hooks, now unconditional):
// - codex cli parity: OpenAI never wants an explicit output token cap
// - cerebras: max_completion_tokens already carries the cap
const providerCapsOutputTokens =
input.model.providerID === "openai" ||
(input.model.api.npm === "@ai-sdk/cerebras" && options.max_completion_tokens !== undefined)
const { headers } = yield* input.plugin.trigger(
"chat.headers",
{
sessionID: input.sessionID,
agent: input.agent.name,
model: input.model,
provider: input.provider,
message: input.user,
},
{
headers: {},
},
)
const params = {
temperature: input.model.capabilities.temperature
? (input.agent.temperature ?? ProviderTransform.temperature(input.model))
: undefined,
topP: input.agent.topP ?? ProviderTransform.topP(input.model),
topK: ProviderTransform.topK(input.model),
maxOutputTokens: providerCapsOutputTokens
? undefined
: ProviderTransform.maxOutputTokens(input.model, input.flags.outputTokenMax),
options,
}
const headers =
input.model.providerID !== "openai"
? {}
: {
originator: "opencode",
"User-Agent": `opencode/${InstallationVersion} (${os.platform()} ${os.release()}; ${os.arch()})`,
"session-id": input.sessionID,
...(webSocketFetchInstalled() && input.agent.name === "title"
? { [openaiTitleFallbackHeader]: "true" }
: {}),
}
const tools = resolveTools(input)
// Codex parity: OpenAI Responses-family providers hardcode `strict: false`
+1 -13
View File
@@ -7,7 +7,6 @@ import * as Stream from "effect/Stream"
import { Agent } from "@/agent/agent"
import { Config } from "@/config/config"
import { Permission } from "@/permission"
import { Plugin } from "@/plugin"
import { Snapshot } from "@/snapshot"
import { Session } from "./session"
import { LLM } from "./llm"
@@ -87,8 +86,7 @@ const layer = Layer.effect(
const agents = yield* Agent.Service
const llm = yield* LLM.Service
const permission = yield* Permission.Service
const plugin = yield* Plugin.Service
const summary = yield* SessionSummary.Service
const summary = yield* SessionSummary.Service
const scope = yield* Scope.Scope
const status = yield* SessionStatus.Service
const image = yield* Image.Service
@@ -513,15 +511,6 @@ const layer = Layer.effect(
if (!ctx.currentText) return
// oxlint-disable-next-line no-self-assign -- reactivity trigger
ctx.currentText.text = ctx.currentText.text
ctx.currentText.text = (yield* plugin.trigger(
"experimental.text.complete",
{
sessionID: ctx.sessionID,
messageID: ctx.assistantMessage.id,
partID: ctx.currentText.id,
},
{ text: ctx.currentText.text },
)).text
{
const end = Date.now()
ctx.currentText.time = { start: ctx.currentText.time?.start ?? end, end }
@@ -706,7 +695,6 @@ export const node = LayerNode.make({
Agent.node,
LLM.node,
Permission.node,
Plugin.node,
SessionSummary.node,
SessionStatus.node,
Image.node,
+1 -38
View File
@@ -15,7 +15,6 @@ import type { JSONSchema7 } from "@ai-sdk/provider"
import { SessionCompaction } from "./compaction"
import { SystemPrompt } from "./system"
import { Instruction } from "./instruction"
import { Plugin } from "../plugin"
import { MAX_STEPS_PROMPT } from "@opencode-ai/core/session/runner/max-steps"
import { ToolRegistry } from "@/tool/registry"
import { MCP } from "../mcp"
@@ -119,7 +118,6 @@ const layer = Layer.effect(
const provider = yield* Provider.Service
const processor = yield* SessionProcessor.Service
const compaction = yield* SessionCompaction.Service
const plugin = yield* Plugin.Service
const commands = yield* Command.Service
const config = yield* Config.Service
const permission = yield* Permission.Service
@@ -304,11 +302,6 @@ const layer = Layer.effect(
subagent_type: task.agent,
command: task.command,
}
yield* plugin.trigger(
"tool.execute.before",
{ tool: TaskTool.id, sessionID, callID: part.id },
{ args: taskArgs },
)
const taskAgent = yield* agents.get(task.agent)
if (!taskAgent) {
@@ -386,11 +379,6 @@ const layer = Layer.effect(
messageID: assistantMessage.id,
}))
yield* plugin.trigger(
"tool.execute.after",
{ tool: TaskTool.id, sessionID, callID: part.id, args: taskArgs },
result,
)
assistantMessage.finish = "tool-calls"
assistantMessage.time.completed = Date.now()
@@ -551,15 +539,10 @@ const layer = Layer.effect(
const exit = yield* restore(
Effect.gen(function* () {
const shellEnv = yield* plugin.trigger(
"shell.env",
{ cwd, sessionID: input.sessionID, callID: part.callID },
{ env: {} },
)
const cmd = ChildProcess.make(sh, args, {
cwd,
extendEnv: true,
env: { ...shellEnv.env, TERM: "dumb" },
env: { TERM: "dumb" },
stdin: "ignore",
forceKillAfter: "3 seconds",
})
@@ -996,17 +979,6 @@ const layer = Layer.effect(
Effect.map((x) => x.flat().map(assign)),
)
yield* plugin.trigger(
"chat.message",
{
sessionID: input.sessionID,
agent: input.agent,
model: input.model,
messageID: input.messageID,
variant: input.variant,
},
{ message: info, parts: resolvedParts },
)
const parts = yield* Effect.forEach(resolvedParts, (part) =>
part.type === "file" && part.mime.startsWith("image/")
@@ -1256,7 +1228,6 @@ const layer = Layer.effect(
messages: msgs,
promptOps,
}).pipe(
Effect.provideService(Plugin.Service, plugin),
Effect.provideService(Permission.Service, permission),
Effect.provideService(ToolRegistry.Service, registry),
Effect.provideService(MCP.Service, mcp),
@@ -1276,8 +1247,6 @@ const layer = Layer.effect(
if (step === 1)
yield* summary.summarize({ sessionID, messageID: lastUser.id }).pipe(Effect.ignore, Effect.forkIn(scope))
yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs })
const [skills, env, instructions, mcpInstructions, modelMsgs] = yield* Effect.all([
sys.skills(agent),
sys.environment(model),
@@ -1481,11 +1450,6 @@ const layer = Layer.effect(
: yield* currentModel(input.sessionID)
: taskModel
yield* plugin.trigger(
"command.execute.before",
{ command: input.command, sessionID: input.sessionID, arguments: input.arguments },
{ parts },
)
const result = yield* prompt({
sessionID: input.sessionID,
@@ -1629,7 +1593,6 @@ export const node = LayerNode.make({
Provider.node,
SessionProcessor.node,
SessionCompaction.node,
Plugin.node,
Command.node,
Config.node,
Permission.node,
+266
View File
@@ -0,0 +1,266 @@
export * as Runner from "./runner"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { Context, Effect, Layer } from "effect"
import { SessionV1 } from "@opencode-ai/core/v1/session"
import { MessageID, PartID, SessionID } from "./schema"
import { type Agent, Agent as Agents } from "@/agent/agent"
import { Provider } from "@/provider/provider"
import { Permission } from "@/permission"
import { RuntimeFlags } from "@/effect/runtime-flags"
import type { TaskPromptOps } from "@/tool/task"
import { NotFoundError } from "@/storage/storage"
import { Session } from "./session"
import { SessionProcessor } from "./processor"
import { SessionCompaction } from "./compaction"
import { SessionTools } from "./tools"
import { Truncate } from "@/tool/truncate"
import { ToolRegistry } from "@/tool/registry"
import { MCP } from "@/mcp"
import { SessionContext } from "./context"
import { MessageV2 } from "./message-v2"
import { InstanceState } from "@/effect/instance-state"
/**
* The agent runtime.
*
* Admit a durable user message, then loop: assemble context through
* SessionContext (the only door text takes to a provider), stream one explicit
* provider turn, persist parts, repeat until the model stops requesting tools.
*
* There is no other orchestration. Anything that needs to influence what the
* model sees belongs in SessionContext or it does not happen.
*/
// Hard backstop. A turn that still requests tools after this many steps is a
// runaway, and runaways die loudly rather than quietly billing.
const MAX_STEPS = 32
type PartInput =
| SessionV1.TextPartInput
| SessionV1.FilePartInput
| SessionV1.AgentPartInput
| SessionV1.SubtaskPartInput
type ModelRef = SessionV1.User["model"]
export interface Interface {
/** Persist a user message with its parts, then run turns until done. */
readonly prompt: (input: {
sessionID: SessionID
parts: readonly PartInput[]
agent?: string
model?: ModelRef
}) => Effect.Effect<SessionV1.WithParts[], NotFoundError>
/** Continue a session from its current transcript state. */
readonly resume: (input: { sessionID: SessionID }) => Effect.Effect<SessionV1.WithParts[], NotFoundError>
}
export class Service extends Context.Service<Service, Interface>()("@neuron/Runner") {}
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const sessions = yield* Session.Service
const processor = yield* SessionProcessor.Service
const permission = yield* Permission.Service
const agents = yield* Agents.Service
const provider = yield* Provider.Service
const compaction = yield* SessionCompaction.Service
const registry = yield* ToolRegistry.Service
const mcp = yield* MCP.Service
const truncate = yield* Truncate.Service
const flags = yield* RuntimeFlags.Service
const ctx = yield* InstanceState.context
const sessionContext = yield* SessionContext.Service
// Subagent prompts route back through this same runner into a CHILD
// session, so the task tool's transcript isolation holds. Command
// expansion is intentionally absent: subagents get literal prompts.
const ops = (parentSessionID: SessionID): TaskPromptOps => ({
cancel: (sessionID) => sessions.touch(sessionID).pipe(Effect.asVoid),
resolvePromptParts: (template) => Effect.succeed([{ type: "text", text: template }]),
prompt: (input) =>
Effect.flatMap(sessions.create({ parentID: parentSessionID }), (child) =>
Effect.flatMap(prompt({ ...input, sessionID: child.id }), (msgs) =>
Effect.sync(() => {
const last = msgs.at(-1)
if (!last) throw new Error("child session produced no messages")
return last
}),
).pipe(Effect.catch((error) => Effect.die(new Error(`subagent run failed: ${String(error)}`)))),
),
})
function materialize(input: PartInput, messageID: ReturnType<typeof MessageID.ascending>, sessionID: SessionID) {
const id = input.id ?? PartID.ascending()
if (input.type === "text") {
const part: SessionV1.TextPart = { ...input, id, sessionID, messageID }
return part
}
if (input.type === "file") {
const part: SessionV1.FilePart = { ...input, id, sessionID, messageID }
return part
}
if (input.type === "agent") {
const part: SessionV1.AgentPart = { ...input, id, sessionID, messageID }
return part
}
const part: SessionV1.SubtaskPart = { ...input, id, sessionID, messageID }
return part
}
const prompt = Effect.fn("Runner.prompt")(function* (input: {
sessionID: SessionID
parts: readonly PartInput[]
agent?: string
model?: ModelRef
}) {
const agentName = input.agent ?? (yield* agents.defaultAgent().pipe(Effect.orDie))
const modelRef = input.model ?? (yield* provider.defaultModel().pipe(Effect.orDie))
const info: SessionV1.User = {
id: MessageID.ascending(),
sessionID: input.sessionID,
time: { created: Date.now() },
role: "user",
agent: agentName,
model: modelRef,
}
yield* sessions.updateMessage(info)
for (const part of input.parts) {
yield* sessions.updatePart(materialize(part, info.id, input.sessionID))
}
return yield* turns(input.sessionID)
})
const turns = Effect.fn("Runner.turns")(function* (sessionID: SessionID) {
const session = yield* sessions.get(sessionID).pipe(Effect.orDie)
for (let step = 1; ; step++) {
const msgs = yield* sessions.messages({ sessionID })
const latest = MessageV2.latest(msgs)
if (!latest.user) throw new Error("no user message in session")
// Stop when the last assistant turn finished without pending tool calls.
const lastAssistant = msgs.findLast(
(msg): msg is SessionV1.WithParts & { info: SessionV1.Assistant } =>
msg.info.role === "assistant" && msg.info.id === latest.assistant?.id,
)
const pendingTools =
lastAssistant?.parts.some((part) => part.type === "tool" && !part.metadata?.providerExecuted) ?? false
const finished =
latest.assistant?.finish !== undefined &&
!["tool-calls", "unknown"].includes(latest.assistant.finish) &&
latest.assistant.parentID === latest.user.id
if ((finished && !pendingTools) || step > MAX_STEPS) {
if (step > MAX_STEPS) throw new Error(`runaway turn loop: exceeded ${MAX_STEPS} steps`)
return yield* sessions.messages({ sessionID })
}
const agentName = latest.user.agent
const agent = yield* agents.get(agentName).pipe(Effect.orDie)
const modelRef = latest.user.model
const model = yield* provider.getModel(modelRef.providerID, modelRef.modelID).pipe(Effect.orDie)
const msg: SessionV1.Assistant = {
id: MessageID.ascending(),
parentID: latest.user.id,
role: "assistant",
mode: agent.name,
agent: agent.name,
variant: modelRef.variant,
path: { cwd: ctx.directory, root: ctx.worktree },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
modelID: model.id,
providerID: model.providerID,
time: { created: Date.now() },
sessionID,
}
yield* sessions.updateMessage(msg)
const handle = yield* processor.create({ assistantMessage: msg, sessionID, model })
// Provenance tags ride on every block: the model sees where each piece
// of its context came from. Unattributed text cannot exist here.
const [blocks, messages, tools] = yield* Effect.all([
sessionContext.system({ agent, modelID: model.api.id }),
sessionContext.messages({ sessionID, model }),
SessionTools.resolve({
agent,
session,
model,
processor: handle,
bypassAgentCheck: false,
messages: msgs,
promptOps: ops(sessionID),
}).pipe(
Effect.provideService(Permission.Service, permission),
Effect.provideService(ToolRegistry.Service, registry),
Effect.provideService(MCP.Service, mcp),
Effect.provideService(Truncate.Service, truncate),
Effect.provideService(RuntimeFlags.Service, flags),
),
])
const result = yield* handle.process({
user: latest.user,
sessionID,
agent,
system: blocks.map((block) => `[${block.source}${block.origin ? `:${block.origin}` : ""}]\n${block.text}`),
messages,
tools,
model,
})
// Surface refusals as errors instead of silent empty turns.
if (handle.message.finish === "content-filter" && !handle.message.error) {
handle.message.error = new SessionV1.ContentFilterError({
message: "The response was blocked by the provider's content filter",
}).toObject()
yield* sessions.updateMessage(handle.message)
return yield* sessions.messages({ sessionID })
}
if (result === "stop") return yield* sessions.messages({ sessionID })
// Overflow is a runner policy: hand the session to compaction and let
// the loop re-evaluate from the compacted transcript.
if (result === "compact") {
yield* compaction.create({
sessionID,
agent: latest.user.agent,
model: { providerID: model.providerID, modelID: model.id },
auto: true,
overflow: !handle.message.finish,
})
}
}
})
const resume: Interface["resume"] = Effect.fn("Runner.resume")(function* (input) {
return yield* turns(input.sessionID)
})
return Service.of({ prompt, resume })
}),
)
export const node = LayerNode.make({
service: Service,
layer,
deps: [
Session.node,
SessionProcessor.node,
SessionCompaction.node,
Permission.node,
Agents.node,
Provider.node,
ToolRegistry.node,
MCP.node,
Truncate.node,
RuntimeFlags.node,
SessionContext.node,
],
})
-52
View File
@@ -10,7 +10,6 @@ import { ToolJsonSchema } from "@/tool/json-schema"
import { ToolRegistry } from "@/tool/registry"
import { Truncate } from "@/tool/truncate"
import { Plugin } from "@/plugin"
import type { TaskPromptOps } from "@/tool/task"
import { type Tool as AITool, tool, jsonSchema, type ToolExecutionOptions, asSchema } from "ai"
import { Effect } from "effect"
@@ -49,7 +48,6 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
}) {
const tools: Record<string, AITool> = {}
const run = yield* EffectBridge.make()
const plugin = yield* Plugin.Service
const permission = yield* Permission.Service
const registry = yield* ToolRegistry.Service
const mcp = yield* MCP.Service
@@ -103,11 +101,6 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
return run.promise(
Effect.gen(function* () {
const ctx = context(args, options)
yield* plugin.trigger(
"tool.execute.before",
{ tool: item.id, sessionID: ctx.sessionID, callID: ctx.callID },
{ args },
)
const result = yield* item.execute(args, ctx)
const output = {
...result,
@@ -118,11 +111,6 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
messageID: input.processor.message.id,
})),
}
yield* plugin.trigger(
"tool.execute.after",
{ tool: item.id, sessionID: ctx.sessionID, callID: ctx.callID, args },
output,
)
if (options.abortSignal?.aborted) {
yield* input.processor.completeToolCall(options.toolCallId, output)
}
@@ -172,11 +160,6 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
const permissionPatterns = parsed.server
? [`mcp:${parsed.server}:*`]
: resourceServers.map((server) => `mcp:${server}:*`)
yield* plugin.trigger(
"tool.execute.before",
{ tool: MCP_RESOURCE_TOOLS.list, sessionID: ctx.sessionID, callID: opts.toolCallId },
{ args },
)
yield* ctx.ask({
permission: "read",
metadata: parsed.server ? { server: parsed.server } : {},
@@ -205,11 +188,6 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
},
output: truncated.content,
}
yield* plugin.trigger(
"tool.execute.after",
{ tool: MCP_RESOURCE_TOOLS.list, sessionID: ctx.sessionID, callID: opts.toolCallId, args },
output,
)
if (opts.abortSignal?.aborted) {
yield* input.processor.completeToolCall(opts.toolCallId, output)
}
@@ -255,11 +233,6 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
const permissionPatterns = parsed.server
? [`mcp:${parsed.server}:*`]
: resourceServers.map((server) => `mcp:${server}:*`)
yield* plugin.trigger(
"tool.execute.before",
{ tool: MCP_RESOURCE_TOOLS.listTemplates, sessionID: ctx.sessionID, callID: opts.toolCallId },
{ args },
)
yield* ctx.ask({
permission: "read",
metadata: parsed.server ? { server: parsed.server } : {},
@@ -288,11 +261,6 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
},
output: truncated.content,
}
yield* plugin.trigger(
"tool.execute.after",
{ tool: MCP_RESOURCE_TOOLS.listTemplates, sessionID: ctx.sessionID, callID: opts.toolCallId, args },
output,
)
if (opts.abortSignal?.aborted) {
yield* input.processor.completeToolCall(opts.toolCallId, output)
}
@@ -335,11 +303,6 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
if (!client.getServerCapabilities()?.resources) {
throw new Error(`MCP server "${parsed.server}" does not support resources`)
}
yield* plugin.trigger(
"tool.execute.before",
{ tool: MCP_RESOURCE_TOOLS.read, sessionID: ctx.sessionID, callID: opts.toolCallId },
{ args },
)
yield* ctx.ask({
permission: "read",
metadata: { server: parsed.server, uri: parsed.uri },
@@ -370,11 +333,6 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
messageID: input.processor.message.id,
})),
}
yield* plugin.trigger(
"tool.execute.after",
{ tool: MCP_RESOURCE_TOOLS.read, sessionID: ctx.sessionID, callID: opts.toolCallId, args },
output,
)
if (opts.abortSignal?.aborted) {
yield* input.processor.completeToolCall(opts.toolCallId, output)
}
@@ -399,11 +357,6 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
run.promise(
Effect.gen(function* () {
const ctx = context(args, opts)
yield* plugin.trigger(
"tool.execute.before",
{ tool: key, sessionID: ctx.sessionID, callID: opts.toolCallId },
{ args },
)
const result: Awaited<ReturnType<NonNullable<typeof execute>>> = yield* Effect.gen(function* () {
yield* ctx.ask({ permission: key, metadata: {}, patterns: ["*"], always: ["*"] })
return yield* Effect.promise(() => execute(args, opts))
@@ -417,11 +370,6 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
},
}),
)
yield* plugin.trigger(
"tool.execute.after",
{ tool: key, sessionID: ctx.sessionID, callID: opts.toolCallId, args },
result,
)
const textParts: string[] = []
const attachments: Omit<SessionV1.FilePart, "id" | "sessionID" | "messageID">[] = []
-140
View File
@@ -1,140 +0,0 @@
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { httpClient, path } from "@opencode-ai/core/effect/app-node-platform"
import { NodePath } from "@effect/platform-node"
import { Effect, Layer, Path, Schema, Context } from "effect"
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { withTransientReadRetry } from "@/util/effect-http-client"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Global } from "@opencode-ai/core/global"
const skillConcurrency = 4
const fileConcurrency = 8
class IndexSkill extends Schema.Class<IndexSkill>("IndexSkill")({
name: Schema.String,
files: Schema.Array(Schema.String),
version: Schema.optional(Schema.String),
}) {}
class Index extends Schema.Class<Index>("Index")({
skills: Schema.Array(IndexSkill),
}) {}
export interface Interface {
readonly pull: (url: string) => Effect.Effect<string[]>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/SkillDiscovery") {}
const layer: Layer.Layer<Service, never, FSUtil.Service | Path.Path | HttpClient.HttpClient> = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const path = yield* Path.Path
const http = HttpClient.filterStatusOk(withTransientReadRetry(yield* HttpClient.HttpClient))
const cache = path.join(Global.Path.cache, "skills")
const download = Effect.fn("Discovery.download")(function* (url: string, dest: string) {
if (yield* fs.exists(dest).pipe(Effect.orDie)) return true
return yield* HttpClientRequest.get(url).pipe(
http.execute,
Effect.flatMap((res) => res.arrayBuffer),
Effect.flatMap((body) => fs.writeWithDirs(dest, new Uint8Array(body))),
Effect.as(true),
Effect.catch((err) => Effect.logError("failed to download", { url: url, error: err }).pipe(Effect.as(false))),
)
})
const pull = Effect.fn("Discovery.pull")(function* (url: string) {
const base = url.endsWith("/") ? url : `${url}/`
const index = new URL("index.json", base).href
const host = base.slice(0, -1)
yield* Effect.logInfo("fetching index", { url: index })
const data = yield* HttpClientRequest.get(index).pipe(
HttpClientRequest.acceptJson,
http.execute,
Effect.flatMap(HttpClientResponse.schemaBodyJson(Index)),
Effect.catch((err) =>
Effect.logError("failed to fetch index", { url: index, error: err }).pipe(Effect.as(null)),
),
)
if (!data) return []
const missing = data.skills.filter((skill) => !skill.files.includes("SKILL.md"))
yield* Effect.forEach(
missing,
(skill) => Effect.logWarning("skill entry missing SKILL.md", { url: index, skill: skill.name }),
{ discard: true },
)
const list = data.skills.filter((skill) => skill.files.includes("SKILL.md"))
const dirs = yield* Effect.forEach(
list,
(skill) =>
Effect.gen(function* () {
const root = path.join(cache, skill.name)
const versionFile = path.join(root, ".opencode-version")
const version = skill.version
const current =
version === undefined
? undefined
: yield* fs.readFileStringSafe(versionFile).pipe(Effect.catch(() => Effect.succeed(undefined)))
if (version === undefined || current === version) {
yield* Effect.forEach(
skill.files,
(file) => download(new URL(file, `${host}/${skill.name}/`).href, path.join(root, file)),
{ concurrency: fileConcurrency, discard: true },
)
} else {
const token = crypto.randomUUID()
const staging = `${root}.tmp-${token}`
const backup = `${root}.old-${token}`
yield* Effect.gen(function* () {
const downloaded = yield* Effect.forEach(
skill.files,
(file) => download(new URL(file, `${host}/${skill.name}/`).href, path.join(staging, file)),
{ concurrency: fileConcurrency },
)
if (!downloaded.every(Boolean)) return
if (!(yield* fs.exists(path.join(staging, "SKILL.md")).pipe(Effect.orDie))) return
yield* fs.writeFileString(path.join(staging, ".opencode-version"), version)
yield* Effect.uninterruptible(
Effect.gen(function* () {
const cached = yield* fs.exists(root).pipe(Effect.orDie)
if (cached) yield* fs.rename(root, backup)
yield* fs.rename(staging, root).pipe(
Effect.catch((error) =>
Effect.gen(function* () {
if (cached) yield* fs.rename(backup, root).pipe(Effect.ignore)
return yield* Effect.fail(error)
}),
),
)
if (cached) yield* fs.remove(backup, { recursive: true, force: true }).pipe(Effect.ignore)
}),
)
}).pipe(
Effect.catch((error) => Effect.logError("failed to refresh skill", { skill: skill.name, error })),
Effect.ensuring(fs.remove(staging, { recursive: true, force: true }).pipe(Effect.ignore)),
)
}
return (yield* fs.exists(path.join(root, "SKILL.md")).pipe(Effect.orDie)) ? root : null
}),
{ concurrency: skillConcurrency },
)
return dirs.filter((dir): dir is string => dir !== null)
})
return Service.of({ pull })
}),
)
export const node = LayerNode.make({ service: Service, layer: layer, deps: [FSUtil.node, path, httpClient] })
export * as Discovery from "./discovery"
+2 -18
View File
@@ -14,7 +14,6 @@ import { FrontmatterError } from "@opencode-ai/core/v1/config/error"
import { ConfigMarkdown } from "@/config/markdown"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { Glob } from "@opencode-ai/core/util/glob"
import { Discovery } from "./discovery"
import { isRecord } from "@/util/record"
import { escapeHtml } from "@/util/html"
@@ -84,11 +83,6 @@ type State = {
dirs: Set<string>
}
type DiscoveryState = {
matches: string[]
dirs: string[]
}
type ScanState = {
matches: Set<string>
dirs: Set<string>
@@ -172,7 +166,6 @@ const scan = Effect.fnUntraced(function* (
const discoverSkills = Effect.fnUntraced(function* (
config: Config.Interface,
discovery: Discovery.Interface,
fsys: FSUtil.Interface,
global: Global.Interface,
disableExternalSkills: boolean,
@@ -219,13 +212,6 @@ const discoverSkills = Effect.fnUntraced(function* (
yield* scan(state, dir, SKILL_PATTERN)
}
for (const url of cfg.skills?.urls ?? []) {
const pulledDirs = yield* discovery.pull(url)
for (const dir of pulledDirs) {
yield* scan(state, dir, SKILL_PATTERN)
}
}
return {
matches: Array.from(state.matches),
dirs: Array.from(state.dirs),
@@ -234,7 +220,7 @@ const discoverSkills = Effect.fnUntraced(function* (
const loadSkills = Effect.fnUntraced(function* (
state: State,
discovered: DiscoveryState,
discovered: { matches: string[]; dirs: string[] },
events: EventV2Bridge.Service["Service"],
) {
yield* Effect.forEach(discovered.matches, (match) => add(state, match, events), {
@@ -250,7 +236,6 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Sk
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const discovery = yield* Discovery.Service
const config = yield* Config.Service
const events = yield* EventV2Bridge.Service
const fsys = yield* FSUtil.Service
@@ -260,7 +245,6 @@ const layer = Layer.effect(
Effect.fn("Skill.discovery")(function* (ctx) {
return yield* discoverSkills(
config,
discovery,
fsys,
global,
flags.disableExternalSkills,
@@ -348,7 +332,7 @@ export function fmt(list: Info[], opts: { verbose: boolean }) {
export const node = LayerNode.make({
service: Service,
layer: layer,
deps: [Discovery.node, Config.node, EventV2Bridge.node, FSUtil.node, Global.node, RuntimeFlags.node],
deps: [Config.node, EventV2Bridge.node, FSUtil.node, Global.node, RuntimeFlags.node],
})
export * as Skill from "."
+1 -15
View File
@@ -7,7 +7,6 @@ import { McpCatalog } from "@/mcp/catalog"
import { Agent } from "@/agent/agent"
import { Session } from "@/session/session"
import { Permission } from "@/permission"
import { Plugin } from "@/plugin"
export const CODE_MODE_TOOL = "execute"
@@ -132,17 +131,11 @@ function toolTree(catalog: readonly CatalogEntry[], run: (entry: CatalogEntry) =
}
const invokeChildTool = Effect.fn("CodeMode.invokeChildTool")(function* (input: {
plugin: Plugin.Interface
entry: CatalogEntry
args: Record<string, unknown>
callID: string
ctx: Tool.Context
}) {
yield* input.plugin.trigger(
"tool.execute.before",
{ tool: input.entry.key, sessionID: input.ctx.sessionID, callID: input.callID },
{ args: input.args },
)
const result: CallToolResult = yield* Effect.gen(function* () {
yield* input.ctx.ask({ permission: input.entry.key, metadata: {}, patterns: ["*"], always: ["*"] })
// Deliberately mirrors McpCatalog.convertTool's transport call so the MCP service stays free of tool-loop concerns.
@@ -177,11 +170,6 @@ const invokeChildTool = Effect.fn("CodeMode.invokeChildTool")(function* (input:
},
}),
)
yield* input.plugin.trigger(
"tool.execute.after",
{ tool: input.entry.key, sessionID: input.ctx.sessionID, callID: input.callID, args: input.args },
result,
)
return result
})
@@ -191,8 +179,7 @@ export const CodeModeTool = Tool.define(
const mcp = yield* MCP.Service
const agents = yield* Agent.Service
const sessions = yield* Session.Service
const plugin = yield* Plugin.Service
const init: Tool.DefWithoutID<typeof Parameters, Metadata> = {
description: DESCRIPTION,
parameters: Parameters,
@@ -221,7 +208,6 @@ export const CodeModeTool = Tool.define(
Effect.gen(function* () {
childCalls += 1
const result = yield* invokeChildTool({
plugin,
entry,
args: (input ?? {}) as Record<string, unknown>,
callID: `${ctx.callID ?? entry.key}/${childCalls}`,
+3 -23
View File
@@ -28,7 +28,6 @@ import { type ToolContext as PluginToolContext, type ToolDefinition } from "@ope
import type { JSONSchema7, JSONSchema7Definition } from "@ai-sdk/provider"
import { Schema } from "effect"
import z from "zod"
import { Plugin } from "../plugin"
import { Provider } from "@/provider/provider"
import { WebSearchTool } from "./websearch"
@@ -98,7 +97,6 @@ const layer = Layer.effect(
Service,
Effect.gen(function* () {
const config = yield* Config.Service
const plugin = yield* Plugin.Service
const agents = yield* Agent.Service
const truncate = yield* Truncate.Service
const flags = yield* RuntimeFlags.Service
@@ -208,13 +206,6 @@ const layer = Layer.effect(
}
}
const plugins = yield* plugin.list()
for (const p of plugins) {
for (const [id, def] of Object.entries(p.tool ?? {})) {
custom.push(fromPlugin(id, def))
}
}
yield* config.get()
const questionEnabled = ["app", "cli", "desktop"].includes(flags.client) || flags.enableQuestionTool
@@ -333,27 +324,17 @@ const layer = Layer.effect(
return yield* Effect.forEach(
visible,
Effect.fnUntraced(function* (tool: Tool.Def) {
const output = {
description: tool.description,
parameters: tool.parameters,
jsonSchema: tool.jsonSchema,
}
yield* plugin.trigger("tool.definition", { toolID: tool.id }, output)
const jsonSchema =
output.parameters === tool.parameters || output.jsonSchema !== tool.jsonSchema
? output.jsonSchema
: undefined
return {
id: tool.id,
description: [
output.description,
tool.description,
tool.id === TaskTool.id ? yield* describeTask(input.agent) : undefined,
tool.id === "execute" ? codeModeDescription : undefined,
]
.filter(Boolean)
.join("\n"),
parameters: output.parameters,
jsonSchema,
parameters: tool.parameters,
jsonSchema: tool.jsonSchema,
execute: tool.execute,
formatValidationError: tool.formatValidationError,
}
@@ -452,7 +433,6 @@ export const node = LayerNode.make({
layer,
deps: [
Config.node,
Plugin.node,
Question.node,
Todo.node,
Agent.node,
+3 -13
View File
@@ -16,7 +16,6 @@ import { Shell } from "@opencode-ai/core/shell"
import { ShellID } from "./shell/id"
import * as Truncate from "./truncate"
import { Plugin } from "@/plugin"
import { ChildProcess } from "effect/unstable/process"
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
import { ShellPrompt, type Parameters } from "./shell/prompt"
@@ -342,8 +341,7 @@ export const ShellTool = Tool.define(
const spawner = yield* ChildProcessSpawner
const fs = yield* FSUtil.Service
const trunc = yield* Truncate.Service
const plugin = yield* Plugin.Service
const flags = yield* RuntimeFlags.Service
const flags = yield* RuntimeFlags.Service
const defaultTimeoutMs = flags.bashDefaultTimeoutMs ?? 2 * 60 * 1000
const cygpath = Effect.fn("ShellTool.cygpath")(function* (shell: string, text: string) {
@@ -413,16 +411,8 @@ export const ShellTool = Tool.define(
return scan
})
const shellEnv = Effect.fn("ShellTool.shellEnv")(function* (ctx: Tool.Context, cwd: string) {
const extra = yield* plugin.trigger(
"shell.env",
{ cwd, sessionID: ctx.sessionID, callID: ctx.callID },
{ env: {} },
)
return {
...process.env,
...extra.env,
}
const shellEnv = Effect.fn("ShellTool.shellEnv")(function* (_ctx: Tool.Context, _cwd: string) {
return { ...process.env }
})
const run = Effect.fn("ShellTool.run")(function* (
+1 -2
View File
@@ -11,14 +11,13 @@ import { RuntimeFlags } from "../../src/effect/runtime-flags"
import { Global } from "@opencode-ai/core/global"
import { Permission } from "../../src/permission"
import { PermissionV1 } from "@opencode-ai/core/v1/permission"
import { Plugin } from "../../src/plugin"
import { Provider } from "../../src/provider/provider"
import { Skill } from "../../src/skill"
import { Truncate } from "../../src/tool/truncate"
const agentLayer = (flags: Partial<RuntimeFlags.Info> = {}) =>
LayerNode.compile(
LayerNode.group([Agent.node, Plugin.node, Provider.node, Auth.node, Config.node, Skill.node, RuntimeFlags.node]),
LayerNode.group([Agent.node, Provider.node, Auth.node, Config.node, Skill.node, RuntimeFlags.node]),
[[RuntimeFlags.node, RuntimeFlags.layer(flags)]],
)
@@ -1,51 +0,0 @@
import { expect } from "bun:test"
import { Npm } from "@opencode-ai/core/npm"
import { Effect } from "effect"
import path from "path"
import { pathToFileURL } from "url"
import { Agent } from "../../src/agent/agent"
import { Account } from "../../src/account/account"
import { Auth } from "../../src/auth"
import { RuntimeFlags } from "../../src/effect/runtime-flags"
import { Plugin } from "../../src/plugin"
import { Provider } from "../../src/provider/provider"
import { Skill } from "../../src/skill"
import { AccountTest } from "../fake/account"
import { AuthTest } from "../fake/auth"
import { NpmTest } from "../fake/npm"
import { ProviderTest } from "../fake/provider"
import { SkillTest } from "../fake/skill"
import { testEffect } from "../lib/effect"
import { PLUGIN_AGENT } from "../fixture/agent-plugin.constants"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
// `it.instance` skips InstanceBootstrap so LSP / MCP don't spin up — those
// services hang during scope teardown on Windows and aren't needed
// to verify plugin → config hook → Agent.list.
const pluginUrl = pathToFileURL(path.join(import.meta.dir, "..", "fixture", "agent-plugin.ts")).href
const provider = ProviderTest.fake()
const it = testEffect(
AppNodeBuilder.build(LayerNode.group([Agent.node, Plugin.node]), [
[Auth.node, AuthTest.empty],
[Account.node, AccountTest.empty],
[Npm.node, NpmTest.noop],
[Provider.node, provider.layer],
[Skill.node, SkillTest.empty],
[RuntimeFlags.node, RuntimeFlags.layer({ disableDefaultPlugins: true })],
]),
)
it.instance(
"plugin-registered agents appear in Agent.list",
() =>
Effect.gen(function* () {
yield* Plugin.Service.use((p) => p.init())
const agents = yield* Agent.use.list()
const added = agents.find((agent) => agent.name === PLUGIN_AGENT.name)
expect(added?.description).toBe(PLUGIN_AGENT.description)
expect(added?.mode).toBe(PLUGIN_AGENT.mode)
}),
{ config: { plugin: [pluginUrl] } },
)
@@ -1,110 +0,0 @@
import { expect, spyOn, test } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { pathToFileURL } from "url"
import { tmpdir } from "../../fixture/fixture"
import { createTuiPluginApi } from "../../fixture/tui-plugin"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
import { TuiConfig } from "../../../src/config/tui"
const { TuiPluginRuntime } = await import("../../../src/plugin/tui/runtime")
test("adds tui plugin at runtime from spec", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const file = path.join(dir, "add-plugin.ts")
const spec = pathToFileURL(file).href
const marker = path.join(dir, "add.txt")
await Bun.write(
file,
`export default {
id: "demo.add",
tui: async () => {
await Bun.write(${JSON.stringify(marker)}, "called")
},
}
`,
)
return { spec, marker }
},
})
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
const config = createTuiResolvedConfig({
plugin: [],
})
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
try {
await TuiPluginRuntime.init({
api: createTuiPluginApi(),
config,
})
await expect(TuiPluginRuntime.addPlugin(tmp.extra.spec)).resolves.toBe(true)
await expect(fs.readFile(tmp.extra.marker, "utf8")).resolves.toBe("called")
expect(TuiPluginRuntime.list().find((item) => item.id === "demo.add")).toEqual({
id: "demo.add",
source: "file",
spec: tmp.extra.spec,
target: tmp.extra.spec,
enabled: true,
active: true,
})
} finally {
await TuiPluginRuntime.dispose()
cwd.mockRestore()
wait.mockRestore()
delete process.env.OPENCODE_PLUGIN_META_FILE
}
})
test("retries runtime add for file plugins after dependency wait", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const mod = path.join(dir, "retry-plugin")
const spec = pathToFileURL(mod).href
const marker = path.join(dir, "retry-add.txt")
await fs.mkdir(mod, { recursive: true })
return { mod, spec, marker }
},
})
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
const config = createTuiResolvedConfig({
plugin: [],
})
const wait = spyOn(TuiConfig, "waitForDependencies").mockImplementation(async () => {
await Bun.write(
path.join(tmp.extra.mod, "index.ts"),
`export default {
id: "demo.add.retry",
tui: async () => {
await Bun.write(${JSON.stringify(tmp.extra.marker)}, "called")
},
}
`,
)
})
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
try {
await TuiPluginRuntime.init({
api: createTuiPluginApi(),
config,
})
await expect(TuiPluginRuntime.addPlugin(tmp.extra.spec)).resolves.toBe(true)
await expect(fs.readFile(tmp.extra.marker, "utf8")).resolves.toBe("called")
expect(wait).toHaveBeenCalledTimes(1)
expect(TuiPluginRuntime.list().find((item) => item.id === "demo.add.retry")?.active).toBe(true)
} finally {
await TuiPluginRuntime.dispose()
cwd.mockRestore()
wait.mockRestore()
delete process.env.OPENCODE_PLUGIN_META_FILE
}
})
@@ -1,87 +0,0 @@
import { expect, spyOn, test } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { pathToFileURL } from "url"
import { tmpdir } from "../../fixture/fixture"
import { createTuiPluginApi } from "../../fixture/tui-plugin"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
import { TuiConfig } from "../../../src/config/tui"
const { TuiPluginRuntime } = await import("../../../src/plugin/tui/runtime")
test("installs plugin without loading it", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const file = path.join(dir, "install-plugin.ts")
const spec = pathToFileURL(file).href
const marker = path.join(dir, "install.txt")
await Bun.write(
path.join(dir, "package.json"),
JSON.stringify(
{
name: "demo-install-plugin",
type: "module",
exports: {
"./tui": {
import: "./install-plugin.ts",
config: { marker },
},
},
},
null,
2,
),
)
await Bun.write(
file,
`export default {
id: "demo.install",
tui: async (_api, options) => {
if (!options?.marker) return
await Bun.write(options.marker, "loaded")
},
}
`,
)
return { spec, marker }
},
})
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
const config = createTuiResolvedConfig({
plugin: [],
})
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
const api = createTuiPluginApi({
state: {
path: {
state: path.join(tmp.path, "state.json"),
config: path.join(tmp.path, "tui.json"),
worktree: tmp.path,
directory: tmp.path,
},
},
})
try {
await TuiPluginRuntime.init({ api, config })
const out = await TuiPluginRuntime.installPlugin(tmp.extra.spec)
expect(out).toMatchObject({
ok: true,
tui: true,
})
await expect(fs.readFile(tmp.extra.marker, "utf8")).rejects.toThrow()
await expect(TuiPluginRuntime.addPlugin(tmp.extra.spec)).resolves.toBe(true)
await expect(fs.readFile(tmp.extra.marker, "utf8")).resolves.toBe("loaded")
} finally {
await TuiPluginRuntime.dispose()
cwd.mockRestore()
wait.mockRestore()
delete process.env.OPENCODE_PLUGIN_META_FILE
}
})
@@ -1,224 +0,0 @@
import { expect, spyOn, test } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { pathToFileURL } from "url"
import { tmpdir } from "../../fixture/fixture"
import { createTuiPluginApi } from "../../fixture/tui-plugin"
import { mockTuiRuntime } from "../../fixture/tui-runtime"
const { TuiPluginRuntime } = await import("../../../src/plugin/tui/runtime")
test("runs onDispose callbacks with aborted signal and is idempotent", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const file = path.join(dir, "plugin.ts")
const spec = pathToFileURL(file).href
const marker = path.join(dir, "marker.txt")
await Bun.write(
file,
`export default {
id: "demo.lifecycle",
tui: async (api, options) => {
api.event.on("event.test", () => {})
api.route.register([{ name: "lifecycle.route", render: () => null }])
api.lifecycle.onDispose(async () => {
const prev = await Bun.file(options.marker).text().catch(() => "")
await Bun.write(options.marker, prev + "custom\\n")
})
api.lifecycle.onDispose(async () => {
const prev = await Bun.file(options.marker).text().catch(() => "")
await Bun.write(options.marker, prev + "aborted:" + String(api.lifecycle.signal.aborted) + "\\n")
})
},
}
`,
)
return { spec, marker }
},
})
const { config, restore } = mockTuiRuntime(tmp.path, [[tmp.extra.spec, { marker: tmp.extra.marker }]])
try {
await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
await TuiPluginRuntime.dispose()
const marker = await fs.readFile(tmp.extra.marker, "utf8")
expect(marker).toContain("custom")
expect(marker).toContain("aborted:true")
// second dispose is a no-op
await TuiPluginRuntime.dispose()
const after = await fs.readFile(tmp.extra.marker, "utf8")
expect(after).toBe(marker)
} finally {
await TuiPluginRuntime.dispose()
restore()
}
})
test("rolls back failed plugin and continues loading next", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const bad = path.join(dir, "bad-plugin.ts")
const good = path.join(dir, "good-plugin.ts")
const badSpec = pathToFileURL(bad).href
const goodSpec = pathToFileURL(good).href
const badMarker = path.join(dir, "bad-cleanup.txt")
const goodMarker = path.join(dir, "good-called.txt")
await Bun.write(
bad,
`export default {
id: "demo.bad",
tui: async (api, options) => {
api.route.register([{ name: "bad.route", render: () => null }])
api.lifecycle.onDispose(async () => {
await Bun.write(options.bad_marker, "cleaned")
})
throw new Error("bad plugin")
},
}
`,
)
await Bun.write(
good,
`export default {
id: "demo.good",
tui: async (_api, options) => {
await Bun.write(options.good_marker, "called")
},
}
`,
)
return { badSpec, goodSpec, badMarker, goodMarker }
},
})
const { config, restore } = mockTuiRuntime(tmp.path, [
[tmp.extra.badSpec, { bad_marker: tmp.extra.badMarker }],
[tmp.extra.goodSpec, { good_marker: tmp.extra.goodMarker }],
])
try {
await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
// bad plugin's onDispose ran during rollback
await expect(fs.readFile(tmp.extra.badMarker, "utf8")).resolves.toBe("cleaned")
// good plugin still loaded
await expect(fs.readFile(tmp.extra.goodMarker, "utf8")).resolves.toBe("called")
} finally {
await TuiPluginRuntime.dispose()
restore()
}
})
test("assigns sequential slot ids scoped to plugin", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const file = path.join(dir, "slot-plugin.ts")
const spec = pathToFileURL(file).href
const marker = path.join(dir, "slot-setup.txt")
await Bun.write(
file,
`import fs from "fs"
const mark = (label) => {
fs.appendFileSync(${JSON.stringify(marker)}, label + "\\n")
}
export default {
id: "demo.slot",
tui: async (api) => {
const one = api.slots.register({
id: 1,
setup: () => { mark("one") },
slots: { home_logo() { return null } },
})
const two = api.slots.register({
id: 2,
setup: () => { mark("two") },
slots: { home_bottom() { return null } },
})
mark("id:" + one)
mark("id:" + two)
},
}
`,
)
return { spec, marker }
},
})
const { config, restore } = mockTuiRuntime(tmp.path, [tmp.extra.spec])
const err = spyOn(console, "error").mockImplementation(() => {})
try {
await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
const marker = await fs.readFile(tmp.extra.marker, "utf8")
expect(marker).toContain("one")
expect(marker).toContain("two")
expect(marker).toContain("id:demo.slot")
expect(marker).toContain("id:demo.slot:1")
// no initialization failures
const hit = err.mock.calls.find(
(item) => typeof item[0] === "string" && item[0].includes("failed to initialize tui plugin"),
)
expect(hit).toBeUndefined()
} finally {
await TuiPluginRuntime.dispose()
err.mockRestore()
restore()
}
})
test(
"times out hanging plugin cleanup on dispose",
async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const file = path.join(dir, "timeout-plugin.ts")
const spec = pathToFileURL(file).href
await Bun.write(
file,
`export default {
id: "demo.timeout",
tui: async (api) => {
api.lifecycle.onDispose(() => new Promise(() => {}))
},
}
`,
)
return { spec }
},
})
const { config, restore } = mockTuiRuntime(tmp.path, [tmp.extra.spec])
try {
await TuiPluginRuntime.init({ api: createTuiPluginApi(), config, disposeTimeoutMs: 25 })
const done = await new Promise<string>((resolve) => {
const timer = setTimeout(() => resolve("timeout"), 500)
void TuiPluginRuntime.dispose().then(() => {
clearTimeout(timer)
resolve("done")
})
})
expect(done).toBe("done")
} finally {
await TuiPluginRuntime.dispose()
restore()
}
},
{ timeout: 15000 },
)
@@ -1,485 +0,0 @@
import { expect, spyOn, test } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { pathToFileURL } from "url"
import { tmpdir } from "../../fixture/fixture"
import { createTuiPluginApi } from "../../fixture/tui-plugin"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
import { TuiConfig } from "../../../src/config/tui"
import { Npm } from "@opencode-ai/core/npm"
const { TuiPluginRuntime } = await import("../../../src/plugin/tui/runtime")
test("loads npm tui plugin from package ./tui export", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const mod = path.join(dir, "mods", "acme-plugin")
const marker = path.join(dir, "tui-called.txt")
await fs.mkdir(mod, { recursive: true })
await Bun.write(
path.join(mod, "package.json"),
JSON.stringify({
name: "acme-plugin",
type: "module",
exports: { ".": "./index.js", "./server": "./server.js", "./tui": "./tui.js" },
}),
)
await Bun.write(path.join(mod, "index.js"), 'import "./main-throws.js"\nexport default {}\n')
await Bun.write(path.join(mod, "main-throws.js"), 'throw new Error("main loaded")\n')
await Bun.write(path.join(mod, "server.js"), "export default {}\n")
await Bun.write(
path.join(mod, "tui.js"),
`export default {
id: "demo.tui.export",
tui: async (_api, options) => {
if (!options?.marker) return
await Bun.write(${JSON.stringify(marker)}, "called")
},
}
`,
)
return { mod, marker, spec: "acme-plugin@1.0.0" }
},
})
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
const config = createTuiResolvedConfig({
plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]],
plugin_origins: [
{
spec: [tmp.extra.spec, { marker: tmp.extra.marker }],
scope: "local",
source: path.join(tmp.path, "tui.json"),
},
],
})
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
try {
await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
await expect(fs.readFile(tmp.extra.marker, "utf8")).resolves.toBe("called")
const hit = TuiPluginRuntime.list().find((item) => item.id === "demo.tui.export")
expect(hit?.enabled).toBe(true)
expect(hit?.active).toBe(true)
expect(hit?.source).toBe("npm")
} finally {
await TuiPluginRuntime.dispose()
install.mockRestore()
cwd.mockRestore()
wait.mockRestore()
delete process.env.OPENCODE_PLUGIN_META_FILE
}
})
test("does not use npm package exports dot for tui entry", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const mod = path.join(dir, "mods", "acme-plugin")
const marker = path.join(dir, "dot-called.txt")
await fs.mkdir(mod, { recursive: true })
await Bun.write(
path.join(mod, "package.json"),
JSON.stringify({
name: "acme-plugin",
type: "module",
exports: { ".": "./index.js" },
}),
)
await Bun.write(
path.join(mod, "index.js"),
`export default {
id: "demo.dot",
tui: async () => {
await Bun.write(${JSON.stringify(marker)}, "called")
},
}
`,
)
return { mod, marker, spec: "acme-plugin@1.0.0" }
},
})
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
const config = createTuiResolvedConfig({
plugin: [tmp.extra.spec],
plugin_origins: [
{
spec: tmp.extra.spec,
scope: "local",
source: path.join(tmp.path, "tui.json"),
},
],
})
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
try {
await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
await expect(fs.readFile(tmp.extra.marker, "utf8")).rejects.toThrow()
expect(TuiPluginRuntime.list().some((item) => item.spec === tmp.extra.spec)).toBe(false)
} finally {
await TuiPluginRuntime.dispose()
install.mockRestore()
cwd.mockRestore()
wait.mockRestore()
delete process.env.OPENCODE_PLUGIN_META_FILE
}
})
test("rejects npm tui export that resolves outside plugin directory", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const mod = path.join(dir, "mods", "acme-plugin")
const outside = path.join(dir, "outside")
const marker = path.join(dir, "outside-called.txt")
await fs.mkdir(mod, { recursive: true })
await fs.mkdir(outside, { recursive: true })
await Bun.write(
path.join(mod, "package.json"),
JSON.stringify({
name: "acme-plugin",
type: "module",
exports: { ".": "./index.js", "./tui": "./escape/tui.js" },
}),
)
await Bun.write(path.join(mod, "index.js"), "export default {}\n")
await Bun.write(
path.join(outside, "tui.js"),
`export default {
id: "demo.outside",
tui: async () => {
await Bun.write(${JSON.stringify(marker)}, "outside")
},
}
`,
)
await fs.symlink(outside, path.join(mod, "escape"), process.platform === "win32" ? "junction" : "dir")
return { mod, marker, spec: "acme-plugin@1.0.0" }
},
})
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
const config = createTuiResolvedConfig({
plugin: [tmp.extra.spec],
plugin_origins: [
{
spec: tmp.extra.spec,
scope: "local",
source: path.join(tmp.path, "tui.json"),
},
],
})
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
try {
await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
// plugin code never ran
await expect(fs.readFile(tmp.extra.marker, "utf8")).rejects.toThrow()
// plugin not listed
expect(TuiPluginRuntime.list().some((item) => item.spec === tmp.extra.spec)).toBe(false)
} finally {
await TuiPluginRuntime.dispose()
install.mockRestore()
cwd.mockRestore()
wait.mockRestore()
delete process.env.OPENCODE_PLUGIN_META_FILE
}
})
test("rejects npm tui plugin that exports server and tui together", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const mod = path.join(dir, "mods", "acme-plugin")
const marker = path.join(dir, "mixed-called.txt")
await fs.mkdir(mod, { recursive: true })
await Bun.write(
path.join(mod, "package.json"),
JSON.stringify({
name: "acme-plugin",
type: "module",
exports: { ".": "./index.js", "./tui": "./tui.js" },
}),
)
await Bun.write(path.join(mod, "index.js"), "export default {}\n")
await Bun.write(
path.join(mod, "tui.js"),
`export default {
id: "demo.mixed",
server: async () => ({}),
tui: async () => {
await Bun.write(${JSON.stringify(marker)}, "called")
},
}
`,
)
return { mod, marker, spec: "acme-plugin@1.0.0" }
},
})
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
const config = createTuiResolvedConfig({
plugin: [tmp.extra.spec],
plugin_origins: [
{
spec: tmp.extra.spec,
scope: "local",
source: path.join(tmp.path, "tui.json"),
},
],
})
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
try {
await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
await expect(fs.readFile(tmp.extra.marker, "utf8")).rejects.toThrow()
expect(TuiPluginRuntime.list().some((item) => item.spec === tmp.extra.spec)).toBe(false)
} finally {
await TuiPluginRuntime.dispose()
install.mockRestore()
cwd.mockRestore()
wait.mockRestore()
delete process.env.OPENCODE_PLUGIN_META_FILE
}
})
test("does not use npm package main for tui entry", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const mod = path.join(dir, "mods", "acme-plugin")
const marker = path.join(dir, "main-called.txt")
await fs.mkdir(mod, { recursive: true })
await Bun.write(
path.join(mod, "package.json"),
JSON.stringify({
name: "acme-plugin",
type: "module",
main: "./index.js",
}),
)
await Bun.write(
path.join(mod, "index.js"),
`export default {
id: "demo.main",
tui: async () => {
await Bun.write(${JSON.stringify(marker)}, "called")
},
}
`,
)
return { mod, marker, spec: "acme-plugin@1.0.0" }
},
})
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
const config = createTuiResolvedConfig({
plugin: [tmp.extra.spec],
plugin_origins: [
{
spec: tmp.extra.spec,
scope: "local",
source: path.join(tmp.path, "tui.json"),
},
],
})
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
const warn = spyOn(console, "warn").mockImplementation(() => {})
const error = spyOn(console, "error").mockImplementation(() => {})
try {
await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
await expect(fs.readFile(tmp.extra.marker, "utf8")).rejects.toThrow()
expect(TuiPluginRuntime.list().some((item) => item.spec === tmp.extra.spec)).toBe(false)
expect(error).not.toHaveBeenCalled()
expect(warn.mock.calls.some((call) => String(call[0]).includes("tui plugin has no entrypoint"))).toBe(true)
} finally {
await TuiPluginRuntime.dispose()
install.mockRestore()
cwd.mockRestore()
wait.mockRestore()
warn.mockRestore()
error.mockRestore()
delete process.env.OPENCODE_PLUGIN_META_FILE
}
})
test("does not use directory package main for tui entry", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const mod = path.join(dir, "mods", "dir-plugin")
const spec = pathToFileURL(mod).href
const marker = path.join(dir, "dir-main-called.txt")
await fs.mkdir(mod, { recursive: true })
await Bun.write(
path.join(mod, "package.json"),
JSON.stringify({
name: "dir-plugin",
type: "module",
main: "./main.js",
}),
)
await Bun.write(
path.join(mod, "main.js"),
`export default {
id: "demo.dir.main",
tui: async () => {
await Bun.write(${JSON.stringify(marker)}, "called")
},
}
`,
)
return { marker, spec }
},
})
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
const config = createTuiResolvedConfig({
plugin: [tmp.extra.spec],
plugin_origins: [
{
spec: tmp.extra.spec,
scope: "local",
source: path.join(tmp.path, "tui.json"),
},
],
})
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
try {
await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
await expect(fs.readFile(tmp.extra.marker, "utf8")).rejects.toThrow()
expect(TuiPluginRuntime.list().some((item) => item.spec === tmp.extra.spec)).toBe(false)
} finally {
await TuiPluginRuntime.dispose()
cwd.mockRestore()
wait.mockRestore()
delete process.env.OPENCODE_PLUGIN_META_FILE
}
})
test("uses directory index fallback for tui when package.json is missing", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const mod = path.join(dir, "mods", "dir-index")
const spec = pathToFileURL(mod).href
const marker = path.join(dir, "dir-index-called.txt")
await fs.mkdir(mod, { recursive: true })
await Bun.write(
path.join(mod, "index.ts"),
`export default {
id: "demo.dir.index",
tui: async () => {
await Bun.write(${JSON.stringify(marker)}, "called")
},
}
`,
)
return { marker, spec }
},
})
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
const config = createTuiResolvedConfig({
plugin: [tmp.extra.spec],
plugin_origins: [
{
spec: tmp.extra.spec,
scope: "local",
source: path.join(tmp.path, "tui.json"),
},
],
})
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
try {
await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
await expect(fs.readFile(tmp.extra.marker, "utf8")).resolves.toBe("called")
expect(TuiPluginRuntime.list().find((item) => item.id === "demo.dir.index")?.active).toBe(true)
} finally {
await TuiPluginRuntime.dispose()
cwd.mockRestore()
wait.mockRestore()
delete process.env.OPENCODE_PLUGIN_META_FILE
}
})
test("uses npm package name when tui plugin id is omitted", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const mod = path.join(dir, "mods", "acme-plugin")
const marker = path.join(dir, "name-id-called.txt")
await fs.mkdir(mod, { recursive: true })
await Bun.write(
path.join(mod, "package.json"),
JSON.stringify({
name: "acme-plugin",
type: "module",
exports: { ".": "./index.js", "./tui": "./tui.js" },
}),
)
await Bun.write(path.join(mod, "index.js"), "export default {}\n")
await Bun.write(
path.join(mod, "tui.js"),
`export default {
tui: async (_api, options) => {
if (!options?.marker) return
await Bun.write(options.marker, "called")
},
}
`,
)
return { mod, marker, spec: "acme-plugin@1.0.0" }
},
})
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
const config = createTuiResolvedConfig({
plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]],
plugin_origins: [
{
spec: [tmp.extra.spec, { marker: tmp.extra.marker }],
scope: "local",
source: path.join(tmp.path, "tui.json"),
},
],
})
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
try {
await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
await expect(fs.readFile(tmp.extra.marker, "utf8")).resolves.toBe("called")
expect(TuiPluginRuntime.list().find((item) => item.spec === tmp.extra.spec)?.id).toBe("acme-plugin")
} finally {
await TuiPluginRuntime.dispose()
install.mockRestore()
cwd.mockRestore()
wait.mockRestore()
delete process.env.OPENCODE_PLUGIN_META_FILE
}
})
@@ -1,72 +0,0 @@
import { expect, spyOn, test } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { pathToFileURL } from "url"
import { tmpdir } from "../../fixture/fixture"
import { createTuiPluginApi } from "../../fixture/tui-plugin"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
import { TuiConfig } from "../../../src/config/tui"
const { TuiPluginRuntime } = await import("../../../src/plugin/tui/runtime")
test("skips external tui plugins in pure mode", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const file = path.join(dir, "plugin.ts")
const spec = pathToFileURL(file).href
const marker = path.join(dir, "called.txt")
const meta = path.join(dir, "plugin-meta.json")
await Bun.write(
file,
`export default {
id: "demo.pure",
tui: async (_api, options) => {
if (!options?.marker) return
await Bun.write(options.marker, "called")
},
}
`,
)
return { spec, marker, meta }
},
})
const pure = process.env.OPENCODE_PURE
const meta = process.env.OPENCODE_PLUGIN_META_FILE
process.env.OPENCODE_PURE = "1"
process.env.OPENCODE_PLUGIN_META_FILE = tmp.extra.meta
const config = createTuiResolvedConfig({
plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]],
plugin_origins: [
{
spec: [tmp.extra.spec, { marker: tmp.extra.marker }],
scope: "local",
source: path.join(tmp.path, "tui.json"),
},
],
})
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
try {
await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
await expect(fs.readFile(tmp.extra.marker, "utf8")).rejects.toThrow()
} finally {
await TuiPluginRuntime.dispose()
cwd.mockRestore()
wait.mockRestore()
if (pure === undefined) {
delete process.env.OPENCODE_PURE
} else {
process.env.OPENCODE_PURE = pure
}
if (meta === undefined) {
delete process.env.OPENCODE_PLUGIN_META_FILE
} else {
process.env.OPENCODE_PLUGIN_META_FILE = meta
}
}
})
File diff suppressed because it is too large Load Diff
@@ -1,264 +0,0 @@
import { expect, spyOn, test } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { pathToFileURL } from "url"
import { tmpdir } from "../../fixture/fixture"
import { createTuiPluginApi } from "../../fixture/tui-plugin"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
import { TuiConfig } from "../../../src/config/tui"
const { TuiPluginRuntime } = await import("../../../src/plugin/tui/runtime")
test("toggles plugin runtime state by exported id", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const file = path.join(dir, "toggle-plugin.ts")
const spec = pathToFileURL(file).href
const marker = path.join(dir, "toggle.txt")
await Bun.write(
file,
`export default {
id: "demo.toggle",
tui: async (api, options) => {
const text = await Bun.file(options.marker).text().catch(() => "")
await Bun.write(options.marker, text + "start\\n")
api.lifecycle.onDispose(async () => {
const next = await Bun.file(options.marker).text().catch(() => "")
await Bun.write(options.marker, next + "stop\\n")
})
},
}
`,
)
return {
spec,
marker,
}
},
})
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
const config = createTuiResolvedConfig({
plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]],
plugin_enabled: {
"demo.toggle": false,
},
plugin_origins: [
{
spec: [tmp.extra.spec, { marker: tmp.extra.marker }],
scope: "local",
source: path.join(tmp.path, "tui.json"),
},
],
})
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
const api = createTuiPluginApi()
try {
await TuiPluginRuntime.init({ api, config })
await expect(fs.readFile(tmp.extra.marker, "utf8")).rejects.toThrow()
expect(TuiPluginRuntime.list().find((item) => item.id === "demo.toggle")).toEqual({
id: "demo.toggle",
source: "file",
spec: tmp.extra.spec,
target: tmp.extra.spec,
enabled: false,
active: false,
})
await expect(TuiPluginRuntime.activatePlugin("demo.toggle")).resolves.toBe(true)
await expect(fs.readFile(tmp.extra.marker, "utf8")).resolves.toBe("start\n")
expect(api.kv.get("plugin_enabled", {})).toEqual({
"demo.toggle": true,
})
await expect(TuiPluginRuntime.deactivatePlugin("demo.toggle")).resolves.toBe(true)
await expect(fs.readFile(tmp.extra.marker, "utf8")).resolves.toBe("start\nstop\n")
expect(api.kv.get("plugin_enabled", {})).toEqual({
"demo.toggle": false,
})
await expect(TuiPluginRuntime.activatePlugin("missing.id")).resolves.toBe(false)
} finally {
await TuiPluginRuntime.dispose()
cwd.mockRestore()
wait.mockRestore()
delete process.env.OPENCODE_PLUGIN_META_FILE
}
})
test("deactivating plugin pops pushed mode", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const file = path.join(dir, "mode-plugin.ts")
const spec = pathToFileURL(file).href
await Bun.write(
file,
`export default {
id: "demo.mode",
tui: async (api) => {
api.mode.push("demo.mode")
},
}
`,
)
return { spec }
},
})
const stack: { id: symbol; mode: string }[] = []
let popCount = 0
const api = createTuiPluginApi({
mode: {
current: () => stack.at(-1)?.mode ?? "base",
push(mode) {
const id = Symbol(mode)
let active = true
stack.push({ id, mode })
return () => {
if (!active) return
active = false
popCount += 1
const index = stack.findIndex((item) => item.id === id)
if (index !== -1) stack.splice(index, 1)
}
},
},
})
const config = createTuiResolvedConfig({
plugin: [tmp.extra.spec],
plugin_origins: [{ spec: tmp.extra.spec, scope: "local", source: path.join(tmp.path, "tui.json") }],
})
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
try {
await TuiPluginRuntime.init({ api, config })
expect(api.mode.current()).toBe("demo.mode")
expect(popCount).toBe(0)
await expect(TuiPluginRuntime.deactivatePlugin("demo.mode")).resolves.toBe(true)
expect(api.mode.current()).toBe("base")
expect(popCount).toBe(1)
} finally {
await TuiPluginRuntime.dispose()
cwd.mockRestore()
wait.mockRestore()
}
})
test("kv plugin_enabled overrides tui config on startup", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const file = path.join(dir, "startup-plugin.ts")
const spec = pathToFileURL(file).href
const marker = path.join(dir, "startup.txt")
await Bun.write(
file,
`export default {
id: "demo.startup",
tui: async (_api, options) => {
await Bun.write(options.marker, "on")
},
}
`,
)
return {
spec,
marker,
}
},
})
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
const config = createTuiResolvedConfig({
plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]],
plugin_enabled: {
"demo.startup": false,
},
plugin_origins: [
{
spec: [tmp.extra.spec, { marker: tmp.extra.marker }],
scope: "local",
source: path.join(tmp.path, "tui.json"),
},
],
})
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
const api = createTuiPluginApi()
api.kv.set("plugin_enabled", {
"demo.startup": true,
})
try {
await TuiPluginRuntime.init({ api, config })
await expect(fs.readFile(tmp.extra.marker, "utf8")).resolves.toBe("on")
expect(TuiPluginRuntime.list().find((item) => item.id === "demo.startup")).toEqual({
id: "demo.startup",
source: "file",
spec: tmp.extra.spec,
target: tmp.extra.spec,
enabled: true,
active: true,
})
} finally {
await TuiPluginRuntime.dispose()
cwd.mockRestore()
wait.mockRestore()
delete process.env.OPENCODE_PLUGIN_META_FILE
}
})
test("loads disabled-by-default internal plugin inactive and activates on demand", async () => {
await using tmp = await tmpdir()
const config = createTuiResolvedConfig()
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
const api = createTuiPluginApi()
try {
await TuiPluginRuntime.init({ api, config })
expect(TuiPluginRuntime.list().find((item) => item.id === "internal:plugin-manager")).toMatchObject({
enabled: true,
active: true,
})
expect(TuiPluginRuntime.list().find((item) => item.id === "which-key")).toEqual({
id: "which-key",
source: "internal",
spec: "which-key",
target: "which-key",
enabled: false,
active: false,
})
await expect(TuiPluginRuntime.activatePlugin("which-key")).resolves.toBe(true)
expect(TuiPluginRuntime.list().find((item) => item.id === "which-key")).toEqual({
id: "which-key",
source: "internal",
spec: "which-key",
target: "which-key",
enabled: true,
active: true,
})
expect(api.kv.get("plugin_enabled", {})).toEqual({
"which-key": true,
})
} finally {
await TuiPluginRuntime.dispose()
cwd.mockRestore()
wait.mockRestore()
}
})
@@ -1,93 +0,0 @@
import path from "path"
import { createPlugTask, type PlugCtx, type PlugDeps } from "../../src/cli/cmd/plug"
import { Filesystem } from "@/util/filesystem"
type Msg = {
dir: string
target: string
mod: string
global?: boolean
force?: boolean
globalDir?: string
vcs?: string
worktree?: string
directory?: string
holdMs?: number
}
function sleep(ms: number) {
return new Promise<void>((resolve) => {
setTimeout(resolve, ms)
})
}
function input() {
const raw = process.argv[2]
if (!raw) {
throw new Error("Missing plug worker input")
}
const msg = JSON.parse(raw) as Partial<Msg>
if (!msg.dir || !msg.target || !msg.mod) {
throw new Error("Invalid plug worker input")
}
return msg as Msg
}
function deps(msg: Msg): PlugDeps {
return {
spinner: () => ({
start() {},
stop() {},
}),
log: {
error() {},
info() {},
success() {},
},
resolve: async () => msg.target,
readText: (file) => Filesystem.readText(file),
write: async (file, text) => {
if (msg.holdMs && msg.holdMs > 0) {
await sleep(msg.holdMs)
}
await Filesystem.write(file, text)
},
exists: (file) => Filesystem.exists(file),
files: (dir, name) => [path.join(dir, `${name}.jsonc`), path.join(dir, `${name}.json`)],
global: msg.globalDir ?? path.join(msg.dir, ".global"),
}
}
function ctx(msg: Msg): PlugCtx {
return {
vcs: msg.vcs ?? "git",
worktree: msg.worktree ?? msg.dir,
directory: msg.directory ?? msg.dir,
}
}
async function main() {
const msg = input()
const run = createPlugTask(
{
mod: msg.mod,
global: msg.global,
force: msg.force,
},
deps(msg),
)
const ok = await run(ctx(msg))
if (!ok) {
throw new Error("Plug task failed")
}
}
await main().catch((err) => {
const text = err instanceof Error ? (err.stack ?? err.message) : String(err)
process.stderr.write(text)
process.exit(1)
})
@@ -1,101 +0,0 @@
import { describe, expect, test } from "bun:test"
import path from "path"
import { pathToFileURL } from "url"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { Effect } from "effect"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { provideInstance, TestInstance, tmpdirScoped } from "../fixture/fixture"
import { ProviderAuth } from "@/provider/auth"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { TestConfig } from "../fixture/config"
import { testEffect } from "../lib/effect"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { Config } from "@/config/config"
const it = testEffect(LayerNode.compile(LayerNode.group([CrossSpawnSpawner.node, FSUtil.node])))
function providerAuthLayer(directory: string, plugins: string[]) {
return LayerNode.compile(ProviderAuth.node, [
[
Config.node,
TestConfig.layer({
get: () =>
Effect.succeed({
plugin: plugins,
plugin_origins: plugins.map((plugin) => ({
spec: plugin,
source: path.join(directory, "opencode.json"),
scope: "local" as const,
})),
}),
directories: () => Effect.succeed([directory]),
}),
],
[RuntimeFlags.node, RuntimeFlags.layer()],
])
}
describe("plugin.auth-override", () => {
it.instance(
"user plugin auth entries are listed alongside built-ins",
() =>
Effect.gen(function* () {
const tmp = yield* TestInstance
const fs = yield* FSUtil.Service
const pluginDir = path.join(tmp.directory, ".opencode", "plugin")
yield* fs.writeWithDirs(
path.join(pluginDir, "custom-auth.ts"),
[
"export default {",
' id: "demo.custom-auth",',
" server: async () => ({",
" auth: {",
' provider: "openai",',
" methods: [",
' { type: "api", label: "Test Override Auth" },',
" ],",
" loader: async () => ({ access: 'test-token' }),",
" },",
" }),",
"}",
"",
].join("\n"),
)
const plain = yield* tmpdirScoped({ git: true })
const plugin = pathToFileURL(path.join(pluginDir, "custom-auth.ts")).href
const methods = yield* ProviderAuth.use
.methods()
.pipe(Effect.provide(providerAuthLayer(tmp.directory, [plugin])))
const plainMethods = yield* ProviderAuth.use
.methods()
.pipe(Effect.provide(providerAuthLayer(plain, [])), provideInstance(plain))
const override = methods[ProviderV2.ID.make("openai")]
expect(override).toBeDefined()
expect(override.length).toBe(1)
expect(override[0].label).toBe("Test Override Auth")
expect(plainMethods[ProviderV2.ID.make("openai")][0].label).not.toBe("Test Override Auth")
}),
{ git: true },
30000,
)
})
const file = path.join(import.meta.dir, "../../src/plugin/index.ts")
describe("plugin.config-hook-error-isolation", () => {
test("config hooks are individually error-isolated in the layer factory", async () => {
const src = await Bun.file(file).text()
// Each hook's config call is wrapped in Effect.tryPromise with error logging + Effect.ignore
expect(src).toContain("plugin config hook failed")
const pattern =
/for\s*\(const hook of hooks\)\s*\{[\s\S]*?Effect\.tryPromise[\s\S]*?\.config\?\.\([\s\S]*?plugin config hook failed[\s\S]*?Effect\.ignore/
expect(pattern.test(src)).toBe(true)
})
})
@@ -1,47 +0,0 @@
import { describe, expect, test } from "bun:test"
import type { Hooks, PluginInput } from "@opencode-ai/plugin"
import { CerebrasPlugin } from "../../src/plugin/cerebras"
type ChatParams = NonNullable<Hooks["chat.params"]>
function input(npm: string) {
return {
model: { api: { npm } },
} as Parameters<ChatParams>[0]
}
function output(options: Record<string, unknown>) {
return {
maxOutputTokens: 32_000,
options,
} as Parameters<ChatParams>[1]
}
describe("CerebrasPlugin", () => {
test("omits the generic output cap when max_completion_tokens is configured", async () => {
const hook = (await CerebrasPlugin({} as PluginInput))["chat.params"]!
const params = output({ max_completion_tokens: 64 })
await hook(input("@ai-sdk/cerebras"), params)
expect(params.maxOutputTokens).toBeUndefined()
})
test("preserves the generic output cap without max_completion_tokens", async () => {
const hook = (await CerebrasPlugin({} as PluginInput))["chat.params"]!
const params = output({})
await hook(input("@ai-sdk/cerebras"), params)
expect(params.maxOutputTokens).toBe(32_000)
})
test("does not change other providers", async () => {
const hook = (await CerebrasPlugin({} as PluginInput))["chat.params"]!
const params = output({ max_completion_tokens: 64 })
await hook(input("@ai-sdk/openai"), params)
expect(params.maxOutputTokens).toBe(32_000)
})
})
-463
View File
@@ -1,463 +0,0 @@
import { describe, expect, test } from "bun:test"
import { createServer, type IncomingMessage } from "node:http"
import { type AddressInfo } from "node:net"
import { WebSocketServer } from "ws"
import {
CodexAuthPlugin,
parseJwtClaims,
extractAccountIdFromClaims,
extractAccountId,
extractResidency,
renderOAuthError,
type IdTokenClaims,
} from "../../src/plugin/openai/codex"
function createTestJwt(payload: object): string {
const header = Buffer.from(JSON.stringify({ alg: "none" })).toString("base64url")
const body = Buffer.from(JSON.stringify(payload)).toString("base64url")
return `${header}.${body}.sig`
}
describe("plugin.codex", () => {
test("escapes provider errors in callback HTML", () => {
const error = `</div><script>alert("xss" & 'more')</script>`
const html = renderOAuthError(error)
expect(html).toContain("&lt;/div&gt;&lt;script&gt;alert(&quot;xss&quot; &amp; &#39;more&#39;)&lt;/script&gt;")
expect(html).not.toContain(error)
})
describe("parseJwtClaims", () => {
test("parses valid JWT with claims", () => {
const payload = { email: "test@example.com", chatgpt_account_id: "acc-123" }
const jwt = createTestJwt(payload)
const claims = parseJwtClaims(jwt)
expect(claims).toEqual(payload)
})
test("returns undefined for JWT with less than 3 parts", () => {
expect(parseJwtClaims("invalid")).toBeUndefined()
expect(parseJwtClaims("only.two")).toBeUndefined()
})
test("returns undefined for invalid base64", () => {
expect(parseJwtClaims("a.!!!invalid!!!.b")).toBeUndefined()
})
test("returns undefined for invalid JSON payload", () => {
const header = Buffer.from("{}").toString("base64url")
const invalidJson = Buffer.from("not json").toString("base64url")
expect(parseJwtClaims(`${header}.${invalidJson}.sig`)).toBeUndefined()
})
})
describe("extractAccountIdFromClaims", () => {
test("extracts chatgpt_account_id from root", () => {
const claims: IdTokenClaims = { chatgpt_account_id: "acc-root" }
expect(extractAccountIdFromClaims(claims)).toBe("acc-root")
})
test("extracts chatgpt_account_id from nested https://api.openai.com/auth", () => {
const claims: IdTokenClaims = {
"https://api.openai.com/auth": { chatgpt_account_id: "acc-nested" },
}
expect(extractAccountIdFromClaims(claims)).toBe("acc-nested")
})
test("prefers root over nested", () => {
const claims: IdTokenClaims = {
chatgpt_account_id: "acc-root",
"https://api.openai.com/auth": { chatgpt_account_id: "acc-nested" },
}
expect(extractAccountIdFromClaims(claims)).toBe("acc-root")
})
test("extracts from organizations array as fallback", () => {
const claims: IdTokenClaims = {
organizations: [{ id: "org-123" }, { id: "org-456" }],
}
expect(extractAccountIdFromClaims(claims)).toBe("org-123")
})
test("returns undefined when no accountId found", () => {
const claims: IdTokenClaims = { email: "test@example.com" }
expect(extractAccountIdFromClaims(claims)).toBeUndefined()
})
})
describe("extractAccountId", () => {
test("extracts from id_token first", () => {
const idToken = createTestJwt({ chatgpt_account_id: "from-id-token" })
const accessToken = createTestJwt({ chatgpt_account_id: "from-access-token" })
expect(
extractAccountId({
id_token: idToken,
access_token: accessToken,
refresh_token: "rt",
}),
).toBe("from-id-token")
})
test("falls back to access_token when id_token has no accountId", () => {
const idToken = createTestJwt({ email: "test@example.com" })
const accessToken = createTestJwt({
"https://api.openai.com/auth": { chatgpt_account_id: "from-access" },
})
expect(
extractAccountId({
id_token: idToken,
access_token: accessToken,
refresh_token: "rt",
}),
).toBe("from-access")
})
test("returns undefined when no tokens have accountId", () => {
const token = createTestJwt({ email: "test@example.com" })
expect(
extractAccountId({
id_token: token,
access_token: token,
refresh_token: "rt",
}),
).toBeUndefined()
})
test("handles missing id_token", () => {
const accessToken = createTestJwt({ chatgpt_account_id: "acc-123" })
expect(
extractAccountId({
id_token: "",
access_token: accessToken,
refresh_token: "rt",
}),
).toBe("acc-123")
})
})
describe("extractResidency", () => {
test("extracts compute residency from the namespaced auth claims", () => {
expect(
extractResidency(
createTestJwt({
"https://api.openai.com/auth": { chatgpt_compute_residency: "eu" },
}),
),
).toBe("eu")
})
test("falls back to a root compute residency claim", () => {
expect(extractResidency(createTestJwt({ chatgpt_compute_residency: "us" }))).toBe("us")
})
test("supports compute residency values without maintaining a region list", () => {
expect(
extractResidency(
createTestJwt({
"https://api.openai.com/auth": { chatgpt_compute_residency: "ae" },
}),
),
).toBe("ae")
expect(
extractResidency(
createTestJwt({
"https://api.openai.com/auth": { chatgpt_compute_residency: "future-region_1" },
}),
),
).toBe("future-region_1")
})
test("ignores unconstrained and data residency values", () => {
expect(
extractResidency(
createTestJwt({
"https://api.openai.com/auth": { chatgpt_compute_residency: "no_constraint" },
}),
),
).toBeUndefined()
expect(
extractResidency(
createTestJwt({
"https://api.openai.com/auth": { chatgpt_data_residency: "gb" },
}),
),
).toBeUndefined()
expect(extractResidency(createTestJwt({ chatgpt_compute_residency: "" }))).toBeUndefined()
expect(extractResidency("not-a-jwt")).toBeUndefined()
})
test("prefers a namespaced unconstrained value over a root residency", () => {
expect(
extractResidency(
createTestJwt({
chatgpt_compute_residency: "eu",
"https://api.openai.com/auth": { chatgpt_compute_residency: "no_constraint" },
}),
),
).toBeUndefined()
})
})
test("installs websocket transport only when experimental websockets are enabled", async () => {
const disabled = await CodexAuthPlugin({} as never)
const enabled = await CodexAuthPlugin({} as never, { experimentalWebSockets: true })
const disabledOptions = await disabled.auth!.loader!(
async () => ({ type: "api", key: "sk-test" }) as never,
{} as never,
)
const enabledOptions = await enabled.auth!.loader!(
async () => ({ type: "api", key: "sk-test" }) as never,
{} as never,
)
expect(disabledOptions.fetch).toBeUndefined()
expect(enabledOptions.fetch).toBeFunction()
await enabled.dispose?.()
})
test("sends token residency only to the ChatGPT Codex backend", async () => {
const requests: Array<{ path: string; residency: string | null }> = []
using server = Bun.serve({
port: 0,
fetch(request) {
requests.push({
path: new URL(request.url).pathname,
residency: request.headers.get("x-openai-internal-codex-residency"),
})
return new Response("{}")
},
})
const hooks = await CodexAuthPlugin({} as never, {
codexApiEndpoint: new URL("/backend-api/codex/responses", server.url).toString(),
})
const loaded = await hooks.auth!.loader!(
async () =>
({
type: "oauth",
refresh: "refresh",
access: createTestJwt({
"https://api.openai.com/auth": { chatgpt_compute_residency: "eu" },
}),
expires: Date.now() + 60_000,
}) as never,
{} as never,
)
await loaded.fetch!("https://api.openai.com/v1/responses")
await loaded.fetch!(new URL("/other", server.url))
expect(requests).toEqual([
{ path: "/backend-api/codex/responses", residency: "eu" },
{ path: "/other", residency: null },
])
})
test("sends token residency through the WebSocket transport", async () => {
await using server = await createCodexWebSocketServer()
const hooks = await CodexAuthPlugin({} as never, {
codexApiEndpoint: server.url,
experimentalWebSockets: true,
})
const loaded = await hooks.auth!.loader!(
async () =>
({
type: "oauth",
refresh: "refresh",
access: createTestJwt({
"https://api.openai.com/auth": { chatgpt_compute_residency: "eu" },
}),
expires: Date.now() + 60_000,
}) as never,
{} as never,
)
const response = await loaded.fetch!("https://api.openai.com/v1/responses", {
method: "POST",
headers: { "session-id": "session-1" },
body: JSON.stringify({ stream: true, input: "hi" }),
})
expect(await response.text()).toContain("data: [DONE]")
expect(server.headers()?.["x-openai-internal-codex-residency"]).toBe("eu")
await hooks.dispose?.()
})
test("filters unsupported modes and uses Codex context limits for OAuth GPT models", async () => {
const hooks = await CodexAuthPlugin({} as never)
const limit = { context: 1_050_000, input: 922_000, output: 128_000 }
const provider = {
models: {
...Object.fromEntries(
["gpt-5.4", "gpt-5.5", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", "gpt-5.7-pro"].map((id) => [
id,
{ id, api: { id }, limit, cost: {}, options: {} },
]),
),
"gpt-5.4-pro": {
id: "gpt-5.4-pro",
api: { id: "gpt-5.4" },
limit,
cost: {},
options: { reasoningMode: "pro" },
},
"gpt-5.6-sol-high": {
id: "gpt-5.6-sol-high",
api: { id: "gpt-5.6-sol" },
limit,
cost: {},
options: { reasoningEffort: "high" },
},
},
}
const models = await hooks.provider!.models!(provider as never, { auth: { type: "oauth" } } as never)
expect(models["gpt-5.4"]?.limit).toEqual(limit)
expect(models["gpt-5.5"]?.limit).toEqual({ context: 400_000, input: 272_000, output: 128_000 })
expect(models["gpt-5.6-sol"]?.limit).toEqual({ context: 400_000, input: 272_000, output: 128_000 })
expect(models["gpt-5.6-terra"]?.limit).toEqual({ context: 400_000, input: 272_000, output: 128_000 })
expect(models["gpt-5.6-luna"]?.limit).toEqual({ context: 400_000, input: 272_000, output: 128_000 })
expect(models["gpt-5.4-pro"]).toBeUndefined()
expect(models["gpt-5.7-pro"]).toBeDefined()
expect(models["gpt-5.6-sol-high"]).toBeDefined()
expect(await hooks.provider!.models!(provider as never, { auth: { type: "api" } } as never)).toBe(
provider.models as never,
)
})
test("deduplicates concurrent Codex token refreshes", async () => {
const refreshedAccess = createTestJwt({
"https://api.openai.com/auth": { chatgpt_compute_residency: "eu" },
})
let auth = {
type: "oauth" as const,
refresh: "refresh-old",
access: "",
expires: 0,
}
const authUpdates: Array<{
body: { refresh: string; access: string; expires: number; accountId?: string }
}> = []
let resolveRefresh: (() => void) | undefined
const refreshReady = new Promise<void>((resolve) => {
resolveRefresh = resolve
})
let refreshRequests = 0
const apiRequests: { authorization: string | null; accountId: string | null; residency: string | null }[] = []
using server = Bun.serve({
port: 0,
async fetch(request) {
const url = new URL(request.url)
if (url.pathname === "/oauth/token") {
expect(await request.text()).toContain("refresh_token=refresh-old")
refreshRequests += 1
await refreshReady
return Response.json({
id_token: createTestJwt({ chatgpt_account_id: "acc-123" }),
access_token: refreshedAccess,
refresh_token: "refresh-new",
expires_in: 3600,
})
}
if (url.pathname === "/backend-api/codex/responses") {
apiRequests.push({
authorization: request.headers.get("authorization"),
accountId: request.headers.get("ChatGPT-Account-Id"),
residency: request.headers.get("x-openai-internal-codex-residency"),
})
return new Response("{}", { status: 200 })
}
return new Response("unexpected request", { status: 500 })
},
})
const hooks = await CodexAuthPlugin(
{
client: {
auth: {
async set(input: { body: { refresh: string; access: string; expires: number; accountId?: string } }) {
authUpdates.push(input)
auth = {
type: "oauth",
refresh: input.body.refresh,
access: input.body.access,
expires: input.body.expires,
...(input.body.accountId && { accountId: input.body.accountId }),
}
},
},
} as never,
project: {} as never,
directory: "",
worktree: "",
experimental_workspace: {
register() {},
},
serverUrl: new URL("https://example.com"),
$: {} as never,
},
{
issuer: server.url.origin,
codexApiEndpoint: new URL("/backend-api/codex/responses", server.url).toString(),
},
)
const loaded = await hooks.auth!.loader!(async () => auth as never, {} as never)
const first = loaded.fetch!("https://api.openai.com/v1/responses")
const second = loaded.fetch!("https://api.openai.com/v1/responses")
await waitFor(() => refreshRequests === 1)
expect(apiRequests).toHaveLength(0)
resolveRefresh!()
await Promise.all([first, second])
expect(refreshRequests).toBe(1)
expect(authUpdates).toHaveLength(1)
expect(authUpdates[0]?.body.refresh).toBe("refresh-new")
expect(authUpdates[0]?.body.access).toBe(refreshedAccess)
expect(authUpdates[0]?.body.accountId).toBe("acc-123")
expect(apiRequests).toEqual([
{ authorization: `Bearer ${refreshedAccess}`, accountId: "acc-123", residency: "eu" },
{ authorization: `Bearer ${refreshedAccess}`, accountId: "acc-123", residency: "eu" },
])
})
})
async function waitFor(predicate: () => boolean) {
const started = Date.now()
while (!predicate()) {
if (Date.now() - started > 1_000) throw new Error("timed out waiting for condition")
await new Promise((resolve) => setTimeout(resolve, 1))
}
}
async function createCodexWebSocketServer() {
let headers: IncomingMessage["headers"] | undefined
const server = createServer()
const sockets = new WebSocketServer({ server })
sockets.on("connection", (socket, request) => {
headers = request.headers
socket.once("message", () => {
socket.send(JSON.stringify({ type: "response.completed", response: { id: "resp_123" } }))
})
})
await new Promise<void>((resolve, reject) => {
server.once("error", reject)
server.listen(0, "127.0.0.1", resolve)
})
const address = server.address() as AddressInfo
return {
url: `http://127.0.0.1:${address.port}/backend-api/codex/responses`,
headers: () => headers,
async [Symbol.asyncDispose]() {
for (const socket of sockets.clients) socket.terminate()
sockets.close()
server.close()
},
}
}
@@ -1,140 +0,0 @@
import { describe, expect, test } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { Process } from "@/util/process"
import { Filesystem } from "@/util/filesystem"
import { tmpdir } from "../fixture/fixture"
const root = path.join(import.meta.dir, "../..")
const worker = path.join(import.meta.dir, "../fixture/plug-worker.ts")
type Msg = {
dir: string
target: string
mod: string
holdMs?: number
}
function run(msg: Msg) {
return Process.run([process.execPath, worker, JSON.stringify(msg)], {
cwd: root,
nothrow: true,
})
}
async function plugin(dir: string, kinds: Array<"server" | "tui">) {
const p = path.join(dir, "plugin")
const server = kinds.includes("server")
const tui = kinds.includes("tui")
const exports: Record<string, string> = {}
if (server) exports["./server"] = "./server.js"
if (tui) exports["./tui"] = "./tui.js"
await fs.mkdir(p, { recursive: true })
await Bun.write(
path.join(p, "package.json"),
JSON.stringify(
{
name: "acme",
version: "1.0.0",
...(server ? { main: "./server.js" } : {}),
...(Object.keys(exports).length ? { exports } : {}),
},
null,
2,
),
)
return p
}
async function read(file: string) {
return Filesystem.readJson<{ plugin?: unknown[] }>(file)
}
function mods(prefix: string, n: number) {
return Array.from({ length: n }, (_, i) => `${prefix}-${i}@1.0.0`)
}
function expectPlugins(list: unknown[] | undefined, expectMods: string[]) {
expect(Array.isArray(list)).toBe(true)
const hit = (list ?? []).filter((item): item is string => typeof item === "string")
expect(hit.length).toBe(expectMods.length)
expect(new Set(hit)).toEqual(new Set(expectMods))
}
describe("plugin.install.concurrent", () => {
test("serializes concurrent server config updates across processes", async () => {
await using tmp = await tmpdir()
const target = await plugin(tmp.path, ["server"])
const all = mods("mod-server", 6)
const out = await Promise.all(
all.map((mod) =>
run({
dir: tmp.path,
target,
mod,
holdMs: 30,
}),
),
)
expect(out.map((x) => x.code)).toEqual(Array.from({ length: all.length }, () => 0))
expect(out.map((x) => x.stderr.toString()).filter(Boolean)).toEqual([])
const cfg = await read(path.join(tmp.path, ".opencode", "opencode.jsonc"))
expectPlugins(cfg.plugin, all)
}, 25_000)
test("serializes concurrent server+tui config updates across processes", async () => {
await using tmp = await tmpdir()
const target = await plugin(tmp.path, ["server", "tui"])
const all = mods("mod-both", 6)
const out = await Promise.all(
all.map((mod) =>
run({
dir: tmp.path,
target,
mod,
holdMs: 30,
}),
),
)
expect(out.map((x) => x.code)).toEqual(Array.from({ length: all.length }, () => 0))
expect(out.map((x) => x.stderr.toString()).filter(Boolean)).toEqual([])
const server = await read(path.join(tmp.path, ".opencode", "opencode.jsonc"))
const tui = await read(path.join(tmp.path, ".opencode", "tui.jsonc"))
expectPlugins(server.plugin, all)
expectPlugins(tui.plugin, all)
}, 25_000)
test("preserves updates when existing config uses .json", async () => {
await using tmp = await tmpdir()
const target = await plugin(tmp.path, ["server"])
const cfg = path.join(tmp.path, ".opencode", "opencode.json")
await fs.mkdir(path.dirname(cfg), { recursive: true })
await Bun.write(cfg, JSON.stringify({ plugin: ["seed@1.0.0"] }, null, 2))
const next = mods("mod-json", 5)
const out = await Promise.all(
next.map((mod) =>
run({
dir: tmp.path,
target,
mod,
holdMs: 30,
}),
),
)
expect(out.map((x) => x.code)).toEqual(Array.from({ length: next.length }, () => 0))
expect(out.map((x) => x.stderr.toString()).filter(Boolean)).toEqual([])
const json = await read(cfg)
expectPlugins(json.plugin, ["seed@1.0.0", ...next])
expect(await Filesystem.exists(path.join(tmp.path, ".opencode", "opencode.jsonc"))).toBe(false)
}, 25_000)
})
@@ -1,570 +0,0 @@
import { describe, expect, test } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { parse as parseJsonc } from "jsonc-parser"
import { Filesystem } from "@/util/filesystem"
import { createPlugTask, type PlugCtx, type PlugDeps } from "../../src/cli/cmd/plug"
import { tmpdir } from "../fixture/fixture"
function deps(global: string, target: string | Error): PlugDeps {
return {
spinner: () => ({
start() {},
stop() {},
}),
log: {
error() {},
info() {},
success() {},
},
resolve: async () => {
if (target instanceof Error) throw target
return target
},
readText: (file) => Filesystem.readText(file),
write: async (file, text) => {
await Filesystem.write(file, text)
},
exists: (file) => Filesystem.exists(file),
files: (dir, name) => [path.join(dir, `${name}.jsonc`), path.join(dir, `${name}.json`)],
global,
}
}
function ctx(dir: string): PlugCtx {
return {
vcs: "git",
worktree: dir,
directory: dir,
}
}
function ctxDir(dir: string, worktree: string): PlugCtx {
return {
vcs: "none",
worktree,
directory: dir,
}
}
function ctxRoot(dir: string): PlugCtx {
return {
vcs: "git",
worktree: "/",
directory: dir,
}
}
async function plugin(
dir: string,
kinds?: Array<"server" | "tui">,
opts?: {
server?: Record<string, unknown>
tui?: Record<string, unknown>
},
themes?: string[],
) {
const p = path.join(dir, "plugin")
const server = kinds?.includes("server") ?? false
const tui = kinds?.includes("tui") ?? false
const exports: Record<string, unknown> = {}
if (server) {
exports["./server"] = opts?.server
? {
import: "./server.js",
config: opts.server,
}
: "./server.js"
}
if (tui) {
exports["./tui"] = opts?.tui
? {
import: "./tui.js",
config: opts.tui,
}
: "./tui.js"
}
await fs.mkdir(p, { recursive: true })
await Bun.write(
path.join(p, "package.json"),
JSON.stringify(
{
name: "acme",
version: "1.0.0",
...(server ? { main: "./server.js" } : {}),
...(Object.keys(exports).length ? { exports } : {}),
...(themes?.length ? { "oc-themes": themes } : {}),
},
null,
2,
),
)
return p
}
async function read(file: string) {
return Filesystem.readJson<{
plugin?: unknown[]
}>(file)
}
describe("plugin.install.task", () => {
test("writes both server and tui config entries", async () => {
await using tmp = await tmpdir()
const target = await plugin(tmp.path, ["server", "tui"])
const run = createPlugTask(
{
mod: "acme@1.2.3",
},
deps(path.join(tmp.path, "global"), target),
)
const ok = await run(ctx(tmp.path))
expect(ok).toBe(true)
const server = await read(path.join(tmp.path, ".opencode", "opencode.jsonc"))
const tui = await read(path.join(tmp.path, ".opencode", "tui.jsonc"))
expect(server.plugin).toEqual(["acme@1.2.3"])
expect(tui.plugin).toEqual(["acme@1.2.3"])
})
test("writes default options from exports config metadata", async () => {
await using tmp = await tmpdir()
const target = await plugin(tmp.path, ["server", "tui"], {
server: { custom: true, other: false },
tui: { compact: true },
})
const run = createPlugTask(
{
mod: "acme@1.2.3",
},
deps(path.join(tmp.path, "global"), target),
)
const ok = await run(ctx(tmp.path))
expect(ok).toBe(true)
const server = await read(path.join(tmp.path, ".opencode", "opencode.jsonc"))
const tui = await read(path.join(tmp.path, ".opencode", "tui.jsonc"))
expect(server.plugin).toEqual([["acme@1.2.3", { custom: true, other: false }]])
expect(tui.plugin).toEqual([["acme@1.2.3", { compact: true }]])
})
test("preserves JSONC comments when adding plugins to server and tui config", async () => {
await using tmp = await tmpdir()
const target = await plugin(tmp.path, ["server", "tui"])
const cfg = path.join(tmp.path, ".opencode")
const server = path.join(cfg, "opencode.jsonc")
const tui = path.join(cfg, "tui.jsonc")
await fs.mkdir(cfg, { recursive: true })
await Bun.write(
server,
`{
// server head
"plugin": [
// server keep
"seed@1.0.0"
],
// server tail
"model": "x"
}
`,
)
await Bun.write(
tui,
`{
// tui head
"plugin": [
// tui keep
"seed@1.0.0"
],
// tui tail
"theme": "opencode"
}
`,
)
const run = createPlugTask(
{
mod: "acme@1.2.3",
},
deps(path.join(tmp.path, "global"), target),
)
const ok = await run(ctx(tmp.path))
expect(ok).toBe(true)
const serverText = await fs.readFile(server, "utf8")
const tuiText = await fs.readFile(tui, "utf8")
expect(serverText).toContain("// server head")
expect(serverText).toContain("// server keep")
expect(serverText).toContain("// server tail")
expect(tuiText).toContain("// tui head")
expect(tuiText).toContain("// tui keep")
expect(tuiText).toContain("// tui tail")
const serverJson = parseJsonc(serverText) as { plugin?: unknown[] }
const tuiJson = parseJsonc(tuiText) as { plugin?: unknown[] }
expect(serverJson.plugin).toEqual(["seed@1.0.0", "acme@1.2.3"])
expect(tuiJson.plugin).toEqual(["seed@1.0.0", "acme@1.2.3"])
})
test("preserves JSONC comments when force replacing plugin version", async () => {
await using tmp = await tmpdir()
const target = await plugin(tmp.path, ["server"])
const cfg = path.join(tmp.path, ".opencode", "opencode.jsonc")
await fs.mkdir(path.dirname(cfg), { recursive: true })
await Bun.write(
cfg,
`{
"plugin": [
// keep this note
"acme@1.0.0"
]
}
`,
)
const run = createPlugTask(
{
mod: "acme@2.0.0",
force: true,
},
deps(path.join(tmp.path, "global"), target),
)
const ok = await run(ctx(tmp.path))
expect(ok).toBe(true)
const text = await fs.readFile(cfg, "utf8")
expect(text).toContain("// keep this note")
const json = parseJsonc(text) as { plugin?: unknown[] }
expect(json.plugin).toEqual(["acme@2.0.0"])
})
test("supports resolver target pointing to a file", async () => {
await using tmp = await tmpdir()
const target = await plugin(tmp.path, ["server"])
const file = path.join(target, "index.js")
await Bun.write(file, "export {}")
const run = createPlugTask(
{
mod: "acme@1.2.3",
},
deps(path.join(tmp.path, "global"), file),
)
const ok = await run(ctx(tmp.path))
expect(ok).toBe(true)
const server = await read(path.join(tmp.path, ".opencode", "opencode.jsonc"))
expect(server.plugin).toEqual(["acme@1.2.3"])
})
test("does not change configured package version without force", async () => {
await using tmp = await tmpdir()
const target = await plugin(tmp.path, ["server"])
const cfg = path.join(tmp.path, ".opencode", "opencode.json")
await fs.mkdir(path.dirname(cfg), { recursive: true })
await Bun.write(cfg, JSON.stringify({ plugin: ["acme@1.0.0"] }, null, 2))
const run = createPlugTask(
{
mod: "acme@2.0.0",
},
deps(path.join(tmp.path, "global"), target),
)
const ok = await run(ctx(tmp.path))
expect(ok).toBe(true)
const json = await read(cfg)
expect(json.plugin).toEqual(["acme@1.0.0"])
})
test("does not change scoped package version without force", async () => {
await using tmp = await tmpdir()
const target = await plugin(tmp.path, ["server"])
const cfg = path.join(tmp.path, ".opencode", "opencode.json")
await fs.mkdir(path.dirname(cfg), { recursive: true })
await Bun.write(cfg, JSON.stringify({ plugin: ["@scope/acme@1.0.0"] }, null, 2))
const run = createPlugTask(
{
mod: "@scope/acme@2.0.0",
},
deps(path.join(tmp.path, "global"), target),
)
const ok = await run(ctx(tmp.path))
expect(ok).toBe(true)
const json = await read(cfg)
expect(json.plugin).toEqual(["@scope/acme@1.0.0"])
})
test("keeps file plugin entries and still adds npm plugin", async () => {
await using tmp = await tmpdir()
const target = await plugin(tmp.path, ["server"])
const cfg = path.join(tmp.path, ".opencode", "opencode.json")
await fs.mkdir(path.dirname(cfg), { recursive: true })
await Bun.write(cfg, JSON.stringify({ plugin: ["file:///tmp/acme.ts"] }, null, 2))
const run = createPlugTask(
{
mod: "acme@1.2.3",
},
deps(path.join(tmp.path, "global"), target),
)
const ok = await run(ctx(tmp.path))
expect(ok).toBe(true)
const json = await read(cfg)
expect(json.plugin).toEqual(["file:///tmp/acme.ts", "acme@1.2.3"])
})
test("force replaces configured package version and keeps tuple options", async () => {
await using tmp = await tmpdir()
const target = await plugin(tmp.path, ["server"])
const cfg = path.join(tmp.path, ".opencode", "opencode.json")
await fs.mkdir(path.dirname(cfg), { recursive: true })
await Bun.write(
cfg,
JSON.stringify(
{
plugin: [["acme@1.0.0", { mode: "safe" }], "acme@1.1.0", "other@1.0.0"],
},
null,
2,
),
)
const run = createPlugTask(
{
mod: "acme@2.0.0",
force: true,
},
deps(path.join(tmp.path, "global"), target),
)
const ok = await run(ctx(tmp.path))
expect(ok).toBe(true)
const json = await read(cfg)
expect(json.plugin).toEqual([["acme@2.0.0", { mode: "safe" }], "other@1.0.0"])
})
test("writes to global scope when global flag is set", async () => {
await using tmp = await tmpdir()
const target = await plugin(tmp.path, ["server"])
const global = path.join(tmp.path, "global")
const run = createPlugTask(
{
mod: "acme@1.2.3",
global: true,
},
deps(global, target),
)
const ok = await run(ctx(tmp.path))
expect(ok).toBe(true)
expect(await Filesystem.exists(path.join(global, "opencode.jsonc"))).toBe(true)
expect(await Filesystem.exists(path.join(tmp.path, ".opencode", "opencode.jsonc"))).toBe(false)
})
test("writes local scope under directory when vcs is not git", async () => {
await using tmp = await tmpdir()
const target = await plugin(tmp.path, ["server"])
const directory = path.join(tmp.path, "dir")
const worktree = path.join(tmp.path, "worktree")
await fs.mkdir(directory, { recursive: true })
await fs.mkdir(worktree, { recursive: true })
const run = createPlugTask(
{
mod: "acme@1.2.3",
},
deps(path.join(tmp.path, "global"), target),
)
const ok = await run(ctxDir(directory, worktree))
expect(ok).toBe(true)
expect(await Filesystem.exists(path.join(directory, ".opencode", "opencode.jsonc"))).toBe(true)
expect(await Filesystem.exists(path.join(worktree, ".opencode", "opencode.jsonc"))).toBe(false)
})
test("writes local scope under directory when worktree is root slash", async () => {
await using tmp = await tmpdir()
const target = await plugin(tmp.path, ["server"])
const directory = path.join(tmp.path, "dir")
await fs.mkdir(directory, { recursive: true })
const run = createPlugTask(
{
mod: "acme@1.2.3",
},
deps(path.join(tmp.path, "global"), target),
)
const ok = await run(ctxRoot(directory))
expect(ok).toBe(true)
expect(await Filesystem.exists(path.join(directory, ".opencode", "opencode.jsonc"))).toBe(true)
})
test("writes tui local scope under directory when worktree is root slash", async () => {
await using tmp = await tmpdir()
const target = await plugin(tmp.path, ["tui"])
const directory = path.join(tmp.path, "dir")
await fs.mkdir(directory, { recursive: true })
const run = createPlugTask(
{
mod: "acme@1.2.3",
},
deps(path.join(tmp.path, "global"), target),
)
const ok = await run(ctxRoot(directory))
expect(ok).toBe(true)
expect(await Filesystem.exists(path.join(directory, ".opencode", "tui.jsonc"))).toBe(true)
})
test("writes only tui config for tui-only plugins", async () => {
await using tmp = await tmpdir()
const target = await plugin(tmp.path, ["tui"])
const run = createPlugTask(
{
mod: "acme@1.2.3",
},
deps(path.join(tmp.path, "global"), target),
)
const ok = await run(ctx(tmp.path))
expect(ok).toBe(true)
expect(await Filesystem.exists(path.join(tmp.path, ".opencode", "tui.jsonc"))).toBe(true)
expect(await Filesystem.exists(path.join(tmp.path, ".opencode", "opencode.jsonc"))).toBe(false)
})
test("writes tui config for oc-themes-only packages", async () => {
await using tmp = await tmpdir()
const target = await plugin(tmp.path, undefined, undefined, ["themes/forest.json"])
await fs.mkdir(path.join(target, "themes"), { recursive: true })
await Bun.write(path.join(target, "themes", "forest.json"), JSON.stringify({ theme: { text: "#fff" } }, null, 2))
const run = createPlugTask(
{
mod: "acme@1.2.3",
},
deps(path.join(tmp.path, "global"), target),
)
const ok = await run(ctx(tmp.path))
expect(ok).toBe(true)
expect(await Filesystem.exists(path.join(tmp.path, ".opencode", "tui.jsonc"))).toBe(true)
expect(await Filesystem.exists(path.join(tmp.path, ".opencode", "opencode.jsonc"))).toBe(false)
const tui = await read(path.join(tmp.path, ".opencode", "tui.jsonc"))
expect(tui.plugin).toEqual(["acme@1.2.3"])
})
test("returns false for oc-themes outside plugin directory", async () => {
await using tmp = await tmpdir()
const target = await plugin(tmp.path, undefined, undefined, ["../outside.json"])
const run = createPlugTask(
{
mod: "acme@1.2.3",
},
deps(path.join(tmp.path, "global"), target),
)
const ok = await run(ctx(tmp.path))
expect(ok).toBe(false)
expect(await Filesystem.exists(path.join(tmp.path, ".opencode", "tui.jsonc"))).toBe(false)
expect(await Filesystem.exists(path.join(tmp.path, ".opencode", "opencode.jsonc"))).toBe(false)
})
test("force replaces version in both server and tui configs", async () => {
await using tmp = await tmpdir()
const target = await plugin(tmp.path, ["server", "tui"])
const server = path.join(tmp.path, ".opencode", "opencode.json")
const tui = path.join(tmp.path, ".opencode", "tui.json")
await fs.mkdir(path.dirname(server), { recursive: true })
await Bun.write(server, JSON.stringify({ plugin: ["acme@1.0.0", "other@1.0.0"] }, null, 2))
await Bun.write(tui, JSON.stringify({ plugin: [["acme@1.0.0", { mode: "safe" }], "other@1.0.0"] }, null, 2))
const run = createPlugTask(
{
mod: "acme@2.0.0",
force: true,
},
deps(path.join(tmp.path, "global"), target),
)
const ok = await run(ctx(tmp.path))
expect(ok).toBe(true)
const serverJson = await read(server)
const tuiJson = await read(tui)
expect(serverJson.plugin).toEqual(["acme@2.0.0", "other@1.0.0"])
expect(tuiJson.plugin).toEqual([["acme@2.0.0", { mode: "safe" }], "other@1.0.0"])
})
test("returns false and keeps config unchanged for invalid JSONC", async () => {
await using tmp = await tmpdir()
const target = await plugin(tmp.path, ["server"])
const cfg = path.join(tmp.path, ".opencode", "opencode.jsonc")
await fs.mkdir(path.dirname(cfg), { recursive: true })
const bad = '{"plugin": ["acme@1.0.0",}'
await Bun.write(cfg, bad)
const run = createPlugTask(
{
mod: "acme@2.0.0",
},
deps(path.join(tmp.path, "global"), target),
)
const ok = await run(ctx(tmp.path))
expect(ok).toBe(false)
expect(await fs.readFile(cfg, "utf8")).toBe(bad)
})
test("returns false when manifest declares no supported targets", async () => {
await using tmp = await tmpdir()
const target = await plugin(tmp.path)
const run = createPlugTask(
{
mod: "acme@1.2.3",
},
deps(path.join(tmp.path, "global"), target),
)
const ok = await run(ctx(tmp.path))
expect(ok).toBe(false)
expect(await Filesystem.exists(path.join(tmp.path, ".opencode", "opencode.jsonc"))).toBe(false)
expect(await Filesystem.exists(path.join(tmp.path, ".opencode", "tui.jsonc"))).toBe(false)
})
test("returns false when manifest cannot be read", async () => {
await using tmp = await tmpdir()
const target = path.join(tmp.path, "plugin")
await fs.mkdir(target, { recursive: true })
const run = createPlugTask(
{
mod: "acme@1.2.3",
},
deps(path.join(tmp.path, "global"), target),
)
const ok = await run(ctx(tmp.path))
expect(ok).toBe(false)
expect(await Filesystem.exists(path.join(tmp.path, ".opencode", "opencode.jsonc"))).toBe(false)
})
test("returns false when install fails", async () => {
await using tmp = await tmpdir()
const run = createPlugTask(
{
mod: "acme@9.9.9",
},
deps(path.join(tmp.path, "global"), new Error("boom")),
)
const ok = await run(ctx(tmp.path))
expect(ok).toBe(false)
expect(await Filesystem.exists(path.join(tmp.path, ".opencode", "opencode.jsonc"))).toBe(false)
})
})
File diff suppressed because it is too large Load Diff
-137
View File
@@ -1,137 +0,0 @@
import { afterEach, describe, expect, test } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { pathToFileURL } from "url"
import { tmpdir } from "../fixture/fixture"
import { Process } from "@/util/process"
import { Filesystem } from "@/util/filesystem"
const { PluginMeta } = await import("../../src/plugin/meta")
const root = path.join(import.meta.dir, "../..")
const worker = path.join(import.meta.dir, "../fixture/plugin-meta-worker.ts")
function run(input: { file: string; spec: string; target: string; id: string }) {
return Process.run([process.execPath, worker, JSON.stringify(input)], {
cwd: root,
nothrow: true,
})
}
async function map<Value>(file: string): Promise<Record<string, Value>> {
return Filesystem.readJson<Record<string, Value>>(file)
}
afterEach(() => {
delete process.env.OPENCODE_PLUGIN_META_FILE
})
describe("plugin.meta", () => {
test("tracks file plugin loads and changes", async () => {
await using tmp = await tmpdir<{ file: string }>({
init: async (dir) => {
const file = path.join(dir, "plugin.ts")
await Bun.write(file, "export default async () => ({})\n")
return { file }
},
})
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "state", "plugin-meta.json")
const file = process.env.OPENCODE_PLUGIN_META_FILE!
const spec = pathToFileURL(tmp.extra.file).href
const one = await PluginMeta.touch(spec, spec, "demo.file")
expect(one.state).toBe("first")
expect(one.entry.source).toBe("file")
expect(one.entry.id).toBe("demo.file")
expect(one.entry.modified).toBeDefined()
const two = await PluginMeta.touch(spec, spec, "demo.file")
expect(two.state).toBe("same")
expect(two.entry.load_count).toBe(2)
await Bun.write(tmp.extra.file, "export default async () => ({ ok: true })\n")
const stamp = new Date(Date.now() + 10_000)
await fs.utimes(tmp.extra.file, stamp, stamp)
const three = await PluginMeta.touch(spec, spec, "demo.file")
expect(three.state).toBe("updated")
expect(three.entry.load_count).toBe(3)
expect((three.entry.modified ?? 0) > (one.entry.modified ?? 0)).toBe(true)
const all = await PluginMeta.list()
expect(Object.values(all).some((item) => item.spec === spec && item.source === "file")).toBe(true)
const saved = await map<{ spec: string; load_count: number }>(file)
expect(saved["demo.file"]?.spec).toBe(spec)
expect(saved["demo.file"]?.load_count).toBe(3)
})
test("tracks npm plugin versions", async () => {
await using tmp = await tmpdir<{ mod: string; pkg: string }>({
init: async (dir) => {
const mod = path.join(dir, "node_modules", "acme-plugin")
const pkg = path.join(mod, "package.json")
await fs.mkdir(mod, { recursive: true })
await Bun.write(pkg, JSON.stringify({ name: "acme-plugin", version: "1.0.0" }, null, 2))
return { mod, pkg }
},
})
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "state", "plugin-meta.json")
const file = process.env.OPENCODE_PLUGIN_META_FILE!
const one = await PluginMeta.touch("acme-plugin@latest", tmp.extra.mod, "acme-plugin")
expect(one.state).toBe("first")
expect(one.entry.source).toBe("npm")
expect(one.entry.requested).toBe("latest")
expect(one.entry.version).toBe("1.0.0")
await Bun.write(tmp.extra.pkg, JSON.stringify({ name: "acme-plugin", version: "1.1.0" }, null, 2))
const two = await PluginMeta.touch("acme-plugin@latest", tmp.extra.mod, "acme-plugin")
expect(two.state).toBe("updated")
expect(two.entry.version).toBe("1.1.0")
expect(two.entry.load_count).toBe(2)
const all = await PluginMeta.list()
expect(Object.values(all).some((item) => item.id === "acme-plugin" && item.version === "1.1.0")).toBe(true)
const saved = await map<{ id: string; version?: string }>(file)
expect(Object.values(saved).some((item) => item.id === "acme-plugin" && item.version === "1.1.0")).toBe(true)
})
test("serializes concurrent metadata updates across processes", async () => {
await using tmp = await tmpdir<{ file: string }>({
init: async (dir) => {
const file = path.join(dir, "plugin.ts")
await Bun.write(file, "export default async () => ({})\n")
return { file }
},
})
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "state", "plugin-meta.json")
const file = process.env.OPENCODE_PLUGIN_META_FILE!
const spec = pathToFileURL(tmp.extra.file).href
const n = 12
const out = await Promise.all(
Array.from({ length: n }, () =>
run({
file,
spec,
target: spec,
id: "demo.file",
}),
),
)
expect(out.map((item) => item.code)).toEqual(Array.from({ length: n }, () => 0))
expect(out.map((item) => item.stderr.toString()).filter(Boolean)).toEqual([])
const all = await PluginMeta.list()
const hit = Object.values(all).find((item) => item.spec === spec)
expect(hit?.load_count).toBe(n)
const saved = await map<{ spec: string; load_count: number }>(file)
expect(Object.values(saved).find((item) => item.spec === spec)?.load_count).toBe(n)
}, 20_000)
})
@@ -1,17 +0,0 @@
import { describe, expect, test } from "bun:test"
import { experimentalWebSocketsEnabled } from "../../src/plugin"
describe("plugin.openai.websocket rollout", () => {
test("enables websockets by default only on pre-release channels", () => {
expect(experimentalWebSocketsEnabled({ enabled: false, channel: "local" })).toBe(true)
expect(experimentalWebSocketsEnabled({ enabled: false, channel: "dev" })).toBe(true)
expect(experimentalWebSocketsEnabled({ enabled: false, channel: "beta" })).toBe(true)
expect(experimentalWebSocketsEnabled({ enabled: false, channel: "latest" })).toBe(false)
expect(experimentalWebSocketsEnabled({ enabled: false, channel: "prod" })).toBe(false)
})
test("allows releases to opt in through the experimental flag", () => {
expect(experimentalWebSocketsEnabled({ enabled: true, channel: "latest" })).toBe(true)
expect(experimentalWebSocketsEnabled({ enabled: true, channel: "prod" })).toBe(true)
})
})
@@ -1,909 +0,0 @@
import { describe, expect, test } from "bun:test"
import { EventEmitter } from "node:events"
import { createServer, type IncomingMessage, type Server as HttpServer } from "node:http"
import net, { type AddressInfo, type Socket } from "node:net"
import WebSocket, { WebSocketServer } from "ws"
import { APICallError } from "ai"
import { ProviderError } from "../../src/provider/error"
import { OpenAIWebSocket } from "../../src/plugin/openai/ws"
import { OpenAIWebSocketPool, TITLE_HEADER } from "../../src/plugin/openai/ws-pool"
describe("plugin.openai.ws", () => {
test("derives websocket URLs and sends auth plus protocol headers", async () => {
let headers: IncomingMessage["headers"] | undefined
await using server = await createWebSocketServer((_socket, request) => {
headers = request.headers
})
const socket = await OpenAIWebSocket.connectResponsesWebSocket({
url: server.wsUrl,
headers: {
authorization: "Bearer test",
"content-length": "123",
"x-openai-internal-codex-residency": "eu",
},
})
expect(OpenAIWebSocket.toWebSocketUrl("http://example.com/v1/responses")).toBe("ws://example.com/v1/responses")
expect(OpenAIWebSocket.toWebSocketUrl("https://example.com/v1/responses")).toBe("wss://example.com/v1/responses")
expect(headers?.authorization).toBe("Bearer test")
expect(headers?.["openai-beta"]).toBe(OpenAIWebSocket.PROTOCOL_HEADER)
expect(headers?.["x-openai-internal-codex-residency"]).toBe("eu")
expect(headers?.["content-length"]).toBeUndefined()
socket.terminate()
})
test("enforces websocket connect timeout", async () => {
await using server = await createHangingTcpServer()
await expect(
OpenAIWebSocket.connectResponsesWebSocket({
url: server.wsUrl,
headers: {},
timeout: 20,
}),
).rejects.toThrow("WebSocket connect timed out")
})
test("surfaces websocket upgrade rejection messages", async () => {
await using server = await createRejectingWebSocketServer(() => {})
await expect(
OpenAIWebSocket.connectResponsesWebSocket({
url: server.wsUrl,
headers: {},
}),
).rejects.toThrow("Expected 101 status code")
})
test("enforces websocket send idle timeout", async () => {
const socket = new (class extends EventEmitter {
send(_data: string, _callback: (error?: Error) => void) {}
})() as unknown as WebSocket
const invalid: string[] = []
const response = OpenAIWebSocket.streamResponsesWebSocket({
socket,
body: { stream: true, input: "hi" },
idleTimeout: 20,
onConnectionInvalid: (error) => invalid.push(error.message),
})
expect((await readTextError(response.text())).message).toContain("idle timeout sending websocket request")
expect(invalid).toEqual(["idle timeout sending websocket request"])
})
test("streams websocket events as SSE and handles response.done", async () => {
let requestBody: unknown
await using server = await createWebSocketServer((socket) => {
socket.once("message", (data) => {
requestBody = JSON.parse(data.toString())
socket.send(JSON.stringify({ type: "response.output_text.delta", delta: "hello" }))
socket.send(JSON.stringify({ type: "response.done", response: { id: "resp_123" } }))
socket.close(1000, "done")
})
})
const socket = await OpenAIWebSocket.connectResponsesWebSocket({
url: server.wsUrl,
headers: { authorization: "Bearer test", "content-length": "123" },
})
const completed: Record<string, unknown>[] = []
const response = OpenAIWebSocket.streamResponsesWebSocket({
socket,
body: { stream: true, background: true, input: "hi" },
onComplete: (event) => completed.push(event),
})
expect(await response.text()).toBe(
'data: {"type":"response.output_text.delta","delta":"hello"}\n\ndata: {"type":"response.done","response":{"id":"resp_123"}}\n\ndata: [DONE]\n\n',
)
expect(requestBody).toEqual({ type: "response.create", input: "hi" })
expect(completed).toHaveLength(1)
expect(completed[0]?.type).toBe("response.done")
})
test("errors the SSE stream when the server closes before a terminal event", async () => {
const invalid: Error[] = []
await using server = await createWebSocketServer((socket) => {
socket.once("message", () => {
socket.close(1009, "payload too large")
})
})
const socket = await OpenAIWebSocket.connectResponsesWebSocket({ url: server.wsUrl, headers: {} })
const response = OpenAIWebSocket.streamResponsesWebSocket({
socket,
body: { stream: true, input: "hi" },
onConnectionInvalid: (error) => invalid.push(error),
})
expect((await readTextError(response.text())).message).toContain(
"WebSocket closed before response.completed (code 1009: message too big: payload too large)",
)
expect(invalid[0]).toBeInstanceOf(ProviderError.ResponseStreamError)
expect(invalid.map((error) => error.message)).toEqual([
"WebSocket closed before response.completed (code 1009: message too big: payload too large)",
])
})
test("rejects unexpected binary websocket frames", async () => {
const invalid: string[] = []
await using server = await createWebSocketServer((socket) => {
socket.once("message", () => {
socket.send(Buffer.from("not json text"))
})
})
const socket = await OpenAIWebSocket.connectResponsesWebSocket({ url: server.wsUrl, headers: {} })
const response = OpenAIWebSocket.streamResponsesWebSocket({
socket,
body: { stream: true, input: "hi" },
onConnectionInvalid: (error) => invalid.push(error.message),
})
expect((await readTextError(response.text())).message).toContain("Unexpected binary WebSocket frame")
expect(invalid).toEqual(["Unexpected binary WebSocket frame"])
})
})
describe("plugin.openai.ws-pool", () => {
test("reuses one healthy websocket for sequential requests", async () => {
let connections = 0
let messages = 0
await using server = await createWebSocketServer((socket) => {
connections += 1
socket.on("message", () => {
messages += 1
socket.send(JSON.stringify({ type: "response.completed", response: { id: `resp_${messages}` } }))
})
})
const fetch = OpenAIWebSocketPool.createWebSocketFetch({
url: server.url,
})
const first = await fetch(server.url, streamRequest())
expect(await first.text()).toContain("data: [DONE]")
const second = await fetch(server.url, streamRequest())
expect(await second.text()).toContain("data: [DONE]")
expect(connections).toBe(1)
expect(messages).toBe(2)
fetch.close()
})
test("rotates a socket that exceeds max connection age", async () => {
let connections = 0
await using server = await createWebSocketServer((socket) => {
connections += 1
socket.on("message", () => {
socket.send(JSON.stringify({ type: "response.completed", response: { id: `resp_${connections}` } }))
})
})
const fetch = OpenAIWebSocketPool.createWebSocketFetch({
url: server.url,
maxConnectionAge: 0,
})
const first = await fetch(server.url, streamRequest())
expect(await first.text()).toContain("data: [DONE]")
const second = await fetch(server.url, streamRequest())
expect(await second.text()).toContain("data: [DONE]")
expect(connections).toBe(2)
fetch.close()
})
test("falls back to HTTP after websocket setup retries are exhausted", async () => {
const attempts: string[] = []
await using server = await createRejectingWebSocketServer(() => attempts.push("websocket"))
const fetch = OpenAIWebSocketPool.createWebSocketFetch({
url: server.url,
connectTimeout: 100,
streamRetries: 1,
})
const first = await fetch(server.url, streamRequest({ [TITLE_HEADER]: "false" }))
expect(await readTextError(first.text())).toBeInstanceOf(ProviderError.ResponseStreamError)
const second = await fetch(server.url, streamRequest({ [TITLE_HEADER]: "false" }))
const third = await fetch(server.url, streamRequest({ [TITLE_HEADER]: "false" }))
expect(await second.text()).toBe("http")
expect(await third.text()).toBe("http")
expect(attempts).toEqual(["websocket", "websocket"])
expect(server.httpRequests).toHaveLength(2)
expect(server.httpRequests[0]?.headers[TITLE_HEADER]).toBeUndefined()
expect(server.httpRequests[1]?.headers[TITLE_HEADER]).toBeUndefined()
fetch.close()
})
test("keeps HTTP fallback active after its idle timeout", async () => {
let websocketAttempts = 0
await using server = await createRejectingWebSocketServer(() => websocketAttempts++)
const fetch = OpenAIWebSocketPool.createWebSocketFetch({
url: server.url,
connectTimeout: 100,
idleTimeout: 20,
streamRetries: 0,
})
const first = await fetch(server.url, streamRequest())
expect(await first.text()).toBe("http")
await new Promise((resolve) => setTimeout(resolve, 50))
const second = await fetch(server.url, streamRequest())
expect(await second.text()).toBe("http")
expect(websocketAttempts).toBe(1)
expect(server.httpRequests).toHaveLength(2)
fetch.close()
})
test("falls back immediately to HTTP when a websocket request is too large", async () => {
let connections = 0
await using server = await createWebSocketServer((socket) => {
connections += 1
socket.once("message", () => socket.close(1009, "payload too large"))
})
const fetch = OpenAIWebSocketPool.createWebSocketFetch({
url: server.url,
})
const first = await fetch(server.url, streamRequest())
const second = await fetch(server.url, streamRequest())
expect(await first.text()).toBe("http")
expect(await second.text()).toBe("http")
expect(connections).toBe(1)
expect(server.httpRequests).toHaveLength(2)
fetch.close()
})
test("removes HTTP fallback when its session is deleted", async () => {
let websocketAttempts = 0
await using server = await createRejectingWebSocketServer(() => websocketAttempts++)
const fetch = OpenAIWebSocketPool.createWebSocketFetch({
url: server.url,
connectTimeout: 100,
streamRetries: 0,
})
const first = await fetch(server.url, streamRequest())
expect(await first.text()).toBe("http")
fetch.remove("session-1")
const second = await fetch(server.url, streamRequest())
expect(await second.text()).toBe("http")
expect(websocketAttempts).toBe(2)
expect(server.httpRequests).toHaveLength(2)
fetch.close()
})
test("terminates active websocket connections when their session is deleted", async () => {
let connections = 0
await using server = await createWebSocketServer((socket) => {
connections += 1
socket.once("message", () => {
if (connections === 1) {
socket.send(JSON.stringify({ type: "response.output_text.delta", delta: "started" }))
return
}
socket.send(JSON.stringify({ type: "response.completed", response: { id: "resp_after_remove" } }))
})
})
const fetch = OpenAIWebSocketPool.createWebSocketFetch({
url: server.url,
})
const first = await fetch(server.url, streamRequest())
const firstText = first.text()
fetch.remove("session-1")
expect((await readTextError(firstText)).message).toContain("WebSocket closed before response.completed")
const second = await fetch(server.url, streamRequest())
expect(await second.text()).toContain("data: [DONE]")
expect(connections).toBe(2)
fetch.close()
})
test("prunes idle websocket connections after completed responses", async () => {
let connections = 0
let closed = 0
await using server = await createWebSocketServer((socket) => {
connections += 1
socket.once("close", () => closed++)
socket.once("message", () => {
socket.send(JSON.stringify({ type: "response.completed", response: { id: `resp_${connections}` } }))
})
})
const fetch = OpenAIWebSocketPool.createWebSocketFetch({
url: server.url,
idleTimeout: 20,
})
const first = await fetch(server.url, streamRequest())
expect(await first.text()).toContain("data: [DONE]")
await waitFor(() => closed === 1, "idle websocket was not pruned")
const second = await fetch(server.url, streamRequest())
expect(await second.text()).toContain("data: [DONE]")
expect(connections).toBe(2)
fetch.close()
})
test("invalidates but does not reuse a socket after terminal failure frames", async () => {
let connections = 0
await using server = await createWebSocketServer((socket) => {
connections += 1
socket.once("message", () => {
socket.send(JSON.stringify({ type: connections === 1 ? "response.failed" : "response.completed" }))
})
})
const fetch = OpenAIWebSocketPool.createWebSocketFetch({
url: server.url,
})
const first = await fetch(server.url, streamRequest())
expect(await first.text()).toContain('data: {"type":"response.failed"}')
const second = await fetch(server.url, streamRequest())
expect(await second.text()).toContain('data: {"type":"response.completed"}')
expect(connections).toBe(2)
expect(server.httpRequests).toHaveLength(0)
fetch.close()
})
test("returns initial websocket error frames as HTTP-style API errors", async () => {
const error = {
type: "invalid_request_error",
message: "The model is not supported when using Codex with a ChatGPT account.",
}
const event = {
type: "error",
status: 400,
error,
headers: {
"x-codex-primary-window-minutes": 15,
ignored: { nested: true },
},
}
await using server = await createWebSocketServer((socket) => {
socket.once("message", () => {
socket.send(JSON.stringify(event))
})
})
const fetch = OpenAIWebSocketPool.createWebSocketFetch({
url: server.url,
})
const response = await fetch(server.url, streamRequest())
expect(response.status).toBe(400)
expect(response.headers.get("content-type")).toContain("application/json")
expect(response.headers.get("x-codex-primary-window-minutes")).toBe("15")
expect(response.headers.get("ignored")).toBeNull()
expect(await response.json()).toEqual(event)
fetch.close()
})
test("fails mid-stream wrapped websocket errors as HTTP-style API errors", async () => {
const event = {
type: "error",
status_code: 429,
error: {
type: "usage_limit_reached",
message: "The usage limit has been reached",
},
headers: {
"x-codex-primary-used-percent": "100.0",
},
}
await using server = await createWebSocketServer((socket) => {
socket.once("message", () => {
socket.send(JSON.stringify({ type: "response.output_text.delta", delta: "started" }))
socket.send(JSON.stringify(event))
})
})
const fetch = OpenAIWebSocketPool.createWebSocketFetch({
url: server.url,
})
const response = await fetch(server.url, streamRequest())
const error = await readTextError(response.text())
expect(APICallError.isInstance(error)).toBe(true)
if (!APICallError.isInstance(error)) throw new Error("Expected APICallError")
expect(error.statusCode).toBe(429)
expect(error.responseHeaders).toEqual({ "x-codex-primary-used-percent": "100.0" })
expect(error.responseBody).toBe(JSON.stringify(event))
fetch.close()
})
test("retries websocket connection limit errors on the next stream attempt", async () => {
let connections = 0
let messages = 0
await using server = await createWebSocketServer((socket) => {
connections += 1
socket.once("message", () => {
messages += 1
if (connections === 1) {
socket.send(
JSON.stringify({
type: "error",
status: 400,
error: {
type: "invalid_request_error",
code: "websocket_connection_limit_reached",
message: "Responses websocket connection limit reached",
},
}),
)
return
}
socket.send(JSON.stringify({ type: "response.completed", response: { id: "resp_retry" } }))
})
})
const fetch = OpenAIWebSocketPool.createWebSocketFetch({
url: server.url,
})
const first = await fetch(server.url, streamRequest())
expect((await readTextError(first.text())).message).toContain("Responses websocket connection limit reached")
const second = await fetch(server.url, streamRequest())
const text = await second.text()
expect(text).not.toContain("websocket_connection_limit_reached")
expect(text).toContain('data: {"type":"response.completed","response":{"id":"resp_retry"}}')
expect(text).toContain("data: [DONE]")
expect(connections).toBe(2)
expect(messages).toBe(2)
expect(server.httpRequests).toHaveLength(0)
fetch.close()
})
test("falls back to HTTP after websocket connection limit retries are exhausted", async () => {
let connections = 0
await using server = await createWebSocketServer((socket) => {
connections += 1
socket.once("message", () => {
socket.send(
JSON.stringify({
type: "error",
status: 400,
error: {
type: "invalid_request_error",
code: "websocket_connection_limit_reached",
message: "Responses websocket connection limit reached",
},
}),
)
})
})
const fetch = OpenAIWebSocketPool.createWebSocketFetch({
url: server.url,
streamRetries: 2,
})
const first = await fetch(server.url, streamRequest())
expect((await readTextError(first.text())).message).toContain("Responses websocket connection limit reached")
const second = await fetch(server.url, streamRequest())
expect((await readTextError(second.text())).message).toContain("Responses websocket connection limit reached")
const third = await fetch(server.url, streamRequest())
const fourth = await fetch(server.url, streamRequest())
expect(await third.text()).toBe("http")
expect(await fourth.text()).toBe("http")
expect(connections).toBe(3)
expect(server.httpRequests).toHaveLength(2)
fetch.close()
})
test("shares the websocket retry budget across stream and connection limit failures", async () => {
let connections = 0
await using server = await createWebSocketServer((socket) => {
connections += 1
socket.once("message", () => {
if (connections === 1) {
socket.send(JSON.stringify({ type: "response.output_text.delta", delta: "started" }))
socket.terminate()
return
}
socket.send(
JSON.stringify({
type: "error",
error: {
code: "websocket_connection_limit_reached",
message: "Responses websocket connection limit reached",
},
}),
)
})
})
const fetch = OpenAIWebSocketPool.createWebSocketFetch({
url: server.url,
streamRetries: 1,
})
const first = await fetch(server.url, streamRequest())
expect((await readTextError(first.text())).message).toContain("WebSocket closed before response.completed")
const second = await fetch(server.url, streamRequest())
expect(await second.text()).toBe("http")
expect(connections).toBe(2)
expect(server.httpRequests).toHaveLength(1)
fetch.close()
})
test("retries websocket idle failures before first event then falls back to HTTP", async () => {
let connections = 0
await using server = await createWebSocketServer((socket) => {
connections += 1
socket.once("message", () => {})
})
const fetch = OpenAIWebSocketPool.createWebSocketFetch({
url: server.url,
idleTimeout: 20,
streamRetries: 1,
})
const first = await fetch(server.url, streamRequest())
expect((await readTextError(first.text())).message).toContain("idle timeout waiting for websocket")
const second = await fetch(server.url, streamRequest())
const third = await fetch(server.url, streamRequest())
expect(await second.text()).toBe("http")
expect(await third.text()).toBe("http")
expect(connections).toBe(2)
expect(server.httpRequests).toHaveLength(2)
fetch.close()
})
test("keeps websocket retry state until the failed stream becomes idle", async () => {
let connections = 0
await using server = await createWebSocketServer((socket) => {
connections += 1
socket.once("message", () => {})
})
const fetch = OpenAIWebSocketPool.createWebSocketFetch({
url: server.url,
idleTimeout: 500,
streamRetries: 1,
})
await new Promise((resolve) => setTimeout(resolve, 250))
const first = await fetch(server.url, streamRequest())
expect((await readTextError(first.text())).message).toContain("idle timeout waiting for websocket")
await new Promise((resolve) => setTimeout(resolve, 300))
const second = await fetch(server.url, streamRequest())
expect(await second.text()).toBe("http")
expect(connections).toBe(2)
expect(server.httpRequests).toHaveLength(1)
fetch.close()
})
test("retries failed websocket streams before using HTTP fallback", async () => {
const attempts: Array<(socket: WebSocket) => void> = []
await using server = await createWebSocketServer((socket) => {
socket.once("message", () => {
socket.send(JSON.stringify({ type: "response.output_text.delta", delta: "started" }))
attempts.shift()?.(socket)
})
})
const fetch = OpenAIWebSocketPool.createWebSocketFetch({
url: server.url,
streamRetries: 1,
})
const firstAttempt = new Promise<WebSocket>((resolve) => attempts.push(resolve))
const first = await fetch(server.url, streamRequest())
const firstSocket = await firstAttempt
firstSocket.terminate()
expect((await readTextError(first.text())).message).toContain("WebSocket closed before response.completed")
const secondAttempt = new Promise<WebSocket>((resolve) => attempts.push(resolve))
const second = await fetch(server.url, streamRequest())
const secondSocket = await secondAttempt
secondSocket.terminate()
expect((await readTextError(second.text())).message).toContain("WebSocket closed before response.completed")
const third = await fetch(server.url, streamRequest())
expect(await third.text()).toBe("http")
expect(server.httpRequests).toHaveLength(1)
fetch.close()
})
test("resets websocket stream failures after a completed response", async () => {
let connections = 0
let requests = 0
await using server = await createWebSocketServer((socket) => {
connections += 1
socket.on("message", () => {
requests += 1
if (requests === 1 || requests === 3) {
socket.send(JSON.stringify({ type: "response.output_text.delta", delta: "started" }))
socket.terminate()
return
}
socket.send(JSON.stringify({ type: "response.completed", response: { id: `resp_${requests}` } }))
})
})
const fetch = OpenAIWebSocketPool.createWebSocketFetch({
url: server.url,
streamRetries: 1,
})
const first = await fetch(server.url, streamRequest())
expect((await readTextError(first.text())).message).toContain("WebSocket closed before response.completed")
const second = await fetch(server.url, streamRequest())
expect(await second.text()).toContain("data: [DONE]")
const third = await fetch(server.url, streamRequest())
expect((await readTextError(third.text())).message).toContain("WebSocket closed before response.completed")
const fourth = await fetch(server.url, streamRequest())
expect(await fourth.text()).toContain("data: [DONE]")
expect(connections).toBe(3)
expect(requests).toBe(4)
expect(server.httpRequests).toHaveLength(0)
fetch.close()
})
test("falls back to HTTP for missing session and title requests", async () => {
await using server = await createWebSocketServer(() => {})
const fetch = OpenAIWebSocketPool.createWebSocketFetch()
const missingSession = await fetch(server.url, {
method: "POST",
headers: { [TITLE_HEADER]: "false" },
body: JSON.stringify({ stream: true }),
})
const title = await fetch(server.url, streamRequest({ [TITLE_HEADER]: "true" }))
expect(await missingSession.text()).toBe("http")
expect(await title.text()).toBe("http")
expect(server.httpRequests).toHaveLength(2)
expect(server.httpRequests[0]?.headers[TITLE_HEADER]).toBeUndefined()
expect(server.httpRequests[1]?.headers[TITLE_HEADER]).toBeUndefined()
fetch.close()
})
test("falls back to HTTP while a websocket lane is busy", async () => {
let connections = 0
await using server = await createWebSocketServer((socket) => {
connections += 1
socket.once("message", () => {
socket.send(JSON.stringify({ type: "response.output_text.delta", delta: "started" }))
})
})
const abort = new AbortController()
const fetch = OpenAIWebSocketPool.createWebSocketFetch({
url: server.url,
})
const first = await fetch(server.url, streamRequest({}, abort.signal))
const firstText = first.text()
await waitFor(() => connections === 1, "websocket did not connect")
const second = await fetch(server.url, streamRequest())
expect(await second.text()).toBe("http")
expect(server.httpRequests).toHaveLength(1)
expect(connections).toBe(1)
abort.abort(new Error("stop"))
expect((await readTextError(firstText)).message).toContain("stop")
fetch.close()
})
test("reserves a websocket lane while its socket is connecting", async () => {
await using server = await createHangingTcpServer()
await using fallback = await createHttpServer()
const fetch = OpenAIWebSocketPool.createWebSocketFetch({
url: server.url,
connectTimeout: 20,
streamRetries: 0,
})
const first = fetch(fallback.url, streamRequest())
await waitFor(() => server.connections() === 1, "first websocket did not begin connecting")
const second = fetch(fallback.url, streamRequest())
expect(await (await second).text()).toBe("http")
expect(await (await first).text()).toBe("http")
expect(server.connections()).toBe(1)
expect(fallback.httpRequests).toHaveLength(2)
fetch.close()
})
test("retries unexpected closes before first event then falls back to HTTP", async () => {
let connections = 0
await using server = await createWebSocketServer((socket) => {
connections += 1
socket.once("message", () => {
socket.close(1001, "server shutdown")
})
})
const fetch = OpenAIWebSocketPool.createWebSocketFetch({
url: server.url,
streamRetries: 1,
})
const first = await fetch(server.url, streamRequest())
expect((await readTextError(first.text())).message).toContain("WebSocket closed before response.completed")
const second = await fetch(server.url, streamRequest())
const third = await fetch(server.url, streamRequest())
expect(await second.text()).toBe("http")
expect(await third.text()).toBe("http")
expect(connections).toBe(2)
expect(server.httpRequests).toHaveLength(2)
fetch.close()
})
test("does not keep HTTP fallback active after aborting a websocket response", async () => {
let connections = 0
await using server = await createWebSocketServer((socket) => {
connections += 1
socket.once("message", () => {
if (connections === 1) {
socket.send(JSON.stringify({ type: "response.output_text.delta", delta: "started" }))
return
}
socket.send(JSON.stringify({ type: "response.completed", response: { id: "resp_456" } }))
})
})
const abort = new AbortController()
const fetch = OpenAIWebSocketPool.createWebSocketFetch({
url: server.url,
})
const first = await fetch(server.url, streamRequest({}, abort.signal))
const firstText = first.text()
await waitFor(() => connections === 1, "first websocket did not connect")
abort.abort(new Error("stop"))
expect((await readTextError(firstText)).message).toContain("stop")
const second = await fetch(server.url, streamRequest())
expect(await second.text()).toContain("data: [DONE]")
expect(connections).toBe(2)
expect(server.httpRequests).toHaveLength(0)
fetch.close()
})
test("releases the websocket lane when the response body is cancelled", async () => {
let connections = 0
await using server = await createWebSocketServer((socket) => {
connections += 1
socket.once("message", () => {
if (connections === 1) {
socket.send(JSON.stringify({ type: "response.output_text.delta", delta: "started" }))
return
}
socket.send(JSON.stringify({ type: "response.completed", response: { id: "resp_after_cancel" } }))
})
})
const fetch = OpenAIWebSocketPool.createWebSocketFetch({
url: server.url,
})
const first = await fetch(server.url, streamRequest())
await waitFor(() => connections === 1, "first websocket did not connect")
await first.body!.cancel("stop")
const second = await fetch(server.url, streamRequest())
expect(await second.text()).toContain("data: [DONE]")
expect(connections).toBe(2)
expect(server.httpRequests).toHaveLength(0)
fetch.close()
})
})
function streamRequest(headers?: Record<string, string>, signal?: AbortSignal): RequestInit {
return {
method: "POST",
headers: {
"session-id": "session-1",
authorization: "Bearer test",
...headers,
},
body: JSON.stringify({ stream: true, input: "hi" }),
signal,
}
}
async function readTextError(promise: Promise<string>) {
// Bun 1.3.14 hangs on expect(response.text()).rejects for streams errored from ws callbacks.
return promise.then(
() => {
throw new Error("Expected response text to reject")
},
(error) => {
expect(error).toBeInstanceOf(Error)
return error as Error
},
)
}
async function createWebSocketServer(onConnection: (socket: WebSocket, request: IncomingMessage) => void) {
const http = await createHttpServer()
const server = new WebSocketServer({ server: http.server })
server.on("connection", onConnection)
return websocketServerHandle(server, http)
}
async function createHangingTcpServer() {
const sockets = new Set<Socket>()
let connections = 0
const server = net.createServer((socket) => {
connections += 1
sockets.add(socket)
socket.on("close", () => sockets.delete(socket))
})
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve))
const address = server.address() as AddressInfo
return {
url: `http://127.0.0.1:${address.port}/v1/responses`,
wsUrl: `ws://127.0.0.1:${address.port}/v1/responses`,
connections: () => connections,
async [Symbol.asyncDispose]() {
for (const socket of sockets) socket.destroy()
server.close()
},
}
}
async function createRejectingWebSocketServer(onAttempt: () => void) {
const http = await createHttpServer()
const server = new WebSocketServer({
server: http.server,
verifyClient(_info, callback) {
onAttempt()
callback(false, 401, "denied")
},
})
return websocketServerHandle(server, http)
}
async function createHttpServer() {
const httpRequests: IncomingMessage[] = []
const server = createServer((request, response) => {
httpRequests.push(request)
response.writeHead(200, { "content-type": "text/plain" })
response.end("http")
})
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve))
const address = server.address() as AddressInfo
return {
server,
httpRequests,
url: `http://127.0.0.1:${address.port}/v1/responses`,
async [Symbol.asyncDispose]() {
await closeHttpServer(server)
},
}
}
function websocketServerHandle(server: WebSocketServer, http: Awaited<ReturnType<typeof createHttpServer>>) {
return {
url: http.url,
wsUrl: http.url.replace(/^http/, "ws"),
httpRequests: http.httpRequests,
async [Symbol.asyncDispose]() {
for (const socket of server.clients) socket.terminate()
server.close()
http.server.close()
},
}
}
function closeHttpServer(server: HttpServer) {
return new Promise<void>((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())))
}
async function waitFor(predicate: () => boolean, message: string) {
const started = Date.now()
while (!predicate()) {
if (Date.now() - started > 1_000) throw new Error(message)
await new Promise((resolve) => setTimeout(resolve, 1))
}
}
@@ -1,88 +0,0 @@
import { describe, expect, test } from "bun:test"
import { parsePluginSpecifier } from "../../src/plugin/shared"
describe("parsePluginSpecifier", () => {
test("parses standard npm package without version", () => {
expect(parsePluginSpecifier("acme")).toEqual({
pkg: "acme",
version: "latest",
})
})
test("parses standard npm package with version", () => {
expect(parsePluginSpecifier("acme@1.0.0")).toEqual({
pkg: "acme",
version: "1.0.0",
})
})
test("parses scoped npm package without version", () => {
expect(parsePluginSpecifier("@opencode/acme")).toEqual({
pkg: "@opencode/acme",
version: "latest",
})
})
test("parses scoped npm package with version", () => {
expect(parsePluginSpecifier("@opencode/acme@1.0.0")).toEqual({
pkg: "@opencode/acme",
version: "1.0.0",
})
})
test("parses package with git+https url", () => {
expect(parsePluginSpecifier("acme@git+https://github.com/opencode/acme.git")).toEqual({
pkg: "acme",
version: "git+https://github.com/opencode/acme.git",
})
})
test("parses scoped package with git+https url", () => {
expect(parsePluginSpecifier("@opencode/acme@git+https://github.com/opencode/acme.git")).toEqual({
pkg: "@opencode/acme",
version: "git+https://github.com/opencode/acme.git",
})
})
test("parses package with git+ssh url containing another @", () => {
expect(parsePluginSpecifier("acme@git+ssh://git@github.com/opencode/acme.git")).toEqual({
pkg: "acme",
version: "git+ssh://git@github.com/opencode/acme.git",
})
})
test("parses scoped package with git+ssh url containing another @", () => {
expect(parsePluginSpecifier("@opencode/acme@git+ssh://git@github.com/opencode/acme.git")).toEqual({
pkg: "@opencode/acme",
version: "git+ssh://git@github.com/opencode/acme.git",
})
})
test("parses unaliased git+ssh url", () => {
expect(parsePluginSpecifier("git+ssh://git@github.com/opencode/acme.git")).toEqual({
pkg: "git+ssh://git@github.com/opencode/acme.git",
version: "",
})
})
test("parses npm alias using the alias name", () => {
expect(parsePluginSpecifier("acme@npm:@opencode/acme@1.0.0")).toEqual({
pkg: "acme",
version: "npm:@opencode/acme@1.0.0",
})
})
test("parses bare npm protocol specifier using the target package", () => {
expect(parsePluginSpecifier("npm:@opencode/acme@1.0.0")).toEqual({
pkg: "@opencode/acme",
version: "1.0.0",
})
})
test("parses unversioned npm protocol specifier", () => {
expect(parsePluginSpecifier("npm:@opencode/acme")).toEqual({
pkg: "@opencode/acme",
version: "latest",
})
})
})
@@ -1,108 +0,0 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { Npm } from "@opencode-ai/core/npm"
import path from "path"
import { pathToFileURL } from "url"
import { Account } from "../../src/account/account"
import { Auth } from "../../src/auth"
import { RuntimeFlags } from "../../src/effect/runtime-flags"
import { Plugin } from "../../src/plugin/index"
import { TestInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { AccountTest } from "../fake/account"
import { AuthTest } from "../fake/auth"
import { NpmTest } from "../fake/npm"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
const it = testEffect(
AppNodeBuilder.build(LayerNode.group([Plugin.node, CrossSpawnSpawner.node]), [
[Auth.node, AuthTest.empty],
[Account.node, AccountTest.empty],
[Npm.node, NpmTest.noop],
[RuntimeFlags.node, RuntimeFlags.layer({ disableDefaultPlugins: true })],
]),
)
const systemHook = "experimental.chat.system.transform"
function withProject<A, E, R>(source: string, self: Effect.Effect<A, E, R>) {
return Effect.gen(function* () {
const test = yield* TestInstance
const file = path.join(test.directory, "plugin.ts")
yield* Effect.all(
[
Effect.promise(() => Bun.write(file, source)),
Effect.promise(() =>
Bun.write(
path.join(test.directory, "opencode.json"),
JSON.stringify(
{
$schema: "https://opencode.ai/config.json",
plugin: [pathToFileURL(file).href],
},
null,
2,
),
),
),
],
{ discard: true, concurrency: 2 },
)
return yield* self
})
}
const triggerSystemTransform = Effect.fn("PluginTriggerTest.triggerSystemTransform")(function* () {
const plugin = yield* Plugin.Service
const out = { system: [] as string[] }
yield* plugin.trigger(
systemHook,
{
model: {
providerID: ProviderV2.ID.anthropic,
modelID: ModelV2.ID.make("claude-sonnet-4-6"),
},
},
out,
)
return out.system
})
describe("plugin.trigger", () => {
it.instance("runs synchronous hooks without crashing", () =>
withProject(
[
"export default async () => ({",
` ${JSON.stringify(systemHook)}: (_input, output) => {`,
' output.system.unshift("sync")',
" },",
"})",
"",
].join("\n"),
Effect.gen(function* () {
expect(yield* triggerSystemTransform()).toEqual(["sync"])
}),
),
)
it.instance("awaits asynchronous hooks", () =>
withProject(
[
"export default async () => ({",
` ${JSON.stringify(systemHook)}: async (_input, output) => {`,
" await Bun.sleep(1)",
' output.system.unshift("async")',
" },",
"})",
"",
].join("\n"),
Effect.gen(function* () {
expect(yield* triggerSystemTransform()).toEqual(["async"])
}),
),
)
})
@@ -1,111 +0,0 @@
import { afterEach, describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { Npm } from "@opencode-ai/core/npm"
import { Ripgrep } from "@opencode-ai/core/ripgrep"
import path from "path"
import { pathToFileURL } from "url"
import { Auth } from "../../src/auth"
import { Account } from "../../src/account/account"
import { RuntimeFlags } from "../../src/effect/runtime-flags"
import { Workspace } from "../../src/control-plane/workspace"
import { Plugin } from "../../src/plugin/index"
import { InstanceBootstrap } from "../../src/project/bootstrap"
import { InstanceStore } from "../../src/project/instance-store"
import { InstanceState } from "../../src/effect/instance-state"
import { disposeAllInstances, TestInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { AccountTest } from "../fake/account"
import { AuthTest } from "../fake/auth"
import { NpmTest } from "../fake/npm"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
const noopBootstrapLayer = Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void }))
const it = testEffect(
AppNodeBuilder.build(LayerNode.group([Plugin.node, Workspace.node, InstanceStore.node, Ripgrep.node]), [
[Auth.node, AuthTest.empty],
[Account.node, AccountTest.empty],
[Npm.node, NpmTest.noop],
[InstanceStore.bootstrapNode, noopBootstrapLayer],
[RuntimeFlags.node, RuntimeFlags.layer({ disableDefaultPlugins: true, experimentalWorkspaces: true })],
]),
)
afterEach(async () => {
await disposeAllInstances()
})
describe("plugin.workspace", () => {
it.instance("plugin can install a workspace adapter", () =>
Effect.gen(function* () {
const dir = (yield* TestInstance).directory
const type = `plug-${Math.random().toString(36).slice(2)}`
const file = path.join(dir, "plugin.ts")
const mark = path.join(dir, "created.json")
const space = path.join(dir, "space")
yield* Effect.promise(() =>
Bun.write(
file,
[
"export default async ({ experimental_workspace }) => {",
` experimental_workspace.register(${JSON.stringify(type)}, {`,
' name: "plug",',
' description: "plugin workspace adapter",',
" configure(input) {",
` return { ...input, name: "plug", branch: "plug/main", directory: ${JSON.stringify(space)} }`,
" },",
" async create(input) {",
` await Bun.write(${JSON.stringify(mark)}, JSON.stringify(input))`,
" },",
" async remove() {},",
" target(input) {",
' return { type: "local", directory: input.directory }',
" },",
" })",
" return {}",
"}",
"",
].join("\n"),
),
)
yield* Effect.promise(() =>
Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify(
{
$schema: "https://opencode.ai/config.json",
plugin: [pathToFileURL(file).href],
},
null,
2,
),
),
)
const plugin = yield* Plugin.Service
yield* plugin.init()
const workspace = yield* Workspace.Service
const ctx = yield* InstanceState.context
const info = yield* workspace.create({
type,
branch: null,
extra: { key: "value" },
projectID: ctx.project.id,
})
expect(info.type).toBe(type)
expect(info.name).toBe("plug")
expect(info.branch).toBe("plug/main")
expect(info.directory).toBe(space)
expect(info.extra).toEqual({ key: "value" })
expect(JSON.parse(yield* Effect.promise(() => Bun.file(mark).text()))).toMatchObject({
type,
name: "plug",
branch: "plug/main",
directory: space,
extra: { key: "value" },
})
}),
)
})
-585
View File
@@ -1,585 +0,0 @@
import { describe, expect, test } from "bun:test"
import { accessTokenIsExpiring, pollDeviceCodeToken, requestDeviceCode, XaiAuthPlugin } from "../../src/plugin/xai"
import { OAUTH_DUMMY_KEY } from "../../src/auth"
function makeJwt(payload: object): string {
const header = Buffer.from(JSON.stringify({ alg: "none", typ: "JWT" })).toString("base64url")
const body = Buffer.from(JSON.stringify(payload)).toString("base64url")
return `${header}.${body}.sig`
}
function makeInput(opts?: { failSet?: boolean }) {
const setCalls: Array<Record<string, unknown>> = []
return {
input: {
client: {
auth: {
set: async (req: Record<string, unknown>) => {
setCalls.push(req)
if (opts?.failSet) throw new Error("auth.set boom")
},
},
},
} as any,
setCalls,
}
}
function makeServer(handler: (request: Request, url: URL) => Response | Promise<Response>) {
return Bun.serve({
port: 0,
fetch: (request) => handler(request, new URL(request.url)),
})
}
function serverOptions(server: ReturnType<typeof Bun.serve>) {
return {
authorizeUrl: new URL("/oauth2/authorize", server.url).toString(),
tokenUrl: new URL("/oauth2/token", server.url).toString(),
deviceAuthorizationUrl: new URL("/oauth2/device/code", server.url).toString(),
}
}
describe("plugin.xai", () => {
describe("accessTokenIsExpiring", () => {
test("returns true for an already-expired JWT", () => {
expect(accessTokenIsExpiring(makeJwt({ exp: Math.floor(Date.now() / 1000) - 60 }), 0)).toBe(true)
})
test("returns false for a fresh JWT outside the skew window", () => {
expect(accessTokenIsExpiring(makeJwt({ exp: Math.floor(Date.now() / 1000) + 3600 }), 0)).toBe(false)
})
test("honors the skew window", () => {
const nearExpiry = makeJwt({ exp: Math.floor(Date.now() / 1000) + 30 })
expect(accessTokenIsExpiring(nearExpiry, 60_000)).toBe(true)
expect(accessTokenIsExpiring(nearExpiry, 0)).toBe(false)
})
test("clamps negative skew to zero rather than refusing to refresh", () => {
expect(accessTokenIsExpiring(makeJwt({ exp: Math.floor(Date.now() / 1000) - 1 }), -60_000)).toBe(true)
})
test("returns false for opaque and malformed tokens", () => {
expect(accessTokenIsExpiring("opaque-token-no-dots", 0)).toBe(false)
expect(accessTokenIsExpiring("", 0)).toBe(false)
expect(accessTokenIsExpiring(undefined, 0)).toBe(false)
expect(accessTokenIsExpiring(makeJwt({ sub: "user-1" }), 0)).toBe(false)
expect(accessTokenIsExpiring(makeJwt({ exp: "1234" }), 0)).toBe(false)
expect(accessTokenIsExpiring("header.!!!not-valid-base64-or-json!!!.sig", 0)).toBe(false)
})
})
describe("loader", () => {
test("returns no options unless stored auth is OAuth and exposes methods in order", async () => {
const hooks = await XaiAuthPlugin({} as any)
expect(await hooks.auth!.loader!(async () => ({ type: "api", key: "sk-test" }), {} as any)).toEqual({})
expect(
await hooks.auth!.loader!(async () => ({ type: "wellknown", key: "k", token: "t" }) as any, {} as any),
).toEqual({})
expect(hooks.auth!.methods.map((m) => [m.type, m.label])).toEqual([
["oauth", "SuperGrok Subscription"],
["api", "Manually enter API Key"],
])
})
test("replaces the dummy bearer, sets User-Agent, and preserves caller headers", async () => {
const { input } = makeInput()
const captured: Headers[] = []
using server = makeServer((request) => {
captured.push(request.headers)
return new Response("{}", { status: 200 })
})
const hooks = await XaiAuthPlugin(input)
const opts = await hooks.auth!.loader!(
async () => ({ type: "oauth", access: "live-token", refresh: "rt", expires: Date.now() + 3600_000 }),
{} as any,
)
expect(opts.apiKey).toBe(OAUTH_DUMMY_KEY)
expect(opts.baseURL).toBeUndefined()
await opts.fetch!(new URL("/chat/completions", server.url), {
headers: { Authorization: `Bearer ${OAUTH_DUMMY_KEY}`, "x-keep": "yes" },
})
expect(captured[0].get("authorization")).toBe("Bearer live-token")
expect(captured[0].get("x-keep")).toBe("yes")
expect(captured[0].get("user-agent")).toMatch(/^opencode\//)
})
test("does not mutate caller headers and supports HeadersInit shapes", async () => {
const { input } = makeInput()
const captured: Headers[] = []
using server = makeServer((request) => {
captured.push(request.headers)
return new Response("{}", { status: 200 })
})
const opts = await (
await XaiAuthPlugin(input)
).auth!.loader!(
async () => ({ type: "oauth", access: "tok", refresh: "rt", expires: Date.now() + 3600_000 }),
{} as any,
)
const objHeaders: Record<string, string> = {
Authorization: `Bearer ${OAUTH_DUMMY_KEY}`,
"x-trace": "plain-object",
}
await opts.fetch!(new URL("/chat/completions", server.url), { headers: objHeaders })
expect(objHeaders).toEqual({ Authorization: `Bearer ${OAUTH_DUMMY_KEY}`, "x-trace": "plain-object" })
const arrayHeaders: [string, string][] = [["x-trace", "tuple-array"]]
const arrayCopy = arrayHeaders.map(([key, value]) => [key, value] as [string, string])
await opts.fetch!(new URL("/chat/completions", server.url), { headers: arrayHeaders })
expect(arrayHeaders).toEqual(arrayCopy)
const headersInstance = new Headers({ "x-trace": "headers-instance" })
await opts.fetch!(new URL("/chat/completions", server.url), { headers: headersInstance })
expect(headersInstance.get("x-trace")).toBe("headers-instance")
expect(captured.map((headers) => headers.get("x-trace"))).toEqual([
"plain-object",
"tuple-array",
"headers-instance",
])
for (const headers of captured) {
expect(headers.get("authorization")).toBe("Bearer tok")
expect(headers.get("user-agent")).toMatch(/^opencode\//)
}
})
test("preserves headers from Request input and lets init headers override them", async () => {
const { input } = makeInput()
const captured: Headers[] = []
using server = makeServer((request) => {
captured.push(request.headers)
return new Response("{}", { status: 200 })
})
const opts = await (
await XaiAuthPlugin(input)
).auth!.loader!(
async () => ({ type: "oauth", access: "tok", refresh: "rt", expires: Date.now() + 3600_000 }),
{} as any,
)
await opts.fetch!(
new Request(new URL("/chat/completions", server.url), {
headers: {
Authorization: `Bearer ${OAUTH_DUMMY_KEY}`,
"content-type": "application/json",
"x-trace": "request",
},
}),
{ headers: { "x-trace": "init", "x-extra": "yes" } },
)
expect(captured[0].get("authorization")).toBe("Bearer tok")
expect(captured[0].get("content-type")).toBe("application/json")
expect(captured[0].get("x-trace")).toBe("init")
expect(captured[0].get("x-extra")).toBe("yes")
})
test("falls through to plain fetch when stored auth flips from oauth to api", async () => {
const { input } = makeInput()
const captured: Headers[] = []
using server = makeServer((request) => {
captured.push(request.headers)
return new Response("{}", { status: 200 })
})
let firstCall = true
const opts = await (
await XaiAuthPlugin(input)
).auth!.loader!(async () => {
if (firstCall) {
firstCall = false
return { type: "oauth", access: "tok", refresh: "rt", expires: Date.now() + 3600_000 }
}
return { type: "api", key: "sk-new" }
}, {} as any)
await opts.fetch!(new URL("/chat/completions", server.url), {
headers: { Authorization: "Bearer sk-from-aisdk", "x-keep": "v" },
})
expect(captured[0].get("authorization")).toBe("Bearer sk-from-aisdk")
expect(captured[0].get("x-keep")).toBe("v")
})
test("deduplicates concurrent refreshes within a loader instance", async () => {
const { input, setCalls } = makeInput()
let tokenRequests = 0
const apiRequests: Headers[] = []
using server = makeServer(async (request, url) => {
if (url.pathname === "/oauth2/token") {
tokenRequests++
expect(await request.text()).toContain("refresh_token=rt-old")
await new Promise((resolve) => setTimeout(resolve, 30))
return Response.json({ access_token: "new-access", refresh_token: "rt-new", expires_in: 3600 })
}
apiRequests.push(request.headers)
return new Response("{}", { status: 200 })
})
const opts = await (
await XaiAuthPlugin(input, serverOptions(server))
).auth!.loader!(async () => ({ type: "oauth" as const, access: "old", refresh: "rt-old", expires: 0 }), {} as any)
await Promise.all([
opts.fetch!(new URL("/chat/completions", server.url), { headers: {} }),
opts.fetch!(new URL("/chat/completions", server.url), { headers: {} }),
])
expect(tokenRequests).toBe(1)
expect(apiRequests.map((headers) => headers.get("authorization"))).toEqual([
"Bearer new-access",
"Bearer new-access",
])
expect(setCalls).toHaveLength(1)
expect((setCalls[0].body as any).refresh).toBe("rt-new")
})
test("does not share refresh single-flight across loader instances", async () => {
const { input } = makeInput()
const tokenRequests: string[] = []
const apiRequests: string[] = []
using server = makeServer(async (request, url) => {
if (url.pathname === "/oauth2/token") {
const refreshToken = new URLSearchParams(await request.text()).get("refresh_token")!
tokenRequests.push(refreshToken)
await new Promise((resolve) => setTimeout(resolve, 20))
return Response.json({
access_token: `access-${refreshToken}`,
refresh_token: `next-${refreshToken}`,
expires_in: 3600,
})
}
apiRequests.push(request.headers.get("authorization")!)
return new Response("{}", { status: 200 })
})
const hooks = await XaiAuthPlugin(input, serverOptions(server))
const first = await hooks.auth!.loader!(
async () => ({ type: "oauth", access: "old-a", refresh: "rt-a", expires: 0 }),
{} as any,
)
const second = await hooks.auth!.loader!(
async () => ({ type: "oauth", access: "old-b", refresh: "rt-b", expires: 0 }),
{} as any,
)
await Promise.all([
first.fetch!(new URL("/chat/completions", server.url), { headers: {} }),
second.fetch!(new URL("/chat/completions", server.url), { headers: {} }),
])
expect(tokenRequests.sort()).toEqual(["rt-a", "rt-b"])
expect(apiRequests.sort()).toEqual(["Bearer access-rt-a", "Bearer access-rt-b"])
})
test("starts a new refresh after success and clears the refresh promise after failure", async () => {
const { input } = makeInput()
let tokenRequests = 0
using server = makeServer((_, url) => {
if (url.pathname === "/oauth2/token") {
tokenRequests++
if (tokenRequests === 2) return new Response("temporarily unavailable", { status: 503 })
return Response.json({
access_token: `new-${tokenRequests}`,
refresh_token: `rt-${tokenRequests}`,
expires_in: 3600,
})
}
return new Response("{}", { status: 200 })
})
const opts = await (
await XaiAuthPlugin(input, serverOptions(server))
).auth!.loader!(async () => ({ type: "oauth", access: "old", refresh: "rt-old", expires: 0 }), {} as any)
await opts.fetch!(new URL("/chat/completions", server.url), { headers: {} })
await expect(opts.fetch!(new URL("/chat/completions", server.url), { headers: {} })).rejects.toThrow(
/xAI token refresh failed \(503\)/,
)
await opts.fetch!(new URL("/chat/completions", server.url), { headers: {} })
expect(tokenRequests).toBe(3)
})
test("handles refresh response variants and persistence failure", async () => {
const { input, setCalls } = makeInput({ failSet: true })
const captured: Headers[] = []
using server = makeServer((request, url) => {
if (url.pathname === "/oauth2/token") return Response.json({ access_token: "new-access", expires_in: 3600 })
captured.push(request.headers)
return new Response("{}", { status: 200 })
})
const opts = await (
await XaiAuthPlugin(input, serverOptions(server))
).auth!.loader!(async () => ({ type: "oauth", access: "old", refresh: "rt-old", expires: 0 }), {} as any)
const resp = await opts.fetch!(new URL("/chat/completions", server.url), { headers: {} })
expect(resp.status).toBe(200)
expect(captured[0].get("authorization")).toBe("Bearer new-access")
expect((setCalls[0].body as any).refresh).toBe("rt-old")
})
test("refreshes based on stored expiry or JWT expiry and skips refresh when both are fresh", async () => {
const { input, setCalls } = makeInput()
let tokenRequests = 0
using server = makeServer((_, url) => {
if (url.pathname === "/oauth2/token") {
tokenRequests++
return Response.json({ access_token: "new-access", refresh_token: "rt-new", expires_in: 3600 })
}
return new Response("{}", { status: 200 })
})
const fresh = await (
await XaiAuthPlugin(input, serverOptions(server))
).auth!.loader!(
async () => ({
type: "oauth",
access: makeJwt({ exp: Math.floor(Date.now() / 1000) + 24 * 3600 }),
refresh: "rt",
expires: Date.now() + 24 * 3600 * 1000,
}),
{} as any,
)
await fresh.fetch!(new URL("/chat/completions", server.url), { headers: {} })
expect(tokenRequests).toBe(0)
const jwtExpiring = await (
await XaiAuthPlugin(input, serverOptions(server))
).auth!.loader!(
async () => ({
type: "oauth",
access: makeJwt({ exp: Math.floor((Date.now() + 30_000) / 1000) }),
refresh: "rt-old",
expires: Date.now() + 24 * 3600 * 1000,
}),
{} as any,
)
const missingExpires = await (
await XaiAuthPlugin(input, serverOptions(server))
).auth!.loader!(async () => ({ type: "oauth", access: "opaque-token", refresh: "rt", expires: 0 }), {} as any)
await jwtExpiring.fetch!(new URL("/chat/completions", server.url), { headers: {} })
await missingExpires.fetch!(new URL("/chat/completions", server.url), { headers: {} })
expect(tokenRequests).toBe(2)
expect(setCalls).toHaveLength(2)
})
test("network failure during refresh surfaces the underlying fetch error", async () => {
const { input } = makeInput()
const opts = await (
await XaiAuthPlugin(input, { tokenUrl: "http://127.0.0.1:9/oauth2/token" })
).auth!.loader!(async () => ({ type: "oauth", access: "old", refresh: "rt", expires: 0 }), {} as any)
await expect(opts.fetch!("https://api.x.ai/v1/chat/completions", { headers: {} })).rejects.toThrow()
})
})
describe("device code flow", () => {
test("authorize advertises verification URL + user code and returns success on callback", async () => {
using server = makeServer((_, url) => {
if (url.pathname === "/oauth2/device/code") {
return Response.json({
device_code: "DEVICE-1",
user_code: "ABCD-1234",
verification_uri: "https://x.ai/device",
verification_uri_complete: "https://x.ai/device?user_code=ABCD-1234",
expires_in: 600,
interval: 5,
})
}
if (url.pathname === "/oauth2/token") {
return Response.json({ access_token: "AT", refresh_token: "RT", expires_in: 3600 })
}
return new Response("unexpected request", { status: 500 })
})
const hooks = await XaiAuthPlugin({} as any, serverOptions(server))
const headless = hooks.auth!.methods.find(
(m): m is Extract<typeof m, { type: "oauth" }> => m.type === "oauth" && m.label === "SuperGrok Subscription",
)!
const result = await headless.authorize!()
expect(result.method).toBe("auto")
expect(result.url).toBe("https://x.ai/device?user_code=ABCD-1234")
expect(result.instructions).toContain("https://x.ai/device")
expect(result.instructions).toContain("ABCD-1234")
expect(await (result as any).callback()).toMatchObject({ type: "success", refresh: "RT", access: "AT" })
})
test("authorize falls back to verification_uri when verification_uri_complete is absent", async () => {
using server = makeServer((_, url) => {
if (url.pathname === "/oauth2/device/code") {
return Response.json({
device_code: "DEVICE-2",
user_code: "WXYZ-9876",
verification_uri: "https://x.ai/device",
})
}
return new Response("unexpected request", { status: 500 })
})
const headless = (await XaiAuthPlugin({} as any, serverOptions(server))).auth!.methods.find(
(m): m is Extract<typeof m, { type: "oauth" }> => m.type === "oauth" && m.label === "SuperGrok Subscription",
)!
expect((await headless.authorize!()).url).toBe("https://x.ai/device")
})
test("requestDeviceCode posts form body, validates fields, and surfaces endpoint errors", async () => {
let capturedBody = ""
using server = makeServer(async (request, url) => {
if (url.pathname === "/missing") return Response.json({ device_code: "x" })
if (url.pathname === "/error") return new Response("rate limited", { status: 429 })
expect(request.method).toBe("POST")
expect(request.headers.get("content-type")).toBe("application/x-www-form-urlencoded")
expect(request.headers.get("accept")).toBe("application/json")
expect(request.headers.get("user-agent")).toMatch(/^opencode\//)
capturedBody = await request.text()
return Response.json({ device_code: "DC", user_code: "UC", verification_uri: "https://x.ai/device" })
})
await requestDeviceCode({ deviceAuthorizationUrl: new URL("/oauth2/device/code", server.url).toString() })
const parsed = new URLSearchParams(capturedBody)
expect(parsed.get("client_id")).toBe("b1a00492-073a-47ea-816f-4c329264a828")
expect(parsed.get("scope")).toContain("offline_access")
expect(parsed.get("scope")).toContain("grok-cli:access")
expect(parsed.get("scope")).toContain("api:access")
expect(parsed.get("referrer")).toBe("opencode")
await expect(
requestDeviceCode({ deviceAuthorizationUrl: new URL("/error", server.url).toString() }),
).rejects.toThrow(/429.*rate limited/)
await expect(
requestDeviceCode({ deviceAuthorizationUrl: new URL("/missing", server.url).toString() }),
).rejects.toThrow(/missing device_code/)
})
test("pollDeviceCodeToken resolves on success and posts the device-code grant", async () => {
let tokenCalls = 0
using server = makeServer(async (request) => {
tokenCalls++
expect(request.headers.get("content-type")).toBe("application/x-www-form-urlencoded")
const body = new URLSearchParams(await request.text())
expect(body.get("grant_type")).toBe("urn:ietf:params:oauth:grant-type:device_code")
expect(body.get("device_code")).toBe("DC-1")
return Response.json({ access_token: "AT", refresh_token: "RT", expires_in: 3600 })
})
const tokens = await pollDeviceCodeToken(
{ device_code: "DC-1", user_code: "UC", verification_uri: "https://x.ai/device", interval: 1, expires_in: 600 },
{ sleep: async () => {}, tokenUrl: new URL("/oauth2/token", server.url).toString() },
)
expect(tokens.access_token).toBe("AT")
expect(tokens.refresh_token).toBe("RT")
expect(tokenCalls).toBe(1)
})
test("pollDeviceCodeToken honors authorization_pending and slow_down", async () => {
let n = 0
using server = makeServer(() => {
n++
if (n === 1) return Response.json({ error: "authorization_pending" }, { status: 400 })
if (n === 2) return Response.json({ error: "slow_down" }, { status: 400 })
return Response.json({ access_token: "AT", refresh_token: "RT", expires_in: 3600 })
})
const sleeps: number[] = []
const tokens = await pollDeviceCodeToken(
{ device_code: "DC", user_code: "UC", verification_uri: "https://x.ai/device", interval: 5, expires_in: 600 },
{ sleep: async (ms) => void sleeps.push(ms), tokenUrl: new URL("/oauth2/token", server.url).toString() },
)
expect(tokens.access_token).toBe("AT")
expect(n).toBe(3)
expect(sleeps).toEqual([8_000, 13_000])
})
test("pollDeviceCodeToken handles terminal errors and timeout", async () => {
for (const [body, error] of [
[{ error: "access_denied" }, /authorization was denied/],
[{ error: "expired_token" }, /device code expired/],
[{ error: "server_error", error_description: "oops" }, /500.*oops/],
] as const) {
using server = makeServer(() => Response.json(body, { status: 500 }))
await expect(
pollDeviceCodeToken(
{
device_code: "DC",
user_code: "UC",
verification_uri: "https://x.ai/device",
interval: 1,
expires_in: 600,
},
{ sleep: async () => {}, tokenUrl: new URL("/oauth2/token", server.url).toString() },
),
).rejects.toThrow(error)
}
using pending = makeServer(() => Response.json({ error: "authorization_pending" }, { status: 400 }))
let tick = 0
await expect(
pollDeviceCodeToken(
{ device_code: "DC", user_code: "UC", verification_uri: "https://x.ai/device", interval: 1, expires_in: 1 },
{
sleep: async () => {},
now: () => 1_000_000 + tick++ * 600,
tokenUrl: new URL("/oauth2/token", pending.url).toString(),
},
),
).rejects.toThrow(/timed out/)
})
test("pollDeviceCodeToken normalizes bad interval and expires_in values", async () => {
const badIntervals: Array<unknown> = [Number.NaN, "NaN", "garbage", -5, null, 0]
for (const bad of badIntervals) {
let n = 0
using server = makeServer(() => {
n++
if (n === 1) return Response.json({ error: "authorization_pending" }, { status: 400 })
return Response.json({ access_token: "AT", refresh_token: "RT", expires_in: 3600 })
})
const sleeps: number[] = []
await pollDeviceCodeToken(
{
device_code: "DC",
user_code: "UC",
verification_uri: "https://x.ai/device",
interval: bad as number,
expires_in: 600,
},
{ sleep: async (ms) => void sleeps.push(ms), tokenUrl: new URL("/oauth2/token", server.url).toString() },
)
expect(sleeps[0]).toBe(8_000)
}
for (const bad of [Number.NaN, "NaN", "garbage", -5, null, 0]) {
using server = makeServer(() => Response.json({ access_token: "AT", refresh_token: "RT", expires_in: 3600 }))
expect(
(
await pollDeviceCodeToken(
{
device_code: "DC",
user_code: "UC",
verification_uri: "https://x.ai/device",
interval: 1,
expires_in: bad as number,
},
{ sleep: async () => {}, tokenUrl: new URL("/oauth2/token", server.url).toString() },
)
).access_token,
).toBe("AT")
}
})
test("device-code authorize callback returns failed when polling errors", async () => {
using server = makeServer((_, url) => {
if (url.pathname === "/oauth2/device/code") {
return Response.json({
device_code: "DC",
user_code: "UC",
verification_uri: "https://x.ai/device",
interval: 0,
expires_in: 600,
})
}
return Response.json({ error: "access_denied" }, { status: 400 })
})
const headless = (await XaiAuthPlugin({} as any, serverOptions(server))).auth!.methods.find(
(m): m is Extract<typeof m, { type: "oauth" }> => m.type === "oauth" && m.label === "SuperGrok Subscription",
)!
expect(await ((await headless.authorize!()) as any).callback()).toEqual({ type: "failed" })
})
})
})
@@ -10,7 +10,6 @@ import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { testProviderConfig } from "../lib/test-provider"
import { Env } from "@/env"
import { Plugin } from "@/plugin"
import { Provider } from "@/provider/provider"
import { ProviderError } from "@/provider/error"
@@ -19,7 +18,7 @@ afterEach(async () => {
})
const it = testEffect(
LayerNode.compile(LayerNode.group([Provider.node, Env.node, Plugin.node, CrossSpawnSpawner.node])),
LayerNode.compile(LayerNode.group([Provider.node, Env.node, CrossSpawnSpawner.node])),
)
it.live("headerTimeout does not abort delayed SSE body after headers arrive", () =>
@@ -9,11 +9,9 @@ import { FSUtil } from "@opencode-ai/core/fs-util"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { Global } from "@opencode-ai/core/global"
import { disposeAllInstances, provideInstanceEffect, tmpdirScoped, TestInstance } from "../fixture/fixture"
import { markPluginDependenciesReady } from "../fixture/plugin"
import { Auth } from "@/auth"
import { Config } from "@/config/config"
import { Env } from "../../src/env"
import { Plugin } from "../../src/plugin/index"
import { Provider } from "@/provider/provider"
import { RuntimeFlags } from "@/effect/runtime-flags"
@@ -67,7 +65,6 @@ const providerLayer = (flags: Partial<RuntimeFlags.Info> = {}) =>
Env.node,
Config.node,
Auth.node,
Plugin.node,
ModelsDev.node,
RuntimeFlags.node,
]),
@@ -84,7 +81,7 @@ const paid = (providers: Record<string, { models: Record<string, { cost: { input
const languageBaseURL = (language: unknown) => (language as { config: { baseURL: string } }).config.baseURL
const it = testEffect(LayerNode.compile(LayerNode.group([Provider.node, Env.node, Plugin.node])))
const it = testEffect(LayerNode.compile(LayerNode.group([Provider.node, Env.node])))
const experimentalModels = testEffect(providerLayer({ enableExperimentalModels: true }))
const alphaProviderConfig = {
@@ -1842,96 +1839,6 @@ const instanceStoreLayer = LayerNode.compile(InstanceStore.node, [
const provideMultiInstance = <A, E, R>(eff: Effect.Effect<A, E, R>) =>
eff.pipe(Effect.provide(instanceStoreLayer), Effect.provide(AppNodeBuilder.build(CrossSpawnSpawner.node)))
it.effect("plugin config providers persist after instance dispose", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
const configDir = path.join(dir, ".opencode")
const root = path.join(configDir, "plugin")
yield* Effect.promise(() => mkdir(root, { recursive: true }))
yield* Effect.promise(() => markPluginDependenciesReady(configDir))
yield* Effect.promise(() => markPluginDependenciesReady(Global.Path.config))
yield* Effect.promise(() =>
Bun.write(
path.join(root, "demo-provider.ts"),
[
"export default {",
' id: "demo.plugin-provider",',
" server: async () => ({",
" async config(cfg) {",
" cfg.provider ??= {}",
" cfg.provider.demo = {",
' name: "Demo Provider",',
' npm: "@ai-sdk/openai-compatible",',
' api: "https://example.com/v1",',
" models: {",
" chat: {",
' name: "Demo Chat",',
" tool_call: true,",
" limit: { context: 128000, output: 4096 },",
" },",
" },",
" }",
" },",
" }),",
"}",
"",
].join("\n"),
),
)
const loadAndList = Effect.gen(function* () {
const plugin = yield* Plugin.Service
const provider = yield* Provider.Service
yield* plugin.init()
return yield* provider.list()
}).pipe(provideInstanceEffect(dir))
const first = yield* loadAndList
expect(first[ProviderV2.ID.make("demo")]).toBeDefined()
expect(first[ProviderV2.ID.make("demo")].models[ModelV2.ID.make("chat")]).toBeDefined()
yield* Effect.promise(() => disposeAllInstances())
const second = yield* loadAndList
expect(second[ProviderV2.ID.make("demo")]).toBeDefined()
expect(second[ProviderV2.ID.make("demo")].models[ModelV2.ID.make("chat")]).toBeDefined()
}).pipe(provideMultiInstance),
)
it.instance(
"plugin config enabled and disabled providers are honored",
Effect.gen(function* () {
const instance = yield* TestInstance
const configDir = path.join(instance.directory, ".opencode")
const root = path.join(configDir, "plugin")
yield* Effect.promise(() => mkdir(root, { recursive: true }))
yield* Effect.promise(() => markPluginDependenciesReady(configDir))
yield* Effect.promise(() =>
Bun.write(
path.join(root, "provider-filter.ts"),
[
"export default {",
' id: "demo.provider-filter",',
" server: async () => ({",
" async config(cfg) {",
' cfg.enabled_providers = ["anthropic", "openai"]',
' cfg.disabled_providers = ["openai"]',
" },",
" }),",
"}",
"",
].join("\n"),
),
)
yield* set("ANTHROPIC_API_KEY", "test-anthropic-key")
yield* set("OPENAI_API_KEY", "test-openai-key")
const providers = yield* list
expect(providers[ProviderV2.ID.anthropic]).toBeDefined()
expect(providers[ProviderV2.ID.openai]).toBeUndefined()
}),
)
it.effect("opencode loader keeps paid models when config apiKey is present", () =>
Effect.gen(function* () {
const noneDir = yield* tmpdirScoped()
@@ -571,11 +571,6 @@ describe("ProviderTransform.options - gpt-5 textVerbosity", () => {
},
provider: { id: "azure", options: { useCompletionUrls: true } } as any,
auth: undefined,
plugin: {
trigger: (_name: string, _input: unknown, output: unknown) => Effect.succeed(output),
list: () => Effect.succeed([]),
init: () => Effect.void,
} as any,
flags: { outputTokenMax: 32_000, client: "test" } as any,
isWorkflow: false,
}),
@@ -10,7 +10,6 @@ import { Config } from "@/config/config"
import { LLM } from "../../src/session/llm"
import { SessionCompaction } from "../../src/session/compaction"
import { Token } from "@/util/token"
import { Plugin } from "../../src/plugin"
import { provideTmpdirInstance, TestInstance } from "../fixture/fixture"
import { Session as SessionNs } from "@/session/session"
import { MessageV2 } from "../../src/session/message-v2"
@@ -247,7 +246,6 @@ const itCompaction = testEffect(compactionEnv)
type CompactionProcessOptions = {
result?: "continue" | "compact"
llm?: Layer.Layer<LLM.Service>
plugin?: Layer.Layer<Plugin.Service>
provider?: ReturnType<typeof wide>
config?: Layer.Layer<Config.Service>
}
@@ -266,14 +264,12 @@ function compactionProcessLayer(options?: CompactionProcessOptions) {
return AppNodeBuilder.build(compactionTestNode, [
...replacements,
[SessionProcessorModule.SessionProcessor.node, processorLayer(options?.result ?? "continue")],
...(options?.plugin ? ([[Plugin.node, options.plugin]] as const) : []),
...(options?.config ? ([[Config.node, options.config]] as const) : []),
])
}
return AppNodeBuilder.build(compactionTestNode, [
...replacements,
[LLM.node, options.llm],
...(options?.plugin ? ([[Plugin.node, options.plugin]] as const) : []),
...(options?.config ? ([[Config.node, options.config]] as const) : []),
])
}
@@ -337,47 +333,8 @@ function reply(
}
}
function plugin(ready: Deferred.Deferred<void>) {
return Layer.mock(Plugin.Service)({
trigger: <Name extends string, Input, Output>(name: Name, _input: Input, output: Output) => {
if (name !== "experimental.session.compacting") return Effect.succeed(output)
return Effect.sync(() => Deferred.doneUnsafe(ready, Effect.void)).pipe(
Effect.andThen(Effect.never),
Effect.as(output),
)
},
list: () => Effect.succeed([]),
init: () => Effect.void,
})
}
function autocontinue(enabled: boolean) {
return Layer.mock(Plugin.Service)({
trigger: <Name extends string, Input, Output>(name: Name, _input: Input, output: Output) => {
if (name !== "experimental.compaction.autocontinue") return Effect.succeed(output)
return Effect.sync(() => {
;(output as { enabled: boolean }).enabled = enabled
return output
})
},
list: () => Effect.succeed([]),
init: () => Effect.void,
})
}
function compactionContext(context: string) {
return Layer.mock(Plugin.Service)({
trigger: <Name extends string, Input, Output>(name: Name, _input: Input, output: Output) => {
if (name !== "experimental.session.compacting") return Effect.succeed(output)
return Effect.sync(() => {
;(output as { context: string[] }).context.push(context)
return output
})
},
list: () => Effect.succeed([]),
init: () => Effect.void,
})
}
describe("session.compaction.isOverflow", () => {
it.live(
@@ -1102,38 +1059,6 @@ describe("session.compaction.process", () => {
{ git: true },
)
itCompaction.instance(
"allows plugins to disable synthetic continue prompt",
Effect.gen(function* () {
const ssn = yield* SessionNs.Service
const session = yield* ssn.create({})
const msg = yield* createUserMessage(session.id, "hello")
const msgs = yield* ssn.messages({ sessionID: session.id })
const result = yield* SessionCompaction.use.process({
parentID: msg.id,
messages: msgs,
sessionID: session.id,
auto: true,
})
const all = yield* ssn.messages({ sessionID: session.id })
const last = all.at(-1)
expect(result).toBe("continue")
expect(last?.info.role).toBe("assistant")
expect(
all.some(
(msg) =>
msg.info.role === "user" &&
msg.parts.some(
(part) => part.type === "text" && part.synthetic && part.text.includes("Continue if you have next steps"),
),
),
).toBe(false)
}).pipe(withCompaction({ plugin: autocontinue(false) })),
)
it.instance(
"replays the prior user turn on overflow when earlier context exists",
Effect.gen(function* () {
@@ -1264,38 +1189,6 @@ describe("session.compaction.process", () => {
{ timeout: 10_000 },
)
itCompaction.instance(
"does not leave a summary assistant when aborted before processor setup",
() =>
Effect.gen(function* () {
const ready = yield* Deferred.make<void>()
return yield* Effect.gen(function* () {
const ssn = yield* SessionNs.Service
const session = yield* ssn.create({})
const msg = yield* createUserMessage(session.id, "hello")
const msgs = yield* ssn.messages({ sessionID: session.id })
const fiber = yield* SessionCompaction.use
.process({
parentID: msg.id,
messages: msgs,
sessionID: session.id,
auto: false,
})
.pipe(Effect.forkChild)
yield* Deferred.await(ready).pipe(Effect.timeout("1 second"))
yield* Fiber.interrupt(fiber)
const exit = yield* Fiber.await(fiber).pipe(Effect.timeout("250 millis"))
const all = yield* ssn.messages({ sessionID: session.id })
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) expect(Cause.hasInterrupts(exit.cause)).toBe(true)
expect(all.some((msg) => msg.info.role === "assistant" && msg.info.summary)).toBe(false)
}).pipe(withCompaction({ plugin: plugin(ready) }))
}),
{ git: true },
)
itCompaction.instance(
"silently drops reasoning-delta arriving without prior reasoning-start",
() => {
@@ -1466,49 +1359,6 @@ describe("session.compaction.process", () => {
{ git: true },
)
itCompaction.instance(
"keeps plugin context outside the serialized conversation",
() => {
const stub = llm()
let captured = ""
stub.push(
reply("summary", (input) => {
captured = JSON.stringify(input.messages)
}),
)
return Effect.gen(function* () {
const ssn = yield* SessionNs.Service
const session = yield* ssn.create({})
yield* createUserMessage(session.id, "older context")
yield* createUserMessage(session.id, "keep this turn")
yield* createUserMessage(session.id, "and this one too")
yield* createCompactionMarker(session.id)
const msgs = yield* ssn.messages({ sessionID: session.id })
const parent = msgs.at(-1)?.info.id
expect(parent).toBeTruthy()
yield* SessionCompaction.use.process({
parentID: parent!,
messages: msgs,
sessionID: session.id,
auto: false,
})
expect(captured).toContain("Prioritize unresolved migration details")
expect(captured.indexOf("</conversation>")).toBeLessThan(
captured.indexOf("Prioritize unresolved migration details"),
)
}).pipe(
withCompaction({
llm: stub.llmLayer,
plugin: compactionContext("Prioritize unresolved migration details"),
}),
)
},
{ git: true },
)
itCompaction.instance(
"serializes repeated compaction history as one user message",
() => {
@@ -17,7 +17,6 @@ import { Config } from "@/config/config"
import { LSP } from "@/lsp/lsp"
import { MCP } from "../../src/mcp"
import { Permission } from "../../src/permission"
import { Plugin } from "../../src/plugin"
import { Provider as ProviderSvc } from "@/provider/provider"
import { Env } from "../../src/env"
import { Git } from "../../src/git"
@@ -179,7 +178,6 @@ const promptRoot = LayerNode.group([
AgentSvc.node,
Command.node,
Permission.node,
Plugin.node,
Config.node,
ProviderSvc.node,
LSP.node,
@@ -1,186 +0,0 @@
import { describe, expect, beforeAll, afterAll } from "bun:test"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Effect } from "effect"
import { Discovery } from "../../src/skill/discovery"
import { Global } from "@opencode-ai/core/global"
import { Filesystem } from "@/util/filesystem"
import { rm } from "fs/promises"
import path from "path"
import { testEffect } from "../lib/effect"
let CLOUDFLARE_SKILLS_URL: string
let server: ReturnType<typeof Bun.serve>
let downloadCount = 0
let mutableVersion = "1"
let mutableContent = "# Old"
let mutableDownloadCount = 0
let mutableFiles = ["SKILL.md"]
const fixturePath = path.join(import.meta.dir, "../fixture/skills")
const cacheDir = path.join(Global.Path.cache, "skills")
const it = testEffect(LayerNode.compile(LayerNode.group([Discovery.node, FSUtil.node])))
beforeAll(async () => {
await rm(cacheDir, { recursive: true, force: true })
server = Bun.serve({
port: 0,
async fetch(req) {
const url = new URL(req.url)
if (url.pathname === "/mutable/index.json") {
return Response.json({ skills: [{ name: "mutable", version: mutableVersion, files: mutableFiles }] })
}
if (url.pathname === "/mutable/mutable/SKILL.md") {
mutableDownloadCount++
return new Response(mutableContent)
}
if (url.pathname === "/mutable/mutable/old.md") return new Response("old reference")
// route /.well-known/skills/* to the fixture directory
if (url.pathname.startsWith("/.well-known/skills/")) {
const filePath = url.pathname.replace("/.well-known/skills/", "")
const fullPath = path.join(fixturePath, filePath)
if (await Filesystem.exists(fullPath)) {
if (!fullPath.endsWith("index.json")) {
downloadCount++
}
return new Response(Bun.file(fullPath))
}
}
return new Response("Not Found", { status: 404 })
},
})
CLOUDFLARE_SKILLS_URL = `http://localhost:${server.port}/.well-known/skills/`
})
afterAll(async () => {
void server?.stop()
await rm(cacheDir, { recursive: true, force: true })
})
describe("Discovery.pull", () => {
it.live("downloads skills from cloudflare url", () =>
Effect.gen(function* () {
const fsys = yield* FSUtil.Service
const discovery = yield* Discovery.Service
const dirs = yield* discovery.pull(CLOUDFLARE_SKILLS_URL)
expect(dirs.length).toBeGreaterThan(0)
for (const dir of dirs) {
expect(dir).toStartWith(cacheDir)
const md = path.join(dir, "SKILL.md")
expect(yield* fsys.existsSafe(md)).toBe(true)
}
}),
)
it.live("url without trailing slash works", () =>
Effect.gen(function* () {
const fsys = yield* FSUtil.Service
const discovery = yield* Discovery.Service
const dirs = yield* discovery.pull(CLOUDFLARE_SKILLS_URL.replace(/\/$/, ""))
expect(dirs.length).toBeGreaterThan(0)
for (const dir of dirs) {
const md = path.join(dir, "SKILL.md")
expect(yield* fsys.existsSafe(md)).toBe(true)
}
}),
)
it.live("returns empty array for invalid url", () =>
Effect.gen(function* () {
const discovery = yield* Discovery.Service
const dirs = yield* discovery.pull(`http://localhost:${server.port}/invalid-url/`)
expect(dirs).toEqual([])
}),
)
it.live("returns empty array for non-json response", () =>
Effect.gen(function* () {
// any url not explicitly handled in server returns 404 text "Not Found"
const discovery = yield* Discovery.Service
const dirs = yield* discovery.pull(`http://localhost:${server.port}/some-other-path/`)
expect(dirs).toEqual([])
}),
)
it.live("downloads reference files alongside SKILL.md", () =>
Effect.gen(function* () {
const fsys = yield* FSUtil.Service
const discovery = yield* Discovery.Service
const dirs = yield* discovery.pull(CLOUDFLARE_SKILLS_URL)
// find a skill dir that should have reference files (e.g. agents-sdk)
const agentsSdk = dirs.find((d) => d.endsWith(path.sep + "agents-sdk"))
expect(agentsSdk).toBeDefined()
if (agentsSdk) {
const refs = path.join(agentsSdk, "references")
expect(yield* fsys.existsSafe(path.join(agentsSdk, "SKILL.md"))).toBe(true)
// agents-sdk has reference files per the index
const refDir = yield* Effect.promise(() =>
Array.fromAsync(new Bun.Glob("**/*.md").scan({ cwd: refs, onlyFiles: true })),
)
expect(refDir.length).toBeGreaterThan(0)
}
}),
)
it.live("caches downloaded files on second pull", () =>
Effect.gen(function* () {
// clear dir and downloadCount
yield* Effect.promise(() => rm(cacheDir, { recursive: true, force: true }))
downloadCount = 0
const discovery = yield* Discovery.Service
// first pull to populate cache
const first = yield* discovery.pull(CLOUDFLARE_SKILLS_URL)
expect(first.length).toBeGreaterThan(0)
const firstCount = downloadCount
expect(firstCount).toBeGreaterThan(0)
// second pull should return same results from cache
const second = yield* discovery.pull(CLOUDFLARE_SKILLS_URL)
expect(second.length).toBe(first.length)
expect(second.sort()).toEqual(first.sort())
// second pull should NOT increment download count
expect(downloadCount).toBe(firstCount)
}),
)
it.live("refreshes a remote skill when its version changes", () =>
Effect.gen(function* () {
yield* Effect.promise(() => rm(cacheDir, { recursive: true, force: true }))
mutableVersion = "1"
mutableContent = "# Old"
mutableDownloadCount = 0
mutableFiles = ["SKILL.md", "old.md"]
const discovery = yield* Discovery.Service
const url = `http://localhost:${server.port}/mutable/`
const first = yield* discovery.pull(url)
expect(yield* Effect.promise(() => Bun.file(path.join(first[0], "SKILL.md")).text())).toBe("# Old")
mutableVersion = "2"
mutableContent = "# Partial"
mutableFiles = ["SKILL.md", "missing.md"]
const second = yield* discovery.pull(url)
expect(yield* Effect.promise(() => Bun.file(path.join(second[0], "SKILL.md")).text())).toBe("# Old")
expect(yield* Effect.promise(() => Bun.file(path.join(second[0], "old.md")).text())).toBe("old reference")
mutableVersion = "3"
mutableContent = "# New"
mutableFiles = ["SKILL.md"]
yield* discovery.pull(url)
expect(yield* Effect.promise(() => Bun.file(path.join(second[0], "SKILL.md")).text())).toBe("# New")
expect(yield* Effect.promise(() => Bun.file(path.join(second[0], "old.md")).exists())).toBe(false)
expect(mutableDownloadCount).toBe(3)
yield* discovery.pull(url)
expect(mutableDownloadCount).toBe(3)
}),
)
})
@@ -2,7 +2,6 @@ import { describe, expect } from "bun:test"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { Effect, Layer } from "effect"
import { Skill } from "../../src/skill"
import { Discovery } from "../../src/skill/discovery"
import { RuntimeFlags } from "../../src/effect/runtime-flags"
import { EventV2Bridge } from "../../src/event-v2-bridge"
import { Config } from "../../src/config/config"
@@ -3,7 +3,6 @@ import { CodeModeTool, describeCatalog } from "@/tool/code-mode"
import { McpCatalog } from "@/mcp/catalog"
import { Agent } from "@/agent/agent"
import { MCP } from "@/mcp"
import { Plugin } from "@/plugin"
import { Session } from "@/session/session"
import { Tool } from "@/tool/tool"
import * as Truncate from "@/tool/truncate"
@@ -139,10 +138,6 @@ async function buildTool() {
}
const layer = Layer.mergeAll(
Layer.mock(Plugin.Service, {
trigger: ((_name: unknown, _input: unknown, output: unknown) =>
Effect.succeed(output)) as Plugin.Interface["trigger"],
}),
Layer.mock(Truncate.Service, {
output: (text: string) => Effect.succeed({ content: text, truncated: false as const }),
}),
+1 -78
View File
@@ -5,7 +5,6 @@ import type { PermissionV1 } from "@opencode-ai/core/v1/permission"
import { Agent } from "@/agent/agent"
import { MCP } from "@/mcp"
import { Permission } from "@/permission"
import { Plugin } from "@/plugin"
import { Session } from "@/session/session"
import { Tool } from "@/tool/tool"
import * as Truncate from "@/tool/truncate"
@@ -41,12 +40,8 @@ function harness(input: {
mcpTools: Record<string, MCP.McpTool>
servers: string[]
permission?: PermissionV1.Rule[]
trigger?: Plugin.Interface["trigger"]
}) {
return Layer.mergeAll(
Layer.mock(Plugin.Service, {
trigger: input.trigger ?? (((_name, _input, output) => Effect.succeed(output)) as Plugin.Interface["trigger"]),
}),
Layer.mock(Truncate.Service, {
output: (text: string) => Effect.succeed({ content: text, truncated: false as const }),
}),
@@ -71,13 +66,12 @@ function build(
mcpTools: Record<string, MCP.McpTool>,
servers?: string[],
permission?: PermissionV1.Rule[],
trigger?: Plugin.Interface["trigger"],
) {
const names = serverNames(mcpTools, servers)
return Effect.runPromise(
CodeModeTool.pipe(
Effect.flatMap(Tool.init),
Effect.provide(harness({ mcpTools, servers: names, permission, trigger })),
Effect.provide(harness({ mcpTools, servers: names, permission })),
),
)
}
@@ -391,77 +385,6 @@ describe("code mode execute", () => {
expect(output.metadata.toolCalls).toEqual([{ tool: "a.tool", status: "error" }])
})
test("child calls fire plugin tool.execute hooks with the MCP key and synthetic parent/N call ids", async () => {
const events: { name: string; input: any; output: any }[] = []
const trigger = ((name: unknown, input: unknown, output: unknown) =>
Effect.sync(() => {
events.push({ name: name as string, input, output })
return output
})) as Plugin.Interface["trigger"]
const tool = await build(
{
a_tool: mcpTool("a", () => ({ content: [{ type: "text", text: "one" }] })),
b_tool: mcpTool("b", () => ({ content: [{ type: "text", text: "two" }] })),
},
undefined,
undefined,
trigger,
)
const out = await Effect.runPromise(
tool.execute({ code: "await tools.a.tool({ x: 1 }); await tools.b.tool({}); return 'done'" }, ctx),
)
expect(out.output).toBe("done")
expect(events.map((e) => [e.name, e.input.tool, e.input.callID])).toEqual([
["tool.execute.before", "a_tool", "call_code_mode/1"],
["tool.execute.after", "a_tool", "call_code_mode/1"],
["tool.execute.before", "b_tool", "call_code_mode/2"],
["tool.execute.after", "b_tool", "call_code_mode/2"],
])
const [before, after] = events
expect(before!.input.sessionID).toBe(ctx.sessionID)
expect(before!.output).toEqual({ args: { x: 1 } })
expect(after!.input.args).toEqual({ x: 1 })
expect(after!.output).toEqual({ content: [{ type: "text", text: "one" }] })
})
test("a failing before hook fails only that child call as a catchable in-program error", async () => {
const trigger = ((name: unknown, input: any, output: unknown) => {
if (name === "tool.execute.before" && input.tool === "a_tool") return Effect.die(new Error("hook exploded"))
return Effect.succeed(output)
}) as Plugin.Interface["trigger"]
const called: string[] = []
const record = (name: string) => () => {
called.push(name)
return { content: [{ type: "text", text: "ok" }] }
}
const tool = await build(
{ a_tool: mcpTool("a", record("a")), b_tool: mcpTool("b", record("b")) },
undefined,
undefined,
trigger,
)
const out = await Effect.runPromise(
tool.execute(
{
code: `
let caught
try { await tools.a.tool({}) } catch (e) { caught = e.message }
const r = await tools.b.tool({})
return caught + " / " + r
`,
},
ctx,
),
)
expect(out.metadata.error).toBeUndefined()
expect(out.output).toBe("hook exploded / ok")
expect(called).toEqual(["b"])
})
test("streams live per-call metadata as a call starts and finishes", async () => {
const snapshots: Array<{ toolCalls: { tool: string; status: string; input?: Record<string, unknown> }[] }> = []
const recordingCtx: Tool.Context = {
@@ -10,7 +10,6 @@ import { disposeAllInstances, TestInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { TestConfig } from "../fixture/config"
import { Config } from "@/config/config"
import { Plugin } from "@/plugin"
import { Agent } from "@/agent/agent"
import { InstanceState } from "@/effect/instance-state"
@@ -26,30 +25,6 @@ const configLayer = TestConfig.layer({
directories: () => InstanceState.directory.pipe(Effect.map((dir) => [path.join(dir, ".opencode")])),
})
// Fake Plugin.Service that returns a single plugin whose `tool` map contains
// one definition with `args: undefined`. Used to exercise the plugin entry
// point of `fromPlugin` for the #27451 / #27630 regression.
const brokenPluginLayer = Layer.succeed(
Plugin.Service,
Plugin.Service.of({
init: () => Effect.void,
trigger: ((_name: unknown, _input: unknown, output: unknown) =>
Effect.succeed(output)) as Plugin.Interface["trigger"],
list: () =>
Effect.succeed([
{
tool: {
broken_plugin_tool: {
description: "plugin tool with missing args",
args: undefined as unknown as Record<string, never>,
execute: async () => "ok",
},
},
},
]),
}),
)
const root = LayerNode.group([ToolRegistry.node, Agent.node])
const replacements = [
[Config.node, configLayer],
@@ -93,8 +68,6 @@ const withEmptyCodeMode = testEffect(
],
]),
)
const withBrokenPlugin = testEffect(LayerNode.compile(root, [...replacements, [Plugin.node, brokenPluginLayer]]))
afterEach(async () => {
await disposeAllInstances()
})
@@ -257,21 +230,6 @@ describe("tool.registry", () => {
}),
)
// Same regression, plugin entry point. The original reports (#27451, #27630)
// came in through `plugin.list()` — `oh-my-opencode` was registering a tool
// with `args: undefined` and crashing every message submit. The file-scan
// and plugin-list loops both funnel through `fromPlugin`, but covering both
// entry points means a future refactor that splits them won't silently lose
// protection.
withBrokenPlugin.instance("tolerates a plugin tool registered with null/undefined args", () =>
Effect.gen(function* () {
const registry = yield* ToolRegistry.Service
const ids = yield* registry.ids()
expect(ids).toContain("read")
expect(ids).toContain("broken_plugin_tool")
}),
)
it.instance("loads tools from .opencode/tools (plural)", () =>
Effect.gen(function* () {
const test = yield* TestInstance
@@ -16,7 +16,6 @@ import { Truncate } from "@/tool/truncate"
import { SessionID, MessageID } from "../../src/session/schema"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Plugin } from "../../src/plugin"
import { testEffect } from "../lib/effect"
import { Tool } from "@/tool/tool"
import { RuntimeFlags } from "@/effect/runtime-flags"
@@ -27,7 +26,6 @@ const shellLayer = Layer.mergeAll(
LayerNode.group([
CrossSpawnSpawner.node,
FSUtil.node,
Plugin.node,
Truncate.node,
Config.node,
Agent.node,