diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/global.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/global.ts new file mode 100644 index 000000000..5f38d3f67 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/global.ts @@ -0,0 +1,123 @@ +import { Info as ConfigInfo } from "@/config/config" +import { Schema } from "effect" +import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" +import { BadRequestError } from "./common" + +const GlobalHealth = Schema.Struct({ + healthy: Schema.Literal(true), + version: Schema.String, +}) + +const GlobalDisposeResult = Schema.Struct({ + status: Schema.Literals(["completed", "deferred"]), + lifecycleActionID: Schema.String, + affectedDirectoryKeys: Schema.Array(Schema.String), +}) + +const GlobalUpgradePayload = Schema.Struct({ + target: Schema.optionalKey(Schema.String), +}) + +const GlobalUpgradeSuccess = Schema.Struct({ + success: Schema.Literal(true), + version: Schema.String, +}) + +const GlobalUpgradeFailure = Schema.Struct({ + success: Schema.Literal(false), + error: Schema.String, +}) + +const GlobalUpgradeBadRequest = GlobalUpgradeFailure.pipe( + HttpApiSchema.status(400), + (schema) => + schema.annotate({ + identifier: "GlobalUpgradeBadRequest", + description: "Global upgrade request cannot be fulfilled", + }), +) + +const GlobalUpgradeServerError = GlobalUpgradeFailure.pipe( + HttpApiSchema.status(500), + (schema) => + schema.annotate({ + identifier: "GlobalUpgradeServerError", + description: "Global upgrade failed", + }), +) + +export const GlobalPaths = { + config: "/global/config", + health: "/global/health", + dispose: "/global/dispose", + upgrade: "/global/upgrade", +} as const + +export const GlobalApi = HttpApi.make("global") + .add( + HttpApiGroup.make("global") + .add( + HttpApiEndpoint.get("configGet", GlobalPaths.config, { + success: ConfigInfo, + }).annotateMerge( + OpenApi.annotations({ + identifier: "global.config.get", + summary: "Get global configuration", + description: "Retrieve the current global OpenCode configuration settings and preferences.", + }), + ), + HttpApiEndpoint.patch("configUpdate", GlobalPaths.config, { + payload: ConfigInfo, + success: ConfigInfo, + error: BadRequestError, + }).annotateMerge( + OpenApi.annotations({ + identifier: "global.config.update", + summary: "Update global configuration", + description: "Update global OpenCode configuration settings and preferences.", + }), + ), + HttpApiEndpoint.get("health", GlobalPaths.health, { + success: GlobalHealth, + }).annotateMerge( + OpenApi.annotations({ + identifier: "global.health", + summary: "Get health", + description: "Get health information about the OpenCode server.", + }), + ), + HttpApiEndpoint.post("dispose", GlobalPaths.dispose, { + success: GlobalDisposeResult, + }).annotateMerge( + OpenApi.annotations({ + identifier: "global.dispose", + summary: "Dispose instance", + description: "Clean up and dispose all OpenCode instances, releasing all resources.", + }), + ), + HttpApiEndpoint.post("upgrade", GlobalPaths.upgrade, { + payload: GlobalUpgradePayload, + success: Schema.Union([GlobalUpgradeSuccess, GlobalUpgradeFailure]), + error: [BadRequestError, GlobalUpgradeBadRequest, GlobalUpgradeServerError], + }).annotateMerge( + OpenApi.annotations({ + identifier: "global.upgrade", + summary: "Upgrade opencode", + description: "Upgrade opencode to the specified version or latest if not specified.", + }), + ), + ) + .annotateMerge( + OpenApi.annotations({ + title: "global", + description: "HttpApi global control routes.", + }), + ), + ) + .annotateMerge( + OpenApi.annotations({ + title: "opencode global HttpApi", + version: "0.0.1", + description: "HttpApi surface for global control routes.", + }), + ) diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/root.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/root.ts new file mode 100644 index 000000000..cd1eae469 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/root.ts @@ -0,0 +1,282 @@ +import { Schema } from "effect" +import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" +import { BadRequestError, WorkspaceRoutingQuery } from "./common" + +const FileStatus = Schema.Literals(["added", "deleted", "modified"]) + +const VcsInfo = Schema.Struct({ + branch: Schema.optionalKey(Schema.String), + default_branch: Schema.optionalKey(Schema.String), +}) + +const VcsFileDiff = Schema.Struct({ + file: Schema.String, + patch: Schema.String, + additions: Schema.Number, + deletions: Schema.Number, + status: Schema.optionalKey(FileStatus), +}) + +const VcsFileStatus = Schema.Struct({ + file: Schema.String, + additions: Schema.Number, + deletions: Schema.Number, + status: FileStatus, +}) + +const VcsModeQuery = Schema.Struct({ + ...WorkspaceRoutingQuery.fields, + mode: Schema.Literals(["git", "branch"]), +}) + +const VcsApplyPayload = Schema.Struct({ + patch: Schema.String, +}) + +const VcsApplyResult = Schema.Struct({ + applied: Schema.Boolean, +}) + +const VcsApplyError = Schema.Struct({ + error: Schema.Literal("vcs_apply_failed"), + reason: Schema.Literals(["non-git", "not-clean", "too-large", "invalid-input"]), + message: Schema.String, +}).pipe( + HttpApiSchema.status(400), + (schema) => + schema.annotate({ + identifier: "VcsApplyFailure", + description: "VCS patch apply failure", + }), +) + +const VcsApplyTooLargeError = Schema.Struct({ + error: Schema.Literal("vcs_apply_failed"), + reason: Schema.Literal("too-large"), + message: Schema.String, +}).pipe( + HttpApiSchema.status(413), + (schema) => + schema.annotate({ + identifier: "VcsApplyTooLargeFailure", + description: "VCS patch apply request is too large", + }), +) + +const VcsDiffRawTooLargeError = Schema.Struct({ + error: Schema.Literal("vcs_diff_raw_failed"), + reason: Schema.Literal("too-large"), + message: Schema.String, +}).pipe( + HttpApiSchema.status(413), + (schema) => + schema.annotate({ + identifier: "VcsDiffRawFailure", + description: "Raw VCS diff is too large", + }), +) + +const CommandInfo = Schema.Struct({ + name: Schema.String, + description: Schema.optionalKey(Schema.String), + agent: Schema.optionalKey(Schema.String), + model: Schema.optionalKey(Schema.String), + source: Schema.optionalKey(Schema.Literals(["command", "mcp", "skill"])), + template: Schema.Unknown, + subtask: Schema.optionalKey(Schema.Boolean), + hints: Schema.Array(Schema.String), +}) + +const AgentInfo = Schema.Struct({ + name: Schema.String, + description: Schema.optionalKey(Schema.String), + mode: Schema.Literals(["subagent", "primary", "all"]), + native: Schema.optionalKey(Schema.Boolean), + hidden: Schema.optionalKey(Schema.Boolean), + topP: Schema.optionalKey(Schema.Number), + temperature: Schema.optionalKey(Schema.Number), + color: Schema.optionalKey(Schema.String), + permission: Schema.Unknown, + model: Schema.optionalKey( + Schema.Struct({ + modelID: Schema.String, + providerID: Schema.String, + }), + ), + variant: Schema.optionalKey(Schema.String), + prompt: Schema.optionalKey(Schema.String), + options: Schema.Record(Schema.String, Schema.Unknown), + steps: Schema.optionalKey(Schema.Number), +}) + +const SkillInfo = Schema.Struct({ + name: Schema.String, + description: Schema.optionalKey(Schema.String), + location: Schema.String, + content: Schema.String, +}) + +const LspStatus = Schema.Struct({ + id: Schema.String, + name: Schema.String, + root: Schema.String, + status: Schema.Literals(["connected", "error"]), +}) + +const PathInfo = Schema.Struct({ + home: Schema.String, + state: Schema.String, + config: Schema.String, + worktree: Schema.String, + directory: Schema.String, +}) + +export const RootPaths = { + instanceDispose: "/instance/dispose", + path: "/path", + vcs: "/vcs", + vcsStatus: "/vcs/status", + vcsDiff: "/vcs/diff", + vcsDiffRaw: "/vcs/diff/raw", + vcsApply: "/vcs/apply", + command: "/command", + agent: "/agent", + skill: "/skill", + lsp: "/lsp", +} as const + +export const RootApi = HttpApi.make("root") + .add( + HttpApiGroup.make("root") + .add( + HttpApiEndpoint.post("instanceDispose", RootPaths.instanceDispose, { + query: WorkspaceRoutingQuery, + success: Schema.Boolean, + }).annotateMerge( + OpenApi.annotations({ + identifier: "instance.dispose", + summary: "Dispose instance", + description: "Clean up and dispose the current OpenCode instance, releasing all resources.", + }), + ), + HttpApiEndpoint.get("path", RootPaths.path, { + query: Schema.Struct({ + ...WorkspaceRoutingQuery.fields, + ensureConfig: Schema.optionalKey(Schema.Literals(["true", "false"])), + }), + success: PathInfo, + }).annotateMerge( + OpenApi.annotations({ + identifier: "path.get", + summary: "Get paths", + description: "Retrieve the current working directory and related path information for the OpenCode instance.", + }), + ), + HttpApiEndpoint.get("vcs", RootPaths.vcs, { + query: WorkspaceRoutingQuery, + success: VcsInfo, + }).annotateMerge( + OpenApi.annotations({ + identifier: "vcs.get", + summary: "Get VCS info", + description: "Retrieve version control system information for the current project.", + }), + ), + HttpApiEndpoint.get("vcsStatus", RootPaths.vcsStatus, { + query: WorkspaceRoutingQuery, + success: Schema.Array(VcsFileStatus), + }).annotateMerge( + OpenApi.annotations({ + identifier: "vcs.status", + summary: "Get VCS status", + description: "Retrieve working tree file status summaries for the current project.", + }), + ), + HttpApiEndpoint.get("vcsDiff", RootPaths.vcsDiff, { + query: VcsModeQuery, + success: Schema.Array(VcsFileDiff), + }).annotateMerge( + OpenApi.annotations({ + identifier: "vcs.diff", + summary: "Get VCS diff", + description: "Retrieve the current working-tree diff.", + }), + ), + HttpApiEndpoint.get("vcsDiffRaw", RootPaths.vcsDiffRaw, { + query: WorkspaceRoutingQuery, + success: Schema.String, + error: VcsDiffRawTooLargeError, + }).annotateMerge( + OpenApi.annotations({ + identifier: "vcs.diffRaw", + summary: "Get raw VCS diff", + description: "Retrieve the current git diff as raw patch text.", + }), + ), + HttpApiEndpoint.post("vcsApply", RootPaths.vcsApply, { + query: WorkspaceRoutingQuery, + payload: VcsApplyPayload, + success: VcsApplyResult, + error: [BadRequestError, VcsApplyError, VcsApplyTooLargeError], + }).annotateMerge( + OpenApi.annotations({ + identifier: "vcs.apply", + summary: "Apply VCS patch", + description: "Apply a git patch to the current project.", + }), + ), + HttpApiEndpoint.get("command", RootPaths.command, { + query: WorkspaceRoutingQuery, + success: Schema.Array(CommandInfo), + }).annotateMerge( + OpenApi.annotations({ + identifier: "command.list", + summary: "List commands", + description: "Get a list of all available commands in the OpenCode system.", + }), + ), + HttpApiEndpoint.get("agent", RootPaths.agent, { + query: WorkspaceRoutingQuery, + success: Schema.Array(AgentInfo), + }).annotateMerge( + OpenApi.annotations({ + identifier: "app.agents", + summary: "List agents", + description: "Get a list of all available AI agents in the OpenCode system.", + }), + ), + HttpApiEndpoint.get("skill", RootPaths.skill, { + query: WorkspaceRoutingQuery, + success: Schema.Array(SkillInfo), + }).annotateMerge( + OpenApi.annotations({ + identifier: "app.skills", + summary: "List skills", + description: "Get a list of all available skills in the OpenCode system.", + }), + ), + HttpApiEndpoint.get("lsp", RootPaths.lsp, { + query: WorkspaceRoutingQuery, + success: Schema.Array(LspStatus), + }).annotateMerge( + OpenApi.annotations({ + identifier: "lsp.status", + summary: "Get LSP status", + description: "Get LSP server status.", + }), + ), + ) + .annotateMerge( + OpenApi.annotations({ + title: "root", + description: "HttpApi root instance routes.", + }), + ), + ) + .annotateMerge( + OpenApi.annotations({ + title: "opencode root instance HttpApi", + version: "0.0.1", + description: "HttpApi surface for root instance routes.", + }), + ) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/global.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/global.ts new file mode 100644 index 000000000..c242edc47 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/global.ts @@ -0,0 +1,147 @@ +import { GlobalBus } from "@/bus/global" +import { Config } from "@/config/config" +import { Installation } from "@/installation" +import { Instance } from "@/project/instance" +import { withRequestContext, type RequestContextSnapshot } from "@/server/request-context" +import { Effect } from "effect" +import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http" +import { HttpApiBuilder } from "effect/unstable/httpapi" +import z from "zod" +import { GlobalApi } from "../groups/global" + +const UpgradePayload = z.object({ + target: z.string().optional(), +}) + +function isJsonContentType(contentType: string | undefined) { + return /^application\/([a-z-.]+\+)?json(?:;\s*[a-zA-Z0-9-]+=([^;]+))*$/.test(contentType ?? "") +} + +function badRequestJson(body: unknown) { + return HttpServerResponse.jsonUnsafe(body, { status: 400 }) +} + +function parseJsonBody(request: HttpServerRequest.HttpServerRequest, schema: z.ZodType) { + return Effect.gen(function* () { + const body = isJsonContentType(request.headers["content-type"]) + ? yield* request.json.pipe( + Effect.catch(() => Effect.succeed(HttpServerResponse.raw("Malformed JSON in request body", { status: 400 }))), + ) + : {} + if (HttpServerResponse.isHttpServerResponse(body)) return body + + const parsed = schema.safeParse(body) + if (!parsed.success) return badRequestJson({ data: body, error: parsed.error.issues, success: false }) + return parsed.data + }) +} + +function safeHeaderToken(value: string | undefined): string | undefined { + if (!value) return undefined + const trimmed = value.trim() + if (!trimmed || trimmed.length > 100) return "unknown" + if (/[/\\]|https?:\/\//i.test(trimmed)) return "unknown" + if (/token|secret|bearer|sk-|cookie|password/i.test(trimmed)) return "unknown" + if (!/^[a-zA-Z0-9_.:-]+$/.test(trimmed)) return "unknown" + return trimmed +} + +function globalRequestContext(request: HttpServerRequest.HttpServerRequest): RequestContextSnapshot { + const clientActionID = safeHeaderToken(request.headers["x-pawwork-client-action-id"]) + const clientActionKind = safeHeaderToken(request.headers["x-pawwork-client-action-kind"]) + const routeSessionID = safeHeaderToken(request.headers["x-pawwork-route-session-id"]) + const visibleSessionID = safeHeaderToken(request.headers["x-pawwork-visible-session-id"]) + const client_action = clientActionID + ? { + id: clientActionID, + kind: clientActionKind ?? "unknown", + route_session_id: routeSessionID, + visible_session_id: visibleSessionID, + } + : undefined + + return { + method: request.method, + path: new URL(request.url, "http://localhost").pathname, + source: client_action ? "renderer" : "local_api", + client_action, + } +} + +function emitGlobalDisposed() { + GlobalBus.emit("event", { + directory: "global", + payload: { + type: "global.disposed", + properties: {}, + }, + }) +} + +const upgradeInstallation = Effect.fn("GlobalHttpApi.upgrade")(function* (target?: string) { + const installation = yield* Installation.Service + const method = yield* installation.method() + if (method === "unknown") { + return { success: false, status: 400, error: "Unknown installation method" } as const + } + + const resolvedTarget = target || (yield* installation.latest(method)) + const result = yield* Effect.catch( + installation.upgrade(method, resolvedTarget).pipe(Effect.as({ success: true as const, version: resolvedTarget })), + (err) => + Effect.succeed({ + success: false as const, + status: 500 as const, + error: err instanceof Error ? err.message : String(err), + }), + ) + if (!result.success) return result + return { ...result, status: 200 } as const +}) + +export const globalHandlers = HttpApiBuilder.group(GlobalApi, "global", (handlers) => + Effect.gen(function* () { + const config = yield* Config.Service + + return handlers + .handleRaw("configGet", () => config.getGlobal().pipe(Effect.map((result) => HttpServerResponse.jsonUnsafe(result)))) + .handleRaw("configUpdate", (ctx) => + Effect.gen(function* () { + const body = yield* parseJsonBody(ctx.request, Config.Info.zod) + if (HttpServerResponse.isHttpServerResponse(body)) return body + const next = yield* config.updateGlobal(body) + return HttpServerResponse.jsonUnsafe(next) + }), + ) + .handleRaw("health", () => + Effect.succeed(HttpServerResponse.jsonUnsafe({ healthy: true, version: Installation.VERSION })), + ) + .handleRaw("dispose", (ctx) => + Effect.promise(() => + withRequestContext(globalRequestContext(ctx.request), () => + Instance.disposeAll({ onCompleted: emitGlobalDisposed }), + ), + ).pipe(Effect.map((result) => HttpServerResponse.jsonUnsafe(result))), + ) + .handleRaw("upgrade", (ctx) => + Effect.gen(function* () { + const body = yield* parseJsonBody(ctx.request, UpgradePayload) + if (HttpServerResponse.isHttpServerResponse(body)) return body + + const result = yield* upgradeInstallation(body.target) + if (!result.success) { + return HttpServerResponse.jsonUnsafe({ success: false, error: result.error }, { status: result.status }) + } + + GlobalBus.emit("event", { + directory: "global", + payload: { + type: Installation.Event.Updated.type, + properties: { version: result.version }, + }, + }) + return HttpServerResponse.jsonUnsafe({ success: true, version: result.version }) + }), + ) + }), +) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/root.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/root.ts new file mode 100644 index 000000000..52a61b2ec --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/root.ts @@ -0,0 +1,171 @@ +import fs from "fs/promises" +import { Agent } from "@/agent/agent" +import { Command } from "@/command" +import { LSP } from "@/lsp" +import { Skill } from "@/skill" +import { Global } from "@/global" +import { Instance } from "@/project/instance" +import { Vcs } from "@/project/vcs" +import { PawWorkHome } from "@opencode-ai/core/pawwork-home" +import { Runtime } from "@opencode-ai/core/runtime" +import { Effect } from "effect" +import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http" +import { HttpApiBuilder } from "effect/unstable/httpapi" +import { RootApi } from "../groups/root" + +const applyPatchTooLarge = () => + ({ + error: "vcs_apply_failed", + reason: "too-large", + message: "Patch exceeds the 10 MB input limit", + }) satisfies Vcs.ApplyError + +const applyPatchInvalidInput = () => + ({ + error: "vcs_apply_failed", + reason: "invalid-input", + message: "Patch request body must be valid JSON with a string patch", + }) satisfies Vcs.ApplyError + +const applyJsonEnvelopeBytes = Buffer.byteLength(JSON.stringify({ patch: "" })) +const maxJsonStringEscapeRatio = 6 +const applyJsonBodyMaxBytes = Vcs.MAX_APPLY_PATCH_BYTES * maxJsonStringEscapeRatio + applyJsonEnvelopeBytes + +function isJsonRequest(request: HttpServerRequest.HttpServerRequest) { + return request.headers["content-type"]?.includes("json") === true +} + +function contentLengthTooLarge(request: HttpServerRequest.HttpServerRequest) { + const contentLength = request.headers["content-length"] + return contentLength !== undefined && Number.parseInt(contentLength, 10) > applyJsonBodyMaxBytes +} + +function applyErrorResponse(body: Vcs.ApplyError, status: 400 | 413) { + return HttpServerResponse.jsonUnsafe(body, { status }) +} + +const parseApplyBody = Effect.fn("RootHttpApi.vcsApplyBody")(function* ( + request: HttpServerRequest.HttpServerRequest, +) { + if (contentLengthTooLarge(request)) return applyErrorResponse(applyPatchTooLarge(), 413) + + if (!isJsonRequest(request)) return applyErrorResponse(applyPatchInvalidInput(), 400) + + const text = yield* request.text.pipe(Effect.catch(() => Effect.succeed(""))) + if (Buffer.byteLength(text) > applyJsonBodyMaxBytes) return applyErrorResponse(applyPatchTooLarge(), 413) + + let body: unknown + try { + body = JSON.parse(text) + } catch { + return applyErrorResponse(applyPatchInvalidInput(), 400) + } + + const parsed = Vcs.ApplyInput.safeParse(body) + if (!parsed.success) return applyErrorResponse(applyPatchInvalidInput(), 400) + return parsed.data +}) + +const getPaths = Effect.fn("RootHttpApi.path")(function* (ensureConfig: boolean) { + const config = Runtime.isPawWork() + ? ensureConfig + ? yield* Effect.promise(() => PawWorkHome.ensurePrimary()) + : PawWorkHome.primary() + : Global.Path.config + if (ensureConfig && !Runtime.isPawWork()) { + yield* Effect.promise(() => fs.mkdir(config, { recursive: true })) + } + return { + home: Global.Path.home, + state: Global.Path.state, + config, + worktree: Instance.worktree, + directory: Instance.directory, + } +}) + +function vcsApplyFailure(error: unknown) { + if (error instanceof Vcs.PatchApplyError) { + const body = + error.reason === "too-large" + ? applyPatchTooLarge() + : ({ + error: "vcs_apply_failed", + reason: error.reason, + message: error.message, + } satisfies Vcs.ApplyError) + return Effect.succeed(applyErrorResponse(body, error.reason === "too-large" ? 413 : 400)) + } + return Effect.die(error) +} + +export const rootHandlers = HttpApiBuilder.group(RootApi, "root", (handlers) => + handlers + .handleRaw("instanceDispose", () => + Effect.promise(() => Instance.dispose()).pipe(Effect.as(HttpServerResponse.jsonUnsafe(true))), + ) + .handleRaw("path", (ctx) => + getPaths(ctx.query.ensureConfig === "true").pipe(Effect.map((result) => HttpServerResponse.jsonUnsafe(result))), + ) + .handleRaw("vcs", () => + Effect.gen(function* () { + const vcs = yield* Vcs.Service + const [branch, defaultBranch] = yield* Effect.all([vcs.branch(), vcs.defaultBranch()], { concurrency: 2 }) + return HttpServerResponse.jsonUnsafe({ branch, default_branch: defaultBranch }) + }), + ) + .handleRaw("vcsStatus", () => + Vcs.Service.use((vcs) => vcs.status()).pipe(Effect.map((result) => HttpServerResponse.jsonUnsafe(result))), + ) + .handleRaw("vcsDiff", (ctx) => + Vcs.Service.use((vcs) => vcs.diff(ctx.query.mode)).pipe(Effect.map((result) => HttpServerResponse.jsonUnsafe(result))), + ) + .handleRaw("vcsDiffRaw", () => + Vcs.Service.use((vcs) => vcs.diffRaw()).pipe( + Effect.map((result) => + HttpServerResponse.raw(result, { + contentType: "text/plain; charset=UTF-8", + }), + ), + Effect.catch((error) => { + if (error instanceof Vcs.RawDiffError) { + return Effect.succeed( + HttpServerResponse.jsonUnsafe( + { + error: "vcs_diff_raw_failed", + reason: error.reason, + message: error.message, + } satisfies Vcs.DiffRawError, + { status: 413 }, + ), + ) + } + return Effect.fail(error) + }), + ), + ) + .handleRaw("vcsApply", (ctx) => + Effect.gen(function* () { + const body = yield* parseApplyBody(ctx.request) + if (HttpServerResponse.isHttpServerResponse(body)) return body + + const result = yield* Vcs.Service.use((vcs) => vcs.apply(body)).pipe( + Effect.map((value) => HttpServerResponse.jsonUnsafe(value)), + Effect.catch(vcsApplyFailure), + ) + return result + }), + ) + .handleRaw("command", () => + Command.Service.use((command) => command.list()).pipe(Effect.map((result) => HttpServerResponse.jsonUnsafe(result))), + ) + .handleRaw("agent", () => + Agent.Service.use((agent) => agent.list()).pipe(Effect.map((result) => HttpServerResponse.jsonUnsafe(result))), + ) + .handleRaw("skill", () => + Skill.Service.use((skill) => skill.all()).pipe(Effect.map((result) => HttpServerResponse.jsonUnsafe(result))), + ) + .handleRaw("lsp", () => + LSP.Service.use((lsp) => lsp.status()).pipe(Effect.map((result) => HttpServerResponse.jsonUnsafe(result))), + ), +) diff --git a/packages/opencode/test/server/global-config-routes.test.ts b/packages/opencode/test/server/global-config-routes.test.ts index 6f84b7ccf..83d59dc52 100644 --- a/packages/opencode/test/server/global-config-routes.test.ts +++ b/packages/opencode/test/server/global-config-routes.test.ts @@ -1,12 +1,19 @@ import { afterEach, describe, expect, test } from "bun:test" +import { NodeFileSystem, NodeHttpPlatform, NodePath } from "@effect/platform-node" +import { Effect, Layer } from "effect" +import { Etag, HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" +import { HttpApiBuilder, OpenApi } from "effect/unstable/httpapi" import fs from "fs/promises" import path from "path" import { Hono } from "hono" import { Config } from "../../src/config" import { AppRuntime } from "../../src/effect/app-runtime" import { Global } from "../../src/global" +import { Installation } from "../../src/installation" import { Instance } from "../../src/project/instance" import { GlobalRoutes } from "../../src/server/instance/global" +import { GlobalApi } from "../../src/server/routes/instance/httpapi/groups/global" +import { globalHandlers } from "../../src/server/routes/instance/httpapi/handlers/global" import { tmpdir } from "../fixture/fixture" import { withConfigDepsLock } from "../shared/config-deps-lock" @@ -46,6 +53,36 @@ async function withIsolatedGlobalConfig(fn: (globalDir: string) => Promise } describe("global config routes", () => { + function requestGlobalHttpApi(routePath: string, init?: RequestInit, serviceLayer = Layer.empty) { + return AppRuntime.runPromise( + Effect.scoped( + Effect.gen(function* () { + const router = yield* HttpRouter.toHttpEffect( + HttpApiBuilder.layer(GlobalApi).pipe( + Layer.provide(globalHandlers), + Layer.provide( + Layer.mergeAll(serviceLayer, NodeFileSystem.layer, NodeHttpPlatform.layer, NodePath.layer, Etag.layer), + ), + ), + ) + const request = HttpServerRequest.fromWeb(new Request(`http://localhost${routePath}`, init)) + const response = yield* router.pipe(Effect.provideService(HttpServerRequest.HttpServerRequest, request), Effect.orDie) + return HttpServerResponse.toWeb(response) + }), + ) as Effect.Effect, + ) + } + + test("declares global HttpApi endpoints", () => { + const spec = OpenApi.fromApi(GlobalApi) as any + + expect(spec.paths["/global/config"]).toHaveProperty("get") + expect(spec.paths["/global/config"]).toHaveProperty("patch") + expect(spec.paths["/global/health"]).toHaveProperty("get") + expect(spec.paths["/global/dispose"]).toHaveProperty("post") + expect(spec.paths["/global/upgrade"]).toHaveProperty("post") + }) + test("gets and patches global config through the route runtime", async () => { await withConfigDepsLock(async () => { await withIsolatedGlobalConfig(async (globalDir) => { @@ -67,4 +104,99 @@ describe("global config routes", () => { }) }) }) + + test("serves config and health through the HttpApi handlers", async () => { + await withConfigDepsLock(async () => { + await withIsolatedGlobalConfig(async (globalDir) => { + const before = await requestGlobalHttpApi("/global/config") + expect(before.status).toBe(200) + + const response = await requestGlobalHttpApi("/global/config", { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "test/httpapi-global-model" }), + }) + const body = await response.json() + + expect(response.status).toBe(200) + expect(body.model).toBe("test/httpapi-global-model") + expect(JSON.parse(await fs.readFile(path.join(globalDir, "pawwork.json"), "utf8")).model).toBe( + "test/httpapi-global-model", + ) + + const health = await requestGlobalHttpApi("/global/health") + expect(health.status).toBe(200) + expect(await health.json()).toMatchObject({ healthy: true, version: expect.any(String) }) + }) + }) + }) + + test("returns the merged global config after HttpApi patch", async () => { + await withConfigDepsLock(async () => { + await withIsolatedGlobalConfig(async (globalDir) => { + await fs.writeFile(path.join(globalDir, "pawwork.json"), JSON.stringify({ username: "kept-user" }), "utf8") + + const response = await requestGlobalHttpApi("/global/config", { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "test/httpapi-merged-model" }), + }) + const body = await response.json() + + expect(response.status).toBe(200) + expect(body).toMatchObject({ + username: "kept-user", + model: "test/httpapi-merged-model", + }) + }) + }) + }) + + test("serves dispose and upgrade through the HttpApi handlers", async () => { + const dispose = await requestGlobalHttpApi("/global/dispose", { method: "POST" }) + expect(dispose.status).toBe(200) + expect(await dispose.json()).toMatchObject({ + status: expect.stringMatching(/^(completed|deferred)$/), + lifecycleActionID: expect.any(String), + affectedDirectoryKeys: expect.any(Array), + }) + + const installation = Layer.succeed(Installation.Service, { + info: () => Effect.succeed({ version: "0.0.0", latest: "9.9.9" }), + method: () => Effect.succeed("npm" as const), + latest: () => Effect.succeed("9.9.9"), + upgrade: () => Effect.void, + } satisfies Installation.Interface) + + const upgraded = await requestGlobalHttpApi( + "/global/upgrade", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ target: "9.9.9" }), + }, + installation, + ) + expect(upgraded.status).toBe(200) + expect(await upgraded.json()).toEqual({ success: true, version: "9.9.9" }) + + const unknownInstallation = Layer.succeed(Installation.Service, { + info: () => Effect.succeed({ version: "0.0.0", latest: "9.9.9" }), + method: () => Effect.succeed("unknown" as const), + latest: () => Effect.succeed("9.9.9"), + upgrade: () => Effect.void, + } satisfies Installation.Interface) + + const rejected = await requestGlobalHttpApi( + "/global/upgrade", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ target: "9.9.9" }), + }, + unknownInstallation, + ) + expect(rejected.status).toBe(400) + expect(await rejected.json()).toEqual({ success: false, error: "Unknown installation method" }) + }) }) diff --git a/packages/opencode/test/server/instance-root-routes.test.ts b/packages/opencode/test/server/instance-root-routes.test.ts index 118bdf043..860d27dad 100644 --- a/packages/opencode/test/server/instance-root-routes.test.ts +++ b/packages/opencode/test/server/instance-root-routes.test.ts @@ -1,9 +1,16 @@ import { $ } from "bun" import { afterEach, describe, expect, test } from "bun:test" +import { NodeFileSystem, NodeHttpPlatform, NodePath } from "@effect/platform-node" +import { Effect, Layer } from "effect" +import { Etag, HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" +import { HttpApiBuilder, OpenApi } from "effect/unstable/httpapi" import fs from "fs/promises" import path from "path" import { Instance } from "../../src/project/instance" import { Server } from "../../src/server/server" +import { RootApi } from "../../src/server/routes/instance/httpapi/groups/root" +import { rootHandlers } from "../../src/server/routes/instance/httpapi/handlers/root" +import { AppRuntime } from "../../src/effect/app-runtime" import { resetDatabase } from "../fixture/db" import { tmpdir } from "../fixture/fixture" @@ -13,6 +20,45 @@ afterEach(async () => { }) describe("instance root routes", () => { + function requestRootHttpApi(routePath: string, init?: RequestInit) { + return AppRuntime.runPromise( + Effect.scoped( + Effect.gen(function* () { + const router = yield* HttpRouter.toHttpEffect( + HttpApiBuilder.layer(RootApi).pipe( + Layer.provide(rootHandlers), + Layer.provide(Layer.mergeAll(NodeFileSystem.layer, NodeHttpPlatform.layer, NodePath.layer, Etag.layer)), + ), + ) + const request = HttpServerRequest.fromWeb(new Request(`http://localhost${routePath}`, init)) + const response = yield* router.pipe(Effect.provideService(HttpServerRequest.HttpServerRequest, request), Effect.orDie) + return HttpServerResponse.toWeb(response) + }), + ) as Effect.Effect, + ) + } + + test("declares root instance HttpApi endpoints", () => { + const spec = OpenApi.fromApi(RootApi) as any + + for (const [routePath, method] of [ + ["/instance/dispose", "post"], + ["/path", "get"], + ["/vcs", "get"], + ["/vcs/status", "get"], + ["/vcs/diff", "get"], + ["/vcs/diff/raw", "get"], + ["/vcs/apply", "post"], + ["/command", "get"], + ["/agent", "get"], + ["/skill", "get"], + ["/lsp", "get"], + ] as const) { + expect(spec.paths).toHaveProperty(routePath) + expect(spec.paths[routePath]).toHaveProperty(method) + } + }) + test("returns path and metadata JSON through the route runtime", async () => { await using tmp = await tmpdir({ git: true }) const app = Server.Default().app @@ -64,4 +110,96 @@ describe("instance root routes", () => { expect(response.status).toBe(200) expect(await response.json()).toBe(true) }) + + test("serves path, metadata, VCS, and dispose through the HttpApi handlers", async () => { + await using tmp = await tmpdir({ git: true }) + await fs.writeFile(path.join(tmp.path, "tracked.txt"), "original\n", "utf-8") + await $`git add tracked.txt`.cwd(tmp.path).quiet() + await $`git commit --no-gpg-sign -m "add file"`.cwd(tmp.path).quiet() + await fs.writeFile(path.join(tmp.path, "tracked.txt"), "changed\n", "utf-8") + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const pathResponse = await requestRootHttpApi("/path") + expect(pathResponse.status).toBe(200) + expect(await pathResponse.json()).toMatchObject({ directory: tmp.path, worktree: tmp.path }) + + for (const route of ["/agent", "/skill", "/command", "/lsp"]) { + const response = await requestRootHttpApi(route) + expect(response.status, route).toBe(200) + expect(await response.json(), route).toBeArray() + } + + const info = await requestRootHttpApi("/vcs") + expect(info.status).toBe(200) + expect(await info.json()).toMatchObject({ branch: expect.any(String) }) + + const diff = await requestRootHttpApi("/vcs/diff?mode=git") + expect(diff.status).toBe(200) + expect(await diff.json()).toEqual([ + expect.objectContaining({ file: "tracked.txt", additions: 1, deletions: 1, status: "modified" }), + ]) + + const status = await requestRootHttpApi("/vcs/status") + expect(status.status).toBe(200) + expect(await status.json()).toEqual([{ file: "tracked.txt", additions: 1, deletions: 1, status: "modified" }]) + + const rawDiff = await requestRootHttpApi("/vcs/diff/raw") + expect(rawDiff.status).toBe(200) + expect(rawDiff.headers.get("content-type")).toContain("text/plain") + expect(await rawDiff.text()).toContain("diff --git a/tracked.txt b/tracked.txt") + + const dispose = await requestRootHttpApi("/instance/dispose", { method: "POST" }) + expect(dispose.status).toBe(200) + expect(await dispose.json()).toBe(true) + }, + }) + }) + + test("preserves VCS apply validation through the HttpApi handlers", async () => { + await using tmp = await tmpdir() + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + for (const item of [ + { name: "missing patch", body: JSON.stringify({}) }, + { name: "non-string patch", body: JSON.stringify({ patch: 1 }) }, + { name: "invalid JSON", body: "{" }, + { name: "empty JSON body", body: undefined }, + ]) { + const response = await requestRootHttpApi("/vcs/apply", { + method: "POST", + headers: { "content-type": "application/json" }, + body: item.body, + }) + + expect(response.status, item.name).toBe(400) + expect(await response.json(), item.name).toEqual({ + error: "vcs_apply_failed", + reason: "invalid-input", + message: "Patch request body must be valid JSON with a string patch", + }) + } + + const maxEncodedBodyBytes = 10_000_000 * 6 + Buffer.byteLength(JSON.stringify({ patch: "" })) + const tooLarge = await requestRootHttpApi("/vcs/apply", { + method: "POST", + headers: { + "content-length": String(maxEncodedBodyBytes + 1), + "content-type": "application/json", + }, + body: JSON.stringify({ patch: "" }), + }) + + expect(tooLarge.status).toBe(413) + expect(await tooLarge.json()).toEqual({ + error: "vcs_apply_failed", + reason: "too-large", + message: "Patch exceeds the 10 MB input limit", + }) + }, + }) + }) }) diff --git a/packages/opencode/test/server/route-inventory-harness.test.ts b/packages/opencode/test/server/route-inventory-harness.test.ts index eb62725c1..49234be25 100644 --- a/packages/opencode/test/server/route-inventory-harness.test.ts +++ b/packages/opencode/test/server/route-inventory-harness.test.ts @@ -159,6 +159,34 @@ describe("route inventory harness", () => { } }) + test("tracks local HttpApi migration coverage for root instance and global JSON routes", async () => { + const inventory = await buildRouteInventory({ root, requireUpstream: false }) + + for (const [method, routePath] of [ + ["POST", "/instance/dispose"], + ["GET", "/path"], + ["GET", "/vcs"], + ["GET", "/vcs/status"], + ["GET", "/vcs/diff"], + ["GET", "/vcs/diff/raw"], + ["POST", "/vcs/apply"], + ["GET", "/command"], + ["GET", "/agent"], + ["GET", "/skill"], + ["GET", "/lsp"], + ["GET", "/global/config"], + ["PATCH", "/global/config"], + ["GET", "/global/health"], + ["POST", "/global/dispose"], + ["POST", "/global/upgrade"], + ] as const) { + expect(inventory.rows.find((row) => row.method === method && row.path === routePath)).toMatchObject({ + hono: true, + localHttpApi: true, + }) + } + }) + test("parses upstream HttpApi route declarations without requiring a live upstream ref", () => { const routes = parseHttpApiRoutesFromText( `