mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-29 21:23:33 +00:00
feat(cli): configure server CORS origins (#45544)
This commit is contained in:
parent
095ed63ea0
commit
c0d0f5f4bc
15 changed files with 363 additions and 5 deletions
|
|
@ -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)),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
|||
124
packages/cli/test/cors.test.ts
Normal file
124
packages/cli/test/cors.test.ts
Normal 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)
|
||||
},
|
||||
)
|
||||
|
|
@ -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") })
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
24
packages/server/test/cors.test.ts
Normal file
24
packages/server/test/cors.test.ts
Normal 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)
|
||||
})
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue