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*
|
runs/rung1/universe.db*
|
||||||
evolve/
|
evolve/
|
||||||
garden/
|
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
|
never banners. And when the application cannot say why it died, that
|
||||||
is not a debugging inconvenience - it is evidence of architecture
|
is not a debugging inconvenience - it is evidence of architecture
|
||||||
built to keep you from asking.
|
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()
|
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
|
### Destructuring
|
||||||
|
|
||||||
Avoid unnecessary destructuring. Use dot notation to preserve context.
|
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 { mergeDeep, pipe, sortBy, values } from "remeda"
|
||||||
import { Global } from "@opencode-ai/core/global"
|
import { Global } from "@opencode-ai/core/global"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { Plugin } from "@/plugin"
|
|
||||||
import { Skill } from "../skill"
|
import { Skill } from "../skill"
|
||||||
import { Effect, Context, Layer, Schema } from "effect"
|
import { Effect, Context, Layer, Schema } from "effect"
|
||||||
import { InstanceState } from "@/effect/instance-state"
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
@@ -90,7 +89,6 @@ const layer = Layer.effect(
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const config = yield* Config.Service
|
const config = yield* Config.Service
|
||||||
const auth = yield* Auth.Service
|
const auth = yield* Auth.Service
|
||||||
const plugin = yield* Plugin.Service
|
|
||||||
const skill = yield* Skill.Service
|
const skill = yield* Skill.Service
|
||||||
const provider = yield* Provider.Service
|
const provider = yield* Provider.Service
|
||||||
const locations = yield* LocationServiceMap.Service
|
const locations = yield* LocationServiceMap.Service
|
||||||
@@ -378,7 +376,6 @@ const layer = Layer.effect(
|
|||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
const system = [PROMPT_GENERATE]
|
const system = [PROMPT_GENERATE]
|
||||||
yield* plugin.trigger("experimental.chat.system.transform", { model: resolved }, { system })
|
|
||||||
const existing = yield* InstanceState.useEffect(state, (s) => s.list())
|
const existing = yield* InstanceState.useEffect(state, (s) => s.list())
|
||||||
|
|
||||||
// TODO: clean this up so provider specific logic doesnt bleed over
|
// TODO: clean this up so provider specific logic doesnt bleed over
|
||||||
@@ -447,7 +444,7 @@ const locationServiceMapNode = LayerNode.make({
|
|||||||
export const node = LayerNode.make({
|
export const node = LayerNode.make({
|
||||||
service: Service,
|
service: Service,
|
||||||
layer: layer,
|
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"
|
export * as Agent from "./agent"
|
||||||
|
|||||||
@@ -51,7 +51,6 @@ const InfoCommand = effectCmd({
|
|||||||
describe: "show debug information",
|
describe: "show debug information",
|
||||||
handler: Effect.fn("Cli.debug.info")(function* () {
|
handler: Effect.fn("Cli.debug.info")(function* () {
|
||||||
const { Config } = yield* Effect.promise(() => import("@/config/config"))
|
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 config = yield* Config.Service.use((cfg) => cfg.get())
|
||||||
const termProgram = process.env.TERM_PROGRAM
|
const termProgram = process.env.TERM_PROGRAM
|
||||||
? `${process.env.TERM_PROGRAM}${process.env.TERM_PROGRAM_VERSION ? ` ${process.env.TERM_PROGRAM_VERSION}` : ""}`
|
? `${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(`opencode version: ${InstallationVersion}`)
|
||||||
console.log(`os: ${os.type()} ${os.release()} ${os.arch()}`)
|
console.log(`os: ${os.type()} ${os.release()} ${os.arch()}`)
|
||||||
console.log(`terminal: ${terminal || "unknown"}`)
|
console.log(`terminal: ${terminal || "unknown"}`)
|
||||||
console.log("plugins:")
|
console.log("plugins: none (plugin system removed)")
|
||||||
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)}`)
|
|
||||||
}
|
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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 os from "os"
|
||||||
import { Config } from "@/config/config"
|
import { Config } from "@/config/config"
|
||||||
import { Global } from "@opencode-ai/core/global"
|
import { Global } from "@opencode-ai/core/global"
|
||||||
import { Plugin } from "../../plugin"
|
|
||||||
import type { Hooks } from "@opencode-ai/plugin"
|
import type { Hooks } from "@opencode-ai/plugin"
|
||||||
|
import { hooks as providerAuthHooks } from "@/provider/hooks"
|
||||||
import { Process } from "@/util/process"
|
import { Process } from "@/util/process"
|
||||||
import { errorMessage } from "@/util/error"
|
import { errorMessage } from "@/util/error"
|
||||||
import { text } from "node:stream/consumers"
|
import { text } from "node:stream/consumers"
|
||||||
@@ -352,7 +352,7 @@ export const ProvidersLoginCommand = effectCmd({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const cfgSvc = yield* Config.Service
|
const cfgSvc = yield* Config.Service
|
||||||
const pluginSvc = yield* Plugin.Service
|
const hooks: Hooks[] = yield* providerAuthHooks
|
||||||
const modelsDev = yield* ModelsDev.Service
|
const modelsDev = yield* ModelsDev.Service
|
||||||
yield* Effect.ignore(modelsDev.refresh(true))
|
yield* Effect.ignore(modelsDev.refresh(true))
|
||||||
|
|
||||||
@@ -366,7 +366,6 @@ export const ProvidersLoginCommand = effectCmd({
|
|||||||
for (const [key, value] of Object.entries(allProviders)) {
|
for (const [key, value] of Object.entries(allProviders)) {
|
||||||
if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) providers[key] = value
|
if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) providers[key] = value
|
||||||
}
|
}
|
||||||
const hooks = yield* pluginSvc.list()
|
|
||||||
|
|
||||||
const priority: Record<string, number> = {
|
const priority: Record<string, number> = {
|
||||||
opencode: 0,
|
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 { 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 { serviceUse } from "@opencode-ai/core/effect/service-use"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { pathToFileURL } from "url"
|
|
||||||
import os from "os"
|
import os from "os"
|
||||||
import { mergeDeep } from "remeda"
|
|
||||||
import { Global } from "@opencode-ai/core/global"
|
|
||||||
import fsNode from "fs/promises"
|
import fsNode from "fs/promises"
|
||||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
import { Context, Effect, Layer, Schema } from "effect"
|
||||||
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 { ConfigV1 } from "@opencode-ai/core/v1/config/config"
|
import { ConfigV1 } from "@opencode-ai/core/v1/config/config"
|
||||||
import { RemoteAuthError } from "@opencode-ai/core/v1/config/error"
|
import type { ConsoleState } from "@opencode-ai/core/v1/config/console-state"
|
||||||
import { ConfigPermissionV1 } from "@opencode-ai/core/v1/config/permission"
|
import { ConfigConsoleStateV1 } from "@opencode-ai/core/v1/config/console-state"
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
type Info = ConfigV1.Info & {
|
type Info = ConfigV1.Info & {
|
||||||
// plugin_origins is derived state, not a persisted config field. It keeps each winning plugin spec together
|
/** derived state: winning plugin specs + origin files */
|
||||||
// with the file and scope it came from so later runtime code can make location-sensitive decisions.
|
plugin_origins?: Array<{ spec: unknown; path: string }>
|
||||||
plugin_origins?: ConfigPlugin.Origin[]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type State = {
|
const configPath = () =>
|
||||||
config: Info
|
process.env.NEURON_CONFIG ?? path.join(os.homedir(), ".config", "neuron", "config.json")
|
||||||
directories: string[]
|
|
||||||
deps: Fiber.Fiber<void>[]
|
async function readLocal(): Promise<Info> {
|
||||||
consoleState: ConsoleState
|
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 {
|
export interface Interface {
|
||||||
readonly get: () => Effect.Effect<Info>
|
readonly get: () => Effect.Effect<Info>
|
||||||
readonly getGlobal: () => Effect.Effect<Info>
|
readonly getGlobal: () => Effect.Effect<Info>
|
||||||
@@ -132,549 +50,72 @@ export interface Interface {
|
|||||||
readonly waitForDependencies: () => Effect.Effect<void>
|
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)
|
export const use = serviceUse(Service)
|
||||||
|
|
||||||
function globalConfigFile() {
|
let cache: Info | undefined
|
||||||
const candidates = ["opencode.jsonc", "opencode.json", "config.json"].map((file) =>
|
|
||||||
path.join(Global.Path.config, file),
|
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(
|
const layer = Layer.effect(
|
||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const fs = yield* FSUtil.Service
|
const directories = [process.cwd()]
|
||||||
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 readConfigFile = (filepath: string) => fs.readFileStringSafe(filepath).pipe(Effect.orDie)
|
const get: () => Effect.Effect<Info> = () => Effect.promise(readLocal)
|
||||||
|
|
||||||
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 }
|
|
||||||
})
|
|
||||||
|
|
||||||
return Service.of({
|
return Service.of({
|
||||||
get,
|
get,
|
||||||
getGlobal,
|
getGlobal: get,
|
||||||
getConsoleState,
|
getConsoleState: () => Effect.succeed(emptyConsole()),
|
||||||
update,
|
update: (config) =>
|
||||||
updateGlobal,
|
Effect.promise(() =>
|
||||||
invalidate,
|
fsNode
|
||||||
directories,
|
.mkdir(path.dirname(configPath()), { recursive: true })
|
||||||
waitForDependencies,
|
.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({
|
// Legacy value-style access for call sites not yet on the service.
|
||||||
service: Service,
|
export const Path = { config: path.join(os.homedir(), ".config", "neuron") }
|
||||||
layer: layer,
|
export const get = Effect.promise(readLocal)
|
||||||
deps: [FSUtil.node, Auth.node, Account.node, Env.node, Npm.node, httpClient],
|
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 { Global } from "@opencode-ai/core/global"
|
||||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||||
import { CurrentWorkingDirectory } from "./tui-cwd"
|
import { CurrentWorkingDirectory } from "./tui-cwd"
|
||||||
import { ConfigPlugin } from "@/config/plugin"
|
|
||||||
import { TuiKeybind } from "@opencode-ai/tui/config/keybind"
|
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 { makeRuntime } from "@opencode-ai/core/effect/runtime"
|
||||||
import { Filesystem } from "@/util/filesystem"
|
|
||||||
import { ConfigVariable } from "@/config/variable"
|
import { ConfigVariable } from "@/config/variable"
|
||||||
import { Npm } from "@opencode-ai/core/npm"
|
|
||||||
import { FormatError, FormatUnknownError } from "@/cli/error"
|
import { FormatError, FormatUnknownError } from "@/cli/error"
|
||||||
import { TuiConfig } from "@opencode-ai/tui/config"
|
import { TuiConfig } from "@opencode-ai/tui/config"
|
||||||
|
|
||||||
@@ -29,29 +25,20 @@ export type Info = TuiConfig.Info
|
|||||||
|
|
||||||
type Acc = {
|
type Acc = {
|
||||||
result: Info
|
result: Info
|
||||||
plugin_origins: ConfigPlugin.Origin[]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Resolved = TuiConfig.Resolved
|
export type Resolved = TuiConfig.Resolved
|
||||||
|
|
||||||
export type HostMetadata = {
|
export type HostMetadata = {}
|
||||||
plugin_origins?: ConfigPlugin.Origin[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
readonly get: () => Effect.Effect<Resolved>
|
readonly get: () => Effect.Effect<Resolved>
|
||||||
readonly pluginOrigins: () => Effect.Effect<ConfigPlugin.Origin[]>
|
readonly pluginOrigins: () => Effect.Effect<never[]>
|
||||||
readonly waitForDependencies: () => Effect.Effect<void>
|
readonly waitForDependencies: () => Effect.Effect<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/TuiConfig") {}
|
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>) {
|
function normalize(raw: Record<string, unknown>) {
|
||||||
const data = { ...raw }
|
const data = { ...raw }
|
||||||
if (!("tui" in data)) return data
|
if (!("tui" in data)) return data
|
||||||
@@ -84,18 +71,6 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory:
|
|||||||
const afs = yield* FSUtil.Service
|
const afs = yield* FSUtil.Service
|
||||||
let appliedOrder = 0
|
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> =>
|
const load = (text: string, configFilepath: string): Effect.Effect<Info> =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const expanded = yield* Effect.promise(() =>
|
const expanded = yield* Effect.promise(() =>
|
||||||
@@ -116,7 +91,7 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory:
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
: parsed
|
: parsed
|
||||||
return yield* resolvePlugins(validated, configFilepath)
|
return validated
|
||||||
}).pipe(
|
}).pipe(
|
||||||
// catchCause (not tapErrorCause + orElseSucceed) because JSONC parsing and validation
|
// catchCause (not tapErrorCause + orElseSucceed) because JSONC parsing and validation
|
||||||
// can sync-throw — those become defects, which orElseSucceed wouldn't catch.
|
// 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 })
|
yield* Effect.logInfo("applying tui config", { path: file, order: appliedOrder })
|
||||||
}
|
}
|
||||||
acc.result = mergeDeep(acc.result, data)
|
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`
|
// 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 = {
|
const acc: Acc = {
|
||||||
result: {},
|
result: {},
|
||||||
plugin_origins: [],
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Global tui config (lowest precedence).
|
// 1. Global tui config (lowest precedence).
|
||||||
@@ -220,8 +182,7 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory:
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
config: result,
|
config: result,
|
||||||
pluginOrigins: acc.plugin_origins,
|
dirs: [],
|
||||||
dirs: result.plugin?.length ? dirs : [],
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -229,37 +190,17 @@ const layer = Layer.effect(
|
|||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const directory = yield* CurrentWorkingDirectory
|
const directory = yield* CurrentWorkingDirectory
|
||||||
const npm = yield* Npm.Service
|
|
||||||
const data = yield* loadState({ directory })
|
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 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")(() =>
|
const waitForDependencies = Effect.fn("TuiConfig.waitForDependencies")(() => Effect.void)
|
||||||
Effect.forEach(deps, Fiber.join, { concurrency: "unbounded" }).pipe(Effect.ignore(), Effect.asVoid),
|
|
||||||
)
|
|
||||||
return Service.of({ get, pluginOrigins, waitForDependencies })
|
return Service.of({ get, pluginOrigins, waitForDependencies })
|
||||||
}).pipe(Effect.withSpan("TuiConfig.layer")),
|
}).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))
|
const { runPromise } = makeRuntime(Service, AppNodeBuilder.build(node))
|
||||||
|
|
||||||
|
|||||||
@@ -11,13 +11,11 @@ import { Git } from "@/git"
|
|||||||
import { Ripgrep } from "@opencode-ai/core/ripgrep"
|
import { Ripgrep } from "@opencode-ai/core/ripgrep"
|
||||||
import { Storage } from "@/storage/storage"
|
import { Storage } from "@/storage/storage"
|
||||||
import { Snapshot } from "@/snapshot"
|
import { Snapshot } from "@/snapshot"
|
||||||
import { Plugin } from "@/plugin"
|
|
||||||
import { ModelsDev } from "@opencode-ai/core/models-dev"
|
import { ModelsDev } from "@opencode-ai/core/models-dev"
|
||||||
import { Provider } from "@/provider/provider"
|
import { Provider } from "@/provider/provider"
|
||||||
import { ProviderAuth } from "@/provider/auth"
|
import { ProviderAuth } from "@/provider/auth"
|
||||||
import { Agent } from "@/agent/agent"
|
import { Agent } from "@/agent/agent"
|
||||||
import { Skill } from "@/skill"
|
import { Skill } from "@/skill"
|
||||||
import { Discovery } from "@/skill/discovery"
|
|
||||||
import { Question } from "@/question"
|
import { Question } from "@/question"
|
||||||
import { Permission } from "@/permission"
|
import { Permission } from "@/permission"
|
||||||
import { Todo } from "@/session/todo"
|
import { Todo } from "@/session/todo"
|
||||||
@@ -66,13 +64,11 @@ export const AppLayer = AppNodeBuilderV1.build(
|
|||||||
Git.node,
|
Git.node,
|
||||||
Storage.node,
|
Storage.node,
|
||||||
Snapshot.node,
|
Snapshot.node,
|
||||||
Plugin.node,
|
|
||||||
ModelsDev.node,
|
ModelsDev.node,
|
||||||
Provider.node,
|
Provider.node,
|
||||||
ProviderAuth.node,
|
ProviderAuth.node,
|
||||||
Agent.node,
|
Agent.node,
|
||||||
Skill.node,
|
Skill.node,
|
||||||
Discovery.node,
|
|
||||||
Question.node,
|
Question.node,
|
||||||
Permission.node,
|
Permission.node,
|
||||||
Todo.node,
|
Todo.node,
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { Layer, ManagedRuntime } from "effect"
|
|||||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||||
|
|
||||||
import { Plugin } from "@/plugin"
|
|
||||||
import { LSP } from "@/lsp/lsp"
|
import { LSP } from "@/lsp/lsp"
|
||||||
import { Format } from "@/format"
|
import { Format } from "@/format"
|
||||||
import { ShareNext } from "@/share/share-next"
|
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"
|
import { memoMap } from "@opencode-ai/core/effect/memo-map"
|
||||||
|
|
||||||
export const BootstrapLayer = AppNodeBuilder.build(
|
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))
|
).pipe(Layer.provide(Observability.layer))
|
||||||
|
|
||||||
export const BootstrapRuntime = ManagedRuntime.make(BootstrapLayer, { memoMap })
|
export const BootstrapRuntime = ManagedRuntime.make(BootstrapLayer, { memoMap })
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ import { PrCommand } from "./cli/cmd/pr"
|
|||||||
import { SessionCommand } from "./cli/cmd/session"
|
import { SessionCommand } from "./cli/cmd/session"
|
||||||
import { DbCommand } from "./cli/cmd/db"
|
import { DbCommand } from "./cli/cmd/db"
|
||||||
import { errorMessage } from "./util/error"
|
import { errorMessage } from "./util/error"
|
||||||
import { PluginCommand } from "./cli/cmd/plug"
|
|
||||||
import { Heap } from "./cli/heap"
|
import { Heap } from "./cli/heap"
|
||||||
|
|
||||||
const args = hideBin(process.argv)
|
const args = hideBin(process.argv)
|
||||||
@@ -100,7 +99,6 @@ const cli = yargs(args)
|
|||||||
.command(ImportCommand)
|
.command(ImportCommand)
|
||||||
.command(PrCommand)
|
.command(PrCommand)
|
||||||
.command(SessionCommand)
|
.command(SessionCommand)
|
||||||
.command(PluginCommand)
|
|
||||||
.command(DbCommand)
|
.command(DbCommand)
|
||||||
.fail((msg, err) => {
|
.fail((msg, err) => {
|
||||||
if (
|
if (
|
||||||
@@ -132,6 +130,13 @@ try {
|
|||||||
if (formatted === undefined) {
|
if (formatted === undefined) {
|
||||||
UI.error("Unexpected error" + EOL)
|
UI.error("Unexpected error" + EOL)
|
||||||
process.stderr.write(errorMessage(e) + 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
|
process.exitCode = 1
|
||||||
} finally {
|
} 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 oauthServer: ReturnType<typeof createServer> | undefined
|
||||||
let pendingOAuth: PendingOAuth | 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 }> {
|
async function startOAuthServer(): Promise<{ port: number; redirectUri: string }> {
|
||||||
if (oauthServer) {
|
if (oauthServer) {
|
||||||
return { port: OAUTH_PORT, redirectUri: `http://localhost:${OAUTH_PORT}/auth/callback` }
|
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> {
|
export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPluginOptions = {}): Promise<Hooks> {
|
||||||
const issuer = options.issuer ?? ISSUER
|
const issuer = options.issuer ?? ISSUER
|
||||||
const codexApiEndpoint = options.codexApiEndpoint ?? CODEX_API_ENDPOINT
|
const codexApiEndpoint = options.codexApiEndpoint ?? CODEX_API_ENDPOINT
|
||||||
let websocketFetchInstalled = false
|
|
||||||
const websocketFetches: Array<ReturnType<typeof OpenAIWebSocketPool.createWebSocketFetch>> = []
|
const websocketFetches: Array<ReturnType<typeof OpenAIWebSocketPool.createWebSocketFetch>> = []
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -331,7 +336,7 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug
|
|||||||
: undefined
|
: undefined
|
||||||
if (websocketFetch) {
|
if (websocketFetch) {
|
||||||
websocketFetches.push(websocketFetch)
|
websocketFetches.push(websocketFetch)
|
||||||
websocketFetchInstalled = true
|
websocketState.fetchInstalled = true
|
||||||
}
|
}
|
||||||
if (auth.type !== "oauth") return websocketFetch ? { fetch: websocketFetch } : {}
|
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
|
// Temporary fetch-layer hack: title generation currently shares the conversation
|
||||||
// session ID, so the OpenAI plugin marks it for HTTP fallback until transport
|
// session ID, so the OpenAI plugin marks it for HTTP fallback until transport
|
||||||
// context can be passed directly instead of smuggled through headers.
|
// 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) => {
|
"chat.params": async (input, output) => {
|
||||||
if (input.model.providerID !== "openai") return
|
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 { PtyEnvironment } from "@opencode-ai/server/pty-environment"
|
||||||
import { Effect, Layer } from "effect"
|
import { Effect, Layer } from "effect"
|
||||||
import { InstanceStore } from "@/project/instance-store"
|
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(
|
export const layer = Layer.effect(
|
||||||
PtyEnvironment.Service,
|
PtyEnvironment.Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* Plugin.Service
|
|
||||||
const instances = yield* InstanceStore.Service
|
const instances = yield* InstanceStore.Service
|
||||||
return PtyEnvironment.Service.of({
|
return PtyEnvironment.Service.of({
|
||||||
get: Effect.fn("PtyEnvironment.get")(function* (input) {
|
get: Effect.fn("PtyEnvironment.get")(function* (input) {
|
||||||
return yield* instances.provide(
|
return yield* instances.provide({ directory: input.directory }, Effect.succeed({}))
|
||||||
{ directory: input.directory },
|
|
||||||
plugin
|
|
||||||
.trigger("shell.env", { cwd: input.cwd }, { env: {} as Record<string, string> })
|
|
||||||
.pipe(Effect.map((result) => result.env)),
|
|
||||||
)
|
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -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 { makeGlobalNode } from "@opencode-ai/core/effect/app-node"
|
||||||
import { Plugin } from "../plugin"
|
|
||||||
import { Format } from "../format"
|
import { Format } from "../format"
|
||||||
import { LSP } from "@/lsp/lsp"
|
import { LSP } from "@/lsp/lsp"
|
||||||
import { Snapshot } from "../snapshot"
|
import { Snapshot } from "../snapshot"
|
||||||
@@ -23,7 +22,6 @@ const layer = Layer.effect(
|
|||||||
const config = yield* Config.Service
|
const config = yield* Config.Service
|
||||||
const format = yield* Format.Service
|
const format = yield* Format.Service
|
||||||
const lsp = yield* LSP.Service
|
const lsp = yield* LSP.Service
|
||||||
const plugin = yield* Plugin.Service
|
|
||||||
const project = yield* Project.Service
|
const project = yield* Project.Service
|
||||||
const shareNext = yield* ShareNext.Service
|
const shareNext = yield* ShareNext.Service
|
||||||
const snapshot = yield* Snapshot.Service
|
const snapshot = yield* Snapshot.Service
|
||||||
@@ -34,8 +32,6 @@ const layer = Layer.effect(
|
|||||||
yield* Effect.logInfo("bootstrapping", { directory: ctx.directory })
|
yield* Effect.logInfo("bootstrapping", { directory: ctx.directory })
|
||||||
// everything depends on config so eager load it for nice traces
|
// everything depends on config so eager load it for nice traces
|
||||||
yield* config.get()
|
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
|
// Each service self-manages its own slow work via Effect.forkScoped against
|
||||||
// its per-instance state scope. We just await materialization here.
|
// its per-instance state scope. We just await materialization here.
|
||||||
yield* Effect.forEach(
|
yield* Effect.forEach(
|
||||||
@@ -52,7 +48,7 @@ const layer = Layer.effect(
|
|||||||
export const node = makeGlobalNode({
|
export const node = makeGlobalNode({
|
||||||
service: Service,
|
service: Service,
|
||||||
layer: layer,
|
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"
|
export * as InstanceBootstrap from "./bootstrap"
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import { serviceUse } from "@opencode-ai/core/effect/service-use"
|
|||||||
import { Auth } from "@/auth"
|
import { Auth } from "@/auth"
|
||||||
import { InstanceState } from "@/effect/instance-state"
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
import { optional } from "@opencode-ai/core/schema"
|
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 { ProviderV2 } from "@opencode-ai/core/provider"
|
||||||
import { Array as Arr, Effect, Layer, Record, Result, Context, Schema } from "effect"
|
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)
|
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,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const auth = yield* Auth.Service
|
const auth = yield* Auth.Service
|
||||||
const plugin = yield* Plugin.Service
|
const flags = yield* RuntimeFlags.Service
|
||||||
const state = yield* InstanceState.make<State>(
|
const state = yield* InstanceState.make<State>(
|
||||||
Effect.fn("ProviderAuth.state")(function* () {
|
Effect.fn("ProviderAuth.state")(function* (ctx) {
|
||||||
const plugins = yield* plugin.list()
|
const plugins = yield* Effect.promise(() =>
|
||||||
|
authProviderHooks({
|
||||||
|
directory: ctx.directory,
|
||||||
|
project: ctx.project,
|
||||||
|
worktree: ctx.worktree,
|
||||||
|
experimentalWebSockets: flags.experimentalWebSockets,
|
||||||
|
}),
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
hooks: Record.fromEntries(
|
hooks: Record.fromEntries(
|
||||||
Arr.filterMap(plugins, (x) =>
|
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"
|
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 { NoSuchModelError, type Provider as SDK } from "ai"
|
||||||
import { Npm } from "@opencode-ai/core/npm"
|
import { Npm } from "@opencode-ai/core/npm"
|
||||||
import { Hash } from "@opencode-ai/core/util/hash"
|
import { Hash } from "@opencode-ai/core/util/hash"
|
||||||
import { Plugin } from "../plugin"
|
|
||||||
import { serviceUse } from "@opencode-ai/core/effect/service-use"
|
import { serviceUse } from "@opencode-ai/core/effect/service-use"
|
||||||
import { type LanguageModelV3 } from "@ai-sdk/provider"
|
import { type LanguageModelV3 } from "@ai-sdk/provider"
|
||||||
import { ModelsDev } from "@opencode-ai/core/models-dev"
|
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 { isRecord } from "@/util/record"
|
||||||
import { optional } from "@opencode-ai/core/schema"
|
import { optional } from "@opencode-ai/core/schema"
|
||||||
import { ProviderTransform } from "./transform"
|
import { ProviderTransform } from "./transform"
|
||||||
|
import { authProviderHooks } from "./hooks"
|
||||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||||
import { ModelV2 } from "@opencode-ai/core/model"
|
import { ModelV2 } from "@opencode-ai/core/model"
|
||||||
import { ModelStatus } from "./model-status"
|
import { ModelStatus } from "./model-status"
|
||||||
@@ -853,11 +853,10 @@ const layer = Layer.effect(
|
|||||||
const config = yield* Config.Service
|
const config = yield* Config.Service
|
||||||
const auth = yield* Auth.Service
|
const auth = yield* Auth.Service
|
||||||
const env = yield* Env.Service
|
const env = yield* Env.Service
|
||||||
const plugin = yield* Plugin.Service
|
|
||||||
const modelsDevSvc = yield* ModelsDev.Service
|
const modelsDevSvc = yield* ModelsDev.Service
|
||||||
const runtimeFlags = yield* RuntimeFlags.Service
|
const runtimeFlags = yield* RuntimeFlags.Service
|
||||||
|
|
||||||
const state = yield* InstanceState.make<State>(() =>
|
const state = yield* InstanceState.make<State>((ctx) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const bridge = yield* EffectBridge.make()
|
const bridge = yield* EffectBridge.make()
|
||||||
const cfg = yield* config.get()
|
const cfg = yield* config.get()
|
||||||
@@ -898,8 +897,15 @@ const layer = Layer.effect(
|
|||||||
providers[providerID] = mergeDeep(match, provider)
|
providers[providerID] = mergeDeep(match, provider)
|
||||||
}
|
}
|
||||||
|
|
||||||
// load plugins first so config() hook runs before reading cfg.provider
|
// First-party auth/provider hooks (static registry; no external plugins).
|
||||||
const plugins = yield* plugin.list()
|
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
|
// now read config providers - includes any modifications from plugin config() hook
|
||||||
const configProviders = Object.entries(cfg.provider ?? {})
|
const configProviders = Object.entries(cfg.provider ?? {})
|
||||||
@@ -1412,19 +1418,6 @@ const layer = Layer.effect(
|
|||||||
const provider = s.providers[providerID]
|
const provider = s.providers[providerID]
|
||||||
if (!provider) return undefined
|
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.
|
// 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")) {
|
if (providerID === ProviderV2.ID.azure || providerID === ProviderV2.ID.make("azure-cognitive-services")) {
|
||||||
return undefined
|
return undefined
|
||||||
@@ -1526,7 +1519,7 @@ export function parseModel(model: string) {
|
|||||||
export const node = LayerNode.make({
|
export const node = LayerNode.make({
|
||||||
service: Service,
|
service: Service,
|
||||||
layer: layer,
|
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"
|
export * as Provider from "./provider"
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import * as InstanceState from "@/effect/instance-state"
|
import * as InstanceState from "@/effect/instance-state"
|
||||||
import { registerDisposer } from "@/effect/instance-registry"
|
import { registerDisposer } from "@/effect/instance-registry"
|
||||||
import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref"
|
import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref"
|
||||||
import { Plugin } from "@/plugin"
|
|
||||||
import { Pty } from "@opencode-ai/core/pty"
|
import { Pty } from "@opencode-ai/core/pty"
|
||||||
import { PtyProtocol } from "@opencode-ai/core/pty/protocol"
|
import { PtyProtocol } from "@opencode-ai/core/pty/protocol"
|
||||||
import { PtyID } from "@opencode-ai/core/pty/schema"
|
import { PtyID } from "@opencode-ai/core/pty/schema"
|
||||||
@@ -42,7 +41,6 @@ export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handler
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const tickets = yield* PtyTicket.Service
|
const tickets = yield* PtyTicket.Service
|
||||||
const cors = yield* CorsConfig
|
const cors = yield* CorsConfig
|
||||||
const plugin = yield* Plugin.Service
|
|
||||||
const locations = yield* LocationServiceMap.Service
|
const locations = yield* LocationServiceMap.Service
|
||||||
const unregister = registerDisposer((directory) =>
|
const unregister = registerDisposer((directory) =>
|
||||||
Effect.runPromise(locations.invalidate(Location.Ref.make({ directory: AbsolutePath.make(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 create = Effect.fn("PtyHttpApi.create")(function* (ctx: { payload: typeof Pty.CreateInput.Type }) {
|
||||||
const cwd = ctx.payload.cwd || (yield* InstanceState.context).directory
|
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(
|
return yield* pty(
|
||||||
Pty.Service.use((service) =>
|
Pty.Service.use((service) =>
|
||||||
service.create({
|
service.create({
|
||||||
...ctx.payload,
|
...ctx.payload,
|
||||||
args: ctx.payload.args ? [...ctx.payload.args] : undefined,
|
args: ctx.payload.args ? [...ctx.payload.args] : undefined,
|
||||||
cwd,
|
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 { MCP } from "@/mcp"
|
||||||
import { McpAuth } from "@/mcp/auth"
|
import { McpAuth } from "@/mcp/auth"
|
||||||
import { Permission } from "@/permission"
|
import { Permission } from "@/permission"
|
||||||
import { Plugin } from "@/plugin"
|
|
||||||
import { PluginPtyEnvironment } from "@/plugin/pty-environment"
|
import { PluginPtyEnvironment } from "@/plugin/pty-environment"
|
||||||
import { InstanceStore } from "@/project/instance-store"
|
import { InstanceStore } from "@/project/instance-store"
|
||||||
import { Project } from "@/project/project"
|
import { Project } from "@/project/project"
|
||||||
@@ -42,7 +41,6 @@ import { Todo } from "@/session/todo"
|
|||||||
import { SessionShare } from "@/share/session"
|
import { SessionShare } from "@/share/session"
|
||||||
import { ShareNext } from "@/share/share-next"
|
import { ShareNext } from "@/share/share-next"
|
||||||
import { Skill } from "@/skill"
|
import { Skill } from "@/skill"
|
||||||
import { Discovery } from "@/skill/discovery"
|
|
||||||
import { Snapshot } from "@/snapshot"
|
import { Snapshot } from "@/snapshot"
|
||||||
import { Storage } from "@/storage/storage"
|
import { Storage } from "@/storage/storage"
|
||||||
import { ToolRegistry } from "@/tool/registry"
|
import { ToolRegistry } from "@/tool/registry"
|
||||||
@@ -219,13 +217,11 @@ const app = LayerNode.group([
|
|||||||
Ripgrep.node,
|
Ripgrep.node,
|
||||||
Storage.node,
|
Storage.node,
|
||||||
Snapshot.node,
|
Snapshot.node,
|
||||||
Plugin.node,
|
|
||||||
ModelsDev.node,
|
ModelsDev.node,
|
||||||
Provider.node,
|
Provider.node,
|
||||||
ProviderAuth.node,
|
ProviderAuth.node,
|
||||||
Agent.node,
|
Agent.node,
|
||||||
Skill.node,
|
Skill.node,
|
||||||
Discovery.node,
|
|
||||||
Question.node,
|
Question.node,
|
||||||
Permission.node,
|
Permission.node,
|
||||||
PermissionSaved.node,
|
PermissionSaved.node,
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import { MessageV2 } from "./message-v2"
|
|||||||
import { Token } from "@/util/token"
|
import { Token } from "@/util/token"
|
||||||
import { SessionProcessor } from "./processor"
|
import { SessionProcessor } from "./processor"
|
||||||
import { Agent } from "@/agent/agent"
|
import { Agent } from "@/agent/agent"
|
||||||
import { Plugin } from "@/plugin"
|
|
||||||
import { Config } from "@/config/config"
|
import { Config } from "@/config/config"
|
||||||
import { NotFoundError } from "@/storage/storage"
|
import { NotFoundError } from "@/storage/storage"
|
||||||
|
|
||||||
@@ -194,7 +193,6 @@ const layer = Layer.effect(
|
|||||||
const config = yield* Config.Service
|
const config = yield* Config.Service
|
||||||
const session = yield* Session.Service
|
const session = yield* Session.Service
|
||||||
const agents = yield* Agent.Service
|
const agents = yield* Agent.Service
|
||||||
const plugin = yield* Plugin.Service
|
|
||||||
const processors = yield* SessionProcessor.Service
|
const processors = yield* SessionProcessor.Service
|
||||||
const provider = yield* Provider.Service
|
const provider = yield* Provider.Service
|
||||||
const events = yield* EventV2Bridge.Service
|
const events = yield* EventV2Bridge.Service
|
||||||
@@ -381,26 +379,11 @@ const layer = Layer.effect(
|
|||||||
cfg,
|
cfg,
|
||||||
model,
|
model,
|
||||||
})
|
})
|
||||||
// Allow plugins to inject context or replace compaction prompt.
|
const conversation = selected.head.map(serialize).filter(Boolean).join("\n\n")
|
||||||
const compacting = yield* plugin.trigger(
|
const nextPrompt = buildPrompt({
|
||||||
"experimental.session.compacting",
|
|
||||||
{ sessionID: input.sessionID },
|
|
||||||
{ context: [], prompt: undefined },
|
|
||||||
)
|
|
||||||
const msgs = structuredClone(selected.head)
|
|
||||||
yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs })
|
|
||||||
const conversation = msgs.map(serialize).filter(Boolean).join("\n\n")
|
|
||||||
const nextPrompt =
|
|
||||||
compacting.prompt ??
|
|
||||||
[
|
|
||||||
buildPrompt({
|
|
||||||
previousSummary,
|
previousSummary,
|
||||||
context: [conversation],
|
context: [conversation],
|
||||||
}),
|
})
|
||||||
...compacting.context,
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join("\n\n")
|
|
||||||
const ctx = yield* InstanceState.context
|
const ctx = yield* InstanceState.context
|
||||||
const msg: SessionV1.Assistant = {
|
const msg: SessionV1.Assistant = {
|
||||||
id: MessageID.ascending(),
|
id: MessageID.ascending(),
|
||||||
@@ -446,12 +429,7 @@ const layer = Layer.effect(
|
|||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
type: "text",
|
type: "text",
|
||||||
text: [
|
text: nextPrompt,
|
||||||
nextPrompt,
|
|
||||||
...(compacting.prompt ? ["The following is the conversation history:", conversation] : []),
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join("\n\n"),
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -507,27 +485,6 @@ const layer = Layer.effect(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!replay) {
|
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({
|
const continueMsg = yield* session.updateMessage({
|
||||||
id: MessageID.ascending(),
|
id: MessageID.ascending(),
|
||||||
role: "user",
|
role: "user",
|
||||||
@@ -546,9 +503,8 @@ const layer = Layer.effect(
|
|||||||
messageID: continueMsg.id,
|
messageID: continueMsg.id,
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
type: "text",
|
type: "text",
|
||||||
// Internal marker for auto-compaction followups so provider plugins
|
// Internal marker for auto-compaction followups so callers can
|
||||||
// can distinguish them from manual post-compaction user prompts.
|
// distinguish them from manual post-compaction user prompts.
|
||||||
// This is not a stable plugin contract and may change or disappear.
|
|
||||||
metadata: { compaction_continue: true },
|
metadata: { compaction_continue: true },
|
||||||
synthetic: true,
|
synthetic: true,
|
||||||
text,
|
text,
|
||||||
@@ -559,7 +515,6 @@ const layer = Layer.effect(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (processor.message.error) return "stop"
|
if (processor.message.error) return "stop"
|
||||||
if (result === "continue") {
|
if (result === "continue") {
|
||||||
@@ -609,7 +564,6 @@ export const node = LayerNode.make({
|
|||||||
Config.node,
|
Config.node,
|
||||||
Session.node,
|
Session.node,
|
||||||
Agent.node,
|
Agent.node,
|
||||||
Plugin.node,
|
|
||||||
SessionProcessor.node,
|
SessionProcessor.node,
|
||||||
Provider.node,
|
Provider.node,
|
||||||
EventV2Bridge.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 { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||||
import { httpClient } from "@opencode-ai/core/effect/app-node-platform"
|
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { SessionV1 } from "@opencode-ai/core/v1/session"
|
import { SessionV1 } from "@opencode-ai/core/v1/session"
|
||||||
import { Effect, Layer, Context } from "effect"
|
import { Effect, Layer, Context } from "effect"
|
||||||
import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"
|
|
||||||
import { Config } from "@/config/config"
|
import { Config } from "@/config/config"
|
||||||
import { InstanceState } from "@/effect/instance-state"
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||||
import { withTransientReadRetry } from "@/util/effect-http-client"
|
|
||||||
import { Global } from "@opencode-ai/core/global"
|
import { Global } from "@opencode-ai/core/global"
|
||||||
import type { MessageV2 } from "./message-v2"
|
import type { MessageV2 } from "./message-v2"
|
||||||
import type { MessageID } from "./schema"
|
import type { MessageID } from "./schema"
|
||||||
@@ -48,7 +45,7 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/In
|
|||||||
const layer: Layer.Layer<
|
const layer: Layer.Layer<
|
||||||
Service,
|
Service,
|
||||||
never,
|
never,
|
||||||
FSUtil.Service | Config.Service | Global.Service | HttpClient.HttpClient | RuntimeFlags.Service
|
FSUtil.Service | Config.Service | Global.Service | RuntimeFlags.Service
|
||||||
> = Layer.effect(
|
> = Layer.effect(
|
||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
@@ -56,7 +53,6 @@ const layer: Layer.Layer<
|
|||||||
const fs = yield* FSUtil.Service
|
const fs = yield* FSUtil.Service
|
||||||
const global = yield* Global.Service
|
const global = yield* Global.Service
|
||||||
const flags = yield* RuntimeFlags.Service
|
const flags = yield* RuntimeFlags.Service
|
||||||
const http = HttpClient.filterStatusOk(withTransientReadRetry(yield* HttpClient.HttpClient))
|
|
||||||
const globalFiles = [
|
const globalFiles = [
|
||||||
path.join(global.config, "AGENTS.md"),
|
path.join(global.config, "AGENTS.md"),
|
||||||
...(!flags.disableClaudeCodePrompt ? [path.join(global.home, ".claude", "CLAUDE.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("")))
|
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 clear = Effect.fn("Instruction.clear")(function* (messageID: MessageID) {
|
||||||
const s = yield* InstanceState.get(state)
|
const s = yield* InstanceState.get(state)
|
||||||
s.claims.delete(messageID)
|
s.claims.delete(messageID)
|
||||||
@@ -155,17 +141,13 @@ const layer: Layer.Layer<
|
|||||||
const system = Effect.fn("Instruction.system")(function* () {
|
const system = Effect.fn("Instruction.system")(function* () {
|
||||||
const config = yield* cfg.get()
|
const config = yield* cfg.get()
|
||||||
const paths = yield* systemPaths()
|
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 files = yield* Effect.forEach(Array.from(paths), read, { concurrency: 8 })
|
||||||
const remote = yield* Effect.forEach(urls, fetch, { concurrency: 4 })
|
|
||||||
|
|
||||||
return [
|
return Array.from(paths).flatMap((item, i) => (files[i] ? [`Instructions from: ${item}\n${files[i]}`] : []))
|
||||||
...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]}`] : [])),
|
|
||||||
]
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const find = Effect.fn("Instruction.find")(function* (dir: string) {
|
const find = Effect.fn("Instruction.find")(function* (dir: string) {
|
||||||
@@ -231,7 +213,7 @@ export function loaded(messages: SessionV1.WithParts[]) {
|
|||||||
export const node = LayerNode.make({
|
export const node = LayerNode.make({
|
||||||
service: Service,
|
service: Service,
|
||||||
layer: layer,
|
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"
|
export * as Instruction from "./instruction"
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import { ProviderTransform } from "@/provider/transform"
|
|||||||
import { Config } from "@/config/config"
|
import { Config } from "@/config/config"
|
||||||
import type { Agent } from "@/agent/agent"
|
import type { Agent } from "@/agent/agent"
|
||||||
import type { MessageV2 } from "./message-v2"
|
import type { MessageV2 } from "./message-v2"
|
||||||
import { Plugin } from "@/plugin"
|
|
||||||
import { Permission } from "@/permission"
|
import { Permission } from "@/permission"
|
||||||
import { EventV2Bridge } from "@/event-v2-bridge"
|
import { EventV2Bridge } from "@/event-v2-bridge"
|
||||||
import { EventV2 } from "@opencode-ai/core/event"
|
import { EventV2 } from "@opencode-ai/core/event"
|
||||||
@@ -65,7 +64,6 @@ const live: Layer.Layer<
|
|||||||
| Auth.Service
|
| Auth.Service
|
||||||
| Config.Service
|
| Config.Service
|
||||||
| Provider.Service
|
| Provider.Service
|
||||||
| Plugin.Service
|
|
||||||
| Permission.Service
|
| Permission.Service
|
||||||
| EventV2Bridge.Service
|
| EventV2Bridge.Service
|
||||||
| LLMClientService
|
| LLMClientService
|
||||||
@@ -76,7 +74,6 @@ const live: Layer.Layer<
|
|||||||
const auth = yield* Auth.Service
|
const auth = yield* Auth.Service
|
||||||
const config = yield* Config.Service
|
const config = yield* Config.Service
|
||||||
const provider = yield* Provider.Service
|
const provider = yield* Provider.Service
|
||||||
const plugin = yield* Plugin.Service
|
|
||||||
const perm = yield* Permission.Service
|
const perm = yield* Permission.Service
|
||||||
const events = yield* EventV2Bridge.Service
|
const events = yield* EventV2Bridge.Service
|
||||||
const llmClient = yield* LLMClient.Service
|
const llmClient = yield* LLMClient.Service
|
||||||
@@ -107,7 +104,6 @@ const live: Layer.Layer<
|
|||||||
...input,
|
...input,
|
||||||
provider: item,
|
provider: item,
|
||||||
auth: info,
|
auth: info,
|
||||||
plugin,
|
|
||||||
flags,
|
flags,
|
||||||
isWorkflow,
|
isWorkflow,
|
||||||
})
|
})
|
||||||
@@ -392,7 +388,6 @@ export const node = LayerNode.make({
|
|||||||
Auth.node,
|
Auth.node,
|
||||||
Config.node,
|
Config.node,
|
||||||
Provider.node,
|
Provider.node,
|
||||||
Plugin.node,
|
|
||||||
Permission.node,
|
Permission.node,
|
||||||
EventV2Bridge.node,
|
EventV2Bridge.node,
|
||||||
llmClient,
|
llmClient,
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ import { SystemPrompt } from "../system"
|
|||||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||||
import { Effect, Record } from "effect"
|
import { Effect, Record } from "effect"
|
||||||
import { jsonSchema, tool as aiTool, type ModelMessage, type Tool } from "ai"
|
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"
|
import { mergeDeep } from "remeda"
|
||||||
|
|
||||||
const USER_AGENT = `opencode/${InstallationVersion}`
|
const USER_AGENT = `opencode/${InstallationVersion}`
|
||||||
@@ -30,7 +32,6 @@ type PrepareInput = {
|
|||||||
readonly tools: Record<string, Tool>
|
readonly tools: Record<string, Tool>
|
||||||
readonly provider: Provider.Info
|
readonly provider: Provider.Info
|
||||||
readonly auth: Auth.Info | undefined
|
readonly auth: Auth.Info | undefined
|
||||||
readonly plugin: Plugin.Interface
|
|
||||||
readonly flags: RuntimeFlags.Info
|
readonly flags: RuntimeFlags.Info
|
||||||
readonly isWorkflow: boolean
|
readonly isWorkflow: boolean
|
||||||
}
|
}
|
||||||
@@ -65,23 +66,6 @@ export const prepare = Effect.fn("LLMRequestPrep.prepare")(function* (input: Pre
|
|||||||
.join("\n"),
|
.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 =
|
const variant =
|
||||||
!input.small && input.model.variants && input.user.model.variant
|
!input.small && input.model.variants && input.user.model.variant
|
||||||
? 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,
|
...input.messages,
|
||||||
]
|
]
|
||||||
|
|
||||||
const params = yield* input.plugin.trigger(
|
// First-party provider parity (formerly plugin hooks, now unconditional):
|
||||||
"chat.params",
|
// - codex cli parity: OpenAI never wants an explicit output token cap
|
||||||
{
|
// - cerebras: max_completion_tokens already carries the cap
|
||||||
sessionID: input.sessionID,
|
const providerCapsOutputTokens =
|
||||||
agent: input.agent.name,
|
input.model.providerID === "openai" ||
|
||||||
model: input.model,
|
(input.model.api.npm === "@ai-sdk/cerebras" && options.max_completion_tokens !== undefined)
|
||||||
provider: input.provider,
|
|
||||||
message: input.user,
|
const params = {
|
||||||
},
|
|
||||||
{
|
|
||||||
temperature: input.model.capabilities.temperature
|
temperature: input.model.capabilities.temperature
|
||||||
? (input.agent.temperature ?? ProviderTransform.temperature(input.model))
|
? (input.agent.temperature ?? ProviderTransform.temperature(input.model))
|
||||||
: undefined,
|
: undefined,
|
||||||
topP: input.agent.topP ?? ProviderTransform.topP(input.model),
|
topP: input.agent.topP ?? ProviderTransform.topP(input.model),
|
||||||
topK: ProviderTransform.topK(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,
|
options,
|
||||||
},
|
}
|
||||||
)
|
|
||||||
|
|
||||||
const { headers } = yield* input.plugin.trigger(
|
const headers =
|
||||||
"chat.headers",
|
input.model.providerID !== "openai"
|
||||||
{
|
? {}
|
||||||
sessionID: input.sessionID,
|
: {
|
||||||
agent: input.agent.name,
|
originator: "opencode",
|
||||||
model: input.model,
|
"User-Agent": `opencode/${InstallationVersion} (${os.platform()} ${os.release()}; ${os.arch()})`,
|
||||||
provider: input.provider,
|
"session-id": input.sessionID,
|
||||||
message: input.user,
|
...(webSocketFetchInstalled() && input.agent.name === "title"
|
||||||
},
|
? { [openaiTitleFallbackHeader]: "true" }
|
||||||
{
|
: {}),
|
||||||
headers: {},
|
}
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
const tools = resolveTools(input)
|
const tools = resolveTools(input)
|
||||||
// Codex parity: OpenAI Responses-family providers hardcode `strict: false`
|
// 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 { Agent } from "@/agent/agent"
|
||||||
import { Config } from "@/config/config"
|
import { Config } from "@/config/config"
|
||||||
import { Permission } from "@/permission"
|
import { Permission } from "@/permission"
|
||||||
import { Plugin } from "@/plugin"
|
|
||||||
import { Snapshot } from "@/snapshot"
|
import { Snapshot } from "@/snapshot"
|
||||||
import { Session } from "./session"
|
import { Session } from "./session"
|
||||||
import { LLM } from "./llm"
|
import { LLM } from "./llm"
|
||||||
@@ -87,7 +86,6 @@ const layer = Layer.effect(
|
|||||||
const agents = yield* Agent.Service
|
const agents = yield* Agent.Service
|
||||||
const llm = yield* LLM.Service
|
const llm = yield* LLM.Service
|
||||||
const permission = yield* Permission.Service
|
const permission = yield* Permission.Service
|
||||||
const plugin = yield* Plugin.Service
|
|
||||||
const summary = yield* SessionSummary.Service
|
const summary = yield* SessionSummary.Service
|
||||||
const scope = yield* Scope.Scope
|
const scope = yield* Scope.Scope
|
||||||
const status = yield* SessionStatus.Service
|
const status = yield* SessionStatus.Service
|
||||||
@@ -513,15 +511,6 @@ const layer = Layer.effect(
|
|||||||
if (!ctx.currentText) return
|
if (!ctx.currentText) return
|
||||||
// oxlint-disable-next-line no-self-assign -- reactivity trigger
|
// oxlint-disable-next-line no-self-assign -- reactivity trigger
|
||||||
ctx.currentText.text = ctx.currentText.text
|
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()
|
const end = Date.now()
|
||||||
ctx.currentText.time = { start: ctx.currentText.time?.start ?? end, end }
|
ctx.currentText.time = { start: ctx.currentText.time?.start ?? end, end }
|
||||||
@@ -706,7 +695,6 @@ export const node = LayerNode.make({
|
|||||||
Agent.node,
|
Agent.node,
|
||||||
LLM.node,
|
LLM.node,
|
||||||
Permission.node,
|
Permission.node,
|
||||||
Plugin.node,
|
|
||||||
SessionSummary.node,
|
SessionSummary.node,
|
||||||
SessionStatus.node,
|
SessionStatus.node,
|
||||||
Image.node,
|
Image.node,
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import type { JSONSchema7 } from "@ai-sdk/provider"
|
|||||||
import { SessionCompaction } from "./compaction"
|
import { SessionCompaction } from "./compaction"
|
||||||
import { SystemPrompt } from "./system"
|
import { SystemPrompt } from "./system"
|
||||||
import { Instruction } from "./instruction"
|
import { Instruction } from "./instruction"
|
||||||
import { Plugin } from "../plugin"
|
|
||||||
import { MAX_STEPS_PROMPT } from "@opencode-ai/core/session/runner/max-steps"
|
import { MAX_STEPS_PROMPT } from "@opencode-ai/core/session/runner/max-steps"
|
||||||
import { ToolRegistry } from "@/tool/registry"
|
import { ToolRegistry } from "@/tool/registry"
|
||||||
import { MCP } from "../mcp"
|
import { MCP } from "../mcp"
|
||||||
@@ -119,7 +118,6 @@ const layer = Layer.effect(
|
|||||||
const provider = yield* Provider.Service
|
const provider = yield* Provider.Service
|
||||||
const processor = yield* SessionProcessor.Service
|
const processor = yield* SessionProcessor.Service
|
||||||
const compaction = yield* SessionCompaction.Service
|
const compaction = yield* SessionCompaction.Service
|
||||||
const plugin = yield* Plugin.Service
|
|
||||||
const commands = yield* Command.Service
|
const commands = yield* Command.Service
|
||||||
const config = yield* Config.Service
|
const config = yield* Config.Service
|
||||||
const permission = yield* Permission.Service
|
const permission = yield* Permission.Service
|
||||||
@@ -304,11 +302,6 @@ const layer = Layer.effect(
|
|||||||
subagent_type: task.agent,
|
subagent_type: task.agent,
|
||||||
command: task.command,
|
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)
|
const taskAgent = yield* agents.get(task.agent)
|
||||||
if (!taskAgent) {
|
if (!taskAgent) {
|
||||||
@@ -386,11 +379,6 @@ const layer = Layer.effect(
|
|||||||
messageID: assistantMessage.id,
|
messageID: assistantMessage.id,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
yield* plugin.trigger(
|
|
||||||
"tool.execute.after",
|
|
||||||
{ tool: TaskTool.id, sessionID, callID: part.id, args: taskArgs },
|
|
||||||
result,
|
|
||||||
)
|
|
||||||
|
|
||||||
assistantMessage.finish = "tool-calls"
|
assistantMessage.finish = "tool-calls"
|
||||||
assistantMessage.time.completed = Date.now()
|
assistantMessage.time.completed = Date.now()
|
||||||
@@ -551,15 +539,10 @@ const layer = Layer.effect(
|
|||||||
|
|
||||||
const exit = yield* restore(
|
const exit = yield* restore(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const shellEnv = yield* plugin.trigger(
|
|
||||||
"shell.env",
|
|
||||||
{ cwd, sessionID: input.sessionID, callID: part.callID },
|
|
||||||
{ env: {} },
|
|
||||||
)
|
|
||||||
const cmd = ChildProcess.make(sh, args, {
|
const cmd = ChildProcess.make(sh, args, {
|
||||||
cwd,
|
cwd,
|
||||||
extendEnv: true,
|
extendEnv: true,
|
||||||
env: { ...shellEnv.env, TERM: "dumb" },
|
env: { TERM: "dumb" },
|
||||||
stdin: "ignore",
|
stdin: "ignore",
|
||||||
forceKillAfter: "3 seconds",
|
forceKillAfter: "3 seconds",
|
||||||
})
|
})
|
||||||
@@ -996,17 +979,6 @@ const layer = Layer.effect(
|
|||||||
Effect.map((x) => x.flat().map(assign)),
|
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) =>
|
const parts = yield* Effect.forEach(resolvedParts, (part) =>
|
||||||
part.type === "file" && part.mime.startsWith("image/")
|
part.type === "file" && part.mime.startsWith("image/")
|
||||||
@@ -1256,7 +1228,6 @@ const layer = Layer.effect(
|
|||||||
messages: msgs,
|
messages: msgs,
|
||||||
promptOps,
|
promptOps,
|
||||||
}).pipe(
|
}).pipe(
|
||||||
Effect.provideService(Plugin.Service, plugin),
|
|
||||||
Effect.provideService(Permission.Service, permission),
|
Effect.provideService(Permission.Service, permission),
|
||||||
Effect.provideService(ToolRegistry.Service, registry),
|
Effect.provideService(ToolRegistry.Service, registry),
|
||||||
Effect.provideService(MCP.Service, mcp),
|
Effect.provideService(MCP.Service, mcp),
|
||||||
@@ -1276,8 +1247,6 @@ const layer = Layer.effect(
|
|||||||
if (step === 1)
|
if (step === 1)
|
||||||
yield* summary.summarize({ sessionID, messageID: lastUser.id }).pipe(Effect.ignore, Effect.forkIn(scope))
|
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([
|
const [skills, env, instructions, mcpInstructions, modelMsgs] = yield* Effect.all([
|
||||||
sys.skills(agent),
|
sys.skills(agent),
|
||||||
sys.environment(model),
|
sys.environment(model),
|
||||||
@@ -1481,11 +1450,6 @@ const layer = Layer.effect(
|
|||||||
: yield* currentModel(input.sessionID)
|
: yield* currentModel(input.sessionID)
|
||||||
: taskModel
|
: taskModel
|
||||||
|
|
||||||
yield* plugin.trigger(
|
|
||||||
"command.execute.before",
|
|
||||||
{ command: input.command, sessionID: input.sessionID, arguments: input.arguments },
|
|
||||||
{ parts },
|
|
||||||
)
|
|
||||||
|
|
||||||
const result = yield* prompt({
|
const result = yield* prompt({
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
@@ -1629,7 +1593,6 @@ export const node = LayerNode.make({
|
|||||||
Provider.node,
|
Provider.node,
|
||||||
SessionProcessor.node,
|
SessionProcessor.node,
|
||||||
SessionCompaction.node,
|
SessionCompaction.node,
|
||||||
Plugin.node,
|
|
||||||
Command.node,
|
Command.node,
|
||||||
Config.node,
|
Config.node,
|
||||||
Permission.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 { ToolRegistry } from "@/tool/registry"
|
||||||
import { Truncate } from "@/tool/truncate"
|
import { Truncate } from "@/tool/truncate"
|
||||||
|
|
||||||
import { Plugin } from "@/plugin"
|
|
||||||
import type { TaskPromptOps } from "@/tool/task"
|
import type { TaskPromptOps } from "@/tool/task"
|
||||||
import { type Tool as AITool, tool, jsonSchema, type ToolExecutionOptions, asSchema } from "ai"
|
import { type Tool as AITool, tool, jsonSchema, type ToolExecutionOptions, asSchema } from "ai"
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
@@ -49,7 +48,6 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
|
|||||||
}) {
|
}) {
|
||||||
const tools: Record<string, AITool> = {}
|
const tools: Record<string, AITool> = {}
|
||||||
const run = yield* EffectBridge.make()
|
const run = yield* EffectBridge.make()
|
||||||
const plugin = yield* Plugin.Service
|
|
||||||
const permission = yield* Permission.Service
|
const permission = yield* Permission.Service
|
||||||
const registry = yield* ToolRegistry.Service
|
const registry = yield* ToolRegistry.Service
|
||||||
const mcp = yield* MCP.Service
|
const mcp = yield* MCP.Service
|
||||||
@@ -103,11 +101,6 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
|
|||||||
return run.promise(
|
return run.promise(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const ctx = context(args, options)
|
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 result = yield* item.execute(args, ctx)
|
||||||
const output = {
|
const output = {
|
||||||
...result,
|
...result,
|
||||||
@@ -118,11 +111,6 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
|
|||||||
messageID: input.processor.message.id,
|
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) {
|
if (options.abortSignal?.aborted) {
|
||||||
yield* input.processor.completeToolCall(options.toolCallId, output)
|
yield* input.processor.completeToolCall(options.toolCallId, output)
|
||||||
}
|
}
|
||||||
@@ -172,11 +160,6 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
|
|||||||
const permissionPatterns = parsed.server
|
const permissionPatterns = parsed.server
|
||||||
? [`mcp:${parsed.server}:*`]
|
? [`mcp:${parsed.server}:*`]
|
||||||
: resourceServers.map((server) => `mcp:${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({
|
yield* ctx.ask({
|
||||||
permission: "read",
|
permission: "read",
|
||||||
metadata: parsed.server ? { server: parsed.server } : {},
|
metadata: parsed.server ? { server: parsed.server } : {},
|
||||||
@@ -205,11 +188,6 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
|
|||||||
},
|
},
|
||||||
output: truncated.content,
|
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) {
|
if (opts.abortSignal?.aborted) {
|
||||||
yield* input.processor.completeToolCall(opts.toolCallId, output)
|
yield* input.processor.completeToolCall(opts.toolCallId, output)
|
||||||
}
|
}
|
||||||
@@ -255,11 +233,6 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
|
|||||||
const permissionPatterns = parsed.server
|
const permissionPatterns = parsed.server
|
||||||
? [`mcp:${parsed.server}:*`]
|
? [`mcp:${parsed.server}:*`]
|
||||||
: resourceServers.map((server) => `mcp:${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({
|
yield* ctx.ask({
|
||||||
permission: "read",
|
permission: "read",
|
||||||
metadata: parsed.server ? { server: parsed.server } : {},
|
metadata: parsed.server ? { server: parsed.server } : {},
|
||||||
@@ -288,11 +261,6 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
|
|||||||
},
|
},
|
||||||
output: truncated.content,
|
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) {
|
if (opts.abortSignal?.aborted) {
|
||||||
yield* input.processor.completeToolCall(opts.toolCallId, output)
|
yield* input.processor.completeToolCall(opts.toolCallId, output)
|
||||||
}
|
}
|
||||||
@@ -335,11 +303,6 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
|
|||||||
if (!client.getServerCapabilities()?.resources) {
|
if (!client.getServerCapabilities()?.resources) {
|
||||||
throw new Error(`MCP server "${parsed.server}" does not support 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({
|
yield* ctx.ask({
|
||||||
permission: "read",
|
permission: "read",
|
||||||
metadata: { server: parsed.server, uri: parsed.uri },
|
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,
|
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) {
|
if (opts.abortSignal?.aborted) {
|
||||||
yield* input.processor.completeToolCall(opts.toolCallId, output)
|
yield* input.processor.completeToolCall(opts.toolCallId, output)
|
||||||
}
|
}
|
||||||
@@ -399,11 +357,6 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
|
|||||||
run.promise(
|
run.promise(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const ctx = context(args, opts)
|
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* () {
|
const result: Awaited<ReturnType<NonNullable<typeof execute>>> = yield* Effect.gen(function* () {
|
||||||
yield* ctx.ask({ permission: key, metadata: {}, patterns: ["*"], always: ["*"] })
|
yield* ctx.ask({ permission: key, metadata: {}, patterns: ["*"], always: ["*"] })
|
||||||
return yield* Effect.promise(() => execute(args, opts))
|
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 textParts: string[] = []
|
||||||
const attachments: Omit<SessionV1.FilePart, "id" | "sessionID" | "messageID">[] = []
|
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 { ConfigMarkdown } from "@/config/markdown"
|
||||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||||
import { Glob } from "@opencode-ai/core/util/glob"
|
import { Glob } from "@opencode-ai/core/util/glob"
|
||||||
import { Discovery } from "./discovery"
|
|
||||||
import { isRecord } from "@/util/record"
|
import { isRecord } from "@/util/record"
|
||||||
import { escapeHtml } from "@/util/html"
|
import { escapeHtml } from "@/util/html"
|
||||||
|
|
||||||
@@ -84,11 +83,6 @@ type State = {
|
|||||||
dirs: Set<string>
|
dirs: Set<string>
|
||||||
}
|
}
|
||||||
|
|
||||||
type DiscoveryState = {
|
|
||||||
matches: string[]
|
|
||||||
dirs: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
type ScanState = {
|
type ScanState = {
|
||||||
matches: Set<string>
|
matches: Set<string>
|
||||||
dirs: Set<string>
|
dirs: Set<string>
|
||||||
@@ -172,7 +166,6 @@ const scan = Effect.fnUntraced(function* (
|
|||||||
|
|
||||||
const discoverSkills = Effect.fnUntraced(function* (
|
const discoverSkills = Effect.fnUntraced(function* (
|
||||||
config: Config.Interface,
|
config: Config.Interface,
|
||||||
discovery: Discovery.Interface,
|
|
||||||
fsys: FSUtil.Interface,
|
fsys: FSUtil.Interface,
|
||||||
global: Global.Interface,
|
global: Global.Interface,
|
||||||
disableExternalSkills: boolean,
|
disableExternalSkills: boolean,
|
||||||
@@ -219,13 +212,6 @@ const discoverSkills = Effect.fnUntraced(function* (
|
|||||||
yield* scan(state, dir, SKILL_PATTERN)
|
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 {
|
return {
|
||||||
matches: Array.from(state.matches),
|
matches: Array.from(state.matches),
|
||||||
dirs: Array.from(state.dirs),
|
dirs: Array.from(state.dirs),
|
||||||
@@ -234,7 +220,7 @@ const discoverSkills = Effect.fnUntraced(function* (
|
|||||||
|
|
||||||
const loadSkills = Effect.fnUntraced(function* (
|
const loadSkills = Effect.fnUntraced(function* (
|
||||||
state: State,
|
state: State,
|
||||||
discovered: DiscoveryState,
|
discovered: { matches: string[]; dirs: string[] },
|
||||||
events: EventV2Bridge.Service["Service"],
|
events: EventV2Bridge.Service["Service"],
|
||||||
) {
|
) {
|
||||||
yield* Effect.forEach(discovered.matches, (match) => add(state, match, events), {
|
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(
|
const layer = Layer.effect(
|
||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const discovery = yield* Discovery.Service
|
|
||||||
const config = yield* Config.Service
|
const config = yield* Config.Service
|
||||||
const events = yield* EventV2Bridge.Service
|
const events = yield* EventV2Bridge.Service
|
||||||
const fsys = yield* FSUtil.Service
|
const fsys = yield* FSUtil.Service
|
||||||
@@ -260,7 +245,6 @@ const layer = Layer.effect(
|
|||||||
Effect.fn("Skill.discovery")(function* (ctx) {
|
Effect.fn("Skill.discovery")(function* (ctx) {
|
||||||
return yield* discoverSkills(
|
return yield* discoverSkills(
|
||||||
config,
|
config,
|
||||||
discovery,
|
|
||||||
fsys,
|
fsys,
|
||||||
global,
|
global,
|
||||||
flags.disableExternalSkills,
|
flags.disableExternalSkills,
|
||||||
@@ -348,7 +332,7 @@ export function fmt(list: Info[], opts: { verbose: boolean }) {
|
|||||||
export const node = LayerNode.make({
|
export const node = LayerNode.make({
|
||||||
service: Service,
|
service: Service,
|
||||||
layer: layer,
|
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 "."
|
export * as Skill from "."
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import { McpCatalog } from "@/mcp/catalog"
|
|||||||
import { Agent } from "@/agent/agent"
|
import { Agent } from "@/agent/agent"
|
||||||
import { Session } from "@/session/session"
|
import { Session } from "@/session/session"
|
||||||
import { Permission } from "@/permission"
|
import { Permission } from "@/permission"
|
||||||
import { Plugin } from "@/plugin"
|
|
||||||
|
|
||||||
export const CODE_MODE_TOOL = "execute"
|
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: {
|
const invokeChildTool = Effect.fn("CodeMode.invokeChildTool")(function* (input: {
|
||||||
plugin: Plugin.Interface
|
|
||||||
entry: CatalogEntry
|
entry: CatalogEntry
|
||||||
args: Record<string, unknown>
|
args: Record<string, unknown>
|
||||||
callID: string
|
callID: string
|
||||||
ctx: Tool.Context
|
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* () {
|
const result: CallToolResult = yield* Effect.gen(function* () {
|
||||||
yield* input.ctx.ask({ permission: input.entry.key, metadata: {}, patterns: ["*"], always: ["*"] })
|
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.
|
// 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
|
return result
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -191,7 +179,6 @@ export const CodeModeTool = Tool.define(
|
|||||||
const mcp = yield* MCP.Service
|
const mcp = yield* MCP.Service
|
||||||
const agents = yield* Agent.Service
|
const agents = yield* Agent.Service
|
||||||
const sessions = yield* Session.Service
|
const sessions = yield* Session.Service
|
||||||
const plugin = yield* Plugin.Service
|
|
||||||
|
|
||||||
const init: Tool.DefWithoutID<typeof Parameters, Metadata> = {
|
const init: Tool.DefWithoutID<typeof Parameters, Metadata> = {
|
||||||
description: DESCRIPTION,
|
description: DESCRIPTION,
|
||||||
@@ -221,7 +208,6 @@ export const CodeModeTool = Tool.define(
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
childCalls += 1
|
childCalls += 1
|
||||||
const result = yield* invokeChildTool({
|
const result = yield* invokeChildTool({
|
||||||
plugin,
|
|
||||||
entry,
|
entry,
|
||||||
args: (input ?? {}) as Record<string, unknown>,
|
args: (input ?? {}) as Record<string, unknown>,
|
||||||
callID: `${ctx.callID ?? entry.key}/${childCalls}`,
|
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 type { JSONSchema7, JSONSchema7Definition } from "@ai-sdk/provider"
|
||||||
import { Schema } from "effect"
|
import { Schema } from "effect"
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
import { Plugin } from "../plugin"
|
|
||||||
import { Provider } from "@/provider/provider"
|
import { Provider } from "@/provider/provider"
|
||||||
|
|
||||||
import { WebSearchTool } from "./websearch"
|
import { WebSearchTool } from "./websearch"
|
||||||
@@ -98,7 +97,6 @@ const layer = Layer.effect(
|
|||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const config = yield* Config.Service
|
const config = yield* Config.Service
|
||||||
const plugin = yield* Plugin.Service
|
|
||||||
const agents = yield* Agent.Service
|
const agents = yield* Agent.Service
|
||||||
const truncate = yield* Truncate.Service
|
const truncate = yield* Truncate.Service
|
||||||
const flags = yield* RuntimeFlags.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()
|
yield* config.get()
|
||||||
const questionEnabled = ["app", "cli", "desktop"].includes(flags.client) || flags.enableQuestionTool
|
const questionEnabled = ["app", "cli", "desktop"].includes(flags.client) || flags.enableQuestionTool
|
||||||
|
|
||||||
@@ -333,27 +324,17 @@ const layer = Layer.effect(
|
|||||||
return yield* Effect.forEach(
|
return yield* Effect.forEach(
|
||||||
visible,
|
visible,
|
||||||
Effect.fnUntraced(function* (tool: Tool.Def) {
|
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 {
|
return {
|
||||||
id: tool.id,
|
id: tool.id,
|
||||||
description: [
|
description: [
|
||||||
output.description,
|
tool.description,
|
||||||
tool.id === TaskTool.id ? yield* describeTask(input.agent) : undefined,
|
tool.id === TaskTool.id ? yield* describeTask(input.agent) : undefined,
|
||||||
tool.id === "execute" ? codeModeDescription : undefined,
|
tool.id === "execute" ? codeModeDescription : undefined,
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join("\n"),
|
.join("\n"),
|
||||||
parameters: output.parameters,
|
parameters: tool.parameters,
|
||||||
jsonSchema,
|
jsonSchema: tool.jsonSchema,
|
||||||
execute: tool.execute,
|
execute: tool.execute,
|
||||||
formatValidationError: tool.formatValidationError,
|
formatValidationError: tool.formatValidationError,
|
||||||
}
|
}
|
||||||
@@ -452,7 +433,6 @@ export const node = LayerNode.make({
|
|||||||
layer,
|
layer,
|
||||||
deps: [
|
deps: [
|
||||||
Config.node,
|
Config.node,
|
||||||
Plugin.node,
|
|
||||||
Question.node,
|
Question.node,
|
||||||
Todo.node,
|
Todo.node,
|
||||||
Agent.node,
|
Agent.node,
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import { Shell } from "@opencode-ai/core/shell"
|
|||||||
import { ShellID } from "./shell/id"
|
import { ShellID } from "./shell/id"
|
||||||
|
|
||||||
import * as Truncate from "./truncate"
|
import * as Truncate from "./truncate"
|
||||||
import { Plugin } from "@/plugin"
|
|
||||||
import { ChildProcess } from "effect/unstable/process"
|
import { ChildProcess } from "effect/unstable/process"
|
||||||
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
|
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
|
||||||
import { ShellPrompt, type Parameters } from "./shell/prompt"
|
import { ShellPrompt, type Parameters } from "./shell/prompt"
|
||||||
@@ -342,7 +341,6 @@ export const ShellTool = Tool.define(
|
|||||||
const spawner = yield* ChildProcessSpawner
|
const spawner = yield* ChildProcessSpawner
|
||||||
const fs = yield* FSUtil.Service
|
const fs = yield* FSUtil.Service
|
||||||
const trunc = yield* Truncate.Service
|
const trunc = yield* Truncate.Service
|
||||||
const plugin = yield* Plugin.Service
|
|
||||||
const flags = yield* RuntimeFlags.Service
|
const flags = yield* RuntimeFlags.Service
|
||||||
const defaultTimeoutMs = flags.bashDefaultTimeoutMs ?? 2 * 60 * 1000
|
const defaultTimeoutMs = flags.bashDefaultTimeoutMs ?? 2 * 60 * 1000
|
||||||
|
|
||||||
@@ -413,16 +411,8 @@ export const ShellTool = Tool.define(
|
|||||||
return scan
|
return scan
|
||||||
})
|
})
|
||||||
|
|
||||||
const shellEnv = Effect.fn("ShellTool.shellEnv")(function* (ctx: Tool.Context, cwd: string) {
|
const shellEnv = Effect.fn("ShellTool.shellEnv")(function* (_ctx: Tool.Context, _cwd: string) {
|
||||||
const extra = yield* plugin.trigger(
|
return { ...process.env }
|
||||||
"shell.env",
|
|
||||||
{ cwd, sessionID: ctx.sessionID, callID: ctx.callID },
|
|
||||||
{ env: {} },
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
...process.env,
|
|
||||||
...extra.env,
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const run = Effect.fn("ShellTool.run")(function* (
|
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 { Global } from "@opencode-ai/core/global"
|
||||||
import { Permission } from "../../src/permission"
|
import { Permission } from "../../src/permission"
|
||||||
import { PermissionV1 } from "@opencode-ai/core/v1/permission"
|
import { PermissionV1 } from "@opencode-ai/core/v1/permission"
|
||||||
import { Plugin } from "../../src/plugin"
|
|
||||||
import { Provider } from "../../src/provider/provider"
|
import { Provider } from "../../src/provider/provider"
|
||||||
import { Skill } from "../../src/skill"
|
import { Skill } from "../../src/skill"
|
||||||
import { Truncate } from "../../src/tool/truncate"
|
import { Truncate } from "../../src/tool/truncate"
|
||||||
|
|
||||||
const agentLayer = (flags: Partial<RuntimeFlags.Info> = {}) =>
|
const agentLayer = (flags: Partial<RuntimeFlags.Info> = {}) =>
|
||||||
LayerNode.compile(
|
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)]],
|
[[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 { testEffect } from "../lib/effect"
|
||||||
import { testProviderConfig } from "../lib/test-provider"
|
import { testProviderConfig } from "../lib/test-provider"
|
||||||
import { Env } from "@/env"
|
import { Env } from "@/env"
|
||||||
import { Plugin } from "@/plugin"
|
|
||||||
import { Provider } from "@/provider/provider"
|
import { Provider } from "@/provider/provider"
|
||||||
import { ProviderError } from "@/provider/error"
|
import { ProviderError } from "@/provider/error"
|
||||||
|
|
||||||
@@ -19,7 +18,7 @@ afterEach(async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const it = testEffect(
|
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", () =>
|
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 { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||||
import { Global } from "@opencode-ai/core/global"
|
import { Global } from "@opencode-ai/core/global"
|
||||||
import { disposeAllInstances, provideInstanceEffect, tmpdirScoped, TestInstance } from "../fixture/fixture"
|
import { disposeAllInstances, provideInstanceEffect, tmpdirScoped, TestInstance } from "../fixture/fixture"
|
||||||
import { markPluginDependenciesReady } from "../fixture/plugin"
|
|
||||||
import { Auth } from "@/auth"
|
import { Auth } from "@/auth"
|
||||||
import { Config } from "@/config/config"
|
import { Config } from "@/config/config"
|
||||||
import { Env } from "../../src/env"
|
import { Env } from "../../src/env"
|
||||||
import { Plugin } from "../../src/plugin/index"
|
|
||||||
import { Provider } from "@/provider/provider"
|
import { Provider } from "@/provider/provider"
|
||||||
|
|
||||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||||
@@ -67,7 +65,6 @@ const providerLayer = (flags: Partial<RuntimeFlags.Info> = {}) =>
|
|||||||
Env.node,
|
Env.node,
|
||||||
Config.node,
|
Config.node,
|
||||||
Auth.node,
|
Auth.node,
|
||||||
Plugin.node,
|
|
||||||
ModelsDev.node,
|
ModelsDev.node,
|
||||||
RuntimeFlags.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 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 experimentalModels = testEffect(providerLayer({ enableExperimentalModels: true }))
|
||||||
|
|
||||||
const alphaProviderConfig = {
|
const alphaProviderConfig = {
|
||||||
@@ -1842,96 +1839,6 @@ const instanceStoreLayer = LayerNode.compile(InstanceStore.node, [
|
|||||||
const provideMultiInstance = <A, E, R>(eff: Effect.Effect<A, E, R>) =>
|
const provideMultiInstance = <A, E, R>(eff: Effect.Effect<A, E, R>) =>
|
||||||
eff.pipe(Effect.provide(instanceStoreLayer), Effect.provide(AppNodeBuilder.build(CrossSpawnSpawner.node)))
|
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", () =>
|
it.effect("opencode loader keeps paid models when config apiKey is present", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const noneDir = yield* tmpdirScoped()
|
const noneDir = yield* tmpdirScoped()
|
||||||
|
|||||||
@@ -571,11 +571,6 @@ describe("ProviderTransform.options - gpt-5 textVerbosity", () => {
|
|||||||
},
|
},
|
||||||
provider: { id: "azure", options: { useCompletionUrls: true } } as any,
|
provider: { id: "azure", options: { useCompletionUrls: true } } as any,
|
||||||
auth: undefined,
|
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,
|
flags: { outputTokenMax: 32_000, client: "test" } as any,
|
||||||
isWorkflow: false,
|
isWorkflow: false,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import { Config } from "@/config/config"
|
|||||||
import { LLM } from "../../src/session/llm"
|
import { LLM } from "../../src/session/llm"
|
||||||
import { SessionCompaction } from "../../src/session/compaction"
|
import { SessionCompaction } from "../../src/session/compaction"
|
||||||
import { Token } from "@/util/token"
|
import { Token } from "@/util/token"
|
||||||
import { Plugin } from "../../src/plugin"
|
|
||||||
import { provideTmpdirInstance, TestInstance } from "../fixture/fixture"
|
import { provideTmpdirInstance, TestInstance } from "../fixture/fixture"
|
||||||
import { Session as SessionNs } from "@/session/session"
|
import { Session as SessionNs } from "@/session/session"
|
||||||
import { MessageV2 } from "../../src/session/message-v2"
|
import { MessageV2 } from "../../src/session/message-v2"
|
||||||
@@ -247,7 +246,6 @@ const itCompaction = testEffect(compactionEnv)
|
|||||||
type CompactionProcessOptions = {
|
type CompactionProcessOptions = {
|
||||||
result?: "continue" | "compact"
|
result?: "continue" | "compact"
|
||||||
llm?: Layer.Layer<LLM.Service>
|
llm?: Layer.Layer<LLM.Service>
|
||||||
plugin?: Layer.Layer<Plugin.Service>
|
|
||||||
provider?: ReturnType<typeof wide>
|
provider?: ReturnType<typeof wide>
|
||||||
config?: Layer.Layer<Config.Service>
|
config?: Layer.Layer<Config.Service>
|
||||||
}
|
}
|
||||||
@@ -266,14 +264,12 @@ function compactionProcessLayer(options?: CompactionProcessOptions) {
|
|||||||
return AppNodeBuilder.build(compactionTestNode, [
|
return AppNodeBuilder.build(compactionTestNode, [
|
||||||
...replacements,
|
...replacements,
|
||||||
[SessionProcessorModule.SessionProcessor.node, processorLayer(options?.result ?? "continue")],
|
[SessionProcessorModule.SessionProcessor.node, processorLayer(options?.result ?? "continue")],
|
||||||
...(options?.plugin ? ([[Plugin.node, options.plugin]] as const) : []),
|
|
||||||
...(options?.config ? ([[Config.node, options.config]] as const) : []),
|
...(options?.config ? ([[Config.node, options.config]] as const) : []),
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
return AppNodeBuilder.build(compactionTestNode, [
|
return AppNodeBuilder.build(compactionTestNode, [
|
||||||
...replacements,
|
...replacements,
|
||||||
[LLM.node, options.llm],
|
[LLM.node, options.llm],
|
||||||
...(options?.plugin ? ([[Plugin.node, options.plugin]] as const) : []),
|
|
||||||
...(options?.config ? ([[Config.node, options.config]] 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", () => {
|
describe("session.compaction.isOverflow", () => {
|
||||||
it.live(
|
it.live(
|
||||||
@@ -1102,38 +1059,6 @@ describe("session.compaction.process", () => {
|
|||||||
{ git: true },
|
{ 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(
|
it.instance(
|
||||||
"replays the prior user turn on overflow when earlier context exists",
|
"replays the prior user turn on overflow when earlier context exists",
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
@@ -1264,38 +1189,6 @@ describe("session.compaction.process", () => {
|
|||||||
{ timeout: 10_000 },
|
{ 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(
|
itCompaction.instance(
|
||||||
"silently drops reasoning-delta arriving without prior reasoning-start",
|
"silently drops reasoning-delta arriving without prior reasoning-start",
|
||||||
() => {
|
() => {
|
||||||
@@ -1466,49 +1359,6 @@ describe("session.compaction.process", () => {
|
|||||||
{ git: true },
|
{ 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(
|
itCompaction.instance(
|
||||||
"serializes repeated compaction history as one user message",
|
"serializes repeated compaction history as one user message",
|
||||||
() => {
|
() => {
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import { Config } from "@/config/config"
|
|||||||
import { LSP } from "@/lsp/lsp"
|
import { LSP } from "@/lsp/lsp"
|
||||||
import { MCP } from "../../src/mcp"
|
import { MCP } from "../../src/mcp"
|
||||||
import { Permission } from "../../src/permission"
|
import { Permission } from "../../src/permission"
|
||||||
import { Plugin } from "../../src/plugin"
|
|
||||||
import { Provider as ProviderSvc } from "@/provider/provider"
|
import { Provider as ProviderSvc } from "@/provider/provider"
|
||||||
import { Env } from "../../src/env"
|
import { Env } from "../../src/env"
|
||||||
import { Git } from "../../src/git"
|
import { Git } from "../../src/git"
|
||||||
@@ -179,7 +178,6 @@ const promptRoot = LayerNode.group([
|
|||||||
AgentSvc.node,
|
AgentSvc.node,
|
||||||
Command.node,
|
Command.node,
|
||||||
Permission.node,
|
Permission.node,
|
||||||
Plugin.node,
|
|
||||||
Config.node,
|
Config.node,
|
||||||
ProviderSvc.node,
|
ProviderSvc.node,
|
||||||
LSP.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 { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||||
import { Effect, Layer } from "effect"
|
import { Effect, Layer } from "effect"
|
||||||
import { Skill } from "../../src/skill"
|
import { Skill } from "../../src/skill"
|
||||||
import { Discovery } from "../../src/skill/discovery"
|
|
||||||
import { RuntimeFlags } from "../../src/effect/runtime-flags"
|
import { RuntimeFlags } from "../../src/effect/runtime-flags"
|
||||||
import { EventV2Bridge } from "../../src/event-v2-bridge"
|
import { EventV2Bridge } from "../../src/event-v2-bridge"
|
||||||
import { Config } from "../../src/config/config"
|
import { Config } from "../../src/config/config"
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { CodeModeTool, describeCatalog } from "@/tool/code-mode"
|
|||||||
import { McpCatalog } from "@/mcp/catalog"
|
import { McpCatalog } from "@/mcp/catalog"
|
||||||
import { Agent } from "@/agent/agent"
|
import { Agent } from "@/agent/agent"
|
||||||
import { MCP } from "@/mcp"
|
import { MCP } from "@/mcp"
|
||||||
import { Plugin } from "@/plugin"
|
|
||||||
import { Session } from "@/session/session"
|
import { Session } from "@/session/session"
|
||||||
import { Tool } from "@/tool/tool"
|
import { Tool } from "@/tool/tool"
|
||||||
import * as Truncate from "@/tool/truncate"
|
import * as Truncate from "@/tool/truncate"
|
||||||
@@ -139,10 +138,6 @@ async function buildTool() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const layer = Layer.mergeAll(
|
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, {
|
Layer.mock(Truncate.Service, {
|
||||||
output: (text: string) => Effect.succeed({ content: text, truncated: false as const }),
|
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 { Agent } from "@/agent/agent"
|
||||||
import { MCP } from "@/mcp"
|
import { MCP } from "@/mcp"
|
||||||
import { Permission } from "@/permission"
|
import { Permission } from "@/permission"
|
||||||
import { Plugin } from "@/plugin"
|
|
||||||
import { Session } from "@/session/session"
|
import { Session } from "@/session/session"
|
||||||
import { Tool } from "@/tool/tool"
|
import { Tool } from "@/tool/tool"
|
||||||
import * as Truncate from "@/tool/truncate"
|
import * as Truncate from "@/tool/truncate"
|
||||||
@@ -41,12 +40,8 @@ function harness(input: {
|
|||||||
mcpTools: Record<string, MCP.McpTool>
|
mcpTools: Record<string, MCP.McpTool>
|
||||||
servers: string[]
|
servers: string[]
|
||||||
permission?: PermissionV1.Rule[]
|
permission?: PermissionV1.Rule[]
|
||||||
trigger?: Plugin.Interface["trigger"]
|
|
||||||
}) {
|
}) {
|
||||||
return Layer.mergeAll(
|
return Layer.mergeAll(
|
||||||
Layer.mock(Plugin.Service, {
|
|
||||||
trigger: input.trigger ?? (((_name, _input, output) => Effect.succeed(output)) as Plugin.Interface["trigger"]),
|
|
||||||
}),
|
|
||||||
Layer.mock(Truncate.Service, {
|
Layer.mock(Truncate.Service, {
|
||||||
output: (text: string) => Effect.succeed({ content: text, truncated: false as const }),
|
output: (text: string) => Effect.succeed({ content: text, truncated: false as const }),
|
||||||
}),
|
}),
|
||||||
@@ -71,13 +66,12 @@ function build(
|
|||||||
mcpTools: Record<string, MCP.McpTool>,
|
mcpTools: Record<string, MCP.McpTool>,
|
||||||
servers?: string[],
|
servers?: string[],
|
||||||
permission?: PermissionV1.Rule[],
|
permission?: PermissionV1.Rule[],
|
||||||
trigger?: Plugin.Interface["trigger"],
|
|
||||||
) {
|
) {
|
||||||
const names = serverNames(mcpTools, servers)
|
const names = serverNames(mcpTools, servers)
|
||||||
return Effect.runPromise(
|
return Effect.runPromise(
|
||||||
CodeModeTool.pipe(
|
CodeModeTool.pipe(
|
||||||
Effect.flatMap(Tool.init),
|
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" }])
|
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 () => {
|
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 snapshots: Array<{ toolCalls: { tool: string; status: string; input?: Record<string, unknown> }[] }> = []
|
||||||
const recordingCtx: Tool.Context = {
|
const recordingCtx: Tool.Context = {
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import { disposeAllInstances, TestInstance } from "../fixture/fixture"
|
|||||||
import { testEffect } from "../lib/effect"
|
import { testEffect } from "../lib/effect"
|
||||||
import { TestConfig } from "../fixture/config"
|
import { TestConfig } from "../fixture/config"
|
||||||
import { Config } from "@/config/config"
|
import { Config } from "@/config/config"
|
||||||
import { Plugin } from "@/plugin"
|
|
||||||
import { Agent } from "@/agent/agent"
|
import { Agent } from "@/agent/agent"
|
||||||
import { InstanceState } from "@/effect/instance-state"
|
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")])),
|
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 root = LayerNode.group([ToolRegistry.node, Agent.node])
|
||||||
const replacements = [
|
const replacements = [
|
||||||
[Config.node, configLayer],
|
[Config.node, configLayer],
|
||||||
@@ -93,8 +68,6 @@ const withEmptyCodeMode = testEffect(
|
|||||||
],
|
],
|
||||||
]),
|
]),
|
||||||
)
|
)
|
||||||
const withBrokenPlugin = testEffect(LayerNode.compile(root, [...replacements, [Plugin.node, brokenPluginLayer]]))
|
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
await disposeAllInstances()
|
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)", () =>
|
it.instance("loads tools from .opencode/tools (plural)", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const test = yield* TestInstance
|
const test = yield* TestInstance
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import { Truncate } from "@/tool/truncate"
|
|||||||
import { SessionID, MessageID } from "../../src/session/schema"
|
import { SessionID, MessageID } from "../../src/session/schema"
|
||||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||||
import { Plugin } from "../../src/plugin"
|
|
||||||
import { testEffect } from "../lib/effect"
|
import { testEffect } from "../lib/effect"
|
||||||
import { Tool } from "@/tool/tool"
|
import { Tool } from "@/tool/tool"
|
||||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||||
@@ -27,7 +26,6 @@ const shellLayer = Layer.mergeAll(
|
|||||||
LayerNode.group([
|
LayerNode.group([
|
||||||
CrossSpawnSpawner.node,
|
CrossSpawnSpawner.node,
|
||||||
FSUtil.node,
|
FSUtil.node,
|
||||||
Plugin.node,
|
|
||||||
Truncate.node,
|
Truncate.node,
|
||||||
Config.node,
|
Config.node,
|
||||||
Agent.node,
|
Agent.node,
|
||||||
|
|||||||
Reference in New Issue
Block a user