mirror of
https://github.com/anomalyco/opencode.git
synced 2026-10-06 07:26:22 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5467c7bf31 |
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, pluginRuntimeLoaderCode } from "../src/plugin-runtime"
|
||||
import { buildAppArchive } from "./app-assets"
|
||||
import { verifyArtifact, verifySimulationGraph } from "./verify-artifact"
|
||||
import { resolveOpencodePty } from "./opencode-pty"
|
||||
@@ -79,31 +78,6 @@ const appAssetsPlugin: BunPlugin = {
|
||||
}))
|
||||
},
|
||||
}
|
||||
const pluginRuntimeEntries = discoverPluginRuntimeSpecifiers()
|
||||
const pluginRuntimeTemplate = await Bun.file("./src/plugin-runtime.ts").text()
|
||||
const pluginRuntimeMarker =
|
||||
"const prebundledModules: Readonly<Record<string, RuntimeModuleLoader>> | undefined = undefined"
|
||||
if (!pluginRuntimeTemplate.includes(pluginRuntimeMarker)) {
|
||||
throw new Error("Missing prebundledModules marker in packages/cli/src/plugin-runtime.ts")
|
||||
}
|
||||
const pluginRuntimeSource = pluginRuntimeTemplate.replace(
|
||||
pluginRuntimeMarker,
|
||||
`const prebundledModules: Readonly<Record<string, RuntimeModuleLoader>> | undefined = {\n${[...pluginRuntimeEntries.keys()].map((specifier) => ` ${JSON.stringify(specifier)}: ${pluginRuntimeLoaderCode(specifier, pluginRuntimeEntries)},`).join("\n")}\n}`,
|
||||
)
|
||||
const pluginRuntimePlugin: BunPlugin = {
|
||||
name: "opencode-plugin-runtime",
|
||||
setup(build) {
|
||||
build.onLoad({ filter: /cli[/\\]src[/\\]plugin-runtime\.ts$/ }, () => ({
|
||||
contents: pluginRuntimeSource,
|
||||
loader: "ts",
|
||||
}))
|
||||
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({
|
||||
@@ -150,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,
|
||||
|
||||
@@ -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))))
|
||||
|
||||
@@ -1,196 +0,0 @@
|
||||
import { existsSync, realpathSync } from "node:fs"
|
||||
import path from "node:path"
|
||||
import { fileURLToPath, pathToFileURL } from "node:url"
|
||||
|
||||
type RuntimeModuleLoader = () => Record<string, unknown> | Promise<Record<string, unknown>>
|
||||
|
||||
const runtimeModulesKey = Symbol.for("opencode.plugin.runtime-modules")
|
||||
const prebundledModules: Readonly<Record<string, RuntimeModuleLoader>> | undefined = undefined
|
||||
|
||||
type GlobalState = typeof globalThis & {
|
||||
[runtimeModulesKey]?: Readonly<Record<string, RuntimeModuleLoader>>
|
||||
}
|
||||
|
||||
export function discoverPluginRuntimeSpecifiers(
|
||||
from = import.meta.dir,
|
||||
packages: readonly string[] = ["effect", "@opencode/plugin"],
|
||||
): ReadonlyMap<string, string> {
|
||||
const entries = new Map<string, string>()
|
||||
for (const pkgName of packages) {
|
||||
const realDir = path.dirname(Bun.resolveSync(`${pkgName}/package.json`, from))
|
||||
const loadDir = findNodeModulesDir(pkgName, from, realDir)
|
||||
const toLoadPath = (resolved: string) =>
|
||||
loadDir === realDir ? resolved : path.join(loadDir, path.relative(realDir, resolved))
|
||||
const rootEntry = Bun.resolveSync(pkgName, from)
|
||||
const relParts = path.relative(realDir, rootEntry).replaceAll("\\", "/").split("/")
|
||||
const scanDir = relParts.length > 1 ? path.join(realDir, relParts[0]) : realDir
|
||||
const ext = path.extname(rootEntry) || ".js"
|
||||
entries.set(pkgName, toLoadPath(rootEntry))
|
||||
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
|
||||
const candidates = base.endsWith("/index")
|
||||
? [`${pkgName}/${base.slice(0, -"/index".length)}`, `${pkgName}/${base}`]
|
||||
: [`${pkgName}/${base}`]
|
||||
for (const specifier of candidates) {
|
||||
try {
|
||||
entries.set(specifier, toLoadPath(Bun.resolveSync(specifier, from)))
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
export function pluginRuntimeLoaderCode(specifier: string, entries: ReadonlyMap<string, string>) {
|
||||
if (specifier.startsWith("effect/")) {
|
||||
const slash = specifier.lastIndexOf("/")
|
||||
const parent = specifier.slice(0, slash)
|
||||
const member = specifier.slice(slash + 1)
|
||||
const parentResolved = entries.get(parent)
|
||||
const resolved = entries.get(specifier)
|
||||
if (
|
||||
member !== "index" &&
|
||||
parentResolved &&
|
||||
resolved &&
|
||||
(require(parentResolved) as Record<string, unknown>)[member] === require(resolved)
|
||||
) {
|
||||
return `() => require(${JSON.stringify(parent)})[${JSON.stringify(member)}]`
|
||||
}
|
||||
}
|
||||
return `() => require(${JSON.stringify(specifier)})`
|
||||
}
|
||||
|
||||
export function ensurePluginRuntime() {
|
||||
if (typeof Bun === "undefined") return {}
|
||||
const state = globalThis as GlobalState
|
||||
if (state[runtimeModulesKey]) return state[runtimeModulesKey]
|
||||
const modules =
|
||||
prebundledModules ??
|
||||
(() => {
|
||||
const entries = discoverPluginRuntimeSpecifiers()
|
||||
const effectEntry = entries.get("effect")
|
||||
if (effectEntry) require(effectEntry)
|
||||
return Object.fromEntries(
|
||||
[...entries.entries()].map(([specifier, resolved]) => [specifier, createLoader(resolved)]),
|
||||
)
|
||||
})()
|
||||
state[runtimeModulesKey] = modules
|
||||
const foreignFilter = createForeignPackageFilter()
|
||||
const hostPluginDir = prebundledModules
|
||||
? undefined
|
||||
: path.dirname(Bun.resolveSync("@opencode/plugin/package.json", import.meta.dir))
|
||||
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" }
|
||||
})
|
||||
}
|
||||
// Temporary until OpenTUI preserves host specifiers (anomalyco/opentui#1569).
|
||||
build.onResolve({ filter: /^(?:file:\/\/|\/|[A-Za-z]:[/\\])/ }, (args) => {
|
||||
if (!args.importer || args.importer === import.meta.path) return undefined
|
||||
const matched = resolveRewrittenHostSpecifier(args.path, modules, hostPluginDir)
|
||||
return matched ? { path: matched } : undefined
|
||||
})
|
||||
build.onLoad({ filter: foreignFilter }, (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
|
||||
}
|
||||
|
||||
export function createLoader(resolved: string): RuntimeModuleLoader {
|
||||
let cached: Record<string, unknown> | undefined
|
||||
let pending: Promise<Record<string, unknown>> | undefined
|
||||
return () => {
|
||||
if (cached) return cached
|
||||
if (pending) return pending
|
||||
try {
|
||||
return (cached = require(resolved) as Record<string, unknown>)
|
||||
} catch {
|
||||
return (pending = import(pathToFileURL(resolved).href).then(
|
||||
(mod: Record<string, unknown>) => (cached = mod),
|
||||
(error) => {
|
||||
pending = undefined
|
||||
throw error
|
||||
},
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createForeignPackageFilter(rootsInput?: Iterable<string>) {
|
||||
const suffix = String.raw`[/\\]node_modules[/\\](?:effect|@opencode[/\\]plugin)[/\\].*\.[cm]?[jt]sx?(?:[?#].*)?$`
|
||||
if (prebundledModules && !rootsInput) return new RegExp(suffix)
|
||||
const roots = new Set<string>(rootsInput)
|
||||
if (!rootsInput) {
|
||||
for (const pkgName of ["effect", "@opencode/plugin"]) {
|
||||
const dir = path.dirname(Bun.resolveSync(`${pkgName}/package.json`, import.meta.dir))
|
||||
roots.add(dir)
|
||||
roots.add(findNodeModulesDir(pkgName, import.meta.dir, dir))
|
||||
}
|
||||
}
|
||||
const escaped = [...roots]
|
||||
.map((value) =>
|
||||
value
|
||||
.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
||||
.replace(/(?:\\\/|\\\\|\/)+/g, "[/\\\\]"),
|
||||
)
|
||||
.join("|")
|
||||
return new RegExp(`^(?!(?:${escaped})[/\\\\]).*${suffix}`)
|
||||
}
|
||||
|
||||
function findNodeModulesDir(pkgName: string, from: string, realDir: string) {
|
||||
if (/[/\\]node_modules[/\\]/.test(realDir)) return realDir
|
||||
for (let dir = path.resolve(from); ; dir = path.dirname(dir)) {
|
||||
const candidate = path.join(dir, "node_modules", pkgName)
|
||||
if (existsSync(candidate) && realpathSync(candidate) === realDir) return candidate
|
||||
if (path.dirname(dir) === dir) return realDir
|
||||
}
|
||||
}
|
||||
|
||||
function resolveRewrittenHostSpecifier(
|
||||
specifier: string,
|
||||
modules: Readonly<Record<string, unknown>>,
|
||||
hostPluginDir?: string,
|
||||
) {
|
||||
const targetPath = specifier.startsWith("file://") ? fileURLToPath(specifier) : specifier
|
||||
const match = targetPath.match(/^(.*)[/\\]node_modules[/\\](@opencode[/\\]plugin|effect)[/\\](.+)$/)
|
||||
const rel = !match && hostPluginDir ? path.relative(hostPluginDir, targetPath) : undefined
|
||||
if (!match && (!rel || rel.startsWith("..") || path.isAbsolute(rel))) return undefined
|
||||
if (!existsSync(targetPath)) return undefined
|
||||
const ownerDir = match ? match[1] || "/" : import.meta.dir
|
||||
const pkgName = match ? match[2].replaceAll("\\", "/") : "@opencode/plugin"
|
||||
const rawSubpath = match ? match[3] : rel!
|
||||
const targetReal = realpathSync(targetPath)
|
||||
const base = rawSubpath
|
||||
.replaceAll("\\", "/")
|
||||
.replace(/^(?:dist(?:\/(?:esm|cjs))?|src)\//, "")
|
||||
.replace(/\.[cm]?[jt]sx?(?:[?#].*)?$/, "")
|
||||
const candidates = base.endsWith("/index")
|
||||
? [pkgName, `${pkgName}/${base.slice(0, -"/index".length)}`, `${pkgName}/${base}`]
|
||||
: [pkgName, `${pkgName}/${base}`]
|
||||
return candidates.find((candidate) => {
|
||||
if (!(candidate in modules)) return false
|
||||
try {
|
||||
return Bun.resolveSync(candidate, ownerDir) === targetReal
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
|
||||
|
||||
@@ -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,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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Agent } from "../../agent.js"
|
||||
import { define } from "@opencode/plugin/effect/plugin"
|
||||
import { Provider } from "../../provider.js"
|
||||
import { SessionAffinity } from "../../session/affinity.js"
|
||||
import { Variant } from "../../variant.js"
|
||||
import type { PluginInternal } from "../internal.js"
|
||||
|
||||
const clientID = "Ov23li8tweQw6odWQebz"
|
||||
@@ -228,6 +229,22 @@ export const GithubCopilotPlugin = define({
|
||||
}
|
||||
for (const id of item.models.keys()) {
|
||||
evt.models.update(item.provider.id, id, (model) => {
|
||||
// Without the live model list there is no advertised /v1/messages endpoint, but Claude
|
||||
// still needs it: over chat completions each thinking tool turn adds another
|
||||
// reasoning_opaque, which the Copilot chat model rejects.
|
||||
if ((model.modelID ?? model.id).startsWith("claude-")) {
|
||||
const efforts = model.variants.flatMap((variant) =>
|
||||
typeof variant.settings?.reasoningEffort === "string" ? [variant.settings.reasoningEffort] : [],
|
||||
)
|
||||
model.package = "@opencode/ai/providers/anthropic"
|
||||
model.settings = Provider.mergeOverlay(model.settings, { baseURL: `${loaded.baseURL ?? baseURL()}/v1` })
|
||||
// Catalog variants were spelled for chat completions; Messages ignores reasoningEffort.
|
||||
model.variants = [
|
||||
...model.variants.filter((variant) => typeof variant.settings?.reasoningEffort !== "string"),
|
||||
...Variant.resolve(model, efforts.length ? [{ type: "effort", values: efforts }] : undefined),
|
||||
]
|
||||
return
|
||||
}
|
||||
model.package = Provider.aisdk("@ai-sdk/github-copilot")
|
||||
if (loaded.baseURL) model.settings = Provider.mergeOverlay(model.settings, { baseURL: loaded.baseURL })
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
+744
-1553
File diff suppressed because it is too large.
Load diff
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -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()
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -1,36 +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 {
|
||||
createForeignPackageFilter,
|
||||
createLoader,
|
||||
discoverPluginRuntimeSpecifiers,
|
||||
ensurePluginRuntime,
|
||||
pluginRuntimeLoaderCode,
|
||||
} 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)),
|
||||
),
|
||||
@@ -109,298 +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" }
|
||||
|
||||
// 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")
|
||||
const originalInternalEffect = await Bun.file(internalEffectPath).text()
|
||||
const logPattern =
|
||||
/const logLevel = level \?\? fiber\.(?:currentLogLevel|cache\.logLevel);\r?\n\s*if \(isLogLevelGreaterThan\(fiber\.(?:minimumLogLevel|cache\.minimumLogLevel), logLevel\)\) \{/
|
||||
const runPromisePattern = /const runPromiseExit = runPromiseExitWith\(context\);/
|
||||
expect(logPattern.test(originalInternalEffect)).toBe(true)
|
||||
expect(runPromisePattern.test(originalInternalEffect)).toBe(true)
|
||||
|
||||
await writeFiles(pluginDir, {
|
||||
"node_modules/effect/package.json": JSON.stringify(pkg),
|
||||
"node_modules/effect/dist/internal/effect.js": originalInternalEffect
|
||||
.replace(
|
||||
logPattern,
|
||||
"const logLevel = level ?? fiber.foreignSkew?.logLevel;\n if (isLogLevelGreaterThan(fiber.foreignSkew?.minimumLogLevel, logLevel)) {",
|
||||
)
|
||||
.replace(
|
||||
runPromisePattern,
|
||||
"if (true) return (effect) => Promise.resolve().then(() => { const fiber = {}; return fiber.succeedWith(effect); });\n const runPromiseExit = runPromiseExitWith(context);",
|
||||
),
|
||||
"node_modules/transitive-dep/package.json":
|
||||
'{"name":"transitive-dep","type":"module","exports":{".":"./index.js"}}',
|
||||
"node_modules/transitive-dep/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 }`,
|
||||
"node_modules/@opencode/plugin/package.json":
|
||||
'{"name":"@opencode/plugin","type":"module","exports":{"./effect":"./effect.js","./rpc":"./rpc.js"}}',
|
||||
"node_modules/@opencode/plugin/effect.js": "export const Plugin = { define: (p) => p }",
|
||||
"node_modules/@opencode/plugin/rpc.js": "export const Rpc = { define: (d) => d }",
|
||||
"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(() =>
|
||||
writeFiles(directory.path, {
|
||||
"pkg-json-plugin/node_modules/effect/package.json":
|
||||
'{"name":"effect","version":"4.0.0-rc.111","type":"module","exports":{"./package.json":"./package.json"}}',
|
||||
"pkg-json-plugin/index.ts":
|
||||
'import pkg from "effect/package.json" with { type: "json" }; export const effectPkgName = pkg.name; export default { id: "pkg-json-plugin", async setup() {} }',
|
||||
"removed-subpath-plugin/node_modules/effect/package.json":
|
||||
'{"name":"effect","type":"module","exports":{"./RemovedLegacySubpath":"./RemovedLegacySubpath.js"}}',
|
||||
"removed-subpath-plugin/node_modules/effect/RemovedLegacySubpath.js": "export const legacy = true",
|
||||
"removed-subpath-plugin/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")
|
||||
|
||||
yield* Effect.promise(() =>
|
||||
writeFiles(pluginDir, {
|
||||
"node_modules/v3-dep/node_modules/effect/package.json":
|
||||
'{"name":"effect","version":"3.19.19","type":"module","exports":{".":"./index.js","./Option":"./Option.js","./ReadonlyArray":"./ReadonlyArray.js"}}',
|
||||
"node_modules/v3-dep/node_modules/effect/index.js":
|
||||
"export const Effect = { major: 3 }; export const Schema = { major: 3 }",
|
||||
"node_modules/v3-dep/node_modules/effect/Option.js": "export const some = () => ({ major: 3 })",
|
||||
"node_modules/v3-dep/node_modules/effect/ReadonlyArray.js": "export const fromIterable = () => []",
|
||||
"node_modules/v3-dep/package.json":
|
||||
'{"name":"v3-dep","type":"module","exports":{".":"./index.js","./v3-only":"./v3-only.js"}}',
|
||||
"node_modules/v3-dep/index.js":
|
||||
'import { Effect, Schema } from "effect"; import { some } from "effect/Option"; export const v3DepCaptured = { Effect, Schema, some }',
|
||||
"node_modules/v3-dep/v3-only.js":
|
||||
'import { fromIterable } from "effect/ReadonlyArray"; export { fromIterable }',
|
||||
"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)
|
||||
yield* Effect.promise(async () => {
|
||||
await expect(import(path.join(v3DepDir, "v3-only.js"))).rejects.toThrow("effect/ReadonlyArray.js")
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("discovers exported specifiers from the resolved tree even when dist/ exists, and validates barrel loader routing, async loader dedup, and Windows foreign filters", () =>
|
||||
Effect.gen(function* () {
|
||||
const directory = yield* tmpdirScoped()
|
||||
const consumerDir = path.join(directory.path, "consumer")
|
||||
const jitFixtureDir = path.join(directory.path, "jit-fixture")
|
||||
const asyncModPath = path.join(directory.path, "async-mod.ts")
|
||||
|
||||
yield* Effect.promise(() =>
|
||||
writeFiles(directory.path, {
|
||||
"consumer/node_modules/@opencode/plugin/package.json": JSON.stringify({
|
||||
name: "@opencode/plugin",
|
||||
type: "module",
|
||||
exports: {
|
||||
".": "./src/promise/index.ts",
|
||||
"./effect": "./src/effect/index.ts",
|
||||
"./effect/*": "./src/effect/*.ts",
|
||||
},
|
||||
}),
|
||||
"consumer/node_modules/@opencode/plugin/src/promise/index.ts": "export const root = 'src'",
|
||||
"consumer/node_modules/@opencode/plugin/src/effect/index.ts": "export * as plugin from './plugin.ts'",
|
||||
"consumer/node_modules/@opencode/plugin/src/effect/plugin.ts": "export const leaf = 'src'",
|
||||
"consumer/node_modules/@opencode/plugin/src/unexported.ts": "export const secret = true",
|
||||
"consumer/node_modules/@opencode/plugin/dist/promise/index.js": "export const root = 'dist'",
|
||||
"consumer/node_modules/@opencode/plugin/dist/effect/index.js": "export const index = 'dist'",
|
||||
"consumer/node_modules/@opencode/plugin/dist/effect/plugin.js": "export const leaf = 'dist'",
|
||||
"jit-fixture/SchemaJITCompiler.js": "export const enable = () => 'fn'",
|
||||
"jit-fixture/enable.js": "export {}",
|
||||
"async-mod.ts": "await new Promise((r) => setTimeout(r, 20)); export const nonce = Math.random()",
|
||||
}),
|
||||
)
|
||||
|
||||
const discovered = discoverPluginRuntimeSpecifiers(consumerDir, ["@opencode/plugin"])
|
||||
expect(discovered.get("@opencode/plugin")?.replaceAll("\\", "/")).toEndWith("src/promise/index.ts")
|
||||
expect(discovered.get("@opencode/plugin/effect")?.replaceAll("\\", "/")).toEndWith("src/effect/index.ts")
|
||||
expect(discovered.get("@opencode/plugin/effect/plugin")?.replaceAll("\\", "/")).toEndWith("src/effect/plugin.ts")
|
||||
expect(discovered.has("@opencode/plugin/unexported")).toBe(false)
|
||||
|
||||
const hostDiscovered = new Map(discoverPluginRuntimeSpecifiers())
|
||||
hostDiscovered.set("effect/schema/SchemaJITCompiler", path.join(jitFixtureDir, "SchemaJITCompiler.js"))
|
||||
hostDiscovered.set("effect/schema/SchemaJITCompiler/enable", path.join(jitFixtureDir, "enable.js"))
|
||||
expect(pluginRuntimeLoaderCode("effect/Option", hostDiscovered)).toBe('() => require("effect")["Option"]')
|
||||
expect(pluginRuntimeLoaderCode("effect/testing", hostDiscovered)).toBe('() => require("effect/testing")')
|
||||
expect(pluginRuntimeLoaderCode("effect/unstable/http/MultipartParser/HeadersParser", hostDiscovered)).toBe(
|
||||
'() => require("effect/unstable/http/MultipartParser/HeadersParser")',
|
||||
)
|
||||
expect(pluginRuntimeLoaderCode("effect/schema/SchemaJITCompiler/enable", hostDiscovered)).toBe(
|
||||
'() => require("effect/schema/SchemaJITCompiler/enable")',
|
||||
)
|
||||
|
||||
const loadAsync = createLoader(asyncModPath)
|
||||
const first = loadAsync()
|
||||
const second = loadAsync()
|
||||
expect(first).toBeInstanceOf(Promise)
|
||||
expect(first).toBe(second)
|
||||
const [res1, res2] = yield* Effect.promise(() => Promise.all([first, second]))
|
||||
expect(res1).toBe(res2)
|
||||
|
||||
const winFilter = createForeignPackageFilter(["C:\\runner\\_work\\opencode\\node_modules\\effect"])
|
||||
expect(winFilter.test("C:\\runner\\_work\\opencode\\node_modules\\effect\\dist\\index.js")).toBe(false)
|
||||
expect(winFilter.test("C:/runner/_work/opencode/node_modules/effect/dist/index.js")).toBe(false)
|
||||
expect(winFilter.test("C:\\Users\\plugin\\node_modules\\effect\\dist\\index.js")).toBe(true)
|
||||
expect(winFilter.test("C:/Users/plugin/node_modules/effect/dist/index.js")).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
async function writeFiles(root: string, files: Record<string, string>) {
|
||||
await Promise.all(Object.entries(files).map(([file, text]) => Bun.write(path.join(root, file), text)))
|
||||
}
|
||||
@@ -391,6 +391,42 @@ describe("GithubCopilotPlugin", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("routes models.dev fallback Claude models to Anthropic Messages", () =>
|
||||
Effect.gen(function* () {
|
||||
const providers = yield* Provider.Service
|
||||
const models = yield* Model.Service
|
||||
yield* providers.transform((editor) => {
|
||||
editor.update(Provider.ID.githubCopilot, (provider) => {
|
||||
provider.activation = "enabled"
|
||||
})
|
||||
editor.models.update(Provider.ID.githubCopilot, Model.ID.make("claude-opus-5.5"), (model) => {
|
||||
model.package = "@opencode/ai/providers/openai-compatible"
|
||||
model.limit = { context: 200_000, output: 64_000 }
|
||||
model.variants = [
|
||||
{ id: Model.VariantID.make("low"), settings: { reasoningEffort: "low" } },
|
||||
{ id: Model.VariantID.make("max"), settings: { reasoningEffort: "max" } },
|
||||
{ id: Model.VariantID.make("fast"), settings: { speed: "fast" } },
|
||||
]
|
||||
})
|
||||
})
|
||||
yield* addPlugin()
|
||||
const fallback = required(yield* models.get(Provider.ID.githubCopilot, Model.ID.make("claude-opus-5.5")))
|
||||
expect(fallback.package).toBe("@opencode/ai/providers/anthropic")
|
||||
expect(fallback.settings?.baseURL).toBe("https://api.githubcopilot.com/v1")
|
||||
expect(fallback.variants).toEqual([
|
||||
{ id: Model.VariantID.make("fast"), settings: { speed: "fast" } },
|
||||
{
|
||||
id: Model.VariantID.make("low"),
|
||||
settings: { thinking: { type: "adaptive", display: "summarized" }, effort: "low" },
|
||||
},
|
||||
{
|
||||
id: Model.VariantID.make("max"),
|
||||
settings: { thinking: { type: "adaptive", display: "summarized" }, effort: "max" },
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("selects languageModel when responses and chat are absent", () =>
|
||||
Effect.gen(function* () {
|
||||
const aisdk = yield* AISDK.Service
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
},
|
||||
)
|
||||
})
|
||||
@@ -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"] })
|
||||
})
|
||||
})
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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.
|
||||
@@ -7,11 +7,6 @@ import { localSource } from "./source.js"
|
||||
import { missingPackageTarget } from "./source.package.js"
|
||||
|
||||
let generation = Date.now()
|
||||
const runtimeModulesKey = Symbol.for("opencode.plugin.runtime-modules")
|
||||
|
||||
type GlobalState = typeof globalThis & {
|
||||
[runtimeModulesKey]?: Readonly<Record<string, unknown>>
|
||||
}
|
||||
|
||||
export async function prepareSource(entrypoint: string, track: (file: string, directory?: boolean) => void) {
|
||||
const root = fileURLToPath(entrypoint)
|
||||
@@ -44,7 +39,6 @@ export async function prepareSource(entrypoint: string, track: (file: string, di
|
||||
? new URL(item.path, pathToFileURL(file))
|
||||
: localSource(item.path, path.dirname(file))
|
||||
if (!local) {
|
||||
if ((globalThis as GlobalState)[runtimeModulesKey]?.[item.path]) continue
|
||||
try {
|
||||
Bun.resolveSync(item.path, path.dirname(file))
|
||||
} catch {
|
||||
|
||||
@@ -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]) => {
|
||||
|
||||
@@ -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,8 +1,6 @@
|
||||
import { Plugin, PluginContextProvider, usePlugin } from "@opencode/plugin/tui"
|
||||
import { ensureRuntimePluginSupport } from "@opentui/solid/runtime-plugin-support/configure"
|
||||
import { ensurePluginRuntime } from "../../../cli/src/plugin-runtime"
|
||||
|
||||
ensurePluginRuntime()
|
||||
ensureRuntimePluginSupport({
|
||||
additional: {
|
||||
"@opencode/plugin/tui": { Plugin, PluginContextProvider, usePlugin },
|
||||
|
||||
@@ -126,6 +126,7 @@ function GroupContent(props: GroupProps) {
|
||||
)
|
||||
return `${completed() ? "Explored" : "Exploring"}: ${names.join(", ")}`
|
||||
})
|
||||
const failed = () => tools().some((part) => part.state.status === "error")
|
||||
const toggle = disclosure.toggle
|
||||
const children = (mode: "normal" | "thought" | "tool") => (
|
||||
<Children {...props} nodes={props.node.children} mode={mode} />
|
||||
@@ -142,7 +143,7 @@ function GroupContent(props: GroupProps) {
|
||||
<Show when={grouped()} fallback={children("normal")}>
|
||||
<Show when={tools().length > 0}>
|
||||
<InlineToolRow
|
||||
icon={completed() ? "→" : "✱"}
|
||||
icon={failed() ? "✗" : completed() ? "→" : "✱"}
|
||||
color={hover() ? theme.text.base : theme.text.muted}
|
||||
complete={completed()}
|
||||
pending={label()}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { createRequire } from "node:module"
|
||||
import path from "node:path"
|
||||
import { mkdir, rename, symlink } from "node:fs/promises"
|
||||
import { fileURLToPath, pathToFileURL } from "node:url"
|
||||
@@ -8,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"
|
||||
|
||||
@@ -140,129 +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 without rewriting host Effect or hijacking plugin dependencies", async () => {
|
||||
const effectPlugin = await import("@opencode/plugin/effect")
|
||||
const req = createRequire(import.meta.url)
|
||||
expect(req("effect").Effect).toBe(Effect)
|
||||
expect(req("effect/Option").some).toBe(Option.some)
|
||||
expect(req("@opencode/plugin/effect").Plugin).toBe(effectPlugin.Plugin)
|
||||
expect(JSON.stringify(Schema.Option(Schema.String).ast)).not.toContain("opentui:runtime-module:")
|
||||
|
||||
const standalone = Bun.spawnSync(
|
||||
[
|
||||
process.execPath,
|
||||
"-e",
|
||||
[
|
||||
`import ${JSON.stringify(fileURLToPath(new URL("../src/plugin/runtime-plugin-support.bun.ts", import.meta.url)))}`,
|
||||
'import assert from "node:assert/strict"',
|
||||
'import { createRequire } from "node:module"',
|
||||
"const req = createRequire(import.meta.url)",
|
||||
'const eff = req("effect")',
|
||||
'const opt = req("effect/Option")',
|
||||
'const plug = req("@opencode/plugin/effect")',
|
||||
'const { Effect } = await import("effect")',
|
||||
'const { some } = await import("effect/Option")',
|
||||
'const { Plugin } = await import("@opencode/plugin/effect")',
|
||||
"assert.equal(eff.Effect, Effect)",
|
||||
"assert.equal(opt.some, some)",
|
||||
"assert.equal(plug.Plugin, Plugin)",
|
||||
].join("\n"),
|
||||
],
|
||||
{ cwd: fileURLToPath(new URL("..", import.meta.url)), stdout: "pipe", stderr: "pipe" },
|
||||
)
|
||||
expect({ stderr: standalone.stderr.toString(), exit: standalone.exitCode }).toEqual({ stderr: "", exit: 0 })
|
||||
|
||||
const watched: string[] = []
|
||||
await using sources = await fixture(async (file) => {
|
||||
watched.push(file)
|
||||
})
|
||||
const entry = new URL("tui.ts", sources.url)
|
||||
const absExtra = new URL("abs-extra.ts", sources.url)
|
||||
await Bun.write(
|
||||
new URL("node_modules/effect/package.json", sources.url),
|
||||
'{"name":"effect","version":"3.19.19","type":"module","exports":{".":{"import":"./dist/esm/index.js"},"./Option":{"import":"./dist/esm/Option.js"},"./RemovedSubpath":{"import":"./dist/esm/RemovedSubpath.js"},"./package.json":"./package.json"}}',
|
||||
)
|
||||
await Bun.write(
|
||||
new URL("node_modules/effect/dist/esm/index.js", sources.url),
|
||||
"export const Effect = { foreign: true }; export const Schema = { foreign: true }",
|
||||
)
|
||||
await Bun.write(new URL("node_modules/effect/dist/esm/Option.js", sources.url), "export const some = () => null")
|
||||
await Bun.write(
|
||||
new URL("node_modules/effect/dist/esm/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 }',
|
||||
].join(" "),
|
||||
)
|
||||
await Bun.write(
|
||||
new URL("node_modules/zod/package.json", sources.url),
|
||||
'{"name":"zod","type":"module","exports":{".":"./index.js"}}',
|
||||
)
|
||||
await Bun.write(new URL("node_modules/zod/index.js", sources.url), "export const fromPluginZod = true")
|
||||
await Bun.write(
|
||||
new URL("helper.ts", sources.url),
|
||||
['import { fromPluginZod }', 'from "zod"; export { fromPluginZod }'].join(" "),
|
||||
)
|
||||
await Bun.write(absExtra, 'export const absValue = "own-abs-file"')
|
||||
await Bun.write(
|
||||
entry,
|
||||
[
|
||||
'import { createSignal }',
|
||||
'from "solid-js"\nimport { Plugin }',
|
||||
'from "@opencode/plugin/tui"\nimport { Plugin as HostEffectPlugin }',
|
||||
'from "@opencode/plugin/effect"\nimport { Effect, Schema }',
|
||||
'from "effect"\nimport { some }',
|
||||
'from "effect/Option"\nimport pkg',
|
||||
'from "effect/package.json" with { type: "json" }\nimport { helper }',
|
||||
'from "effect-helper"\nconst { fromPluginZod } = await import("./helper.ts")',
|
||||
'\nconst dynOption = await import("effect/Option")',
|
||||
'\nconst dynEffectPlugin = await import("@opencode/plugin/effect")',
|
||||
`\nconst { absValue } = await import(${JSON.stringify(fileURLToPath(absExtra))})`,
|
||||
"\nexport const plugin = { createSignal, Plugin, HostEffectPlugin, Effect, Schema, some, dynSome: dynOption.some, dynEffectPlugin: dynEffectPlugin.Plugin, absValue, pkgName: pkg.name, fromPluginZod }\nexport { helper }",
|
||||
].join(" "),
|
||||
)
|
||||
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.HostEffectPlugin).toBe(effectPlugin.Plugin)
|
||||
expect(loaded.plugin.Effect).toBe(Effect)
|
||||
expect(loaded.plugin.Schema).toBe(Schema)
|
||||
expect(loaded.plugin.some).toBe(Option.some)
|
||||
expect(loaded.plugin.dynSome).toBe(Option.some)
|
||||
expect(loaded.plugin.dynEffectPlugin).toBe(effectPlugin.Plugin)
|
||||
expect(loaded.plugin.absValue).toBe("own-abs-file")
|
||||
expect(loaded.plugin.pkgName).toBe("effect")
|
||||
expect(loaded.plugin.fromPluginZod).toBe(true)
|
||||
expect(loaded.helper.Effect).toBe(Effect)
|
||||
expect(loaded.helper.Schema).toBe(Schema)
|
||||
expect(loaded.helper.some).toBe(Option.some)
|
||||
expect(watched.some((item) => item.replaceAll("\\", "/").endsWith("/node_modules/effect"))).toBe(false)
|
||||
expect(watched.some((item) => item.replaceAll("\\", "/").endsWith("/node_modules/@opencode/plugin"))).toBe(false)
|
||||
|
||||
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 }',
|
||||
].join(" "),
|
||||
)
|
||||
await expect(sources.read(badEntry.href)).rejects.toThrow("effect/dist/esm/RemovedSubpath.js")
|
||||
})
|
||||
|
||||
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"
|
||||
|
||||
Reference in new issue
Block a user