700 lines · 24.2 KB · TypeScript
RawDownload
import { BusEvent } from "@/bus/bus-event"import { Bus } from "@/bus"import { Log } from "../util/log"import { describeRoute, generateSpecs, validator, resolver, openAPIRouteHandler } from "hono-openapi"import { Hono } from "hono"import { cors } from "hono/cors"import { streamSSE } from "hono/streaming"import { serveWebAsset } from "../web/serve"import { isAllowedHost, isAllowedOrigin, isCrossOrigin } from "./host-guard"import { timingSafeEqual } from "../util/timing-safe"import { FolderResolveRoutes } from "./routes/folder-resolve"import { AtlasBridgeRoutes } from "./routes/atlas-bridge"import { RepoRoutes } from "./routes/repo"import z from "zod"import { Provider } from "../provider/provider"import { NamedError } from "@synsci/util/error"import { LSP } from "../lsp"import { Format } from "../format"import { Instance } from "../project/instance"import { Vcs } from "../project/vcs"import { Agent } from "../agent/agent"import { Skill } from "../skill/skill"import { Auth } from "../auth"import { Command } from "../command"import { Global } from "../global"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 { lazy } from "../util/lazy"import { InstanceBootstrap } from "../project/bootstrap"import { Storage } from "../storage/storage"import type { ContentfulStatusCode } from "hono/utils/http-status"import { websocket } from "hono/bun"import { HTTPException } from "hono/http-exception"import { errors } from "./error"import { QuestionRoutes } from "./routes/question"import { PermissionRoutes } from "./routes/permission"import { GlobalRoutes } from "./routes/global"import { AccountRoutes } from "./routes/account"import { SettingsSkillsRoutes } from "./routes/settings/skills"import { MemorySettingsRoutes } from "./routes/settings/memory"import { NetworkSettingsRoutes } from "./routes/settings/network"import { CredentialsRoutes } from "./routes/settings/credentials"import { StorageRoutes } from "./routes/settings/storage"import { ComputeSettingsRoutes } from "./routes/settings/compute"import { RegistryPermissionsRoutes } from "./routes/settings/registry-permissions"import { SettingsPreferencesRoutes } from "./routes/settings/preferences"import { BillingSettingsRoutes } from "./routes/settings/billing"import { WalletSettingsRoutes } from "./routes/settings/wallet"import { SettingsUsageRoutes } from "./routes/settings/usage" // @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#L85globalThis.AI_SDK_LOG_WARNINGS = false export namespace Server {  const log = Log.create({ service: "server" })   let _url: URL | undefined  let _corsWhitelist: string[] = []  let _server: Bun.Server<unknown> | undefined   // Per-process secret marking trusted in-process calls (Server.internalFetch).  // Generated fresh each run, kept in memory, never sent to any client — a  // network request cannot reproduce it.  const INTERNAL_HEADER = "x-openscience-internal"  const INTERNAL_NONCE = crypto.randomUUID()   export function url(): URL {    return _url ?? new URL("http://localhost:4096")  }   export function requestIP(req: Request): string | undefined {    return _server?.requestIP(req)?.address  }   const app = new Hono()  export const App: () => Hono = lazy(    () =>      // TODO: Break server.ts into smaller route files to fix type inference      app        .onError((err, c) => {          log.error("failed", {            error: err,          })          if (err instanceof NamedError) {            let status: ContentfulStatusCode            if (err instanceof Storage.NotFoundError) status = 404            else if (err instanceof Provider.ModelNotFoundError) 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,          })        })        .use(async (c, next) => {          // In-process callers (Server.internalFetch) carry a per-process nonce          // and are never network-reachable — trust them outright. Compare in          // constant time so the nonce (which bypasses the host/origin guards)          // can't be recovered byte-by-byte via a timing side channel.          const internal = c.req.header(INTERNAL_HEADER)          if (internal !== undefined && timingSafeEqual(internal, INTERNAL_NONCE)) return next()           // 1. DNS-rebinding defense: only loopback Host values are accepted.          const host = c.req.header("host") ?? new URL(c.req.url).host          if (!isAllowedHost(host)) {            return c.json({ error: "Forbidden host" }, 403)          }           // 2. Cross-origin defense (covers WebSocket upgrades, which CORS does          //    not). A foreign Origin — or a cross-site fetch that omits Origin          //    (e.g. a no-cors GET) — is rejected. See isCrossOrigin.          if (isCrossOrigin(c.req.header("origin"), c.req.header("sec-fetch-site"), _corsWhitelist)) {            return c.json({ error: "Forbidden origin" }, 403)          }          return next()        })        .use(async (c, next) => {          const skipLogging = c.req.path === "/log"          if (!skipLogging) {            log.info("request", {              method: c.req.method,              path: c.req.path,            })          }          const timer = log.time("request", {            method: c.req.method,            path: c.req.path,          })          await next()          if (!skipLogging) {            timer.stop()          }        })        .use(          cors({            // Reuse the same allow-list the request guard enforces, so CORS            // response headers and the cross-origin gate never drift apart.            origin(input) {              if (!input) return              return isAllowedOrigin(input, _corsWhitelist) ? input : undefined            },          }),        )        .route("/global", GlobalRoutes())        .route("/account", AccountRoutes())        // Settings panels backed by global (project-independent) stores, so        // mounted before the Instance.provide wrapper below (no directory).        .route("/settings/credentials", CredentialsRoutes())        .route("/settings/storage", StorageRoutes())        .route("/settings/compute", ComputeSettingsRoutes())        .route("/settings/permissions", RegistryPermissionsRoutes())        .route("/settings/preferences", SettingsPreferencesRoutes())        .route("/settings/billing", BillingSettingsRoutes())        .route("/settings/wallet", WalletSettingsRoutes())        .put(          "/auth/:providerID",          describeRoute({            summary: "Set auth credentials",            description: "Set authentication credentials",            operationId: "auth.set",            responses: {              200: {                description: "Successfully set authentication credentials",                content: {                  "application/json": {                    schema: resolver(z.boolean()),                  },                },              },              ...errors(400),            },          }),          validator(            "param",            z.object({              providerID: z.string(),            }),          ),          validator("json", Auth.Info),          async (c) => {            const providerID = c.req.valid("param").providerID            const info = c.req.valid("json")            await Auth.set(providerID, info)            // Don't depend on the client remembering to call global.sync —            // stale provider state would keep serving the old credential.            Provider.invalidate()            return c.json(true)          },        )        .delete(          "/auth/:providerID",          describeRoute({            summary: "Remove auth credentials",            description: "Remove authentication credentials",            operationId: "auth.remove",            responses: {              200: {                description: "Successfully removed authentication credentials",                content: {                  "application/json": {                    schema: resolver(z.boolean()),                  },                },              },              ...errors(400),            },          }),          validator(            "param",            z.object({              providerID: z.string(),            }),          ),          async (c) => {            const providerID = c.req.valid("param").providerID            await Auth.remove(providerID)            Provider.invalidate()            return c.json(true)          },        )        // Folder-resolve endpoints are filesystem-global (no project Instance        // needed), so mount before the Instance.provide wrapper below.        .route("/api/resolve-folder", FolderResolveRoutes())        // Atlas graph bridge — proxies /api/thesis/* to the Atlas REST API        // using the user's stored thk_ key (see routes/atlas-bridge.ts).        .route("/api/thesis", AtlasBridgeRoutes())        // Repository tab (status/commit/push/remote) — shells out to git.        .route("/api/repo", RepoRoutes())        .use(async (c, next) => {          let directory = c.req.query("directory") || c.req.header("x-openscience-directory") || process.cwd()          try {            directory = decodeURIComponent(directory)          } catch {            // fallback to original value          }          return Instance.provide({            directory,            init: InstanceBootstrap,            async fn() {              return next()            },          })        })        .get(          "/doc",          openAPIRouteHandler(app, {            documentation: {              info: {                title: "openscience",                version: "0.0.3",                description: "openscience api",              },              openapi: "3.1.1",            },          }),        )        .use(validator("query", z.object({ directory: z.string().optional() })))        .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("/mcp", McpRoutes())        .route("/settings/skills", SettingsSkillsRoutes())        .route("/settings/memory", MemorySettingsRoutes())        .route("/settings/network", NetworkSettingsRoutes())        .route("/settings/usage", SettingsUsageRoutes())        .post(          "/instance/dispose",          describeRoute({            summary: "Dispose instance",            description: "Clean up and dispose the current OpenScience 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 OpenScience 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 OpenScience 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({            summary: "Write log",            description: "Write a log entry to the server logs with specified level and metadata.",            operationId: "app.log",            responses: {              200: {                description: "Log entry written successfully",                content: {                  "application/json": {                    schema: resolver(z.boolean()),                  },                },              },              ...errors(400),            },          }),          validator(            "json",            z.object({              service: z.string().meta({ description: "Service name for the log entry" }),              level: z.enum(["debug", "info", "error", "warn"]).meta({ description: "Log level" }),              message: z.string().meta({ description: "Log message" }),              extra: z                .record(z.string(), z.any())                .optional()                .meta({ description: "Additional metadata for the log entry" }),            }),          ),          async (c) => {            const { service, level, message, extra } = c.req.valid("json")            const logger = Log.create({ service })             switch (level) {              case "debug":                logger.debug(message, extra)                break              case "info":                logger.info(message, extra)                break              case "error":                logger.error(message, extra)                break              case "warn":                logger.warn(message, extra)                break            }             return c.json(true)          },        )        .get(          "/agent",          describeRoute({            summary: "List agents",            description: "Get a list of all available AI agents in the OpenScience 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 OpenScience 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)          },        )        .put(          "/skill/:name",          describeRoute({            summary: "Write user skill",            description: "Create or update a local user-authored skill.",            operationId: "app.skill.write",            responses: {              200: {                description: "Saved skill",                content: {                  "application/json": {                    schema: resolver(Skill.Info),                  },                },              },

Lines 1–500 of 700.