server.ts 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619
  1. import { createHash } from "node:crypto"
  2. import { Log } from "../util/log"
  3. import { describeRoute, generateSpecs, validator, resolver, openAPIRouteHandler } from "hono-openapi"
  4. import { Hono } from "hono"
  5. import { cors } from "hono/cors"
  6. import { proxy } from "hono/proxy"
  7. import { basicAuth } from "hono/basic-auth"
  8. import z from "zod"
  9. import { Provider } from "../provider/provider"
  10. import { NamedError } from "@opencode-ai/util/error"
  11. import { LSP } from "../lsp"
  12. import { Format } from "../format"
  13. import { TuiRoutes } from "./routes/tui"
  14. import { Instance } from "../project/instance"
  15. import { Vcs } from "../project/vcs"
  16. import { Agent } from "../agent/agent"
  17. import { Skill } from "../skill"
  18. import { Auth } from "../auth"
  19. import { Flag } from "../flag/flag"
  20. import { Command } from "../command"
  21. import { Global } from "../global"
  22. import { WorkspaceContext } from "../control-plane/workspace-context"
  23. import { WorkspaceID } from "../control-plane/schema"
  24. import { ProviderID } from "../provider/schema"
  25. import { WorkspaceRouterMiddleware } from "../control-plane/workspace-router-middleware"
  26. import { ProjectRoutes } from "./routes/project"
  27. import { SessionRoutes } from "./routes/session"
  28. import { PtyRoutes } from "./routes/pty"
  29. import { McpRoutes } from "./routes/mcp"
  30. import { FileRoutes } from "./routes/file"
  31. import { ConfigRoutes } from "./routes/config"
  32. import { ExperimentalRoutes } from "./routes/experimental"
  33. import { ProviderRoutes } from "./routes/provider"
  34. import { EventRoutes } from "./routes/event"
  35. import { InstanceBootstrap } from "../project/bootstrap"
  36. import { NotFoundError } from "../storage/db"
  37. import type { ContentfulStatusCode } from "hono/utils/http-status"
  38. import { websocket } from "hono/bun"
  39. import { HTTPException } from "hono/http-exception"
  40. import { errors } from "./error"
  41. import { Filesystem } from "@/util/filesystem"
  42. import { Snapshot } from "@/snapshot"
  43. import { QuestionRoutes } from "./routes/question"
  44. import { PermissionRoutes } from "./routes/permission"
  45. import { GlobalRoutes } from "./routes/global"
  46. import { MDNS } from "./mdns"
  47. import { lazy } from "@/util/lazy"
  48. import { initProjectors } from "./projectors"
  49. // @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
  50. globalThis.AI_SDK_LOG_WARNINGS = false
  51. const csp = (hash = "") =>
  52. `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:`
  53. initProjectors()
  54. export namespace Server {
  55. const log = Log.create({ service: "server" })
  56. export const Default = lazy(() => createApp({}))
  57. export const createApp = (opts: { cors?: string[] }): Hono => {
  58. const app = new Hono()
  59. return app
  60. .onError((err, c) => {
  61. log.error("failed", {
  62. error: err,
  63. })
  64. if (err instanceof NamedError) {
  65. let status: ContentfulStatusCode
  66. if (err instanceof NotFoundError) status = 404
  67. else if (err instanceof Provider.ModelNotFoundError) status = 400
  68. else if (err.name === "ProviderAuthValidationFailed") status = 400
  69. else if (err.name.startsWith("Worktree")) status = 400
  70. else status = 500
  71. return c.json(err.toObject(), { status })
  72. }
  73. if (err instanceof HTTPException) return err.getResponse()
  74. const message = err instanceof Error && err.stack ? err.stack : err.toString()
  75. return c.json(new NamedError.Unknown({ message }).toObject(), {
  76. status: 500,
  77. })
  78. })
  79. .use((c, next) => {
  80. // Allow CORS preflight requests to succeed without auth.
  81. // Browser clients sending Authorization headers will preflight with OPTIONS.
  82. if (c.req.method === "OPTIONS") return next()
  83. const password = Flag.OPENCODE_SERVER_PASSWORD
  84. if (!password) return next()
  85. const username = Flag.OPENCODE_SERVER_USERNAME ?? "opencode"
  86. return basicAuth({ username, password })(c, next)
  87. })
  88. .use(async (c, next) => {
  89. const skipLogging = c.req.path === "/log"
  90. if (!skipLogging) {
  91. log.info("request", {
  92. method: c.req.method,
  93. path: c.req.path,
  94. })
  95. }
  96. const timer = log.time("request", {
  97. method: c.req.method,
  98. path: c.req.path,
  99. })
  100. await next()
  101. if (!skipLogging) {
  102. timer.stop()
  103. }
  104. })
  105. .use(
  106. cors({
  107. origin(input) {
  108. if (!input) return
  109. if (input.startsWith("http://localhost:")) return input
  110. if (input.startsWith("http://127.0.0.1:")) return input
  111. if (
  112. input === "tauri://localhost" ||
  113. input === "http://tauri.localhost" ||
  114. input === "https://tauri.localhost"
  115. )
  116. return input
  117. // *.opencode.ai (https only, adjust if needed)
  118. if (/^https:\/\/([a-z0-9-]+\.)*opencode\.ai$/.test(input)) {
  119. return input
  120. }
  121. if (opts?.cors?.includes(input)) {
  122. return input
  123. }
  124. return
  125. },
  126. }),
  127. )
  128. .route("/global", GlobalRoutes())
  129. .put(
  130. "/auth/:providerID",
  131. describeRoute({
  132. summary: "Set auth credentials",
  133. description: "Set authentication credentials",
  134. operationId: "auth.set",
  135. responses: {
  136. 200: {
  137. description: "Successfully set authentication credentials",
  138. content: {
  139. "application/json": {
  140. schema: resolver(z.boolean()),
  141. },
  142. },
  143. },
  144. ...errors(400),
  145. },
  146. }),
  147. validator(
  148. "param",
  149. z.object({
  150. providerID: ProviderID.zod,
  151. }),
  152. ),
  153. validator("json", Auth.Info.zod),
  154. async (c) => {
  155. const providerID = c.req.valid("param").providerID
  156. const info = c.req.valid("json")
  157. await Auth.set(providerID, info)
  158. return c.json(true)
  159. },
  160. )
  161. .delete(
  162. "/auth/:providerID",
  163. describeRoute({
  164. summary: "Remove auth credentials",
  165. description: "Remove authentication credentials",
  166. operationId: "auth.remove",
  167. responses: {
  168. 200: {
  169. description: "Successfully removed authentication credentials",
  170. content: {
  171. "application/json": {
  172. schema: resolver(z.boolean()),
  173. },
  174. },
  175. },
  176. ...errors(400),
  177. },
  178. }),
  179. validator(
  180. "param",
  181. z.object({
  182. providerID: ProviderID.zod,
  183. }),
  184. ),
  185. async (c) => {
  186. const providerID = c.req.valid("param").providerID
  187. await Auth.remove(providerID)
  188. return c.json(true)
  189. },
  190. )
  191. .use(async (c, next) => {
  192. if (c.req.path === "/log") return next()
  193. const rawWorkspaceID = c.req.query("workspace") || c.req.header("x-opencode-workspace")
  194. const raw = c.req.query("directory") || c.req.header("x-opencode-directory") || process.cwd()
  195. const directory = Filesystem.resolve(
  196. (() => {
  197. try {
  198. return decodeURIComponent(raw)
  199. } catch {
  200. return raw
  201. }
  202. })(),
  203. )
  204. return WorkspaceContext.provide({
  205. workspaceID: rawWorkspaceID ? WorkspaceID.make(rawWorkspaceID) : undefined,
  206. async fn() {
  207. return Instance.provide({
  208. directory,
  209. init: InstanceBootstrap,
  210. async fn() {
  211. return next()
  212. },
  213. })
  214. },
  215. })
  216. })
  217. .use(WorkspaceRouterMiddleware)
  218. .get(
  219. "/doc",
  220. openAPIRouteHandler(app, {
  221. documentation: {
  222. info: {
  223. title: "opencode",
  224. version: "0.0.3",
  225. description: "opencode api",
  226. },
  227. openapi: "3.1.1",
  228. },
  229. }),
  230. )
  231. .use(
  232. validator(
  233. "query",
  234. z.object({
  235. directory: z.string().optional(),
  236. workspace: z.string().optional(),
  237. }),
  238. ),
  239. )
  240. .route("/project", ProjectRoutes())
  241. .route("/pty", PtyRoutes())
  242. .route("/config", ConfigRoutes())
  243. .route("/experimental", ExperimentalRoutes())
  244. .route("/session", SessionRoutes())
  245. .route("/permission", PermissionRoutes())
  246. .route("/question", QuestionRoutes())
  247. .route("/provider", ProviderRoutes())
  248. .route("/", FileRoutes())
  249. .route("/", EventRoutes())
  250. .route("/mcp", McpRoutes())
  251. .route("/tui", TuiRoutes())
  252. .post(
  253. "/instance/dispose",
  254. describeRoute({
  255. summary: "Dispose instance",
  256. description: "Clean up and dispose the current OpenCode instance, releasing all resources.",
  257. operationId: "instance.dispose",
  258. responses: {
  259. 200: {
  260. description: "Instance disposed",
  261. content: {
  262. "application/json": {
  263. schema: resolver(z.boolean()),
  264. },
  265. },
  266. },
  267. },
  268. }),
  269. async (c) => {
  270. await Instance.dispose()
  271. return c.json(true)
  272. },
  273. )
  274. .get(
  275. "/path",
  276. describeRoute({
  277. summary: "Get paths",
  278. description: "Retrieve the current working directory and related path information for the OpenCode instance.",
  279. operationId: "path.get",
  280. responses: {
  281. 200: {
  282. description: "Path",
  283. content: {
  284. "application/json": {
  285. schema: resolver(
  286. z
  287. .object({
  288. home: z.string(),
  289. state: z.string(),
  290. config: z.string(),
  291. worktree: z.string(),
  292. directory: z.string(),
  293. })
  294. .meta({
  295. ref: "Path",
  296. }),
  297. ),
  298. },
  299. },
  300. },
  301. },
  302. }),
  303. async (c) => {
  304. return c.json({
  305. home: Global.Path.home,
  306. state: Global.Path.state,
  307. config: Global.Path.config,
  308. worktree: Instance.worktree,
  309. directory: Instance.directory,
  310. })
  311. },
  312. )
  313. .get(
  314. "/vcs",
  315. describeRoute({
  316. summary: "Get VCS info",
  317. description: "Retrieve version control system (VCS) information for the current project, such as git branch.",
  318. operationId: "vcs.get",
  319. responses: {
  320. 200: {
  321. description: "VCS info",
  322. content: {
  323. "application/json": {
  324. schema: resolver(Vcs.Info),
  325. },
  326. },
  327. },
  328. },
  329. }),
  330. async (c) => {
  331. const [branch, default_branch] = await Promise.all([Vcs.branch(), Vcs.defaultBranch()])
  332. return c.json({
  333. branch,
  334. default_branch,
  335. })
  336. },
  337. )
  338. .get(
  339. "/vcs/diff",
  340. describeRoute({
  341. summary: "Get VCS diff",
  342. description: "Retrieve the current git diff for the working tree or against the default branch.",
  343. operationId: "vcs.diff",
  344. responses: {
  345. 200: {
  346. description: "VCS diff",
  347. content: {
  348. "application/json": {
  349. schema: resolver(Snapshot.FileDiff.array()),
  350. },
  351. },
  352. },
  353. },
  354. }),
  355. validator(
  356. "query",
  357. z.object({
  358. mode: Vcs.Mode,
  359. }),
  360. ),
  361. async (c) => {
  362. return c.json(await Vcs.diff(c.req.valid("query").mode))
  363. },
  364. )
  365. .get(
  366. "/command",
  367. describeRoute({
  368. summary: "List commands",
  369. description: "Get a list of all available commands in the OpenCode system.",
  370. operationId: "command.list",
  371. responses: {
  372. 200: {
  373. description: "List of commands",
  374. content: {
  375. "application/json": {
  376. schema: resolver(Command.Info.array()),
  377. },
  378. },
  379. },
  380. },
  381. }),
  382. async (c) => {
  383. const commands = await Command.list()
  384. return c.json(commands)
  385. },
  386. )
  387. .post(
  388. "/log",
  389. describeRoute({
  390. summary: "Write log",
  391. description: "Write a log entry to the server logs with specified level and metadata.",
  392. operationId: "app.log",
  393. responses: {
  394. 200: {
  395. description: "Log entry written successfully",
  396. content: {
  397. "application/json": {
  398. schema: resolver(z.boolean()),
  399. },
  400. },
  401. },
  402. ...errors(400),
  403. },
  404. }),
  405. validator(
  406. "json",
  407. z.object({
  408. service: z.string().meta({ description: "Service name for the log entry" }),
  409. level: z.enum(["debug", "info", "error", "warn"]).meta({ description: "Log level" }),
  410. message: z.string().meta({ description: "Log message" }),
  411. extra: z
  412. .record(z.string(), z.any())
  413. .optional()
  414. .meta({ description: "Additional metadata for the log entry" }),
  415. }),
  416. ),
  417. async (c) => {
  418. const { service, level, message, extra } = c.req.valid("json")
  419. const logger = Log.create({ service })
  420. switch (level) {
  421. case "debug":
  422. logger.debug(message, extra)
  423. break
  424. case "info":
  425. logger.info(message, extra)
  426. break
  427. case "error":
  428. logger.error(message, extra)
  429. break
  430. case "warn":
  431. logger.warn(message, extra)
  432. break
  433. }
  434. return c.json(true)
  435. },
  436. )
  437. .get(
  438. "/agent",
  439. describeRoute({
  440. summary: "List agents",
  441. description: "Get a list of all available AI agents in the OpenCode system.",
  442. operationId: "app.agents",
  443. responses: {
  444. 200: {
  445. description: "List of agents",
  446. content: {
  447. "application/json": {
  448. schema: resolver(Agent.Info.array()),
  449. },
  450. },
  451. },
  452. },
  453. }),
  454. async (c) => {
  455. const modes = await Agent.list()
  456. return c.json(modes)
  457. },
  458. )
  459. .get(
  460. "/skill",
  461. describeRoute({
  462. summary: "List skills",
  463. description: "Get a list of all available skills in the OpenCode system.",
  464. operationId: "app.skills",
  465. responses: {
  466. 200: {
  467. description: "List of skills",
  468. content: {
  469. "application/json": {
  470. schema: resolver(Skill.Info.array()),
  471. },
  472. },
  473. },
  474. },
  475. }),
  476. async (c) => {
  477. const skills = await Skill.all()
  478. return c.json(skills)
  479. },
  480. )
  481. .get(
  482. "/lsp",
  483. describeRoute({
  484. summary: "Get LSP status",
  485. description: "Get LSP server status",
  486. operationId: "lsp.status",
  487. responses: {
  488. 200: {
  489. description: "LSP server status",
  490. content: {
  491. "application/json": {
  492. schema: resolver(LSP.Status.array()),
  493. },
  494. },
  495. },
  496. },
  497. }),
  498. async (c) => {
  499. return c.json(await LSP.status())
  500. },
  501. )
  502. .get(
  503. "/formatter",
  504. describeRoute({
  505. summary: "Get formatter status",
  506. description: "Get formatter status",
  507. operationId: "formatter.status",
  508. responses: {
  509. 200: {
  510. description: "Formatter status",
  511. content: {
  512. "application/json": {
  513. schema: resolver(Format.Status.array()),
  514. },
  515. },
  516. },
  517. },
  518. }),
  519. async (c) => {
  520. return c.json(await Format.status())
  521. },
  522. )
  523. .all("/*", async (c) => {
  524. const path = c.req.path
  525. const response = await proxy(`https://app.opencode.ai${path}`, {
  526. ...c.req,
  527. headers: {
  528. ...c.req.raw.headers,
  529. host: "app.opencode.ai",
  530. },
  531. })
  532. const match = response.headers.get("content-type")?.includes("text/html")
  533. ? (await response.clone().text()).match(
  534. /<script\b(?![^>]*\bsrc\s*=)[^>]*\bid=(['"])oc-theme-preload-script\1[^>]*>([\s\S]*?)<\/script>/i,
  535. )
  536. : undefined
  537. const hash = match ? createHash("sha256").update(match[2]).digest("base64") : ""
  538. response.headers.set("Content-Security-Policy", csp(hash))
  539. return response
  540. })
  541. }
  542. export async function openapi() {
  543. // Cast to break excessive type recursion from long route chains
  544. const result = await generateSpecs(Default(), {
  545. documentation: {
  546. info: {
  547. title: "opencode",
  548. version: "1.0.0",
  549. description: "opencode api",
  550. },
  551. openapi: "3.1.1",
  552. },
  553. })
  554. return result
  555. }
  556. /** @deprecated do not use this dumb shit */
  557. export let url: URL
  558. export function listen(opts: {
  559. port: number
  560. hostname: string
  561. mdns?: boolean
  562. mdnsDomain?: string
  563. cors?: string[]
  564. }) {
  565. url = new URL(`http://${opts.hostname}:${opts.port}`)
  566. const app = createApp(opts)
  567. const args = {
  568. hostname: opts.hostname,
  569. idleTimeout: 0,
  570. fetch: app.fetch,
  571. websocket: websocket,
  572. } as const
  573. const tryServe = (port: number) => {
  574. try {
  575. return Bun.serve({ ...args, port })
  576. } catch {
  577. return undefined
  578. }
  579. }
  580. const server = opts.port === 0 ? (tryServe(4096) ?? tryServe(0)) : tryServe(opts.port)
  581. if (!server) throw new Error(`Failed to start server on port ${opts.port}`)
  582. const shouldPublishMDNS =
  583. opts.mdns &&
  584. server.port &&
  585. opts.hostname !== "127.0.0.1" &&
  586. opts.hostname !== "localhost" &&
  587. opts.hostname !== "::1"
  588. if (shouldPublishMDNS) {
  589. MDNS.publish(server.port!, opts.mdnsDomain)
  590. } else if (opts.mdns) {
  591. log.warn("mDNS enabled but hostname is loopback; skipping mDNS publish")
  592. }
  593. const originalStop = server.stop.bind(server)
  594. server.stop = async (closeActiveConnections?: boolean) => {
  595. if (shouldPublishMDNS) MDNS.unpublish()
  596. return originalStop(closeActiveConnections)
  597. }
  598. return server
  599. }
  600. }