Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fdfe599cb5 | ||
|
|
6df566161e | ||
|
|
62e1335388 | ||
|
|
908e28175f | ||
|
|
3398fd7719 | ||
|
|
9bddf7f3ef | ||
|
|
8ba374fefa | ||
|
|
3ef0aaf768 | ||
|
|
d7701dbfb6 | ||
|
|
c49bf0b402 | ||
|
|
cee9610d26 | ||
|
|
38adc13295 | ||
|
|
4fe14abb8c | ||
|
|
9052e8a1ba | ||
|
|
de78dedceb | ||
|
|
6f508d574e |
@@ -16,6 +16,7 @@ ariane-emory
|
||||
-danieljoshuanazareth
|
||||
-danieljoshuanazareth
|
||||
-davidbernat looks to be a clawdbot that spams team and sends super weird emails, doesnt appear to be a real person
|
||||
dmtrkovalenko
|
||||
edemaine
|
||||
fahreddinozcan
|
||||
-florianleibert
|
||||
|
||||
@@ -88,7 +88,7 @@ jobs:
|
||||
- name: Build
|
||||
id: build
|
||||
run: |
|
||||
./packages/opencode/script/build.ts
|
||||
./packages/opencode/script/build.ts ${{ (github.ref_name == 'beta' && '--sourcemaps') || '' }}
|
||||
env:
|
||||
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
|
||||
OPENCODE_RELEASE: ${{ needs.version.outputs.release }}
|
||||
|
||||
@@ -28,3 +28,11 @@ Use the current Effect v4 / effect-smol source, not memory or older Effect v2/v3
|
||||
- In tests, prefer the repo's existing Effect test helpers and live tests for filesystem, git, child process, locks, or timing behavior.
|
||||
- Do not introduce `any`, non-null assertions, unchecked casts, or older Effect APIs just to satisfy types.
|
||||
- Do not answer from memory. Verify against `.opencode/references/effect-smol` or nearby code first.
|
||||
|
||||
## Testing Patterns
|
||||
|
||||
- Use `testEffect(...)` from `packages/opencode/test/lib/effect.ts` for tests that exercise Effect services, layers, runtime context, scoped resources, or platform integrations.
|
||||
- Use `it.live(...)` for filesystem, git repositories, HTTP servers, sockets, child processes, locks, real time, and other live platform behavior.
|
||||
- Run tests from package directories such as `packages/opencode`; never run package tests from the repo root.
|
||||
- Prefer explicit test layers over ad hoc managed runtimes. Keep dependency provisioning visible in the test file.
|
||||
- Use scoped fixtures and finalizers for resources that must be cleaned up, including temporary directories, flags, databases, fibers, servers, and global state.
|
||||
|
||||
@@ -187,7 +187,7 @@ export function createChildStoreManager(input: {
|
||||
projectMeta: initialMeta,
|
||||
icon: initialIcon,
|
||||
get provider_ready() {
|
||||
return providerQuery.isLoading
|
||||
return !providerQuery.isLoading
|
||||
},
|
||||
provider: { all: [], connected: [], default: {} },
|
||||
config: {},
|
||||
@@ -207,13 +207,13 @@ export function createChildStoreManager(input: {
|
||||
permission: {},
|
||||
question: {},
|
||||
get mcp_ready() {
|
||||
return mcpQuery.isLoading
|
||||
return !mcpQuery.isLoading
|
||||
},
|
||||
get mcp() {
|
||||
return mcpQuery.isLoading ? {} : (mcpQuery.data ?? {})
|
||||
},
|
||||
get lsp_ready() {
|
||||
return lspQuery.isLoading
|
||||
return !lspQuery.isLoading
|
||||
},
|
||||
get lsp() {
|
||||
return lspQuery.isLoading ? [] : (lspQuery.data ?? [])
|
||||
|
||||
@@ -382,7 +382,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
setSaved("session", session, {
|
||||
agent: msg.agent,
|
||||
model: msg.model,
|
||||
variant: msg.model.variant ?? null,
|
||||
variant: msg.model?.variant ?? null,
|
||||
})
|
||||
},
|
||||
},
|
||||
|
||||
@@ -50,6 +50,7 @@ console.log(`Loaded ${migrations.length} migrations`)
|
||||
const singleFlag = process.argv.includes("--single")
|
||||
const baselineFlag = process.argv.includes("--baseline")
|
||||
const skipInstall = process.argv.includes("--skip-install")
|
||||
const sourcemapsFlag = process.argv.includes("--sourcemaps")
|
||||
const plugin = createSolidTransformPlugin()
|
||||
const skipEmbedWebUi = process.argv.includes("--skip-embed-web-ui")
|
||||
|
||||
@@ -199,6 +200,7 @@ for (const item of targets) {
|
||||
external: ["node-gyp"],
|
||||
format: "esm",
|
||||
minify: true,
|
||||
sourcemap: sourcemapsFlag ? "linked" : "none",
|
||||
splitting: true,
|
||||
compile: {
|
||||
autoloadBunfig: false,
|
||||
|
||||
@@ -156,28 +156,38 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string,
|
||||
}
|
||||
|
||||
if (method.type === "api") {
|
||||
if (method.authorize) {
|
||||
const key = await prompts.password({
|
||||
message: "Enter your API key",
|
||||
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
|
||||
})
|
||||
if (prompts.isCancel(key)) throw new UI.CancelledError()
|
||||
const key = await prompts.password({
|
||||
message: "Enter your API key",
|
||||
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
|
||||
})
|
||||
if (prompts.isCancel(key)) throw new UI.CancelledError()
|
||||
|
||||
const result = await method.authorize(inputs)
|
||||
if (result.type === "failed") {
|
||||
prompts.log.error("Failed to authorize")
|
||||
}
|
||||
if (result.type === "success") {
|
||||
const saveProvider = result.provider ?? provider
|
||||
await put(saveProvider, {
|
||||
type: "api",
|
||||
key: result.key ?? key,
|
||||
})
|
||||
prompts.log.success("Login successful")
|
||||
}
|
||||
const metadata = Object.keys(inputs).length ? { metadata: inputs } : {}
|
||||
if (!method.authorize) {
|
||||
await put(provider, {
|
||||
type: "api",
|
||||
key,
|
||||
...metadata,
|
||||
})
|
||||
prompts.outro("Done")
|
||||
return true
|
||||
}
|
||||
|
||||
const result = await method.authorize(inputs)
|
||||
if (result.type === "failed") {
|
||||
prompts.log.error("Failed to authorize")
|
||||
}
|
||||
if (result.type === "success") {
|
||||
const saveProvider = result.provider ?? provider
|
||||
await put(saveProvider, {
|
||||
type: "api",
|
||||
key: result.key ?? key,
|
||||
...metadata,
|
||||
})
|
||||
prompts.log.success("Login successful")
|
||||
}
|
||||
prompts.outro("Done")
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
|
||||
@@ -12,22 +12,7 @@ export function useEvent() {
|
||||
return
|
||||
}
|
||||
|
||||
// Special hack for truly global events
|
||||
if (event.directory === "global") {
|
||||
handler(event.payload)
|
||||
}
|
||||
|
||||
if (project.workspace.current()) {
|
||||
if (event.workspace === project.workspace.current()) {
|
||||
handler(event.payload)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (event.directory === project.instance.directory()) {
|
||||
handler(event.payload)
|
||||
}
|
||||
handler(event.payload)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { Config, Context, Effect, Layer } from "effect"
|
||||
|
||||
type ConfigMap = Record<string, Config.Config<unknown>>
|
||||
|
||||
/**
|
||||
* The service shape inferred from an object of Effect `Config` definitions.
|
||||
*/
|
||||
export type Shape<Fields extends ConfigMap> = {
|
||||
readonly [Key in keyof Fields]: Config.Success<Fields[Key]>
|
||||
}
|
||||
|
||||
/**
|
||||
* A Context service class with generated layers for config-backed services.
|
||||
*/
|
||||
export type ServiceClass<Self, Id extends string, Service> = Context.ServiceClass<Self, Id, Service> & {
|
||||
/** Provide already-parsed config, useful in tests. */
|
||||
readonly layer: (input: Service) => Layer.Layer<Self>
|
||||
/** Parse config once from the active Effect ConfigProvider and provide the service. */
|
||||
readonly defaultLayer: Layer.Layer<Self, Config.ConfigError>
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Context service whose implementation is derived from Effect `Config`.
|
||||
*
|
||||
* This keeps Effect `Config` as the source of truth for env names, defaults, and
|
||||
* validation while generating a typed service plus convenient production/test
|
||||
* layers.
|
||||
*
|
||||
* ```ts
|
||||
* class ServerAuthConfig extends ConfigService.Service<ServerAuthConfig>()(
|
||||
* "@opencode/ServerAuthConfig",
|
||||
* {
|
||||
* password: Config.string("OPENCODE_SERVER_PASSWORD").pipe(Config.option),
|
||||
* username: Config.string("OPENCODE_SERVER_USERNAME").pipe(Config.withDefault("opencode")),
|
||||
* },
|
||||
* ) {}
|
||||
*
|
||||
* const live = ServerAuthConfig.defaultLayer
|
||||
* const test = ServerAuthConfig.layer({ password: Option.some("secret"), username: "kit" })
|
||||
* ```
|
||||
*/
|
||||
export const Service =
|
||||
<Self>() =>
|
||||
<const Id extends string, const Fields extends ConfigMap>(id: Id, fields: Fields) => {
|
||||
class ConfigTag extends Context.Service<Self, Shape<Fields>>()(id) {
|
||||
static layer(input: Shape<Fields>) {
|
||||
return Layer.succeed(this, this.of(input))
|
||||
}
|
||||
|
||||
static get defaultLayer() {
|
||||
return Layer.effect(
|
||||
this,
|
||||
Config.all(fields)
|
||||
.asEffect()
|
||||
.pipe(
|
||||
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- Config.all preserves the field shape, but its conditional return type also supports iterable inputs.
|
||||
Effect.map((config) => this.of(config as Shape<Fields>)),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- The generated class carries typed static helpers.
|
||||
return ConfigTag as ServiceClass<Self, Id, Shape<Fields>>
|
||||
}
|
||||
|
||||
export * as ConfigService from "./config-service"
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Context, Effect } from "effect"
|
||||
|
||||
type EffectMethod = (...args: ReadonlyArray<never>) => Effect.Effect<unknown, unknown, unknown>
|
||||
|
||||
type ServiceUse<Identifier, Shape> = {
|
||||
readonly [Key in keyof Shape as Shape[Key] extends EffectMethod ? Key : never]: Shape[Key] extends (
|
||||
...args: infer Args
|
||||
) => infer Return
|
||||
? Args extends ReadonlyArray<unknown>
|
||||
? Return extends Effect.Effect<infer A, infer E, infer R>
|
||||
? (...args: Args) => Effect.Effect<A, E, R | Identifier>
|
||||
: never
|
||||
: never
|
||||
: never
|
||||
}
|
||||
|
||||
export const serviceUse = <Identifier, Shape>(tag: Context.Service<Identifier, Shape>) => {
|
||||
// This is the only dynamic boundary: TypeScript knows the accessor shape,
|
||||
// but Proxy property names are runtime values.
|
||||
const access = new Proxy(
|
||||
{},
|
||||
{
|
||||
get: (_, key) => {
|
||||
if (typeof key !== "string") return undefined
|
||||
return (...args: unknown[]) =>
|
||||
tag.use((service) => {
|
||||
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- Proxy keys are checked at runtime.
|
||||
const method = service[key as keyof Shape]
|
||||
if (typeof method !== "function") return Effect.die(new Error(`Service method not found: ${key}`))
|
||||
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- ServiceUse exposes only Effect-returning methods.
|
||||
return (method as (...args: unknown[]) => Effect.Effect<unknown, unknown, unknown>)(...args)
|
||||
})
|
||||
},
|
||||
},
|
||||
)
|
||||
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- Proxy implements the mapped accessor surface lazily.
|
||||
return access as ServiceUse<Identifier, Shape>
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { Hooks, PluginInput } from "@opencode-ai/plugin"
|
||||
|
||||
export async function AzureAuthPlugin(_input: PluginInput): Promise<Hooks> {
|
||||
const prompts = []
|
||||
if (!process.env.AZURE_RESOURCE_NAME) {
|
||||
prompts.push({
|
||||
type: "text" as const,
|
||||
key: "resourceName",
|
||||
message: "Enter Azure Resource Name",
|
||||
placeholder: "e.g. my-models",
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
auth: {
|
||||
provider: "azure",
|
||||
methods: [
|
||||
{
|
||||
type: "api",
|
||||
label: "API key",
|
||||
prompts,
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import { CopilotAuthPlugin } from "./github-copilot/copilot"
|
||||
import { gitlabAuthPlugin as GitlabAuthPlugin } from "opencode-gitlab-auth"
|
||||
import { PoeAuthPlugin } from "opencode-poe-auth"
|
||||
import { CloudflareAIGatewayAuthPlugin, CloudflareWorkersAuthPlugin } from "./cloudflare"
|
||||
import { AzureAuthPlugin } from "./azure"
|
||||
import { Effect, Layer, Context, Stream } from "effect"
|
||||
import { EffectBridge } from "@/effect/bridge"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
@@ -61,6 +62,7 @@ const INTERNAL_PLUGINS: PluginInstance[] = [
|
||||
PoeAuthPlugin,
|
||||
CloudflareWorkersAuthPlugin,
|
||||
CloudflareAIGatewayAuthPlugin,
|
||||
AzureAuthPlugin,
|
||||
]
|
||||
|
||||
function isServerPlugin(value: unknown): value is PluginInstance {
|
||||
|
||||
@@ -17,6 +17,7 @@ import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { NonNegativeInt, withStatics } from "@/util/schema"
|
||||
import { serviceUse } from "@/effect/service-use"
|
||||
|
||||
const log = Log.create({ service: "project" })
|
||||
|
||||
@@ -178,7 +179,7 @@ export const layer: Layer.Layer<
|
||||
const readCachedProjectId = Effect.fnUntraced(function* (dir: string) {
|
||||
return yield* fs.readFileString(pathSvc.join(dir, "opencode")).pipe(
|
||||
Effect.map((x) => x.trim()),
|
||||
Effect.map(ProjectID.make),
|
||||
Effect.map((x) => ProjectID.make(x)),
|
||||
Effect.catch(() => Effect.void),
|
||||
)
|
||||
})
|
||||
@@ -485,6 +486,8 @@ export const defaultLayer = layer.pipe(
|
||||
Layer.provide(NodePath.layer),
|
||||
)
|
||||
|
||||
export const use = serviceUse(Service)
|
||||
|
||||
export function list() {
|
||||
return Database.use((db) =>
|
||||
db
|
||||
|
||||
@@ -199,12 +199,26 @@ function custom(dep: CustomDep): Record<string, CustomLoader> {
|
||||
}),
|
||||
azure: Effect.fnUntraced(function* (provider: Info) {
|
||||
const env = yield* dep.env()
|
||||
const auth = yield* dep.auth(provider.id)
|
||||
const resource = iife(() => {
|
||||
const name = provider.options?.resourceName
|
||||
if (typeof name === "string" && name.trim() !== "") return name
|
||||
return env["AZURE_RESOURCE_NAME"]
|
||||
return [
|
||||
provider.options?.resourceName,
|
||||
auth?.type === "api" ? auth.metadata?.resourceName : undefined,
|
||||
env["AZURE_RESOURCE_NAME"],
|
||||
].find((name) => typeof name === "string" && name.trim() !== "")
|
||||
})
|
||||
|
||||
if (!resource && !provider.options?.baseURL) {
|
||||
return {
|
||||
autoload: false,
|
||||
async getModel() {
|
||||
throw new Error(
|
||||
"AZURE_RESOURCE_NAME is missing, set it using env var or reconnecting the azure provider and setting it",
|
||||
)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
autoload: false,
|
||||
async getModel(sdk: any, modelID: string, options?: Record<string, any>) {
|
||||
@@ -215,11 +229,16 @@ function custom(dep: CustomDep): Record<string, CustomLoader> {
|
||||
return sdk.responses(modelID)
|
||||
}
|
||||
},
|
||||
options: {},
|
||||
vars(_options) {
|
||||
return {
|
||||
...(resource && { AZURE_RESOURCE_NAME: resource }),
|
||||
options: {
|
||||
resourceName: resource,
|
||||
},
|
||||
vars(_options): Record<string, string> {
|
||||
if (resource) {
|
||||
return {
|
||||
AZURE_RESOURCE_NAME: resource,
|
||||
}
|
||||
}
|
||||
return {}
|
||||
},
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -74,6 +74,7 @@ export function CorsMiddleware(opts?: { cors?: string[] }): MiddlewareHandler {
|
||||
|
||||
if (input.startsWith("http://localhost:")) return input
|
||||
if (input.startsWith("http://127.0.0.1:")) return input
|
||||
if (input.startsWith("oc://renderer")) return input
|
||||
if (input === "tauri://localhost" || input === "http://tauri.localhost" || input === "https://tauri.localhost")
|
||||
return input
|
||||
|
||||
|
||||
@@ -1,17 +1,11 @@
|
||||
import { Effect, Encoding, Layer, Redacted, Schema } from "effect"
|
||||
import { HttpApiMiddleware, HttpApiSecurity } from "effect/unstable/httpapi"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
|
||||
class Unauthorized extends Schema.TaggedErrorClass<Unauthorized>()(
|
||||
"Unauthorized",
|
||||
{ message: Schema.String },
|
||||
{ httpApiStatus: 401 },
|
||||
) {}
|
||||
import { ConfigService } from "@/effect/config-service"
|
||||
import { Config, Context, Effect, Encoding, Layer, Option, Redacted } from "effect"
|
||||
import { HttpApiError, HttpApiMiddleware, HttpApiSecurity } from "effect/unstable/httpapi"
|
||||
|
||||
export class Authorization extends HttpApiMiddleware.Service<Authorization>()(
|
||||
"@opencode/ExperimentalHttpApiAuthorization",
|
||||
{
|
||||
error: Unauthorized,
|
||||
error: HttpApiError.UnauthorizedNoContent,
|
||||
security: {
|
||||
basic: HttpApiSecurity.basic,
|
||||
authToken: HttpApiSecurity.apiKey({ in: "query", key: "auth_token" }),
|
||||
@@ -19,29 +13,38 @@ export class Authorization extends HttpApiMiddleware.Service<Authorization>()(
|
||||
},
|
||||
) {}
|
||||
|
||||
const emptyCredential = {
|
||||
username: "",
|
||||
password: Redacted.make(""),
|
||||
}
|
||||
export class ServerAuthConfig extends ConfigService.Service<ServerAuthConfig>()(
|
||||
"@opencode/ExperimentalHttpApiServerAuthConfig",
|
||||
{
|
||||
password: Config.string("OPENCODE_SERVER_PASSWORD").pipe(Config.option),
|
||||
username: Config.string("OPENCODE_SERVER_USERNAME").pipe(Config.withDefault("opencode")),
|
||||
},
|
||||
) {}
|
||||
|
||||
function validateCredential<A, E, R>(
|
||||
effect: Effect.Effect<A, E, R>,
|
||||
credential: { readonly username: string; readonly password: typeof emptyCredential.password },
|
||||
credential: { readonly username: string; readonly password: Redacted.Redacted },
|
||||
config: Context.Service.Shape<typeof ServerAuthConfig>,
|
||||
) {
|
||||
return Effect.gen(function* () {
|
||||
if (!Flag.OPENCODE_SERVER_PASSWORD) return yield* effect
|
||||
if (Option.isNone(config.password) || config.password.value === "") return yield* effect
|
||||
|
||||
if (credential.username !== (Flag.OPENCODE_SERVER_USERNAME ?? "opencode")) {
|
||||
return yield* new Unauthorized({ message: "Unauthorized" })
|
||||
if (credential.username !== config.username) {
|
||||
return yield* new HttpApiError.Unauthorized({})
|
||||
}
|
||||
if (Redacted.value(credential.password) !== Flag.OPENCODE_SERVER_PASSWORD) {
|
||||
return yield* new Unauthorized({ message: "Unauthorized" })
|
||||
if (Redacted.value(credential.password) !== config.password.value) {
|
||||
return yield* new HttpApiError.Unauthorized({})
|
||||
}
|
||||
return yield* effect
|
||||
})
|
||||
}
|
||||
|
||||
function decodeCredential(input: string) {
|
||||
const emptyCredential = {
|
||||
username: "",
|
||||
password: Redacted.make(""),
|
||||
}
|
||||
|
||||
return Encoding.decodeBase64String(input)
|
||||
.asEffect()
|
||||
.pipe(
|
||||
@@ -59,13 +62,16 @@ function decodeCredential(input: string) {
|
||||
)
|
||||
}
|
||||
|
||||
export const authorizationLayer = Layer.succeed(
|
||||
export const authorizationLayer = Layer.effect(
|
||||
Authorization,
|
||||
Authorization.of({
|
||||
basic: (effect, { credential }) => validateCredential(effect, credential),
|
||||
authToken: (effect, { credential }) =>
|
||||
Effect.gen(function* () {
|
||||
return yield* validateCredential(effect, yield* decodeCredential(Redacted.value(credential)))
|
||||
}),
|
||||
Effect.gen(function* () {
|
||||
const config = yield* ServerAuthConfig
|
||||
return Authorization.of({
|
||||
basic: (effect, { credential }) => validateCredential(effect, credential, config),
|
||||
authToken: (effect, { credential }) =>
|
||||
decodeCredential(Redacted.value(credential)).pipe(
|
||||
Effect.flatMap((decoded) => validateCredential(effect, decoded, config)),
|
||||
),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -32,7 +32,7 @@ import { lazy } from "@/util/lazy"
|
||||
import { Vcs } from "@/project/vcs"
|
||||
import { Worktree } from "@/worktree"
|
||||
import { InstanceHttpApi, RootHttpApi } from "./api"
|
||||
import { authorizationLayer } from "./middleware/authorization"
|
||||
import { ServerAuthConfig, authorizationLayer } from "./middleware/authorization"
|
||||
import { eventRoute } from "./event"
|
||||
import { configHandlers } from "./handlers/config"
|
||||
import { controlHandlers } from "./handlers/control"
|
||||
@@ -55,8 +55,9 @@ import { workspaceRouterMiddleware, workspaceRoutingLayer } from "./middleware/w
|
||||
import { disposeMiddleware } from "./lifecycle"
|
||||
import { memoMap } from "@opencode-ai/core/effect/memo-map"
|
||||
import * as ServerBackend from "@/server/backend"
|
||||
import type { Predicate } from "effect/Predicate"
|
||||
|
||||
export const context = Context.empty() as Context.Context<unknown>
|
||||
export const context = Context.makeUnsafe<unknown>(new Map())
|
||||
|
||||
const runtime = HttpRouter.middleware()(
|
||||
Effect.succeed((effect) =>
|
||||
@@ -97,13 +98,30 @@ const rawInstanceRoutes = Layer.mergeAll(eventRoute, ptyConnectRoute).pipe(
|
||||
)
|
||||
const instanceRoutes = Layer.mergeAll(rawInstanceRoutes, instanceApiRoutes).pipe(
|
||||
Layer.provide([
|
||||
authorizationLayer,
|
||||
authorizationLayer.pipe(Layer.provide(ServerAuthConfig.defaultLayer)),
|
||||
workspaceRoutingLayer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal)),
|
||||
instanceContextLayer,
|
||||
]),
|
||||
)
|
||||
|
||||
export const routes = Layer.mergeAll(rootApiRoutes, instanceRoutes).pipe(
|
||||
Layer.provide(
|
||||
HttpRouter.cors({
|
||||
maxAge: 86_400,
|
||||
allowedOrigins: ((input) => {
|
||||
return (
|
||||
!input ||
|
||||
input.startsWith("http://localhost:") ||
|
||||
input.startsWith("http://127.0.0.1:") ||
|
||||
input.startsWith("oc://renderer") ||
|
||||
input === "tauri://localhost" ||
|
||||
input === "http://tauri.localhost" ||
|
||||
input === "https://tauri.localhost" ||
|
||||
/^https:\/\/([a-z0-9-]+\.)*opencode\.ai$/.test(input)
|
||||
)
|
||||
}) as Predicate<string> as any,
|
||||
}),
|
||||
),
|
||||
Layer.provide([
|
||||
runtime,
|
||||
Account.defaultLayer,
|
||||
|
||||
@@ -48,14 +48,14 @@ export function workspaceProxyURL(target: string | URL, requestURL: URL) {
|
||||
return proxyURL
|
||||
}
|
||||
|
||||
async function getSessionWorkspace(url: URL) {
|
||||
async function getSession(url: URL) {
|
||||
const id = getWorkspaceRouteSessionID(url)
|
||||
if (!id) return null
|
||||
|
||||
const session = await AppRuntime.runPromise(
|
||||
Session.Service.use((svc) => svc.get(id)).pipe(Effect.withSpan("WorkspaceRouter.lookup")),
|
||||
).catch(() => undefined)
|
||||
return session?.workspaceID
|
||||
return session
|
||||
}
|
||||
|
||||
export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): MiddlewareHandler {
|
||||
@@ -64,10 +64,20 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware
|
||||
return async (c, next) => {
|
||||
const url = new URL(c.req.url)
|
||||
|
||||
const sessionWorkspaceID = await getSessionWorkspace(url)
|
||||
const workspaceID = sessionWorkspaceID || url.searchParams.get("workspace")
|
||||
const session = await getSession(url)
|
||||
const workspaceID = session?.workspaceID || url.searchParams.get("workspace")
|
||||
|
||||
if (!workspaceID || url.pathname.startsWith("/console") || Flag.OPENCODE_WORKSPACE_ID) {
|
||||
if (session) {
|
||||
return Instance.provide({
|
||||
directory: session.directory,
|
||||
init: () => AppRuntime.runPromise(InstanceBootstrap),
|
||||
async fn() {
|
||||
return next()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return next()
|
||||
}
|
||||
|
||||
|
||||
@@ -64,12 +64,16 @@ export const TaskTool = Tool.define(
|
||||
const session = taskID
|
||||
? yield* sessions.get(SessionID.make(taskID)).pipe(Effect.catchCause(() => Effect.succeed(undefined)))
|
||||
: undefined
|
||||
const parent = yield* sessions.get(ctx.sessionID)
|
||||
const nextSession =
|
||||
session ??
|
||||
(yield* sessions.create({
|
||||
parentID: ctx.sessionID,
|
||||
title: params.description + ` (@${next.name} subagent)`,
|
||||
permission: [
|
||||
...(parent.permission ?? []).filter(
|
||||
(rule) => rule.permission === "external_directory" || rule.action === "deny",
|
||||
),
|
||||
...(canTodo
|
||||
? []
|
||||
: [
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Config, ConfigProvider, Context, Effect, Layer, Option } from "effect"
|
||||
import { ConfigService } from "../../src/effect/config-service"
|
||||
import { it } from "../lib/effect"
|
||||
|
||||
class TestConfig extends ConfigService.Service<TestConfig>()("@test/ConfigService", {
|
||||
name: Config.string("NAME"),
|
||||
token: Config.string("TOKEN").pipe(Config.option),
|
||||
port: Config.number("PORT").pipe(Config.withDefault(3000)),
|
||||
}) {}
|
||||
|
||||
const fromConfig = (input: Record<string, unknown>) =>
|
||||
TestConfig.defaultLayer.pipe(Layer.provide(ConfigProvider.layer(ConfigProvider.fromUnknown(input))))
|
||||
|
||||
const readConfig = TestConfig.useSync((config) => config)
|
||||
|
||||
describe("ConfigService", () => {
|
||||
it.effect("defaultLayer parses values from the active ConfigProvider", () =>
|
||||
Effect.gen(function* () {
|
||||
const config = yield* readConfig.pipe(
|
||||
Effect.provide(
|
||||
fromConfig({
|
||||
NAME: "kit",
|
||||
TOKEN: "secret",
|
||||
PORT: "4096",
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
expect(config.name).toBe("kit")
|
||||
expect(config.token).toEqual(Option.some("secret"))
|
||||
expect(config.port).toBe(4096)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("defaultLayer applies Effect Config defaults", () =>
|
||||
Effect.gen(function* () {
|
||||
const config = yield* readConfig.pipe(Effect.provide(fromConfig({ NAME: "kit" })))
|
||||
|
||||
expect(config.name).toBe("kit")
|
||||
expect(config.token).toEqual(Option.none())
|
||||
expect(config.port).toBe(3000)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("layer provides an already parsed service value", () =>
|
||||
Effect.gen(function* () {
|
||||
const config = yield* readConfig.pipe(
|
||||
Effect.provide(
|
||||
TestConfig.layer({
|
||||
name: "direct",
|
||||
token: Option.some("parsed"),
|
||||
port: 9000,
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
expect(config).toEqual({
|
||||
name: "direct",
|
||||
token: Option.some("parsed"),
|
||||
port: 9000,
|
||||
} satisfies Context.Service.Shape<typeof TestConfig>)
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -115,8 +115,16 @@ describe("Runner", () => {
|
||||
Effect.gen(function* () {
|
||||
const s = yield* Scope.Scope
|
||||
const runner = Runner.make<string>(s)
|
||||
const fiber = yield* runner.ensureRunning(Effect.never.pipe(Effect.as("never"))).pipe(Effect.forkChild)
|
||||
yield* Effect.sleep("10 millis")
|
||||
const started = yield* Deferred.make<void>()
|
||||
const fiber = yield* runner
|
||||
.ensureRunning(
|
||||
Effect.gen(function* () {
|
||||
yield* Deferred.succeed(started, void 0)
|
||||
return yield* Effect.never.pipe(Effect.as("never"))
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.forkChild)
|
||||
yield* Deferred.await(started)
|
||||
expect(runner.busy).toBe(true)
|
||||
expect(runner.state._tag).toBe("Running")
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
# Server Test Guide
|
||||
|
||||
Use these patterns for server and HttpApi middleware tests in this directory.
|
||||
|
||||
- Prefer focused middleware tests with tiny fake routes over full API route trees when testing routing, context, proxying, or middleware policy.
|
||||
- Use `testEffect(...)` with `NodeHttpServer.layerTest` for the primary in-test server and make relative `HttpClient` requests against it.
|
||||
- Use `HttpRouter.add(...)` probe routes that expose the context under test, such as `WorkspaceRouteContext`, `InstanceRef`, or `WorkspaceRef`.
|
||||
- Compose middleware in the same order as production when testing interactions, for example `instanceRouterMiddleware.combine(workspaceRouterMiddleware)`.
|
||||
- For secondary upstream servers, build Effect `NodeHttpServer.layer(...)` into the current test scope with `Layer.build(...)` so the listener stays alive until the test scope exits.
|
||||
- Avoid `Bun.serve` when testing Effect HTTP middleware. Keep the test in the Effect HTTP stack unless the production path being tested is Bun-specific.
|
||||
- For WebSocket paths, use `Socket.makeWebSocket(...)` from the test client and assert protocol forwarding or frame relay when relevant.
|
||||
- Use scoped test layers for flags, database reset, and other global mutable state. Restore flags and reset state in finalizers.
|
||||
- Use `tmpdirScoped({ git: true })` plus `Project.use.fromDirectory(dir)` for project-backed requests.
|
||||
- If a test needs persisted state without matching runtime state, keep direct database setup inside a narrowly named helper that explains that state.
|
||||
- Add comments for non-obvious test topology, especially tests involving both the local test server and a fake upstream server.
|
||||
@@ -0,0 +1,103 @@
|
||||
import { NodeHttpServer } from "@effect/platform-node"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer, Option, Schema } from "effect"
|
||||
import { HttpClient, HttpClientRequest, HttpRouter } from "effect/unstable/http"
|
||||
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"
|
||||
import {
|
||||
Authorization,
|
||||
ServerAuthConfig,
|
||||
authorizationLayer,
|
||||
} from "../../src/server/routes/instance/httpapi/middleware/authorization"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const Api = HttpApi.make("test-authorization").add(
|
||||
HttpApiGroup.make("test")
|
||||
.add(
|
||||
HttpApiEndpoint.get("probe", "/probe", {
|
||||
success: Schema.String,
|
||||
}),
|
||||
)
|
||||
.middleware(Authorization),
|
||||
)
|
||||
|
||||
const handlers = HttpApiBuilder.group(Api, "test", (handlers) => handlers.handle("probe", () => Effect.succeed("ok")))
|
||||
|
||||
const apiLayer = HttpRouter.serve(
|
||||
HttpApiBuilder.layer(Api).pipe(Layer.provide(handlers), Layer.provide(authorizationLayer)),
|
||||
{ disableListenLog: true, disableLogger: true },
|
||||
).pipe(Layer.provideMerge(NodeHttpServer.layerTest))
|
||||
|
||||
const noAuthLayer = ServerAuthConfig.layer({ password: Option.none(), username: "opencode" })
|
||||
const secretLayer = ServerAuthConfig.layer({ password: Option.some("secret"), username: "opencode" })
|
||||
const kitSecretLayer = ServerAuthConfig.layer({ password: Option.some("secret"), username: "kit" })
|
||||
|
||||
const it = testEffect(apiLayer.pipe(Layer.provide(noAuthLayer)))
|
||||
const itSecret = testEffect(apiLayer.pipe(Layer.provide(secretLayer)))
|
||||
const itKitSecret = testEffect(apiLayer.pipe(Layer.provide(kitSecretLayer)))
|
||||
|
||||
const basic = (username: string, password: string) =>
|
||||
`Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`
|
||||
|
||||
const token = (username: string, password: string) => Buffer.from(`${username}:${password}`).toString("base64")
|
||||
|
||||
const getProbe = (headers?: Record<string, string>) =>
|
||||
HttpClientRequest.get("/probe").pipe(
|
||||
headers ? HttpClientRequest.setHeaders(headers) : (request) => request,
|
||||
HttpClient.execute,
|
||||
)
|
||||
|
||||
describe("HttpApi authorization middleware", () => {
|
||||
it.live("allows requests when server password is not configured", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* getProbe()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(yield* response.json).toBe("ok")
|
||||
}),
|
||||
)
|
||||
|
||||
itSecret.live("requires configured password for basic auth", () =>
|
||||
Effect.gen(function* () {
|
||||
const [missing, badPassword, good] = yield* Effect.all(
|
||||
[
|
||||
getProbe(),
|
||||
getProbe({ authorization: basic("opencode", "wrong") }),
|
||||
getProbe({ authorization: basic("opencode", "secret") }),
|
||||
],
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
|
||||
expect(missing.status).toBe(401)
|
||||
expect(badPassword.status).toBe(401)
|
||||
expect(good.status).toBe(200)
|
||||
}),
|
||||
)
|
||||
|
||||
itKitSecret.live("respects configured basic auth username", () =>
|
||||
Effect.gen(function* () {
|
||||
const [defaultUser, configuredUser] = yield* Effect.all(
|
||||
[getProbe({ authorization: basic("opencode", "secret") }), getProbe({ authorization: basic("kit", "secret") })],
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
|
||||
expect(defaultUser.status).toBe(401)
|
||||
expect(configuredUser.status).toBe(200)
|
||||
}),
|
||||
)
|
||||
|
||||
itSecret.live("accepts auth token query credentials", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* HttpClient.get(`/probe?auth_token=${encodeURIComponent(token("opencode", "secret"))}`)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
}),
|
||||
)
|
||||
|
||||
itSecret.live("rejects malformed auth token query credentials", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* HttpClient.get("/probe?auth_token=not-base64")
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -2,11 +2,14 @@ import { afterEach, describe, expect, test } from "bun:test"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { ControlPaths } from "../../src/server/routes/instance/httpapi/groups/control"
|
||||
import { FileApi, FilePaths } from "../../src/server/routes/instance/httpapi/groups/file"
|
||||
import { FilePaths } from "../../src/server/routes/instance/httpapi/groups/file"
|
||||
import { GlobalPaths } from "../../src/server/routes/instance/httpapi/groups/global"
|
||||
import { PublicApi } from "../../src/server/routes/instance/httpapi/public"
|
||||
import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server"
|
||||
import { Server } from "../../src/server/server"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { ConfigProvider, Layer } from "effect"
|
||||
import { HttpRouter } from "effect/unstable/http"
|
||||
import { OpenApi } from "effect/unstable/httpapi"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
@@ -30,7 +33,26 @@ function app(input?: { password?: string; username?: string }) {
|
||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
|
||||
Flag.OPENCODE_SERVER_PASSWORD = input?.password
|
||||
Flag.OPENCODE_SERVER_USERNAME = input?.username
|
||||
return Server.Default().app
|
||||
|
||||
const handler = HttpRouter.toWebHandler(
|
||||
ExperimentalHttpApiServer.routes.pipe(
|
||||
Layer.provide(
|
||||
ConfigProvider.layer(
|
||||
ConfigProvider.fromUnknown({
|
||||
OPENCODE_SERVER_PASSWORD: input?.password,
|
||||
OPENCODE_SERVER_USERNAME: input?.username,
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
{ disableLogger: true },
|
||||
).handler
|
||||
return {
|
||||
fetch: (request: Request) => handler(request, ExperimentalHttpApiServer.context),
|
||||
request(input: string | URL | Request, init?: RequestInit) {
|
||||
return this.fetch(input instanceof Request ? input : new Request(new URL(input, "http://localhost"), init))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function openApiRouteKeys(spec: { paths: Record<string, Partial<Record<(typeof methods)[number], unknown>>> }) {
|
||||
@@ -94,9 +116,9 @@ type RequestBody = {
|
||||
required?: boolean
|
||||
}
|
||||
|
||||
function parameterKey(param: unknown) {
|
||||
if (!param || typeof param !== "object" || !("in" in param) || !("name" in param)) return
|
||||
if (typeof param.in !== "string" || typeof param.name !== "string") return
|
||||
function parameterKey(param: unknown): string | undefined {
|
||||
if (!param || typeof param !== "object" || !("in" in param) || !("name" in param)) return undefined
|
||||
if (typeof param.in !== "string" || typeof param.name !== "string") return undefined
|
||||
return `${param.in}:${param.name}:${"required" in param && param.required === true}`
|
||||
}
|
||||
|
||||
@@ -105,27 +127,29 @@ function parameterSchema(input: {
|
||||
path: string
|
||||
method: (typeof methods)[number]
|
||||
name: string
|
||||
}) {
|
||||
}): unknown {
|
||||
const param = input.spec.paths[input.path]?.[input.method]?.parameters?.find(
|
||||
(param) => !!param && typeof param === "object" && "name" in param && param.name === input.name,
|
||||
)
|
||||
if (!param || typeof param !== "object" || !("schema" in param)) return
|
||||
if (!param || typeof param !== "object" || !("schema" in param)) return undefined
|
||||
return param.schema
|
||||
}
|
||||
|
||||
function requestBodyKey(spec: OpenApiSpec, body: unknown) {
|
||||
if (!body || typeof body !== "object" || !("content" in body)) return ""
|
||||
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- Guarded above; test helper only needs this OpenAPI subset.
|
||||
const requestBody = body as RequestBody
|
||||
return JSON.stringify({
|
||||
required: requestBody.required === true,
|
||||
content: Object.entries(requestBody.content ?? {})
|
||||
.map(([type, value]) => [type, requestBodySchemaKind(spec, value.schema)])
|
||||
.sort(),
|
||||
.map(([type, value]) => [type, requestBodySchemaKind(spec, value.schema)] as const)
|
||||
.sort(([left], [right]) => left.localeCompare(right)),
|
||||
})
|
||||
}
|
||||
|
||||
function requestBodySchemaKind(spec: OpenApiSpec, schema: OpenApiSchema | undefined) {
|
||||
if (!schema) return ""
|
||||
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- `$ref` lookup is constrained to OpenAPI schema components in this test helper.
|
||||
const resolved = (
|
||||
schema.$ref ? spec.components?.schemas?.[schema.$ref.replace("#/components/schemas/", "")] : schema
|
||||
) as OpenApiSchema | undefined
|
||||
@@ -142,6 +166,7 @@ function responseContentTypes(input: {
|
||||
}) {
|
||||
const responses = input.spec.paths[input.path]?.[input.method]?.responses
|
||||
if (!responses || typeof responses !== "object" || !(input.status in responses)) return []
|
||||
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- Guarded dynamic OpenAPI response lookup.
|
||||
const response = (responses as Record<string, unknown>)[input.status]
|
||||
if (!response || typeof response !== "object" || !("content" in response)) return []
|
||||
const content = (response as { content?: unknown }).content
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import { NodeHttpServer, NodeServices } from "@effect/platform-node"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { HttpClient, HttpClientRequest, HttpRouter, HttpServerResponse } from "effect/unstable/http"
|
||||
import * as Socket from "effect/unstable/socket/Socket"
|
||||
import { mkdir } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { registerAdaptor } from "../../src/control-plane/adaptors"
|
||||
import type { WorkspaceAdaptor } from "../../src/control-plane/types"
|
||||
import { Workspace } from "../../src/control-plane/workspace"
|
||||
import { InstanceRef, WorkspaceRef } from "../../src/effect/instance-ref"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { Project } from "../../src/project/project"
|
||||
import { instanceRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/instance-context"
|
||||
import { workspaceRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/workspace-routing"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { tmpdirScoped } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const testStateLayer = Layer.effectDiscard(
|
||||
Effect.gen(function* () {
|
||||
const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
|
||||
yield* Effect.promise(() => resetDatabase())
|
||||
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.promise(async () => {
|
||||
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces
|
||||
await Instance.disposeAll()
|
||||
await resetDatabase()
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
const it = testEffect(
|
||||
Layer.mergeAll(testStateLayer, NodeHttpServer.layerTest, NodeServices.layer, Project.defaultLayer),
|
||||
)
|
||||
|
||||
const instanceContextTestLayer = instanceRouterMiddleware
|
||||
.combine(workspaceRouterMiddleware)
|
||||
.layer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal))
|
||||
|
||||
const localAdaptor = (directory: string): WorkspaceAdaptor => ({
|
||||
name: "Local Test",
|
||||
description: "Create a local test workspace",
|
||||
configure: (info) => ({ ...info, name: "local-test", directory }),
|
||||
create: async () => {
|
||||
await mkdir(directory, { recursive: true })
|
||||
},
|
||||
async remove() {},
|
||||
target: () => ({ type: "local" as const, directory }),
|
||||
})
|
||||
|
||||
const createLocalWorkspace = (input: { projectID: Project.Info["id"]; type: string; directory: string }) =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(async () => {
|
||||
registerAdaptor(input.projectID, input.type, localAdaptor(input.directory))
|
||||
return Workspace.create({
|
||||
type: input.type,
|
||||
branch: null,
|
||||
extra: null,
|
||||
projectID: input.projectID,
|
||||
})
|
||||
}),
|
||||
(workspace) => Effect.promise(() => Workspace.remove(workspace.id)).pipe(Effect.ignore),
|
||||
)
|
||||
|
||||
const probeInstanceContext = Effect.gen(function* () {
|
||||
const instance = yield* InstanceRef
|
||||
const workspaceID = yield* WorkspaceRef
|
||||
return yield* HttpServerResponse.json({
|
||||
directory: instance?.directory,
|
||||
worktree: instance?.worktree,
|
||||
projectID: instance?.project.id,
|
||||
workspaceID,
|
||||
})
|
||||
})
|
||||
|
||||
const serveProbe = (probePath: HttpRouter.PathInput = "/probe") =>
|
||||
HttpRouter.add("GET", probePath, probeInstanceContext).pipe(
|
||||
Layer.provide(instanceContextTestLayer),
|
||||
HttpRouter.serve,
|
||||
Layer.build,
|
||||
)
|
||||
|
||||
describe("HttpApi instance context middleware", () => {
|
||||
it.live("provides instance context from the routed directory", () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdirScoped({ git: true })
|
||||
const project = yield* Project.use.fromDirectory(dir)
|
||||
yield* serveProbe()
|
||||
|
||||
const response = yield* HttpClient.get(`/probe?directory=${encodeURIComponent(dir)}`)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(yield* response.json).toEqual({
|
||||
directory: dir,
|
||||
worktree: dir,
|
||||
projectID: project.project.id,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("falls back to the raw directory when URI decoding fails", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* serveProbe()
|
||||
|
||||
const response = yield* HttpClient.get("/probe?directory=%25E0%25A4%25A")
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(yield* response.json).toMatchObject({
|
||||
directory: path.join(process.cwd(), "%E0%A4%A"),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("provides selected workspace id on control-plane routes", () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdirScoped({ git: true })
|
||||
const project = yield* Project.use.fromDirectory(dir)
|
||||
const workspaceDir = path.join(dir, ".workspace-local")
|
||||
const workspace = yield* createLocalWorkspace({
|
||||
projectID: project.project.id,
|
||||
type: "instance-context-workspace-ref",
|
||||
directory: workspaceDir,
|
||||
})
|
||||
yield* serveProbe("/session")
|
||||
|
||||
const response = yield* HttpClientRequest.get(`/session?workspace=${workspace.id}`).pipe(
|
||||
HttpClientRequest.setHeader("x-opencode-directory", dir),
|
||||
HttpClient.execute,
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(yield* response.json).toMatchObject({
|
||||
directory: dir,
|
||||
workspaceID: workspace.id,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("uses workspace routing output instead of raw directory hints", () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdirScoped({ git: true })
|
||||
const project = yield* Project.use.fromDirectory(dir)
|
||||
const workspaceDir = path.join(dir, ".workspace-local")
|
||||
const workspace = yield* createLocalWorkspace({
|
||||
projectID: project.project.id,
|
||||
type: "instance-context-routing-output",
|
||||
directory: workspaceDir,
|
||||
})
|
||||
yield* serveProbe()
|
||||
|
||||
const response = yield* HttpClientRequest.get(`/probe?workspace=${workspace.id}`).pipe(
|
||||
HttpClientRequest.setHeader("x-opencode-directory", dir),
|
||||
HttpClient.execute,
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(yield* response.json).toMatchObject({
|
||||
directory: workspaceDir,
|
||||
workspaceID: workspace.id,
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -1,9 +1,11 @@
|
||||
import { afterEach, describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { ConfigProvider, Effect, Layer } from "effect"
|
||||
import type * as Scope from "effect/Scope"
|
||||
import { HttpRouter } from "effect/unstable/http"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server"
|
||||
import { Server } from "../../src/server/server"
|
||||
import { MessageID, PartID, SessionID } from "../../src/session/schema"
|
||||
import { MessageV2 } from "../../src/session/message-v2"
|
||||
@@ -33,7 +35,27 @@ function app(backend: Backend, input?: { password?: string; username?: string })
|
||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = backend === "httpapi"
|
||||
Flag.OPENCODE_SERVER_PASSWORD = input?.password
|
||||
Flag.OPENCODE_SERVER_USERNAME = input?.username
|
||||
return backend === "httpapi" ? Server.Default().app : Server.Legacy().app
|
||||
if (backend === "legacy") return Server.Legacy().app
|
||||
|
||||
const handler = HttpRouter.toWebHandler(
|
||||
ExperimentalHttpApiServer.routes.pipe(
|
||||
Layer.provide(
|
||||
ConfigProvider.layer(
|
||||
ConfigProvider.fromUnknown({
|
||||
OPENCODE_SERVER_PASSWORD: input?.password,
|
||||
OPENCODE_SERVER_USERNAME: input?.username,
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
{ disableLogger: true },
|
||||
).handler
|
||||
return {
|
||||
fetch: (request: Request) => handler(request, ExperimentalHttpApiServer.context),
|
||||
request(input: string | URL | Request, init?: RequestInit) {
|
||||
return this.fetch(input instanceof Request ? input : new Request(new URL(input, "http://localhost"), init))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function client(
|
||||
@@ -123,7 +145,7 @@ function firstEvent(open: () => Promise<{ stream: AsyncIterator<unknown> }>) {
|
||||
}
|
||||
|
||||
function record(value: unknown) {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : {}
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? Object.fromEntries(Object.entries(value)) : {}
|
||||
}
|
||||
|
||||
function array(value: unknown) {
|
||||
|
||||
@@ -0,0 +1,437 @@
|
||||
import { NodeHttpServer, NodeServices } from "@effect/platform-node"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Context, Effect, Layer, Queue } from "effect"
|
||||
import {
|
||||
HttpClient,
|
||||
HttpClientRequest,
|
||||
HttpRouter,
|
||||
HttpServer,
|
||||
HttpServerRequest,
|
||||
HttpServerResponse,
|
||||
} from "effect/unstable/http"
|
||||
import * as Socket from "effect/unstable/socket/Socket"
|
||||
import Http from "node:http"
|
||||
import { mkdir } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { registerAdaptor } from "../../src/control-plane/adaptors"
|
||||
import { WorkspaceID } from "../../src/control-plane/schema"
|
||||
import type { WorkspaceAdaptor } from "../../src/control-plane/types"
|
||||
import { Workspace } from "../../src/control-plane/workspace"
|
||||
import { WorkspaceTable } from "../../src/control-plane/workspace.sql"
|
||||
import { Project } from "../../src/project/project"
|
||||
import {
|
||||
WorkspaceRouteContext,
|
||||
workspaceRouterMiddleware,
|
||||
} from "../../src/server/routes/instance/httpapi/middleware/workspace-routing"
|
||||
import { Database } from "../../src/storage/db"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { tmpdirScoped } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const testStateLayer = Layer.effectDiscard(
|
||||
Effect.gen(function* () {
|
||||
const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
|
||||
yield* Effect.promise(() => resetDatabase())
|
||||
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.promise(async () => {
|
||||
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces
|
||||
await resetDatabase()
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
const it = testEffect(
|
||||
Layer.mergeAll(
|
||||
testStateLayer,
|
||||
NodeHttpServer.layerTest,
|
||||
NodeServices.layer,
|
||||
Project.defaultLayer,
|
||||
Socket.layerWebSocketConstructorGlobal,
|
||||
),
|
||||
)
|
||||
|
||||
type ProxiedRequest = {
|
||||
url: string
|
||||
method: string
|
||||
headers: Record<string, string>
|
||||
}
|
||||
|
||||
type TestHandler<E, R> = (
|
||||
request: HttpServerRequest.HttpServerRequest,
|
||||
) => Effect.Effect<HttpServerResponse.HttpServerResponse, E, R>
|
||||
|
||||
const workspaceRoutingTestLayer = workspaceRouterMiddleware.layer.pipe(
|
||||
Layer.provide(Socket.layerWebSocketConstructorGlobal),
|
||||
)
|
||||
|
||||
const serverUrl = HttpServer.HttpServer.use((server) => Effect.succeed(HttpServer.formatAddress(server.address)))
|
||||
|
||||
const requestURL = (request: { readonly url: string }) => new URL(request.url, "http://localhost")
|
||||
|
||||
const listenAdditionalServer = <E, R>(handler: TestHandler<E, R>) =>
|
||||
Effect.gen(function* () {
|
||||
const context = yield* Layer.build(NodeHttpServer.layer(Http.createServer, { host: "127.0.0.1", port: 0 }))
|
||||
const server = Context.get(context, HttpServer.HttpServer)
|
||||
yield* server.serve(HttpServerRequest.HttpServerRequest.use(handler))
|
||||
return HttpServer.formatAddress(server.address)
|
||||
})
|
||||
|
||||
const localAdaptor = (directory: string): WorkspaceAdaptor => ({
|
||||
name: "Local Test",
|
||||
description: "Create a local test workspace",
|
||||
configure: (info) => ({ ...info, name: "local-test", directory }),
|
||||
create: async () => {
|
||||
await mkdir(directory, { recursive: true })
|
||||
},
|
||||
async remove() {},
|
||||
target: () => ({ type: "local" as const, directory }),
|
||||
})
|
||||
|
||||
const remoteAdaptor = (directory: string, url: string, headers?: HeadersInit): WorkspaceAdaptor => ({
|
||||
name: "Remote Test",
|
||||
description: "Create a remote test workspace",
|
||||
configure: (info) => ({ ...info, name: "remote-test", directory }),
|
||||
create: async () => {
|
||||
await mkdir(directory, { recursive: true })
|
||||
},
|
||||
async remove() {},
|
||||
target: () => ({ type: "remote" as const, url, headers }),
|
||||
})
|
||||
|
||||
const eventStreamResponse = () =>
|
||||
HttpServerResponse.text('data: {"payload":{"type":"server.connected","properties":{}}}\n\n', {
|
||||
contentType: "text/event-stream",
|
||||
})
|
||||
|
||||
const syncResponse = (request: HttpServerRequest.HttpServerRequest) => {
|
||||
const url = requestURL(request)
|
||||
if (url.pathname === "/base/global/event") return Effect.succeed(eventStreamResponse())
|
||||
if (url.pathname === "/base/sync/history") return HttpServerResponse.json([])
|
||||
return undefined
|
||||
}
|
||||
|
||||
const createWorkspace = (input: { projectID: Project.Info["id"]; type: string; adaptor: WorkspaceAdaptor }) =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(async () => {
|
||||
registerAdaptor(input.projectID, input.type, input.adaptor)
|
||||
return Workspace.create({
|
||||
type: input.type,
|
||||
branch: null,
|
||||
extra: null,
|
||||
projectID: input.projectID,
|
||||
})
|
||||
}),
|
||||
(workspace) => Effect.promise(() => Workspace.remove(workspace.id)).pipe(Effect.ignore),
|
||||
)
|
||||
|
||||
const createRemoteWorkspace = (input: {
|
||||
dir: string
|
||||
projectID: Project.Info["id"]
|
||||
type: string
|
||||
url: string
|
||||
headers?: HeadersInit
|
||||
}) =>
|
||||
// Workspace.create starts the remote sync loop. The test upstream exposes
|
||||
// /global/event and /sync/history so middleware proxying sees the remote
|
||||
// workspace as active, just like production would.
|
||||
createWorkspace({
|
||||
projectID: input.projectID,
|
||||
type: input.type,
|
||||
adaptor: remoteAdaptor(path.join(input.dir, `.${input.type}`), input.url, input.headers),
|
||||
})
|
||||
|
||||
const createLocalWorkspace = (input: { projectID: Project.Info["id"]; type: string; directory: string }) =>
|
||||
createWorkspace({
|
||||
projectID: input.projectID,
|
||||
type: input.type,
|
||||
adaptor: localAdaptor(input.directory),
|
||||
})
|
||||
|
||||
const insertRemoteWorkspaceWithoutSync = (input: {
|
||||
dir: string
|
||||
projectID: Project.Info["id"]
|
||||
type: string
|
||||
url: string
|
||||
}) =>
|
||||
Effect.sync(() => {
|
||||
const id = WorkspaceID.ascending()
|
||||
registerAdaptor(input.projectID, input.type, remoteAdaptor(path.join(input.dir, `.${input.type}`), input.url))
|
||||
Database.use((db) => db.insert(WorkspaceTable).values({ id, type: input.type, project_id: input.projectID }).run())
|
||||
return id
|
||||
})
|
||||
|
||||
const startRemoteWorkspaceHttpServer = <E, R>(
|
||||
handler: (request: ProxiedRequest) => Effect.Effect<HttpServerResponse.HttpServerResponse, E, R>,
|
||||
) =>
|
||||
listenAdditionalServer((request) =>
|
||||
Effect.gen(function* () {
|
||||
// Remote workspaces run a sync loop against their target server. These
|
||||
// bootstrap routes make Workspace.isSyncing(...) true for proxy tests;
|
||||
// everything else is the request being proxied by the middleware.
|
||||
const sync = syncResponse(request)
|
||||
if (sync) return yield* sync
|
||||
return yield* handler({ url: request.url, method: request.method, headers: request.headers })
|
||||
}),
|
||||
)
|
||||
|
||||
const listenRemoteWebSocket = () =>
|
||||
listenAdditionalServer((request) => {
|
||||
const sync = syncResponse(request)
|
||||
if (sync) return sync
|
||||
if (requestURL(request).pathname !== "/base/probe") return Effect.succeed(HttpServerResponse.empty({ status: 404 }))
|
||||
return echoWebSocket(request)
|
||||
})
|
||||
|
||||
const echoWebSocket = (request: HttpServerRequest.HttpServerRequest) =>
|
||||
Effect.gen(function* () {
|
||||
const socket = yield* Effect.orDie(request.upgrade)
|
||||
const write = yield* socket.writer
|
||||
yield* socket
|
||||
.runRaw((message) => write(`echo:${String(message)}`), {
|
||||
onOpen: write(`protocol:${request.headers["sec-websocket-protocol"] ?? "none"}`).pipe(
|
||||
Effect.catch(() => Effect.void),
|
||||
),
|
||||
})
|
||||
.pipe(Effect.catch(() => Effect.void))
|
||||
return HttpServerResponse.empty()
|
||||
})
|
||||
|
||||
const serveRouteContextProbe = HttpRouter.add(
|
||||
"GET",
|
||||
"/probe",
|
||||
Effect.gen(function* () {
|
||||
// The fake route exposes the context installed by the middleware, so tests
|
||||
// can assert routing decisions without pulling in the production API tree.
|
||||
const route = yield* WorkspaceRouteContext
|
||||
return yield* HttpServerResponse.json({ directory: route.directory, workspaceID: route.workspaceID })
|
||||
}),
|
||||
).pipe(Layer.provide(workspaceRoutingTestLayer), HttpRouter.serve, Layer.build)
|
||||
|
||||
describe("HttpApi workspace routing middleware", () => {
|
||||
it.live("proxies remote workspace HTTP requests through the selected workspace target", () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdirScoped({ git: true })
|
||||
const project = yield* Project.use.fromDirectory(dir)
|
||||
let forwarded: ProxiedRequest | undefined
|
||||
|
||||
// This starts a second HTTP server that stands in for the opencode server
|
||||
// backing a remote workspace. The client below still calls the local test
|
||||
// server; only the middleware should call this server.
|
||||
const remoteUrl = yield* startRemoteWorkspaceHttpServer((request) => {
|
||||
forwarded = request
|
||||
const url = requestURL(request)
|
||||
return HttpServerResponse.json(
|
||||
{
|
||||
proxied: true,
|
||||
path: url.pathname,
|
||||
keep: url.searchParams.get("keep"),
|
||||
workspace: url.searchParams.get("workspace"),
|
||||
},
|
||||
{ status: 201, headers: { "x-remote": "yes" } },
|
||||
)
|
||||
})
|
||||
// The adaptor target tells the middleware where to proxy selected remote
|
||||
// workspace requests. Appending /probe to this base should produce
|
||||
// `${remoteUrl}/base/probe` on the fake remote server above.
|
||||
const workspace = yield* createRemoteWorkspace({
|
||||
dir,
|
||||
projectID: project.project.id,
|
||||
type: "remote-http-target",
|
||||
url: `${remoteUrl}/base`,
|
||||
headers: { "x-target-auth": "secret" },
|
||||
})
|
||||
|
||||
// The local /probe handler should not run. Selecting a remote workspace
|
||||
// should make the middleware call HttpApiProxy.http instead.
|
||||
yield* HttpRouter.add("PATCH", "/probe", HttpServerResponse.text("route called")).pipe(
|
||||
Layer.provide(workspaceRoutingTestLayer),
|
||||
HttpRouter.serve,
|
||||
Layer.build,
|
||||
)
|
||||
|
||||
const response = yield* HttpClientRequest.patch(`/probe?workspace=${workspace.id}&keep=yes`).pipe(
|
||||
HttpClientRequest.setHeaders({
|
||||
"content-type": "application/json",
|
||||
"x-opencode-directory": "/secret/path",
|
||||
"x-opencode-workspace": "internal",
|
||||
}),
|
||||
HttpClient.execute,
|
||||
)
|
||||
|
||||
expect(response.status).toBe(201)
|
||||
expect(response.headers["x-remote"]).toBe("yes")
|
||||
expect(yield* response.json).toEqual({ proxied: true, path: "/base/probe", keep: "yes", workspace: null })
|
||||
const forwardedURL = forwarded ? requestURL(forwarded) : undefined
|
||||
// These assertions are the routing contract: append the original path to
|
||||
// the remote base URL, preserve normal query params, and remove workspace.
|
||||
expect(forwardedURL?.pathname).toBe("/base/probe")
|
||||
expect(forwardedURL?.searchParams.get("keep")).toBe("yes")
|
||||
expect(forwardedURL?.searchParams.get("workspace")).toBeNull()
|
||||
expect(forwarded?.method).toBe("PATCH")
|
||||
expect(forwarded?.headers["content-type"]).toBe("application/json")
|
||||
expect(forwarded?.headers["x-target-auth"]).toBe("secret")
|
||||
expect(forwarded?.headers["x-opencode-directory"]).toBeUndefined()
|
||||
expect(forwarded?.headers["x-opencode-workspace"]).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("returns 503 when a remote workspace is not actively syncing", () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdirScoped({ git: true })
|
||||
const project = yield* Project.use.fromDirectory(dir)
|
||||
const workspaceID = yield* insertRemoteWorkspaceWithoutSync({
|
||||
dir,
|
||||
projectID: project.project.id,
|
||||
type: "remote-not-syncing",
|
||||
url: "http://127.0.0.1:1/base",
|
||||
})
|
||||
|
||||
yield* HttpRouter.add("GET", "/probe", HttpServerResponse.text("route called")).pipe(
|
||||
Layer.provide(workspaceRoutingTestLayer),
|
||||
HttpRouter.serve,
|
||||
Layer.build,
|
||||
)
|
||||
|
||||
const response = yield* HttpClient.get(`/probe?workspace=${workspaceID}`)
|
||||
|
||||
expect(response.status).toBe(503)
|
||||
expect(yield* response.text).toBe(`broken sync connection for workspace: ${workspaceID}`)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("proxies remote workspace WebSocket requests through the selected workspace target", () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdirScoped({ git: true })
|
||||
const project = yield* Project.use.fromDirectory(dir)
|
||||
const remoteUrl = yield* listenRemoteWebSocket()
|
||||
const workspace = yield* createRemoteWorkspace({
|
||||
dir,
|
||||
projectID: project.project.id,
|
||||
type: "remote-websocket-target",
|
||||
url: `${remoteUrl}/base`,
|
||||
})
|
||||
|
||||
// The client connects to the local test server. The middleware should
|
||||
// detect the WebSocket upgrade and proxy it to the remote /base/probe.
|
||||
yield* HttpRouter.add("GET", "/probe", HttpServerResponse.text("route called")).pipe(
|
||||
Layer.provide(workspaceRoutingTestLayer),
|
||||
HttpRouter.serve,
|
||||
Layer.build,
|
||||
)
|
||||
|
||||
const socket = yield* Socket.makeWebSocket(
|
||||
`${(yield* serverUrl).replace(/^http/, "ws")}/probe?workspace=${workspace.id}`,
|
||||
{
|
||||
closeCodeIsError: () => false,
|
||||
protocols: "chat",
|
||||
},
|
||||
)
|
||||
const messages = yield* Queue.unbounded<string>()
|
||||
yield* socket.runRaw((message) => Queue.offer(messages, String(message))).pipe(Effect.forkScoped)
|
||||
const write = yield* socket.writer
|
||||
|
||||
expect(yield* Queue.take(messages)).toBe("protocol:chat")
|
||||
yield* write("hello")
|
||||
expect(yield* Queue.take(messages)).toBe("echo:hello")
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("returns a missing workspace response for unknown workspace ids", () =>
|
||||
Effect.gen(function* () {
|
||||
const workspaceID = WorkspaceID.ascending("wrk_missing")
|
||||
// If the middleware resolves the workspace first, this handler is never
|
||||
// reached and the response should be the middleware error response.
|
||||
yield* HttpRouter.add("GET", "/probe", HttpServerResponse.text("route called")).pipe(
|
||||
Layer.provide(workspaceRoutingTestLayer),
|
||||
HttpRouter.serve,
|
||||
Layer.build,
|
||||
)
|
||||
|
||||
const response = yield* HttpClient.get(`/probe?workspace=${workspaceID}`)
|
||||
|
||||
expect(response.status).toBe(500)
|
||||
expect(yield* response.text).toBe(`Workspace not found: ${workspaceID}`)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("keeps control-plane routes local even when workspace is selected", () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdirScoped({ git: true })
|
||||
const project = yield* Project.use.fromDirectory(dir)
|
||||
|
||||
const workspaceDir = path.join(dir, ".workspace-local")
|
||||
const workspace = yield* createLocalWorkspace({
|
||||
projectID: project.project.id,
|
||||
type: "control-plane-target",
|
||||
directory: workspaceDir,
|
||||
})
|
||||
|
||||
// GET /session is a control-plane route: it lists sessions for the main
|
||||
// process and should not be redirected into the selected workspace target.
|
||||
yield* HttpRouter.add(
|
||||
"GET",
|
||||
"/session",
|
||||
Effect.gen(function* () {
|
||||
const route = yield* WorkspaceRouteContext
|
||||
return yield* HttpServerResponse.json({ directory: route.directory, workspaceID: route.workspaceID })
|
||||
}),
|
||||
).pipe(Layer.provide(workspaceRoutingTestLayer), HttpRouter.serve, Layer.build)
|
||||
|
||||
const response = yield* HttpClient.get(`/session?workspace=${workspace.id}`)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(yield* response.json).toEqual({ directory: process.cwd(), workspaceID: workspace.id })
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("uses directory query/header fallback when no workspace is selected", () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdirScoped()
|
||||
const queryDir = path.join(dir, "query-target")
|
||||
const headerDir = path.join(dir, "header-target")
|
||||
yield* serveRouteContextProbe
|
||||
|
||||
// Without a selected workspace, the middleware falls back to request
|
||||
// directory hints before using the process cwd.
|
||||
const queryResponse = yield* HttpClient.get(`/probe?directory=${encodeURIComponent(queryDir)}`)
|
||||
const headerResponse = yield* HttpClientRequest.get("/probe").pipe(
|
||||
HttpClientRequest.setHeader("x-opencode-directory", headerDir),
|
||||
HttpClient.execute,
|
||||
)
|
||||
|
||||
expect(queryResponse.status).toBe(200)
|
||||
expect(yield* queryResponse.json).toEqual({ directory: queryDir })
|
||||
expect(headerResponse.status).toBe(200)
|
||||
expect(yield* headerResponse.json).toEqual({ directory: headerDir })
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("routes local workspace requests through WorkspaceRouteContext", () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdirScoped({ git: true })
|
||||
const project = yield* Project.use.fromDirectory(dir)
|
||||
|
||||
const workspaceDir = path.join(dir, ".workspace-local")
|
||||
const workspace = yield* createLocalWorkspace({
|
||||
projectID: project.project.id,
|
||||
type: "local-target",
|
||||
directory: workspaceDir,
|
||||
})
|
||||
|
||||
yield* serveRouteContextProbe
|
||||
|
||||
// /probe is not a control-plane route, so selecting a local workspace
|
||||
// should swap the route context to the workspace target directory.
|
||||
const response = yield* HttpClient.get(`/probe?workspace=${workspace.id}`)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(yield* response.json).toEqual({
|
||||
directory: workspaceDir,
|
||||
workspaceID: workspace.id,
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,130 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import path from "path"
|
||||
import { GlobalBus, type GlobalEvent } from "../../src/bus/global"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { Server } from "../../src/server/server"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
void Log.init({ print: false })
|
||||
|
||||
const originalHttpApi = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
|
||||
type SyncTrace = { type: string; directory?: string }
|
||||
|
||||
function app() {
|
||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = false
|
||||
return Server.Default().app
|
||||
}
|
||||
|
||||
function route(pathname: string, directory: string, query?: Record<string, string>) {
|
||||
const url = new URL(pathname, "http://localhost")
|
||||
url.searchParams.set("directory", directory)
|
||||
for (const [key, value] of Object.entries(query ?? {})) {
|
||||
url.searchParams.set(key, value)
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
async function fetchJson<T>(
|
||||
pathname: string,
|
||||
directory: string,
|
||||
init?: RequestInit,
|
||||
query?: Record<string, string>,
|
||||
) {
|
||||
const response = await app().fetch(new Request(route(pathname, directory, query), init))
|
||||
if (response.status !== 200) throw new Error(await response.text())
|
||||
return (await response.json()) as T
|
||||
}
|
||||
|
||||
function pathFor(pathname: string, params: Record<string, string>) {
|
||||
return Object.entries(params).reduce((result, [key, value]) => result.replace(`:${key}`, value), pathname)
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = originalHttpApi
|
||||
await Instance.disposeAll()
|
||||
await resetDatabase()
|
||||
})
|
||||
|
||||
describe("Hono session routes", () => {
|
||||
test("use request directory for non-session routes and saved session directory for session routes", async () => {
|
||||
await using sessionDir = await tmpdir({
|
||||
git: true,
|
||||
config: { formatter: false, lsp: false },
|
||||
init: (dir) => Bun.write(path.join(dir, "marker.txt"), "session-directory"),
|
||||
})
|
||||
await using requestDir = await tmpdir({
|
||||
git: true,
|
||||
config: { formatter: false, lsp: false },
|
||||
init: (dir) => Bun.write(path.join(dir, "marker.txt"), "request-directory"),
|
||||
})
|
||||
|
||||
const json = { "content-type": "application/json" }
|
||||
const trace: SyncTrace[] = []
|
||||
const onEvent = (event: GlobalEvent) => {
|
||||
if (event.payload.type !== "sync") return
|
||||
if (!["session.created.1", "message.updated.1", "message.part.updated.1"].includes(event.payload.syncEvent.type)) return
|
||||
trace.push({ type: event.payload.syncEvent.type, directory: event.directory })
|
||||
}
|
||||
GlobalBus.on("event", onEvent)
|
||||
|
||||
const session = await fetchJson<{ id: string }>("/session", sessionDir.path, {
|
||||
method: "POST",
|
||||
headers: json,
|
||||
body: JSON.stringify({ title: "session-dir" }),
|
||||
})
|
||||
|
||||
const currentPath = await fetchJson<{ directory: string }>("/path", requestDir.path)
|
||||
expect(currentPath.directory).toBe(requestDir.path)
|
||||
|
||||
const marker = await fetchJson<{ type: string; content: string }>(
|
||||
"/file/content",
|
||||
requestDir.path,
|
||||
undefined,
|
||||
{
|
||||
path: "marker.txt",
|
||||
},
|
||||
)
|
||||
expect(marker).toMatchObject({ type: "text", content: "request-directory" })
|
||||
|
||||
await fetchJson<unknown>(pathFor("/session/:sessionID", { sessionID: session.id }), requestDir.path)
|
||||
|
||||
await fetchJson<unknown>(
|
||||
pathFor("/session/:sessionID/fork", { sessionID: session.id }),
|
||||
requestDir.path,
|
||||
{
|
||||
method: "POST",
|
||||
headers: json,
|
||||
body: JSON.stringify({}),
|
||||
},
|
||||
)
|
||||
|
||||
await fetchJson<{ info: { path: { cwd: string; root: string } }; parts: unknown[] }>(
|
||||
pathFor("/session/:sessionID/shell", { sessionID: session.id }),
|
||||
requestDir.path,
|
||||
{
|
||||
method: "POST",
|
||||
headers: json,
|
||||
body: JSON.stringify({
|
||||
agent: "build",
|
||||
model: { providerID: "test", modelID: "test" },
|
||||
command: "pwd",
|
||||
}),
|
||||
},
|
||||
)
|
||||
GlobalBus.off("event", onEvent)
|
||||
|
||||
expect(trace).toContainEqual({ type: "session.created.1", directory: sessionDir.path })
|
||||
expect(trace.filter((event) => event.type === "session.created.1")).toEqual([
|
||||
{ type: "session.created.1", directory: sessionDir.path },
|
||||
{ type: "session.created.1", directory: sessionDir.path },
|
||||
])
|
||||
expect(trace.filter((event) => event.type === "message.updated.1").map((event) => event.directory)).toEqual(
|
||||
expect.arrayContaining([sessionDir.path]),
|
||||
)
|
||||
expect(trace.filter((event) => event.type === "message.updated.1").every((event) => event.directory === sessionDir.path))
|
||||
.toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -46,7 +46,7 @@ function echoWebSocket(request: HttpServerRequest.HttpServerRequest) {
|
||||
// The upstream announces the negotiated protocol, then echoes every
|
||||
// received frame. The assertions use those messages to prove proxy flow.
|
||||
yield* socket
|
||||
.runRaw((message) => write(`echo:${message}`), {
|
||||
.runRaw((message) => write(`echo:${String(message)}`), {
|
||||
onOpen: write(`protocol:${request.headers["sec-websocket-protocol"] ?? "none"}`).pipe(
|
||||
Effect.catch(() => Effect.void),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user