Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff6f032faa | ||
|
|
d3a69ad910 | ||
|
|
1c3950111a |
@@ -29,7 +29,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.14.45",
|
||||
"version": "1.14.44",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
@@ -85,7 +85,7 @@
|
||||
},
|
||||
"packages/console/app": {
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.14.45",
|
||||
"version": "1.14.44",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@ibm/plex": "6.4.1",
|
||||
@@ -120,7 +120,7 @@
|
||||
},
|
||||
"packages/console/core": {
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.14.45",
|
||||
"version": "1.14.44",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sts": "3.782.0",
|
||||
"@jsx-email/render": "1.1.1",
|
||||
@@ -147,7 +147,7 @@
|
||||
},
|
||||
"packages/console/function": {
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.14.45",
|
||||
"version": "1.14.44",
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "3.0.64",
|
||||
"@ai-sdk/openai": "3.0.48",
|
||||
@@ -171,7 +171,7 @@
|
||||
},
|
||||
"packages/console/mail": {
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.14.45",
|
||||
"version": "1.14.44",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
@@ -195,7 +195,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@opencode-ai/core",
|
||||
"version": "1.14.45",
|
||||
"version": "1.14.44",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -229,7 +229,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode-ai/desktop",
|
||||
"version": "1.14.45",
|
||||
"version": "1.14.44",
|
||||
"dependencies": {
|
||||
"drizzle-orm": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -283,7 +283,7 @@
|
||||
},
|
||||
"packages/enterprise": {
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.14.45",
|
||||
"version": "1.14.44",
|
||||
"dependencies": {
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
@@ -313,7 +313,7 @@
|
||||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.14.45",
|
||||
"version": "1.14.44",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "catalog:",
|
||||
@@ -329,7 +329,7 @@
|
||||
},
|
||||
"packages/http-recorder": {
|
||||
"name": "@opencode-ai/http-recorder",
|
||||
"version": "1.14.45",
|
||||
"version": "1.14.44",
|
||||
"dependencies": {
|
||||
"@effect/platform-node": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -342,7 +342,7 @@
|
||||
},
|
||||
"packages/llm": {
|
||||
"name": "@opencode-ai/llm",
|
||||
"version": "1.14.45",
|
||||
"version": "1.14.44",
|
||||
"dependencies": {
|
||||
"@smithy/eventstream-codec": "4.2.14",
|
||||
"@smithy/util-utf8": "4.2.2",
|
||||
@@ -360,7 +360,7 @@
|
||||
},
|
||||
"packages/opencode": {
|
||||
"name": "opencode",
|
||||
"version": "1.14.45",
|
||||
"version": "1.14.44",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -495,7 +495,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.14.45",
|
||||
"version": "1.14.44",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"effect": "catalog:",
|
||||
@@ -533,7 +533,7 @@
|
||||
},
|
||||
"packages/sdk/js": {
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.14.45",
|
||||
"version": "1.14.44",
|
||||
"dependencies": {
|
||||
"cross-spawn": "catalog:",
|
||||
},
|
||||
@@ -548,7 +548,7 @@
|
||||
},
|
||||
"packages/slack": {
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.14.45",
|
||||
"version": "1.14.44",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@slack/bolt": "^3.17.1",
|
||||
@@ -583,7 +583,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.14.45",
|
||||
"version": "1.14.44",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
@@ -632,7 +632,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode-ai/web",
|
||||
"version": "1.14.45",
|
||||
"version": "1.14.44",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.14.45",
|
||||
"version": "1.14.44",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.14.45",
|
||||
"version": "1.14.44",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.14.45",
|
||||
"version": "1.14.44",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.14.45",
|
||||
"version": "1.14.44",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.14.45",
|
||||
"version": "1.14.44",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.14.45",
|
||||
"version": "1.14.44",
|
||||
"name": "@opencode-ai/core",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode-ai/desktop",
|
||||
"private": true,
|
||||
"version": "1.14.45",
|
||||
"version": "1.14.44",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"homepage": "https://opencode.ai",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.14.45",
|
||||
"version": "1.14.44",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
id = "opencode"
|
||||
name = "OpenCode"
|
||||
description = "The open source coding agent."
|
||||
version = "1.14.45"
|
||||
version = "1.14.44"
|
||||
schema_version = 1
|
||||
authors = ["Anomaly"]
|
||||
repository = "https://github.com/anomalyco/opencode"
|
||||
@@ -11,26 +11,26 @@ name = "OpenCode"
|
||||
icon = "./icons/opencode.svg"
|
||||
|
||||
[agent_servers.opencode.targets.darwin-aarch64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.45/opencode-darwin-arm64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.44/opencode-darwin-arm64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.darwin-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.45/opencode-darwin-x64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.44/opencode-darwin-x64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-aarch64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.45/opencode-linux-arm64.tar.gz"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.44/opencode-linux-arm64.tar.gz"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.45/opencode-linux-x64.tar.gz"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.44/opencode-linux-x64.tar.gz"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.windows-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.45/opencode-windows-x64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.44/opencode-windows-x64.zip"
|
||||
cmd = "./opencode.exe"
|
||||
args = ["acp"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.14.45",
|
||||
"version": "1.14.44",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.14.45",
|
||||
"version": "1.14.44",
|
||||
"name": "@opencode-ai/http-recorder",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.14.45",
|
||||
"version": "1.14.44",
|
||||
"name": "@opencode-ai/llm",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.14.45",
|
||||
"version": "1.14.44",
|
||||
"name": "opencode",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import type { Permission } from "../permission"
|
||||
import type { Agent } from "./agent"
|
||||
|
||||
/**
|
||||
* Build the `permission` ruleset for a subagent's session when it's spawned
|
||||
* via the task tool. Combines:
|
||||
*
|
||||
* 1. The parent **agent's** deny rules — Plan Mode and other agent-level
|
||||
* restrictions live on the agent ruleset, not on the session, so a
|
||||
* subagent that only inherited the parent SESSION's permission would
|
||||
* silently bypass them. (#26514)
|
||||
* 2. The parent **session's** deny rules and external_directory rules —
|
||||
* same forwarding the original code already did.
|
||||
* 3. Default `todowrite` and `task` denies if the subagent's own ruleset
|
||||
* doesn't already permit them.
|
||||
*/
|
||||
export function deriveSubagentSessionPermission(input: {
|
||||
parentSessionPermission: Permission.Ruleset
|
||||
parentAgent: Agent.Info | undefined
|
||||
subagent: Agent.Info
|
||||
}): Permission.Ruleset {
|
||||
const canTask = input.subagent.permission.some((rule) => rule.permission === "task")
|
||||
const canTodo = input.subagent.permission.some((rule) => rule.permission === "todowrite")
|
||||
const parentAgentDenies = input.parentAgent?.permission.filter((rule) => rule.action === "deny") ?? []
|
||||
return [
|
||||
...parentAgentDenies,
|
||||
...input.parentSessionPermission.filter(
|
||||
(rule) => rule.permission === "external_directory" || rule.action === "deny",
|
||||
),
|
||||
...(canTodo ? [] : [{ permission: "todowrite" as const, pattern: "*" as const, action: "deny" as const }]),
|
||||
...(canTask ? [] : [{ permission: "task" as const, pattern: "*" as const, action: "deny" as const }]),
|
||||
]
|
||||
}
|
||||
@@ -2,13 +2,10 @@ export * as ConfigParse from "./parse"
|
||||
|
||||
import { type ParseError as JsoncParseError, parse as parseJsoncImpl, printParseErrorCode } from "jsonc-parser"
|
||||
import { Cause, Exit, Schema as EffectSchema, SchemaIssue } from "effect"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import z from "zod"
|
||||
import type { DeepMutable } from "@opencode-ai/core/schema"
|
||||
import { InvalidError, JsonError } from "./error"
|
||||
|
||||
const log = Log.create({ service: "config.parse" })
|
||||
|
||||
type ZodSchema<T> = z.ZodType<T>
|
||||
|
||||
export function jsonc(text: string, filepath: string): unknown {
|
||||
@@ -53,70 +50,34 @@ export function effectSchema<S extends EffectSchema.Decoder<unknown, never>>(
|
||||
data: unknown,
|
||||
source: string,
|
||||
): DeepMutable<S["Type"]> {
|
||||
// The user's config lives on disk and may legitimately be stale, hand-edited,
|
||||
// or carry leftover keys from older versions. Crashing the whole load on a
|
||||
// single bad field would make opencode unstartable for those users (see Ben
|
||||
// Matthews / Discord, v1.14.45). Strip the malformed top-level fields and
|
||||
// keep going — log every drop so users can see what was ignored and fix it.
|
||||
const cleaned = stripUnknownTopLevelKeys(schema, data, source)
|
||||
return decodeWithFieldTolerance(schema, cleaned, source)
|
||||
}
|
||||
|
||||
function stripUnknownTopLevelKeys(schema: EffectSchema.Top, data: unknown, source: string): unknown {
|
||||
if (typeof data !== "object" || data === null || Array.isArray(data)) return data
|
||||
const extra = topLevelExtraKeys(schema, data)
|
||||
if (extra.length === 0) return data
|
||||
log.warn("ignoring unrecognized config keys", { source, keys: extra })
|
||||
const obj = data as Record<string, unknown>
|
||||
return Object.fromEntries(Object.entries(obj).filter(([key]) => !extra.includes(key)))
|
||||
}
|
||||
if (extra.length) {
|
||||
throw new InvalidError({
|
||||
path: source,
|
||||
issues: [
|
||||
{
|
||||
code: "unrecognized_keys",
|
||||
keys: extra,
|
||||
path: [],
|
||||
message: `Unrecognized key${extra.length === 1 ? "" : "s"}: ${extra.join(", ")}`,
|
||||
} as z.core.$ZodIssue,
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
function decodeWithFieldTolerance<S extends EffectSchema.Decoder<unknown, never>>(
|
||||
schema: S,
|
||||
data: unknown,
|
||||
source: string,
|
||||
): DeepMutable<S["Type"]> {
|
||||
// Try a clean decode first. If it succeeds we're done — common path.
|
||||
const decoded = EffectSchema.decodeUnknownExit(schema)(data, { errors: "all", propertyOrder: "original" })
|
||||
if (Exit.isSuccess(decoded)) return decoded.value as DeepMutable<S["Type"]>
|
||||
const error = Cause.squash(decoded.cause)
|
||||
const issues = EffectSchema.isSchemaError(error)
|
||||
? (SchemaIssue.makeFormatterStandardSchemaV1()(error.issue).issues as z.core.$ZodIssue[])
|
||||
: ([{ code: "custom", message: String(error), path: [] }] as z.core.$ZodIssue[])
|
||||
|
||||
// Identify malformed top-level fields. Anything with a non-empty path is a
|
||||
// field-scoped issue we can drop and retry. Issues with an empty path are
|
||||
// root-level (e.g. data is not an object at all) and can't be field-recovered.
|
||||
const badFields = collectTopLevelFieldNames(issues)
|
||||
if (badFields.size === 0 || typeof data !== "object" || data === null || Array.isArray(data)) {
|
||||
throw new InvalidError({ path: source, issues }, { cause: error })
|
||||
}
|
||||
|
||||
log.warn("ignoring invalid config fields", {
|
||||
source,
|
||||
fields: [...badFields],
|
||||
summary: issues
|
||||
.filter((issue) => issue.path && issue.path.length > 0)
|
||||
.map((issue) => `${issue.path?.join(".")}: ${issue.message}`)
|
||||
.slice(0, 8),
|
||||
})
|
||||
|
||||
const obj = data as Record<string, unknown>
|
||||
const cleaned = Object.fromEntries(Object.entries(obj).filter(([key]) => !badFields.has(key)))
|
||||
// Retry without the bad fields. If THIS fails, we're past field-tolerance —
|
||||
// fall back to the original strict error so the user sees the real cause.
|
||||
const retry = EffectSchema.decodeUnknownExit(schema)(cleaned, { errors: "all", propertyOrder: "original" })
|
||||
if (Exit.isSuccess(retry)) return retry.value as DeepMutable<S["Type"]>
|
||||
throw new InvalidError({ path: source, issues }, { cause: error })
|
||||
}
|
||||
|
||||
function collectTopLevelFieldNames(issues: z.core.$ZodIssue[]): Set<string> {
|
||||
const names = new Set<string>()
|
||||
for (const issue of issues) {
|
||||
const head = issue.path?.[0]
|
||||
if (typeof head === "string") names.add(head)
|
||||
}
|
||||
return names
|
||||
throw new InvalidError(
|
||||
{
|
||||
path: source,
|
||||
issues: EffectSchema.isSchemaError(error)
|
||||
? (SchemaIssue.makeFormatterStandardSchemaV1()(error.issue).issues as z.core.$ZodIssue[])
|
||||
: ([{ code: "custom", message: String(error), path: [] }] as z.core.$ZodIssue[]),
|
||||
},
|
||||
{ cause: error },
|
||||
)
|
||||
}
|
||||
|
||||
function topLevelExtraKeys(schema: EffectSchema.Top, data: unknown) {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Schema } from "effect"
|
||||
import { zod } from "@opencode-ai/core/effect-zod"
|
||||
import { PositiveInt, withStatics } from "@opencode-ai/core/schema"
|
||||
import { ModelStatus } from "@/provider/model-status"
|
||||
|
||||
export const Model = Schema.Struct({
|
||||
id: Schema.optional(Schema.String),
|
||||
@@ -50,7 +49,7 @@ export const Model = Schema.Struct({
|
||||
}),
|
||||
),
|
||||
experimental: Schema.optional(Schema.Boolean),
|
||||
status: Schema.optional(ModelStatus),
|
||||
status: Schema.optional(Schema.Literals(["alpha", "beta", "deprecated"])),
|
||||
provider: Schema.optional(
|
||||
Schema.Struct({ npm: Schema.optional(Schema.String), api: Schema.optional(Schema.String) }),
|
||||
),
|
||||
|
||||
@@ -36,6 +36,24 @@ import { withStatics } from "@opencode-ai/core/schema"
|
||||
const log = Log.create({ service: "mcp" })
|
||||
const DEFAULT_TIMEOUT = 30_000
|
||||
|
||||
const TolerantToolSchema = z.looseObject({
|
||||
name: z.string(),
|
||||
description: z.string().optional(),
|
||||
inputSchema: z
|
||||
.object({
|
||||
type: z.literal("object"),
|
||||
properties: z.record(z.string(), z.unknown()).optional(),
|
||||
required: z.array(z.string()).optional(),
|
||||
})
|
||||
.catchall(z.unknown()),
|
||||
outputSchema: z.unknown().optional(),
|
||||
})
|
||||
|
||||
const TolerantListToolsResultSchema = z.looseObject({
|
||||
tools: z.array(TolerantToolSchema),
|
||||
nextCursor: z.string().optional(),
|
||||
})
|
||||
|
||||
export const Resource = Schema.Struct({
|
||||
name: Schema.String,
|
||||
uri: Schema.String,
|
||||
@@ -119,6 +137,32 @@ function remoteURL(key: string, value: string) {
|
||||
log.warn("invalid remote mcp url", { key })
|
||||
}
|
||||
|
||||
function isSchemaReferenceError(err: unknown) {
|
||||
return err instanceof Error && /can't resolve reference|schema.*reference|reference.*schema/i.test(err.message)
|
||||
}
|
||||
|
||||
const wrapAsError = (err: unknown) => (err instanceof Error ? err : new Error(String(err)))
|
||||
|
||||
function listToolsTolerant(key: string, client: MCPClient, timeout: number) {
|
||||
return Effect.tryPromise({
|
||||
try: () => client.listTools(undefined, { timeout }),
|
||||
catch: wrapAsError,
|
||||
}).pipe(
|
||||
Effect.map((result) => result.tools),
|
||||
Effect.catch((err) => {
|
||||
if (!isSchemaReferenceError(err)) return Effect.fail(err)
|
||||
log.warn("failed to validate MCP tool output schemas, retrying without output schema validation", {
|
||||
key,
|
||||
error: err,
|
||||
})
|
||||
return Effect.tryPromise({
|
||||
try: () => client.request({ method: "tools/list" }, TolerantListToolsResultSchema, { timeout }),
|
||||
catch: wrapAsError,
|
||||
}).pipe(Effect.map((result) => result.tools as MCPToolDef[]))
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
// Convert MCP tool definition to AI SDK Tool type
|
||||
function convertMcpTool(mcpTool: MCPToolDef, client: MCPClient, timeout?: number): Tool {
|
||||
const inputSchema = mcpTool.inputSchema
|
||||
@@ -151,11 +195,7 @@ function convertMcpTool(mcpTool: MCPToolDef, client: MCPClient, timeout?: number
|
||||
}
|
||||
|
||||
function defs(key: string, client: MCPClient, timeout?: number) {
|
||||
return Effect.tryPromise({
|
||||
try: () => withTimeout(client.listTools(), timeout ?? DEFAULT_TIMEOUT),
|
||||
catch: (err) => (err instanceof Error ? err : new Error(String(err))),
|
||||
}).pipe(
|
||||
Effect.map((result) => result.tools),
|
||||
return listToolsTolerant(key, client, timeout ?? DEFAULT_TIMEOUT).pipe(
|
||||
Effect.catch((err) => {
|
||||
log.error("failed to get tools from client", { key, error: err })
|
||||
return Effect.succeed(undefined)
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
import { Schema } from "effect"
|
||||
|
||||
export const CatalogModelStatus = Schema.Literals(["alpha", "beta", "deprecated"])
|
||||
export type CatalogModelStatus = typeof CatalogModelStatus.Type
|
||||
|
||||
export const ModelStatus = Schema.Literals(["alpha", "beta", "deprecated", "active"])
|
||||
export type ModelStatus = typeof ModelStatus.Type
|
||||
|
||||
export * as ProviderModelStatus from "./model-status"
|
||||
@@ -8,7 +8,6 @@ import { Flock } from "@opencode-ai/core/util/flock"
|
||||
import { Hash } from "@opencode-ai/core/util/hash"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { withTransientReadRetry } from "@/util/effect-http-client"
|
||||
import { CatalogModelStatus } from "./model-status"
|
||||
|
||||
const Cost = Schema.Struct({
|
||||
input: Schema.Finite,
|
||||
@@ -72,7 +71,7 @@ export const Model = Schema.Struct({
|
||||
),
|
||||
}),
|
||||
),
|
||||
status: Schema.optional(CatalogModelStatus),
|
||||
status: Schema.optional(Schema.Literals(["alpha", "beta", "deprecated"])),
|
||||
provider: Schema.optional(
|
||||
Schema.Struct({ npm: Schema.optional(Schema.String), api: Schema.optional(Schema.String) }),
|
||||
),
|
||||
|
||||
@@ -28,7 +28,6 @@ import { optionalOmitUndefined, withStatics } from "@opencode-ai/core/schema"
|
||||
|
||||
import * as ProviderTransform from "./transform"
|
||||
import { ModelID, ProviderID } from "./schema"
|
||||
import { ModelStatus } from "./model-status"
|
||||
|
||||
const log = Log.create({ service: "provider" })
|
||||
|
||||
@@ -898,7 +897,7 @@ export const Model = Schema.Struct({
|
||||
capabilities: ProviderCapabilities,
|
||||
cost: ProviderCost,
|
||||
limit: ProviderLimit,
|
||||
status: ModelStatus,
|
||||
status: Schema.Literals(["alpha", "beta", "deprecated", "active"]),
|
||||
options: Schema.Record(Schema.String, Schema.Any),
|
||||
headers: Schema.Record(Schema.String, Schema.String),
|
||||
release_date: Schema.String,
|
||||
|
||||
@@ -4,7 +4,6 @@ import { Session } from "@/session/session"
|
||||
import { SessionID, MessageID } from "../session/schema"
|
||||
import { MessageV2 } from "../session/message-v2"
|
||||
import { Agent } from "../agent/agent"
|
||||
import { deriveSubagentSessionPermission } from "../agent/subagent-permissions"
|
||||
import type { SessionPrompt } from "../session/prompt"
|
||||
import { Config } from "@/config/config"
|
||||
import { Effect, Exit, Schema } from "effect"
|
||||
@@ -59,25 +58,41 @@ export const TaskTool = Tool.define(
|
||||
return yield* Effect.fail(new Error(`Unknown agent type: ${params.subagent_type} is not a valid agent type`))
|
||||
}
|
||||
|
||||
const canTask = next.permission.some((rule) => rule.permission === id)
|
||||
const canTodo = next.permission.some((rule) => rule.permission === "todowrite")
|
||||
|
||||
const taskID = params.task_id
|
||||
const session = taskID
|
||||
? yield* sessions.get(SessionID.make(taskID)).pipe(Effect.catchCause(() => Effect.succeed(undefined)))
|
||||
: undefined
|
||||
const parent = yield* sessions.get(ctx.sessionID)
|
||||
const parentAgent = parent.agent
|
||||
? yield* agent.get(parent.agent).pipe(Effect.catchCause(() => Effect.succeed(undefined)))
|
||||
: undefined
|
||||
const nextSession =
|
||||
session ??
|
||||
(yield* sessions.create({
|
||||
parentID: ctx.sessionID,
|
||||
title: params.description + ` (@${next.name} subagent)`,
|
||||
permission: [
|
||||
...deriveSubagentSessionPermission({
|
||||
parentSessionPermission: parent.permission ?? [],
|
||||
parentAgent,
|
||||
subagent: next,
|
||||
}),
|
||||
...(parent.permission ?? []).filter(
|
||||
(rule) => rule.permission === "external_directory" || rule.action === "deny",
|
||||
),
|
||||
...(canTodo
|
||||
? []
|
||||
: [
|
||||
{
|
||||
permission: "todowrite" as const,
|
||||
pattern: "*" as const,
|
||||
action: "deny" as const,
|
||||
},
|
||||
]),
|
||||
...(canTask
|
||||
? []
|
||||
: [
|
||||
{
|
||||
permission: id,
|
||||
pattern: "*" as const,
|
||||
action: "deny" as const,
|
||||
},
|
||||
]),
|
||||
...(cfg.experimental?.primary_tools?.map((item) => ({
|
||||
pattern: "*",
|
||||
action: "allow" as const,
|
||||
@@ -129,8 +144,8 @@ export const TaskTool = Tool.define(
|
||||
},
|
||||
agent: next.name,
|
||||
tools: {
|
||||
...(next.permission.some((rule) => rule.permission === "todowrite") ? {} : { todowrite: false }),
|
||||
...(next.permission.some((rule) => rule.permission === id) ? {} : { task: false }),
|
||||
...(canTodo ? {} : { todowrite: false }),
|
||||
...(canTask ? {} : { task: false }),
|
||||
...Object.fromEntries((cfg.experimental?.primary_tools ?? []).map((item) => [item, false])),
|
||||
},
|
||||
parts,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { withStatics } from "@opencode-ai/core/schema"
|
||||
import { ModelStatus } from "@/provider/model-status"
|
||||
import { Array, Context, Effect, HashMap, Layer, Option, Order, pipe, Schema } from "effect"
|
||||
import { DateTimeUtcFromMillis } from "effect/Schema"
|
||||
|
||||
@@ -115,7 +114,7 @@ export class Info extends Schema.Class<Info>("Model.Info")({
|
||||
released: DateTimeUtcFromMillis,
|
||||
}),
|
||||
cost: Cost.pipe(Schema.Array),
|
||||
status: ModelStatus,
|
||||
status: Schema.Literals(["alpha", "beta", "deprecated", "active"]),
|
||||
limit: Schema.Struct({
|
||||
context: Schema.Int,
|
||||
input: Schema.Int.pipe(Schema.optional),
|
||||
|
||||
@@ -1,141 +0,0 @@
|
||||
/**
|
||||
* Reproducer for opencode issue #26514:
|
||||
*
|
||||
* In Plan Mode (the `plan` agent), the main agent's edit/write tools are
|
||||
* blocked by the plan agent's permission ruleset (`edit: { "*": "deny" }`).
|
||||
* However, when the plan agent spawns a subagent via the `task` tool, the
|
||||
* subagent retains full file modification capabilities — a security bypass.
|
||||
*
|
||||
* This test replicates the permission ruleset that would govern a
|
||||
* `general` subagent when launched from a `plan` parent session, mirroring
|
||||
* the logic in `src/tool/task.ts` (filtered parent permissions ++ runtime
|
||||
* subagent agent permissions, evaluated as in `session/prompt.ts`).
|
||||
*
|
||||
* The expected (secure) behavior is that the subagent inherits the plan
|
||||
* mode read-only restriction and `edit`/`write` resolve to `deny`. On
|
||||
* origin/dev this assertion fails because the parent **agent** permissions
|
||||
* are not propagated to the subagent — only the parent **session**
|
||||
* permissions are passed through, and Plan Mode's restrictions live on the
|
||||
* agent, not the session.
|
||||
*/
|
||||
import { test, expect, afterEach } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { disposeAllInstances, provideInstance, tmpdir } from "../fixture/fixture"
|
||||
import { WithInstance } from "../../src/project/with-instance"
|
||||
import { Agent } from "../../src/agent/agent"
|
||||
import { deriveSubagentSessionPermission } from "../../src/agent/subagent-permissions"
|
||||
import { Permission } from "../../src/permission"
|
||||
|
||||
afterEach(async () => {
|
||||
await disposeAllInstances()
|
||||
})
|
||||
|
||||
function load<A>(dir: string, fn: (svc: Agent.Interface) => Effect.Effect<A>) {
|
||||
return Effect.runPromise(provideInstance(dir)(Agent.Service.use(fn)).pipe(Effect.provide(Agent.defaultLayer)))
|
||||
}
|
||||
|
||||
// `deriveSubagentSessionPermission` is imported from production. The test
|
||||
// exercises the actual helper that task.ts uses to build the subagent's
|
||||
// session permission, so any regression in that helper trips this test.
|
||||
|
||||
test("[#26514] subagent spawned from plan mode inherits read-only restriction (edit denied)", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const planAgent = await load(tmp.path, (svc) => svc.get("plan"))
|
||||
const generalAgent = await load(tmp.path, (svc) => svc.get("general"))
|
||||
|
||||
expect(planAgent).toBeDefined()
|
||||
expect(generalAgent).toBeDefined()
|
||||
// Sanity: the plan agent itself blocks edit. (Note: `write` and
|
||||
// `apply_patch` route through the `edit` permission at the runtime
|
||||
// tool layer — see Permission.disabled / EDIT_TOOLS.)
|
||||
expect(Permission.evaluate("edit", "/some/file.ts", planAgent!.permission).action).toBe("deny")
|
||||
|
||||
// Simulate the plan-mode parent session: in real flow the plan
|
||||
// session's `permission` field is empty (Plan Mode lives on the agent
|
||||
// ruleset, not the session). So we pass [] through as the parent
|
||||
// session permission, exactly like the actual code path.
|
||||
const parentSessionPermission: Permission.Ruleset = []
|
||||
|
||||
const subagentSessionPermission = deriveSubagentSessionPermission({
|
||||
parentSessionPermission,
|
||||
parentAgent: planAgent,
|
||||
subagent: generalAgent!,
|
||||
})
|
||||
|
||||
// Mirror the runtime evaluation in session/prompt.ts (~line 410, 639):
|
||||
// ruleset: Permission.merge(agent.permission, session.permission ?? [])
|
||||
const effective = Permission.merge(generalAgent!.permission, subagentSessionPermission)
|
||||
|
||||
expect(Permission.evaluate("edit", "/some/file.ts", effective).action).toBe("deny")
|
||||
expect(Permission.evaluate("edit", "/another/path/index.tsx", effective).action).toBe("deny")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("[#26514] explore subagent launched from plan mode also stays read-only", async () => {
|
||||
// Sibling check: even though `explore` is intrinsically read-only, the
|
||||
// bug surface is the same. Including this case to document that the fix
|
||||
// should propagate the parent **agent** permissions, not just deny edit
|
||||
// when the subagent happens to already deny it.
|
||||
await using tmp = await tmpdir()
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const planAgent = await load(tmp.path, (svc) => svc.get("plan"))
|
||||
const explore = await load(tmp.path, (svc) => svc.get("explore"))
|
||||
expect(planAgent).toBeDefined()
|
||||
expect(explore).toBeDefined()
|
||||
|
||||
const parentSessionPermission: Permission.Ruleset = []
|
||||
const subagentSessionPermission = deriveSubagentSessionPermission({
|
||||
parentSessionPermission,
|
||||
parentAgent: planAgent,
|
||||
subagent: explore!,
|
||||
})
|
||||
const effective = Permission.merge(explore!.permission, subagentSessionPermission)
|
||||
|
||||
// Already deny — sanity check.
|
||||
expect(Permission.evaluate("edit", "/x.ts", effective).action).toBe("deny")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("[#26514] custom user subagent launched from plan mode bypasses Plan Mode read-only", async () => {
|
||||
// The most damaging case: a user-defined subagent with default
|
||||
// permissions (allow-by-default, like `general`). The subagent must NOT
|
||||
// be able to edit when the parent agent is `plan`.
|
||||
await using tmp = await tmpdir({
|
||||
config: {
|
||||
agent: {
|
||||
my_subagent: {
|
||||
description: "A user-defined subagent",
|
||||
mode: "subagent",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const planAgent = await load(tmp.path, (svc) => svc.get("plan"))
|
||||
const my = await load(tmp.path, (svc) => svc.get("my_subagent"))
|
||||
expect(planAgent).toBeDefined()
|
||||
expect(my).toBeDefined()
|
||||
|
||||
const parentSessionPermission: Permission.Ruleset = []
|
||||
const subagentSessionPermission = deriveSubagentSessionPermission({
|
||||
parentSessionPermission,
|
||||
parentAgent: planAgent,
|
||||
subagent: my!,
|
||||
})
|
||||
const effective = Permission.merge(my!.permission, subagentSessionPermission)
|
||||
|
||||
// BUG: on origin/dev edit resolves to "allow" because the plan
|
||||
// agent's `edit: deny *` rule never reaches the subagent.
|
||||
expect(Permission.evaluate("edit", "/some/file.ts", effective).action).toBe("deny")
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -558,22 +558,20 @@ test("handles file inclusion with replacement tokens", async () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("config loader is tolerant: drops unknown fields, keeps the rest", async () => {
|
||||
test("validates config schema and throws on invalid fields", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await writeConfig(dir, {
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
username: "kept",
|
||||
invalid_field: "should be dropped, not crash the app",
|
||||
invalid_field: "should cause error",
|
||||
})
|
||||
},
|
||||
})
|
||||
await provideTestInstance({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
expect(config.username).toBe("kept")
|
||||
expect((config as Record<string, unknown>).invalid_field).toBeUndefined()
|
||||
// Strict schema should throw an error for invalid fields
|
||||
await expect(load()).rejects.toThrow()
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -1683,70 +1681,7 @@ test("permission config preserves user key order", async () => {
|
||||
})
|
||||
})
|
||||
|
||||
// Discord bug report (Ben Matthews, v1.14.45): a malformed `skills:` field
|
||||
// (array instead of object) made the WHOLE config fail to load, the server
|
||||
// returned 500, and the desktop app couldn't start. Per Kit:
|
||||
// "for all of these things that we load from the user's computer, they
|
||||
// should be kind of tolerant. ... It shouldn't break opencode."
|
||||
// The contract: drop the malformed top-level field, log a warning, keep
|
||||
// the rest of the config so the app starts.
|
||||
test("config parser is tolerant: drops malformed top-level fields, keeps the rest", () => {
|
||||
const config = ConfigParse.effectSchema(
|
||||
Config.Info,
|
||||
{
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
username: "ben",
|
||||
// Wrong shape — schema expects { paths?, urls? }, user has an array
|
||||
// (looks like the LOADED skills list got pasted into the config).
|
||||
skills: [
|
||||
{ name: "scss-layout-accessibility", path: ".opencode/skills/scss-layout-accessibility.md" },
|
||||
{ name: "testing", path: ".opencode/skills/testing.md" },
|
||||
],
|
||||
},
|
||||
"test",
|
||||
)
|
||||
|
||||
// Pre-fix this throws ConfigInvalidError and the user can't start opencode.
|
||||
// Post-fix the bad field is dropped and the rest of the config loads.
|
||||
expect(config.username).toBe("ben")
|
||||
expect(config.skills).toBeUndefined()
|
||||
})
|
||||
|
||||
test("config parser is tolerant: drops unrecognized top-level keys instead of throwing", () => {
|
||||
const config = ConfigParse.effectSchema(
|
||||
Config.Info,
|
||||
{
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
username: "ben",
|
||||
// Typo or stale key — pre-fix this threw `unrecognized_keys`.
|
||||
autoshrare: true,
|
||||
},
|
||||
"test",
|
||||
)
|
||||
|
||||
expect(config.username).toBe("ben")
|
||||
expect((config as Record<string, unknown>).autoshrare).toBeUndefined()
|
||||
})
|
||||
|
||||
test("config parser is tolerant: drops multiple bad fields in one pass", () => {
|
||||
const config = ConfigParse.effectSchema(
|
||||
Config.Info,
|
||||
{
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
username: "ben",
|
||||
skills: ["wrong shape"],
|
||||
autoshare: 42, // wrong type — schema wants string literal | undefined
|
||||
not_a_real_key: "ignore me",
|
||||
},
|
||||
"test",
|
||||
)
|
||||
|
||||
expect(config.username).toBe("ben")
|
||||
expect(config.skills).toBeUndefined()
|
||||
expect(config.autoshare).toBeUndefined()
|
||||
})
|
||||
|
||||
test("Effect config parser preserves permission order while dropping unknown top-level keys", () => {
|
||||
test("Effect config parser preserves permission order while rejecting unknown top-level keys", () => {
|
||||
const config = ConfigParse.effectSchema(
|
||||
Config.Info,
|
||||
{
|
||||
@@ -1760,10 +1695,13 @@ test("Effect config parser preserves permission order while dropping unknown top
|
||||
)
|
||||
|
||||
expect(Object.keys(config.permission!)).toEqual(["bash", "*", "edit"])
|
||||
// Tolerant parser: unknown keys are stripped (with a warning log) instead
|
||||
// of failing the entire config load.
|
||||
const stripped = ConfigParse.effectSchema(Config.Info, { invalid_field: true }, "test")
|
||||
expect((stripped as Record<string, unknown>).invalid_field).toBeUndefined()
|
||||
try {
|
||||
ConfigParse.effectSchema(Config.Info, { invalid_field: true }, "test")
|
||||
throw new Error("expected config parse to fail")
|
||||
} catch (err) {
|
||||
const error = err as { data?: { issues?: Array<{ code?: string; keys?: string[]; path?: string[] }> } }
|
||||
expect(error.data?.issues?.[0]).toMatchObject({ code: "unrecognized_keys", keys: ["invalid_field"], path: [] })
|
||||
}
|
||||
})
|
||||
|
||||
// MCP config merging tests
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
// Reproducer for opencode issue #26529
|
||||
//
|
||||
// When an MCP server's `tools/list` response contains a tool whose
|
||||
// `outputSchema` has an unresolved `$ref` (e.g. `#/$defs/ScreenInstance`),
|
||||
// the MCP SDK's response validation throws on the entire `listTools()`
|
||||
// call. opencode currently treats this as a fatal error and marks the
|
||||
// whole server as `failed`, even though the server has other valid tools
|
||||
// that should still be usable.
|
||||
//
|
||||
// Expected behavior: opencode should skip tools with malformed schemas
|
||||
// and keep the server connected with its remaining valid tools.
|
||||
|
||||
import { test, expect, mock, beforeEach } from "bun:test"
|
||||
import { InstanceRuntime } from "../../src/project/instance-runtime"
|
||||
import { Effect } from "effect"
|
||||
import type { MCP as MCPNS } from "../../src/mcp/index"
|
||||
|
||||
// --- Mock infrastructure (mirrors lifecycle.test.ts patterns) ---
|
||||
|
||||
interface MockClientState {
|
||||
tools: Array<{ name: string; description?: string; inputSchema: object; outputSchema?: object }>
|
||||
listToolsShouldFail: boolean
|
||||
listToolsError: string
|
||||
notificationHandlers: Map<unknown, (...args: any[]) => any>
|
||||
closed: boolean
|
||||
}
|
||||
|
||||
const clientStates = new Map<string, MockClientState>()
|
||||
let lastCreatedClientName: string | undefined
|
||||
|
||||
function getOrCreateClientState(name?: string): MockClientState {
|
||||
const key = name ?? "default"
|
||||
let state = clientStates.get(key)
|
||||
if (!state) {
|
||||
state = {
|
||||
tools: [],
|
||||
listToolsShouldFail: false,
|
||||
listToolsError: "listTools failed",
|
||||
notificationHandlers: new Map(),
|
||||
closed: false,
|
||||
}
|
||||
clientStates.set(key, state)
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
class MockStdioTransport {
|
||||
stderr: null = null
|
||||
pid = 12345
|
||||
// oxlint-disable-next-line no-useless-constructor
|
||||
constructor(_opts: any) {}
|
||||
async start() {}
|
||||
async close() {}
|
||||
}
|
||||
|
||||
class MockStreamableHTTP {
|
||||
// oxlint-disable-next-line no-useless-constructor
|
||||
constructor(_url: URL, _opts?: any) {}
|
||||
async start() {}
|
||||
async close() {}
|
||||
async finishAuth() {}
|
||||
}
|
||||
|
||||
class MockSSE {
|
||||
// oxlint-disable-next-line no-useless-constructor
|
||||
constructor(_url: URL, _opts?: any) {}
|
||||
async start() {}
|
||||
async close() {}
|
||||
}
|
||||
|
||||
void mock.module("@modelcontextprotocol/sdk/client/stdio.js", () => ({
|
||||
StdioClientTransport: MockStdioTransport,
|
||||
}))
|
||||
|
||||
void mock.module("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({
|
||||
StreamableHTTPClientTransport: MockStreamableHTTP,
|
||||
}))
|
||||
|
||||
void mock.module("@modelcontextprotocol/sdk/client/sse.js", () => ({
|
||||
SSEClientTransport: MockSSE,
|
||||
}))
|
||||
|
||||
void mock.module("@modelcontextprotocol/sdk/client/auth.js", () => ({
|
||||
UnauthorizedError: class extends Error {
|
||||
constructor() {
|
||||
super("Unauthorized")
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
void mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({
|
||||
Client: class MockClient {
|
||||
_state!: MockClientState
|
||||
transport: any
|
||||
// oxlint-disable-next-line no-useless-constructor
|
||||
constructor(_opts: any) {}
|
||||
async connect(transport: { start: () => Promise<void> }) {
|
||||
this.transport = transport
|
||||
await transport.start()
|
||||
this._state = getOrCreateClientState(lastCreatedClientName)
|
||||
}
|
||||
setNotificationHandler(schema: unknown, handler: (...args: any[]) => any) {
|
||||
this._state?.notificationHandlers.set(schema, handler)
|
||||
}
|
||||
async listTools() {
|
||||
if (this._state?.listToolsShouldFail) {
|
||||
throw new Error(this._state.listToolsError)
|
||||
}
|
||||
return { tools: this._state?.tools ?? [] }
|
||||
}
|
||||
async request(req: { method: string }) {
|
||||
// The fix retries via raw `request("tools/list", ...)` with a
|
||||
// tolerant schema after the typed listTools() rejects on a bad
|
||||
// outputSchema reference. The retry path bypasses the SDK's
|
||||
// strict validator, so the mock returns the same tools list
|
||||
// without the validation that originally threw.
|
||||
if (req.method === "tools/list") return { tools: this._state?.tools ?? [] }
|
||||
throw new Error(`unsupported request: ${req.method}`)
|
||||
}
|
||||
async listPrompts() {
|
||||
return { prompts: [] }
|
||||
}
|
||||
async listResources() {
|
||||
return { resources: [] }
|
||||
}
|
||||
async close() {
|
||||
if (this._state) this._state.closed = true
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
beforeEach(() => {
|
||||
clientStates.clear()
|
||||
lastCreatedClientName = undefined
|
||||
})
|
||||
|
||||
const { MCP } = await import("../../src/mcp/index")
|
||||
const { Instance } = await import("../../src/project/instance")
|
||||
const { WithInstance } = await import("../../src/project/with-instance")
|
||||
const { tmpdir } = await import("../fixture/fixture")
|
||||
|
||||
function withInstance(
|
||||
config: Record<string, unknown>,
|
||||
fn: (mcp: MCPNS.Interface) => Effect.Effect<void, unknown, never>,
|
||||
) {
|
||||
return async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(
|
||||
`${dir}/opencode.json`,
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
mcp: config,
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await Effect.runPromise(MCP.Service.use(fn).pipe(Effect.provide(MCP.defaultLayer)))
|
||||
await InstanceRuntime.disposeInstance(Instance.current)
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Reproducer: outputSchema with unresolved $ref fails the whole server
|
||||
// ========================================================================
|
||||
//
|
||||
// In the real bug, the MCP SDK's response-validation layer attempts to
|
||||
// resolve `$ref`s inside a tool's `outputSchema`. When a referenced
|
||||
// definition is missing (e.g. `#/$defs/ScreenInstance`), validation
|
||||
// throws something like:
|
||||
//
|
||||
// can't resolve reference #/$defs/ScreenInstance from id #
|
||||
//
|
||||
// `client.listTools()` therefore rejects, opencode's `defs()` catches
|
||||
// the error and returns `undefined`, and `create()` then marks the whole
|
||||
// MCP server as `failed` -- losing access to all other valid tools the
|
||||
// server exposes.
|
||||
//
|
||||
// This test simulates the same failure path by making `listTools()`
|
||||
// throw the same error, and asserts the server stays connected with its
|
||||
// valid tool exposed.
|
||||
|
||||
test(
|
||||
"tool with unresolved $ref in outputSchema does not fail the whole server",
|
||||
withInstance(
|
||||
{
|
||||
"screen-server": {
|
||||
type: "local",
|
||||
command: ["echo", "test"],
|
||||
},
|
||||
},
|
||||
(mcp) =>
|
||||
Effect.gen(function* () {
|
||||
lastCreatedClientName = "screen-server"
|
||||
const serverState = getOrCreateClientState("screen-server")
|
||||
|
||||
// Simulate the SDK's validation throwing on the bad outputSchema.
|
||||
// This is exactly what happens in the wild when one tool in
|
||||
// tools/list has an `outputSchema` like:
|
||||
// { $ref: "#/$defs/ScreenInstance" }
|
||||
// with no `$defs` block to resolve against.
|
||||
serverState.tools = [
|
||||
{
|
||||
name: "good_tool",
|
||||
description: "valid tool that should still load",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
{
|
||||
name: "bad_tool",
|
||||
description: "tool with unresolved outputSchema $ref",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
outputSchema: { $ref: "#/$defs/ScreenInstance" },
|
||||
},
|
||||
]
|
||||
serverState.listToolsShouldFail = true
|
||||
serverState.listToolsError = "can't resolve reference #/$defs/ScreenInstance from id #"
|
||||
|
||||
yield* mcp.add("screen-server", {
|
||||
type: "local",
|
||||
command: ["echo", "test"],
|
||||
})
|
||||
|
||||
const status = yield* mcp.status()
|
||||
|
||||
// Expected: the server should remain connected because at least
|
||||
// one tool (`good_tool`) has a valid schema.
|
||||
expect(status["screen-server"]?.status).toBe("connected")
|
||||
|
||||
// Expected: the valid tool should be available even though one
|
||||
// of the server's tools had a bad outputSchema.
|
||||
const tools = yield* mcp.tools()
|
||||
expect(Object.keys(tools).some((k) => k.includes("good_tool"))).toBe(true)
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -1,61 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Schema } from "effect"
|
||||
import { ConfigProvider } from "@/config/provider"
|
||||
import { CatalogModelStatus, ModelStatus } from "@/provider/model-status"
|
||||
import { ModelsDev } from "@/provider/models"
|
||||
import { Provider } from "@/provider/provider"
|
||||
|
||||
describe("provider model status schemas", () => {
|
||||
test("keeps catalog status separate from normalized provider status", () => {
|
||||
expect(Schema.decodeUnknownSync(CatalogModelStatus)("deprecated")).toBe("deprecated")
|
||||
expect(() => Schema.decodeUnknownSync(CatalogModelStatus)("active")).toThrow()
|
||||
expect(Schema.decodeUnknownSync(ModelStatus)("active")).toBe("active")
|
||||
})
|
||||
|
||||
test("accepts active status across public provider schemas", () => {
|
||||
expect(Schema.decodeUnknownSync(ConfigProvider.Model)({ status: "active" }).status).toBe("active")
|
||||
expect(
|
||||
Schema.decodeUnknownSync(ModelsDev.Model)({
|
||||
id: "test-model",
|
||||
name: "Test Model",
|
||||
release_date: "2026-01-01",
|
||||
attachment: false,
|
||||
reasoning: false,
|
||||
temperature: true,
|
||||
tool_call: true,
|
||||
limit: { context: 128000, output: 8192 },
|
||||
}).status,
|
||||
).toBeUndefined()
|
||||
expect(
|
||||
Schema.decodeUnknownSync(Provider.Model)({
|
||||
id: "test-model",
|
||||
providerID: "test-provider",
|
||||
api: {
|
||||
id: "test-model",
|
||||
url: "",
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
},
|
||||
name: "Test Model",
|
||||
capabilities: {
|
||||
temperature: true,
|
||||
reasoning: false,
|
||||
attachment: false,
|
||||
toolcall: true,
|
||||
input: { text: true, audio: false, image: false, video: false, pdf: false },
|
||||
output: { text: true, audio: false, image: false, video: false, pdf: false },
|
||||
interleaved: false,
|
||||
},
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cache: { read: 0, write: 0 },
|
||||
},
|
||||
limit: { context: 128000, output: 8192 },
|
||||
status: "active",
|
||||
options: {},
|
||||
headers: {},
|
||||
release_date: "2026-01-01",
|
||||
}).status,
|
||||
).toBe("active")
|
||||
})
|
||||
})
|
||||
@@ -47,41 +47,4 @@ describe("config HttpApi", () => {
|
||||
lsp: false,
|
||||
})
|
||||
})
|
||||
|
||||
test("serves config with active provider model status", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
config: {
|
||||
formatter: false,
|
||||
lsp: false,
|
||||
provider: {
|
||||
omniroute: {
|
||||
models: {
|
||||
"gpt-4o": {
|
||||
status: "active",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const response = await app().request("/config", {
|
||||
headers: {
|
||||
"x-opencode-directory": tmp.path,
|
||||
},
|
||||
})
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(await response.json()).toMatchObject({
|
||||
provider: {
|
||||
omniroute: {
|
||||
models: {
|
||||
"gpt-4o": {
|
||||
status: "active",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.14.45",
|
||||
"version": "1.14.44",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.14.45",
|
||||
"version": "1.14.44",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1065,7 +1065,7 @@ export type ProviderConfig = {
|
||||
output: Array<"text" | "audio" | "image" | "video" | "pdf">
|
||||
}
|
||||
experimental?: boolean
|
||||
status?: "alpha" | "beta" | "deprecated" | "active"
|
||||
status?: "alpha" | "beta" | "deprecated"
|
||||
options?: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
@@ -3012,7 +3012,7 @@ export type ProviderListResponses = {
|
||||
output: Array<"text" | "audio" | "image" | "video" | "pdf">
|
||||
}
|
||||
experimental?: boolean
|
||||
status?: "alpha" | "beta" | "deprecated" | "active"
|
||||
status?: "alpha" | "beta" | "deprecated"
|
||||
options: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
@@ -1060,7 +1060,7 @@ export type ProviderConfig = {
|
||||
output: Array<"text" | "audio" | "image" | "video" | "pdf">
|
||||
}
|
||||
experimental?: boolean
|
||||
status?: "alpha" | "beta" | "deprecated" | "active"
|
||||
status?: "alpha" | "beta" | "deprecated"
|
||||
provider?: {
|
||||
npm?: string
|
||||
api?: string
|
||||
|
||||
@@ -11725,7 +11725,7 @@
|
||||
},
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": ["alpha", "beta", "deprecated", "active"]
|
||||
"enum": ["alpha", "beta", "deprecated"]
|
||||
},
|
||||
"provider": {
|
||||
"type": "object",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.14.45",
|
||||
"version": "1.14.44",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.14.45",
|
||||
"version": "1.14.44",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"exports": {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "@opencode-ai/web",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"version": "1.14.45",
|
||||
"version": "1.14.44",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "opencode",
|
||||
"displayName": "opencode",
|
||||
"description": "opencode for VS Code",
|
||||
"version": "1.14.45",
|
||||
"version": "1.14.44",
|
||||
"publisher": "sst-dev",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
Reference in New Issue
Block a user