Skip to content

Commit 9739d75

Browse files
authored
fix(httpapi): handle corrupt v2 session messages (#28633)
1 parent 4a97648 commit 9739d75

7 files changed

Lines changed: 120 additions & 13 deletions

File tree

packages/opencode/src/server/routes/instance/httpapi/groups/v2/message.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { SessionID } from "@/session/schema"
22
import { SessionMessage } from "@opencode-ai/core/session-message"
33
import { Schema } from "effect"
44
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
5-
import { InvalidCursorError, SessionNotFoundError } from "../../errors"
5+
import { InvalidCursorError, SessionNotFoundError, UnknownError } from "../../errors"
66
import { V2Authorization } from "../../middleware/authorization"
77
import { WorkspaceRoutingQueryFields } from "../../middleware/workspace-routing"
88

@@ -36,7 +36,7 @@ export const MessageGroup = HttpApiGroup.make("v2.message")
3636
next: Schema.String.pipe(Schema.optional),
3737
}),
3838
}).annotate({ identifier: "V2SessionMessagesResponse" }),
39-
error: [InvalidCursorError, SessionNotFoundError],
39+
error: [InvalidCursorError, SessionNotFoundError, UnknownError],
4040
}).annotateMerge(
4141
OpenApi.annotations({
4242
identifier: "v2.session.messages",

packages/opencode/src/server/routes/instance/httpapi/groups/v2/session.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { Prompt } from "@opencode-ai/core/session-prompt"
44
import { SessionV2 } from "@/v2/session"
55
import { Schema } from "effect"
66
import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
7-
import { InvalidCursorError, InvalidRequestError, ServiceUnavailableError, SessionNotFoundError } from "../../errors"
7+
import { InvalidCursorError, InvalidRequestError, ServiceUnavailableError, SessionNotFoundError, UnknownError } from "../../errors"
88
import { V2Authorization } from "../../middleware/authorization"
99
import { WorkspaceRoutingQuery, WorkspaceRoutingQueryFields } from "../../middleware/workspace-routing"
1010
import { QueryBoolean } from "../query"
@@ -103,7 +103,7 @@ export const SessionGroup = HttpApiGroup.make("v2.session")
103103
params: { sessionID: SessionID },
104104
query: WorkspaceRoutingQuery,
105105
success: Schema.Array(SessionMessage.Message),
106-
error: SessionNotFoundError,
106+
error: [SessionNotFoundError, UnknownError],
107107
}).annotateMerge(
108108
OpenApi.annotations({
109109
identifier: "v2.session.context",

packages/opencode/src/server/routes/instance/httpapi/handlers/v2/message.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { Effect, Schema } from "effect"
44
import * as DateTime from "effect/DateTime"
55
import { HttpApiBuilder } from "effect/unstable/httpapi"
66
import { InstanceHttpApi } from "../../api"
7-
import { InvalidCursorError, SessionNotFoundError } from "../../errors"
7+
import { InvalidCursorError, SessionNotFoundError, UnknownError } from "../../errors"
88

99
const DefaultMessagesLimit = 50
1010

@@ -58,6 +58,20 @@ export const messageHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.message
5858
}),
5959
),
6060
),
61+
Effect.catchTag("Session.MessageDecodeError", (error) => {
62+
const ref = `err_${crypto.randomUUID().slice(0, 8)}`
63+
return Effect.logError("failed to decode v2 session message").pipe(
64+
Effect.annotateLogs({ ref, sessionID: error.sessionID, messageID: error.messageID }),
65+
Effect.andThen(
66+
Effect.fail(
67+
new UnknownError({
68+
message: "Unexpected server error. Check server logs for details.",
69+
ref,
70+
}),
71+
),
72+
),
73+
)
74+
}),
6175
)
6276
const first = messages[0]
6377
const last = messages.at(-1)

packages/opencode/src/server/routes/instance/httpapi/handlers/v2/session.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { SessionV2 } from "@/v2/session"
33
import { DateTime, Effect, Option, Schema } from "effect"
44
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
55
import { InstanceHttpApi } from "../../api"
6-
import { InvalidCursorError, InvalidRequestError, ServiceUnavailableError, SessionNotFoundError } from "../../errors"
6+
import { InvalidCursorError, InvalidRequestError, ServiceUnavailableError, SessionNotFoundError, UnknownError } from "../../errors"
77

88
const DefaultSessionsLimit = 50
99

@@ -219,6 +219,20 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.session
219219
}),
220220
),
221221
),
222+
Effect.catchTag("Session.MessageDecodeError", (error) => {
223+
const ref = `err_${crypto.randomUUID().slice(0, 8)}`
224+
return Effect.logError("failed to decode v2 session message").pipe(
225+
Effect.annotateLogs({ ref, sessionID: error.sessionID, messageID: error.messageID }),
226+
Effect.andThen(
227+
Effect.fail(
228+
new UnknownError({
229+
message: "Unexpected server error. Check server logs for details.",
230+
ref,
231+
}),
232+
),
233+
),
234+
)
235+
}),
222236
)
223237
}),
224238
)

packages/opencode/src/v2/session.ts

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,11 @@ export class OperationUnavailableError extends Schema.TaggedErrorClass<Operation
7272
},
7373
) {}
7474

75+
export class MessageDecodeError extends Schema.TaggedErrorClass<MessageDecodeError>()("Session.MessageDecodeError", {
76+
sessionID: SessionID,
77+
messageID: SessionMessage.ID,
78+
}) {}
79+
7580
export interface Interface {
7681
readonly create: (input?: {
7782
agent?: string
@@ -104,8 +109,8 @@ export interface Interface {
104109
time: number
105110
direction: "previous" | "next"
106111
}
107-
}) => Effect.Effect<SessionMessage.Message[], NotFoundError>
108-
readonly context: (sessionID: SessionID) => Effect.Effect<SessionMessage.Message[], NotFoundError>
112+
}) => Effect.Effect<SessionMessage.Message[], NotFoundError | MessageDecodeError>
113+
readonly context: (sessionID: SessionID) => Effect.Effect<SessionMessage.Message[], NotFoundError | MessageDecodeError>
109114
readonly prompt: (input: {
110115
id?: EventV2.ID
111116
sessionID: SessionID
@@ -120,7 +125,7 @@ export interface Interface {
120125
prompt: Prompt
121126
agent: string
122127
model?: ModelV2.Ref
123-
}) => Effect.Effect<void, NotFoundError | OperationUnavailableError>
128+
}) => Effect.Effect<void, NotFoundError | OperationUnavailableError | MessageDecodeError>
124129
readonly switchAgent: (input: { sessionID: SessionID; agent: string }) => Effect.Effect<void, never>
125130
readonly switchModel: (input: { sessionID: SessionID; model: ModelV2.Ref }) => Effect.Effect<void, never>
126131
readonly compact: (sessionID: SessionID) => Effect.Effect<void, NotFoundError | OperationUnavailableError>
@@ -133,10 +138,18 @@ export const layer = Layer.effect(
133138
Service,
134139
Effect.gen(function* () {
135140
const events = yield* EventV2Bridge.Service
136-
const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Message)
141+
const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Message)
137142

138143
const decode = (row: typeof SessionMessageTable.$inferSelect) =>
139-
decodeMessage({ ...row.data, id: row.id, type: row.type })
144+
decodeMessage({ ...row.data, id: row.id, type: row.type }).pipe(
145+
Effect.mapError(
146+
() =>
147+
new MessageDecodeError({
148+
sessionID: SessionID.make(row.session_id),
149+
messageID: SessionMessage.ID.make(row.id),
150+
}),
151+
),
152+
)
140153

141154
function fromRow(row: typeof SessionTable.$inferSelect): Info {
142155
return new Info({
@@ -262,7 +275,7 @@ export const layer = Layer.effect(
262275
const rows = input.limit === undefined ? query.all() : query.limit(input.limit).all()
263276
return direction === "previous" ? rows.toReversed() : rows
264277
})
265-
return rows.map((row) => decode(row))
278+
return yield* Effect.forEach(rows, (row) => decode(row))
266279
}),
267280
context: Effect.fn("V2Session.context")(function* (sessionID) {
268281
yield* result.get(sessionID)
@@ -295,7 +308,7 @@ export const layer = Layer.effect(
295308
.orderBy(asc(SessionMessageTable.time_created), asc(SessionMessageTable.id))
296309
.all()
297310
})
298-
return rows.map((row) => decode(row))
311+
return yield* Effect.forEach(rows, (row) => decode(row))
299312
}),
300313
prompt: Effect.fn("V2Session.prompt")(function* (input) {
301314
yield* result.get(input.sessionID)

packages/opencode/test/server/httpapi-public-openapi.test.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,4 +129,17 @@ describe("PublicApi OpenAPI v2 errors", () => {
129129
)
130130
}
131131
})
132+
133+
test("documents v2 session read data errors", () => {
134+
const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
135+
136+
for (const route of [
137+
["get", "/api/session/{sessionID}/context"],
138+
["get", "/api/session/{sessionID}/message"],
139+
] as const) {
140+
expect(componentName(responseRef(spec.paths[route[1]]?.[route[0]]?.responses?.["500"]) ?? "")).toMatch(
141+
/^UnknownError\d*$/,
142+
)
143+
}
144+
})
132145
})

packages/opencode/test/server/httpapi-session.test.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,24 @@ const insertLegacyAssistantMessage = (sessionID: SessionIDType, time = 1) =>
139139
)
140140
})
141141

142+
const insertCorruptV2Message = (sessionID: SessionIDType, time = 1) =>
143+
Effect.sync(() =>
144+
Database.use((db) =>
145+
db
146+
.insert(SessionMessageTable)
147+
.values([
148+
{
149+
id: SessionMessage.ID.create(),
150+
session_id: sessionID,
151+
type: "assistant",
152+
time_created: time,
153+
data: {} as NonNullable<(typeof SessionMessageTable.$inferInsert)["data"]>,
154+
},
155+
])
156+
.run(),
157+
),
158+
)
159+
142160
const setLegacySummaryDiff = (sessionID: SessionIDType) =>
143161
Effect.sync(() =>
144162
Database.use((db) =>
@@ -481,6 +499,41 @@ describe("session HttpApi", () => {
481499
{ git: true, config: { formatter: false, lsp: false } },
482500
)
483501

502+
it.instance(
503+
"returns safe v2 unknown errors for corrupt projected messages",
504+
() =>
505+
Effect.gen(function* () {
506+
const test = yield* TestInstance
507+
const session = yield* createSession({ title: "v2 corrupt message" })
508+
yield* insertCorruptV2Message(session.id)
509+
510+
const messages = yield* request(`/api/session/${session.id}/message`, {
511+
headers: { "x-opencode-directory": test.directory },
512+
})
513+
const messagesBody = yield* responseJson(messages)
514+
expect(messages.status).toBe(500)
515+
expect(messagesBody).toMatchObject({
516+
_tag: "UnknownError",
517+
message: "Unexpected server error. Check server logs for details.",
518+
})
519+
expect((messagesBody as { ref?: unknown }).ref).toMatch(/^err_[0-9a-f-]{8}$/)
520+
expect(JSON.stringify(messagesBody)).not.toContain("assistant")
521+
522+
const context = yield* request(`/api/session/${session.id}/context`, {
523+
headers: { "x-opencode-directory": test.directory },
524+
})
525+
const contextBody = yield* responseJson(context)
526+
expect(context.status).toBe(500)
527+
expect(contextBody).toMatchObject({
528+
_tag: "UnknownError",
529+
message: "Unexpected server error. Check server logs for details.",
530+
})
531+
expect((contextBody as { ref?: unknown }).ref).toMatch(/^err_[0-9a-f-]{8}$/)
532+
expect(JSON.stringify(contextBody)).not.toContain("assistant")
533+
}),
534+
{ git: true, config: { formatter: false, lsp: false } },
535+
)
536+
484537
it.instance(
485538
"serves sessions with migrated summary diffs missing file details",
486539
() =>

0 commit comments

Comments
 (0)