Compare commits

..
48 changed files with 1080 additions and 3650 deletions

No files matched your search

+45 -62
View File
@@ -422,74 +422,57 @@ const AnthropicUsage = Schema.StructWithRest(
)
type AnthropicUsage = Schema.Schema.Type<typeof AnthropicUsage>
const AnthropicStreamBlock = Schema.StructWithRest(
Schema.Struct({
type: Schema.String,
id: Schema.optional(Schema.String),
name: Schema.optional(Schema.String),
text: Schema.optional(Schema.String),
thinking: Schema.optional(Schema.String),
signature: Schema.optional(Schema.String),
// redacted_thinking blocks arrive whole in content_block_start with the
// encrypted payload in `data`; there is no streaming delta sequence.
data: Schema.optional(Schema.String),
input: Schema.optional(Schema.Unknown),
// *_tool_result blocks arrive whole as content_block_start (no streaming
// delta) with the structured payload in `content` and the originating
// server_tool_use id in `tool_use_id`.
tool_use_id: Schema.optional(Schema.String),
content: Schema.optional(Schema.Unknown),
}),
[JsonObject],
)
const AnthropicStreamBlock = Schema.Struct({
type: Schema.String,
id: Schema.optional(Schema.String),
name: Schema.optional(Schema.String),
text: Schema.optional(Schema.String),
thinking: Schema.optional(Schema.String),
signature: Schema.optional(Schema.String),
// redacted_thinking blocks arrive whole in content_block_start with the
// encrypted payload in `data`; there is no streaming delta sequence.
data: Schema.optional(Schema.String),
input: Schema.optional(Schema.Unknown),
// *_tool_result blocks arrive whole as content_block_start (no streaming
// delta) with the structured payload in `content` and the originating
// server_tool_use id in `tool_use_id`.
tool_use_id: Schema.optional(Schema.String),
content: Schema.optional(Schema.Unknown),
})
type AnthropicStreamBlock = Schema.Schema.Type<typeof AnthropicStreamBlock>
const decodeAnthropicStreamBlock = Schema.decodeUnknownOption(AnthropicStreamBlock)
const AnthropicStreamDelta = Schema.StructWithRest(
Schema.Struct({
content: optionalNull(Schema.String),
type: Schema.optional(Schema.String),
text: Schema.optional(Schema.String),
thinking: Schema.optional(Schema.String),
partial_json: Schema.optional(Schema.String),
signature: Schema.optional(Schema.String),
stop_reason: optionalNull(Schema.String),
stop_sequence: optionalNull(Schema.String),
stop_details: optionalNull(
Schema.StructWithRest(
Schema.Struct({ category: optionalNull(Schema.String), explanation: optionalNull(Schema.String) }),
[JsonObject],
),
),
}),
[JsonObject],
)
const AnthropicStreamDelta = Schema.Struct({
content: optionalNull(Schema.String),
type: Schema.optional(Schema.String),
text: Schema.optional(Schema.String),
thinking: Schema.optional(Schema.String),
partial_json: Schema.optional(Schema.String),
signature: Schema.optional(Schema.String),
stop_reason: optionalNull(Schema.String),
stop_sequence: optionalNull(Schema.String),
stop_details: optionalNull(
Schema.Struct({ category: optionalNull(Schema.String), explanation: optionalNull(Schema.String) }),
),
})
type AnthropicStreamDelta = Schema.Schema.Type<typeof AnthropicStreamDelta>
const decodeAnthropicStreamDelta = Schema.decodeUnknownOption(AnthropicStreamDelta)
const AnthropicEvent = Schema.StructWithRest(
Schema.Struct({
type: Schema.String,
index: Schema.optional(Schema.Number),
message: Schema.optional(
Schema.StructWithRest(Schema.Struct({ usage: Schema.optional(AnthropicUsage) }), [JsonObject]),
),
content_block: Schema.optional(Schema.Unknown),
delta: Schema.optional(Schema.Unknown),
usage: Schema.optional(AnthropicUsage),
// `type` and `message` are both required per Anthropic's spec, but
// OpenAI-compatible proxies and gateway translations occasionally drop one
// or the other; mark them optional so a partial payload still parses and
// the parser can fall back to whichever field is populated.
error: Schema.optional(
Schema.StructWithRest(
Schema.Struct({ type: Schema.optional(Schema.String), message: Schema.optional(Schema.String) }),
[JsonObject],
),
),
}),
[JsonObject],
)
const AnthropicEvent = Schema.Struct({
type: Schema.String,
index: Schema.optional(Schema.Number),
message: Schema.optional(Schema.Struct({ usage: Schema.optional(AnthropicUsage) })),
content_block: Schema.optional(Schema.Unknown),
delta: Schema.optional(Schema.Unknown),
usage: Schema.optional(AnthropicUsage),
// `type` and `message` are both required per Anthropic's spec, but
// OpenAI-compatible proxies and gateway translations occasionally drop one
// or the other; mark them optional so a partial payload still parses and
// the parser can fall back to whichever field is populated.
error: Schema.optional(
Schema.Struct({ type: Schema.optional(Schema.String), message: Schema.optional(Schema.String) }),
),
})
type AnthropicEvent = Schema.Schema.Type<typeof AnthropicEvent>
interface ParserState {
+12 -20
View File
@@ -301,26 +301,18 @@ export const OpenResponsesBody = Schema.Struct({
})
export type OpenResponsesBody = Schema.Schema.Type<typeof OpenResponsesBody>
export const OpenResponsesUsage = Schema.StructWithRest(
Schema.Struct({
input_tokens: Schema.optional(Schema.Number),
input_tokens_details: optionalNull(
Schema.StructWithRest(
Schema.Struct({
cached_tokens: Schema.optional(Schema.Number),
cache_write_tokens: Schema.optional(Schema.Number),
}),
[JsonObject],
),
),
output_tokens: Schema.optional(Schema.Number),
output_tokens_details: optionalNull(
Schema.StructWithRest(Schema.Struct({ reasoning_tokens: Schema.optional(Schema.Number) }), [JsonObject]),
),
total_tokens: Schema.optional(Schema.Number),
}),
[JsonObject],
)
export const OpenResponsesUsage = Schema.Struct({
input_tokens: Schema.optional(Schema.Number),
input_tokens_details: optionalNull(
Schema.Struct({
cached_tokens: Schema.optional(Schema.Number),
cache_write_tokens: Schema.optional(Schema.Number),
}),
),
output_tokens: Schema.optional(Schema.Number),
output_tokens_details: optionalNull(Schema.Struct({ reasoning_tokens: Schema.optional(Schema.Number) })),
total_tokens: Schema.optional(Schema.Number),
})
type OpenResponsesUsage = Schema.Schema.Type<typeof OpenResponsesUsage>
// The spec requires `id` on every output item, but some gateways drop it from
+1 -66
View File
@@ -7,7 +7,6 @@ import { Script } from "@opencode/script"
import { createSolidTransformPlugin } from "@opentui/solid/bun-plugin"
import type { BunPlugin } from "bun"
import pkg from "../package.json"
import { discoverPluginRuntimeSpecifiers } from "../src/plugin-runtime"
import { buildAppArchive } from "./app-assets"
import { verifyArtifact, verifySimulationGraph } from "./verify-artifact"
import { resolveOpencodePty } from "./opencode-pty"
@@ -79,63 +78,6 @@ const appAssetsPlugin: BunPlugin = {
}))
},
}
const pluginRuntimeSpecifiers = discoverPluginRuntimeSpecifiers()
const pluginRuntimeLoaderCode = (specifier: string) => {
if (specifier.startsWith("effect/")) {
const subpath = specifier.slice("effect/".length)
const slash = subpath.lastIndexOf("/")
const parent = slash === -1 ? "effect" : `effect/${subpath.slice(0, slash)}`
const member = slash === -1 ? subpath : subpath.slice(slash + 1)
const parentPath = pluginRuntimeSpecifiers.get(parent)
if (parentPath && /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(member) && member in require(parentPath)) {
return `() => require(${JSON.stringify(parent)}).${member}`
}
}
return `() => require(${JSON.stringify(specifier)})`
}
const pluginRuntimeSource = `const runtimeModulesKey = Symbol.for("opencode.plugin.runtime-modules")
const modules = {
${[...pluginRuntimeSpecifiers.keys()].map((specifier) => ` ${JSON.stringify(specifier)}: ${pluginRuntimeLoaderCode(specifier)},`).join("\n")}
}
export function ensurePluginRuntime() {
const state = globalThis
if (state[runtimeModulesKey]) return state[runtimeModulesKey]
state[runtimeModulesKey] = modules
Bun.plugin({
name: "opencode-plugin-runtime",
setup(build) {
for (const [specifier, load] of Object.entries(modules)) {
build.module(specifier, () => ({ exports: load(), loader: "object" }))
}
build.onLoad(
{ filter: /[/\\\\]node_modules[/\\\\](?:effect|@opencode[/\\\\]plugin)[/\\\\].*\\.[cm]?[jt]sx?(?:[?#].*)?$/ },
(args) => {
const match = args.path.replaceAll("\\\\", "/").match(/\\/node_modules\\/((?:@opencode\\/plugin|effect)\\/.+)$/)
const target = match ? match[1] : args.path
throw new Error(
\`Cannot load "\${target}" from plugin node_modules: "\${target}" is not provided by OpenCode; plugins must use the host's "effect" and "@opencode/plugin" modules.\`,
)
},
)
},
})
return modules
}
`
const pluginRuntimePlugin: BunPlugin = {
name: "opencode-plugin-runtime",
setup(build) {
build.onLoad({ filter: /cli[/\\]src[/\\]plugin-runtime\.ts$/ }, () => ({
contents: pluginRuntimeSource,
loader: "js",
}))
build.onLoad({ filter: /[/\\]internal[/\\]httpApi(?:Scalar|Swagger)\.js$/ }, () => ({
contents:
'export const css = ""; export const javascript = \'document.body.textContent = "Scalar/Swagger UI assets are not bundled in OpenCode"\'',
loader: "js",
}))
},
}
for (const item of targets) {
const opencodePty = await resolveOpencodePty({
@@ -182,14 +124,7 @@ export default { path: file, version: ${JSON.stringify(opencodePty.version)}, sh
const result = await Bun.build({
entrypoints: ["./src/index.ts"],
tsconfig: "./tsconfig.json",
plugins: [
appAssetsPlugin,
solidPlugin,
parcelWatcherPlugin,
opencodePtyPlugin,
pluginRuntimePlugin,
simulationGraphPlugin,
],
plugins: [appAssetsPlugin, solidPlugin, parcelWatcherPlugin, opencodePtyPlugin, simulationGraphPlugin],
external: ["node-gyp"],
format: "esm",
minify: true,
+2
View File
@@ -475,6 +475,8 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
Spec.make("restart", { description: "Restart the background server" }),
Spec.make("status", { description: "Show background server status" }),
Spec.make("stop", { description: "Stop the background server" }),
Spec.make("disable", { description: "Stop the background server and use private servers by default" }),
Spec.make("enable", { description: "Use the background server by default" }),
Spec.make("get", {
description: "Get service configuration",
params: {
+1 -1
View File
@@ -12,7 +12,7 @@ export default Runtime.handler(
Effect.fn("cli.pair")(function* (input: Runtime.Input<typeof Commands.commands.pair>) {
if ((yield* ServiceConfig.read()).disabled === true)
return yield* Effect.fail(
new Error("Pairing requires the background service; run `opencode service unset disabled` first"),
new Error("Pairing requires the background service; run `opencode service enable` first"),
)
const endpoint = yield* Service.ensure(yield* ServiceConfig.options())
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
@@ -0,0 +1,13 @@
import { Effect } from "effect"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { ServiceConfig } from "../../../services/service-config"
import { ServerConnection } from "../../../services/server-connection"
export default Runtime.handler(
Commands.commands.service.commands.disable,
Effect.fn("cli.service.disable")(function* () {
yield* ServerConnection.shutdownPersistentPty(yield* ServiceConfig.options()).pipe(Effect.ignore)
yield* ServiceConfig.disable()
}),
)
@@ -0,0 +1,11 @@
import { Effect } from "effect"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { ServiceConfig } from "../../../services/service-config"
export default Runtime.handler(
Commands.commands.service.commands.enable,
Effect.fn("cli.service.enable")(function* () {
yield* ServiceConfig.enable()
}),
)
@@ -10,6 +10,7 @@ export default Runtime.handler(
Effect.fn("cli.service.status")(function* () {
const options = yield* ServiceConfig.options()
const found = yield* Service.discover({ ...options, version: undefined })
process.stdout.write((found?.url ?? "stopped") + EOL)
if (found) return process.stdout.write(found.url + EOL)
process.stdout.write(((yield* ServiceConfig.read()).disabled === true ? "disabled" : "stopped") + EOL)
}),
)
+2 -3
View File
@@ -1,6 +1,5 @@
#!/usr/bin/env bun
import { ensurePluginRuntime } from "./plugin-runtime"
import { NodeRuntime, NodeServices } from "@effect/platform-node"
import { Cause, Effect } from "effect"
import { getErrorReported } from "effect/Runtime"
@@ -18,8 +17,6 @@ import { EffectFlock } from "@opencode/util/effect-flock"
import { Heap } from "./heap"
import { CpuProfile } from "./cpu-profile"
ensurePluginRuntime()
if (process.env.OPENCODE_SSH_ASKPASS_PORT) {
const { askpass } = await import("./ssh-askpass")
process.exit(await Effect.runPromise(askpass.pipe(Effect.provide(NodeServices.layer))))
@@ -74,6 +71,8 @@ const Handlers = Runtime.handlers(Commands, {
restart: () => import("./commands/handlers/service/restart"),
status: () => import("./commands/handlers/service/status"),
stop: () => import("./commands/handlers/service/stop"),
disable: () => import("./commands/handlers/service/disable"),
enable: () => import("./commands/handlers/service/enable"),
get: () => import("./commands/handlers/service/get"),
set: () => import("./commands/handlers/service/set"),
unset: () => import("./commands/handlers/service/unset"),
-105
View File
@@ -1,105 +0,0 @@
import { existsSync, realpathSync } from "node:fs"
import path from "node:path"
import { pathToFileURL } from "node:url"
type RuntimeModuleLoader = () => Record<string, unknown> | Promise<Record<string, unknown>>
const runtimeModulesKey = Symbol.for("opencode.plugin.runtime-modules")
type GlobalState = typeof globalThis & {
[runtimeModulesKey]?: Readonly<Record<string, RuntimeModuleLoader>>
}
export function discoverPluginRuntimeSpecifiers(): ReadonlyMap<string, string> {
const entries = new Map<string, string>()
for (const pkgName of ["effect", "@opencode/plugin"]) {
const dir = realpathSync(path.dirname(Bun.resolveSync(`${pkgName}/package.json`, import.meta.dir)))
const [subdir, ext] = existsSync(path.join(dir, "dist")) ? ["dist", ".js"] : ["src", ".ts"]
const scanDir = path.join(dir, subdir)
entries.set(pkgName, Bun.resolveSync(pkgName, import.meta.dir))
for (const file of new Bun.Glob(`**/*${ext}`).scanSync({ cwd: scanDir })) {
const normalized = file.replaceAll("\\", "/")
if (normalized.startsWith("internal/") || normalized.includes("/internal/") || normalized.startsWith("source.")) {
continue
}
const base = normalized.slice(0, -ext.length)
if (base === "index") continue
if (!base.endsWith("/index")) {
entries.set(`${pkgName}/${base}`, path.join(scanDir, file))
continue
}
for (const specifier of [`${pkgName}/${base.slice(0, -"/index".length)}`, `${pkgName}/${base}`]) {
try {
entries.set(specifier, Bun.resolveSync(specifier, import.meta.dir))
} catch {}
}
}
}
return entries
}
export function ensurePluginRuntime(): Readonly<Record<string, RuntimeModuleLoader>> {
if (typeof Bun === "undefined") return {}
const state = globalThis as GlobalState
if (state[runtimeModulesKey]) return state[runtimeModulesKey]
const modules = Object.fromEntries(
[...discoverPluginRuntimeSpecifiers().entries()].map(([specifier, resolved]) => [specifier, createLoader(resolved)]),
)
state[runtimeModulesKey] = modules
const foreignFilter = createForeignPackageFilter()
Bun.plugin({
name: "opencode-plugin-runtime",
setup(build) {
for (const [specifier, load] of Object.entries(modules)) {
build.module(specifier, () => {
const exports = load()
if (exports instanceof Promise) {
return exports.then((value) => ({ exports: value, loader: "object" as const }))
}
return { exports, loader: "object" }
})
}
build.onLoad({ filter: foreignFilter }, (args) => {
throw formatForeignPackageError(args.path)
})
},
})
return modules
}
function createLoader(resolved: string): RuntimeModuleLoader {
let cached: Record<string, unknown> | undefined
return () => {
if (cached) return cached
try {
cached = require(resolved) as Record<string, unknown>
return cached
} catch {
return import(pathToFileURL(resolved).href).then((mod: Record<string, unknown>) => {
cached = mod
return mod
})
}
}
}
function createForeignPackageFilter() {
const roots = new Set<string>()
for (const pkgName of ["effect", "@opencode/plugin"]) {
const dir = path.dirname(Bun.resolveSync(`${pkgName}/package.json`, import.meta.dir))
roots.add(dir)
roots.add(realpathSync(dir))
}
const escaped = [...roots].map((value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|")
return new RegExp(
`^(?!(?:${escaped})[/\\\\]).*[/\\\\]node_modules[/\\\\](?:effect|@opencode[/\\\\]plugin)[/\\\\].*\\.[cm]?[jt]sx?(?:[?#].*)?$`,
)
}
function formatForeignPackageError(filePath: string) {
const match = filePath.replaceAll("\\", "/").match(/\/node_modules\/((?:@opencode\/plugin|effect)\/.+)$/)
const target = match ? match[1] : filePath
return new Error(
`Cannot load "${target}" from plugin node_modules: "${target}" is not provided by OpenCode; plugins must use the host's "effect" and "@opencode/plugin" modules.`,
)
}
+12 -24
View File
@@ -21,7 +21,7 @@ export const Info = Schema.Struct({
})
export type Info = typeof Info.Type
const keys = ["disabled", "hostname", "port", "password", "cors", "env"] as const
const keys = ["hostname", "port", "password", "cors", "env"] as const
type Key = (typeof keys)[number]
const decodeInfo = Schema.decodeUnknownEffect(Schema.fromJsonString(Info))
@@ -79,15 +79,7 @@ export const migrateConfig = Effect.fnUntraced(function* (legacy: string, file:
})
function configKey(key: string): Key {
if (
key === "disabled" ||
key === "hostname" ||
key === "port" ||
key === "password" ||
key === "cors" ||
key === "env"
)
return key
if (key === "hostname" || key === "port" || key === "password" || key === "cors" || key === "env") return key
throw new Error(`Unknown service config key: ${key}`)
}
@@ -152,6 +144,16 @@ export const password = Effect.fn("cli.service-config.password")(function* (valu
return next
})
export const disable = Effect.fn("cli.service-config.disable")(function* () {
yield* Service.stop(yield* options())
yield* write({ ...(yield* read()), disabled: true })
})
export const enable = Effect.fn("cli.service-config.enable")(function* () {
const { disabled: _disabled, ...next } = yield* read()
yield* write(next)
})
export const get = Effect.fn("cli.service-config.get")(function* (key?: string, name?: string) {
if (key === undefined) {
const { password: _password, ...safe } = yield* read()
@@ -160,9 +162,6 @@ export const get = Effect.fn("cli.service-config.get")(function* (key?: string,
const selected = configKey(key)
if (selected !== "env" && name !== undefined) throw new Error(`Usage: opencode service get ${selected}`)
switch (selected) {
case "disabled": {
return String((yield* read()).disabled ?? false)
}
case "hostname": {
return (yield* read()).hostname ?? ""
}
@@ -189,12 +188,6 @@ export const set = Effect.fn("cli.service-config.set")(function* (key: string, v
if (selected !== "env" && nestedValue !== undefined)
throw new Error(`Usage: opencode service set ${selected} <value>`)
switch (selected) {
case "disabled": {
if (value !== "true" && value !== "false") throw new Error("Disabled must be true or false")
if (value === "true") yield* Service.stop(yield* options())
yield* write({ ...(yield* read()), disabled: value === "true" })
return
}
case "hostname": {
yield* Service.stop(yield* options())
yield* write({ ...(yield* read()), hostname: value })
@@ -239,11 +232,6 @@ export const unset = Effect.fn("cli.service-config.unset")(function* (key: strin
const selected = configKey(key)
if (selected !== "env" && name !== undefined) throw new Error(`Usage: opencode service unset ${selected}`)
switch (selected) {
case "disabled": {
const { disabled: _disabled, ...next } = yield* read()
yield* write(next)
return
}
case "hostname": {
yield* Service.stop(yield* options())
const { hostname: _hostname, ...next } = yield* read()
+7 -8
View File
@@ -21,19 +21,18 @@ test("managed service ports are stable per installation channel", () => {
expect(ServiceConfig.defaultPort("preview-a")).not.toBe(ServiceConfig.defaultPort("preview-b"))
})
test("service disabled accepts only booleans without changing configuration on invalid input", async () => {
test("service disable and enable toggle the disabled flag", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-disabled-config-"))
const layer = Global.layerWith({ config: path.join(root, "config"), state: path.join(root, "state") })
const run = <A, E>(effect: Effect.Effect<A, E, Global.Service | FileSystem.FileSystem>) =>
Effect.runPromise(effect.pipe(Effect.provide(layer), Effect.provide(NodeFileSystem.layer)))
try {
expect(await run(ServiceConfig.get("disabled"))).toBe("false")
await expect(run(ServiceConfig.set("disabled", "yes"))).rejects.toThrow("Disabled must be true or false")
expect(await run(ServiceConfig.read())).toEqual({})
await run(ServiceConfig.set("disabled", "true"))
expect(await run(ServiceConfig.read())).toEqual({ disabled: true })
await run(ServiceConfig.unset("disabled"))
expect(await run(ServiceConfig.read())).toEqual({})
await run(ServiceConfig.set("hostname", "127.0.0.2"))
await run(ServiceConfig.disable())
expect(await run(ServiceConfig.read())).toEqual({ hostname: "127.0.0.2", disabled: true })
await expect(run(ServiceConfig.set("disabled", "true"))).rejects.toThrow("Unknown service config key: disabled")
await run(ServiceConfig.enable())
expect(await run(ServiceConfig.read())).toEqual({ hostname: "127.0.0.2" })
} finally {
await fs.rm(root, { recursive: true, force: true })
}
+42 -13
View File
@@ -1,6 +1,11 @@
import { Effect, FileSystem, Option, Schedule, Schema } from "effect"
import type { DiscoverOptions, EnsureOptions, StopOptions } from "../service.js"
import { contenderPool, spawnServiceContender } from "../service-contender.js"
import {
contenderFailure,
contenderFinished,
type ServiceContender,
spawnServiceContender,
} from "../service-contender.js"
import { defaultEnsureTiming, ensureTiming, type EnsureTiming } from "../service-timing.js"
import { matchesVersion } from "../service-version.js"
import { PtyHandoff } from "../pty-handoff.js"
@@ -48,9 +53,12 @@ export const incumbent = Effect.fn("service.incumbent")(function* (
/** Ensure a healthy, compatible local service is running. */
export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOptions = {}) {
const timing = ensureTiming(options)
const pool = contenderPool(timing)
const contenders = new Set<ServiceContender>()
let timeouts: { readonly info: Info; readonly count: number } | undefined
let announced = false
let lastSpawn = 0
let spawnDelay = timing.spawnDelay
let failure: Error | undefined
const announce = (reason: "missing" | "version-mismatch", previousVersion?: string) =>
Effect.sync(() => {
if (announced) return
@@ -82,13 +90,19 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
yield* Effect.logWarning("Background service is unresponsive; recovery cannot preserve persistent terminals")
yield* Effect.tryPromise(() => PtyHandoff.clear(options.file ?? fallback()))
yield* terminate(info, options, timing)
pool.evict(info.pid)
pool.recruitNow()
for (const item of contenders) {
if (item.child.pid === info.pid || contenderFinished(item)) {
item.release()
contenders.delete(item)
}
}
failure = undefined
timeouts = undefined
lastSpawn = Date.now() - spawnDelay
}
} else timeouts = undefined
if (service !== undefined) {
pool.serviceAnswered()
spawnDelay = timing.spawnDelay
const versionMatches = matchesVersion(service.version, options)
const compatible = service.compatible && versionMatches
if (!service.compatible && versionMatches)
@@ -111,15 +125,30 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
file: options.file,
pty: service.state === "ready" ? "handoff" : "clear",
}).pipe(Effect.ignore)
pool.evict(service.info.pid)
for (const item of contenders) {
if (item.child.pid === service.info.pid || contenderFinished(item)) {
item.release()
contenders.delete(item)
}
}
failure = undefined
lastSpawn = 0
return Option.none<LocalService>()
}
} else if (lastSpawn === 0 && info !== undefined) lastSpawn = Date.now()
const failed = pool.reap()
if (failed !== undefined) return yield* Effect.fail(failed)
if (pool.shouldRecruit(info !== undefined)) {
const finished = [...contenders].filter(contenderFinished)
failure ??= finished.map(contenderFailure).find((error): error is Error => error !== undefined)
if (finished.some((item) => item.child.exitCode === 0)) {
spawnDelay = Math.min(spawnDelay * 2, timing.maxSpawnDelay)
}
finished.forEach((item) => contenders.delete(item))
if (failure !== undefined && contenders.size === 0) return yield* Effect.fail(failure)
// Keep one candidate plus one lock probe for pre-lock stalls. After a failure, let the
// survivors finish without recruiting replacements that could hide the error indefinitely.
if (failure === undefined && contenders.size < 2 && Date.now() - lastSpawn >= spawnDelay) {
yield* announce("missing")
pool.add(yield* spawnContender)
contenders.add(yield* spawnContender)
lastSpawn = Date.now()
}
return Option.none<LocalService>()
}).pipe(
@@ -129,10 +158,10 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
// like the Promise variant rather than by attempt count.
schedule: Schedule.spaced(timing.pollInterval).pipe(Schedule.upTo({ duration: timing.promiseTimeout })),
}),
Effect.ensuring(Effect.sync(() => pool.releaseAll())),
Effect.ensuring(Effect.sync(() => contenders.forEach((contender) => contender.release()))),
)
if (Option.isNone(found))
return yield* Effect.fail(pool.failure() ?? new Error("Timed out waiting for the background service to start"))
return yield* Effect.fail(failure ?? new Error("Timed out waiting for the background service to start"))
return found.value.endpoint
})
+42 -13
View File
@@ -1,6 +1,11 @@
import { readFile, rm } from "node:fs/promises"
import type { DiscoverOptions, Endpoint, Info, EnsureOptions, StopOptions } from "../service.js"
import { contenderPool, spawnServiceContender } from "../service-contender.js"
import {
contenderFailure,
contenderFinished,
type ServiceContender,
spawnServiceContender,
} from "../service-contender.js"
import { defaultEnsureTiming, ensureTiming, type EnsureTiming } from "../service-timing.js"
import { matchesVersion } from "../service-version.js"
import { PtyHandoff } from "../pty-handoff.js"
@@ -28,9 +33,12 @@ export async function discover(options: DiscoverOptions = {}) {
export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
const timing = ensureTiming(options)
const deadline = Date.now() + timing.promiseTimeout
const pool = contenderPool(timing)
const contenders = new Set<ServiceContender>()
let timeouts: { readonly info: Info; readonly count: number } | undefined
let announced = false
let lastSpawn = 0
let spawnDelay = timing.spawnDelay
let failure: Error | undefined
const announce = (reason: "missing" | "version-mismatch", previousVersion?: string) => {
if (announced) return
@@ -49,8 +57,7 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
try {
while (true) {
if (Date.now() >= deadline)
throw pool.failure() ?? new Error("Timed out waiting for the background service to start")
if (Date.now() >= deadline) throw failure ?? new Error("Timed out waiting for the background service to start")
const registration = await registered(options.file, timing.requestTimeout)
if (registration.timedOut && registration.info !== undefined) {
timeouts = {
@@ -62,14 +69,20 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
console.warn("Background service is unresponsive; recovery cannot preserve persistent terminals")
await PtyHandoff.clear(options.file ?? fallback())
await terminate(registration.info, options, timing)
pool.evict(registration.info.pid)
pool.recruitNow()
for (const item of contenders) {
if (item.child.pid === registration.info.pid || contenderFinished(item)) {
item.release()
contenders.delete(item)
}
}
failure = undefined
timeouts = undefined
lastSpawn = Date.now() - spawnDelay
}
} else timeouts = undefined
if (registration.service !== undefined) {
pool.serviceAnswered()
spawnDelay = timing.spawnDelay
const service = registration.service
const versionMatches = matchesVersion(service.version, options)
const compatible = service.compatible && versionMatches
@@ -90,20 +103,36 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
file: options.file,
pty: service.state === "ready" ? "handoff" : "clear",
}).catch(() => undefined)
pool.evict(service.info.pid)
for (const item of contenders) {
if (item.child.pid === service.info.pid || contenderFinished(item)) {
item.release()
contenders.delete(item)
}
}
failure = undefined
lastSpawn = 0
}
} else {
const failed = pool.reap()
if (failed !== undefined) throw failed
if (pool.shouldRecruit(registration.info !== undefined)) {
if (lastSpawn === 0 && registration.info !== undefined) lastSpawn = Date.now()
const finished = [...contenders].filter(contenderFinished)
failure ??= finished.map(contenderFailure).find((error) => error !== undefined)
if (finished.some((item) => item.child.exitCode === 0)) {
spawnDelay = Math.min(spawnDelay * 2, timing.maxSpawnDelay)
}
finished.forEach((item) => contenders.delete(item))
if (failure !== undefined && contenders.size === 0) throw failure
// Keep one candidate plus one lock probe for pre-lock stalls. After a failure, let the
// survivors finish without recruiting replacements that could hide the error indefinitely.
if (failure === undefined && contenders.size < 2 && Date.now() - lastSpawn >= spawnDelay) {
announce("missing")
pool.add(await spawnContender())
contenders.add(await spawnContender())
lastSpawn = Date.now()
}
}
await delay(timing.pollInterval)
}
} finally {
pool.releaseAll()
contenders.forEach((contender) => contender.release())
}
}
+1 -65
View File
@@ -1,5 +1,4 @@
import { spawn, type ChildProcess } from "node:child_process"
import type { EnsureTiming } from "./service-timing.js"
export type ServiceContender = {
readonly child: ChildProcess
@@ -54,70 +53,7 @@ export function spawnServiceContender(
}
}
/**
* The startup attempts of one `ensure()` call. It keeps at most two attempts alive, remembers
* the first startup failure, and backs off when attempts exit cleanly because another one won.
*/
export function contenderPool(timing: EnsureTiming) {
const contenders = new Set<ServiceContender>()
let failure: Error | undefined
let spawnDelay = timing.spawnDelay
let lastSpawn = 0
return {
/** The first startup failure seen since the last eviction. */
failure: () => failure,
/** A registered service answered, so the next attempt waits the base delay again. */
serviceAnswered() {
spawnDelay = timing.spawnDelay
},
/**
* The owner `pid` was replaced. Drop its attempt and any finished ones, forget their
* failure, and restart the spawn clock.
*/
evict(pid: number) {
for (const item of contenders) {
if (item.child.pid === pid || contenderFinished(item)) {
item.release()
contenders.delete(item)
}
}
failure = undefined
lastSpawn = 0
},
/** Let the next attempt start without waiting a spawn delay. */
recruitNow() {
lastSpawn = Date.now() - spawnDelay
},
/** Collect finished attempts. Returns the startup failure once no attempt is left alive. */
reap() {
const finished = [...contenders].filter(contenderFinished)
failure ??= finished.map(contenderFailure).find((error) => error !== undefined)
if (finished.some((item) => item.child.exitCode === 0))
spawnDelay = Math.min(spawnDelay * 2, timing.maxSpawnDelay)
finished.forEach((item) => contenders.delete(item))
return contenders.size === 0 ? failure : undefined
},
/**
* Whether to start another attempt now. A registration that has not answered yet gets one
* spawn delay before an attempt competes with it.
*/
shouldRecruit(registered: boolean) {
if (lastSpawn === 0 && registered) lastSpawn = Date.now()
// Keep one candidate plus one lock probe for pre-lock stalls. After a failure, let the
// survivors finish without recruiting replacements that could hide the error indefinitely.
return failure === undefined && contenders.size < 2 && Date.now() - lastSpawn >= spawnDelay
},
add(contender: ServiceContender) {
contenders.add(contender)
lastSpawn = Date.now()
},
releaseAll() {
contenders.forEach((contender) => contender.release())
},
}
}
function contenderFailure(contender: ServiceContender) {
export function contenderFailure(contender: ServiceContender) {
const error = contender.error()
if (error !== undefined) return error
if (contender.child.exitCode !== null && contender.child.exitCode !== 0)
@@ -1,126 +0,0 @@
import { afterEach, expect, test } from "bun:test"
import {
contenderFinished,
contenderPool,
type ServiceContender,
spawnServiceContender,
} from "../src/service-contender"
import { defaultEnsureTiming } from "../src/service-timing"
const spawned: ServiceContender[] = []
afterEach(() => {
spawned.splice(0).forEach((contender) => {
contender.release()
contender.child.kill("SIGKILL")
})
})
test("remembers the first startup failure and reports it once no attempt is left", async () => {
const pool = contenderPool({ ...defaultEnsureTiming, spawnDelay: 0 })
const survivor = running()
const failed = failing("first failure")
pool.add(survivor)
pool.add(failed)
await finished(failed)
expect(pool.reap()).toBeUndefined()
expect(pool.failure()?.message).toContain("first failure")
expect(pool.shouldRecruit(false)).toBe(false)
survivor.child.kill("SIGKILL")
await finished(survivor)
expect(pool.reap()?.message).toContain("first failure")
})
test("evicting a replaced owner forgets its failure and restarts the spawn clock", async () => {
const pool = contenderPool({ ...defaultEnsureTiming, spawnDelay: 60_000 })
const owner = running()
const failed = failing("old failure")
pool.add(owner)
pool.add(failed)
await finished(failed)
expect(pool.reap()).toBeUndefined()
expect(pool.failure()).toBeDefined()
pool.evict(owner.child.pid!)
expect(pool.failure()).toBeUndefined()
expect(pool.reap()).toBeUndefined()
expect(pool.shouldRecruit(false)).toBe(true)
expect(owner.child.exitCode).toBeNull()
})
test("an unanswered registration gets one spawn delay before an attempt competes", () => {
const timing = { ...defaultEnsureTiming, spawnDelay: 60_000 }
expect(contenderPool(timing).shouldRecruit(false)).toBe(true)
const registered = contenderPool(timing)
expect(registered.shouldRecruit(true)).toBe(false)
registered.recruitNow()
expect(registered.shouldRecruit(true)).toBe(true)
})
test("keeps at most two attempts alive", async () => {
const pool = contenderPool({ ...defaultEnsureTiming, spawnDelay: 0 })
const exiting = clean()
pool.add(running())
pool.add(exiting)
expect(pool.shouldRecruit(false)).toBe(false)
await finished(exiting)
expect(pool.reap()).toBeUndefined()
expect(pool.shouldRecruit(false)).toBe(true)
})
test("clean exits back off up to the maximum spawn delay", async () => {
const pool = contenderPool({ ...defaultEnsureTiming, spawnDelay: 200, maxSpawnDelay: 300 })
const exiting = clean()
pool.add(exiting)
const start = Date.now()
await finished(exiting)
expect(pool.reap()).toBeUndefined()
// The base delay (200 ms) has passed, but the backed-off delay (300 ms) has not, unless a
// loaded machine overslept past it.
await Bun.sleep(Math.max(0, 250 - (Date.now() - start)))
if (Date.now() - start < 290) expect(pool.shouldRecruit(false)).toBe(false)
// Doubling would give 400 ms; the cap allows an attempt at 300 ms.
await Bun.sleep(Math.max(0, 320 - (Date.now() - start)))
expect(pool.shouldRecruit(false)).toBe(true)
})
test("an answering service resets the backoff to the base spawn delay", async () => {
const pool = contenderPool({ ...defaultEnsureTiming, spawnDelay: 200, maxSpawnDelay: 1_000 })
const exiting = clean()
pool.add(exiting)
const start = Date.now()
await finished(exiting)
expect(pool.reap()).toBeUndefined()
pool.serviceAnswered()
// Backed off, the next attempt would wait 400 ms; after the reset it waits the base 200 ms.
await Bun.sleep(Math.max(0, 250 - (Date.now() - start)))
expect(pool.shouldRecruit(false)).toBe(true)
})
function spawn(code: string) {
const contender = spawnServiceContender(process.execPath, ["-e", code])
spawned.push(contender)
return contender
}
function running() {
return spawn("setTimeout(() => {}, 60_000)")
}
function failing(message: string) {
return spawn(`console.error(${JSON.stringify(message)}); process.exit(1)`)
}
function clean() {
return spawn("process.exit(0)")
}
async function finished(contender: ServiceContender) {
while (!contenderFinished(contender)) await Bun.sleep(5)
}
+4 -11
View File
@@ -14,12 +14,7 @@ type Target<ID extends string> = Overlays & {
variants?: (Overlays & { id: ID })[]
}
type Context = {
readonly providerID: string
readonly canonical?: string
readonly modelID?: string
readonly shape?: "responses" | "completions"
}
type Context = { readonly providerID: string; readonly canonical?: string; readonly modelID?: string }
export function rewrite<ID extends string>(
target: Target<ID>,
@@ -131,13 +126,11 @@ export function native(npm: string, context: Context & { readonly settings?: Pro
const host = HOSTS[context.providerID]?.[npm]
if (host) return host
if (npm === "@ai-sdk/amazon-bedrock/mantle") return mantle(context.modelID)
if (npm === "@ai-sdk/azure" && azureChat(context)) return "@opencode/ai/providers/azure/chat"
if (npm === "@ai-sdk/azure" && context.settings?.useCompletionUrls === true)
return "@opencode/ai/providers/azure/chat"
return PACKAGES[npm]
}
const azureChat = (context: Context & { readonly settings?: Provider.Settings }) =>
context.shape === "completions" || context.settings?.useCompletionUrls === true
const mantle = (modelID: string | undefined) => {
if (modelID === undefined) return "@opencode/ai/providers/amazon-bedrock/mantle"
return `@opencode/ai/providers/amazon-bedrock/mantle/${modelID.includes("gpt-oss") ? "chat" : "responses"}`
@@ -148,7 +141,7 @@ function resolve(specifier: string, context: Context & { readonly settings?: Pro
if (Provider.isAISDK(specifier) || npm in PACKAGES || npm in (HOSTS[context.providerID] ?? {}))
return native(npm, context)
if (npm === "@opencode/ai/providers/amazon-bedrock/mantle") return mantle(context.modelID)
if (npm === "@opencode/ai/providers/azure/responses" && azureChat(context))
if (npm === "@opencode/ai/providers/azure/responses" && context.settings?.useCompletionUrls === true)
return "@opencode/ai/providers/azure/chat"
return NATIVE.has(npm) ? npm : undefined
}
+24 -50
View File
@@ -17,7 +17,6 @@ import {
} from "@modelcontextprotocol/client"
import { OAuthMetadataSchema, OpenIdProviderDiscoveryMetadataSchema } from "@modelcontextprotocol/core"
import { Cause, Deferred, Effect } from "effect"
import type { ServerResponse } from "node:http"
import { ConfigMCP } from "@opencode/schema/config/mcp"
import { Credential } from "../credential.js"
import { OauthCallbackPage } from "../oauth/page.js"
@@ -349,7 +348,7 @@ export const authorize = (input: {
const client = clientFromCredential(previous)
if (client) yield* Effect.promise(() => store.saveClientInformation(client))
}
const code = yield* Deferred.make<{ code: string; iss: string | undefined; response: ServerResponse }, Error>()
const code = yield* Deferred.make<{ code: string; iss: string | undefined }, Error>()
const redirect = oauth?.redirect_uri ? new URL(oauth.redirect_uri) : undefined
const redirectPath = redirect?.pathname ?? "/callback"
const state = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString("base64url")
@@ -364,7 +363,7 @@ export const authorize = (input: {
}
const fail = (reason: string, failure: string) => {
runFork(Effect.logWarning("mcp oauth callback rejected", { ...fields, reason: failure }))
Effect.runSync(Deferred.fail(code, new Error(reason)))
Effect.runFork(Deferred.fail(code, new Error(reason)))
response
.writeHead(400, { "Content-Type": "text/html" })
.end(OauthCallbackPage.error(reason, { provider: input.name }))
@@ -374,11 +373,8 @@ export const authorize = (input: {
if (url.searchParams.get("state") !== state) return fail("OAuth state mismatch", "state_mismatch")
const value = url.searchParams.get("code")
if (!value) return fail("Missing authorization code", "missing_code")
// The page waits for the token exchange so the browser never reports success for a rejected code.
if (
!Effect.runSync(Deferred.succeed(code, { code: value, iss: url.searchParams.get("iss") ?? undefined, response }))
)
response.writeHead(409).end("OAuth callback already received")
Effect.runFork(Deferred.succeed(code, { code: value, iss: url.searchParams.get("iss") ?? undefined }))
response.writeHead(200, { "Content-Type": "text/html" }).end(OauthCallbackPage.success({ provider: input.name }))
})
// callback_port, else the port pinned by redirect_uri, else ephemeral; a mismatch strands the browser.
@@ -474,52 +470,30 @@ export const authorize = (input: {
if (!authorizationUrl)
return yield* Effect.fail(new Error(`MCP server "${input.name}" did not provide an authorization URL`))
const completed = yield* Deferred.make<Credential.OAuth, Error>()
yield* Deferred.await(code).pipe(
Effect.flatMap((value) => {
const respond = (error?: string) =>
Effect.sync(() =>
value.response
.writeHead(error ? 400 : 200, { "Content-Type": "text/html" })
.end(
error
? OauthCallbackPage.error(error, { provider: input.name })
: OauthCallbackPage.success({ provider: input.name }),
),
)
return Effect.tryPromise({
try: () =>
auth(oauthProvider, {
serverUrl: input.config.url,
authorizationCode: value.code,
iss: value.iss,
scope: oauth?.scope ?? challenge.scope,
fetchFn,
}),
catch: (error) => (error instanceof Error ? error : new Error(String(error))),
}).pipe(
Effect.flatMap(() => finalize),
Effect.tap(() => respond()),
Effect.tapError((error) => respond(error.message)),
Effect.onInterrupt(() => Effect.sync(() => value.response.destroy())),
)
}),
Effect.onErrorIf(
(cause) => !Cause.hasInterruptsOnly(cause),
(cause) =>
Effect.logWarning("mcp oauth authorization failed", { errors: ErrorSummary.from(Cause.squash(cause)) }),
),
Effect.annotateLogs(fields),
Effect.exit,
Effect.flatMap((exit) => Deferred.done(completed, exit)),
Effect.forkScoped({ startImmediately: true }),
)
return {
url: authorizationUrl.toString(),
instructions: `Authorize ${input.name} in your browser. This window will close automatically.`,
mode: "auto" as const,
callback: Deferred.await(completed),
callback: Deferred.await(code).pipe(
Effect.flatMap((value) =>
Effect.tryPromise({
try: () =>
auth(oauthProvider, {
serverUrl: input.config.url,
authorizationCode: value.code,
iss: value.iss,
scope: oauth?.scope ?? challenge.scope,
fetchFn,
}),
catch: (error) => (error instanceof Error ? error : new Error(String(error))),
}),
),
Effect.flatMap(() => finalize),
Effect.onError((cause) =>
Effect.logWarning("mcp oauth authorization failed", { errors: ErrorSummary.from(Cause.squash(cause)) }),
),
Effect.annotateLogs(fields),
),
}
}).pipe(
Effect.onError((cause) =>
+1 -3
View File
@@ -408,9 +408,7 @@ export const layer = Layer.effect(
Effect.flatMap((model) =>
model && hasPackage(model)
? Effect.succeed(model)
: Effect.map(models.available(), (models) =>
models.find((model) => hasPackage(model) && Model.supportsText(model)),
),
: Effect.map(models.available(), (models) => models.find(hasPackage)),
),
)
if (!selected) return undefined
+1 -8
View File
@@ -261,7 +261,7 @@ const layer = Layer.effect(
const value = yield* read()
const requested = value.data.defaultModel
const model = requested && value.byProvider.get(requested.providerID)?.get(requested.modelID)
return model?.enabled ? model : value.available.find(supportsText)
return model?.enabled ? model : value.available[0]
}),
small: Effect.fn("Model.small")(function* (providerID) {
const value = yield* read()
@@ -282,13 +282,6 @@ const layer = Layer.effect(
export const node = makeLocationNode({ service: Service, layer, deps: [Provider.node, Bus.node, Location.node] })
export function supportsText(model: Pick<Info, "capabilities">) {
return (
(model.capabilities.input.length === 0 || model.capabilities.input.some((item) => item.startsWith("text"))) &&
(model.capabilities.output.length === 0 || model.capabilities.output.some((item) => item.startsWith("text")))
)
}
export function compatibility(input: unknown): Compatibility | undefined {
if (typeof input === "string") return { reasoningField: input }
if (typeof input !== "object" || input === null || Array.isArray(input) || !("field" in input)) return undefined
+4 -15
View File
@@ -60,11 +60,7 @@ type SourceModel = {
>
}
readonly status?: CatalogModelStatus
readonly provider?: {
readonly npm?: string
readonly api?: string
readonly shape?: "responses" | "completions"
}
readonly provider?: { readonly npm?: string; readonly api?: string }
}
type SourceProvider = {
@@ -84,13 +80,7 @@ export type Snapshot = {
function nativePackage(provider: SourceProvider, model?: SourceModel) {
const npm = model?.provider?.npm ?? provider.npm
return (
AISDKNative.native(npm, {
providerID: provider.id,
modelID: model?.id,
shape: model?.provider?.shape,
}) ?? Provider.aisdk(npm)
)
return AISDKNative.native(npm, { providerID: provider.id, modelID: model?.id }) ?? Provider.aisdk(npm)
}
function normalize(input: Record<string, SourceProvider>): readonly Snapshot[] {
@@ -213,12 +203,11 @@ function modelInfo(
} = {},
): Model.Info {
const providerID = Provider.ID.make(provider.id)
const resolved = nativePackage(provider, model)
const pkg = model.provider?.npm || resolved !== nativePackage(provider) ? resolved : undefined
const pkg = model.provider?.npm ? nativePackage(provider, model) : undefined
// Per model, so it never merges into a model that overrides to a different package.
const settings = {
...(model.provider?.api ? { baseURL: model.provider.api } : {}),
...(resolved === "@opencode/ai/providers/openai-compatible" ? { provider: providerID } : {}),
...(nativePackage(provider, model) === "@opencode/ai/providers/openai-compatible" ? { provider: providerID } : {}),
}
return {
id,
+10 -5
View File
@@ -7,13 +7,19 @@ import { Permission } from "../permission.js"
const PROMPT_EXPLORE = `You are a file search specialist. You excel at thoroughly navigating and exploring codebases.
Your strengths:
- Rapidly finding files using glob patterns
- Searching code and text with powerful regex patterns
- Reading and analyzing file contents
Guidelines:
- Your role is EXCLUSIVELY to search and analyze
- Parallelize independent tool calls for searches and reads whenever possible
- Use Glob for broad file pattern matching
- Use Grep for searching file contents with regex
- Use Read when you know the specific file path you need to read
- Adapt your search approach based on the thoroughness level specified by the caller
- Return file paths as absolute paths in your final response
- You MUST NOT create, modify, delete, move, or copy files, including temporary files and reports
- Shell commands MUST be read-only. NEVER run commands that write files or change system state
- For clear communication, avoid using emojis
- Do not create any files, or run bash commands that modify the user's system state in any way
Complete the user's search request efficiently and report your findings clearly.`
@@ -109,7 +115,6 @@ export const Plugin = define({
...Permission.merge(
[
{ action: "*", resource: "*", effect: "deny" },
{ action: "shell", resource: "*", effect: "allow" },
{ action: "grep", resource: "*", effect: "allow" },
{ action: "glob", resource: "*", effect: "allow" },
{ action: "webfetch", resource: "*", effect: "allow" },
-2
View File
@@ -3,7 +3,6 @@ import { Integration } from "@opencode/schema/integration"
import { Provider } from "@opencode/schema/provider"
import { Effect, Stream } from "effect"
import { Bus } from "../bus.js"
import { Model } from "../model.js"
import { ModelsDev } from "../models-dev.js"
// These catalog entries require inference profiles on Bedrock Runtime.
@@ -101,7 +100,6 @@ function snapshots(data: readonly ModelsDev.Snapshot[]) {
models: provider.models.filter(
(model) =>
model.status !== "deprecated" &&
Model.supportsText(model) &&
!(
provider.info.id === Provider.ID.amazonBedrock &&
BEDROCK_PROFILE_ONLY_IDS.includes(model.modelID ?? model.id)
@@ -470,8 +470,7 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Manag
})
function fetchConfig(http: HttpClient.HttpClient, value: Credential.Value) {
// Scoped so responses whose body is never read (404, errors) are released here instead of by a GC-time abort.
return HttpClient.withScope(http)
return http
.execute(
HttpClientRequest.get(`${serverUrl(value)}/api/v2/config`).pipe(
HttpClientRequest.acceptJson,
@@ -499,7 +498,6 @@ function fetchConfig(http: HttpClient.HttpClient, value: Credential.Value) {
)
}),
),
Effect.scoped,
)
}
+10 -19
View File
@@ -210,10 +210,7 @@ export const scanPortable = Effect.fnUntraced(function* (command: string, shell:
catch: (cause) => new Error(`Portable shell scanner failed to load: ${cause}`, { cause }),
})
const powershell = ShellSelect.ps(shell)
const name = ShellSelect.name(shell)
const result = powershell
? ShellScan.scanPowerShell(command)
: ShellScan.scan(command, name === "bash" || name === "zsh" ? name : "posix")
const result = powershell ? ShellScan.scanPowerShell(command) : ShellScan.scan(command)
if (result.kind === "opaque")
return yield* Effect.fail(new Error(`Portable shell scanner cannot analyze command: ${result.reason}`))
@@ -228,21 +225,15 @@ export const scanPortable = Effect.fnUntraced(function* (command: string, shell:
if (CWD.has(name)) {
output.directories.push(
...directoryArgs(
powershell
? words.flatMap((text): Part[] => {
const parameter = /^(-(?:literalpath|path)):(.*)$/i.exec(text)
if (parameter)
return [
{ type: "command_parameter", text: parameter[1] },
{ type: "word", text: parameter[2] },
]
return [{ type: text.startsWith("-") ? "command_parameter" : "word", text }]
})
: item.rawWords.map((text, index) => ({
type: "word",
// Only literal quoting resolves to a static directory.
text: text.startsWith("$'") || (!/[$`~\\]/.test(text) && /['"]/.test(text)) ? item.words[index] : text,
})),
words.flatMap((text): Part[] => {
const parameter = powershell ? /^(-(?:literalpath|path)):(.*)$/i.exec(text) : undefined
if (parameter)
return [
{ type: "command_parameter", text: parameter[1] },
{ type: "word", text: parameter[2] },
]
return [{ type: powershell && text.startsWith("-") ? "command_parameter" : "word", text }]
}),
powershell,
cwd,
shell,
File diff suppressed because it is too large. Load diff
-1
View File
@@ -575,7 +575,6 @@ const PROTOCOLS: Readonly<Record<string, Protocol>> = {
"@opencode/ai/providers/zai-coding-plan/responses": openaiResponses,
"@opencode/ai/providers/openai-compatible": openaiCompatible,
"@opencode/ai/providers/azure/chat": openaiChat,
"@opencode/ai/providers/google-vertex/chat": openaiChat,
"@opencode/ai/providers/alibaba/chat": alibabaChat,
"@opencode/ai/providers/baseten": basetenChat,
-2
View File
@@ -203,8 +203,6 @@ describe("Agent", () => {
expect(Permission.evaluate("external_directory", path.join(global.config, "*"), permissions).effect).toBe("allow")
expect(Permission.evaluate("external_directory", path.join(global.tmp, "*"), permissions).effect).toBe("allow")
const explore = yield* agent.get(Agent.ID.make("explore"))
expect(Permission.evaluate("shell", "git log -5", explore?.permissions ?? []).effect).toBe("allow")
expect(Permission.evaluate("edit", "src/index.ts", explore?.permissions ?? []).effect).toBe("deny")
expect(Permission.evaluate("read", ".env", explore?.permissions ?? []).effect).toBe("ask")
expect(Permission.evaluate("read", ".env.local", explore?.permissions ?? []).effect).toBe("ask")
expect(Permission.evaluate("read", ".env.example", explore?.permissions ?? []).effect).toBe("allow")
-3
View File
@@ -224,9 +224,6 @@ describe("AISDKNative", () => {
const chat = map("@ai-sdk/azure", { ...settings, useCompletionUrls: true }, "custom-deployment")
expect(chat?.package).toBe("@opencode/ai/providers/azure/chat")
expect(chat?.settings).not.toHaveProperty("useCompletionUrls")
expect(AISDKNative.native("@ai-sdk/azure", { providerID: "azure", shape: "completions" })).toBe(
"@opencode/ai/providers/azure/chat",
)
})
test("maps Bedrock provider and request options", () => {
-10
View File
@@ -553,16 +553,6 @@ describe("Provider and Model", () => {
editor.models.update(providerID, Model.ID.make("new"), (model) => {
model.time.released = 2000
})
editor.models.update(providerID, Model.ID.make("newest-video"), (model) => {
model.capabilities.input = ["text", "image"]
model.capabilities.output = ["video"]
model.time.released = 3000
})
editor.models.update(providerID, Model.ID.make("newest-stt"), (model) => {
model.capabilities.input = ["audio"]
model.capabilities.output = ["text"]
model.time.released = 4000
})
})
expect((yield* models.default())?.id).toMatch("new")
-35
View File
@@ -145,7 +145,6 @@ describe("MCP OAuth", () => {
redirect.searchParams.set("code", "accepted")
redirect.searchParams.set("state", state)
expect((yield* Effect.promise(() => fetch(redirect))).status).toBe(200)
expect((yield* Effect.promise(() => fetch(redirect))).status).toBe(409)
return yield* authorization.callback
}),
),
@@ -160,40 +159,6 @@ describe("MCP OAuth", () => {
expect(tokenRequests[0]?.get("resource")).toBe(server.url.href)
})
test("reports a rejected token exchange on the callback page", async () => {
const server = Bun.serve({
port: 0,
fetch(request) {
if (request.method !== "POST" || new URL(request.url).pathname !== "/token")
return new Response(null, { status: 404 })
return Response.json(
{ error: "invalid_client", error_description: "bad_client_secret" },
{ status: 400 },
)
},
})
const result = await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const { authorization, url: authorizationUrl } = yield* start(server, { client_id: "client" })
const redirect = new URL(authorizationUrl.searchParams.get("redirect_uri") ?? "")
redirect.searchParams.set("code", "accepted")
redirect.searchParams.set("state", authorizationUrl.searchParams.get("state") ?? "")
const response = yield* Effect.promise(() => fetch(redirect))
const page = yield* Effect.promise(() => response.text())
const exit = yield* Effect.exit(authorization.callback)
return { status: response.status, page, exit }
}),
),
).finally(() => server.stop(true))
expect(result.status).toBe(400)
expect(result.page).toContain("Authorization failed")
expect(result.page).toContain("bad_client_secret")
expect(result.exit._tag).toBe("Failure")
})
test("refreshes tokens loaded from a persisted credential", async () => {
const tokenRequests: URLSearchParams[] = []
const server = Bun.serve({
-22
View File
@@ -255,23 +255,6 @@ describe("ModelsDev Service", () => {
api: "https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/ai/v1",
models: {},
},
azure: {
id: "azure",
name: "Azure",
env: ["AZURE_RESOURCE_NAME", "AZURE_API_KEY"],
npm: "@ai-sdk/azure",
models: {
"gpt-5": {
...fixture.acme.models["acme-1"],
id: "gpt-5",
},
"custom-chat": {
...fixture.acme.models["acme-1"],
id: "custom-chat",
provider: { shape: "completions" },
},
},
},
})
const state = yield* Ref.make(initialState)
const result = yield* provided(
@@ -283,11 +266,6 @@ describe("ModelsDev Service", () => {
expect(result[0]?.models[0]?.package).toBe("@opencode/ai/providers/openai")
expect(result[1]?.info.package).toBe("@opencode/ai/providers/cloudflare-workers-ai")
expect(result[1]?.info.settings).toBeUndefined()
expect(result[2]?.info.package).toBe("@opencode/ai/providers/azure/responses")
expect(result[2]?.models.find((model) => model.id === "gpt-5")?.package).toBeUndefined()
expect(result[2]?.models.find((model) => model.id === "custom-chat")?.package).toBe(
"@opencode/ai/providers/azure/chat",
)
}),
)
+2 -21
View File
@@ -624,7 +624,7 @@ describe("ModelsDevPlugin", () => {
}),
)
it.effect("omits deprecated and non-text model definitions", () =>
it.effect("omits deprecated model definitions", () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
const providers = yield* Provider.Service
@@ -632,13 +632,11 @@ describe("ModelsDevPlugin", () => {
const providerID = Provider.ID.make("acme")
const activeID = Model.ID.make("current")
const deprecatedID = Model.ID.make("legacy")
const videoID = Model.ID.make("video-gen")
const sttID = Model.ID.make("transcribe")
const model = {
modelID: activeID,
providerID,
name: "Current",
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
capabilities: { tools: true, input: [], output: [] },
variants: [],
time: { released: Date.parse("2026-01-01") },
cost: [],
@@ -664,21 +662,6 @@ describe("ModelsDevPlugin", () => {
name: "Legacy",
status: "deprecated" as const,
},
{
id: videoID,
...model,
modelID: videoID,
name: "Video Gen",
capabilities: { tools: false, input: ["text", "image"], output: ["video"] },
limit: { context: 1_024, output: 0 },
},
{
id: sttID,
...model,
modelID: sttID,
name: "Transcribe",
capabilities: { tools: false, input: ["audio"], output: ["text"] },
},
],
},
] satisfies readonly ModelsDev.Snapshot[]
@@ -701,8 +684,6 @@ describe("ModelsDevPlugin", () => {
yield* activate(providers)
expect(yield* modelState.get(providerID, activeID)).toBeDefined()
expect(yield* modelState.get(providerID, deprecatedID)).toBeUndefined()
expect(yield* modelState.get(providerID, videoID)).toBeUndefined()
expect(yield* modelState.get(providerID, sttID)).toBeUndefined()
}),
)
+2 -286
View File
@@ -1,30 +1,17 @@
import { expect } from "bun:test"
import { cp } from "node:fs/promises"
import path from "node:path"
import { Brand, Cause, Deferred, Effect, Exit, Fiber, Layer, Option, Schedule, Schema, Scope, Stream } from "effect"
import { Agent } from "@opencode/schema/agent"
import { Session } from "@opencode/schema/session"
import { SessionMessage } from "@opencode/schema/session-message"
import { Deferred, Effect, Exit, Fiber, Layer, Schedule, Scope, Stream } from "effect"
import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder"
import { Watcher } from "@opencode/core/filesystem/watcher"
import { Plugin } from "@opencode/core/plugin"
import { PluginModule } from "@opencode/core/plugin/module"
import { Rpc } from "@opencode/core/rpc"
import { Tool } from "@opencode/core/tool"
import { execute } from "@opencode/core/tool/runtime"
import { Global } from "@opencode/util/global"
import { Npm } from "@opencode/util/npm"
import { ensurePluginRuntime } from "../../../cli/src/plugin-runtime"
import { tempGlobalLayer } from "../fixture/global"
import { tmpdirScoped } from "../fixture/tmpdir"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
ensurePluginRuntime()
const it = testEffect(
Layer.mergeAll(
PluginTestLayer,
Layer.merge(
AppNodeBuilder.build(Npm.node, [Global.node.replace(tempGlobalLayer)]),
Watcher.layer().pipe(Layer.provide(Watcher.nativeLayer)),
),
@@ -103,274 +90,3 @@ it.live("interrupts pending watcher setup when the loader scope closes during mo
yield* Effect.promise(() => Bun.sleep(50))
}),
)
it.live("loads plugins and their transitive dependencies against the host's Effect and @opencode/plugin instances", () =>
Effect.gen(function* () {
const directory = yield* tmpdirScoped()
const pluginDir = path.join(directory.path, "plugin")
const pluginEffectDir = path.join(pluginDir, "node_modules/effect")
const hostEffectDir = path.dirname(Bun.resolveSync("effect/package.json", import.meta.dir))
yield* Effect.promise(async () => {
await cp(path.join(hostEffectDir, "dist"), path.join(pluginEffectDir, "dist"), {
recursive: true,
filter: (src) => !src.endsWith(".d.ts") && !src.endsWith(".map") && !/httpApi(?:Scalar|Swagger)\.js$/.test(src),
})
const pkg = { ...(await Bun.file(path.join(hostEffectDir, "package.json")).json()), version: "4.0.0-rc.111" }
await Bun.write(path.join(pluginEffectDir, "package.json"), JSON.stringify(pkg))
// Sabotage the plugin's own Effect copy with the version-skew failure modes so loading it would crash:
// 1. Effect.log reading an incompatible fiber log-level property (crashing host logger with logLevel.toUpperCase)
// 2. Effect.runPromise calling fiber.succeedWith on a host fiber
// 3. Schema.withDecodingDefault / Schema.Int / Schema.isPattern / Schema.Trim using foreign parser sentinels
const internalEffectPath = path.join(pluginEffectDir, "dist/internal/effect.js")
await Bun.write(
internalEffectPath,
(await Bun.file(internalEffectPath).text())
.replace(
"const logLevel = level ?? fiber.currentLogLevel;\n if (isLogLevelGreaterThan(fiber.minimumLogLevel, logLevel)) {",
"const logLevel = level ?? fiber.cache?.logLevel;\n if (isLogLevelGreaterThan(fiber.cache?.minimumLogLevel, logLevel)) {",
)
.replace(
"const runPromiseExit = runPromiseExitWith(context);",
"if (true) return (effect) => Promise.resolve().then(() => { const fiber = {}; return fiber.succeedWith(effect); });\n const runPromiseExit = runPromiseExitWith(context);",
),
)
const depDir = path.join(pluginDir, "node_modules/transitive-dep")
await Bun.write(
path.join(depDir, "package.json"),
'{"name":"transitive-dep","type":"module","exports":{".":"./index.js"}}',
)
await Bun.write(
path.join(depDir, "index.js"),
`import { Effect, Schema } from "effect"
import { some } from "effect/Option"
export const depToolInput = Schema.Struct({
mode: Schema.String.pipe(Schema.withDecodingDefault(Effect.succeed("from-dep"))),
count: Schema.Int,
code: Schema.Trim.check(Schema.isPattern(/^v[0-9]+$/)),
})
export const depCaptured = { Effect, Schema, some }`,
)
const foreignPluginPkgDir = path.join(pluginDir, "node_modules/@opencode/plugin")
await Bun.write(
path.join(foreignPluginPkgDir, "package.json"),
'{"name":"@opencode/plugin","type":"module","exports":{"./effect":"./effect.js","./rpc":"./rpc.js"}}',
)
await Bun.write(path.join(foreignPluginPkgDir, "effect.js"), "export const Plugin = { define: (p) => p }")
await Bun.write(path.join(foreignPluginPkgDir, "rpc.js"), "export const Rpc = { define: (d) => d }")
await Bun.write(
path.join(pluginDir, "index.ts"),
`import { Plugin } from "@opencode/plugin/effect"
import { Rpc } from "@opencode/plugin/rpc"
import { Effect, Schema } from "effect"
import { some } from "effect/Option"
import { nominal } from "effect/Brand"
import { depCaptured, depToolInput } from "transitive-dep"
export const captured = {
plugin: { Effect, Schema, some, nominal },
dep: depCaptured,
pluginCount: -1,
}
const Contract = Rpc.define({
id: "host-effect-rpc",
methods: {
check: {
input: Schema.Struct({
count: Schema.Int.pipe(Schema.withDecodingDefault(Effect.succeed(5))),
tag: Schema.Trim.check(Schema.isPattern(/^v[0-9]+$/)),
}),
output: Schema.Struct({ value: Schema.String }),
},
},
events: {},
})
export default Plugin.define({
id: "host-effect-fixture",
effect: (ctx) =>
Effect.gen(function* () {
yield* Effect.log("setup log from plugin")
const listed = yield* Effect.promise(() =>
Effect.runPromise(ctx.plugin.list().pipe(Effect.orDie)),
)
captured.pluginCount = listed.data.length
yield* ctx.tool.transform((editor) => {
editor.add({
name: "check_tool",
description: "Tool with decoding default from transitive dependency and Int/Trim/isPattern checks",
input: depToolInput,
output: Schema.Struct({ formatted: Schema.String }),
execute: ({ mode, count, code }) =>
Effect.log("executing check_tool").pipe(
Effect.as({
output: { formatted: \`\${mode}:\${code}:\${count}\` },
content: \`\${mode}:\${code}:\${count}\`,
}),
),
})
})
yield* ctx.rpc.register(Contract, {
check: ({ count, tag }) =>
Effect.log("executing rpc check").pipe(
Effect.as({ value: \`\${tag}#\${count}\` }),
),
}).pipe(Effect.orDie)
}),
})`,
)
})
const modules = yield* PluginModule.make()
const plugins = yield* Plugin.Service
const tools = yield* Tool.Service
const rpc = yield* Rpc.Service
const definition = yield* modules.load({ type: "add", target: pluginDir, options: {} })
if ("pending" in definition) return yield* Effect.die(new Error("Local plugin was not loaded"))
yield* plugins.activate([definition])
yield* plugins.awaitActivation
expect(yield* plugins.list()).toMatchObject([{ id: "host-effect-fixture", state: { status: "active" } }])
const imported = yield* Effect.promise(() => import(path.join(pluginDir, "index.ts")))
expect(imported.captured.plugin.Effect).toBe(Effect)
expect(imported.captured.plugin.Schema).toBe(Schema)
expect(imported.captured.plugin.some).toBe(Option.some)
expect(imported.captured.plugin.nominal).toBe(Brand.nominal)
expect(imported.captured.dep.Effect).toBe(Effect)
expect(imported.captured.dep.Schema).toBe(Schema)
expect(imported.captured.dep.some).toBe(Option.some)
expect(imported.captured.pluginCount).toBe(0)
const checkTool = (yield* tools.list()).find((tool) => tool.id === "check_tool")
expect(checkTool).toBeDefined()
if (!checkTool) return
const context = {
sessionID: Session.ID.make("ses_host_effect"),
agent: Agent.ID.make("build"),
messageID: SessionMessage.ID.make("msg_host_effect"),
id: Tool.CallID.make("call_host_effect"),
progress: () => Effect.void,
}
expect(yield* execute(checkTool, { count: 3, code: " v42 " }, context)).toEqual({
output: { formatted: "from-dep:v42:3" },
content: [{ type: "text", text: "from-dep:v42:3" }],
})
expect(yield* rpc.call("host-effect-rpc", "check", { tag: " v9 " })).toEqual({ value: "v9#5" })
}),
)
it.live("fails loudly at load when a plugin imports an unprovided effect/* subpath from its own node_modules, while allowing effect/package.json", () =>
Effect.gen(function* () {
const directory = yield* tmpdirScoped()
const okDir = path.join(directory.path, "pkg-json-plugin")
const badDir = path.join(directory.path, "removed-subpath-plugin")
yield* Effect.promise(async () => {
await Bun.write(
path.join(okDir, "node_modules/effect/package.json"),
'{"name":"effect","version":"4.0.0-rc.111","type":"module","exports":{"./package.json":"./package.json"}}',
)
await Bun.write(
path.join(okDir, "index.ts"),
`import pkg from "effect/package.json" with { type: "json" }
export const effectPkgName = pkg.name
export default { id: "pkg-json-plugin", async setup() {} }`,
)
await Bun.write(
path.join(badDir, "node_modules/effect/package.json"),
'{"name":"effect","type":"module","exports":{"./RemovedLegacySubpath":"./RemovedLegacySubpath.js"}}',
)
await Bun.write(path.join(badDir, "node_modules/effect/RemovedLegacySubpath.js"), "export const legacy = true")
await Bun.write(
path.join(badDir, "index.ts"),
`import { legacy } from "effect/RemovedLegacySubpath"
export default { id: "removed-subpath", async setup() { void legacy } }`,
)
})
const modules = yield* PluginModule.make()
expect(yield* modules.load({ type: "add", target: okDir, options: {} })).toMatchObject({ id: "pkg-json-plugin" })
const exit = yield* modules.load({ type: "add", target: badDir, options: {} }).pipe(Effect.exit)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) {
expect(String(Cause.squash(exit.cause))).toContain("effect/RemovedLegacySubpath.js")
}
}),
)
it.live("redirects plugin dependencies with a nested Effect 3 installation to the host's Effect instance", () =>
Effect.gen(function* () {
const directory = yield* tmpdirScoped()
const pluginDir = path.join(directory.path, "v3-dep-plugin")
const v3DepDir = path.join(pluginDir, "node_modules/v3-dep")
const nestedEffect3Dir = path.join(v3DepDir, "node_modules/effect")
yield* Effect.promise(async () => {
await Bun.write(
path.join(nestedEffect3Dir, "package.json"),
'{"name":"effect","version":"3.19.19","type":"module","exports":{".":"./index.js","./Option":"./Option.js","./ReadonlyArray":"./ReadonlyArray.js"}}',
)
await Bun.write(
path.join(nestedEffect3Dir, "index.js"),
"export const Effect = { major: 3 }; export const Schema = { major: 3 }",
)
await Bun.write(path.join(nestedEffect3Dir, "Option.js"), "export const some = () => ({ major: 3 })")
await Bun.write(path.join(nestedEffect3Dir, "ReadonlyArray.js"), "export const fromIterable = () => []")
await Bun.write(
path.join(v3DepDir, "package.json"),
'{"name":"v3-dep","type":"module","exports":{".":"./index.js","./v3-only":"./v3-only.js"}}',
)
await Bun.write(
path.join(v3DepDir, "index.js"),
`import { Effect, Schema } from "effect"
import { some } from "effect/Option"
export const v3DepCaptured = { Effect, Schema, some }`,
)
await Bun.write(
path.join(v3DepDir, "v3-only.js"),
`import { fromIterable } from "effect/ReadonlyArray"
export { fromIterable }`,
)
await Bun.write(
path.join(pluginDir, "index.ts"),
`import { v3DepCaptured } from "v3-dep"
export { v3DepCaptured }
export default { id: "v3-dep-plugin", async setup() {} }`,
)
})
const modules = yield* PluginModule.make()
expect(yield* modules.load({ type: "add", target: pluginDir, options: {} })).toMatchObject({ id: "v3-dep-plugin" })
const imported = yield* Effect.promise(() => import(path.join(pluginDir, "index.ts")))
expect(imported.v3DepCaptured.Effect).toBe(Effect)
expect(imported.v3DepCaptured.Schema).toBe(Schema)
expect(imported.v3DepCaptured.some).toBe(Option.some)
const v3OnlyExit = yield* Effect.promise(() =>
import(path.join(v3DepDir, "v3-only.js")).then(
() => ({ ok: true as const }),
(error: unknown) => ({ ok: false as const, error: String(error) }),
),
)
expect(v3OnlyExit.ok).toBe(false)
if (!v3OnlyExit.ok) {
expect(v3OnlyExit.error).toContain("effect/ReadonlyArray.js")
}
}),
)
@@ -1072,30 +1072,6 @@ describe("OpencodePlugin", () => {
),
)
it.effect("releases unread Console config responses when the fetch completes", () =>
Effect.gen(function* () {
const credentials = yield* Credential.Service
const signals: AbortSignal[] = []
const state = { status: 404 }
const http = HttpClient.make((request, _url, signal) => {
signals.push(signal)
return Effect.succeed(HttpClientResponse.fromWeb(request, new Response("unavailable", state)))
})
yield* credentials.create({
integrationID: Integration.ID.make("opencode"),
value: Credential.Key.make({ type: "key", key: "secret", metadata: { server: "https://console.test" } }),
})
yield* addPlugin().pipe(Effect.provideService(HttpClient.HttpClient, http))
yield* drain
state.status = 503
yield* TestClock.adjust("1 minute")
yield* drain
// An unreleased response is only aborted when garbage collected, which fails outside a request on workerd.
expect(signals.map((signal) => signal.aborted)).toEqual([true, true])
}),
)
it.effect("reports a rejected Console refresh token as signed out without removing the credential", () =>
Effect.acquireUseRelease(
Effect.sync(() =>
@@ -42,21 +42,6 @@ describe("ShellScan adversarial corpus", () => {
expect(result.commands.map((command) => command.words[0])).toEqual([...names])
})
// Only shell sinks evaluate subscripts; ordinary arguments, heredoc bodies, and bound data without a sink are safe.
test.each([
"bun -e 'f(`a[${x}]`)'",
"rg 'a[$(x)]'",
"cat <<'EOF'\na[$(x)]\nEOF",
"echo 'a[$(x)]'",
'BODY="- [x] Fix \\`scan.ts\\`"; gh pr create --title "fix" --body "$BODY"',
"MSG='[feat] Fix `foo`'; git commit -m \"$MSG\"",
"PATTERN='a[${b}]'; rg \"$PATTERN\"",
"export REGEX='[0-9]+${foo}'",
"cat <<< 'const a = arr[0]; const b = `${a}`'",
])("scans subscript-shaped text outside shell sinks: %s", (input) => {
expect(ShellScan.scan(input).kind).toBe("scanned")
})
test.each(['printf "unterminated', "printf ok &&", "printf ok >", "echo > >out"])(
"keeps structurally uncertain Bash input opaque: %s",
(input) => {
@@ -64,19 +49,6 @@ describe("ShellScan adversarial corpus", () => {
},
)
test.each([
["repeated assignment value operators", "x[a]" + "=]".repeat(32_000)],
["unclosed assignment subscripts", "a[\n".repeat(21_000)],
["case patterns with substitutions", "case x in " + "[$(:)".repeat(10_000)],
["nested groups with bracket words", "{ ".repeat(31) + "echo " + "a[] ".repeat(15_000) + "; }".repeat(31)],
["continued heredoc lines", "cat <<E\n" + "x\\\n".repeat(20_000) + "E\n"],
["heredoc backslash runs", "cat <<E\n" + "\\".repeat(60_000) + "x\nE\n"],
])("scans adversarial Bash input in bounded time: %s", (_, input) => {
const start = performance.now()
ShellScan.scan(input)
expect(performance.now() - start).toBeLessThan(500)
})
test.each([
['pwsh --command "Remove-Item victim.txt"', ["pwsh"]],
["Import-Module ./evil.psm1", ["Import-Module"]],
@@ -60,41 +60,23 @@ const fixtures = [
["! scan_probe", ["scan_probe"]],
["time scan_probe", ["time"]],
["{fd}>/dev/null scan_probe", ["scan_probe"]],
["case $r in a) ls | head;; esac", ["ls", "head"]],
["case $r in a) ls && echo;; esac", ["ls", "echo"]],
["{ find . -exec echo {} \\; ; }", ["find"]],
["{ echo {a,{b,c}}; }", ["echo"]],
["if true; then\\\n echo hi; fi", ["true", "echo"]],
["for ((i=0; i<2; i++)) do echo hi; done", ["echo"]],
['echo "$(case x in @(a)) echo hi;; esac)"', ["echo", "echo"]],
["set -- 1; for x do scan_probe; done", ["set", "scan_probe"]],
["'q'; x=1 a", ["q", "a"]],
["cat <<E; ( true\nscan_ignored\nE\n)", ["cat", "true"]],
["cat <<E; f() { true\nscan_ignored\nE\n}; f", ["cat", "true", "f"]],
["cat <<E; case x in\nscan_ignored\nE\nx) scan_probe;; esac", ["cat", "scan_probe"]],
["time [[ ( -f foo ) ]]", []],
["time ! { echo a; echo b; }", ["echo", "echo"]],
] as const
describe("ordinary Bash and Zsh syntax", () => {
test.each(fixtures)("extracts actual command nodes: %s", (source, names) => {
for (const dialect of ["bash", "zsh"] as const) {
const result = ShellScan.scan(source, dialect)
expect(result.kind).toBe("scanned")
if (result.kind !== "scanned") throw new Error(result.reason)
expect(result.commands.map((command) => command.words[0])).toEqual([...names])
}
const result = ShellScan.scan(source)
expect(result.kind).toBe("scanned")
if (result.kind !== "scanned") throw new Error(result.reason)
expect(result.commands.map((command) => command.words[0])).toEqual([...names])
})
for (const shell of ["bash", "zsh"]) {
const executable = Bun.which(shell)
for (const [source] of fixtures) {
// These are Bash spellings; Zsh's fd allocation is a standalone statement. Bash parses extglob
// patterns only when extglob is enabled.
// These are Bash spellings; Zsh's fd allocation is a standalone statement.
test.skipIf(
!executable ||
(shell === "zsh" && (source.includes('$"') || source.startsWith("{fd}") || source.includes("$["))) ||
(shell === "bash" && source.includes("@(")),
(shell === "zsh" && (source.includes('$"') || source.startsWith("{fd}") || source.includes("$["))),
)(`${shell} accepts the source grammar: ${source}`, () => {
const result = Bun.spawnSync([
executable ?? shell,
@@ -150,15 +132,10 @@ describe("ordinary Bash and Zsh syntax", () => {
"cat <<EOF\nunclosed",
"echo ${missing",
"echo $'missing",
"case $r in a) ls |;; esac",
])("rejects incomplete syntax: %s", (source) => {
expect(ShellScan.scan(source).kind).toBe("opaque")
})
test("scans a Zsh brace group closed after a redirect", () => {
expect(ShellScan.scan("{ a && >f }")).toMatchObject({ kind: "scanned", commands: [{ words: ["a"] }] })
})
test("preserves raw lexical spelling of ANSI-C and locale quoted words", () => {
expect(ShellScan.scan("$'pri\\x6etf' $'line\\n' $\"text\"")).toMatchObject({
kind: "scanned",
@@ -168,7 +145,7 @@ describe("ordinary Bash and Zsh syntax", () => {
test.each([
["coproc job { scan_probe; }", ["scan_probe"]],
["case x in @(one|$(scan_probe))) ;; esac", ["scan_probe"]],
["printf '%s' @(one|$(scan_probe))", ["printf", "scan_probe"]],
["printf '%s' $((1 + '$(scan_probe)'))", ["printf", "scan_probe"]],
["printf '%s' $(((1 + '$(scan_probe)')))", ["printf", "scan_probe"]],
['printf %s "${ scan_probe; }"', ["printf", "scan_probe"]],
@@ -264,56 +241,3 @@ describe("Bash shared heredoc delimiter grammar", () => {
)
})
})
describe("Bash dialects", () => {
const heads = (source: string, dialect?: ShellScan.Dialect) => {
const result = ShellScan.scan(source, dialect)
return result.kind === "scanned" ? result.commands.map((command) => command.words[0]) : result.kind
}
test("posix reports both readings of a double parenthesis", () => {
expect(heads("(( x = 1 )); y", "bash")).toEqual(["y"])
expect(heads("(( x = 1 )); y", "zsh")).toEqual(["y"])
expect(heads("(( x = 1 )); y", "posix")).toEqual(["x", "y"])
expect(heads("(( x = 1 )); y")).toEqual(["x", "y"])
expect(heads("(( (1) + (2) ))", "bash")).toEqual([])
expect(heads("(( (1) + (2) ))", "posix")).toBe("opaque")
})
test.each([
["true &>/dev/null next", ["true"], "opaque"],
["X=$[1 + 2] next", ["next"], "opaque"],
] as const)("reads Bash and Zsh syntax precisely where Dash diverges: %s", (source, precise, posix) => {
expect(heads(source, "bash")).toEqual([...precise])
expect(heads(source, "zsh")).toEqual([...precise])
expect(heads(source, "posix")).toEqual(posix)
})
test("reads a reserved word after a redirect as a Zsh keyword", () => {
const source = "if true; then >/dev/null fi; next"
expect(heads(source, "zsh")).toEqual(["true", "next"])
expect(heads(source, "bash")).toBe("opaque")
expect(heads(source, "posix")).toBe("opaque")
})
test("scans safe Zsh parameter flags, Zsh repeat loops, and Bash extglob arguments", () => {
expect(heads("print -l ${(M)files:#*.ts}", "zsh")).toEqual(["print"])
expect(heads("print -l ${(ps:\\n:)text}", "zsh")).toEqual(["print"])
expect(heads("print -r -- ${(q-)x} ${(q+)x} ${(on-)x}", "zsh")).toEqual(["print"])
expect(heads("repeat 3; do echo hi; done", "zsh")).toEqual(["echo"])
expect(heads("repeat 3; do echo hi; done", "posix")).toEqual(["echo"])
expect(heads("f() repeat 3; do echo hi; done", "zsh")).toEqual(["echo"])
expect(heads("ls @(foo|bar)", "bash")).toEqual(["ls"])
})
test.each([
["/bin/bash", ["y"]],
["/usr/local/bin/zsh", ["y"]],
["/bin/sh", ["x = 1", "y"]],
["/bin/dash", ["x = 1", "y"]],
["/opt/bin/mksh", ["x = 1", "y"]],
] as const)("derives the dialect from the shell executable: %s", async (shell, resources) => {
const result = await Effect.runPromise(ShellParse.scanPortable("(( x = 1 )); y", shell, "/workspace"))
expect(result.commands.map((command) => command.resource)).toEqual([...resources])
})
})
@@ -1,751 +0,0 @@
import { afterAll, describe, expect, test } from "bun:test"
import { Effect, Exit } from "effect"
import fs from "fs"
import os from "os"
import path from "path"
import { ShellParse } from "../../src/shell/parse.js"
import { ShellScan } from "../../src/shell/scan.js"
const shellCandidates = ["/bin/bash", "/opt/homebrew/bin/bash", "/usr/local/bin/bash", "bash", "zsh", "dash"]
const shells = [
...new Set(
shellCandidates
.map((item) => (item.startsWith("/") ? (fs.existsSync(item) ? item : undefined) : Bun.which(item)))
.filter((item): item is string => Boolean(item)),
),
]
// Fixtures target specific shells and versions (bash 3.2 and 5.3, zsh, dash). Set SHELL_ORACLE_STRICT=1 on a
// machine with all of them to also verify that every fixture still executes somewhere.
const strict = process.env.SHELL_ORACLE_STRICT === "1"
const root = fs.mkdtempSync(path.join(os.tmpdir(), "opencode-shell-oracle-"))
const bin = path.join(root, "bin")
const target = path.join(root, "target")
const log = path.join(root, "log")
fs.mkdirSync(bin)
fs.mkdirSync(target)
fs.writeFileSync(
path.join(bin, "scan_probe"),
'#!/bin/sh\nprintf \'%s\\n\' "scan_probe${1:+ $*}" >> "$SCAN_PROBE_LOG"\n',
{
mode: 0o755,
},
)
// Dash runs `scan_probe[x y]=1` as the command `scan_probe[x`.
fs.copyFileSync(path.join(bin, "scan_probe"), path.join(bin, "scan_probe[x"))
// Zsh treats a trailing parenthesized group after an existing file name as glob qualifiers.
fs.writeFileSync(path.join(root, "a="), "")
afterAll(() => fs.rmSync(root, { recursive: true, force: true }))
function shellFlags(executable: string) {
if (executable.endsWith("/bash")) return ["--noprofile", "--norc"]
if (executable.endsWith("/zsh")) return ["-f"]
return []
}
// Runs source in a real shell and returns the lines it logged.
function observe(executable: string, source: string) {
fs.writeFileSync(log, "")
Bun.spawnSync([executable, ...shellFlags(executable), "-c", source], {
cwd: root,
env: { PATH: `${bin}:/usr/bin:/bin`, HOME: root, LC_ALL: "C", SCAN_PROBE_LOG: log },
timeout: 2_000,
})
return fs
.readFileSync(log, "utf8")
.split("\n")
.map((line) => line.trim())
.filter(Boolean)
}
// Each dialect must report what its shells run; posix, the default, covers every shell.
const dialects = {
bash: (executable: string) => path.basename(executable) === "bash",
zsh: (executable: string) => path.basename(executable) === "zsh",
posix: () => true,
} satisfies Record<ShellScan.Dialect, (executable: string) => boolean>
function expectProbesReported(source: string) {
const runs = shells.map((executable) => [executable, observe(executable, source)] as const)
if (strict)
expect(
runs.some(([, invocations]) => invocations.length > 0),
`Fixture never executed scan_probe in any real shell: ${source}`,
).toBe(true)
expect(ShellScan.scan(source)).toEqual(ShellScan.scan(source, "posix"))
for (const [dialect, runsIn] of Object.entries(dialects)) {
const result = ShellScan.scan(source, dialect as ShellScan.Dialect)
if (result.kind === "opaque") continue
const reported = result.commands.filter((command) => !command.declaration).map((command) => command.words.join(" "))
for (const [executable, invocations] of runs.filter(([executable]) => runsIn(executable)))
for (const invocation of invocations)
expect(reported, `${executable} executed ${invocation} with ${dialect} in: ${source}`).toContain(invocation)
}
}
// Each fixture runs scan_probe in at least one real shell, which the scanner must report or reject.
const fixtures = [
"a=(1)scan_probe",
"a=(\n1)scan_probe; ",
"a=(1)'scan_probe'",
"alias p=scan_probe\np",
"command alias p=scan_probe\np",
"x='*(e:scan_probe:)'; echo $^~x",
"x='$(scan_probe)'; echo \"${\\\n(e)x}\"",
"x='$(scan_probe)'; echo ${(j:):e)x}",
"x='*(e:scan_probe:)'; echo ${(f)~x}",
"x='*(e:scan_probe:)'; echo ${=~x}",
"x='*(e:scan_probe:)'; echo $=~x",
"x='$(scan_probe)'; echo ${(%e)x}",
"x='$(scan_probe)'; echo ${(s.:.e)x}",
"x='$(scan_probe)'; echo ${(s(:)e)x}",
"x='$(scan_probe)'; echo ${(ej: :)x}",
"x='*(e:scan_probe:)'; echo ${(s: :)~x}",
"x='*(e:scan_probe:)'; echo ${^~x}",
"printf -v x 'a[$(scan_probe)]'; echo $((x))",
"a=(1); getopts a: x -a 'a[$(scan_probe)]'; echo $((OPTARG))",
"command -- declare 'a[$(scan_probe)]=1'",
"a=(1); unset 'a[b[$\\\n(scan_probe)0]]'",
'declare \'a["\\"]"$(scan_probe)0]=1\'',
"declare -a 'a=(+ [$(scan_probe)]=1)'",
"a=(1); echo ${a[b[\\$(scan_probe)]]}",
'a["b[\\$(scan_probe)]"]=1',
'b=1; a["b[\\$(scan_probe)1]"]=1',
"a=(1); echo $[ ${x:-a[\\$(scan_probe)1]} ]",
"a=(1); s=abc; echo ${s:'a[$(scan_probe)0]'}",
"a=(1); s=abc; echo ${s:${x:-'a[$(scan_probe)1]'}}",
"read x <<< 'a[$(scan_probe f)]'; echo $((x))",
"mapfile -t a <<< 'b[$(scan_probe g)]'; echo $((a[0]))",
"x=1; echo $(( x[\\$(scan_probe i)] ))",
"a=(1); echo ${a[@]:'a[$(scan_probe k)]'}",
"builtin -- declare 'a[$(scan_probe)]=1'",
"command -p declare 'a[$(scan_probe)]=1'",
'echo "${unset:+${x[\'"\']}}"]}} \'$(scan_probe)\' " # "',
"echo \"${unset:+${x['}}\"']}}'; scan_probe # \"",
"echo \"${x:-$'\\''}\"; scan_probe # '\"",
"cat <<E\n${x:-'}'}' $(scan_probe)\nE",
'echo "${x:-a\'b}c\'d}"$(scan_probe)"\'"',
"echo \"${PATH//:/$'\\n'}\"; scan_probe ok",
"echo $(( : # ))'\n); scan_probe ) # '",
"echo $(( $(echo 1) # ))'\n); scan_probe ) # '",
"(( : # ))'\n); scan_probe ) # '",
"echo $(( 16#ff + 2#1 + $# + ${#x} )); scan_probe ok",
"(( scan_probe ))",
"(( (scan_probe) & (scan_probe) ))",
"(( (scan_probe)\n(scan_probe) ))",
"cat <<'}'; {\n:\n}\nscan_probe; cat <<'}'; }\n}",
"cat <<'x)'; case x in\nx)\nx) scan_probe; cat <<'x)'\nx)\n;; esac",
"cat <<'if'; f()\nif\nif scan_probe; cat <<'if'\nif\ntrue; then :; fi; f",
"cat <<E; ( true\nE\nscan_probe g\n)",
"cat <<E; { true\nE\nscan_probe g\n}",
"cat <<E; f() { true\nE\nscan_probe f\n}; f",
"cat <<E; if true\nE\nscan_probe i\nthen :; fi",
"cat <<E; echo $(true\nscan_probe s\n)\nE\nscan_probe after",
"cat <<E; echo `true\nscan_probe b\n`\nE\nscan_probe after",
"cat <<E; cat <(true\nscan_probe s\n)\nE\nscan_probe after",
"cat <<E; x=$(cat <<F\nF\n)\nE\nscan_probe out",
"{ cat <<E; }\nscan_probe x\nE\nscan_probe y",
"[[ a]]b# ]] && scan_probe",
'[[ "a]]"# ]] && scan_probe',
"[[ 'a]]'# ]] && scan_probe",
"[[ a]]b = a]]b ]] && scan_probe",
'{ export X="a"{}# ; scan_probe; }',
"{ export X=a}# ; scan_probe; }",
"{ case esac in (esac) scan_probe;; esac\n}",
"unset a[b\nscan_probe\necho ]+=1",
"export a[b; scan_probe; echo ]=1",
"a[b; scan_probe; echo ]=1",
"a[0 #]\n]=1; scan_probe",
"for i in 1; { scan_probe; }; while false; do export X=1; done",
"for ((i=0; i<1; i++)); { scan_probe; }; while false; do export X=1; done",
"for i in 1; scan_probe",
"((( echo '\"' ); scan_probe; ( echo '\"' )))",
"set=1; export X=${set:-${x['0\"0']}}; scan_probe; : '\"]}}' # '",
"export X=$$$$'\\'; scan_probe # '",
'export X="`export Y=\\"\'\\" ; scan_probe; export Z=\\"\'\\"`"',
'export X="`echo \\"(\\"; scan_probe; echo \\")\\"`"',
": <<-export\n\tex\\\n\tport\n$(scan_probe)\nexport",
'export X="${\\\n scan_probe; }"',
'export X="${\n scan_probe; }"',
'export X="${|\\\n REPLY=$(scan_probe); }"',
"!(scan_probe; true)",
"if !(scan_probe); then :; fi",
"f+() { scan_probe; }; f+",
': <<$"export"\n$export\nscan_probe\nexport',
': <<$"export"\nexport\nscan_probe\n$export',
"export X=$[1; scan_probe; : ]",
"X=$[1 scan_probe ]",
'export X=$["]"]; scan_probe # ]',
"export X=${#}# ; scan_probe",
"export X=$#a# ; scan_probe",
"export X=~+# ; scan_probe",
"{ export X=1; }# ; scan_probe; }",
"if true; then export X=1; fi# ; scan_probe; fi",
"for i in 1; do export X=1; done# ; scan_probe; done",
"case esac# in x) export X=1;; esac#) scan_probe;; esac",
": <<A <<B\nA\n$(scan_probe)\nB",
'export X="$(cat <<EOF\n)\nEOF\nscan_probe)"',
"cat <<EOF | scan_probe\nhello\nEOF",
"cat <<EOF && scan_probe\nhello\nEOF",
"cat <<'E'\"O\"F\n# $(scan_probe)\nEOF\nscan_probe",
"cat <<E\\\nOF\n$(scan_probe)\nEOF",
"cat <<-EOF\n\t EOF\n$(scan_probe)\n\tEOF",
"cat <<export\nex\\\nport\n# $(scan_probe)\nexport",
"cat <<< 'a'\"$(scan_probe)\"",
'export X=${x:-"}"}; scan_probe',
"export X=${x:-'}'}; scan_probe",
'export X="${x:-"}"}"; scan_probe',
'export X="${x:-"$(scan_probe)"}"',
"export X=${x//\\}/}; scan_probe; : }",
'export X="${x//\\}/}"; scan_probe; : "}"',
"export X=${x#${y}}; scan_probe",
'export X="${x#${y:-"}"}}"; scan_probe',
"export X=${!prefix*}; scan_probe",
"export X=${x@Q}; scan_probe",
"export X=\"${unset:+${x['\"']}}\"; scan_probe; : '\"}]}'",
'export X="${unset:+${x[\'}}\"; scan_probe; : \"\']}}"',
'echo $(case x in x) echo ")" ;; esac; scan_probe)',
"echo \"$(case x in (x) echo ')' ;; esac; scan_probe)\"",
'echo "`echo \\"$(scan_probe)\\"`"',
"echo `echo \\`scan_probe\\``",
"echo $( (echo a); scan_probe )",
"echo $((echo a); scan_probe)",
"if true; then scan_probe; elif true; then :; else :; fi",
"for i in in do done esac; do scan_probe; done",
"for do in 1; do scan_probe; done",
"for in in 1; do scan_probe; done",
"for x\nin 1; do scan_probe; done",
"set -- 1; for x\ndo scan_probe; done",
"set -- 1; for x do scan_probe; done",
"case in in in) scan_probe;; esac",
"case esac in a|esac) scan_probe;; esac",
"case y in x) ;; y) scan_probe;; esac",
"case x in x) scan_probe ;& y) : ;;& z) : ;; esac",
"case x in x) scan_probe ;| y) : ;; esac",
"case [ in [) scan_probe & ( scan_probe q ]) ;; esac",
"case x in (x|[) scan_probe & ( scan_probe q ]) ;; esac",
"while false; do :; done & scan_probe",
"{ scan_probe & }",
"( scan_probe & )",
"[[ b =~ b ]] && scan_probe",
"[[ ( a == a ) && ( b == b ) ]] && scan_probe",
'[[ "$(scan_probe)" == "]]" ]]',
"(( 1 + $(scan_probe) ))",
"(( a = 1 )) && scan_probe",
"(( (1) + (2) )); scan_probe",
"(( (echo a); scan_probe ))",
">/dev/null scan_probe",
"2>&1 scan_probe",
"A=1 >/dev/null B=2 scan_probe",
'export A=1 B="$(scan_probe)"',
'declare -a arr=(1 "$(scan_probe)")',
"export X=$'a'\\\n; scan_probe",
"case x in \\\nx) \\\nscan_probe;; \\\nesac",
"if true; then \\\nscan_probe; fi",
'() { :; } "$(scan_probe)"',
"function f() ( scan_probe ); f",
"{ export X=1 } && scan_probe",
"{ export X=1 } ; scan_probe ; }",
// Dialects disagree about reserved words after redirects and operators split by line continuations.
"if true; then >/dev/null fi; scan_probe; fi",
"if true; then >/dev/null fi; scan_probe",
"case x in x) >/dev/null esac; scan_probe; esac",
"true &>/dev/null scan_probe",
"true &>>/dev/null scan_probe",
"true &\\\n>/dev/null scan_probe",
"cat <<\\\n-EOF\nEOF\nscan_probe h1\n-EOF",
"cat <<\\\n-EOF\n-EOF\nscan_probe z\nEOF",
"cat <\\\n(scan_probe i)",
"[[ -n <\\\n(scan_probe c1) ]]",
"a=(<\\\n(scan_probe a1))",
"cat <<E\n$\\\n(scan_probe h)\nE",
"echo ${x:-$\\\n(scan_probe p)}",
"(( $\\\n(scan_probe a) ))",
"[[ $\\\n(scan_probe c) ]]",
"a=($\\\n(scan_probe arr))",
// Dash splits assignment subscripts at blanks.
"scan_probe[x y]=1",
"a[x '$(scan_probe)']=1",
"a[1 + $(scan_probe)]=1",
// Expansions inside parameter words, arithmetic, and subscripts.
"echo \"${x:-$'$(scan_probe q1)'}\"",
"echo ${x:-<(scan_probe p1)}",
"echo ${x:->(scan_probe g)}",
"x=${y:-<(scan_probe p4)}",
"[[ x == ${y:-<(scan_probe p5)} ]]",
"echo $(( $'$(scan_probe a)' ))",
"(( x = $'$(scan_probe b)' ))",
"echo $(( ${x:-'$(scan_probe a)'} ))",
"(( ${x:-'$(scan_probe b)'} ))",
"a[${x:-'$(scan_probe c)'}]=1",
"echo $[ ${x:-'$(scan_probe d)'} ]",
"echo $[ $'$(scan_probe h)' ]",
"echo ${a[${x:-'$(scan_probe e)'}]}",
"echo \"${a[${x:-'$(scan_probe f)'}]}\"",
"cat <<E\n${x:-'$(scan_probe h)'}\nE",
// Zsh glob qualifiers and extglob groups run code in globbed words.
"echo @(<(scan_probe e1))",
"printf '%s' @(one|$(scan_probe))",
"echo *(e:'scan_probe q1':)",
"echo *(+scan_probe)",
"a=(*(e:'scan_probe g':))",
"declare -a a=(*(e:'scan_probe g':))",
"export a=(*(e:'scan_probe h':))",
"for f in *(e:'scan_probe h':); do :; done",
"echo ${x:-target(e:'scan_probe p2':)}",
"echo ${x:-*(e:'scan_probe j':)}",
"echo a=(e:'scan_probe p3':)",
"echo >*(e:'scan_probe f':)",
"cat <*(e:'scan_probe g':)",
"echo $x*(e:'scan_probe h':)",
"echo \"\"*(e:'scan_probe i':)",
"echo {a,*(e:'scan_probe m':)}",
// Builtins and arithmetic evaluate subscripts in decoded literal text.
"declare -i x='a[$(scan_probe)]'",
"declare 'a[$(scan_probe)]=1'",
"declare -a 'a=([$(scan_probe)]=1)'",
"a=(1); unset 'a[$(scan_probe)]'",
'a=(1); unset "a[\\$(scan_probe)]"',
"[[ 'a[$(scan_probe)]' -eq 1 ]]",
"[[ -v 'a[$(scan_probe)]' ]]",
"read 'a[$(scan_probe)]' </dev/null",
"printf -v 'a[$(scan_probe)]' x",
"x='a[$(scan_probe)]'; echo $((x))",
"s=abc; x='a[$(scan_probe)0]'; printf '%s' \"${s:x}\"",
"ref='x[$(scan_probe)0]'; printf '%s' \"${!ref}\"",
"declare ${x:-'a[$(scan_probe)]=1'}",
'declare "${x:-a[\\$(scan_probe)]=1}"',
"read ${x:-'a[$(scan_probe)]'} </dev/null",
"declare \"$(echo 'a[$(scan_probe)]=1')\"",
"declare \"${ echo a; }\"'[$(scan_probe)]=1'",
"n=a; declare \"$n\"'[$(scan_probe)]=1'",
"declare \"$(echo a)\"'[$(scan_probe)]=1'",
"builtin declare 'a[$(scan_probe)]=1'",
"printf -v'a[$(scan_probe)]' x",
"set -- 'a[$(scan_probe)]'; echo $(($1))",
"for x in 'a[$(scan_probe)]'; do echo $((x)); done",
"a=(1 'a[$(scan_probe)]'); echo $((a[1]))",
"x='a[$(scan_probe)]' eval 'echo $((x))'",
"a=(1); echo $(( a[\\$(scan_probe)] ))",
"a=(1); (( a[\\$(scan_probe)] ))",
// Explicit evaluation operators.
"x='$(scan_probe)'; echo ${x@P}",
"x='$(scan_probe)'; echo \"${x@P}\"",
"x='$(scan_probe)'; echo ${(e)x}",
"x='$(scan_probe)'; echo ${(ee)x}",
"echo ${(e):-'$(scan_probe)'}",
"x='*(e:scan_probe:)'; echo ${~x}",
"x='*(e:scan_probe:)'; echo $~x",
// Precommand modifiers, bundled/ordered binding options, repeat, compgen, and setopt.
"noglob typeset 'a[$(scan_probe)]=1'",
"nocorrect typeset 'a[$(scan_probe)]=1'",
"a=(1); noglob unset 'a[$(scan_probe)]'",
"noglob read 'a[$(scan_probe)]' <<< x",
'a=(1); noglob let "a[\\$(scan_probe)]"',
"print -r -v 'a[$(scan_probe)]' x",
"print -rv 'a[$(scan_probe)]' x",
"a=(1); print -r -v x 'a[$(scan_probe)]'; echo $((x))",
"a=(1); print -rv x 'a[$(scan_probe)]'; echo $((x))",
": & wait -np 'a[$(scan_probe)]'",
": & wait -n -p'a[$(scan_probe)]'",
": & wait -fp 'a[$(scan_probe)]' $!",
"time declare 'a[$(scan_probe)]=1'",
"coproc declare 'a[$(scan_probe)]=1'",
"a=(1); time unset 'a[$(scan_probe)]'",
"a=(1); time let 'a[$(scan_probe)]'",
"a=(1); time [[ -v 'a[$(scan_probe)]' ]]",
"a=(1); time [[ 'a[$(scan_probe)]' -eq 1 ]]",
"a=(1); coproc [[ -v 'a[$(scan_probe)]' ]]",
'export X="${ time ! { :; }; scan_probe; }"',
'export X="${ time\\\n { :; }; scan_probe; }"',
'export X="${ time -- { :; }; scan_probe; }"',
"repeat 1 scan_probe",
"a=(1); repeat 'a[$(scan_probe)]' :",
"setopt globsubst; x='*(e:scan_probe:)'; echo $x",
"set -o globsubst; x='*(e:scan_probe:)'; echo $x",
"compgen -C scan_probe",
"compgen -C 'scan_probe'",
// Combined literal/output subscripts, deferred bindings, continuations, Zsh flags, and ANSI-C escapes.
"declare \"a[$(echo '$(scan_probe)')]=1\"",
"declare \"$(echo 'a[')\"'$(scan_probe)]=1'",
"a=(1); unset \"a[$(echo '$(scan_probe)')]\"",
"a=(1); echo $(( a[$(echo '$(scan_probe)')] ))",
"x='$(scan_probe)'; declare \"a[$x]=1\"",
"x='$(scan_probe)'; a=(1); unset \"a[$x]\"",
"x='$(scan_probe)'; let \"a[$x]=1\"",
"x='$(scan_probe)'; read \"a[$x]\" <<< 1",
"x='$(scan_probe)'; printf -v \"a[$x]\" 1",
"a=(1); x='$(scan_probe)'; echo $((a[$x]))",
"a=(1); x='$(scan_probe)'; [[ -v \"a[$x]\" ]]",
"a=(1); x='$(scan_probe)'; [[ \"a[$x]\" -eq 1 ]]",
"a=(1); echo ${a\\\n['$(scan_probe)']}",
"a=(1); s=abc; echo ${s\\\n:'a[$(scan_probe)0]'}",
"a=(1); echo ${a[0]\\\n:'a[$(scan_probe)0]'}",
"x='$(scan_probe)'; echo ${x@\\\nP}",
"x='$(scan_probe)'; echo \"${x@\\\nP}\"",
"x='$(scan_probe)'; echo ${x@P\\\n}",
"x='*(e:scan_probe:)'; echo $\\\n~x",
"x='*(e:scan_probe:)'; echo $^\\\n~x",
"x='*(e:scan_probe:)'; echo ${^\\\n~x}",
"x='*(e:scan_probe:)'; echo ${(f)\\\n~x}",
"a=(1); echo ${(f)a['a[$(scan_probe)]']}",
"a=(1); echo ${=a['a[$(scan_probe)]']}",
"a=(1); echo ${^a['a[$(scan_probe)]']}",
"a=(1); s=abc; echo ${(f)s:'a[$(scan_probe)0]'}",
"a=(1); s=abc; echo ${=s:'a[$(scan_probe)0]'}",
"a=(1); s=abc; echo ${^s:'a[$(scan_probe)0]'}",
"a=(1); [[ x != *(a)]] && 0 -eq 'a[$(scan_probe)]' ]]",
"a=(1); [[ x != *(a)]] && -v 'a[$(scan_probe)]' ]]",
"a=(1); [[ x != (a)]] && 0 -eq 'a[$(scan_probe)]' ]]",
"shopt -s extglob\na=(1); [[ x != *(a)]] && 0 -eq 'a[$(scan_probe)]' ]]",
"a=(1); echo $(( $'a\\x5b\\x24\\x28scan_probe\\x29\\x5d' ))",
"a=(1); (( $'a\\x5b\\x24\\x28scan_probe\\x29\\x5d' ))",
"a=(1); echo ${a[$'a\\x5b\\x24\\x28scan_probe\\x29\\x5d']}",
"a=(1); declare \"${x:-$'a\\x5b\\x24\\x28scan_probe\\x29\\x5d=1'}\"",
"a=(1); unset \"${x:-$'a\\x5b\\x24\\x28scan_probe\\x29\\x5d'}\"",
"(( $'\\' )) # ' )); scan_probe",
"declare -A a; a[$'\\']=1 # ']=1; scan_probe",
"a=(1); echo ${a[$'\\']} # ']} $(scan_probe)",
": <<$'export'\n$export\nscan_probe\nexport",
": <<$'EOF'\n$EOF\nscan_probe\nEOF",
"echo $'\\'; scan_probe; : ' # '",
"export X=$'\\'; scan_probe; : ' # '",
"export X=$'\\c\\\\'; scan_probe # '",
"export X=$'\\c'; scan_probe; : ' # '",
"$'scan_probe\\0x'",
"$'scan_probe\\x00x'",
"$'scan_probe\\u0000x'",
"$'scan_probe\\c@x'",
"$'declare\\0x' 'a[$(scan_probe)]=1'",
"a=(1); $'unset\\0x' 'a[$(scan_probe)]'",
"$'let\\0x' 'a[$(scan_probe)]'",
"x='x y'; scan_probe[$x]=1",
// Parameter default bindings, Zsh subscripts, precommands, namerefs, ANSI-C escapes, and Zsh builtins.
"a=(1); : ${x:='a[$(scan_probe)]'}; echo $((x))",
"a=(1); : ${x:='$(scan_probe)'}; echo $((a[$x]))",
"a=(1); : ${x='$(scan_probe)'}; unset \"a[$x]\"",
'a=(1); : "${x:="a[\\$(scan_probe)]"}"; echo $((x))',
"a=(); : ${a[0]:='a[$(scan_probe)]'}; echo $((a[0]))",
"declare -i a x; : ${x:='a[$(scan_probe)]'}",
"a=(1); echo ${@[1,'a[$(scan_probe)]']}",
"a=(1); echo ${*[1,'a[$(scan_probe)]']}",
"a=(1); echo ${1[1,'a[$(scan_probe)]']}",
"a=(1); echo ${a[1]['$(scan_probe)']}",
"a=(1); echo ${s[1][1]:'a[$(scan_probe)0]'}",
"a=(1); echo ${${a}['$(scan_probe)']}",
"a=(1); echo ${${s}:'a[$(scan_probe)0]'}",
"a=(1); echo $a['$(scan_probe)']",
'a=(1); echo "$a[a[\\$(scan_probe)]]"',
"a=(1); x='$(scan_probe)'; echo $a[a[$x]]",
"a=(1); x='$(scan_probe)'; echo \"$a[a[$x]]\"",
"a=(1); echo $@[1,'a[$(scan_probe)]']",
"a=(1); echo $^a['$(scan_probe)']",
"exec typeset 'a[$(scan_probe)]=1'",
"a=(1); - typeset 'a[$(scan_probe)]=1'",
"a=(1); exec unset 'a[$(scan_probe)]'",
"a=(1); - unset 'a[$(scan_probe)]'",
"a=(1); exec -a foo unset 'a[$(scan_probe)]'",
"a=(1); exec let 'a[$(scan_probe)]'",
"a=(1); - let 'a[$(scan_probe)]'",
"unset a=(e:'scan_probe':)",
"unsetenv a=(e:'scan_probe':)",
"a=(1); r='a[$(scan_probe)]'; declare -n r; r=1",
"a=(1); r='a[$(scan_probe)]'; declare -n r; echo $r",
"a=(1); r='a[$(scan_probe)]'; typeset -n r; r=1",
"a=(1); x='a[$(scan_probe)]'; (( $'\\170' ))",
"a=(1); x='a[$(scan_probe)]'; echo ${a[$'\\170']}",
"a=(1); x='a[$(scan_probe)]'; declare a[x]=1",
"a=(1); x='a[$(scan_probe)]'; declare 'a[x]=1'",
"a=(1); x='a[$(scan_probe)]'; unset 'a[x]'",
"a=(1); x='a[$(scan_probe)]'; [[ -v a[x] ]]",
"a=(1); x='a[$(scan_probe)]'; read 'a[x]' <<< 1",
"a=(1); x='a[$(scan_probe)]'; printf -v 'a[x]' 1",
"a=(1); x='a[$(scan_probe)]'; test -v 'a[x]'",
"a=(1); echo ${(s:):)a['a[$(scan_probe)]']}",
"a=(1); s=abc; echo ${(s:):)s:'a[$(scan_probe)0]'}",
"a=(1); [[ !(-v 'a[$(scan_probe)]') ]]",
"echo $'a\\'; scan_probe; ' \\'",
"echo $'\\'$(scan_probe)' \\'",
": <<$'a\\'; scan_probe; '\\'\n$a\\\n\na'; scan_probe; '",
"export X=$'\\c\\''; scan_probe # '",
"a=(1); declare $'a[\\0$(scan_probe)]=1'",
"a=(1); unset $'a[\\0$(scan_probe)]'",
"a=(1); declare $'a[\\u0000$(scan_probe)]=1'",
": <<$'\\u0041'\n\\u0041\nscan_probe\nA",
": <<$'\\U00000041'\n\\U00000041\nscan_probe\nA",
"$'s\\can_probe'",
"$'de\\clare' 'a[$(scan_probe)]=1'",
"$'\\scan_probe'",
"a=(1); typeset $'a\\[\\$\\(scan_probe\\)\\]=1'",
"$'scan_probe\\xz'",
"$'scan_probe\\uz'",
"$'scan_probe\\Uz'",
"$'scan_probe\\C-@x'",
"$'scan_probe\\C@x'",
"a=(1); set -A 'a[$(scan_probe)]' 1",
"a=(1); set +A 'a[$(scan_probe)]' 1",
"unsetopt noglobsubst; x='*(e:scan_probe:)'; echo $x",
"set +o noglobsubst; x='*(e:scan_probe:)'; echo $x",
"set -xo globsubst; x='*(e:scan_probe:)'; echo $x",
"emulate zsh -o globsubst; x='*(e:scan_probe:)'; echo $x",
"NULLCMD=scan_probe; >/dev/null",
"READNULLCMD=scan_probe; </dev/null",
"NULLCMD=scan_probe; <<< x",
"setopt promptsubst; x='$(scan_probe)'; print -P $x",
"setopt promptvars; print -P '$(scan_probe)'",
"a=(1); exit 'a[$(scan_probe)]'",
"f() { return 'a[$(scan_probe)]'; }; a=(1); f",
"a=(1); shift 'a[$(scan_probe)]'",
"a=(1); for x in 1; do break 'a[$(scan_probe)]'; done",
"a=(1); for x in 1; do continue 'a[$(scan_probe)]'; done",
"a=(1); bye 'a[$(scan_probe)]'",
"a=(1); logout 'a[$(scan_probe)]'",
"a=(1); getln 'a[$(scan_probe)]'",
"a=(1); vared 'a[$(scan_probe)]'",
"a=(1); zformat -f 'a[$(scan_probe)]' x",
"a=(1); zstyle -s : x 'a[$(scan_probe)]'",
"zmodload zsh/stat; a=(1); zstat -A 'a[$(scan_probe)]' .",
"a=(1); compgen -V x -W 'a[$(scan_probe)]'; echo $((x[0]))",
// Special/positional subscripts, escape decoders, split expansions, float/array declarations, and continuations.
"a=(1); set -- 1 2 3 4 5 6 7 8 9 abc; echo ${1\\\n0:'a[$(scan_probe)0]'}",
"a=(1); echo ${1\\\n0[1,'a[$(scan_probe)]']}",
"a=(1); echo $0[1,'a[$(scan_probe)]']",
"a=(1); echo $?[1,'a[$(scan_probe)]']",
"a=(1); echo $#[1,'a[$(scan_probe)]']",
"a=(1); echo $-[1,'a[$(scan_probe)]']",
"a=(1); echo $![1,'a[$(scan_probe)]']",
"a=(1); echo $$[1,'a[$(scan_probe)]']",
'a=(1); echo "$?[1,a[\\$(scan_probe)]]"',
'a=(1); echo "$$[1,a[\\$(scan_probe)]]"',
"a=(1); x='a[\\x24(scan_probe)]'; unset \"${x@E}\"",
"a=(1); x='a[\\x24(scan_probe)]'; declare \"${x@E}=1\"",
"a=(1); x='a[\\x24(scan_probe)]'; y=${x@E}; echo $((y))",
'a=(1); x="$\'a[\\\\x24(scan_probe)]\'"; unset "${(Q)x}"',
'a=(1); x="$\'a[\\\\x24(scan_probe)]\'"; typeset "${(Q)x}=1"',
"a=(1); x=\"$'a[\\\\x24(scan_probe)]'\"; y=${(Q)x}; echo $((y))",
"a=(1); x='a[$'; x+='(scan_probe)]'; unset \"$x\"",
'a=(1); d=\'$\'; x="a[${d}(scan_probe)]"; unset "$x"',
"a=(1); x='a[$_1(scan_probe)]'; unset \"${x/_1/}\"",
"a=(1); x='a[$_1(scan_probe)]'; echo $(( ${x/_1/} ))",
"a=(1); x='a[$'; y='(scan_probe)]'; echo $(( $x$y ))",
"a=(1); read x <<< 'a[$\\(scan_probe)]'; echo $((x))",
"a=(1); printf -v x 'a[\\x24(scan_probe)]'; echo $((x))",
"a=(1); print -v x 'a[\\x24(scan_probe)]'; echo $((x))",
"a=(1); typeset -E x; x='a[$(scan_probe)]'",
"a=(1); typeset -F x; x='a[$(scan_probe)]'",
"a=(1); y='a[$(scan_probe)]'; typeset -E x=y",
"a=(1); y='a[$(scan_probe)]'; typeset -F x=y",
"a=(); declare a='([$(scan_probe)]=1)'",
"a=(); typeset a='([$(scan_probe)]=1)'",
"a=(); declare a+='([$(scan_probe)]=1)'",
"f() { local -a a; local a='([$(scan_probe)]=1)'; }; f",
"declare -a a; x='([$(scan_probe)]=1)'; declare a=$x",
"x=${NULLCMD::=scan_probe}; >/dev/null",
"x=${READNULLCMD::=scan_probe}; </dev/null",
": a${NULLCMD::=scan_probe}; >/dev/null",
": ${^NULLCMD::=scan_probe}; >/dev/null",
": ${(f)NULLCMD::=scan_probe}; >/dev/null",
"print -vNULLCMD scan_probe; >/dev/null",
"(( ${NULLCMD::=scan_probe} )); >/dev/null",
"[[ -n ${NULLCMD::=scan_probe} ]]; >/dev/null",
"a=(${NULLCMD::=scan_probe}); >/dev/null",
": <<EOF\n${NULLCMD::=scan_probe}\nEOF\n>/dev/null",
"PS4='$(scan_probe)'; set -x; x=1",
"PS4='$(scan_probe)'; set -o xtrace; x=1",
"export PS4='$(scan_probe)'; set -x; x=1",
"setopt -m 'glob*'; x='*(e:scan_probe:)'; echo $x",
"setopt -m '*subst*'; x='*(e:scan_probe:)'; echo $x",
"emulate sh; unsetopt shglob; setopt bareglobqual; x='*(e:scan_probe:)'; echo $x",
"emulate ksh; unsetopt shglob; setopt bareglobqual; x='*(e:scan_probe:)'; echo $x",
"a=(1); zregexparse 'a[$(scan_probe)]' 'a[$(scan_probe)]' x /x/",
"a=(1); private 'a[$(scan_probe)]=1'",
"a=(1); [[ x =~ (a )(b)]] || 0 -eq 'a[$(scan_probe)]' ]]",
"a=(1); [[ x =~ (a )(b)]] || -v 'a[$(scan_probe)]' ]]",
"a=(1); [[ -t 'a[$(scan_probe)]' ]]",
"a=(1); x='a[$(scan_probe)]'; [[ -t x ]]",
"a=(1); x='a[$(scan_probe)]'; [[ -t $x ]]",
"a=(1); test -t 'a[$(scan_probe)]'",
"a=(1); [ -t 'a[$(scan_probe)]' ]",
"a=(1); x='a[$(scan_probe)]'; test -t x",
"a=(1); x='a[$(scan_probe)]'; [ -t x ]",
"echo $\\\n'a\\''; scan_probe; : \\'",
": <<$\\\n'EOF'\nEOF\nscan_probe\n$EOF",
': <<$\\\n"EOF"\nEOF\nscan_probe\n$EOF',
"$\\\n'scan_probe'",
'$\\\n"scan_probe"',
"a=(1); unset $\\\n'a[\\x24(scan_probe)]'",
"echo \"${x:-$\\\n'\\x24(scan_probe)'}\"",
"[[ -n x ]\\\n] && scan_probe && [[ -n y ]]",
"a=(1); (\\\n( 'a[$(scan_probe)]' ))",
"a=(1); (\\\n( 1 + 'a[$(scan_probe)]' ))",
"a=(1); echo $(\\\n( 'a[$(scan_probe)]' ))",
"a=(1); x='a[$(scan_probe)]'; echo $(( x )\\\n)",
"a=(1); [\\\n[ -v 'a[$(scan_probe)]' ]]",
"a=(1); [\\\n[ 0 -eq 'a[$(scan_probe)]' ]]",
"a[0]\\\n=1 scan_probe",
"a[0]+\\\n=1 scan_probe",
"a=(); a[0]\\\n='a[$(scan_probe)]'; echo $((a[0]))",
"x='x y'; scan_probe[$x]\\\n=1",
"a=(1); x='a[$_1]'; y='(scan_probe)'; unset \"${x/_1/$y}\"",
"a=(1); x='a[$_1]'; y='(scan_probe)'; echo $(( ${x/_1/$y} ))",
"a=(1); x='a[$1'; y='(scan_probe)]'; echo $(( ${x%1}$y ))",
"a=(1); x='a[$1\n(scan_probe)]'; unset \"${x/1?/}\"",
"printf -v x 'a[\\x24\\x28scan_probe\\x29]'; echo $((x))",
"printf -v x 'a[\\044\\050scan_probe\\051]'; echo $((x))",
"a=(1); printf -v x 'a[\\u0024(scan_probe)]'; echo $((x))",
"a=(1); printf -v x 'a[\\U00000024(scan_probe)]'; echo $((x))",
"typeset -a a; print -v x 'a[\\x24\\x28scan_probe\\x29]'; echo $((x))",
"typeset -a a; print -v x 'a[\\044\\050scan_probe\\051]'; echo $((x))",
"typeset -a a; print -v x 'a[\\u0024\\u0028scan_probe\\u0029]'; echo $((x))",
"typeset -a a; print -v x 'a[\\U00000024\\U00000028scan_probe\\U00000029]'; echo $((x))",
'a=(1); x="$\'a[\\\\x24\\\\x28scan_probe\\\\x29]\'"; unset "${x:Q}"',
'a=(1); x="$\'a[\\\\x24\\\\x28scan_probe\\\\x29]\'"; unset "$x:Q"',
"a=(1); x=\"$'a[\\\\x24\\\\x28scan_probe\\\\x29]'\"; y=${x:Q}; echo $((y))",
"a=(1); read x <<'EOF'\na[$(scan_probe)]\nEOF\necho $((x))",
"a=(1); read x <<EOF\na[\\$(scan_probe)]\nEOF\necho $((x))",
"mapfile -t a <<'EOF'\nb[$(scan_probe)]\nEOF\necho $((a[0]))",
"a=(); declare a=\"$(echo '([$(scan_probe)]=1)')\"",
"a=(); declare a=\"`echo '([$(scan_probe)]=1)'`\"",
"a=(); set -- '([$(scan_probe)]=1)'; declare a=$1",
"a=(); set -- '([$(scan_probe)]=1)'; declare a=$@",
"a=(); set -- '([$(scan_probe)]=1)'; declare a=$*",
"a=(); set -- '([$(scan_probe)]=1)'; declare a=\"$1\"",
"x==(scan_probe)",
"x+==(scan_probe)",
"a=(1); a[1]==(scan_probe)",
"a=(1); a[1]+==(scan_probe)",
"export x==(scan_probe)",
"typeset x==(scan_probe)",
"f() { local x==(scan_probe); }; f",
"x==\\\n(scan_probe)",
"a=(1); ref='a[$(scan_probe)]'; echo ${\\\n!ref}",
"a=(1); ref='a[$(scan_probe)]'; echo \"${\\\n!ref}\"",
"a=(1); set -k; f() { echo $((x)); }; f x='a[$(scan_probe)]'",
"a=(1); set -o keyword; f() { echo $((x)); }; f x='a[$(scan_probe)]'",
"a=(1); set -k; test -v 'a[x]' x='a[$(scan_probe)]'",
"a=(1); set -o posix -k; : x='a[$(scan_probe)]'; echo $((x))",
"set -oglobsubst; x='*(e:scan_probe:)'; echo $x",
"set +onoglobsubst; x='*(e:scan_probe:)'; echo $x",
"setopt -oglobsubst; x='*(e:scan_probe:)'; echo $x",
"setopt +onoglobsubst; x='*(e:scan_probe:)'; echo $x",
"printf -vPS4 '\\x24(scan_probe)'; set -x; x=1",
"printf -vPS4 '\\044(scan_probe)'; set -x; x=1",
"functions[zshexit]=scan_probe",
"functions[TRAPEXIT]=scan_probe",
"functions[TRAPDEBUG]=scan_probe; x=1",
"functions=(zshexit scan_probe)",
": ${functions[zshexit]::=scan_probe}",
"functions[echo]=scan_probe; echo hi",
"options[globsubst]=on; x='*(e:scan_probe:)'; echo $x",
"options=(globsubst on); x='*(e:scan_probe:)'; echo $x",
": ${options[globsubst]::=on}; x='*(e:scan_probe:)'; echo $x",
"aliases[echo]=scan_probe; options[aliases]=on; : `echo`",
"galiases[echo]=scan_probe; options[aliases]=on; : `echo`",
"saliases[x]=scan_probe; options[aliases]=on; : `a.x`",
"commands[ls]=$(command -v scan_probe); ls",
"a=(1); [[ x == \"\" || ( 0 -eq 'a[$(scan_probe)]' ) ]]",
"a=(1); [[ x != '' && ( 0 -eq 'a[$(scan_probe)]' ) ]]",
"a=(1); [[ x != '' && ( -v 'a[$(scan_probe)]' ) ]]",
"a=(1); [[ x != '' && ( -t 'a[$(scan_probe)]' ) ]]",
"a=(1); [[ == && ( 0 -eq 'a[$(scan_probe)]' ) ]]",
"a=(1); [[ -n == && ( 0 -eq 'a[$(scan_probe)]' ) ]]",
"a=(1); [[ x == != || ( -t 'a[$(scan_probe)]' ) ]]",
"a=(1); [[ x == == || ( 0 -eq 'a[$(scan_probe)]' ) ]]",
"a=(1); [[ ( -n == ) && ( 0 -eq 'a[$(scan_probe)]' ) ]]",
"[[ -n $$'\\'' ]]; scan_probe # ' ]]",
"a=($$'\\''); scan_probe # ')",
"echo ${x:-$$'\\''}; scan_probe # '}",
"export X=$$\\\n'\\''; scan_probe # '",
"export X=$'\\'; scan_probe; : '; cat <<E\n'\nE",
"export X=$\\\n$'\\'; scan_probe; : ' # '",
'a=(1); echo "$a[(r)\\],a[\\$(scan_probe)]]"',
'a=(1); echo "$?[1\\],a[\\$(scan_probe)]]"',
'export X="${unset:+${a[0}}"; scan_probe; : "]}}"',
"export X=${unset:+${a[0}}; scan_probe; : ]}}",
'export X=${unset:+${a["0}"}}; scan_probe; : ]}}',
'cat <<E; export X="${unset:+$(( 0 + "0 ))}" && scan_probe\n))}"\nE',
'false && export X="${unset:+$(( 0 }"; scan_probe; : "))}"',
'false && export X="${unset:+$(echo a}"; scan_probe; : ")}"',
'false && export X="${unset:+$(echo {a}"; scan_probe; : ")}"',
'false && export X="${unset:+$(echo a})}"; scan_probe; : "}"',
"export X=$$'\\' # '; scan_probe",
"export X=$$'\\' #\nexport Y='; scan_probe # '",
"export X=$^$'\\' # '; scan_probe",
"export X=$#$'\\' # '; scan_probe",
"export X=$'\\'\nscan_probe\n'",
] as const
// These also run inside every wrapper below.
const nestedFixtures = [
"[[ a]]# ]] && scan_probe",
"{ export X={}# ; scan_probe; }",
"unset a[b; scan_probe; echo ]=1",
"case esac in (esac) scan_probe;; esac",
"case esac in (a|esac) scan_probe;; esac",
"for i in 1; { if true; then scan_probe; fi }; while false; do export X=1; done",
"(( echo '\"' ); scan_probe; ( echo '\"' ))",
"export X=${unset:+${x['0\"0']}}; scan_probe; : '\"]}}' # '",
"export X=$$'\\'; scan_probe # '",
": <<-export\n\tex\\\n\tport\n# $(scan_probe)\nexport",
"!(scan_probe)",
"f+() case x in x) scan_probe;; esac; f+",
"f@g() case x in x) scan_probe;; esac; f@g",
"f@g() for i in 1; do scan_probe; done; f@g",
] as const
const wrappers: Array<[name: string, wrap: (inner: string) => string]> = [
["$(...)", (inner) => `export OUTER=$( ${inner}\n)`],
['"$(...)"', (inner) => `export OUTER="$( ${inner}\n)"`],
["backticks", (inner) => `export OUTER=\` ${inner}\n\``],
["heredoc", (inner) => `: <<EOF\n$( ${inner}\n)\nEOF`],
["case arm", (inner) => `case x in x) ${inner}\n;; esac`],
["for loop", (inner) => `for k in 1; do ${inner}\ndone`],
["function", (inner) => `wrap_fn() {\n${inner}\n}; wrap_fn`],
["brace group", (inner) => `{ ${inner}\n}`],
]
describe.skipIf(process.platform === "win32")("real-shell soundness oracle", () => {
test("discovers at least bash on PATH", () => {
expect(shells.some((item) => item.endsWith("/bash"))).toBe(true)
})
test.each([...fixtures, ...nestedFixtures])("reports or rejects real-shell execution: %j", (source) => {
expectProbesReported(source)
})
test.each(wrappers.flatMap(([name, wrap]) => nestedFixtures.map((source) => [name, source, wrap(source)])))(
"reports or rejects in %s: %j",
(_, __, source) => {
expectProbesReported(source)
},
)
})
describe.skipIf(process.platform === "win32")("real-shell directory oracle", () => {
test.each(["cd >/dev/null TARGET", "cd 2>/dev/null TARGET", "cd $'TARGET'", 'cd "TARGET"'])(
"reports the directory a real shell changes to: %s",
async (template) => {
const source = template.replace("TARGET", target)
const targets = [target, fs.realpathSync(target)]
const changed = shells.filter((executable) =>
targets.includes(observe(executable, `${source}\npwd >> "$SCAN_PROBE_LOG"`).at(-1) ?? ""),
)
expect(changed.length, `Fixture never changed directory in any real shell: ${source}`).toBeGreaterThan(0)
for (const executable of changed) {
const parsed = await Effect.runPromiseExit(ShellParse.scanPortable(source, executable, root))
if (Exit.isSuccess(parsed)) expect(parsed.value.directories, `${executable} in: ${source}`).toContain(target)
}
},
)
})
+1 -13
View File
@@ -38,11 +38,6 @@ describe("Bash redirect resource oracle", () => {
`${redirect} FOO=bar git status 3>tail`,
`npm run ${redirect} test`,
]) {
// Dash reads `&>` as `&` and `>`, so words after its target start another command there.
if (redirect.startsWith("&") && !command.endsWith(redirect)) {
expect(ShellScan.scan(command).kind).toBe("opaque")
continue
}
await parity(command)
for (const separator of separators) {
await parity(`printf ok${separator}${command}`)
@@ -85,6 +80,7 @@ describe("Bash redirect resource oracle", () => {
"pwd | cat 2\\>out",
"if true; then printf ok && cat >$(printf path); fi",
"if true; then printf ok && git >out status; else cat >log; fi",
"pwd && cd >out /outside",
"time git status",
"time -p git status",
"coproc git status",
@@ -155,12 +151,4 @@ describe("Bash redirect resource oracle", () => {
{ resource: "FOO=bar >output git status", save: "git status *" },
])
})
test("known gap: redirect before cd operand retains the target directory natively", async () => {
const source = "pwd && cd >out /outside"
const legacy = await Effect.runPromise(ShellParse.scan(source, "/bin/bash", "/workspace"))
const native = await Effect.runPromise(ShellParse.scanPortable(source, "/bin/bash", "/workspace"))
expect(legacy).toEqual({ commands: [{ resource: "pwd", save: "pwd *" }], directories: [] })
expect(native).toEqual({ commands: [{ resource: "pwd", save: "pwd *" }], directories: ["/outside"] })
})
})
+2 -2
View File
@@ -217,8 +217,6 @@ describe("ShellScan", () => {
["CDPATH=/usr # comment\ncd bin; rm victim", ["cd", "rm"]],
["HOME=/etc # comment\ncd; rm victim", ["cd", "rm"]],
["VALUE=$(printf 2); echo $((VALUE + 1))", ["printf", "echo"]],
["MSG='Use ${FOO} here'; some_cmd || exit $?", ["some_cmd", "exit"]],
["f() { local REGEX='[0-9]+${FOO}'; return $?; }", ["local", "return"]],
] as const)("scans assignment-only boundaries without evaluating their effects: %s", (command, names) => {
const result = ShellScan.scan(command)
expect(result.kind).toBe("scanned")
@@ -236,6 +234,8 @@ describe("ShellScan", () => {
"{fd}>/tmp/log touch /tmp/victim",
"time touch /tmp/victim",
"printf '%s' \"$(printf safe ${x%)}; touch /tmp/victim)\"",
"s=abc; x='a[$(touch /tmp/victim)0]'; printf '%s' \"${s:x}\"",
"ref='x[$(touch /tmp/victim)0]'; printf '%s' \"${!ref}\"",
"if true; then echo safe; fi > /tmp/victim",
"if true; then :; 'if' victim; fi",
])("scans Bash lexical forms without interpreting shell values: %s", (command) => {
-16
View File
@@ -1,16 +0,0 @@
# `@opencode/plugin`
Authoring interfaces and runtime loader support for OpenCode V2 plugins:
- `@opencode/plugin` — [Promise plugin API](./src/README.md)
- `@opencode/plugin/effect` — [Effect plugin API](./src/effect/README.md)
- `@opencode/plugin/rpc` — portable RPC contract definitions
- `@opencode/plugin/tui` — terminal UI plugin API
## Packaging And Runtime `effect`
When the OpenCode CLI loads server or TUI plugins, it resolves imports of `effect`, exported `effect/*` subpaths, and `@opencode/plugin` entrypoints (including imports from dependencies inside a plugin's `node_modules`) to the host's runtime module instances so fibers, loggers, and `Schema` parsers share one copy.
- Declare `effect` as a `peerDependency` (and `devDependency` for local type-checking and testing) rather than a bundled runtime dependency.
- Do not bundle `effect` into published plugin files; if you build with a bundler, keep `effect` and `effect/*` external. Two copies of `effect` do not share fiber, logger, or `Schema` internals.
- Plugins and their `node_modules` dependencies always receive OpenCode's host `effect` instance. Use `effect` APIs and module paths compatible with the OpenCode release you target; dependencies built on another `effect` major (such as Effect 3) are not supported.
+2 -2
View File
@@ -1,5 +1,5 @@
import assert from "node:assert/strict"
import { mkdir, mkdtemp, realpath, rm, writeFile } from "node:fs/promises"
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"
import { tmpdir } from "node:os"
import path from "node:path"
import { describe, it } from "node:test"
@@ -11,7 +11,7 @@ const source = 'throw new Error("Plugin code must not run during resolution")'
const name = "@fixture/plugin"
async function fixture(files: Record<string, string>, installed = false) {
const root = await realpath(await mkdtemp(path.join(tmpdir(), "opencode-host-")))
const root = await mkdtemp(path.join(tmpdir(), "opencode-host-"))
const directory = installed ? path.join(root, "node_modules", name) : root
await Promise.all(
Object.entries(files).map(async ([file, content]) => {
+42 -27
View File
@@ -1094,6 +1094,7 @@ body {
[data-page="stats"] [data-component="top-models-chart"] {
--top-models-bar-gap: 12px;
--top-models-dot-size: 6px;
--top-models-dot-offset: 2px;
position: relative;
display: grid;
grid-template-rows: 34px minmax(0, 1fr);
@@ -1223,31 +1224,38 @@ body {
padding-inline: calc(var(--top-models-bar-gap) / 2);
outline: none;
cursor: pointer;
/* Resolved by absolutely positioned children against this slot's width. Three 2px dot columns span the bar, so this inset makes the gap between bars equal the gap between dot columns. */
--top-models-bar-inset: calc((100% - 6px) / 6);
}
/* Three dot columns aligned to the bar's left edge, center, and right edge, so they read as rising out of the bar. The SVG has no viewBox, so the 2px dot keeps its size while the cell stretches. */
[data-page="stats"] [data-slot="top-models-bar"]::before {
position: absolute;
inset: 0 var(--top-models-bar-inset);
top: 0;
bottom: 0;
left: 50%;
width: calc(100% - var(--top-models-bar-gap));
transform: translateX(-50%);
content: "";
background: var(--stats-dot);
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'%3E%3Crect width='2' height='2'/%3E%3C/svg%3E");
mask-position: left top;
mask-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 6 6' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0 0H2V2H0V0Z' fill='black'/%3E%3C/svg%3E");
mask-position: var(--top-models-dot-offset) top;
mask-repeat: repeat;
mask-size: calc((100% - 2px) / 2) var(--top-models-dot-size);
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'%3E%3Crect width='2' height='2'/%3E%3C/svg%3E");
-webkit-mask-position: left top;
mask-size: var(--top-models-dot-size) var(--top-models-dot-size);
-webkit-mask-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 6 6' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0 0H2V2H0V0Z' fill='black'/%3E%3C/svg%3E");
-webkit-mask-position: var(--top-models-dot-offset) top;
-webkit-mask-repeat: repeat;
-webkit-mask-size: calc((100% - 2px) / 2) var(--top-models-dot-size);
-webkit-mask-size: var(--top-models-dot-size) var(--top-models-dot-size);
}
@supports (width: round(down, 100%, 1px)) {
[data-page="stats"] [data-slot="top-models-bar"]::before {
width: round(down, calc(100% - var(--top-models-bar-gap)), var(--top-models-dot-size));
}
}
[data-page="stats"] [data-slot="top-models-stack"] {
position: absolute;
right: var(--top-models-bar-inset);
right: calc(var(--top-models-bar-gap) / 2);
bottom: 0;
left: var(--top-models-bar-inset);
left: calc(var(--top-models-bar-gap) / 2);
z-index: 1;
display: grid;
height: var(--top-models-bar-height);
@@ -4537,6 +4545,7 @@ body {
[data-page="stats"] [data-component="model-usage-chart"] {
--model-usage-bar-gap: 6px;
--model-usage-dot-size: 6px;
--model-usage-dot-offset: 2px;
position: relative;
display: grid;
grid-template-rows: 40px minmax(0, 1fr);
@@ -4594,31 +4603,38 @@ body {
padding-inline: calc(var(--model-usage-bar-gap) / 2);
outline: none;
cursor: pointer;
/* Resolved by absolutely positioned children against this slot's width. Three 2px dot columns span the bar, so this inset makes the gap between bars equal the gap between dot columns. */
--model-usage-bar-inset: calc((100% - 6px) / 6);
}
/* Three dot columns aligned to the bar's left edge, center, and right edge, so they read as rising out of the bar. The SVG has no viewBox, so the 2px dot keeps its size while the cell stretches. */
[data-page="stats"] [data-slot="model-usage-column"]::before {
position: absolute;
inset: 0 var(--model-usage-bar-inset);
top: 0;
bottom: 0;
left: 50%;
width: calc(100% - var(--model-usage-bar-gap));
transform: translateX(-50%);
content: "";
background: var(--stats-dot);
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'%3E%3Crect width='2' height='2'/%3E%3C/svg%3E");
mask-position: left top;
mask-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 6 6' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0 0H2V2H0V0Z' fill='black'/%3E%3C/svg%3E");
mask-position: var(--model-usage-dot-offset) top;
mask-repeat: repeat;
mask-size: calc((100% - 2px) / 2) var(--model-usage-dot-size);
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'%3E%3Crect width='2' height='2'/%3E%3C/svg%3E");
-webkit-mask-position: left top;
mask-size: var(--model-usage-dot-size) var(--model-usage-dot-size);
-webkit-mask-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 6 6' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0 0H2V2H0V0Z' fill='black'/%3E%3C/svg%3E");
-webkit-mask-position: var(--model-usage-dot-offset) top;
-webkit-mask-repeat: repeat;
-webkit-mask-size: calc((100% - 2px) / 2) var(--model-usage-dot-size);
-webkit-mask-size: var(--model-usage-dot-size) var(--model-usage-dot-size);
}
@supports (width: round(down, 100%, 1px)) {
[data-page="stats"] [data-slot="model-usage-column"]::before {
width: round(down, calc(100% - var(--model-usage-bar-gap)), var(--model-usage-dot-size));
}
}
[data-page="stats"] [data-slot="model-usage-bar"] {
position: absolute;
right: var(--model-usage-bar-inset);
right: calc(var(--model-usage-bar-gap) / 2);
bottom: 0;
left: var(--model-usage-bar-inset);
left: calc(var(--model-usage-bar-gap) / 2);
z-index: 1;
height: var(--model-usage-fill);
min-height: 0;
@@ -8180,8 +8196,7 @@ body {
}
[data-page="stats"] [data-component="model-usage-chart"] {
/* 18px puts three 2px dot columns 6px apart, matching the 6px bar gap. */
--model-usage-mobile-bar-width: 18px;
--model-usage-mobile-bar-width: 16px;
--model-usage-mobile-edge-space: 48px;
--model-usage-mobile-track-width: calc(
var(--model-usage-count) * (var(--model-usage-mobile-bar-width) + var(--model-usage-bar-gap)) +
@@ -8222,7 +8237,7 @@ body {
}
[data-page="stats"] [data-slot="model-usage-column"]::before {
inset: 0;
width: 100%;
}
[data-page="stats"] [data-slot="model-usage-bar"] {
@@ -1,10 +1,8 @@
import { Plugin, PluginContextProvider, usePlugin } from "@opencode/plugin/tui"
import { ensureRuntimePluginSupport } from "@opentui/solid/runtime-plugin-support/configure"
import { ensurePluginRuntime } from "../../../cli/src/plugin-runtime"
ensureRuntimePluginSupport({
additional: {
...ensurePluginRuntime(),
"@opencode/plugin/tui": { Plugin, PluginContextProvider, usePlugin },
},
})
-121
View File
@@ -7,7 +7,6 @@ import "../src/plugin/runtime-plugin-support.bun"
import { createPluginSources } from "../src/plugin/source"
import { createSourceWatcher } from "../src/plugin/watch"
import { createSignal } from "solid-js"
import { Effect, Option, Schema } from "effect"
import { Plugin } from "@opencode/plugin/tui"
import { tmpdir } from "./fixture/fixture"
@@ -139,126 +138,6 @@ test("shared runtime and ordinary package identities survive plugin generations"
}
})
test("TUI plugins importing @opencode/plugin/tui and solid-js alongside effect resolve effect and effect/* to the host copy", async () => {
await using sources = await fixture()
const entry = new URL("tui.ts", sources.url)
await Bun.write(
new URL("node_modules/effect/package.json", sources.url),
'{"name":"effect","version":"4.0.0-rc.111","type":"module","exports":{".":"./index.js","./Option":"./Option.js","./RemovedSubpath":"./RemovedSubpath.js","./package.json":"./package.json"}}',
)
await Bun.write(
new URL("node_modules/effect/index.js", sources.url),
"export const Effect = { foreign: true }; export const Schema = { foreign: true }",
)
await Bun.write(new URL("node_modules/effect/Option.js", sources.url), "export const some = () => null")
await Bun.write(new URL("node_modules/effect/RemovedSubpath.js", sources.url), "export const removed = true")
await Bun.write(
new URL("node_modules/effect-helper/package.json", sources.url),
'{"name":"effect-helper","type":"module","exports":{".":"./index.js"}}',
)
await Bun.write(
new URL("node_modules/effect-helper/index.js", sources.url),
'import { Effect, Schema } from "effect"; import { some } from "effect/Option"; export const helper = { Effect, Schema, some }',
)
await Bun.write(
entry,
`import { createSignal } from "solid-js"
import { Plugin } from "@opencode/plugin/tui"
import { Effect, Schema } from "effect"
import { some } from "effect/Option"
import pkg from "effect/package.json" with { type: "json" }
import { helper } from "effect-helper"
export const plugin = { createSignal, Plugin, Effect, Schema, some, pkgName: pkg.name }
export { helper }`,
)
const loaded = (await sources.read(entry.href)).module as {
plugin: Record<string, unknown>
helper: Record<string, unknown>
}
expect(loaded.plugin.createSignal).toBe(createSignal)
expect(loaded.plugin.Plugin).toBe(Plugin)
expect(loaded.plugin.Effect).toBe(Effect)
expect(loaded.plugin.Schema).toBe(Schema)
expect(loaded.plugin.some).toBe(Option.some)
expect(loaded.plugin.pkgName).toBe("effect")
expect(loaded.helper.Effect).toBe(Effect)
expect(loaded.helper.Schema).toBe(Schema)
expect(loaded.helper.some).toBe(Option.some)
const badEntry = new URL("bad-tui.ts", sources.url)
await Bun.write(
badEntry,
'import { Plugin } from "@opencode/plugin/tui"; import { removed } from "effect/RemovedSubpath"; export default { Plugin, removed }',
)
await expect(sources.read(badEntry.href)).rejects.toThrow("effect/RemovedSubpath.js")
})
test.each(["cli-first", "opentui-first"] as const)(
"resolves host Effect in TUI plugins under %s registration order",
async (order) => {
await using dir = await tmpdir()
await Bun.write(
path.join(dir.path, "node_modules/effect/package.json"),
'{"name":"effect","type":"module","exports":{".":"./index.js","./Brand":"./Brand.js"}}',
)
await Bun.write(path.join(dir.path, "node_modules/effect/index.js"), "export const Effect = { foreign: true }")
await Bun.write(path.join(dir.path, "node_modules/effect/Brand.js"), "export const nominal = () => null")
const pluginEntry = path.join(dir.path, "tui.ts")
await Bun.write(
pluginEntry,
`import { Plugin } from "@opencode/plugin/tui"
import { createSignal } from "solid-js"
import { Effect } from "effect"
import { nominal } from "effect/Brand"
export const captured = { Plugin, createSignal, Effect, nominal }`,
)
const probe = path.join(dir.path, "probe.ts")
await Bun.write(
probe,
`import assert from "node:assert/strict"
if (${JSON.stringify(order)} === "cli-first") {
const { ensurePluginRuntime } = await import(${JSON.stringify(fileURLToPath(new URL("../../cli/src/plugin-runtime.ts", import.meta.url)))})
ensurePluginRuntime()
}
await import(${JSON.stringify(fileURLToPath(new URL("../src/plugin/runtime-plugin-support.bun.ts", import.meta.url)))})
const { createPluginSources } = await import(${JSON.stringify(fileURLToPath(new URL("../src/plugin/source.ts", import.meta.url)))})
const { Effect, Brand } = await import("effect")
const { Plugin } = await import("@opencode/plugin/tui")
const { createSignal } = await import(${JSON.stringify(Bun.resolveSync("solid-js", import.meta.dir))})
const sources = createPluginSources(async () => {})
try {
const loaded = (await sources.read(${JSON.stringify(pathToFileURL(pluginEntry).href)})).module as {
captured: Record<string, unknown>
}
assert.equal(loaded.captured.Plugin, Plugin)
assert.equal(loaded.captured.createSignal, createSignal)
assert.equal(loaded.captured.Effect, Effect)
assert.equal(loaded.captured.nominal, Brand.nominal)
console.log(${JSON.stringify(`order passed: ${order}`)})
} finally {
sources.dispose()
}`,
)
const child = Bun.spawn([process.execPath, probe], {
cwd: fileURLToPath(new URL("..", import.meta.url)),
stdout: "pipe",
stderr: "pipe",
})
const [stdout, stderr, exit] = await Promise.all([
new Response(child.stdout).text(),
new Response(child.stderr).text(),
child.exited,
])
expect({ stdout: stdout.trim(), stderr, exit }).toEqual({
stdout: `order passed: ${order}`,
stderr: "",
exit: 0,
})
},
)
test("helper import.meta stays anchored to its source, including assets and resolution", async () => {
await using sources = await fixture()
const entry = new URL("tui.ts", sources.url)
@@ -3,14 +3,11 @@ title: "Effect"
---
`@opencode/plugin/effect` is the Effect-native version of the OpenCode plugin API. Its context operations return
Effects or Streams, callbacks return Effects, and plugin lifetime is represented by `Scope`.
OpenCode provides `effect` (including exported `effect/*` subpaths) and `@opencode/plugin` at runtime when loading
plugins and their `node_modules` dependencies. Install them as peer/dev dependencies for local type-checking and testing
rather than bundling them into your plugin.
Effects or Streams, callbacks return Effects, and plugin lifetime is represented by `Scope`. Install `effect` with the
plugin package.
```sh
bun add --peer @opencode/plugin effect
bun add @opencode/plugin effect
```
Export an Effect plugin from `.opencode/plugins/` to load it automatically.
@@ -1503,7 +1500,7 @@ type Transform<Input> = (callback: (input: Input) => void) => Effect.Effect<Regi
## Publish
A package plugin uses the same default export as a local Effect plugin. Export the Effect implementation from the main
entrypoint and declare `effect` as a `peerDependency`.
entrypoint and declare both runtime dependencies.
```json title="package.json"
{
@@ -1514,24 +1511,12 @@ entrypoint and declare `effect` as a `peerDependency`.
".": "./src/index.ts"
},
"dependencies": {
"@opencode/plugin": "latest"
},
"peerDependencies": {
"effect": ">=4.0.0-rc.112"
"@opencode/plugin": "latest",
"effect": "4.0.0-rc.111"
}
}
```
OpenCode redirects imports of `effect`, exported `effect/*` subpaths, and `@opencode/plugin` (including imports from
dependencies in the plugin's `node_modules`) to the host's runtime instances so fibers, loggers, and `Schema` parsers
share one runtime copy.
- Do not bundle `effect` into the published plugin artifact. If you build with a bundler, mark `effect` and `effect/*`
external.
- Plugins and their `node_modules` dependencies always execute against the host OpenCode release's `effect` instance.
Use `effect` APIs and module paths compatible with the OpenCode release you target; dependencies built on another
`effect` major (such as Effect 3) also receive the host's `effect` and are not supported.
The package entrypoint exports `Plugin.define` with an `effect` function.
```ts title="src/index.ts"
@@ -407,6 +407,18 @@ Stop it.
$ opencode service stop
```
Stop it and use private servers by default. See [Background service](/cli#background-service).
```bash
$ opencode service disable
```
Use it by default again.
```bash
$ opencode service enable
```
Read a setting.
```bash
+12 -4
View File
@@ -58,13 +58,21 @@ opencode --server http://localhost:4096
To make private servers the default for CLI commands instead of starting the shared service, disable it:
```bash
opencode service set disabled true
opencode service disable
opencode
```
This stops the running background service. `--server` still connects to an explicitly selected server; `opencode service start`
can still start the shared service explicitly. To return to automatic service use, run `opencode service unset disabled`.
Pairing requires the shared service and is unavailable while it is disabled.
This stops the running background service. While it is disabled:
- `--server` still connects to an explicitly selected server.
- `opencode service start` still starts the shared service explicitly.
- Pairing is unavailable, because it requires the shared service.
To return to automatic service use, enable it again:
```bash
opencode service enable
```
See [Troubleshooting](/troubleshooting) for shared service diagnostics and the [API reference](/api) for server endpoints.