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,7 +89,6 @@ 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 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,7 +193,6 @@ 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 provider = yield* Provider.Service
|
||||
const events = yield* EventV2Bridge.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({
|
||||
const conversation = selected.head.map(serialize).filter(Boolean).join("\n\n")
|
||||
const nextPrompt = buildPrompt({
|
||||
previousSummary,
|
||||
context: [conversation],
|
||||
}),
|
||||
...compacting.context,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n\n")
|
||||
})
|
||||
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,27 +485,6 @@ 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",
|
||||
@@ -546,9 +503,8 @@ const layer = Layer.effect(
|
||||
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.
|
||||
// Internal marker for auto-compaction followups so callers can
|
||||
// distinguish them from manual post-compaction user prompts.
|
||||
metadata: { compaction_continue: true },
|
||||
synthetic: true,
|
||||
text,
|
||||
@@ -559,7 +515,6 @@ const layer = Layer.effect(
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (processor.message.error) return "stop"
|
||||
if (result === "continue") {
|
||||
@@ -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,
|
||||
},
|
||||
{
|
||||
// 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 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: ProviderTransform.maxOutputTokens(input.model, input.flags.outputTokenMax),
|
||||
maxOutputTokens: providerCapsOutputTokens
|
||||
? undefined
|
||||
: ProviderTransform.maxOutputTokens(input.model, input.flags.outputTokenMax),
|
||||
options,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
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 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,7 +86,6 @@ 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 scope = yield* Scope.Scope
|
||||
const status = yield* SessionStatus.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,7 +179,6 @@ 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,
|
||||
@@ -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,7 +341,6 @@ 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 defaultTimeoutMs = flags.bashDefaultTimeoutMs ?? 2 * 60 * 1000
|
||||
|
||||
@@ -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