diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts index e4a86ca139..3488d2616c 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts @@ -82,6 +82,7 @@ export const ExperimentalApi = HttpApi.make("experimental") .add( HttpApiEndpoint.get("console", ExperimentalPaths.console, { success: described(ConsoleStateResponse, "Active Console provider metadata"), + error: HttpApiError.InternalServerError, }).annotateMerge( OpenApi.annotations({ identifier: "experimental.console.get", @@ -91,6 +92,7 @@ export const ExperimentalApi = HttpApi.make("experimental") ), HttpApiEndpoint.get("consoleOrgs", ExperimentalPaths.consoleOrgs, { success: described(ConsoleOrgList, "Switchable Console orgs"), + error: HttpApiError.InternalServerError, }).annotateMerge( OpenApi.annotations({ identifier: "experimental.console.listOrgs", diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts index cc958da303..fa91b44117 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts @@ -26,7 +26,10 @@ export const experimentalHandlers = HttpApiBuilder.group(InstanceHttpApi, "exper const getConsole = Effect.fn("ExperimentalHttpApi.console")(function* () { const [state, groups] = yield* Effect.all( - [config.getConsoleState(), account.orgsByAccount().pipe(Effect.orDie)], + [ + config.getConsoleState(), + account.orgsByAccount().pipe(Effect.catch(() => Effect.fail(new HttpApiError.InternalServerError({})))), + ], { concurrency: "unbounded", }, @@ -40,7 +43,10 @@ export const experimentalHandlers = HttpApiBuilder.group(InstanceHttpApi, "exper const listConsoleOrgs = Effect.fn("ExperimentalHttpApi.consoleOrgs")(function* () { const [groups, active] = yield* Effect.all( - [account.orgsByAccount().pipe(Effect.orDie), account.active().pipe(Effect.orDie)], + [ + account.orgsByAccount().pipe(Effect.catch(() => Effect.fail(new HttpApiError.InternalServerError({})))), + account.active().pipe(Effect.catch(() => Effect.fail(new HttpApiError.InternalServerError({})))), + ], { concurrency: "unbounded", }, diff --git a/packages/opencode/test/server/httpapi-account-error-mapping.test.ts b/packages/opencode/test/server/httpapi-account-error-mapping.test.ts new file mode 100644 index 0000000000..b96e86365b --- /dev/null +++ b/packages/opencode/test/server/httpapi-account-error-mapping.test.ts @@ -0,0 +1,99 @@ +import { afterEach, describe, expect, mock, test } from "bun:test" +import { Effect, Layer, Option } from "effect" + +// Account.orgsByAccount() can fail with AccountServiceError when the +// upstream Anthropic Console API is unreachable. The HTTP API used to +// pipe the call through Effect.orDie, which converts the typed error +// into a defect — surfacing as a 500 with the raw stack trace embedded +// in the response body. +// +// The handlers now map the failure onto HttpApiError.InternalServerError +// and the endpoints declare it as a typed error. Operators get a +// structured 500 response with no stack-trace leak, and future error +// middleware can recognize the failure type instead of seeing a defect. +// +// To force the failure path, mock @/account/account so its defaultLayer +// provides an Account.Service whose orgsByAccount returns Effect.fail. + +const ORIG = await import("../../src/account/account") + +const failingAccountLayer = Layer.succeed( + ORIG.Service, + ORIG.Service.of({ + active: () => Effect.succeed(Option.none()), + activeOrg: () => Effect.succeed(Option.none()), + list: () => Effect.succeed([]), + orgsByAccount: () => + Effect.fail(new ORIG.AccountServiceError({ message: "simulated upstream failure" })), + remove: () => Effect.void, + use: () => Effect.void, + orgs: () => Effect.succeed([]), + config: () => Effect.succeed(Option.none()), + token: () => Effect.succeed(Option.none()), + login: () => Effect.fail(new ORIG.AccountServiceError({ message: "unused" })), + poll: () => Effect.fail(new ORIG.AccountServiceError({ message: "unused" })), + }), +) + +const mocked = { + ...ORIG, + defaultLayer: failingAccountLayer, + layer: failingAccountLayer, + Account: { + ...ORIG.Account, + defaultLayer: failingAccountLayer, + layer: failingAccountLayer, + }, +} + +void mock.module("@/account/account", () => mocked) +void mock.module("../../src/account/account", () => mocked) + +const { Flag } = await import("@opencode-ai/core/flag/flag") +const Log = await import("@opencode-ai/core/util/log") +const { Server } = await import("../../src/server/server") +const { ExperimentalPaths } = await import("../../src/server/routes/instance/httpapi/groups/experimental") +const { resetDatabase } = await import("../fixture/db") +const { disposeAllInstances, tmpdir } = await import("../fixture/fixture") + +void Log.init({ print: false }) + +const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI + +afterEach(async () => { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original + await disposeAllInstances() + await resetDatabase() +}) + +function httpApiApp() { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true + return Server.Default().app +} + +async function probe(path: string) { + await using tmp = await tmpdir({ config: { formatter: false, lsp: false } }) + return httpApiApp().request(path, { + headers: { "x-opencode-directory": tmp.path }, + }) +} + +describe("HTTP API account failure mapping", () => { + test("/experimental/console returns a structured 500, not a stack-trace defect", async () => { + const response = await probe(ExperimentalPaths.console) + expect(response.status).toBe(500) + const body = await response.text() + expect(body).not.toContain("\n at ") + const json = JSON.parse(body) + expect(json._tag).toBe("InternalServerError") + }) + + test("/experimental/console/orgs returns a structured 500, not a stack-trace defect", async () => { + const response = await probe(ExperimentalPaths.consoleOrgs) + expect(response.status).toBe(500) + const body = await response.text() + expect(body).not.toContain("\n at ") + const json = JSON.parse(body) + expect(json._tag).toBe("InternalServerError") + }) +})