feat(cli): configure server CORS origins (#45544)

This commit is contained in:
Brendan Allan 2026-08-29 09:41:52 +08:00 committed by GitHub
parent 095ed63ea0
commit c0d0f5f4bc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 363 additions and 5 deletions

View file

@ -375,6 +375,11 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
params: {
hostname: Flag.string("hostname").pipe(Flag.optional),
port: Flag.integer("port").pipe(Flag.optional),
cors: Flag.string("cors").pipe(
Flag.withSchema(Schema.NonEmptyString),
Flag.withDescription("Additional allowed CORS origin (repeat for multiple origins)"),
Flag.atLeast(0),
),
service: Flag.boolean("service").pipe(Flag.withDefault(false)),
stdio: Flag.boolean("stdio").pipe(Flag.withDefault(false)),
},

View file

@ -11,6 +11,7 @@ export default Runtime.handler(
mode: input.service ? "service" : input.stdio ? "stdio" : "default",
hostname: Option.getOrUndefined(input.hostname),
port: Option.getOrUndefined(input.port),
cors: input.cors.length > 0 ? input.cors : undefined,
})
}),
)

View file

@ -22,6 +22,7 @@ export type Options = {
readonly mode: Mode
readonly hostname?: string
readonly port?: number
readonly cors?: readonly string[]
}
// The process effect lives until server shutdown; tracing it would parent every request to one process-lifetime trace.
@ -88,6 +89,7 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
},
hostname,
port,
cors: options.cors ?? config.cors,
password,
pty: { handoff },
simulation: truthy(process.env.OPENCODE_SIMULATE),

View file

@ -15,11 +15,12 @@ export const Info = Schema.Struct({
hostname: Schema.optional(Schema.String),
port: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(1), Schema.isLessThanOrEqualTo(65_535))),
password: Schema.optional(Schema.String),
cors: Schema.optional(Schema.Array(Schema.String)),
env: Schema.optional(Schema.Record(Schema.String, Schema.String)),
})
export type Info = typeof Info.Type
const keys = ["hostname", "port", "password", "env"] as const
const keys = ["hostname", "port", "password", "cors", "env"] as const
type Key = (typeof keys)[number]
const decodeInfo = Schema.decodeUnknownEffect(Schema.fromJsonString(Info))
@ -77,7 +78,7 @@ export const migrateConfig = Effect.fnUntraced(function* (legacy: string, file:
})
function configKey(key: string): Key {
if (key === "hostname" || key === "port" || key === "password" || key === "env") return key
if (key === "hostname" || key === "port" || key === "password" || key === "cors" || key === "env") return key
throw new Error(`Unknown service config key: ${key}`)
}
@ -160,6 +161,9 @@ export const get = Effect.fn("cli.service-config.get")(function* (key?: string,
case "password": {
return yield* password()
}
case "cors": {
return JSON.stringify((yield* read()).cors ?? [], null, 2)
}
case "env": {
const env = (yield* read()).env ?? {}
return name === undefined ? JSON.stringify(env, null, 2) : (env[name] ?? "")
@ -197,6 +201,19 @@ export const set = Effect.fn("cli.service-config.set")(function* (key: string, v
yield* write({ ...existing, env: { ...existing.env, [value]: nestedValue } })
return
}
case "cors": {
const cors = value.split(",").map((origin) => origin.trim())
if (
cors.some((origin) => {
const url = URL.parse(origin)
return !url || (url.protocol !== "http:" && url.protocol !== "https:") || url.origin !== origin
})
)
throw new Error("CORS must be a comma-separated list of HTTP(S) origins without paths or trailing slashes")
yield* Service.stop(yield* options())
yield* write({ ...(yield* read()), cors })
return
}
}
})
@ -231,6 +248,12 @@ export const unset = Effect.fn("cli.service-config.unset")(function* (key: strin
yield* write(Object.keys(env).length === 0 ? rest : { ...rest, env })
return
}
case "cors": {
yield* Service.stop(yield* options())
const { cors: _cors, ...next } = yield* read()
yield* write(next)
return
}
}
})

View file

@ -0,0 +1,124 @@
import { NodeServices } from "@effect/platform-node"
import { Global } from "@opencode-ai/util/global"
import { expect, test } from "bun:test"
import { Effect, Exit, FileSystem } from "effect"
import { Command } from "effect/unstable/cli"
import path from "node:path"
import { Commands } from "../src/commands/commands"
import { ServiceConfig } from "../src/services/service-config"
import { it } from "../../core/test/lib/effect"
it.live("service CORS config persists multiple origins and preserves other settings on set and unset", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
const root = yield* fs.makeTempDirectoryScoped({ prefix: "opencode-cors-" })
const config = path.join(root, "config")
const state = path.join(root, "state")
const file = path.join(config, ServiceConfig.filename())
const existing = { hostname: "127.0.0.1", port: 4321, password: "test-secret", env: { TEST: "value" } }
yield* fs.makeDirectory(config)
yield* fs.makeDirectory(state)
yield* fs.writeFileString(file, JSON.stringify(existing))
yield* Effect.gen(function* () {
expect(yield* ServiceConfig.get("cors")).toBe("[]")
yield* ServiceConfig.set("cors", " http://192.0.2.10:3001, https://app.example.com ")
const cors = ["http://192.0.2.10:3001", "https://app.example.com"]
expect(yield* ServiceConfig.read()).toEqual({ ...existing, cors })
expect(yield* ServiceConfig.get("cors")).toBe(JSON.stringify(cors, null, 2))
expect(JSON.parse(yield* ServiceConfig.get())).toEqual({
hostname: existing.hostname,
port: existing.port,
env: existing.env,
cors,
})
expect(JSON.parse(yield* fs.readFileString(file))).toEqual({ ...existing, cors })
yield* ServiceConfig.set("cors", "https://replacement.example.com")
expect((yield* ServiceConfig.read()).cors).toEqual(["https://replacement.example.com"])
yield* ServiceConfig.unset("cors")
expect(yield* ServiceConfig.get("cors")).toBe("[]")
expect(JSON.parse(yield* fs.readFileString(file))).toEqual(existing)
}).pipe(Effect.provideService(Global.Service, Global.make({ config, state })))
}).pipe(Effect.provide(NodeServices.layer)),
)
it.live("service CORS config rejects empty lists, invalid origins, and extra arguments without changing config", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
const root = yield* fs.makeTempDirectoryScoped({ prefix: "opencode-cors-invalid-" })
const config = path.join(root, "config")
const state = path.join(root, "state")
const file = path.join(config, ServiceConfig.filename())
const existing = { port: 4321, cors: ["https://app.example.com"] }
yield* fs.makeDirectory(config)
yield* fs.makeDirectory(state)
yield* fs.writeFileString(file, JSON.stringify(existing))
yield* Effect.gen(function* () {
yield* Effect.forEach(
[
"",
" ",
",",
"https://app.example.com,",
",https://app.example.com",
"https://app.example.com,,https://other.example.com",
"not-a-url",
"*",
"null",
"ftp://app.example.com",
"https://app.example.com/",
"https://app.example.com/path",
"https://app.example.com?query=1",
"https://app.example.com#fragment",
"https://user:password@app.example.com",
],
(value) =>
Effect.gen(function* () {
expect(Exit.isFailure(yield* ServiceConfig.set("cors", value).pipe(Effect.exit))).toBe(true)
expect(yield* ServiceConfig.read()).toEqual(existing)
}),
)
yield* Effect.forEach(
[
ServiceConfig.get("cors", "extra"),
ServiceConfig.set("cors", "https://app.example.com", "extra"),
ServiceConfig.unset("cors", "extra"),
],
(operation) =>
Effect.gen(function* () {
expect(Exit.isFailure(yield* operation.pipe(Effect.exit))).toBe(true)
}),
)
expect(JSON.parse(yield* fs.readFileString(file))).toEqual(existing)
}).pipe(Effect.provideService(Global.Service, Global.make({ config, state })))
}).pipe(Effect.provide(NodeServices.layer)),
)
test.each([
{ args: [], cors: [] },
{ args: ["--cors", "https://app.example.com"], cors: ["https://app.example.com"] },
{
args: ["--service", "--cors", "http://192.0.2.10:3001", "--cors", "https://app.example.com"],
cors: ["http://192.0.2.10:3001", "https://app.example.com"],
},
])("serve parses CORS flags: $args", async ({ args, cors }) => {
const received: (readonly string[])[] = []
const command = Commands.commands.serve.spec.pipe(
Command.withHandler((input) => Effect.sync(() => void received.push(input.cors))),
)
await Effect.runPromise(Command.runWith(command, { version: "test" })(args).pipe(Effect.provide(NodeServices.layer)))
expect(received).toEqual([cors])
})
test.each([{ args: ["--cors"] }, { args: ["--cors", ""] }])(
"serve rejects a missing or empty CORS flag value: $args",
async ({ args }) => {
const command = Commands.commands.serve.spec.pipe(Command.withHandler(() => Effect.void))
const result = await Effect.runPromise(
Command.runWith(command, { version: "test", renderErrors: false })(args).pipe(
Effect.exit,
Effect.provide(NodeServices.layer),
),
)
expect(Exit.isFailure(result)).toBe(true)
},
)

View file

@ -9,6 +9,7 @@ import os from "node:os"
import path from "node:path"
import { ServiceConfig } from "../src/services/service-config"
import { ServiceRegistration } from "../src/services/service-registration"
import { isolatedEnv } from "./fixture/environment"
test("managed service ports are stable per installation channel", () => {
expect(ServiceConfig.defaultPort("latest")).toBe(0xc0de)
@ -313,6 +314,48 @@ test("configured managed service port overrides the channel default", async () =
}
}, 30_000)
test.each([
{ args: [], origins: ["http://192.0.2.10:3001", "https://configured.example.com"] },
{
args: ["--cors", "http://192.0.2.20:3001", "--cors", "https://override.example.com"],
origins: ["http://192.0.2.20:3001", "https://override.example.com"],
},
])(
"managed service applies CORS configuration with flag overrides: $args",
async ({ args, origins }) => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-cors-"))
const config = path.join(root, "config", ServiceConfig.filename())
const registration = path.join(root, "state", "opencode", ServiceConfig.filename())
const cors = ["http://192.0.2.10:3001", "https://configured.example.com"]
await fs.mkdir(path.dirname(config), { recursive: true })
await fs.writeFile(config, JSON.stringify({ cors }))
const owner = Bun.spawn(
[process.execPath, path.join(import.meta.dir, "../src/index.ts"), "serve", "--service", "--port", "0", ...args],
{ env: isolatedEnv(root), stderr: "pipe", stdout: "ignore" },
)
try {
const info = await waitForInfo(registration)
await Promise.all(
[...new Set([...cors, ...origins, "https://unlisted.example.com"])].map(async (origin) => {
const response = await fetch(new URL("/api/health", info.url), {
method: "OPTIONS",
headers: { Origin: origin, "Access-Control-Request-Method": "GET" },
})
expect(response.headers.get("access-control-allow-origin")).toBe(
origins.some((value) => value === origin) ? origin : null,
)
}),
)
expect((await Bun.file(config).json()).cors).toEqual(cors)
} finally {
owner.kill("SIGTERM")
await owner.exited
await fs.rm(root, { recursive: true, force: true })
}
},
30_000,
)
test("unrelated managed port occupancy reports an actionable conflict", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-conflict-"))
const listener = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: () => new Response("unrelated") })

View file

@ -48,7 +48,7 @@ export const make = Effect.fn("ServerFetch.make")(function* (options: ServerOpti
return Context.get(context, HttpRouter.HttpRouter)
.asHttpEffect()
.pipe(
HttpMiddleware.cors({ allowedOrigins: isAllowedCorsOrigin, maxAge: 86_400 }),
HttpMiddleware.cors({ allowedOrigins: (origin) => isAllowedCorsOrigin(origin, options), maxAge: 86_400 }),
HttpEffect.toWebHandlerWith(context),
)
})

View file

@ -14,6 +14,7 @@ export const ServerOptions = Schema.Struct({
hostname: Schema.optional(Schema.String),
port: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0), Schema.isLessThanOrEqualTo(65_535))),
password: Schema.optional(Schema.String),
cors: Schema.optional(Schema.Array(Schema.String)),
simulation: Schema.optional(Schema.Boolean),
database: Schema.optional(Database.Options),
pty: Schema.optional(PersistentPty.Options),

View file

@ -63,7 +63,7 @@ export const start = Effect.fn("ServerProcess.start")(function* <E, R>(
yield* bound.http
.serve(
dispatch(password, status, application, options.app?.version ?? "unknown").pipe(
HttpMiddleware.cors({ allowedOrigins: isAllowedCorsOrigin, maxAge: 86_400 }),
HttpMiddleware.cors({ allowedOrigins: (origin) => isAllowedCorsOrigin(origin, options), maxAge: 86_400 }),
),
errorResponseLogger,
)

View file

@ -35,6 +35,7 @@ import { HttpApiBuilder } from "effect/unstable/httpapi"
import { Context, Effect, Layer, Option } from "effect"
import { Api } from "./api"
import { ServerAuth } from "./auth"
import { CorsConfig } from "./cors"
import { handlers } from "./handlers"
import { authorizationLayer } from "./middleware/authorization"
import { schemaErrorLayer } from "./middleware/schema-error"
@ -149,7 +150,7 @@ function makeRoutes<AuthError, AuthServices>(
ServerInfo.layer(serviceURLs, options.app),
)
const api = HttpApiBuilder.layer(Api, { openapiPath: "/openapi.json" }).pipe(
Layer.provide(handlers.pipe(Layer.provide(services))),
Layer.provide(handlers.pipe(Layer.provide(services), Layer.provide(Layer.succeed(CorsConfig, options)))),
Layer.provide(formLocationLayer),
Layer.provide(sessionLocationLayer),
Layer.provide(layer),

View file

@ -0,0 +1,24 @@
import { expect, test } from "bun:test"
import { isAllowedCorsOrigin, isAllowedRequestOrigin } from "../src/cors"
test("custom origins extend the defaults without allowing other origins", () => {
const options = { cors: ["http://192.168.1.10:3001", "https://example.com"] }
expect(isAllowedCorsOrigin("http://192.168.1.10:3001")).toBe(false)
expect(isAllowedCorsOrigin("http://192.168.1.10:3001", options)).toBe(true)
expect(isAllowedCorsOrigin("https://example.com", options)).toBe(true)
expect(isAllowedCorsOrigin("http://localhost:3001", options)).toBe(true)
expect(isAllowedCorsOrigin("https://app.opencode.ai", options)).toBe(true)
expect(isAllowedCorsOrigin(undefined, options)).toBe(true)
expect(isAllowedCorsOrigin("http://192.168.1.10:3002", options)).toBe(false)
expect(isAllowedCorsOrigin("https://example.com.evil.test", options)).toBe(false)
expect(isAllowedCorsOrigin("http://example.com", options)).toBe(false)
expect(isAllowedCorsOrigin("null", options)).toBe(false)
})
test("PTY origin checks use the same allowlist and retain same-host access", () => {
const options = { cors: ["http://192.168.1.10:3001"] }
expect(isAllowedRequestOrigin("http://192.168.1.10:3001", "192.168.1.10:1029")).toBe(false)
expect(isAllowedRequestOrigin("http://192.168.1.10:3001", "192.168.1.10:1029", options)).toBe(true)
expect(isAllowedRequestOrigin("http://192.168.1.10:1029", "192.168.1.10:1029", options)).toBe(true)
expect(isAllowedRequestOrigin("http://192.168.1.10:3002", "192.168.1.10:1029", options)).toBe(false)
})

View file

@ -132,6 +132,59 @@ it.live("serves unauthenticated and answers CORS preflight when no password is c
}),
)
it.live("applies custom CORS origins to HTTP responses and PTY ticket checks", () =>
Effect.gen(function* () {
const handler = yield* ServerFetch.make({
...options,
password: "secret",
cors: ["http://192.168.1.10:3001"],
})
yield* Effect.forEach(
["http://192.168.1.10:3001", "http://localhost:3000", "https://untrusted.example.com"],
(origin) =>
Effect.gen(function* () {
const allowed = origin !== "https://untrusted.example.com"
const preflight = yield* Effect.promise(() =>
handler(
new Request("http://opencode.local/api/health", {
method: "OPTIONS",
headers: {
origin,
"access-control-request-method": "GET",
"access-control-request-headers": "authorization",
},
}),
),
)
expect(preflight.status).toBe(204)
expect(preflight.headers.get("access-control-allow-origin")).toBe(allowed ? origin : null)
expect(preflight.headers.get("access-control-allow-headers")).toBe("authorization")
const response = yield* Effect.promise(() =>
handler(
new Request("http://opencode.local/api/health", {
headers: { origin, authorization: `Basic ${btoa("opencode:secret")}` },
}),
),
)
expect(response.status).toBe(200)
expect(response.headers.get("access-control-allow-origin")).toBe(allowed ? origin : null)
const ticket = yield* Effect.promise(() =>
handler(
new Request("http://opencode.local/api/experimental/persistent-pty/pty_missing/connect-token", {
method: "POST",
headers: { origin, authorization: `Basic ${btoa("opencode:secret")}`, "x-opencode-ticket": "1" },
}),
),
)
// Allowed origins pass the ticket guard and reach the missing-terminal lookup.
expect(ticket.status).toBe(allowed ? 404 : 403)
}),
)
}).pipe(Effect.scoped),
)
it.live("cancels a stale OpenAI OAuth callback server before falling back", () =>
Effect.gen(function* () {
const requests = yield* occupy(1455, true)

View file

@ -24,3 +24,13 @@ test("accepts optional app metadata", () => {
test("accepts durable event persistence configuration", () => {
expect(Option.getOrThrow(decode({ events: { persist: true } })).events).toEqual({ persist: true })
})
test("accepts an optional CORS allowlist", () => {
expect(Option.getOrThrow(decode({})).cors).toBeUndefined()
expect(Option.getOrThrow(decode({ cors: [] })).cors).toEqual([])
expect(Option.getOrThrow(decode({ cors: ["http://192.168.1.10:3001", "https://example.com"] })).cors).toEqual([
"http://192.168.1.10:3001",
"https://example.com",
])
expect(Option.isNone(decode({ cors: "http://192.168.1.10:3001" }))).toBe(true)
})

View file

@ -12,6 +12,7 @@ it.live("allows browser preflight requests without credentials", () =>
hostname: "127.0.0.1",
port: 0,
password: "secret",
cors: ["http://192.168.1.10:3001", "https://example.com"],
app: { version: "test-version" },
database: { path: ":memory:" },
},
@ -52,6 +53,42 @@ it.live("allows browser preflight requests without credentials", () =>
expect(health.headers.get("access-control-allow-origin")).toBe("http://localhost:3000")
expect(yield* Effect.promise(() => health.json())).toMatchObject({ version: "test-version" })
yield* Effect.forEach(
["http://192.168.1.10:3001", "https://example.com", "https://untrusted.example.com"],
(origin) =>
Effect.gen(function* () {
const allowed = origin === "https://untrusted.example.com" ? null : origin
const preflight = yield* Effect.promise(() =>
fetch(new URL("/api/health", HttpServer.formatAddress(server.address)), {
method: "OPTIONS",
headers: {
origin,
"access-control-request-method": "GET",
"access-control-request-headers": "authorization",
},
}),
)
expect(preflight.status).toBe(204)
expect(preflight.headers.get("access-control-allow-origin")).toBe(allowed)
const health = yield* Effect.promise(() =>
fetch(new URL("/api/health", HttpServer.formatAddress(server.address)), {
headers: { origin, authorization: `Basic ${btoa("opencode:secret")}` },
}),
)
expect(health.status).toBe(200)
expect(health.headers.get("access-control-allow-origin")).toBe(allowed)
yield* Effect.promise(() => health.arrayBuffer())
const denied = yield* Effect.promise(() =>
fetch(new URL("/api/health", HttpServer.formatAddress(server.address)), { headers: { origin } }),
)
expect(denied.status).toBe(401)
expect(denied.headers.get("access-control-allow-origin")).toBe(allowed)
yield* Effect.promise(() => denied.arrayBuffer())
}),
)
const event = yield* Effect.promise(() =>
fetch(new URL("/api/event", HttpServer.formatAddress(server.address)), {
headers: {

View file

@ -42,6 +42,40 @@ opencode2 service start
needed when diagnosing its lifecycle.
</Callout>
## Allow a browser origin
If a browser client on another origin cannot connect because of CORS, add the client's origin to the service configuration:
```bash
opencode2 service set cors http://192.168.1.10:3001
opencode2 service get cors
```
Use an exact HTTP or HTTPS origin, including the port when needed, without a path or trailing slash. To allow multiple
origins, pass a comma-separated list as one argument; whitespace around each origin is trimmed:
```bash
opencode2 service set cors "http://192.168.1.10:3001, https://app.example.com"
```
`service get cors` prints a JSON array. Remove the configured list with:
```bash
opencode2 service unset cors
```
Setting or unsetting service configuration stops the background service. Its next start picks up the new configuration;
use `opencode2 service start` to start it explicitly.
For a foreground server, repeat `--cors` for each additional allowed origin:
```bash
opencode2 serve --cors http://192.168.1.10:3001 --cors https://app.example.com
```
With `serve --service`, supplied `--cors` flags override the persisted list for that process. Without those flags, service
mode uses the persisted list. CORS does not change the listening address or bypass server authentication.
## Inspect the API
The `api` command uses the local service discovery and authentication flow. It accepts either an HTTP method and path or an