diff --git a/packages/client/src/promise/generated/client.ts b/packages/client/src/promise/generated/client.ts
index 2d7bf3140e5..3a64fad5077 100644
--- a/packages/client/src/promise/generated/client.ts
+++ b/packages/client/src/promise/generated/client.ts
@@ -1367,7 +1367,7 @@ export function make(options: ClientOptions) {
method: "GET",
path: `/api/session/${encodeURIComponent(input.sessionID)}/form`,
successStatus: 200,
- declaredStatuses: [404, 400, 401],
+ declaredStatuses: [404, 401, 400],
empty: false,
},
requestOptions,
@@ -1496,7 +1496,7 @@ export function make(options: ClientOptions) {
method: "GET",
path: `/api/session/${encodeURIComponent(input.sessionID)}/permission`,
successStatus: 200,
- declaredStatuses: [404, 400, 401],
+ declaredStatuses: [404, 401, 400],
empty: false,
},
requestOptions,
diff --git a/packages/console/app/src/global.d.ts b/packages/console/app/src/global.d.ts
index 4c2b0a1700e..b34e132b633 100644
--- a/packages/console/app/src/global.d.ts
+++ b/packages/console/app/src/global.d.ts
@@ -1,5 +1,7 @@
///
+import "@solidjs/start"
+
export declare module "@solidjs/start/server" {
export type APIEvent = { request: Request }
}
diff --git a/packages/core/src/location-services.ts b/packages/core/src/location-services.ts
index aee581b1863..afe874c31bc 100644
--- a/packages/core/src/location-services.ts
+++ b/packages/core/src/location-services.ts
@@ -37,6 +37,7 @@ export function buildLocationServiceMap(
...inner,
get: (ref: Location.Ref) => inner.get(canonical(ref)),
contextEffect: (ref: Location.Ref) => inner.contextEffect(canonical(ref)),
+ contextEffectOption: (ref: Location.Ref) => inner.contextEffectOption(canonical(ref)),
invalidate: (ref: Location.Ref) => inner.invalidate(canonical(ref)),
}),
),
diff --git a/packages/core/test/location-layer.test.ts b/packages/core/test/location-layer.test.ts
index 78414570af0..9ce09cb2397 100644
--- a/packages/core/test/location-layer.test.ts
+++ b/packages/core/test/location-layer.test.ts
@@ -13,6 +13,7 @@ import {
Hash,
Layer,
LayerMap,
+ Option,
RcMap,
Schema,
Stream,
@@ -674,14 +675,21 @@ describe("LocationServiceMap", () => {
expect(Equal.equals(absent, present)).toBe(false)
if (process.platform === "win32") expect(absent.directory).not.toBe(present.directory)
+ expect(yield* locations.contextEffectOption(absent)).toEqual(Option.none())
+ expect(Array.from(yield* RcMap.keys(locations.rcMap))).toHaveLength(0)
+
const first = yield* locations.contextEffect(absent)
expect(yield* locations.contextEffect(present)).toBe(first)
+ expect(Option.getOrThrow(yield* locations.contextEffectOption(absent))).toBe(first)
+ expect(Option.getOrThrow(yield* locations.contextEffectOption(present))).toBe(first)
expect(Array.from(yield* RcMap.keys(locations.rcMap))).toEqual([
Location.Ref.make({ directory, workspaceID: undefined }),
])
// Invalidating with the shape opposite to the one that booted must evict.
yield* locations.invalidate(present)
+ expect(yield* locations.contextEffectOption(absent)).toEqual(Option.none())
+ expect(yield* locations.contextEffectOption(present)).toEqual(Option.none())
expect(Array.from(yield* RcMap.keys(locations.rcMap))).toHaveLength(0)
}),
),
diff --git a/packages/protocol/src/groups/form.ts b/packages/protocol/src/groups/form.ts
index f9160e5dc90..198b5f06fc3 100644
--- a/packages/protocol/src/groups/form.ts
+++ b/packages/protocol/src/groups/form.ts
@@ -55,15 +55,13 @@ export const makeFormGroup = <
params: { sessionID: Schema.String },
success: Schema.Struct({ data: Schema.Array(Form.Info) }),
error: SessionNotFoundError,
- })
- .middleware(formLocationMiddleware)
- .annotateMerge(
- OpenApi.annotations({
- identifier: "v2.session.form.list",
- summary: "List session forms",
- description: "Retrieve pending forms for a session.",
- }),
- ),
+ }).annotateMerge(
+ OpenApi.annotations({
+ identifier: "v2.session.form.list",
+ summary: "List session forms",
+ description: "Retrieve pending forms for a session.",
+ }),
+ ),
)
.add(
HttpApiEndpoint.post("session.form.create", "/api/session/:sessionID/form", {
diff --git a/packages/protocol/src/groups/permission.ts b/packages/protocol/src/groups/permission.ts
index d8f722193f7..6fdef8405fd 100644
--- a/packages/protocol/src/groups/permission.ts
+++ b/packages/protocol/src/groups/permission.ts
@@ -90,15 +90,13 @@ export const makePermissionGroup = <
params: { sessionID: Session.ID },
success: Schema.Struct({ data: Schema.Array(Permission.Request) }),
error: SessionNotFoundError,
- })
- .middleware(sessionLocationMiddleware)
- .annotateMerge(
- OpenApi.annotations({
- identifier: "v2.session.permission.list",
- summary: "List session permission requests",
- description: "Retrieve pending permission requests owned by a session.",
- }),
- ),
+ }).annotateMerge(
+ OpenApi.annotations({
+ identifier: "v2.session.permission.list",
+ summary: "List session permission requests",
+ description: "Retrieve pending permission requests owned by a session.",
+ }),
+ ),
)
.add(
HttpApiEndpoint.get("session.permission.get", "/api/session/:sessionID/permission/:requestID", {
diff --git a/packages/server/src/handlers/form.ts b/packages/server/src/handlers/form.ts
index e561153bb05..f7cdba048ba 100644
--- a/packages/server/src/handlers/form.ts
+++ b/packages/server/src/handlers/form.ts
@@ -1,4 +1,6 @@
+import { Database } from "@opencode-ai/core/database/database"
import { Form } from "@opencode-ai/core/form"
+import { LocationServiceMap } from "@opencode-ai/core/location-services"
import {
ConflictError,
FormAlreadySettledError,
@@ -6,10 +8,10 @@ import {
FormNotFoundError,
InvalidRequestError,
} from "@opencode-ai/protocol/errors"
-import { Effect } from "effect"
+import { Effect, Option } from "effect"
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
import { Api } from "../api"
-import { response } from "../location"
+import { requestRef, response, sessionRef, withLoadedLocationServices } from "../location"
function missingForm(id: Form.ID) {
return new FormNotFoundError({ id, message: `Form not found: ${id}` })
@@ -17,6 +19,8 @@ function missingForm(id: Form.ID) {
export const FormHandler = HttpApiBuilder.group(Api, "server.form", (handlers) =>
Effect.gen(function* () {
+ const locations = yield* LocationServiceMap.Service
+ const database = yield* Database.Service
const requireOwnedForm = Effect.fnUntraced(function* (sessionID: Form.Info["sessionID"], formID: Form.ID) {
const form = yield* Form.Service
const info = yield* form.get(formID).pipe(Effect.catchTag("Form.NotFoundError", () => missingForm(formID)))
@@ -35,9 +39,16 @@ export const FormHandler = HttpApiBuilder.group(Api, "server.form", (handlers) =
.handle(
"session.form.list",
Effect.fn(function* (ctx) {
- const form = yield* Form.Service
- const forms = yield* form.list({ sessionID: ctx.params.sessionID })
- return { data: forms }
+ const ref =
+ ctx.params.sessionID === "global"
+ ? requestRef(ctx.request)
+ : yield* sessionRef(database, ctx.params.sessionID)
+ const forms = yield* withLoadedLocationServices(
+ locations,
+ ref,
+ Form.Service.use((form) => form.list({ sessionID: ctx.params.sessionID })),
+ )
+ return { data: Option.getOrElse(forms, () => []) }
}),
)
.handle(
diff --git a/packages/server/src/handlers/permission.ts b/packages/server/src/handlers/permission.ts
index 0d5df461b6b..b33b64ef740 100644
--- a/packages/server/src/handlers/permission.ts
+++ b/packages/server/src/handlers/permission.ts
@@ -1,11 +1,13 @@
+import { Database } from "@opencode-ai/core/database/database"
import { Location } from "@opencode-ai/core/location"
+import { LocationServiceMap } from "@opencode-ai/core/location-services"
import { Permission } from "@opencode-ai/core/permission"
import { PermissionSaved } from "@opencode-ai/core/permission/saved"
-import { Effect } from "effect"
+import { Effect, Option } from "effect"
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
import { Api } from "../api"
import { PermissionNotFoundError, SessionNotFoundError } from "@opencode-ai/protocol/errors"
-import { response } from "../location"
+import { response, sessionRef, withLoadedLocationServices } from "../location"
function missingRequest(id: Permission.ID) {
return new PermissionNotFoundError({ requestID: id, message: `Permission request not found: ${id}` })
@@ -13,6 +15,8 @@ function missingRequest(id: Permission.ID) {
export const PermissionHandler = HttpApiBuilder.group(Api, "server.permission", (handlers) =>
Effect.gen(function* () {
+ const locations = yield* LocationServiceMap.Service
+ const database = yield* Database.Service
const requireOwnedRequest = Effect.fnUntraced(function* (
sessionID: Permission.Request["sessionID"],
requestID: Permission.ID,
@@ -63,8 +67,13 @@ export const PermissionHandler = HttpApiBuilder.group(Api, "server.permission",
.handle(
"session.permission.list",
Effect.fn(function* (ctx) {
- const permission = yield* Permission.Service
- return { data: yield* permission.forSession(ctx.params.sessionID) }
+ const ref = yield* sessionRef(database, ctx.params.sessionID)
+ const requests = yield* withLoadedLocationServices(
+ locations,
+ ref,
+ Permission.Service.use((permission) => permission.forSession(ctx.params.sessionID)),
+ )
+ return { data: Option.getOrElse(requests, () => []) }
}),
)
.handle(
diff --git a/packages/server/src/location.ts b/packages/server/src/location.ts
index 7b23b5e4899..13a0b4fbfb0 100644
--- a/packages/server/src/location.ts
+++ b/packages/server/src/location.ts
@@ -1,8 +1,13 @@
+import { Database } from "@opencode-ai/core/database/database"
import { Location } from "@opencode-ai/core/location"
import { LocationServiceMap } from "@opencode-ai/core/location-services"
import { AbsolutePath } from "@opencode-ai/core/schema"
+import { Session } from "@opencode-ai/core/session"
+import { SessionTable } from "@opencode-ai/core/session/sql"
import { Workspace } from "@opencode-ai/core/workspace"
-import { Effect, Layer } from "effect"
+import { InvalidRequestError, SessionNotFoundError } from "@opencode-ai/protocol/errors"
+import { eq } from "drizzle-orm"
+import { Context, Effect, Layer, Option, Schema } from "effect"
import { HttpServerRequest } from "effect/unstable/http"
import { HttpApiMiddleware } from "effect/unstable/httpapi"
@@ -26,6 +31,41 @@ export function response(data: Effect.Effect) {
})
}
+const decodeSessionID = Schema.decodeUnknownEffect(Session.ID)
+
+export function sessionRef(database: Context.Service.Shape, sessionID: unknown) {
+ return Effect.gen(function* () {
+ const id = yield* decodeSessionID(sessionID).pipe(
+ Effect.mapError(() => new InvalidRequestError({ message: "Invalid session ID", field: "sessionID" })),
+ )
+ const row = yield* database.db
+ .select({ directory: SessionTable.directory, workspaceID: SessionTable.workspace_id })
+ .from(SessionTable)
+ .where(eq(SessionTable.id, id))
+ .get()
+ .pipe(Effect.orDie)
+ if (!row) return yield* new SessionNotFoundError({ sessionID: id, message: `Session not found: ${id}` })
+ return Location.Ref.make({
+ directory: AbsolutePath.make(row.directory),
+ workspaceID: row.workspaceID ? Workspace.ID.make(row.workspaceID) : undefined,
+ })
+ })
+}
+
+export function withLoadedLocationServices(
+ locations: Context.Service.Shape,
+ ref: Location.Ref,
+ effect: Effect.Effect,
+) {
+ return Effect.scoped(
+ Effect.gen(function* () {
+ const context = yield* locations.contextEffectOption(ref)
+ if (Option.isNone(context)) return Option.none()
+ return Option.some(yield* effect.pipe(Effect.provide(context.value)))
+ }),
+ )
+}
+
export function requestRef(request: HttpServerRequest.HttpServerRequest): Location.Ref {
const query = new URL(request.url, "http://localhost").searchParams
const workspaceID = query.get("location[workspace]") || request.headers["x-opencode-workspace"]
diff --git a/packages/server/src/middleware/form-location.ts b/packages/server/src/middleware/form-location.ts
index 96333dc1c64..ebcef974020 100644
--- a/packages/server/src/middleware/form-location.ts
+++ b/packages/server/src/middleware/form-location.ts
@@ -1,16 +1,10 @@
import { Database } from "@opencode-ai/core/database/database"
-import { Location } from "@opencode-ai/core/location"
import { LocationServiceMap } from "@opencode-ai/core/location-services"
-import { AbsolutePath } from "@opencode-ai/core/schema"
-import { Session } from "@opencode-ai/core/session"
-import { SessionTable } from "@opencode-ai/core/session/sql"
-import { Workspace } from "@opencode-ai/core/workspace"
import { InvalidRequestError, SessionNotFoundError } from "@opencode-ai/protocol/errors"
-import { eq } from "drizzle-orm"
-import { Effect, Layer, Schema } from "effect"
+import { Effect, Layer } from "effect"
import { HttpRouter, HttpServerRequest } from "effect/unstable/http"
import { HttpApiMiddleware } from "effect/unstable/httpapi"
-import { requestRef, type LocationServices } from "../location"
+import { requestRef, sessionRef, type LocationServices } from "../location"
export class FormLocationMiddleware extends HttpApiMiddleware.Service<
FormLocationMiddleware,
@@ -19,12 +13,10 @@ export class FormLocationMiddleware extends HttpApiMiddleware.Service<
error: [InvalidRequestError, SessionNotFoundError],
}) {}
-const decodeSessionID = Schema.decodeUnknownEffect(Session.ID)
-
export const formLocationLayer = Layer.effect(
FormLocationMiddleware,
Effect.gen(function* () {
- const { db } = yield* Database.Service
+ const database = yield* Database.Service
const locations = yield* LocationServiceMap.Service
return FormLocationMiddleware.of((effect) =>
@@ -38,38 +30,8 @@ export const formLocationLayer = Layer.effect(
return yield* effect.pipe(Effect.provide(locations.get(requestRef(request))))
}
- const sessionID = yield* decodeSessionID(route.params.sessionID).pipe(
- Effect.mapError(
- () =>
- new InvalidRequestError({
- message: "Invalid session ID",
- field: "sessionID",
- }),
- ),
- )
- const row = yield* db
- .select({ directory: SessionTable.directory, workspaceID: SessionTable.workspace_id })
- .from(SessionTable)
- .where(eq(SessionTable.id, sessionID))
- .get()
- .pipe(Effect.orDie)
- if (!row) {
- return yield* new SessionNotFoundError({
- sessionID,
- message: `Session not found: ${sessionID}`,
- })
- }
-
- return yield* effect.pipe(
- Effect.provide(
- locations.get(
- Location.Ref.make({
- directory: AbsolutePath.make(row.directory),
- workspaceID: row.workspaceID ? Workspace.ID.make(row.workspaceID) : undefined,
- }),
- ),
- ),
- )
+ const ref = yield* sessionRef(database, route.params.sessionID)
+ return yield* effect.pipe(Effect.provide(locations.get(ref)))
}),
)
}),
diff --git a/packages/server/src/middleware/session-location.ts b/packages/server/src/middleware/session-location.ts
index d34076e5ce4..ca5c7182b19 100644
--- a/packages/server/src/middleware/session-location.ts
+++ b/packages/server/src/middleware/session-location.ts
@@ -1,16 +1,10 @@
import { Database } from "@opencode-ai/core/database/database"
import { LocationServiceMap } from "@opencode-ai/core/location-services"
-import { Location } from "@opencode-ai/core/location"
-import { AbsolutePath } from "@opencode-ai/core/schema"
-import { Session } from "@opencode-ai/core/session"
-import { SessionTable } from "@opencode-ai/core/session/sql"
-import { Workspace } from "@opencode-ai/core/workspace"
-import { eq } from "drizzle-orm"
-import { Effect, Layer, Schema } from "effect"
+import { Effect, Layer } from "effect"
import { HttpRouter } from "effect/unstable/http"
import { HttpApiMiddleware } from "effect/unstable/httpapi"
import { InvalidRequestError, SessionNotFoundError } from "@opencode-ai/protocol/errors"
-import type { LocationServices } from "../location"
+import { sessionRef, type LocationServices } from "../location"
export class SessionLocationMiddleware extends HttpApiMiddleware.Service<
SessionLocationMiddleware,
@@ -19,48 +13,17 @@ export class SessionLocationMiddleware extends HttpApiMiddleware.Service<
error: [InvalidRequestError, SessionNotFoundError],
}) {}
-const decodeSessionID = Schema.decodeUnknownEffect(Session.ID)
-
export const sessionLocationLayer = Layer.effect(
SessionLocationMiddleware,
Effect.gen(function* () {
- const { db } = yield* Database.Service
+ const database = yield* Database.Service
const locations = yield* LocationServiceMap.Service
return SessionLocationMiddleware.of((effect) =>
Effect.gen(function* () {
const route = yield* HttpRouter.RouteContext
- const sessionID = yield* decodeSessionID(route.params.sessionID).pipe(
- Effect.mapError(
- () =>
- new InvalidRequestError({
- message: "Invalid session ID",
- field: "sessionID",
- }),
- ),
- )
- const row = yield* db
- .select({ directory: SessionTable.directory, workspaceID: SessionTable.workspace_id })
- .from(SessionTable)
- .where(eq(SessionTable.id, sessionID))
- .get()
- .pipe(Effect.orDie)
- if (!row)
- return yield* new SessionNotFoundError({
- sessionID,
- message: `Session not found: ${sessionID}`,
- })
-
- return yield* effect.pipe(
- Effect.provide(
- locations.get(
- Location.Ref.make({
- directory: AbsolutePath.make(row.directory),
- workspaceID: row.workspaceID ? Workspace.ID.make(row.workspaceID) : undefined,
- }),
- ),
- ),
- )
+ const ref = yield* sessionRef(database, route.params.sessionID)
+ return yield* effect.pipe(Effect.provide(locations.get(ref)))
}),
)
}),
diff --git a/packages/server/test/fetch.test.ts b/packages/server/test/fetch.test.ts
index 6d4805bf948..cd2982ae4c9 100644
--- a/packages/server/test/fetch.test.ts
+++ b/packages/server/test/fetch.test.ts
@@ -4,6 +4,7 @@ import { makeMemoryDriver } from "@opencode-ai/core/environment/index"
import { Workspace } from "@opencode-ai/core/workspace"
import { WorkspaceDriver } from "@opencode-ai/core/workspace/driver"
import { Effect } from "effect"
+import { tmpdir } from "../../core/test/fixture/tmpdir"
import { it } from "../../core/test/lib/effect"
import { ServerFetch } from "../src/fetch"
@@ -276,6 +277,129 @@ it.live("serves the session view operation and missing-session error", () =>
}),
)
+it.live("does not load a location when reading pending session requests", () =>
+ Effect.gen(function* () {
+ const config = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-pending-read-")))
+ const handler = yield* ServerFetch.make({
+ ...options,
+ config: {
+ directory: config.path,
+ project: false,
+ content: JSON.stringify({ permissions: [{ action: "shell", resource: "*", effect: "ask" }] }),
+ },
+ })
+ const created = (yield* Effect.promise(() =>
+ handler(
+ new Request("http://opencode.local/api/session", {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: "{}",
+ }),
+ ).then((response) => response.json()),
+ )) as { data: { id: string } }
+
+ const loaded = () =>
+ Effect.promise(() =>
+ handler(new Request("http://opencode.local/api/debug/location")).then(
+ (response) => response.json() as Promise,
+ ),
+ )
+
+ expect(yield* loaded()).toEqual([])
+ for (const resource of ["permission", "form"]) {
+ const response = yield* Effect.promise(() =>
+ handler(new Request(`http://opencode.local/api/session/${created.data.id}/${resource}`)),
+ )
+ expect(response.status).toBe(200)
+ expect(yield* Effect.promise(() => response.json())).toEqual({ data: [] })
+
+ const missing = yield* Effect.promise(() =>
+ handler(new Request(`http://opencode.local/api/session/ses_missing_pending/${resource}`)),
+ )
+ expect(missing.status).toBe(404)
+ }
+ const global = yield* Effect.promise(() =>
+ handler(
+ new Request("http://opencode.local/api/session/global/form", {
+ headers: { "x-opencode-directory": encodeURIComponent(process.cwd()) },
+ }),
+ ),
+ )
+ expect(global.status).toBe(200)
+ expect(yield* Effect.promise(() => global.json())).toEqual({ data: [] })
+ expect(yield* loaded()).toEqual([])
+
+ const createdForm = yield* Effect.promise(() =>
+ handler(
+ new Request(`http://opencode.local/api/session/${created.data.id}/form`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ title: "Test form", fields: [{ key: "answer", type: "string" }] }),
+ }),
+ ),
+ )
+ expect(createdForm.status).toBe(200)
+
+ const forms = yield* Effect.promise(() =>
+ handler(new Request(`http://opencode.local/api/session/${created.data.id}/form`)),
+ )
+ expect(forms.status).toBe(200)
+ expect(yield* Effect.promise(() => forms.json())).toMatchObject({
+ data: [{ title: "Test form" }],
+ })
+ expect(yield* loaded()).toHaveLength(1)
+
+ const globalForm = yield* Effect.promise(() =>
+ handler(
+ new Request("http://opencode.local/api/session/global/form", {
+ method: "POST",
+ headers: {
+ "content-type": "application/json",
+ "x-opencode-directory": encodeURIComponent(process.cwd()),
+ },
+ body: JSON.stringify({ title: "Global form", fields: [{ key: "answer", type: "string" }] }),
+ }),
+ ),
+ )
+ expect(globalForm.status).toBe(200)
+
+ const globalForms = yield* Effect.promise(() =>
+ handler(
+ new Request("http://opencode.local/api/session/global/form", {
+ headers: { "x-opencode-directory": encodeURIComponent(process.cwd()) },
+ }),
+ ),
+ )
+ expect(globalForms.status).toBe(200)
+ expect(yield* Effect.promise(() => globalForms.json())).toMatchObject({ data: [{ title: "Global form" }] })
+
+ // Agent permission policy is installed by plugin activation.
+ expect((yield* ready(handler)).status).toBe(200)
+ const createdPermission = yield* Effect.promise(() =>
+ handler(
+ new Request(`http://opencode.local/api/session/${created.data.id}/permission`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ id: "per_pending_read", action: "shell", resources: ["pwd"] }),
+ }),
+ ),
+ )
+ expect(createdPermission.status).toBe(200)
+ expect(yield* Effect.promise(() => createdPermission.json())).toEqual({
+ data: { id: "per_pending_read", effect: "ask" },
+ })
+
+ const permissions = yield* Effect.promise(() =>
+ handler(new Request(`http://opencode.local/api/session/${created.data.id}/permission`)),
+ )
+ expect(permissions.status).toBe(200)
+ expect(yield* Effect.promise(() => permissions.json())).toMatchObject({
+ data: [{ id: "per_pending_read", sessionID: created.data.id, action: "shell", resources: ["pwd"] }],
+ })
+ expect(yield* loaded()).toHaveLength(1)
+ }),
+)
+
// Pins the eager-boot guarantee: the application layer is built before the handler returns, so
// an aborted first request cannot interrupt layer construction and wedge every later request
// (the Effect-TS/effect#6319 failure class that lazy first-request builds are prone to).