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:
@@ -36,3 +36,4 @@ runs/neuron-v0/universe.db*
|
||||
runs/rung1/universe.db*
|
||||
evolve/
|
||||
garden/
|
||||
/neuron
|
||||
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
|
||||
@@ -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)")
|
||||
}),
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
}),
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
@@ -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: [] })
|
||||
|
||||
@@ -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"
|
||||
@@ -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 })
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 "."
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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({}))
|
||||
}),
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -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 +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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
})
|
||||
@@ -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">[] = []
|
||||
|
||||
@@ -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"
|
||||
@@ -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 "."
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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* (
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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("</div><script>alert("xss" & 'more')</script>")
|
||||
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
@@ -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" },
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -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 }),
|
||||
}),
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user