diff --git a/packages/cli/src/commands/commands.ts b/packages/cli/src/commands/commands.ts index 30304d04617..23937d8292b 100644 --- a/packages/cli/src/commands/commands.ts +++ b/packages/cli/src/commands/commands.ts @@ -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)), }, diff --git a/packages/cli/src/commands/handlers/serve.ts b/packages/cli/src/commands/handlers/serve.ts index 0e4343f916e..5e1337a16ae 100644 --- a/packages/cli/src/commands/handlers/serve.ts +++ b/packages/cli/src/commands/handlers/serve.ts @@ -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, }) }), ) diff --git a/packages/cli/src/server-process.ts b/packages/cli/src/server-process.ts index 8a1dcda2f6b..c81e4b44b0d 100644 --- a/packages/cli/src/server-process.ts +++ b/packages/cli/src/server-process.ts @@ -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), diff --git a/packages/cli/src/services/service-config.ts b/packages/cli/src/services/service-config.ts index df723295db1..ba4512a8c79 100644 --- a/packages/cli/src/services/service-config.ts +++ b/packages/cli/src/services/service-config.ts @@ -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 + } } }) diff --git a/packages/cli/test/cors.test.ts b/packages/cli/test/cors.test.ts new file mode 100644 index 00000000000..5adab448fbc --- /dev/null +++ b/packages/cli/test/cors.test.ts @@ -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) + }, +) diff --git a/packages/cli/test/service.test.ts b/packages/cli/test/service.test.ts index 88b9f5434e3..00c7c9a97d0 100644 --- a/packages/cli/test/service.test.ts +++ b/packages/cli/test/service.test.ts @@ -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") }) diff --git a/packages/server/src/fetch.ts b/packages/server/src/fetch.ts index 4781be4fb05..2dbf7f36c1b 100644 --- a/packages/server/src/fetch.ts +++ b/packages/server/src/fetch.ts @@ -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), ) }) diff --git a/packages/server/src/options.ts b/packages/server/src/options.ts index b45b52ed590..c7bd5ef0b88 100644 --- a/packages/server/src/options.ts +++ b/packages/server/src/options.ts @@ -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), diff --git a/packages/server/src/process.ts b/packages/server/src/process.ts index 0e726b482b6..bb25cbd5f3c 100644 --- a/packages/server/src/process.ts +++ b/packages/server/src/process.ts @@ -63,7 +63,7 @@ export const start = Effect.fn("ServerProcess.start")(function* ( 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, ) diff --git a/packages/server/src/routes.ts b/packages/server/src/routes.ts index 7c065f01673..9d2ee4a7c27 100644 --- a/packages/server/src/routes.ts +++ b/packages/server/src/routes.ts @@ -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( 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), diff --git a/packages/server/test/cors.test.ts b/packages/server/test/cors.test.ts new file mode 100644 index 00000000000..cec0a4f2413 --- /dev/null +++ b/packages/server/test/cors.test.ts @@ -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) +}) diff --git a/packages/server/test/fetch.test.ts b/packages/server/test/fetch.test.ts index a54f2d04652..c7a3cc98d37 100644 --- a/packages/server/test/fetch.test.ts +++ b/packages/server/test/fetch.test.ts @@ -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) diff --git a/packages/server/test/options.test.ts b/packages/server/test/options.test.ts index a16a20665e0..d41327b7121 100644 --- a/packages/server/test/options.test.ts +++ b/packages/server/test/options.test.ts @@ -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) +}) diff --git a/packages/server/test/process.test.ts b/packages/server/test/process.test.ts index 9deabc24ea0..96f4f92b851 100644 --- a/packages/server/test/process.test.ts +++ b/packages/server/test/process.test.ts @@ -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: { diff --git a/packages/www/src/docs/content/troubleshooting.mdx b/packages/www/src/docs/content/troubleshooting.mdx index ab99994853e..e26f9447f35 100644 --- a/packages/www/src/docs/content/troubleshooting.mdx +++ b/packages/www/src/docs/content/troubleshooting.mdx @@ -42,6 +42,40 @@ opencode2 service start needed when diagnosing its lifecycle. +## 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