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).