fix(server): reject a session location that is not an absolute path

This commit is contained in:
Kit Langton
2026-11-06 17:22:08 -08:00
Unverified
parent b2b5da600e
commit 200f1cf15b
4 changed files with 80 additions and 6 deletions
+2 -2
View File
@@ -1059,7 +1059,7 @@
}
}
},
"description": "Create a session at the requested location. A parentID creates a linked child session at its parent's location.",
"description": "Create a session at the requested location. A parentID creates a linked child session at its parent's location. The location directory must be an absolute path.",
"summary": "Create session",
"requestBody": {
"content": {
@@ -1312,7 +1312,7 @@
}
}
},
"description": "Import a projected session transcript at the requested location. Accepts transcripts exported by earlier releases. If parentID is supplied, the parent session must already exist; import parents before children.",
"description": "Import a projected session transcript at the requested location. Accepts transcripts exported by earlier releases. If parentID is supplied, the parent session must already exist; import parents before children. The location directory must be an absolute path.",
"summary": "Import session",
"requestBody": {
"content": {
+4 -4
View File
@@ -174,13 +174,13 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S, FormI ext
permissions: Permission.Ruleset.pipe(Schema.optional),
}),
success: Schema.Struct({ data: PublicSessionInfo }),
error: SessionNotFoundError,
error: [InvalidRequestError, SessionNotFoundError],
}).annotateMerge(
OpenApi.annotations({
identifier: "session.create",
summary: "Create session",
description:
"Create a session at the requested location. A parentID creates a linked child session at its parent's location.",
"Create a session at the requested location. A parentID creates a linked child session at its parent's location. The location directory must be an absolute path.",
}),
),
)
@@ -192,13 +192,13 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S, FormI ext
location: Location.PublicRef.pipe(Schema.optional),
}),
success: Schema.Struct({ data: PublicSessionInfo }),
error: [ConflictError, SessionNotFoundError],
error: [ConflictError, InvalidRequestError, SessionNotFoundError],
}).annotateMerge(
OpenApi.annotations({
identifier: "experimental.session.import",
summary: "Import session",
description:
"Import a projected session transcript at the requested location. Accepts transcripts exported by earlier releases. If parentID is supplied, the parent session must already exist; import parents before children.",
"Import a projected session transcript at the requested location. Accepts transcripts exported by earlier releases. If parentID is supplied, the parent session must already exist; import parents before children. The location directory must be an absolute path.",
}),
),
)
+11
View File
@@ -26,6 +26,7 @@ import {
SessionBusyError,
SkillNotFoundError,
} from "@opencode/protocol/errors"
import { storedAbsolutePath } from "@opencode/core/database/path"
import { AbsolutePath } from "@opencode/core/schema"
import { locationErrors } from "../location"
import { failedMessageDecode, failedSnapshot, missingMessage, missingSession } from "./session-error"
@@ -34,6 +35,14 @@ function missingForm(id: Form.ID) {
return new FormNotFoundError({ id, message: `Form not found: ${id}` })
}
// Session and project rows only store absolute directories, so anything else would die at insert as an empty 500.
function requireStorableLocation(location: { readonly directory: string } | undefined) {
if (location === undefined || storedAbsolutePath(location.directory) !== undefined) return Effect.void
return Effect.fail(
new InvalidRequestError({ message: "location.directory must be an absolute path", field: "location" }),
)
}
export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handlers) =>
Effect.gen(function* () {
const session = yield* Session.Service
@@ -101,6 +110,7 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
.handle(
"session.create",
Effect.fn(function* (ctx) {
if (ctx.payload.parentID === undefined) yield* requireStorableLocation(ctx.payload.location)
return {
data: yield* session
.create({
@@ -121,6 +131,7 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
.handle(
"session.import",
Effect.fn(function* (ctx) {
yield* requireStorableLocation(ctx.payload.location)
return {
data: yield* transfer
.import({
@@ -0,0 +1,63 @@
import { expect } from "bun:test"
import { App } from "@opencode/core/app"
import { Bus } from "@opencode/core/bus"
import { Database } from "@opencode/core/database/database"
import { Watcher } from "@opencode/core/filesystem/watcher"
import { ModelsDev } from "@opencode/core/models-dev"
import { SessionExecution } from "@opencode/core/session/execution"
import { LayerNode } from "@opencode/util/effect/layer-node"
import { Global } from "@opencode/util/global"
import { Context, Effect, Layer } from "effect"
import { HttpEffect, HttpRouter, HttpServer } from "effect/http"
import { tempGlobalLayer } from "../../core/test/fixture/global"
import { tmpdirScoped } from "../../core/test/fixture/tmpdir"
import { it } from "../../core/test/lib/effect"
import { createEmbeddedRoutes } from "../src/routes"
it.live("rejects a session location that cannot be stored as an absolute path", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
const replacements: LayerNode.Replacements = [
Global.node.replace(tempGlobalLayer),
Database.node.replace(Database.configured({ path: ":memory:" })),
Bus.node.replace(Bus.node),
App.node.replace(App.node),
ModelsDev.node.replace(ModelsDev.configured({ fetch: false })),
Watcher.node.replace(Watcher.configured({ enabled: false })),
SessionExecution.node.replace(SessionExecution.noopLayer),
]
const context = yield* Layer.build(
createEmbeddedRoutes({ config: { directory: dir.path, project: false, content: "{}" } }, replacements).pipe(
Layer.provide(HttpServer.layerServices),
),
)
const handler = Context.get(context, HttpRouter.HttpRouter)
.asHttpEffect()
.pipe(HttpEffect.toWebHandlerWith(context))
const create = (directory: string) =>
Effect.promise(() =>
handler(
new Request("http://opencode.local/api/session", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ location: { directory } }),
}),
),
)
// A relative, home-relative or empty directory used to die while writing the session row, which reached the
// client as an empty 500 instead of a request error it can explain.
for (const directory of ["", "relative/path", "~/code"]) {
const response = yield* create(directory)
expect(response.status).toBe(400)
expect(yield* Effect.promise(() => response.json())).toMatchObject({
_tag: "InvalidRequestError",
field: "location",
})
}
const response = yield* create(dir.path)
expect(response.status).toBe(200)
expect(yield* Effect.promise(() => response.json())).toMatchObject({ data: { location: { directory: dir.path } } })
}),
)