Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7aa35180ab | ||
|
|
4fdeb5aac2 |
@@ -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 }]),
|
||||
]
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
/**
|
||||
* Aggregate Promise.allSettled results into a single Error that names every
|
||||
* failed endpoint, or return null when all fulfilled. Used at TUI bootstrap
|
||||
* boundaries so a single 4xx doesn't drown its parallel siblings as
|
||||
* unhandled rejections — every failure surfaces in one labeled message.
|
||||
*/
|
||||
export type LabeledSettled = {
|
||||
name: string
|
||||
result: PromiseSettledResult<unknown>
|
||||
}
|
||||
|
||||
export function aggregateFailures(labeled: LabeledSettled[]): Error | null {
|
||||
const failed = labeled.filter(
|
||||
(x): x is { name: string; result: PromiseRejectedResult } => x.result.status === "rejected",
|
||||
)
|
||||
if (failed.length === 0) return null
|
||||
|
||||
const reasons = failed.map((f) => `${f.name}: ${reasonMessage(f.result.reason)}`).join("; ")
|
||||
const summary = `${failed.length} of ${labeled.length} requests failed: ${reasons}`
|
||||
const err = new Error(summary)
|
||||
err.cause = { failures: failed.map((f) => ({ name: f.name, reason: f.result.reason })) }
|
||||
return err
|
||||
}
|
||||
|
||||
function reasonMessage(reason: unknown): string {
|
||||
if (reason instanceof Error) return reason.message
|
||||
if (typeof reason === "string") return reason
|
||||
if (reason && typeof reason === "object") {
|
||||
const obj = reason as { message?: unknown; name?: unknown }
|
||||
if (typeof obj.message === "string") return obj.message
|
||||
if (typeof obj.name === "string") return obj.name
|
||||
}
|
||||
return String(reason)
|
||||
}
|
||||
@@ -15,13 +15,17 @@ export function useEvent() {
|
||||
// Special hack for truly global events
|
||||
if (event.directory === "global") {
|
||||
handler(event.payload)
|
||||
return
|
||||
}
|
||||
|
||||
if (project.workspace.current()) {
|
||||
// Workspace-scoped events match on workspace identity. Events without a
|
||||
// workspace label fall through to the directory check — a session with
|
||||
// no workspaceID can be live in the same directory as the TUI even when
|
||||
// the TUI itself is attached to a workspace (#26671).
|
||||
if (event.workspace !== undefined) {
|
||||
if (event.workspace === project.workspace.current()) {
|
||||
handler(event.payload)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -32,7 +32,6 @@ import * as Log from "@opencode-ai/core/util/log"
|
||||
import { emptyConsoleState, type ConsoleState } from "@/config/console-state"
|
||||
import path from "path"
|
||||
import { useKV } from "./kv"
|
||||
import { aggregateFailures } from "./aggregate-failures"
|
||||
|
||||
export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
name: "Sync",
|
||||
@@ -392,23 +391,16 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
.catch(() => emptyConsoleState)
|
||||
const agentsPromise = sdk.client.app.agents({ workspace }, { throwOnError: true })
|
||||
const configPromise = sdk.client.config.get({ workspace }, { throwOnError: true })
|
||||
const blockingRequests: { name: string; promise: Promise<unknown> }[] = [
|
||||
{ name: "config.providers", promise: providersPromise },
|
||||
{ name: "provider.list", promise: providerListPromise },
|
||||
{ name: "app.agents", promise: agentsPromise },
|
||||
{ name: "config.get", promise: configPromise },
|
||||
{ name: "project.sync", promise: projectPromise },
|
||||
...(args.continue ? [{ name: "session.list", promise: sessionListPromise }] : []),
|
||||
const blockingRequests: Promise<unknown>[] = [
|
||||
providersPromise,
|
||||
providerListPromise,
|
||||
agentsPromise,
|
||||
configPromise,
|
||||
projectPromise,
|
||||
...(args.continue ? [sessionListPromise] : []),
|
||||
]
|
||||
|
||||
await Promise.allSettled(blockingRequests.map((r) => r.promise))
|
||||
.then((settled) => {
|
||||
// Surface every failed endpoint in one labeled message instead of
|
||||
// letting the first rejection drown its siblings as unhandled
|
||||
// rejections.
|
||||
const failure = aggregateFailures(blockingRequests.map((r, i) => ({ name: r.name, result: settled[i] })))
|
||||
if (failure) throw failure
|
||||
})
|
||||
await Promise.all(blockingRequests)
|
||||
.then(async () => {
|
||||
const providersResponse = providersPromise.then((x) => x.data!)
|
||||
const providerListResponse = providerListPromise.then((x) => x.data!)
|
||||
|
||||
@@ -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) }),
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -178,7 +178,7 @@ export const ReadTool = Tool.define(
|
||||
|
||||
yield* ctx.ask({
|
||||
permission: "read",
|
||||
patterns: [path.relative(instance.worktree, filepath)],
|
||||
patterns: [filepath],
|
||||
always: ["*"],
|
||||
metadata: {},
|
||||
})
|
||||
|
||||
@@ -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")
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -1,55 +0,0 @@
|
||||
/**
|
||||
* Regression test for the TUI bootstrap aggregation helper. Replaces the
|
||||
* pre-fix Promise.all behavior where the first rejection drowned every
|
||||
* sibling endpoint's failure as an unhandled rejection.
|
||||
*/
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { aggregateFailures } from "@/cli/cmd/tui/context/aggregate-failures"
|
||||
|
||||
describe("aggregateFailures", () => {
|
||||
test("returns null when every result is fulfilled", () => {
|
||||
expect(
|
||||
aggregateFailures([
|
||||
{ name: "config", result: { status: "fulfilled", value: 1 } },
|
||||
{ name: "providers", result: { status: "fulfilled", value: 2 } },
|
||||
]),
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
test("names the failed endpoint when one rejects", () => {
|
||||
const err = aggregateFailures([
|
||||
{ name: "config", result: { status: "fulfilled", value: 1 } },
|
||||
{
|
||||
name: "providers",
|
||||
result: { status: "rejected", reason: new Error("Service unavailable") },
|
||||
},
|
||||
])
|
||||
expect(err).toBeInstanceOf(Error)
|
||||
expect(err!.message).toContain("1 of 2")
|
||||
expect(err!.message).toContain("providers: Service unavailable")
|
||||
})
|
||||
|
||||
test("names every failed endpoint when multiple reject", () => {
|
||||
const err = aggregateFailures([
|
||||
{ name: "config", result: { status: "rejected", reason: new Error("400 Bad Request") } },
|
||||
{ name: "providers", result: { status: "fulfilled", value: 1 } },
|
||||
{ name: "agents", result: { status: "rejected", reason: { message: "boom" } } },
|
||||
])
|
||||
expect(err).toBeInstanceOf(Error)
|
||||
expect(err!.message).toContain("2 of 3")
|
||||
expect(err!.message).toContain("config: 400 Bad Request")
|
||||
expect(err!.message).toContain("agents: boom")
|
||||
})
|
||||
|
||||
test("attaches structured failure list under .cause", () => {
|
||||
const reason = new Error("nope")
|
||||
const err = aggregateFailures([{ name: "providers", result: { status: "rejected", reason } }])
|
||||
const cause = err!.cause as { failures: Array<{ name: string; reason: unknown }> }
|
||||
expect(cause.failures).toEqual([{ name: "providers", reason }])
|
||||
})
|
||||
|
||||
test("falls back to String() for opaque reasons", () => {
|
||||
const err = aggregateFailures([{ name: "x", result: { status: "rejected", reason: 42 } }])
|
||||
expect(err!.message).toContain("x: 42")
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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,189 +0,0 @@
|
||||
// Regression coverage for issue #26526's claim that promptAsync's
|
||||
// Effect.forkIn loses the request's InstanceRef/WorkspaceRef. It does not —
|
||||
// forkIn preserves Context.Reference values via standard fiber inheritance.
|
||||
//
|
||||
// The companion claim that the streaming prompt handler "captures and
|
||||
// provides" those services is true and load-bearing: Stream.fromEffect's
|
||||
// body runs detached from the request fiber's context, so the explicit
|
||||
// Effect.provideService calls there are required, not defensive duplication.
|
||||
|
||||
import { NodeHttpServer, NodeServices } from "@effect/platform-node"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Deferred, Effect, Layer, Scope } from "effect"
|
||||
import * as Stream from "effect/Stream"
|
||||
import { HttpClient, HttpRouter, HttpServerResponse } from "effect/unstable/http"
|
||||
import * as Socket from "effect/unstable/socket/Socket"
|
||||
import { mkdir } from "node:fs/promises"
|
||||
import { registerAdapter } from "../../src/control-plane/adapters"
|
||||
import type { WorkspaceAdapter } from "../../src/control-plane/types"
|
||||
import { Workspace } from "../../src/control-plane/workspace"
|
||||
import { InstanceRef, WorkspaceRef } from "../../src/effect/instance-ref"
|
||||
import { InstanceBootstrap } from "../../src/project/bootstrap"
|
||||
import { InstanceLayer } from "../../src/project/instance-layer"
|
||||
import { InstanceStore } from "../../src/project/instance-store"
|
||||
import { Project } from "../../src/project/project"
|
||||
import { instanceRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/instance-context"
|
||||
import { workspaceRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/workspace-routing"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { disposeAllInstances, tmpdirScoped } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const testStateLayer = Layer.effectDiscard(
|
||||
Effect.gen(function* () {
|
||||
const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
|
||||
yield* Effect.promise(() => resetDatabase())
|
||||
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.promise(async () => {
|
||||
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces
|
||||
await disposeAllInstances()
|
||||
await resetDatabase()
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
const workspaceLayer = Workspace.defaultLayer.pipe(
|
||||
Layer.provide(InstanceStore.defaultLayer),
|
||||
Layer.provide(InstanceBootstrap.defaultLayer),
|
||||
)
|
||||
|
||||
const it = testEffect(
|
||||
Layer.mergeAll(
|
||||
testStateLayer,
|
||||
NodeHttpServer.layerTest,
|
||||
NodeServices.layer,
|
||||
InstanceLayer.layer,
|
||||
Project.defaultLayer,
|
||||
workspaceLayer,
|
||||
),
|
||||
)
|
||||
|
||||
const instanceContextTestLayer = instanceRouterMiddleware
|
||||
.combine(workspaceRouterMiddleware)
|
||||
.layer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal))
|
||||
|
||||
const localAdapter = (directory: string): WorkspaceAdapter => ({
|
||||
name: "Local Test",
|
||||
description: "Create a local test workspace",
|
||||
configure: (info) => ({ ...info, name: "local-test", directory }),
|
||||
create: async () => {
|
||||
await mkdir(directory, { recursive: true })
|
||||
},
|
||||
async remove() {},
|
||||
target: () => ({ type: "local" as const, directory }),
|
||||
})
|
||||
|
||||
const setupWorkspace = (kind: string) =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdirScoped({ git: true })
|
||||
yield* Project.use.fromDirectory(dir)
|
||||
const projectID = yield* Project.Service.use((svc) => svc.fromDirectory(dir).pipe(Effect.map((p) => p.project.id)))
|
||||
registerAdapter(projectID, kind, localAdapter(dir))
|
||||
const workspace = yield* Workspace.Service.use((svc) =>
|
||||
svc.create({ type: kind, branch: null, extra: null, projectID }),
|
||||
)
|
||||
return { dir, workspace }
|
||||
})
|
||||
|
||||
type Capture = { directory?: string; workspaceID?: string }
|
||||
|
||||
const captureInstance = Effect.gen(function* () {
|
||||
const instance = yield* InstanceRef
|
||||
const workspaceID = yield* WorkspaceRef
|
||||
return { directory: instance?.directory, workspaceID } satisfies Capture
|
||||
})
|
||||
|
||||
describe("HttpApi handler context inheritance", () => {
|
||||
// Mirrors handlers/session.ts:281 promptAsync. The forked fiber inherits
|
||||
// the request's Context — including InstanceRef and WorkspaceRef provided
|
||||
// by InstanceContextMiddleware — without any explicit re-provide.
|
||||
it.live("Effect.forkIn preserves InstanceRef/WorkspaceRef across the fork", () =>
|
||||
Effect.gen(function* () {
|
||||
const { dir, workspace } = yield* setupWorkspace("local-fork")
|
||||
const capture = yield* Deferred.make<Capture>()
|
||||
|
||||
yield* HttpRouter.add(
|
||||
"POST",
|
||||
"/fork-probe",
|
||||
Effect.gen(function* () {
|
||||
const scope = yield* Scope.Scope
|
||||
yield* Effect.gen(function* () {
|
||||
yield* Deferred.succeed(capture, yield* captureInstance)
|
||||
}).pipe(Effect.forkIn(scope, { startImmediately: true }))
|
||||
return HttpServerResponse.empty({ status: 204 })
|
||||
}),
|
||||
).pipe(Layer.provide(instanceContextTestLayer), HttpRouter.serve, Layer.build)
|
||||
|
||||
const response = yield* HttpClient.post(
|
||||
`/fork-probe?directory=${encodeURIComponent(dir)}&workspace=${encodeURIComponent(workspace.id)}`,
|
||||
)
|
||||
expect(response.status).toBe(204)
|
||||
|
||||
const observed = yield* Deferred.await(capture).pipe(Effect.timeout("2 seconds"))
|
||||
expect(observed.directory).toBe(dir)
|
||||
expect(observed.workspaceID).toBe(workspace.id)
|
||||
}),
|
||||
)
|
||||
|
||||
// Mirrors handlers/session.ts:255 prompt — the streaming handler reads
|
||||
// InstanceRef/WorkspaceRef in the request fiber and re-provides them to
|
||||
// the Stream.fromEffect body. This test locks in why the explicit
|
||||
// provides are required: without them the stream body sees undefined.
|
||||
it.live("Stream.fromEffect body needs explicit provides — inheritance does not carry through", () =>
|
||||
Effect.gen(function* () {
|
||||
const { dir, workspace } = yield* setupWorkspace("local-stream")
|
||||
const withoutCapture = yield* Deferred.make<Capture>()
|
||||
const withCapture = yield* Deferred.make<Capture>()
|
||||
|
||||
yield* HttpRouter.add(
|
||||
"POST",
|
||||
"/stream-probe-without",
|
||||
Effect.gen(function* () {
|
||||
return HttpServerResponse.stream(
|
||||
Stream.fromEffect(
|
||||
Effect.gen(function* () {
|
||||
yield* Deferred.succeed(withoutCapture, yield* captureInstance)
|
||||
return ""
|
||||
}),
|
||||
).pipe(Stream.encodeText),
|
||||
{ contentType: "application/json" },
|
||||
)
|
||||
}),
|
||||
).pipe(Layer.provide(instanceContextTestLayer), HttpRouter.serve, Layer.build)
|
||||
|
||||
yield* HttpRouter.add(
|
||||
"POST",
|
||||
"/stream-probe-with",
|
||||
Effect.gen(function* () {
|
||||
const instance = yield* InstanceRef
|
||||
const workspaceID = yield* WorkspaceRef
|
||||
return HttpServerResponse.stream(
|
||||
Stream.fromEffect(
|
||||
Effect.gen(function* () {
|
||||
yield* Deferred.succeed(withCapture, yield* captureInstance)
|
||||
return ""
|
||||
}).pipe(Effect.provideService(InstanceRef, instance), Effect.provideService(WorkspaceRef, workspaceID)),
|
||||
).pipe(Stream.encodeText),
|
||||
{ contentType: "application/json" },
|
||||
)
|
||||
}),
|
||||
).pipe(Layer.provide(instanceContextTestLayer), HttpRouter.serve, Layer.build)
|
||||
|
||||
const queryString = `directory=${encodeURIComponent(dir)}&workspace=${encodeURIComponent(workspace.id)}`
|
||||
const responseWithout = yield* HttpClient.post(`/stream-probe-without?${queryString}`)
|
||||
yield* responseWithout.text
|
||||
const responseWith = yield* HttpClient.post(`/stream-probe-with?${queryString}`)
|
||||
yield* responseWith.text
|
||||
|
||||
const without = yield* Deferred.await(withoutCapture).pipe(Effect.timeout("2 seconds"))
|
||||
expect(without.directory).toBeUndefined()
|
||||
expect(without.workspaceID).toBeUndefined()
|
||||
|
||||
const withProvide = yield* Deferred.await(withCapture).pipe(Effect.timeout("2 seconds"))
|
||||
expect(withProvide.directory).toBe(dir)
|
||||
expect(withProvide.workspaceID).toBe(workspace.id)
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,176 @@
|
||||
/*
|
||||
* Regression coverage for issue #26671 — TUI does not live-render messages when
|
||||
* an external HTTP client POSTs to /session/{id}/prompt_async on the same
|
||||
* server, even though the web UI renders them correctly.
|
||||
*
|
||||
* The hypothesis under test: prompt_async forks via Effect.forkIn(scope), and
|
||||
* the forked work emits GlobalBus events (the same bus the SSE /event endpoint
|
||||
* forwards). Per #26586, Effect.forkIn preserves InstanceRef/WorkspaceRef
|
||||
* across the fork, so events emitted inside the fork should carry the
|
||||
* request's directory and workspace.
|
||||
*
|
||||
* Test 1 (live request): mount the in-process server, create a session in a
|
||||
* tmp directory (no workspace), POST /session/{id}/prompt_async, and assert
|
||||
* that some GlobalBus event scoped to the session fires with
|
||||
* { directory: <tmp.path>, workspace: undefined }. If this fails, the bug is
|
||||
* in event publishing. If it passes, publishing is correct and the bug lives
|
||||
* in the TUI's client-side filter.
|
||||
*
|
||||
* Test 2 (synthetic filter): replicate the TUI useEvent filter shape from
|
||||
* packages/opencode/src/cli/cmd/tui/context/event.ts and lock in the fixed
|
||||
* behaviour: events without a workspace label fall through to a directory
|
||||
* comparison even when the TUI itself is attached to a workspace, so a
|
||||
* directory-mode session driven by an external POST is forwarded correctly.
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { WithInstance } from "../../src/project/with-instance"
|
||||
import { Server } from "../../src/server/server"
|
||||
import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session"
|
||||
import { Session } from "@/session/session"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
|
||||
import { waitGlobalBusEventPromise } from "./global-bus"
|
||||
|
||||
void Log.init({ print: false })
|
||||
|
||||
const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
|
||||
|
||||
function app() {
|
||||
return Server.Default().app
|
||||
}
|
||||
|
||||
function pathFor(path: string, params: Record<string, string>) {
|
||||
return Object.entries(params).reduce((result, [key, value]) => result.replace(`:${key}`, value), path)
|
||||
}
|
||||
|
||||
function runSession<A, E>(fx: Effect.Effect<A, E, Session.Service>) {
|
||||
return Effect.runPromise(fx.pipe(Effect.provide(Session.defaultLayer)))
|
||||
}
|
||||
|
||||
function createSession(directory: string, input?: Session.CreateInput) {
|
||||
return WithInstance.provide({
|
||||
directory,
|
||||
fn: () => runSession(Session.Service.use((svc) => svc.create(input))),
|
||||
})
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces
|
||||
await disposeAllInstances()
|
||||
await resetDatabase()
|
||||
})
|
||||
|
||||
describe("session prompt_async events (issue #26671)", () => {
|
||||
test("forks publish GlobalBus events with the request's directory/workspace", async () => {
|
||||
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
|
||||
const session = await createSession(tmp.path, { title: "external prompt" })
|
||||
|
||||
// Subscribe BEFORE posting so we don't miss the first emit. The
|
||||
// GlobalBus.on inside Effect.callback registers synchronously when the
|
||||
// outer Promise is created, so kicking the request after this line is
|
||||
// safe.
|
||||
const eventPromise = waitGlobalBusEventPromise({
|
||||
timeout: 8_000,
|
||||
message: "no GlobalBus event observed for prompt_async",
|
||||
// Match the first non-housekeeping event scoped to this session. The
|
||||
// exact event type depends on how far the prompt gets before failing
|
||||
// (no provider configured): the user-message persistence emits sync
|
||||
// events, and any subsequent failure emits Session.Event.Error.
|
||||
predicate: (event) => {
|
||||
if (event.payload.type === "server.heartbeat") return false
|
||||
if (event.payload.type === "server.connected") return false
|
||||
// Attached payload always serialises the sessionID for session events.
|
||||
return JSON.stringify(event.payload).includes(session.id)
|
||||
},
|
||||
})
|
||||
|
||||
const promptAsyncPath = pathFor(SessionPaths.promptAsync, { sessionID: session.id })
|
||||
const response = await app().request(promptAsyncPath, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"x-opencode-directory": tmp.path,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
parts: [{ type: "text", text: "hello from external POST" }],
|
||||
}),
|
||||
})
|
||||
|
||||
// prompt_async returns 204 immediately; the work continues in the fork.
|
||||
expect(response.status).toBe(204)
|
||||
|
||||
const event = await eventPromise
|
||||
expect(event.directory).toBe(tmp.path)
|
||||
expect(event.workspace).toBeUndefined()
|
||||
})
|
||||
|
||||
test("TUI useEvent filter forwards directory-scoped events even when the TUI has an active workspace", () => {
|
||||
// Mirrors the (fixed) filter at packages/opencode/src/cli/cmd/tui/context/event.ts.
|
||||
// If someone reverts the fix back to an early-return in the workspace
|
||||
// branch, case (c) below catches it.
|
||||
type IncomingEvent = {
|
||||
directory: string | undefined
|
||||
workspace: string | undefined
|
||||
payload: { type: string }
|
||||
}
|
||||
function tuiFilter(input: {
|
||||
event: IncomingEvent
|
||||
activeWorkspace: string | undefined
|
||||
activeDirectory: string
|
||||
}): boolean {
|
||||
const { event, activeWorkspace, activeDirectory } = input
|
||||
if (event.payload.type === "sync") return false
|
||||
if (event.directory === "global") return true
|
||||
|
||||
if (event.workspace !== undefined) {
|
||||
return event.workspace === activeWorkspace
|
||||
}
|
||||
return event.directory === activeDirectory
|
||||
}
|
||||
|
||||
// (a) Directory mode: matching directory is forwarded.
|
||||
expect(
|
||||
tuiFilter({
|
||||
event: { directory: "/proj", workspace: undefined, payload: { type: "session.next.message.created" } },
|
||||
activeWorkspace: undefined,
|
||||
activeDirectory: "/proj",
|
||||
}),
|
||||
).toBe(true)
|
||||
|
||||
// (b) Workspace mode: matching workspace is forwarded.
|
||||
expect(
|
||||
tuiFilter({
|
||||
event: { directory: "/proj", workspace: "W1", payload: { type: "session.next.message.created" } },
|
||||
activeWorkspace: "W1",
|
||||
activeDirectory: "/proj",
|
||||
}),
|
||||
).toBe(true)
|
||||
|
||||
// (c) THE FIX FOR #26671:
|
||||
// TUI is in workspace mode, the inbound event has workspace=undefined
|
||||
// (the session has no workspaceID and the external POST didn't carry
|
||||
// workspace context), and the directory matches. The fixed filter
|
||||
// recognises that an event without a workspace label is directory-
|
||||
// scoped and consults the directory comparator, so this is forwarded.
|
||||
expect(
|
||||
tuiFilter({
|
||||
event: { directory: "/proj", workspace: undefined, payload: { type: "session.next.message.created" } },
|
||||
activeWorkspace: "W1",
|
||||
activeDirectory: "/proj",
|
||||
}),
|
||||
).toBe(true)
|
||||
|
||||
// (d) Cross-workspace events stay dropped.
|
||||
expect(
|
||||
tuiFilter({
|
||||
event: { directory: "/proj", workspace: "W2", payload: { type: "session.next.message.created" } },
|
||||
activeWorkspace: "W1",
|
||||
activeDirectory: "/proj",
|
||||
}),
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -394,16 +394,8 @@ describe("HttpApi SDK", () => {
|
||||
const missing = yield* capture(() => sdk.session.get({ sessionID }))
|
||||
const thrown = yield* captureThrown(() => sdk.session.get({ sessionID }, { throwOnError: true }))
|
||||
|
||||
// Result-tuple path: error body is preserved as-is so existing
|
||||
// consumers reading `result.error.name` / `JSON.stringify(error)`
|
||||
// keep working byte-for-byte.
|
||||
expect(missing.error).toEqual(expected)
|
||||
// throwOnError path: SDK wraps the body in a real Error with the
|
||||
// server's message, with the original parsed body preserved under
|
||||
// `.cause.body`.
|
||||
expect(thrown).toBeInstanceOf(Error)
|
||||
expect((thrown as Error).message).toBe(expected.data.message)
|
||||
expect(((thrown as Error).cause as { body: unknown }).body).toEqual(expected)
|
||||
expect(thrown).toEqual(expected)
|
||||
return {
|
||||
status: missing.status,
|
||||
error: missing.error,
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
/**
|
||||
* Regression tests for the SDK error shape — the v2 SDK's `throwOnError: true`
|
||||
* path used to throw raw values (empty strings or POJOs from JSON-decoded
|
||||
* error bodies). The TUI catches those and `e.message`/`e.stack` are
|
||||
* undefined, so users see `[object Object]` or a blank crash.
|
||||
*
|
||||
* Both cases must throw a real `Error` instance with a non-empty `.message`
|
||||
* extracted from the response body, plus `.status` and `.body` attached.
|
||||
*/
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
|
||||
import { Server } from "../../src/server/server"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
|
||||
void Log.init({ print: false })
|
||||
|
||||
afterEach(async () => {
|
||||
await disposeAllInstances()
|
||||
await resetDatabase()
|
||||
})
|
||||
|
||||
function client(directory: string) {
|
||||
return createOpencodeClient({
|
||||
baseUrl: "http://test",
|
||||
directory,
|
||||
fetch: ((req: Request) => Server.Default().app.fetch(req)) as unknown as typeof fetch,
|
||||
})
|
||||
}
|
||||
|
||||
describe("v2 SDK error shape", () => {
|
||||
test("404 with NamedError body throws a real Error carrying the server message", async () => {
|
||||
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
|
||||
const sdk = client(tmp.path)
|
||||
|
||||
let caught: unknown
|
||||
try {
|
||||
await sdk.session.get({ sessionID: "ses_no_such" }, { throwOnError: true })
|
||||
} catch (e) {
|
||||
caught = e
|
||||
}
|
||||
|
||||
expect(caught).toBeInstanceOf(Error)
|
||||
const err = caught as Error
|
||||
const cause = err.cause as { body?: any; status?: number }
|
||||
expect(err.message).toContain("Session not found")
|
||||
expect(cause.status).toBe(404)
|
||||
expect(cause.body).toMatchObject({
|
||||
name: "NotFoundError",
|
||||
data: { message: expect.stringContaining("Session not found") },
|
||||
})
|
||||
})
|
||||
|
||||
test("400 with empty body throws a real Error naming the status", async () => {
|
||||
await using tmp = await tmpdir({ config: { formatter: false, lsp: false } })
|
||||
const sdk = client(tmp.path)
|
||||
|
||||
let caught: unknown
|
||||
try {
|
||||
// POST /sync/history with `aggregate: -1` triggers schema validation
|
||||
// that returns an empty 400 body (verified via plan-mode probe).
|
||||
await sdk.sync.history.list({ aggregate: -1 } as any, { throwOnError: true })
|
||||
} catch (e) {
|
||||
caught = e
|
||||
}
|
||||
|
||||
expect(caught).toBeInstanceOf(Error)
|
||||
const err = caught as Error
|
||||
const cause = err.cause as { status?: number }
|
||||
expect(err.message.length).toBeGreaterThan(0)
|
||||
expect(cause.status).toBe(400)
|
||||
})
|
||||
})
|
||||
@@ -155,24 +155,11 @@ describe("tool.read external_directory permission", () => {
|
||||
yield* exec(dir, { filePath: alt }, next)
|
||||
const read = items.find((item) => item.permission === "read")
|
||||
expect(read).toBeDefined()
|
||||
expect(read!.patterns).toEqual([path.relative(dir, full(target))])
|
||||
expect(read!.patterns).toEqual([full(target)])
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
it.live("uses worktree-relative path for read permission so user rules match like edit/write", () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdirScoped({ git: true })
|
||||
yield* put(path.join(dir, "src", "secret.ts"), "shh")
|
||||
|
||||
const { items, next } = asks()
|
||||
yield* exec(dir, { filePath: path.join(dir, "src", "secret.ts") }, next)
|
||||
const read = items.find((item) => item.permission === "read")
|
||||
expect(read).toBeDefined()
|
||||
expect(read!.patterns).toEqual([path.join("src", "secret.ts")])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("asks for directory-scoped external_directory permission when reading external directory", () =>
|
||||
Effect.gen(function* () {
|
||||
const outer = yield* tmpdirScoped()
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -3,7 +3,6 @@ export * from "./gen/types.gen.js"
|
||||
import { createClient } from "./gen/client/client.gen.js"
|
||||
import { type Config } from "./gen/client/types.gen.js"
|
||||
import { OpencodeClient } from "./gen/sdk.gen.js"
|
||||
import { wrapClientError } from "./error-interceptor.js"
|
||||
export { type Config as OpencodeClientConfig, OpencodeClient }
|
||||
|
||||
function pick(value: string | null, fallback?: string) {
|
||||
@@ -52,6 +51,5 @@ export function createOpencodeClient(config?: Config & { directory?: string }) {
|
||||
|
||||
const client = createClient(config)
|
||||
client.interceptors.request.use((request) => rewrite(request, config?.directory))
|
||||
client.interceptors.error.use(wrapClientError)
|
||||
return new OpencodeClient({ client })
|
||||
}
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
/**
|
||||
* Wrap whatever the generated client decoded from a non-2xx error body
|
||||
* into a real `Error` so downstream formatters (TUI, plugins) get a
|
||||
* useful `.message` instead of `[object Object]` or blank. The original
|
||||
* parsed body and status live under `.cause` for callers that need
|
||||
* structured fields.
|
||||
*
|
||||
* Only fires when the caller used `{ throwOnError: true }`. Callers that
|
||||
* read `result.error` directly (the result-tuple path) get the parsed
|
||||
* body unchanged so existing field-level reads (`.error.name`,
|
||||
* `JSON.stringify(error)`, etc.) are byte-for-byte identical to before.
|
||||
*/
|
||||
export function wrapClientError(
|
||||
error: unknown,
|
||||
response: Response | undefined,
|
||||
request: Request | undefined,
|
||||
opts: { throwOnError?: boolean } | undefined,
|
||||
): unknown {
|
||||
if (!opts?.throwOnError) return error
|
||||
if (error instanceof Error) return error
|
||||
|
||||
// NamedError-shaped responses (the common case for opencode 4xx) come
|
||||
// through as POJOs — extract a useful message first, then wrap.
|
||||
if (typeof error === "object" && error !== null && Object.keys(error).length > 0) {
|
||||
const obj = error as { data?: { message?: unknown }; message?: unknown; name?: unknown }
|
||||
const message =
|
||||
(typeof obj.data?.message === "string" && obj.data.message) ||
|
||||
(typeof obj.message === "string" && obj.message) ||
|
||||
(typeof obj.name === "string" && obj.name) ||
|
||||
describe(request, response)
|
||||
return new Error(message, { cause: { body: error, status: response?.status } })
|
||||
}
|
||||
|
||||
if (typeof error === "string" && error.length > 0) {
|
||||
return new Error(error, { cause: { body: error, status: response?.status } })
|
||||
}
|
||||
|
||||
// Empty body / network failure / undefined / null / empty object.
|
||||
const reason = response ? "(empty response body)" : "network error (no response)"
|
||||
return new Error(`opencode server ${describe(request, response)}: ${reason}`, {
|
||||
cause: { body: error, status: response?.status },
|
||||
})
|
||||
}
|
||||
|
||||
function describe(request: Request | undefined, response: Response | undefined) {
|
||||
const method = request?.method ?? "?"
|
||||
const url = request?.url ?? "?"
|
||||
const status = response?.status
|
||||
const statusText = response?.statusText
|
||||
return `${method} ${url}${status ? " → " + status : ""}${statusText ? " " + statusText : ""}`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ export * from "./gen/types.gen.js"
|
||||
import { createClient } from "./gen/client/client.gen.js"
|
||||
import { type Config } from "./gen/client/types.gen.js"
|
||||
import { OpencodeClient } from "./gen/sdk.gen.js"
|
||||
import { wrapClientError } from "../error-interceptor.js"
|
||||
export { type Config as OpencodeClientConfig, OpencodeClient }
|
||||
|
||||
function pick(value: string | null, fallback?: string, encode?: (value: string) => string) {
|
||||
@@ -85,6 +84,24 @@ export function createOpencodeClient(config?: Config & { directory?: string; exp
|
||||
|
||||
return response
|
||||
})
|
||||
client.interceptors.error.use(wrapClientError)
|
||||
// The generated client falls back to throwing a literal `{}` when the server
|
||||
// responds with an empty / unparseable error body, which surfaces as a bare
|
||||
// `{}` in TUI / CLI error output. Wrap ONLY that case in a real Error so
|
||||
// downstream formatters get a useful message — but pass through any parsed
|
||||
// JSON error body unchanged so existing consumers can still inspect fields.
|
||||
client.interceptors.error.use((error, response, request) => {
|
||||
const isEmpty =
|
||||
error === undefined ||
|
||||
error === null ||
|
||||
error === "" ||
|
||||
(typeof error === "object" && !(error instanceof Error) && Object.keys(error).length === 0)
|
||||
if (!isEmpty) return error
|
||||
const method = request?.method ?? "?"
|
||||
const url = request?.url ?? "?"
|
||||
if (!response) return new Error(`opencode server ${method} ${url}: network error (no response)`)
|
||||
const status = response.status
|
||||
const statusText = response.statusText ? " " + response.statusText : ""
|
||||
return new Error(`opencode server ${method} ${url} → ${status}${statusText}: (empty response body)`)
|
||||
})
|
||||
return new OpencodeClient({ client })
|
||||
}
|
||||
|
||||
@@ -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