chore: merge v2 into service channel config

This commit is contained in:
Dax Raad 2026-07-21 10:28:58 -04:00
commit e76b29c0b4
1174 changed files with 21121 additions and 336917 deletions

View file

@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/server",
"version": "1.18.3",
"version": "1.18.4",
"private": true,
"type": "module",
"license": "MIT",

View file

@ -1,11 +1,9 @@
export * as ServerAuth from "./auth"
import { Context, Effect, Layer, Option, Redacted } from "effect"
import { all, option, string, withDefault } from "effect/Config"
import { Context, Layer, Option, Redacted } from "effect"
export type Credentials = {
password?: string
username?: string
}
export type DecodedCredentials = {
@ -19,22 +17,12 @@ export type Info = {
}
export class Config extends Context.Service<Config, Info>()("@opencode/ServerAuthConfig") {
static configLayer(input: Info) {
return Layer.succeed(this, this.of(input))
static configLayer(input: Pick<Info, "password">) {
return Layer.succeed(this, this.of({ ...input, username: "opencode" }))
}
static get layer() {
return Layer.effect(
this,
Effect.gen(function* () {
return Config.of(
yield* all({
password: string("OPENCODE_SERVER_PASSWORD").pipe(option),
username: string("OPENCODE_SERVER_USERNAME").pipe(withDefault("opencode")),
}),
)
}),
)
return this.configLayer({ password: Option.none() })
}
}
@ -51,10 +39,10 @@ export function authorized(credentials: DecodedCredentials, config: Info) {
}
export function header(credentials?: Credentials) {
const password = credentials?.password ?? process.env.OPENCODE_SERVER_PASSWORD
const password = credentials?.password
if (!password) return undefined
return `Basic ${Buffer.from(`${credentials?.username ?? process.env.OPENCODE_SERVER_USERNAME ?? "opencode"}:${password}`).toString("base64")}`
return `Basic ${Buffer.from(`opencode:${password}`).toString("base64")}`
}
export function headers(credentials?: Credentials) {

View file

@ -0,0 +1,37 @@
import { Database } from "@opencode-ai/core/database/database"
import { ModelsDev } from "@opencode-ai/core/models-dev"
import { Observability } from "@opencode-ai/util/observability"
import { Schema } from "effect"
export const ServerOptions = Schema.Struct({
client: Schema.optional(Schema.String),
hostname: Schema.optional(Schema.String),
port: Schema.optional(
Schema.Int.check(Schema.isGreaterThanOrEqualTo(1), Schema.isLessThanOrEqualTo(65_535)),
),
password: Schema.optional(Schema.String),
simulation: Schema.optional(Schema.Boolean),
database: Schema.optional(Database.Options),
models: Schema.optional(ModelsDev.Options),
observability: Schema.optional(Observability.Options),
config: Schema.optional(
Schema.Struct({
directory: Schema.optional(Schema.String),
project: Schema.optional(Schema.Boolean),
file: Schema.optional(Schema.String),
content: Schema.optional(Schema.String),
}),
),
windows: Schema.optional(
Schema.Struct({
gitbash: Schema.optional(Schema.String),
}),
),
fs: Schema.optional(
Schema.Struct({
filewatcher: Schema.optional(Schema.Boolean),
fff: Schema.optional(Schema.Boolean),
}),
),
})
export type ServerOptions = typeof ServerOptions.Type

View file

@ -7,25 +7,23 @@ import { ServiceStatus } from "@opencode-ai/protocol/groups/health"
import { hasPtyConnectTicketURL } from "@opencode-ai/protocol/groups/pty"
import { Cause, Context, Deferred, Effect, Exit, Layer, Option, Ref, Schema, Scope } from "effect"
import { HttpMiddleware, HttpRouter, HttpServer, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import { randomUUID } from "node:crypto"
import { createServer } from "node:http"
import { ServerAuth } from "./auth"
import { isAllowedCorsOrigin } from "./cors"
import { authorizedRequest } from "./middleware/authorization"
import { withoutParentSpan } from "./request-tracing"
import { createRoutes } from "./routes"
import { ServerInfo } from "./server-info"
import { Status } from "./service-status"
import type { ServerOptions } from "./options"
export type Options<E = never, R = never> = {
readonly hostname: string
readonly port: Option.Option<number>
readonly password: string
export interface Lifecycle<E = never, R = never> {
readonly instanceID: string
readonly service?: {
readonly onListen: (
address: HttpServer.Address,
shutdown: Effect.Effect<void>,
) => Effect.Effect<Effect.Effect<void>, E, R>
}
readonly onListen: (
address: HttpServer.Address,
shutdown: Effect.Effect<void>,
) => Effect.Effect<Effect.Effect<void>, E, R>
}
type App = Effect.Effect<
@ -34,21 +32,32 @@ type App = Effect.Effect<
HttpServerRequest.HttpServerRequest | Scope.Scope
>
export const start = Effect.fn("ServerProcess.start")(function* <E, R>(options: Options<E, R>) {
if (!options.password) return yield* Effect.fail(new Error("Missing server password"))
export const start = Effect.fn("ServerProcess.start")(function* <E, R>(
options: ServerOptions,
lifecycle?: Lifecycle<E, R>,
) {
const password = options.password
if (!password) return yield* Effect.fail(new Error("Missing server password"))
const hostname = options.hostname ?? "127.0.0.1"
const port = Option.fromNullishOr(options.port)
const shutdown = yield* Deferred.make<void>()
const status = yield* Status.make({
instanceID: options.instanceID,
managed: options.service !== undefined,
instanceID: lifecycle?.instanceID ?? randomUUID(),
managed: lifecycle !== undefined,
})
const bound = yield* listen(options)
const bound = yield* listen({ hostname, port })
const application = yield* Ref.make(Option.none<App>())
// Request fibers may continue inbound trace context, but must not inherit the server startup parent.
yield* bound.http
.serve(dispatch(options.password, status, application, shutdown), HttpMiddleware.logger)
.serve(
dispatch(password, status, application, shutdown).pipe(
HttpMiddleware.cors({ allowedOrigins: isAllowedCorsOrigin, maxAge: 86_400 }),
),
HttpMiddleware.logger,
)
.pipe(withoutParentSpan)
if (options.service)
yield* options.service.onListen(bound.http.address, Deferred.succeed(shutdown, undefined).pipe(Effect.asVoid)).pipe(
if (lifecycle)
yield* lifecycle.onListen(bound.http.address, Deferred.succeed(shutdown, undefined).pipe(Effect.asVoid)).pipe(
Effect.flatMap((cleanup) =>
Effect.addFinalizer(() => Scope.close(bound.scope, Exit.void).pipe(Effect.andThen(cleanup))),
),
@ -66,15 +75,21 @@ export const start = Effect.fn("ServerProcess.start")(function* <E, R>(options:
const boot = Effect.gen(function* () {
const context = yield* Layer.buildWithScope(
createRoutes(options.password, () => {
const address = bound.server.address()
if (address === null || typeof address === "string") return []
const host = address.family === "IPv6" ? `[${address.address}]` : address.address
return ServerInfo.connectionURLs(`http://${host}:${address.port}`, options.hostname)
}).pipe(Layer.provide(NodeHttpServer.layerHttpServices)),
createRoutes(
{
...options,
password,
},
() => {
const address = bound.server.address()
if (address === null || typeof address === "string") return []
const host = address.family === "IPv6" ? `[${address.address}]` : address.address
return ServerInfo.connectionURLs(`http://${host}:${address.port}`, hostname)
},
).pipe(Layer.provide(NodeHttpServer.layerHttpServices)),
applicationScope,
)
if (options.service) {
if (lifecycle) {
yield* installRestartContinuity(Context.get(context, SessionRestart.Service)).pipe(
Effect.provideService(Scope.Scope, applicationScope),
)
@ -84,7 +99,7 @@ export const start = Effect.fn("ServerProcess.start")(function* <E, R>(options:
return { address: bound.http.address, shutdown: Deferred.await(shutdown) }
}).pipe(
Effect.catchCause((cause) => {
if (!options.service || Cause.hasInterruptsOnly(cause)) return Effect.failCause(cause)
if (!lifecycle || Cause.hasInterruptsOnly(cause)) return Effect.failCause(cause)
return status.fail.pipe(
Effect.andThen(
Scope.close(applicationScope, Exit.failCause(cause)).pipe(
@ -98,7 +113,7 @@ export const start = Effect.fn("ServerProcess.start")(function* <E, R>(options:
)
}),
)
if (!options.service) return yield* boot
if (!lifecycle) return yield* boot
return yield* Effect.raceFirst(boot, Deferred.await(shutdown).pipe(Effect.andThen(Effect.interrupt)))
})
@ -139,7 +154,7 @@ function dispatch(
application: Ref.Ref<Option.Option<App>>,
shutdown: Deferred.Deferred<void>,
): App {
const auth = ServerAuth.Config.of({ username: "opencode", password: Option.some(password) })
const auth = ServerAuth.Config.of({ password: Option.some(password), username: "opencode" })
return Effect.gen(function* () {
const request = yield* HttpServerRequest.HttpServerRequest
const url = new URL(request.url, "http://localhost")

View file

@ -4,19 +4,32 @@ import { httpClient } from "@opencode-ai/util/effect/app-node-platform"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { EventV2 } from "@opencode-ai/core/event"
import { EventLogger } from "@opencode-ai/core/event-logger"
import { FileSystemSearch } from "@opencode-ai/core/filesystem/search"
import { Observability } from "@opencode-ai/util/observability"
import { Credential } from "@opencode-ai/core/credential"
import { Config } from "@opencode-ai/core/config"
import { CommandV2 } from "@opencode-ai/core/command"
import { PermissionSaved } from "@opencode-ai/core/permission/saved"
import { PtyTicket } from "@opencode-ai/core/pty/ticket"
import { Pty } from "@opencode-ai/core/pty"
import { Project } from "@opencode-ai/core/project"
import { SessionV2 } from "@opencode-ai/core/session"
import { SessionCompaction } from "@opencode-ai/core/session/compaction"
import { SessionGenerateNode } from "@opencode-ai/core/session/generate-node"
import { SessionModelRequest } from "@opencode-ai/core/session/model-request"
import { SessionTitle } from "@opencode-ai/core/session/title"
import { Shell } from "@opencode-ai/core/shell"
import { Job } from "@opencode-ai/core/job"
import { Global } from "@opencode-ai/util/global"
import { InstructionDiscovery } from "@opencode-ai/core/instruction-discovery"
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
import { ModelsDev } from "@opencode-ai/core/models-dev"
import { SessionRestart } from "@opencode-ai/core/session/execution/restart"
import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { WellKnown } from "@opencode-ai/core/wellknown"
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
import { HttpRouter, HttpServer } from "effect/unstable/http"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { Context, Effect, Layer, Option } from "effect"
@ -30,6 +43,7 @@ import { layer } from "./location"
import { formLocationLayer } from "./middleware/form-location"
import { sessionLocationLayer } from "./middleware/session-location"
import { ServerInfo } from "./server-info"
import type { ServerOptions } from "./options"
const applicationServices = LayerNode.group([
Database.node,
@ -51,29 +65,52 @@ const applicationServices = LayerNode.group([
SessionRestart.node,
])
export function createRoutes(password?: string, serviceURLs: () => ReadonlyArray<string> = () => []) {
export function createRoutes(options: ServerOptions = {}, serviceURLs: () => ReadonlyArray<string> = () => []) {
return makeRoutes(
password
? ServerAuth.Config.configLayer({ username: "opencode", password: Option.some(password) })
options.password
? ServerAuth.Config.configLayer({ password: Option.some(options.password) })
: ServerAuth.Config.layer,
options,
serviceURLs,
)
}
export function createEmbeddedRoutes() {
return makeRoutes(ServerAuth.Config.configLayer({ username: "opencode", password: Option.none() }))
export function createEmbeddedRoutes(options: ServerOptions = {}) {
return makeRoutes(ServerAuth.Config.configLayer({ password: Option.none() }), options, () => [])
}
function makeRoutes<AuthError, AuthServices>(
auth: Layer.Layer<ServerAuth.Config, AuthError, AuthServices>,
serviceURLs: () => ReadonlyArray<string> = () => [],
options: ServerOptions,
serviceURLs: () => ReadonlyArray<string>,
) {
const pluginRuntimeCell = PluginRuntime.makeCell()
const replacements: LayerNode.Replacements = [
[Database.node, Database.configured(options.database)],
[ModelsDev.node, ModelsDev.configured({ ...options.models, client: options.client })],
[Watcher.node, Watcher.configured({ enabled: options.fs?.filewatcher })],
[FileSystemSearch.node, FileSystemSearch.configured({ fff: options.fs?.fff })],
[Global.node, Global.layerWith(options.config?.directory ? { config: options.config.directory } : {})],
[
Config.node,
Config.configured({
project: options.config?.project,
file: options.config?.file,
content: options.config?.content,
}),
],
[InstructionDiscovery.node, InstructionDiscovery.configured({ project: options.config?.project })],
[CommandV2.node, CommandV2.configured({ gitbash: options.windows?.gitbash })],
[Pty.node, Pty.configured({ gitbash: options.windows?.gitbash })],
[Shell.node, Shell.configured({ gitbash: options.windows?.gitbash })],
[SessionCompaction.node, SessionCompaction.configured({ client: options.client })],
[SessionGenerateNode.node, SessionGenerateNode.configured({ client: options.client })],
[SessionModelRequest.node, SessionModelRequest.configured({ client: options.client })],
[SessionTitle.node, SessionTitle.configured({ client: options.client })],
[PluginRuntime.node, PluginRuntime.layerWithCell(pluginRuntimeCell)],
[PluginRuntime.providerNode, PluginRuntime.providerNodeWithCell(pluginRuntimeCell)],
]
const serviceLayer = simulateEnabled()
const serviceLayer = options.simulation
? Layer.unwrap(
Effect.gen(function* () {
const { simulationReplacements } = yield* Effect.promise(() => import("@opencode-ai/simulation/backend"))
@ -98,7 +135,7 @@ function makeRoutes<AuthError, AuthServices>(
Layer.provide(authorizationLayer),
Layer.provide(schemaErrorLayer),
Layer.provide(auth),
Layer.provide(Observability.layer),
Layer.provide(Observability.layer({ ...options.observability, client: options.client })),
HttpRouter.provideRequest(requestServices),
Layer.provideMerge(services),
Layer.provideMerge(HttpRouter.layer),
@ -107,8 +144,4 @@ function makeRoutes<AuthError, AuthServices>(
)
}
function simulateEnabled() {
return !!process.env.OPENCODE_SIMULATE
}
export const webHandler = () => HttpRouter.toWebHandler(createRoutes().pipe(Layer.provide(HttpServer.layerServices)))

View file

@ -0,0 +1,13 @@
import { expect, test } from "bun:test"
import { ServerAuth } from "@opencode-ai/server/auth"
import { Option, Redacted } from "effect"
test("accepts only the fixed opencode username", () => {
const config = { password: Option.some("secret"), username: "opencode" }
expect(ServerAuth.authorized({ username: "opencode", password: Redacted.make("secret") }, config)).toBe(true)
expect(ServerAuth.authorized({ username: "custom", password: Redacted.make("secret") }, config)).toBe(false)
})
test("encodes the fixed opencode username", () => {
expect(ServerAuth.header({ password: "secret" })).toBe(`Basic ${Buffer.from("opencode:secret").toString("base64")}`)
})

View file

@ -0,0 +1,42 @@
import { expect } from "bun:test"
import { Effect } from "effect"
import { HttpServer } from "effect/unstable/http"
import { it } from "../../core/test/lib/effect"
import { ServerProcess } from "../src/process"
it.live("allows browser preflight requests without credentials", () =>
Effect.gen(function* () {
const server = yield* ServerProcess.start<never, never>({
hostname: "127.0.0.1",
port: 0,
password: "secret",
database: { path: ":memory:" },
})
const response = yield* Effect.promise(() =>
fetch(new URL("/api/health", HttpServer.formatAddress(server.address)), {
method: "OPTIONS",
headers: {
origin: "http://localhost:3000",
"access-control-request-method": "GET",
"access-control-request-headers": "authorization",
},
}),
)
expect(response.status).toBe(204)
expect(response.headers.get("access-control-allow-origin")).toBe("http://localhost:3000")
expect(response.headers.get("access-control-allow-headers")).toBe("authorization")
const health = yield* Effect.promise(() =>
fetch(new URL("/api/health", HttpServer.formatAddress(server.address)), {
headers: {
authorization: `Basic ${btoa("opencode:secret")}`,
origin: "http://localhost:3000",
},
}),
)
expect(health.status).toBe(200)
expect(health.headers.get("access-control-allow-origin")).toBe("http://localhost:3000")
}),
)