Просмотр исходного кода

refactor(core): split out instance and route through workspaces (#19335)

James Long 3 недель назад
Родитель
Сommit
a76be695c7

+ 21 - 24
packages/opencode/src/control-plane/workspace-router-middleware.ts

@@ -3,6 +3,8 @@ import { Flag } from "../flag/flag"
 import { getAdaptor } from "./adaptors"
 import { WorkspaceID } from "./schema"
 import { Workspace } from "./workspace"
+import { InstanceRoutes } from "../server/instance"
+import { lazy } from "../util/lazy"
 
 type Rule = { method?: string; path: string; exact?: boolean; action: "local" | "forward" }
 
@@ -20,16 +22,25 @@ function local(method: string, path: string) {
   return false
 }
 
-async function routeRequest(req: Request) {
-  const url = new URL(req.url)
-  const raw = url.searchParams.get("workspace") || req.headers.get("x-opencode-workspace")
+const routes = lazy(() => InstanceRoutes())
 
-  if (!raw) return
+export const WorkspaceRouterMiddleware: MiddlewareHandler = async (c) => {
+  if (!Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) {
+    return routes().fetch(c.req.raw, c.env)
+  }
 
-  if (local(req.method, url.pathname)) return
+  const url = new URL(c.req.url)
+  const raw = url.searchParams.get("workspace")
 
-  const workspaceID = WorkspaceID.make(raw)
+  if (!raw) {
+    return routes().fetch(c.req.raw, c.env)
+  }
 
+  if (local(c.req.method, url.pathname)) {
+    return routes().fetch(c.req.raw, c.env)
+  }
+
+  const workspaceID = WorkspaceID.make(raw)
   const workspace = await Workspace.get(workspaceID)
   if (!workspace) {
     return new Response(`Workspace not found: ${workspaceID}`, {
@@ -41,27 +52,13 @@ async function routeRequest(req: Request) {
   }
 
   const adaptor = await getAdaptor(workspace.type)
-
-  const headers = new Headers(req.headers)
+  const headers = new Headers(c.req.raw.headers)
   headers.delete("x-opencode-workspace")
 
   return adaptor.fetch(workspace, `${url.pathname}${url.search}`, {
-    method: req.method,
-    body: req.method === "GET" || req.method === "HEAD" ? undefined : await req.arrayBuffer(),
-    signal: req.signal,
+    method: c.req.method,
+    body: c.req.method === "GET" || c.req.method === "HEAD" ? undefined : await c.req.raw.arrayBuffer(),
+    signal: c.req.raw.signal,
     headers,
   })
 }
-
-export const WorkspaceRouterMiddleware: MiddlewareHandler = async (c, next) => {
-  // Only available in development for now
-  if (!Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) {
-    return next()
-  }
-
-  const response = await routeRequest(c.req.raw)
-  if (response) {
-    return response
-  }
-  return next()
-}

+ 307 - 0
packages/opencode/src/server/instance.ts

@@ -0,0 +1,307 @@
+import { describeRoute, resolver } from "hono-openapi"
+import { Hono } from "hono"
+import { proxy } from "hono/proxy"
+import z from "zod"
+import { createHash } from "node:crypto"
+import { Log } from "../util/log"
+import { Format } from "../format"
+import { TuiRoutes } from "./routes/tui"
+import { Instance } from "../project/instance"
+import { Vcs } from "../project/vcs"
+import { Agent } from "../agent/agent"
+import { Skill } from "../skill"
+import { Global } from "../global"
+import { LSP } from "../lsp"
+import { Command } from "../command"
+import { Flag } from "../flag/flag"
+import { Filesystem } from "@/util/filesystem"
+import { QuestionRoutes } from "./routes/question"
+import { PermissionRoutes } from "./routes/permission"
+import { ProjectRoutes } from "./routes/project"
+import { SessionRoutes } from "./routes/session"
+import { PtyRoutes } from "./routes/pty"
+import { McpRoutes } from "./routes/mcp"
+import { FileRoutes } from "./routes/file"
+import { ConfigRoutes } from "./routes/config"
+import { ExperimentalRoutes } from "./routes/experimental"
+import { ProviderRoutes } from "./routes/provider"
+import { EventRoutes } from "./routes/event"
+import { InstanceBootstrap } from "../project/bootstrap"
+import { errorHandler } from "./middleware"
+
+const log = Log.create({ service: "server" })
+
+const embeddedUIPromise = Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI
+  ? Promise.resolve(null)
+  : // @ts-expect-error - generated file at build time
+    import("opencode-web-ui.gen.ts").then((module) => module.default as Record<string, string>).catch(() => null)
+
+const DEFAULT_CSP =
+  "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data:"
+
+const csp = (hash = "") =>
+  `default-src 'self'; script-src 'self' 'wasm-unsafe-eval'${hash ? ` 'sha256-${hash}'` : ""}; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data:`
+
+export const InstanceRoutes = (app?: Hono) =>
+  (app ?? new Hono())
+    .onError(errorHandler(log))
+    .use(async (c, next) => {
+      const raw = c.req.query("directory") || c.req.header("x-opencode-directory") || process.cwd()
+      const directory = Filesystem.resolve(
+        (() => {
+          try {
+            return decodeURIComponent(raw)
+          } catch {
+            return raw
+          }
+        })(),
+      )
+
+      return Instance.provide({
+        directory,
+        init: InstanceBootstrap,
+        async fn() {
+          return next()
+        },
+      })
+    })
+    .route("/project", ProjectRoutes())
+    .route("/pty", PtyRoutes())
+    .route("/config", ConfigRoutes())
+    .route("/experimental", ExperimentalRoutes())
+    .route("/session", SessionRoutes())
+    .route("/permission", PermissionRoutes())
+    .route("/question", QuestionRoutes())
+    .route("/provider", ProviderRoutes())
+    .route("/", FileRoutes())
+    .route("/", EventRoutes())
+    .route("/mcp", McpRoutes())
+    .route("/tui", TuiRoutes())
+    .post(
+      "/instance/dispose",
+      describeRoute({
+        summary: "Dispose instance",
+        description: "Clean up and dispose the current OpenCode instance, releasing all resources.",
+        operationId: "instance.dispose",
+        responses: {
+          200: {
+            description: "Instance disposed",
+            content: {
+              "application/json": {
+                schema: resolver(z.boolean()),
+              },
+            },
+          },
+        },
+      }),
+      async (c) => {
+        await Instance.dispose()
+        return c.json(true)
+      },
+    )
+    .get(
+      "/path",
+      describeRoute({
+        summary: "Get paths",
+        description: "Retrieve the current working directory and related path information for the OpenCode instance.",
+        operationId: "path.get",
+        responses: {
+          200: {
+            description: "Path",
+            content: {
+              "application/json": {
+                schema: resolver(
+                  z
+                    .object({
+                      home: z.string(),
+                      state: z.string(),
+                      config: z.string(),
+                      worktree: z.string(),
+                      directory: z.string(),
+                    })
+                    .meta({
+                      ref: "Path",
+                    }),
+                ),
+              },
+            },
+          },
+        },
+      }),
+      async (c) => {
+        return c.json({
+          home: Global.Path.home,
+          state: Global.Path.state,
+          config: Global.Path.config,
+          worktree: Instance.worktree,
+          directory: Instance.directory,
+        })
+      },
+    )
+    .get(
+      "/vcs",
+      describeRoute({
+        summary: "Get VCS info",
+        description: "Retrieve version control system (VCS) information for the current project, such as git branch.",
+        operationId: "vcs.get",
+        responses: {
+          200: {
+            description: "VCS info",
+            content: {
+              "application/json": {
+                schema: resolver(Vcs.Info),
+              },
+            },
+          },
+        },
+      }),
+      async (c) => {
+        const branch = await Vcs.branch()
+        return c.json({
+          branch,
+        })
+      },
+    )
+    .get(
+      "/command",
+      describeRoute({
+        summary: "List commands",
+        description: "Get a list of all available commands in the OpenCode system.",
+        operationId: "command.list",
+        responses: {
+          200: {
+            description: "List of commands",
+            content: {
+              "application/json": {
+                schema: resolver(Command.Info.array()),
+              },
+            },
+          },
+        },
+      }),
+      async (c) => {
+        const commands = await Command.list()
+        return c.json(commands)
+      },
+    )
+    .get(
+      "/agent",
+      describeRoute({
+        summary: "List agents",
+        description: "Get a list of all available AI agents in the OpenCode system.",
+        operationId: "app.agents",
+        responses: {
+          200: {
+            description: "List of agents",
+            content: {
+              "application/json": {
+                schema: resolver(Agent.Info.array()),
+              },
+            },
+          },
+        },
+      }),
+      async (c) => {
+        const modes = await Agent.list()
+        return c.json(modes)
+      },
+    )
+    .get(
+      "/skill",
+      describeRoute({
+        summary: "List skills",
+        description: "Get a list of all available skills in the OpenCode system.",
+        operationId: "app.skills",
+        responses: {
+          200: {
+            description: "List of skills",
+            content: {
+              "application/json": {
+                schema: resolver(Skill.Info.array()),
+              },
+            },
+          },
+        },
+      }),
+      async (c) => {
+        const skills = await Skill.all()
+        return c.json(skills)
+      },
+    )
+    .get(
+      "/lsp",
+      describeRoute({
+        summary: "Get LSP status",
+        description: "Get LSP server status",
+        operationId: "lsp.status",
+        responses: {
+          200: {
+            description: "LSP server status",
+            content: {
+              "application/json": {
+                schema: resolver(LSP.Status.array()),
+              },
+            },
+          },
+        },
+      }),
+      async (c) => {
+        return c.json(await LSP.status())
+      },
+    )
+    .get(
+      "/formatter",
+      describeRoute({
+        summary: "Get formatter status",
+        description: "Get formatter status",
+        operationId: "formatter.status",
+        responses: {
+          200: {
+            description: "Formatter status",
+            content: {
+              "application/json": {
+                schema: resolver(Format.Status.array()),
+              },
+            },
+          },
+        },
+      }),
+      async (c) => {
+        return c.json(await Format.status())
+      },
+    )
+    .all("/*", async (c) => {
+      const embeddedWebUI = await embeddedUIPromise
+      const path = c.req.path
+
+      if (embeddedWebUI) {
+        const match = embeddedWebUI[path.replace(/^\//, "")] ?? embeddedWebUI["index.html"] ?? null
+        if (!match) return c.json({ error: "Not Found" }, 404)
+        const file = Bun.file(match)
+        if (await file.exists()) {
+          c.header("Content-Type", file.type)
+          if (file.type.startsWith("text/html")) {
+            c.header("Content-Security-Policy", DEFAULT_CSP)
+          }
+          return c.body(await file.arrayBuffer())
+        } else {
+          return c.json({ error: "Not Found" }, 404)
+        }
+      } else {
+        const response = await proxy(`https://app.opencode.ai${path}`, {
+          ...c.req,
+          headers: {
+            ...c.req.raw.headers,
+            host: "app.opencode.ai",
+          },
+        })
+        const match = response.headers.get("content-type")?.includes("text/html")
+          ? (await response.clone().text()).match(
+              /<script\b(?![^>]*\bsrc\s*=)[^>]*\bid=(['"])oc-theme-preload-script\1[^>]*>([\s\S]*?)<\/script>/i,
+            )
+          : undefined
+        const hash = match ? createHash("sha256").update(match[2]).digest("base64") : ""
+        response.headers.set("Content-Security-Policy", csp(hash))
+        return response
+      }
+    })

+ 29 - 0
packages/opencode/src/server/middleware.ts

@@ -0,0 +1,29 @@
+import { Provider } from "../provider/provider"
+import { NamedError } from "@opencode-ai/util/error"
+import { NotFoundError } from "../storage/db"
+import type { ContentfulStatusCode } from "hono/utils/http-status"
+import type { ErrorHandler } from "hono"
+import { HTTPException } from "hono/http-exception"
+import type { Log } from "../util/log"
+
+export function errorHandler(log: Log.Logger): ErrorHandler {
+  return (err, c) => {
+    log.error("failed", {
+      error: err,
+    })
+    if (err instanceof NamedError) {
+      let status: ContentfulStatusCode
+      if (err instanceof NotFoundError) status = 404
+      else if (err instanceof Provider.ModelNotFoundError) status = 400
+      else if (err.name === "ProviderAuthValidationFailed") status = 400
+      else if (err.name.startsWith("Worktree")) status = 400
+      else status = 500
+      return c.json(err.toObject(), { status })
+    }
+    if (err instanceof HTTPException) return err.getResponse()
+    const message = err instanceof Error && err.stack ? err.stack : err.toString()
+    return c.json(new NamedError.Unknown({ message }).toObject(), {
+      status: 500,
+    })
+  }
+}

+ 2 - 4
packages/opencode/src/server/routes/event.ts

@@ -4,12 +4,11 @@ import { streamSSE } from "hono/streaming"
 import { Log } from "@/util/log"
 import { BusEvent } from "@/bus/bus-event"
 import { Bus } from "@/bus"
-import { lazy } from "../../util/lazy"
 import { AsyncQueue } from "../../util/queue"
 
 const log = Log.create({ service: "server" })
 
-export const EventRoutes = lazy(() =>
+export const EventRoutes = () =>
   new Hono().get(
     "/event",
     describeRoute({
@@ -81,5 +80,4 @@ export const EventRoutes = lazy(() =>
         }
       })
     },
-  ),
-)
+  )

+ 20 - 327
packages/opencode/src/server/server.ts

@@ -1,67 +1,30 @@
-import { createHash } from "node:crypto"
 import { Log } from "../util/log"
 import { describeRoute, generateSpecs, validator, resolver, openAPIRouteHandler } from "hono-openapi"
 import { Hono } from "hono"
 import { compress } from "hono/compress"
 import { cors } from "hono/cors"
-import { proxy } from "hono/proxy"
 import { basicAuth } from "hono/basic-auth"
 import z from "zod"
-import { Provider } from "../provider/provider"
-import { NamedError } from "@opencode-ai/util/error"
-import { LSP } from "../lsp"
-import { Format } from "../format"
-import { TuiRoutes } from "./routes/tui"
-import { Instance } from "../project/instance"
-import { Vcs } from "../project/vcs"
-import { Agent } from "../agent/agent"
-import { Skill } from "../skill"
 import { Auth } from "../auth"
 import { Flag } from "../flag/flag"
-import { Command } from "../command"
-import { Global } from "../global"
-import { WorkspaceID } from "../control-plane/schema"
 import { ProviderID } from "../provider/schema"
 import { WorkspaceRouterMiddleware } from "../control-plane/workspace-router-middleware"
-import { ProjectRoutes } from "./routes/project"
-import { SessionRoutes } from "./routes/session"
-import { PtyRoutes } from "./routes/pty"
-import { McpRoutes } from "./routes/mcp"
-import { FileRoutes } from "./routes/file"
-import { ConfigRoutes } from "./routes/config"
-import { ExperimentalRoutes } from "./routes/experimental"
-import { ProviderRoutes } from "./routes/provider"
-import { EventRoutes } from "./routes/event"
-import { InstanceBootstrap } from "../project/bootstrap"
-import { NotFoundError } from "../storage/db"
-import type { ContentfulStatusCode } from "hono/utils/http-status"
 import { websocket } from "hono/bun"
-import { HTTPException } from "hono/http-exception"
 import { errors } from "./error"
-import { Filesystem } from "@/util/filesystem"
-import { QuestionRoutes } from "./routes/question"
-import { PermissionRoutes } from "./routes/permission"
 import { GlobalRoutes } from "./routes/global"
 import { MDNS } from "./mdns"
 import { lazy } from "@/util/lazy"
+import { errorHandler } from "./middleware"
+import { InstanceRoutes } from "./instance"
 import { initProjectors } from "./projectors"
 
 // @ts-ignore This global is needed to prevent ai-sdk from logging warnings to stdout https://github.com/vercel/ai/blob/2dc67e0ef538307f21368db32d5a12345d98831b/packages/ai/src/logger/log-warnings.ts#L85
 globalThis.AI_SDK_LOG_WARNINGS = false
 
-const csp = (hash = "") =>
-  `default-src 'self'; script-src 'self' 'wasm-unsafe-eval'${hash ? ` 'sha256-${hash}'` : ""}; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data:`
-
 initProjectors()
 
 export namespace Server {
   const log = Log.create({ service: "server" })
-  const DEFAULT_CSP =
-    "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data:"
-  const embeddedUIPromise = Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI
-    ? Promise.resolve(null)
-    : // @ts-expect-error - generated file at build time
-      import("opencode-web-ui.gen.ts").then((module) => module.default as Record<string, string>).catch(() => null)
 
   const zipped = compress()
 
@@ -71,30 +34,12 @@ export namespace Server {
     return false
   }
 
-  export const Default = lazy(() => createApp({}))
+  export const Default = lazy(() => ControlPlaneRoutes())
 
-  export const createApp = (opts: { cors?: string[] }): Hono => {
+  export const ControlPlaneRoutes = (opts?: { cors?: string[] }): Hono => {
     const app = new Hono()
     return app
-      .onError((err, c) => {
-        log.error("failed", {
-          error: err,
-        })
-        if (err instanceof NamedError) {
-          let status: ContentfulStatusCode
-          if (err instanceof NotFoundError) status = 404
-          else if (err instanceof Provider.ModelNotFoundError) status = 400
-          else if (err.name === "ProviderAuthValidationFailed") status = 400
-          else if (err.name.startsWith("Worktree")) status = 400
-          else status = 500
-          return c.json(err.toObject(), { status })
-        }
-        if (err instanceof HTTPException) return err.getResponse()
-        const message = err instanceof Error && err.stack ? err.stack : err.toString()
-        return c.json(new NamedError.Unknown({ message }).toObject(), {
-          status: 500,
-        })
-      })
+      .onError(errorHandler(log))
       .use((c, next) => {
         // Allow CORS preflight requests to succeed without auth.
         // Browser clients sending Authorization headers will preflight with OPTIONS.
@@ -105,8 +50,8 @@ export namespace Server {
         return basicAuth({ username, password })(c, next)
       })
       .use(async (c, next) => {
-        const skipLogging = c.req.path === "/log"
-        if (!skipLogging) {
+        const skip = c.req.path === "/log"
+        if (!skip) {
           log.info("request", {
             method: c.req.method,
             path: c.req.path,
@@ -117,7 +62,7 @@ export namespace Server {
           path: c.req.path,
         })
         await next()
-        if (!skipLogging) {
+        if (!skip) {
           timer.stop()
         }
       })
@@ -215,27 +160,6 @@ export namespace Server {
           return c.json(true)
         },
       )
-      .use(async (c, next) => {
-        if (c.req.path === "/log") return next()
-        const raw = c.req.query("directory") || c.req.header("x-opencode-directory") || process.cwd()
-        const directory = Filesystem.resolve(
-          (() => {
-            try {
-              return decodeURIComponent(raw)
-            } catch {
-              return raw
-            }
-          })(),
-        )
-
-        return Instance.provide({
-          directory,
-          init: InstanceBootstrap,
-          async fn() {
-            return next()
-          },
-        })
-      })
       .get(
         "/doc",
         openAPIRouteHandler(app, {
@@ -258,126 +182,6 @@ export namespace Server {
           }),
         ),
       )
-      .use(WorkspaceRouterMiddleware)
-      .route("/project", ProjectRoutes())
-      .route("/pty", PtyRoutes())
-      .route("/config", ConfigRoutes())
-      .route("/experimental", ExperimentalRoutes())
-      .route("/session", SessionRoutes())
-      .route("/permission", PermissionRoutes())
-      .route("/question", QuestionRoutes())
-      .route("/provider", ProviderRoutes())
-      .route("/", FileRoutes())
-      .route("/", EventRoutes())
-      .route("/mcp", McpRoutes())
-      .route("/tui", TuiRoutes())
-      .post(
-        "/instance/dispose",
-        describeRoute({
-          summary: "Dispose instance",
-          description: "Clean up and dispose the current OpenCode instance, releasing all resources.",
-          operationId: "instance.dispose",
-          responses: {
-            200: {
-              description: "Instance disposed",
-              content: {
-                "application/json": {
-                  schema: resolver(z.boolean()),
-                },
-              },
-            },
-          },
-        }),
-        async (c) => {
-          await Instance.dispose()
-          return c.json(true)
-        },
-      )
-      .get(
-        "/path",
-        describeRoute({
-          summary: "Get paths",
-          description: "Retrieve the current working directory and related path information for the OpenCode instance.",
-          operationId: "path.get",
-          responses: {
-            200: {
-              description: "Path",
-              content: {
-                "application/json": {
-                  schema: resolver(
-                    z
-                      .object({
-                        home: z.string(),
-                        state: z.string(),
-                        config: z.string(),
-                        worktree: z.string(),
-                        directory: z.string(),
-                      })
-                      .meta({
-                        ref: "Path",
-                      }),
-                  ),
-                },
-              },
-            },
-          },
-        }),
-        async (c) => {
-          return c.json({
-            home: Global.Path.home,
-            state: Global.Path.state,
-            config: Global.Path.config,
-            worktree: Instance.worktree,
-            directory: Instance.directory,
-          })
-        },
-      )
-      .get(
-        "/vcs",
-        describeRoute({
-          summary: "Get VCS info",
-          description: "Retrieve version control system (VCS) information for the current project, such as git branch.",
-          operationId: "vcs.get",
-          responses: {
-            200: {
-              description: "VCS info",
-              content: {
-                "application/json": {
-                  schema: resolver(Vcs.Info),
-                },
-              },
-            },
-          },
-        }),
-        async (c) => {
-          const branch = await Vcs.branch()
-          return c.json({
-            branch,
-          })
-        },
-      )
-      .get(
-        "/command",
-        describeRoute({
-          summary: "List commands",
-          description: "Get a list of all available commands in the OpenCode system.",
-          operationId: "command.list",
-          responses: {
-            200: {
-              description: "List of commands",
-              content: {
-                "application/json": {
-                  schema: resolver(Command.Info.array()),
-                },
-              },
-            },
-          },
-        }),
-        async (c) => {
-          const commands = await Command.list()
-          return c.json(commands)
-        },
-      )
       .post(
         "/log",
         describeRoute({
@@ -430,132 +234,21 @@ export namespace Server {
           return c.json(true)
         },
       )
-      .get(
-        "/agent",
-        describeRoute({
-          summary: "List agents",
-          description: "Get a list of all available AI agents in the OpenCode system.",
-          operationId: "app.agents",
-          responses: {
-            200: {
-              description: "List of agents",
-              content: {
-                "application/json": {
-                  schema: resolver(Agent.Info.array()),
-                },
-              },
-            },
-          },
-        }),
-        async (c) => {
-          const modes = await Agent.list()
-          return c.json(modes)
-        },
-      )
-      .get(
-        "/skill",
-        describeRoute({
-          summary: "List skills",
-          description: "Get a list of all available skills in the OpenCode system.",
-          operationId: "app.skills",
-          responses: {
-            200: {
-              description: "List of skills",
-              content: {
-                "application/json": {
-                  schema: resolver(Skill.Info.array()),
-                },
-              },
-            },
-          },
-        }),
-        async (c) => {
-          const skills = await Skill.all()
-          return c.json(skills)
-        },
-      )
-      .get(
-        "/lsp",
-        describeRoute({
-          summary: "Get LSP status",
-          description: "Get LSP server status",
-          operationId: "lsp.status",
-          responses: {
-            200: {
-              description: "LSP server status",
-              content: {
-                "application/json": {
-                  schema: resolver(LSP.Status.array()),
-                },
-              },
-            },
-          },
-        }),
-        async (c) => {
-          return c.json(await LSP.status())
-        },
-      )
-      .get(
-        "/formatter",
-        describeRoute({
-          summary: "Get formatter status",
-          description: "Get formatter status",
-          operationId: "formatter.status",
-          responses: {
-            200: {
-              description: "Formatter status",
-              content: {
-                "application/json": {
-                  schema: resolver(Format.Status.array()),
-                },
-              },
-            },
-          },
-        }),
-        async (c) => {
-          return c.json(await Format.status())
-        },
-      )
-      .all("/*", async (c) => {
-        const embeddedWebUI = await embeddedUIPromise
-        const path = c.req.path
+      .use(WorkspaceRouterMiddleware)
+  }
 
-        if (embeddedWebUI) {
-          const match = embeddedWebUI[path.replace(/^\//, "")] ?? embeddedWebUI["index.html"] ?? null
-          if (!match) return c.json({ error: "Not Found" }, 404)
-          const file = Bun.file(match)
-          if (await file.exists()) {
-            c.header("Content-Type", file.type)
-            if (file.type.startsWith("text/html")) {
-              c.header("Content-Security-Policy", DEFAULT_CSP)
-            }
-            return c.body(await file.arrayBuffer())
-          } else {
-            return c.json({ error: "Not Found" }, 404)
-          }
-        } else {
-          const response = await proxy(`https://app.opencode.ai${path}`, {
-            ...c.req,
-            headers: {
-              ...c.req.raw.headers,
-              host: "app.opencode.ai",
-            },
-          })
-          const match = response.headers.get("content-type")?.includes("text/html")
-            ? (await response.clone().text()).match(
-                /<script\b(?![^>]*\bsrc\s*=)[^>]*\bid=(['"])oc-theme-preload-script\1[^>]*>([\s\S]*?)<\/script>/i,
-              )
-            : undefined
-          const hash = match ? createHash("sha256").update(match[2]).digest("base64") : ""
-          response.headers.set("Content-Security-Policy", csp(hash))
-          return response
-        }
-      }) as unknown as Hono
+  export function createApp(opts: { cors?: string[] }) {
+    return ControlPlaneRoutes(opts)
   }
 
   export async function openapi() {
-    // Cast to break excessive type recursion from long route chains
-    const result = await generateSpecs(Default(), {
+    // Build a fresh app with all routes registered directly so
+    // hono-openapi can see describeRoute metadata (`.route()` wraps
+    // handlers when the sub-app has a custom errorHandler, which
+    // strips the metadata symbol).
+    const app = ControlPlaneRoutes()
+    InstanceRoutes(app)
+    const result = await generateSpecs(app, {
       documentation: {
         info: {
           title: "opencode",
@@ -579,7 +272,7 @@ export namespace Server {
     cors?: string[]
   }) {
     url = new URL(`http://${opts.hostname}:${opts.port}`)
-    const app = createApp(opts)
+    const app = ControlPlaneRoutes({ cors: opts.cors })
     const args = {
       hostname: opts.hostname,
       idleTimeout: 0,

+ 112 - 112
packages/sdk/js/src/v2/gen/sdk.gen.ts

@@ -411,6 +411,113 @@ export class Auth extends HeyApiClient {
   }
 }
 
+export class App extends HeyApiClient {
+  /**
+   * Write log
+   *
+   * Write a log entry to the server logs with specified level and metadata.
+   */
+  public log<ThrowOnError extends boolean = false>(
+    parameters?: {
+      directory?: string
+      workspace?: string
+      service?: string
+      level?: "debug" | "info" | "error" | "warn"
+      message?: string
+      extra?: {
+        [key: string]: unknown
+      }
+    },
+    options?: Options<never, ThrowOnError>,
+  ) {
+    const params = buildClientParams(
+      [parameters],
+      [
+        {
+          args: [
+            { in: "query", key: "directory" },
+            { in: "query", key: "workspace" },
+            { in: "body", key: "service" },
+            { in: "body", key: "level" },
+            { in: "body", key: "message" },
+            { in: "body", key: "extra" },
+          ],
+        },
+      ],
+    )
+    return (options?.client ?? this.client).post<AppLogResponses, AppLogErrors, ThrowOnError>({
+      url: "/log",
+      ...options,
+      ...params,
+      headers: {
+        "Content-Type": "application/json",
+        ...options?.headers,
+        ...params.headers,
+      },
+    })
+  }
+
+  /**
+   * List agents
+   *
+   * Get a list of all available AI agents in the OpenCode system.
+   */
+  public agents<ThrowOnError extends boolean = false>(
+    parameters?: {
+      directory?: string
+      workspace?: string
+    },
+    options?: Options<never, ThrowOnError>,
+  ) {
+    const params = buildClientParams(
+      [parameters],
+      [
+        {
+          args: [
+            { in: "query", key: "directory" },
+            { in: "query", key: "workspace" },
+          ],
+        },
+      ],
+    )
+    return (options?.client ?? this.client).get<AppAgentsResponses, unknown, ThrowOnError>({
+      url: "/agent",
+      ...options,
+      ...params,
+    })
+  }
+
+  /**
+   * List skills
+   *
+   * Get a list of all available skills in the OpenCode system.
+   */
+  public skills<ThrowOnError extends boolean = false>(
+    parameters?: {
+      directory?: string
+      workspace?: string
+    },
+    options?: Options<never, ThrowOnError>,
+  ) {
+    const params = buildClientParams(
+      [parameters],
+      [
+        {
+          args: [
+            { in: "query", key: "directory" },
+            { in: "query", key: "workspace" },
+          ],
+        },
+      ],
+    )
+    return (options?.client ?? this.client).get<AppSkillsResponses, unknown, ThrowOnError>({
+      url: "/skill",
+      ...options,
+      ...params,
+    })
+  }
+}
+
 export class Project extends HeyApiClient {
   /**
    * List all projects
@@ -3773,113 +3880,6 @@ export class Command extends HeyApiClient {
   }
 }
 
-export class App extends HeyApiClient {
-  /**
-   * Write log
-   *
-   * Write a log entry to the server logs with specified level and metadata.
-   */
-  public log<ThrowOnError extends boolean = false>(
-    parameters?: {
-      directory?: string
-      workspace?: string
-      service?: string
-      level?: "debug" | "info" | "error" | "warn"
-      message?: string
-      extra?: {
-        [key: string]: unknown
-      }
-    },
-    options?: Options<never, ThrowOnError>,
-  ) {
-    const params = buildClientParams(
-      [parameters],
-      [
-        {
-          args: [
-            { in: "query", key: "directory" },
-            { in: "query", key: "workspace" },
-            { in: "body", key: "service" },
-            { in: "body", key: "level" },
-            { in: "body", key: "message" },
-            { in: "body", key: "extra" },
-          ],
-        },
-      ],
-    )
-    return (options?.client ?? this.client).post<AppLogResponses, AppLogErrors, ThrowOnError>({
-      url: "/log",
-      ...options,
-      ...params,
-      headers: {
-        "Content-Type": "application/json",
-        ...options?.headers,
-        ...params.headers,
-      },
-    })
-  }
-
-  /**
-   * List agents
-   *
-   * Get a list of all available AI agents in the OpenCode system.
-   */
-  public agents<ThrowOnError extends boolean = false>(
-    parameters?: {
-      directory?: string
-      workspace?: string
-    },
-    options?: Options<never, ThrowOnError>,
-  ) {
-    const params = buildClientParams(
-      [parameters],
-      [
-        {
-          args: [
-            { in: "query", key: "directory" },
-            { in: "query", key: "workspace" },
-          ],
-        },
-      ],
-    )
-    return (options?.client ?? this.client).get<AppAgentsResponses, unknown, ThrowOnError>({
-      url: "/agent",
-      ...options,
-      ...params,
-    })
-  }
-
-  /**
-   * List skills
-   *
-   * Get a list of all available skills in the OpenCode system.
-   */
-  public skills<ThrowOnError extends boolean = false>(
-    parameters?: {
-      directory?: string
-      workspace?: string
-    },
-    options?: Options<never, ThrowOnError>,
-  ) {
-    const params = buildClientParams(
-      [parameters],
-      [
-        {
-          args: [
-            { in: "query", key: "directory" },
-            { in: "query", key: "workspace" },
-          ],
-        },
-      ],
-    )
-    return (options?.client ?? this.client).get<AppSkillsResponses, unknown, ThrowOnError>({
-      url: "/skill",
-      ...options,
-      ...params,
-    })
-  }
-}
-
 export class Lsp extends HeyApiClient {
   /**
    * Get LSP status
@@ -3962,6 +3962,11 @@ export class OpencodeClient extends HeyApiClient {
     return (this._auth ??= new Auth({ client: this.client }))
   }
 
+  private _app?: App
+  get app(): App {
+    return (this._app ??= new App({ client: this.client }))
+  }
+
   private _project?: Project
   get project(): Project {
     return (this._project ??= new Project({ client: this.client }))
@@ -4062,11 +4067,6 @@ export class OpencodeClient extends HeyApiClient {
     return (this._command ??= new Command({ client: this.client }))
   }
 
-  private _app?: App
-  get app(): App {
-    return (this._app ??= new App({ client: this.client }))
-  }
-
   private _lsp?: Lsp
   get lsp(): Lsp {
     return (this._lsp ??= new Lsp({ client: this.client }))

+ 47 - 47
packages/sdk/js/src/v2/gen/types.gen.ts

@@ -2249,6 +2249,53 @@ export type AuthSetResponses = {
 
 export type AuthSetResponse = AuthSetResponses[keyof AuthSetResponses]
 
+export type AppLogData = {
+  body?: {
+    /**
+     * Service name for the log entry
+     */
+    service: string
+    /**
+     * Log level
+     */
+    level: "debug" | "info" | "error" | "warn"
+    /**
+     * Log message
+     */
+    message: string
+    /**
+     * Additional metadata for the log entry
+     */
+    extra?: {
+      [key: string]: unknown
+    }
+  }
+  path?: never
+  query?: {
+    directory?: string
+    workspace?: string
+  }
+  url: "/log"
+}
+
+export type AppLogErrors = {
+  /**
+   * Bad request
+   */
+  400: BadRequestError
+}
+
+export type AppLogError = AppLogErrors[keyof AppLogErrors]
+
+export type AppLogResponses = {
+  /**
+   * Log entry written successfully
+   */
+  200: boolean
+}
+
+export type AppLogResponse = AppLogResponses[keyof AppLogResponses]
+
 export type ProjectListData = {
   body?: never
   path?: never
@@ -5036,53 +5083,6 @@ export type CommandListResponses = {
 
 export type CommandListResponse = CommandListResponses[keyof CommandListResponses]
 
-export type AppLogData = {
-  body?: {
-    /**
-     * Service name for the log entry
-     */
-    service: string
-    /**
-     * Log level
-     */
-    level: "debug" | "info" | "error" | "warn"
-    /**
-     * Log message
-     */
-    message: string
-    /**
-     * Additional metadata for the log entry
-     */
-    extra?: {
-      [key: string]: unknown
-    }
-  }
-  path?: never
-  query?: {
-    directory?: string
-    workspace?: string
-  }
-  url: "/log"
-}
-
-export type AppLogErrors = {
-  /**
-   * Bad request
-   */
-  400: BadRequestError
-}
-
-export type AppLogError = AppLogErrors[keyof AppLogErrors]
-
-export type AppLogResponses = {
-  /**
-   * Log entry written successfully
-   */
-  200: boolean
-}
-
-export type AppLogResponse = AppLogResponses[keyof AppLogResponses]
-
 export type AppAgentsData = {
   body?: never
   path?: never

+ 84 - 84
packages/sdk/openapi.json

@@ -356,6 +356,90 @@
         ]
       }
     },
+    "/log": {
+      "post": {
+        "operationId": "app.log",
+        "parameters": [
+          {
+            "in": "query",
+            "name": "directory",
+            "schema": {
+              "type": "string"
+            }
+          },
+          {
+            "in": "query",
+            "name": "workspace",
+            "schema": {
+              "type": "string"
+            }
+          }
+        ],
+        "summary": "Write log",
+        "description": "Write a log entry to the server logs with specified level and metadata.",
+        "responses": {
+          "200": {
+            "description": "Log entry written successfully",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "boolean"
+                }
+              }
+            }
+          },
+          "400": {
+            "description": "Bad request",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "$ref": "#/components/schemas/BadRequestError"
+                }
+              }
+            }
+          }
+        },
+        "requestBody": {
+          "content": {
+            "application/json": {
+              "schema": {
+                "type": "object",
+                "properties": {
+                  "service": {
+                    "description": "Service name for the log entry",
+                    "type": "string"
+                  },
+                  "level": {
+                    "description": "Log level",
+                    "type": "string",
+                    "enum": ["debug", "info", "error", "warn"]
+                  },
+                  "message": {
+                    "description": "Log message",
+                    "type": "string"
+                  },
+                  "extra": {
+                    "description": "Additional metadata for the log entry",
+                    "type": "object",
+                    "propertyNames": {
+                      "type": "string"
+                    },
+                    "additionalProperties": {}
+                  }
+                },
+                "required": ["service", "level", "message"]
+              }
+            }
+          }
+        },
+        "x-codeSamples": [
+          {
+            "lang": "js",
+            "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.app.log({\n  ...\n})"
+          }
+        ]
+      }
+    },
     "/project": {
       "get": {
         "operationId": "project.list",
@@ -6762,90 +6846,6 @@
         ]
       }
     },
-    "/log": {
-      "post": {
-        "operationId": "app.log",
-        "parameters": [
-          {
-            "in": "query",
-            "name": "directory",
-            "schema": {
-              "type": "string"
-            }
-          },
-          {
-            "in": "query",
-            "name": "workspace",
-            "schema": {
-              "type": "string"
-            }
-          }
-        ],
-        "summary": "Write log",
-        "description": "Write a log entry to the server logs with specified level and metadata.",
-        "responses": {
-          "200": {
-            "description": "Log entry written successfully",
-            "content": {
-              "application/json": {
-                "schema": {
-                  "type": "boolean"
-                }
-              }
-            }
-          },
-          "400": {
-            "description": "Bad request",
-            "content": {
-              "application/json": {
-                "schema": {
-                  "$ref": "#/components/schemas/BadRequestError"
-                }
-              }
-            }
-          }
-        },
-        "requestBody": {
-          "content": {
-            "application/json": {
-              "schema": {
-                "type": "object",
-                "properties": {
-                  "service": {
-                    "description": "Service name for the log entry",
-                    "type": "string"
-                  },
-                  "level": {
-                    "description": "Log level",
-                    "type": "string",
-                    "enum": ["debug", "info", "error", "warn"]
-                  },
-                  "message": {
-                    "description": "Log message",
-                    "type": "string"
-                  },
-                  "extra": {
-                    "description": "Additional metadata for the log entry",
-                    "type": "object",
-                    "propertyNames": {
-                      "type": "string"
-                    },
-                    "additionalProperties": {}
-                  }
-                },
-                "required": ["service", "level", "message"]
-              }
-            }
-          }
-        },
-        "x-codeSamples": [
-          {
-            "lang": "js",
-            "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.app.log({\n  ...\n})"
-          }
-        ]
-      }
-    },
     "/agent": {
       "get": {
         "operationId": "app.agents",