Skip to content

Commit 049502f

Browse files
authored
fix(server): return diagnosable body for schema rejections (#26631)
1 parent cc2915b commit 049502f

10 files changed

Lines changed: 606 additions & 324 deletions

File tree

packages/opencode/src/server/routes/instance/httpapi/api.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { TuiApi } from "./groups/tui"
2121
import { WorkspaceApi } from "./groups/workspace"
2222
import { V2Api } from "./groups/v2"
2323
import { Authorization } from "./middleware/authorization"
24+
import { SchemaErrorMiddleware } from "./middleware/schema-error"
2425

2526
// SSE event schemas built from the BusEvent/SyncEvent registries.
2627
const EventSchema = Schema.Union(BusEvent.effectPayloads()).annotate({ identifier: "Event" })
@@ -29,6 +30,7 @@ const SyncEventSchemas = SyncEvent.effectPayloads()
2930
export const RootHttpApi = HttpApi.make("opencode-root")
3031
.addHttpApi(ControlApi)
3132
.addHttpApi(GlobalApi)
33+
.middleware(SchemaErrorMiddleware)
3234
.middleware(Authorization)
3335

3436
export const InstanceHttpApi = HttpApi.make("opencode-instance")
@@ -47,6 +49,7 @@ export const InstanceHttpApi = HttpApi.make("opencode-instance")
4749
.addHttpApi(V2Api)
4850
.addHttpApi(TuiApi)
4951
.addHttpApi(WorkspaceApi)
52+
.middleware(SchemaErrorMiddleware)
5053

5154
export const OpenCodeHttpApi = HttpApi.make("opencode")
5255
.addHttpApi(RootHttpApi)
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { Effect } from "effect"
2+
import { HttpServerResponse } from "effect/unstable/http"
3+
import { HttpApiMiddleware } from "effect/unstable/httpapi"
4+
import * as Log from "@opencode-ai/core/util/log"
5+
6+
const log = Log.create({ service: "server" })
7+
8+
// Effect's Issue formatter recursively dumps the rejected `actual` value with
9+
// no truncation, so a 5KB invalid array produces a ~360KB string. Cap to keep
10+
// 4xx responses small and avoid mirroring entire request payloads (which may
11+
// contain secrets) into the response body and log file.
12+
const REASON_LIMIT = 1024
13+
function truncateReason(reason: string) {
14+
if (reason.length <= REASON_LIMIT) return reason
15+
return reason.slice(0, REASON_LIMIT) + `… (${reason.length - REASON_LIMIT} more chars)`
16+
}
17+
18+
// Default Respondable returns an empty 400 body. Match the NamedError shape
19+
// used by other 4xx/5xx so the SDK's `wrapClientError` extracts `.data.message`.
20+
export class SchemaErrorMiddleware extends HttpApiMiddleware.Service<SchemaErrorMiddleware>()(
21+
"@opencode/HttpApiSchemaError",
22+
) {}
23+
24+
export const schemaErrorLayer = HttpApiMiddleware.layerSchemaErrorTransform(
25+
SchemaErrorMiddleware,
26+
(error) => {
27+
const reason = truncateReason(error.cause.message)
28+
log.warn("schema rejection", { kind: error.kind, reason })
29+
return Effect.succeed(
30+
HttpServerResponse.jsonUnsafe(
31+
{ name: "BadRequest", data: { message: reason, kind: error.kind } },
32+
{ status: 400 },
33+
),
34+
)
35+
},
36+
)

packages/opencode/src/server/routes/instance/httpapi/public.ts

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -178,17 +178,20 @@ function addLegacyErrorSchemas(spec: OpenApiSpec) {
178178
if (!spec.components?.schemas) return
179179
spec.components.schemas.BadRequestError = {
180180
type: "object",
181-
required: ["data", "errors", "success"],
181+
required: ["name", "data"],
182182
properties: {
183-
data: {},
184-
errors: {
185-
type: "array",
186-
items: {
187-
type: "object",
188-
additionalProperties: {},
183+
name: { type: "string", enum: ["BadRequest"] },
184+
data: {
185+
type: "object",
186+
required: ["message"],
187+
properties: {
188+
message: { type: "string" },
189+
kind: {
190+
type: "string",
191+
enum: ["Params", "Headers", "Query", "Body", "Payload"],
192+
},
189193
},
190194
},
191-
success: { type: "boolean", enum: [false] },
192195
},
193196
}
194197
spec.components.schemas.NotFoundError = {

packages/opencode/src/server/routes/instance/httpapi/server.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ import { compressionLayer } from "./middleware/compression"
8484
import { corsVaryFix } from "./middleware/cors-vary"
8585
import { errorLayer } from "./middleware/error"
8686
import { fenceLayer } from "./middleware/fence"
87+
import { schemaErrorLayer } from "./middleware/schema-error"
8788

8889
export const context = Context.makeUnsafe<unknown>(new Map())
8990

@@ -114,6 +115,7 @@ const authOnlyRouterLayer = authorizationRouterMiddleware.layer.pipe(Layer.provi
114115
const httpApiAuthLayer = authorizationLayer.pipe(Layer.provide(ServerAuth.Config.defaultLayer))
115116
const rootApiRoutes = HttpApiBuilder.layer(RootHttpApi).pipe(
116117
Layer.provide([controlHandlers, globalHandlers]),
118+
Layer.provide(schemaErrorLayer),
117119
Layer.provide(httpApiAuthLayer),
118120
)
119121
const instanceRouterLayer = authorizationRouterMiddleware
@@ -150,6 +152,7 @@ const instanceRoutes = Layer.mergeAll(rawInstanceRoutes, instanceApiRoutes).pipe
150152
httpApiAuthLayer,
151153
workspaceRoutingLayer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal)),
152154
instanceContextLayer,
155+
schemaErrorLayer,
153156
]),
154157
)
155158

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
import { afterEach, describe, expect } from "bun:test"
2+
import { Effect } from "effect"
3+
import { eq } from "drizzle-orm"
4+
import * as Database from "@/storage/db"
5+
import { ModelID, ProviderID } from "../../src/provider/schema"
6+
import { WithInstance } from "../../src/project/with-instance"
7+
import { Server } from "../../src/server/server"
8+
import { Session } from "@/session/session"
9+
import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session"
10+
import { SyncPaths } from "../../src/server/routes/instance/httpapi/groups/sync"
11+
import { MessageID, PartID } from "../../src/session/schema"
12+
import { PartTable } from "@/session/session.sql"
13+
import { resetDatabase } from "../fixture/db"
14+
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
15+
import { it } from "../lib/effect"
16+
17+
afterEach(async () => {
18+
await disposeAllInstances()
19+
await resetDatabase()
20+
})
21+
22+
const withTmp = <A, E, R>(
23+
options: Parameters<typeof tmpdir>[0],
24+
fn: (tmp: Awaited<ReturnType<typeof tmpdir>>) => Effect.Effect<A, E, R>,
25+
) =>
26+
Effect.acquireRelease(
27+
Effect.promise(() => tmpdir(options)),
28+
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
29+
).pipe(Effect.flatMap(fn))
30+
31+
async function seedCorruptStepFinishPart(directory: string) {
32+
return WithInstance.provide({
33+
directory,
34+
fn: () =>
35+
Effect.runPromise(
36+
Effect.gen(function* () {
37+
const session = yield* Session.Service
38+
const info = yield* session.create({})
39+
const message = yield* session.updateMessage({
40+
id: MessageID.ascending(),
41+
role: "user",
42+
sessionID: info.id,
43+
agent: "build",
44+
model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") },
45+
time: { created: Date.now() },
46+
})
47+
const partID = PartID.ascending()
48+
yield* session.updatePart({
49+
id: partID,
50+
sessionID: info.id,
51+
messageID: message.id,
52+
type: "step-finish",
53+
reason: "stop",
54+
cost: 0,
55+
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
56+
})
57+
// Schema.Finite still rejects NaN at encode — exact mirror of the
58+
// corrupt row that broke the user's session in the OMO/Windows bug.
59+
Database.use((db) =>
60+
db
61+
.update(PartTable)
62+
.set({
63+
data: {
64+
type: "step-finish",
65+
reason: "stop",
66+
cost: 0,
67+
tokens: { input: 0, output: NaN, reasoning: 0, cache: { read: 0, write: 0 } },
68+
} as never, // drizzle's .set() can't narrow the discriminated union
69+
})
70+
.where(eq(PartTable.id, partID))
71+
.run(),
72+
)
73+
return info.id
74+
}).pipe(Effect.provide(Session.defaultLayer)),
75+
),
76+
})
77+
}
78+
79+
describe("schema-rejection wire shape", () => {
80+
it.live(
81+
"Payload schema rejection returns NamedError-shaped JSON, not empty",
82+
withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) =>
83+
Effect.gen(function* () {
84+
const res = yield* Effect.promise(async () =>
85+
Server.Default().app.request(SyncPaths.history, {
86+
method: "POST",
87+
headers: { "x-opencode-directory": tmp.path, "content-type": "application/json" },
88+
body: JSON.stringify({ aggregate: -1 }),
89+
}),
90+
)
91+
const body = yield* Effect.promise(async () => res.text())
92+
expect(res.status).toBe(400)
93+
expect(res.headers.get("content-type") ?? "").toContain("application/json")
94+
const parsed = JSON.parse(body)
95+
expect(parsed).toMatchObject({
96+
name: "BadRequest",
97+
data: { kind: expect.stringMatching(/^(Body|Payload)$/) },
98+
})
99+
expect(parsed.data.message).toEqual(expect.any(String))
100+
expect(parsed.data.message.length).toBeGreaterThan(0)
101+
}),
102+
),
103+
)
104+
105+
it.live(
106+
"Query schema rejection returns NamedError-shaped JSON",
107+
withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) =>
108+
Effect.gen(function* () {
109+
// /find/file?limit=999999 violates the limit constraint check.
110+
const url = `/find/file?query=foo&limit=999999&directory=${encodeURIComponent(tmp.path)}`
111+
const res = yield* Effect.promise(async () => Server.Default().app.request(url))
112+
const body = yield* Effect.promise(async () => res.text())
113+
expect(res.status).toBe(400)
114+
const parsed = JSON.parse(body)
115+
expect(parsed).toMatchObject({ name: "BadRequest", data: { kind: "Query" } })
116+
}),
117+
),
118+
)
119+
120+
it.live(
121+
"rejected request body never echoes back unbounded — message is capped",
122+
// Defense against DoS-amplification + secret-echo: Effect's Issue formatter
123+
// dumps the rejected `actual` verbatim. A multi-MB invalid array would
124+
// become a multi-MB 400 response and log line. Cap kicks in around 1KB.
125+
withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) =>
126+
Effect.gen(function* () {
127+
const huge = "X".repeat(50_000)
128+
const res = yield* Effect.promise(async () =>
129+
Server.Default().app.request(SyncPaths.history, {
130+
method: "POST",
131+
headers: { "x-opencode-directory": tmp.path, "content-type": "application/json" },
132+
body: JSON.stringify({ aggregate: huge }),
133+
}),
134+
)
135+
const body = yield* Effect.promise(async () => res.text())
136+
expect(res.status).toBe(400)
137+
// 1 KB cap + small JSON envelope ≈ <2 KB — never tens of KB.
138+
expect(body.length).toBeLessThan(2 * 1024)
139+
const parsed = JSON.parse(body)
140+
expect(parsed.data.message).not.toContain(huge)
141+
}),
142+
),
143+
)
144+
145+
it.live(
146+
"response-encode failure: corrupted stored row returns NamedError-shaped JSON with field path",
147+
withTmp({ config: { formatter: false, lsp: false } }, (tmp) =>
148+
Effect.gen(function* () {
149+
const sessionID = yield* Effect.promise(() => seedCorruptStepFinishPart(tmp.path))
150+
const url = `${SessionPaths.messages.replace(":sessionID", sessionID)}?limit=80&directory=${encodeURIComponent(tmp.path)}`
151+
const res = yield* Effect.promise(async () => Server.Default().app.request(url))
152+
const body = yield* Effect.promise(async () => res.text())
153+
expect(res.status).toBe(400)
154+
expect(res.headers.get("content-type") ?? "").toContain("application/json")
155+
const parsed = JSON.parse(body)
156+
expect(parsed).toMatchObject({ name: "BadRequest", data: { kind: "Body" } })
157+
// Field path in data.message — what made this PR worth shipping.
158+
expect(parsed.data.message).toMatch(/output/)
159+
}),
160+
),
161+
)
162+
})

packages/opencode/test/server/sdk-error-shape.test.ts

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -52,23 +52,33 @@ describe("v2 SDK error shape", () => {
5252
})
5353
})
5454

55-
test("400 with empty body throws a real Error naming the status", async () => {
55+
test("400 schema rejection: SDK extracts the field-level reason from the NamedError body", async () => {
56+
// Canary for the #26631 wire shape. Asserts the contract end-to-end:
57+
// server emits {name:"BadRequest", data:{message, kind}}, SDK's
58+
// wrapClientError extracts .data.message into Error.message. If either
59+
// side regresses (#26457 reverted because both layers were missing),
60+
// this test fails before users see (empty response body).
5661
await using tmp = await tmpdir({ config: { formatter: false, lsp: false } })
5762
const sdk = client(tmp.path)
5863

5964
let caught: unknown
6065
try {
61-
// POST /sync/history with `aggregate: -1` triggers schema validation
62-
// that returns an empty 400 body (verified via plan-mode probe).
63-
await sdk.sync.history.list({ aggregate: -1 } as any, { throwOnError: true })
66+
await sdk.sync.history.list({ body: { aggregate: -1 } as any }, { throwOnError: true })
6467
} catch (e) {
6568
caught = e
6669
}
6770

6871
expect(caught).toBeInstanceOf(Error)
6972
const err = caught as Error
70-
const cause = err.cause as { status?: number }
71-
expect(err.message.length).toBeGreaterThan(0)
73+
const cause = err.cause as { body?: any; status?: number }
7274
expect(cause.status).toBe(400)
75+
expect(cause.body).toMatchObject({
76+
name: "BadRequest",
77+
data: { kind: expect.stringMatching(/^(Body|Payload)$/) },
78+
})
79+
expect(typeof cause.body.data.message).toBe("string")
80+
expect(cause.body.data.message.length).toBeGreaterThan(0)
81+
// Whatever the server put in data.message must be what the user sees.
82+
expect(err.message).toBe(cause.body.data.message)
7383
})
7484
})
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
// Smoke test: v1 SDK (the plugin contract) can actually reach core endpoints
2+
// against the current server. v1 generation has been frozen since #5216
3+
// (2025-12-07) so types may be stale, but runtime calls should still work
4+
// for endpoints the v1 SDK was generated against.
5+
import { afterEach, describe, expect, test } from "bun:test"
6+
import { createOpencodeClient } from "@opencode-ai/sdk"
7+
import { Server } from "../../src/server/server"
8+
import { tmpdir, disposeAllInstances } from "../fixture/fixture"
9+
import { resetDatabase } from "../fixture/db"
10+
import * as Log from "@opencode-ai/core/util/log"
11+
12+
void Log.init({ print: false })
13+
14+
afterEach(async () => {
15+
await disposeAllInstances()
16+
await resetDatabase()
17+
})
18+
19+
function client(directory: string) {
20+
return createOpencodeClient({
21+
baseUrl: "http://test",
22+
directory,
23+
fetch: ((req: Request) => Server.Default().app.fetch(req)) as unknown as typeof fetch,
24+
})
25+
}
26+
27+
describe("v1 SDK runtime smoke", () => {
28+
test("session.list reaches the server and returns 200", async () => {
29+
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
30+
const sdk = client(tmp.path)
31+
const result = await sdk.session.list()
32+
expect(result.error).toBeUndefined()
33+
expect(Array.isArray(result.data)).toBe(true)
34+
})
35+
36+
test("path.get reaches the server and returns 200", async () => {
37+
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
38+
const sdk = client(tmp.path)
39+
const result = await sdk.path.get()
40+
expect(result.error).toBeUndefined()
41+
expect(result.data).toBeDefined()
42+
})
43+
44+
test("config.get reaches the server and returns 200", async () => {
45+
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
46+
const sdk = client(tmp.path)
47+
const result = await sdk.config.get()
48+
expect(result.error).toBeUndefined()
49+
expect(result.data).toBeDefined()
50+
})
51+
52+
test("session 404: result-tuple path returns the error body", async () => {
53+
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
54+
const sdk = client(tmp.path)
55+
const result = await sdk.session.get({ path: { id: "ses_no_such" } as never })
56+
expect(result.error).toBeDefined()
57+
// wire body for 404 is NamedError-shaped
58+
expect(result.error).toMatchObject({ name: "NotFoundError" })
59+
})
60+
})

packages/sdk/js/src/gen/types.gen.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -752,11 +752,11 @@ export type Project = {
752752
}
753753

754754
export type BadRequestError = {
755-
data: unknown
756-
errors: Array<{
757-
[key: string]: unknown
758-
}>
759-
success: false
755+
name: "BadRequest"
756+
data: {
757+
message: string
758+
kind?: "Params" | "Headers" | "Query" | "Body" | "Payload"
759+
}
760760
}
761761

762762
export type NotFoundError = {

0 commit comments

Comments
 (0)