chore: merge v2 into service channel config
This commit is contained in:
commit
e76b29c0b4
1174 changed files with 21121 additions and 336917 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
37
packages/server/src/options.ts
Normal file
37
packages/server/src/options.ts
Normal 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
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)))
|
||||
|
|
|
|||
13
packages/server/test/auth.test.ts
Normal file
13
packages/server/test/auth.test.ts
Normal 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")}`)
|
||||
})
|
||||
42
packages/server/test/process.test.ts
Normal file
42
packages/server/test/process.test.ts
Normal 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")
|
||||
}),
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue