Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4b9c5d2fe9 | |||
| e0339227d9 | |||
| 52c912237b | |||
| b30415b18f | |||
| fe7db4f40e | |||
| 71e27e0871 | |||
| edf0cbbcdc | |||
| c6b42d1fac | |||
| 0fb9603e8b | |||
| 60f3eadef9 | |||
| 95bf01d839 | |||
| 9f3112748e | |||
| 48d4175461 | |||
| 2b3e3573b2 | |||
| 7a7979ea9b |
@@ -1,67 +0,0 @@
|
||||
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,24 @@
|
||||
import { Effect } from "effect"
|
||||
|
||||
const inputDecoder = new TextDecoder("utf-8", { fatal: true })
|
||||
|
||||
export function handlePtyInput(
|
||||
handler: { onMessage: (message: string | ArrayBuffer) => void },
|
||||
message: string | Uint8Array,
|
||||
) {
|
||||
if (typeof message === "string") {
|
||||
handler.onMessage(message)
|
||||
return Effect.void
|
||||
}
|
||||
return Effect.try({
|
||||
try: () => inputDecoder.decode(message),
|
||||
catch: () => new Error("invalid PTY websocket input"),
|
||||
}).pipe(
|
||||
Effect.catch(() => Effect.succeed(undefined)),
|
||||
Effect.flatMap((decoded) => {
|
||||
if (decoded === undefined) return Effect.void
|
||||
handler.onMessage(decoded)
|
||||
return Effect.void
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
const opencodeOrigin = /^https:\/\/([a-z0-9-]+\.)*opencode\.ai$/
|
||||
|
||||
export function isAllowedCorsOrigin(input: string | undefined, opts?: { cors?: string[] }) {
|
||||
if (!input) return false
|
||||
if (input.startsWith("http://localhost:")) return true
|
||||
if (input.startsWith("http://127.0.0.1:")) return true
|
||||
if (input === "tauri://localhost" || input === "http://tauri.localhost" || input === "https://tauri.localhost")
|
||||
return true
|
||||
if (opencodeOrigin.test(input)) return true
|
||||
return opts?.cors?.includes(input) ?? false
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { basicAuth } from "hono/basic-auth"
|
||||
import { cors } from "hono/cors"
|
||||
import { compress } from "hono/compress"
|
||||
import * as ServerBackend from "./backend"
|
||||
import { isAllowedCorsOrigin } from "./cors"
|
||||
|
||||
const log = Log.create({ service: "server" })
|
||||
|
||||
@@ -70,15 +71,7 @@ export function CorsMiddleware(opts?: { cors?: string[] }): MiddlewareHandler {
|
||||
return cors({
|
||||
maxAge: 86_400,
|
||||
origin(input) {
|
||||
if (!input) return
|
||||
|
||||
if (input.startsWith("http://localhost:")) return input
|
||||
if (input.startsWith("http://127.0.0.1:")) return input
|
||||
if (input === "tauri://localhost" || input === "http://tauri.localhost" || input === "https://tauri.localhost")
|
||||
return input
|
||||
|
||||
if (/^https:\/\/([a-z0-9-]+\.)*opencode\.ai$/.test(input)) return input
|
||||
if (opts?.cors?.includes(input)) return input
|
||||
if (isAllowedCorsOrigin(input, opts)) return input
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ export const AddPayload = Schema.Struct({
|
||||
export const StatusMap = Schema.Record(Schema.String, MCP.Status)
|
||||
export const AuthStartResponse = Schema.Struct({
|
||||
authorizationUrl: Schema.String,
|
||||
oauthState: Schema.String,
|
||||
})
|
||||
export const AuthCallbackPayload = Schema.Struct({
|
||||
code: Schema.String,
|
||||
|
||||
@@ -10,7 +10,6 @@ import { SessionSummary } from "@/session/summary"
|
||||
import { Todo } from "@/session/todo"
|
||||
import { MessageID, PartID, SessionID } from "@/session/schema"
|
||||
import { Snapshot } from "@/snapshot"
|
||||
import { NonNegativeInt } from "@/util/schema"
|
||||
import { Schema, SchemaGetter, Struct } from "effect"
|
||||
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
|
||||
import { Authorization } from "../middleware/authorization"
|
||||
@@ -45,7 +44,7 @@ export const UpdatePayload = Schema.Struct({
|
||||
permission: Schema.optional(Permission.Ruleset),
|
||||
time: Schema.optional(
|
||||
Schema.Struct({
|
||||
archived: Schema.optional(NonNegativeInt),
|
||||
archived: Schema.optional(Session.ArchivedTimestamp),
|
||||
}),
|
||||
),
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { EffectBridge } from "@/effect/bridge"
|
||||
import { Pty } from "@/pty"
|
||||
import { PtyID } from "@/pty/schema"
|
||||
import { handlePtyInput } from "@/pty/input"
|
||||
import { Shell } from "@/shell/shell"
|
||||
import { Effect } from "effect"
|
||||
import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
|
||||
@@ -102,9 +103,7 @@ export const ptyConnectRoute = HttpRouter.add(
|
||||
if (!handler) return HttpServerResponse.empty()
|
||||
|
||||
yield* socket
|
||||
.runRaw((message) => {
|
||||
handler.onMessage(typeof message === "string" ? message : message.slice().buffer)
|
||||
})
|
||||
.runRaw((message) => handlePtyInput(handler, message))
|
||||
.pipe(
|
||||
Effect.catchReason("SocketError", "SocketCloseError", () => Effect.void),
|
||||
Effect.ensuring(
|
||||
|
||||
@@ -62,7 +62,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
|
||||
return Instance.restore(instance, () =>
|
||||
Array.from(
|
||||
Session.list({
|
||||
directory: ctx.query.directory,
|
||||
directory: ctx.query.scope === "project" ? undefined : ctx.query.directory,
|
||||
scope: ctx.query.scope,
|
||||
path: ctx.query.path,
|
||||
roots: ctx.query.roots,
|
||||
|
||||
@@ -28,8 +28,8 @@ const commandAliases = {
|
||||
export const tuiHandlers = HttpApiBuilder.group(InstanceHttpApi, "tui", (handlers) =>
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const publishCommand = (command: typeof TuiEvent.CommandExecute.properties.Type.command) =>
|
||||
bus.publish(TuiEvent.CommandExecute, { command })
|
||||
const publishCommand = (command: typeof TuiEvent.CommandExecute.properties.Type.command | undefined) =>
|
||||
bus.publish(TuiEvent.CommandExecute, { command } as typeof TuiEvent.CommandExecute.properties.Type)
|
||||
|
||||
const appendPrompt = Effect.fn("TuiHttpApi.appendPrompt")(function* (ctx: {
|
||||
payload: typeof TuiEvent.PromptAppend.properties.Type
|
||||
@@ -71,7 +71,8 @@ export const tuiHandlers = HttpApiBuilder.group(InstanceHttpApi, "tui", (handler
|
||||
const executeCommand = Effect.fn("TuiHttpApi.executeCommand")(function* (ctx: {
|
||||
payload: typeof CommandPayload.Type
|
||||
}) {
|
||||
yield* publishCommand(commandAliases[ctx.payload.command as keyof typeof commandAliases] ?? ctx.payload.command)
|
||||
// Legacy only publishes known aliases; unknown commands become undefined.
|
||||
yield* publishCommand(commandAliases[ctx.payload.command as keyof typeof commandAliases])
|
||||
return true
|
||||
})
|
||||
|
||||
|
||||
@@ -1,32 +1,63 @@
|
||||
import type { WorkspaceID } from "@/control-plane/schema"
|
||||
import { WorkspaceContext } from "@/control-plane/workspace-context"
|
||||
import { WorkspaceRef } from "@/effect/instance-ref"
|
||||
import { Instance, type InstanceContext } from "@/project/instance"
|
||||
import { Effect } from "effect"
|
||||
import { HttpEffect, HttpMiddleware, HttpServerRequest } from "effect/unstable/http"
|
||||
|
||||
const disposeAfterResponse = new WeakMap<object, InstanceContext>()
|
||||
type MarkedInstance = {
|
||||
ctx: InstanceContext
|
||||
workspaceID?: WorkspaceID
|
||||
}
|
||||
|
||||
export const markInstanceForDisposal = (ctx: InstanceContext) =>
|
||||
HttpEffect.appendPreResponseHandler((request, response) =>
|
||||
Effect.sync(() => {
|
||||
disposeAfterResponse.set(request.source, ctx)
|
||||
return response
|
||||
// Disposal is requested by an endpoint handler, but must run from the outer
|
||||
// server middleware after the response has been produced. The original Request
|
||||
// object is the stable handoff key between those two phases.
|
||||
const disposeAfterResponse = new WeakMap<object, MarkedInstance>()
|
||||
|
||||
const mark = (ctx: InstanceContext) =>
|
||||
Effect.gen(function* () {
|
||||
return { ctx, workspaceID: yield* WorkspaceRef }
|
||||
})
|
||||
|
||||
// Instance.dispose/reload still publish events through legacy ALS helpers.
|
||||
// Effect request handlers carry these values in services, so bridge them back
|
||||
// into the legacy contexts only around the lifecycle operation.
|
||||
const restoreMarked = <A>(marked: MarkedInstance, fn: () => A) =>
|
||||
Effect.promise(() =>
|
||||
WorkspaceContext.provide({
|
||||
workspaceID: marked.workspaceID,
|
||||
fn: () => Instance.restore(marked.ctx, fn),
|
||||
}),
|
||||
)
|
||||
|
||||
export const markInstanceForDisposal = (ctx: InstanceContext) =>
|
||||
Effect.gen(function* () {
|
||||
const marked = yield* mark(ctx)
|
||||
return yield* HttpEffect.appendPreResponseHandler((request, response) =>
|
||||
Effect.sync(() => {
|
||||
// The response is sent before disposeMiddleware performs the teardown.
|
||||
disposeAfterResponse.set(request.source, marked)
|
||||
return response
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
export const markInstanceForReload = (ctx: InstanceContext, next: Parameters<typeof Instance.reload>[0]) =>
|
||||
HttpEffect.appendPreResponseHandler((_request, response) =>
|
||||
Effect.as(
|
||||
Effect.uninterruptible(Effect.promise(() => Instance.restore(ctx, () => Instance.reload(next)))),
|
||||
response,
|
||||
),
|
||||
)
|
||||
Effect.gen(function* () {
|
||||
const marked = yield* mark(ctx)
|
||||
return yield* HttpEffect.appendPreResponseHandler((_request, response) =>
|
||||
Effect.as(Effect.uninterruptible(restoreMarked(marked, () => Instance.reload(next))), response),
|
||||
)
|
||||
})
|
||||
|
||||
export const disposeMiddleware: HttpMiddleware.HttpMiddleware = (effect) =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* effect
|
||||
const request = yield* HttpServerRequest.HttpServerRequest
|
||||
const ctx = disposeAfterResponse.get(request.source)
|
||||
if (!ctx) return response
|
||||
const marked = disposeAfterResponse.get(request.source)
|
||||
if (!marked) return response
|
||||
disposeAfterResponse.delete(request.source)
|
||||
yield* Effect.uninterruptible(Effect.promise(() => Instance.restore(ctx, () => Instance.dispose())))
|
||||
yield* Effect.uninterruptible(restoreMarked(marked, () => Instance.dispose()))
|
||||
return response
|
||||
})
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import { ConfigService } from "@/effect/config-service"
|
||||
import { Config, Context, Effect, Encoding, Layer, Option, Redacted } from "effect"
|
||||
import { HttpApiError, HttpApiMiddleware, HttpApiSecurity } from "effect/unstable/httpapi"
|
||||
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 },
|
||||
) {}
|
||||
|
||||
export class Authorization extends HttpApiMiddleware.Service<Authorization>()(
|
||||
"@opencode/ExperimentalHttpApiAuthorization",
|
||||
{
|
||||
error: HttpApiError.UnauthorizedNoContent,
|
||||
error: Unauthorized,
|
||||
security: {
|
||||
basic: HttpApiSecurity.basic,
|
||||
authToken: HttpApiSecurity.apiKey({ in: "query", key: "auth_token" }),
|
||||
@@ -13,38 +19,29 @@ export class Authorization extends HttpApiMiddleware.Service<Authorization>()(
|
||||
},
|
||||
) {}
|
||||
|
||||
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")),
|
||||
},
|
||||
) {}
|
||||
const emptyCredential = {
|
||||
username: "",
|
||||
password: Redacted.make(""),
|
||||
}
|
||||
|
||||
function validateCredential<A, E, R>(
|
||||
effect: Effect.Effect<A, E, R>,
|
||||
credential: { readonly username: string; readonly password: Redacted.Redacted },
|
||||
config: Context.Service.Shape<typeof ServerAuthConfig>,
|
||||
credential: { readonly username: string; readonly password: typeof emptyCredential.password },
|
||||
) {
|
||||
return Effect.gen(function* () {
|
||||
if (Option.isNone(config.password) || config.password.value === "") return yield* effect
|
||||
if (!Flag.OPENCODE_SERVER_PASSWORD) return yield* effect
|
||||
|
||||
if (credential.username !== config.username) {
|
||||
return yield* new HttpApiError.Unauthorized({})
|
||||
if (credential.username !== (Flag.OPENCODE_SERVER_USERNAME ?? "opencode")) {
|
||||
return yield* new Unauthorized({ message: "Unauthorized" })
|
||||
}
|
||||
if (Redacted.value(credential.password) !== config.password.value) {
|
||||
return yield* new HttpApiError.Unauthorized({})
|
||||
if (Redacted.value(credential.password) !== Flag.OPENCODE_SERVER_PASSWORD) {
|
||||
return yield* new Unauthorized({ message: "Unauthorized" })
|
||||
}
|
||||
return yield* effect
|
||||
})
|
||||
}
|
||||
|
||||
function decodeCredential(input: string) {
|
||||
const emptyCredential = {
|
||||
username: "",
|
||||
password: Redacted.make(""),
|
||||
}
|
||||
|
||||
return Encoding.decodeBase64String(input)
|
||||
.asEffect()
|
||||
.pipe(
|
||||
@@ -62,16 +59,13 @@ function decodeCredential(input: string) {
|
||||
)
|
||||
}
|
||||
|
||||
export const authorizationLayer = Layer.effect(
|
||||
export const authorizationLayer = Layer.succeed(
|
||||
Authorization,
|
||||
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)),
|
||||
),
|
||||
})
|
||||
Authorization.of({
|
||||
basic: (effect, { credential }) => validateCredential(effect, credential),
|
||||
authToken: (effect, { credential }) =>
|
||||
Effect.gen(function* () {
|
||||
return yield* validateCredential(effect, yield* decodeCredential(Redacted.value(credential)))
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
||||
import { HttpRouter, HttpServer } from "effect/unstable/http"
|
||||
import { HttpMiddleware, HttpRouter, HttpServer } from "effect/unstable/http"
|
||||
import * as Socket from "effect/unstable/socket/Socket"
|
||||
import { Account } from "@/account/account"
|
||||
import { Agent } from "@/agent/agent"
|
||||
@@ -31,8 +31,9 @@ import { ToolRegistry } from "@/tool/registry"
|
||||
import { lazy } from "@/util/lazy"
|
||||
import { Vcs } from "@/project/vcs"
|
||||
import { Worktree } from "@/worktree"
|
||||
import { isAllowedCorsOrigin } from "@/server/cors"
|
||||
import { InstanceHttpApi, RootHttpApi } from "./api"
|
||||
import { ServerAuthConfig, authorizationLayer } from "./middleware/authorization"
|
||||
import { authorizationLayer } from "./middleware/authorization"
|
||||
import { eventRoute } from "./event"
|
||||
import { configHandlers } from "./handlers/config"
|
||||
import { controlHandlers } from "./handlers/control"
|
||||
@@ -56,7 +57,7 @@ import { disposeMiddleware } from "./lifecycle"
|
||||
import { memoMap } from "@opencode-ai/core/effect/memo-map"
|
||||
import * as ServerBackend from "@/server/backend"
|
||||
|
||||
export const context = Context.makeUnsafe<unknown>(new Map())
|
||||
export const context = Context.empty() as Context.Context<unknown>
|
||||
|
||||
const runtime = HttpRouter.middleware()(
|
||||
Effect.succeed((effect) =>
|
||||
@@ -68,6 +69,11 @@ const runtime = HttpRouter.middleware()(
|
||||
),
|
||||
).layer
|
||||
|
||||
const cors = HttpRouter.middleware(HttpMiddleware.cors({
|
||||
allowedOrigins: isAllowedCorsOrigin,
|
||||
maxAge: 86_400,
|
||||
}), { global: true })
|
||||
|
||||
const rootApiRoutes = HttpApiBuilder.layer(RootHttpApi).pipe(Layer.provide([controlHandlers, globalHandlers]))
|
||||
const instanceApiRoutes = HttpApiBuilder.layer(InstanceHttpApi).pipe(
|
||||
Layer.provide([
|
||||
@@ -97,7 +103,7 @@ const rawInstanceRoutes = Layer.mergeAll(eventRoute, ptyConnectRoute).pipe(
|
||||
)
|
||||
const instanceRoutes = Layer.mergeAll(rawInstanceRoutes, instanceApiRoutes).pipe(
|
||||
Layer.provide([
|
||||
authorizationLayer.pipe(Layer.provide(ServerAuthConfig.defaultLayer)),
|
||||
authorizationLayer,
|
||||
workspaceRoutingLayer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal)),
|
||||
instanceContextLayer,
|
||||
]),
|
||||
@@ -105,6 +111,7 @@ const instanceRoutes = Layer.mergeAll(rawInstanceRoutes, instanceApiRoutes).pipe
|
||||
|
||||
export const routes = Layer.mergeAll(rootApiRoutes, instanceRoutes).pipe(
|
||||
Layer.provide([
|
||||
cors,
|
||||
runtime,
|
||||
Account.defaultLayer,
|
||||
Agent.defaultLayer,
|
||||
|
||||
@@ -17,6 +17,7 @@ import { ServerProxy } from "./proxy"
|
||||
type Rule = { method?: string; path: string; exact?: boolean; action: "local" | "forward" }
|
||||
|
||||
const RULES: Array<Rule> = [
|
||||
{ path: "/experimental/workspace", action: "local" },
|
||||
{ path: "/session/status", action: "forward" },
|
||||
{ method: "GET", path: "/session", action: "local" },
|
||||
]
|
||||
|
||||
@@ -142,11 +142,15 @@ const Share = Schema.Struct({
|
||||
url: Schema.String,
|
||||
})
|
||||
|
||||
// Legacy HTTP accepted any number here, and persisted data may already contain
|
||||
// negative values. Keep archive timestamps permissive while other clocks stay non-negative.
|
||||
export const ArchivedTimestamp = Schema.Number
|
||||
|
||||
const Time = Schema.Struct({
|
||||
created: NonNegativeInt,
|
||||
updated: NonNegativeInt,
|
||||
compacting: optionalOmitUndefined(NonNegativeInt),
|
||||
archived: optionalOmitUndefined(NonNegativeInt),
|
||||
archived: optionalOmitUndefined(ArchivedTimestamp),
|
||||
})
|
||||
|
||||
const Revert = Schema.Struct({
|
||||
@@ -215,7 +219,7 @@ export const SetTitleInput = Schema.Struct({ sessionID: SessionID, title: Schema
|
||||
)
|
||||
export const SetArchivedInput = Schema.Struct({
|
||||
sessionID: SessionID,
|
||||
time: Schema.optional(NonNegativeInt),
|
||||
time: Schema.optional(ArchivedTimestamp),
|
||||
}).pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||
export const SetPermissionInput = Schema.Struct({
|
||||
sessionID: SessionID,
|
||||
@@ -244,7 +248,7 @@ const UpdatedTime = Schema.Struct({
|
||||
created: Schema.optional(Schema.NullOr(NonNegativeInt)),
|
||||
updated: Schema.optional(Schema.NullOr(NonNegativeInt)),
|
||||
compacting: Schema.optional(Schema.NullOr(NonNegativeInt)),
|
||||
archived: Schema.optional(Schema.NullOr(NonNegativeInt)),
|
||||
archived: Schema.optional(Schema.NullOr(ArchivedTimestamp)),
|
||||
})
|
||||
|
||||
const UpdatedInfo = Schema.Struct({
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
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,14 +2,11 @@ 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 { FilePaths } from "../../src/server/routes/instance/httpapi/groups/file"
|
||||
import { FileApi, 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"
|
||||
@@ -33,26 +30,7 @@ function app(input?: { password?: string; username?: string }) {
|
||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
|
||||
Flag.OPENCODE_SERVER_PASSWORD = input?.password
|
||||
Flag.OPENCODE_SERVER_USERNAME = input?.username
|
||||
|
||||
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))
|
||||
},
|
||||
}
|
||||
return Server.Default().app
|
||||
}
|
||||
|
||||
function openApiRouteKeys(spec: { paths: Record<string, Partial<Record<(typeof methods)[number], unknown>>> }) {
|
||||
@@ -116,9 +94,9 @@ type RequestBody = {
|
||||
required?: boolean
|
||||
}
|
||||
|
||||
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
|
||||
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
|
||||
return `${param.in}:${param.name}:${"required" in param && param.required === true}`
|
||||
}
|
||||
|
||||
@@ -127,29 +105,27 @@ 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 undefined
|
||||
if (!param || typeof param !== "object" || !("schema" in param)) return
|
||||
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)] as const)
|
||||
.sort(([left], [right]) => left.localeCompare(right)),
|
||||
.map(([type, value]) => [type, requestBodySchemaKind(spec, value.schema)])
|
||||
.sort(),
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
@@ -166,7 +142,6 @@ 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,64 @@
|
||||
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, HttpServer } from "effect/unstable/http"
|
||||
import * as Socket from "effect/unstable/socket/Socket"
|
||||
import { InstancePaths } from "../../src/server/routes/instance/httpapi/groups/instance"
|
||||
import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const testStateLayer = Layer.effectDiscard(
|
||||
Effect.gen(function* () {
|
||||
const original = {
|
||||
OPENCODE_EXPERIMENTAL_HTTPAPI: Flag.OPENCODE_EXPERIMENTAL_HTTPAPI,
|
||||
OPENCODE_SERVER_PASSWORD: Flag.OPENCODE_SERVER_PASSWORD,
|
||||
}
|
||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
|
||||
Flag.OPENCODE_SERVER_PASSWORD = "secret"
|
||||
yield* Effect.promise(() => resetDatabase())
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.promise(async () => {
|
||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original.OPENCODE_EXPERIMENTAL_HTTPAPI
|
||||
Flag.OPENCODE_SERVER_PASSWORD = original.OPENCODE_SERVER_PASSWORD
|
||||
await resetDatabase()
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
const servedRoutes: Layer.Layer<never, never, HttpServer.HttpServer> = HttpRouter.serve(
|
||||
ExperimentalHttpApiServer.routes,
|
||||
{ disableListenLog: true, disableLogger: true },
|
||||
)
|
||||
|
||||
const it = testEffect(
|
||||
Layer.mergeAll(
|
||||
testStateLayer,
|
||||
servedRoutes.pipe(
|
||||
Layer.provide(Socket.layerWebSocketConstructorGlobal),
|
||||
Layer.provideMerge(NodeHttpServer.layerTest),
|
||||
Layer.provideMerge(NodeServices.layer),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
describe("HttpApi CORS", () => {
|
||||
it.live("allows browser preflight requests without credentials", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* HttpClientRequest.options(InstancePaths.path).pipe(
|
||||
HttpClientRequest.setHeaders({
|
||||
origin: "http://localhost:3000",
|
||||
"access-control-request-method": "GET",
|
||||
"access-control-request-headers": "authorization",
|
||||
}),
|
||||
HttpClient.execute,
|
||||
)
|
||||
|
||||
expect(response.status).toBe(204)
|
||||
expect(response.headers["access-control-allow-origin"]).toBe("http://localhost:3000")
|
||||
expect(response.headers["access-control-allow-headers"]).toBe("authorization")
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -11,9 +11,9 @@ void Log.init({ print: false })
|
||||
|
||||
const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
|
||||
|
||||
function app() {
|
||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
|
||||
return Server.Default().app
|
||||
function app(experimental = true) {
|
||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = experimental
|
||||
return experimental ? Server.Default().app : Server.Legacy().app
|
||||
}
|
||||
|
||||
async function readFirstChunk(response: Response) {
|
||||
@@ -45,4 +45,13 @@ describe("event HttpApi bridge", () => {
|
||||
expect(response.headers.get("x-content-type-options")).toBe("nosniff")
|
||||
expect(await readFirstChunk(response)).toContain('data: {"type":"server.connected","properties":{}}\n\n')
|
||||
})
|
||||
|
||||
test("matches legacy first event frame", async () => {
|
||||
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
|
||||
const headers = { "x-opencode-directory": tmp.path }
|
||||
const legacy = await app(false).request(EventPaths.event, { headers })
|
||||
const effect = await app(true).request(EventPaths.event, { headers })
|
||||
|
||||
expect(await readFirstChunk(effect)).toBe(await readFirstChunk(legacy))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { NodeHttpServer, NodeServices } from "@effect/platform-node"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { GlobalBus } from "@/bus/global"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Effect, Fiber, 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"
|
||||
@@ -12,6 +13,7 @@ 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 { disposeMiddleware, markInstanceForDisposal } from "../../src/server/routes/instance/httpapi/lifecycle"
|
||||
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"
|
||||
@@ -84,6 +86,40 @@ const serveProbe = (probePath: HttpRouter.PathInput = "/probe") =>
|
||||
Layer.build,
|
||||
)
|
||||
|
||||
const waitDisposedEvent = Effect.promise(
|
||||
() =>
|
||||
new Promise<{ directory?: string; workspace?: string }>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
GlobalBus.off("event", onEvent)
|
||||
reject(new Error("timed out waiting for instance disposal"))
|
||||
}, 10_000)
|
||||
|
||||
function onEvent(event: { directory?: string; workspace?: string; payload: { type?: string } }) {
|
||||
if (event.payload.type !== "server.instance.disposed") return
|
||||
clearTimeout(timer)
|
||||
GlobalBus.off("event", onEvent)
|
||||
resolve({ directory: event.directory, workspace: event.workspace })
|
||||
}
|
||||
|
||||
GlobalBus.on("event", onEvent)
|
||||
}),
|
||||
)
|
||||
|
||||
const serveDisposeProbe = () =>
|
||||
HttpRouter.serve(
|
||||
HttpRouter.add(
|
||||
"POST",
|
||||
"/dispose-probe",
|
||||
Effect.gen(function* () {
|
||||
const instance = yield* InstanceRef
|
||||
if (!instance) return HttpServerResponse.empty({ status: 500 })
|
||||
yield* markInstanceForDisposal(instance)
|
||||
return yield* HttpServerResponse.json(true)
|
||||
}),
|
||||
).pipe(Layer.provide(instanceContextTestLayer)),
|
||||
{ middleware: disposeMiddleware, disableListenLog: true, disableLogger: true },
|
||||
).pipe(Layer.build)
|
||||
|
||||
describe("HttpApi instance context middleware", () => {
|
||||
it.live("provides instance context from the routed directory", () =>
|
||||
Effect.gen(function* () {
|
||||
@@ -164,4 +200,25 @@ describe("HttpApi instance context middleware", () => {
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("preserves selected workspace id on instance disposal events", () =>
|
||||
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-dispose-event",
|
||||
directory: workspaceDir,
|
||||
})
|
||||
yield* serveDisposeProbe()
|
||||
const disposed = yield* waitDisposedEvent.pipe(Effect.forkScoped)
|
||||
|
||||
const response = yield* HttpClientRequest.post(`/dispose-probe?workspace=${workspace.id}`).pipe(HttpClient.execute)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(yield* response.json).toBe(true)
|
||||
expect(yield* Fiber.join(disposed)).toEqual({ directory: workspaceDir, workspace: workspace.id })
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { GlobalBus } from "@/bus/global"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { Server } from "../../src/server/server"
|
||||
import { InstancePaths } from "../../src/server/routes/instance/httpapi/groups/instance"
|
||||
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 original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
|
||||
|
||||
function app() {
|
||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
|
||||
return Server.Default().app
|
||||
}
|
||||
|
||||
async function waitDisposed(directory: string) {
|
||||
return await new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
GlobalBus.off("event", onEvent)
|
||||
reject(new Error("timed out waiting for instance disposal"))
|
||||
}, 10_000)
|
||||
|
||||
function onEvent(event: { directory?: string; payload: { type?: string } }) {
|
||||
if (event.payload.type !== "server.instance.disposed" || event.directory !== directory) return
|
||||
clearTimeout(timer)
|
||||
GlobalBus.off("event", onEvent)
|
||||
resolve()
|
||||
}
|
||||
|
||||
GlobalBus.on("event", onEvent)
|
||||
})
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original
|
||||
await Instance.disposeAll()
|
||||
await resetDatabase()
|
||||
})
|
||||
|
||||
describe("instance HttpApi", () => {
|
||||
test("serves catalog read endpoints through Hono bridge", async () => {
|
||||
await using tmp = await tmpdir({ config: { formatter: false, lsp: false } })
|
||||
|
||||
const [commands, agents, skills, lsp, formatter] = await Promise.all([
|
||||
app().request(InstancePaths.command, { headers: { "x-opencode-directory": tmp.path } }),
|
||||
app().request(InstancePaths.agent, { headers: { "x-opencode-directory": tmp.path } }),
|
||||
app().request(InstancePaths.skill, { headers: { "x-opencode-directory": tmp.path } }),
|
||||
app().request(InstancePaths.lsp, { headers: { "x-opencode-directory": tmp.path } }),
|
||||
app().request(InstancePaths.formatter, { headers: { "x-opencode-directory": tmp.path } }),
|
||||
])
|
||||
|
||||
expect(commands.status).toBe(200)
|
||||
expect(await commands.json()).toContainEqual(expect.objectContaining({ name: "init", source: "command" }))
|
||||
|
||||
expect(agents.status).toBe(200)
|
||||
expect(await agents.json()).toContainEqual(expect.objectContaining({ name: "build", mode: "primary" }))
|
||||
|
||||
expect(skills.status).toBe(200)
|
||||
expect(await skills.json()).toBeArray()
|
||||
|
||||
expect(lsp.status).toBe(200)
|
||||
expect(await lsp.json()).toEqual([])
|
||||
|
||||
expect(formatter.status).toBe(200)
|
||||
expect(await formatter.json()).toEqual([])
|
||||
})
|
||||
|
||||
test("serves project git init through Hono bridge", async () => {
|
||||
await using tmp = await tmpdir({ config: { formatter: false, lsp: false } })
|
||||
const disposed = waitDisposed(tmp.path)
|
||||
|
||||
const response = await app().request("/project/git/init", {
|
||||
method: "POST",
|
||||
headers: { "x-opencode-directory": tmp.path },
|
||||
})
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(await response.json()).toMatchObject({ vcs: "git", worktree: tmp.path })
|
||||
await disposed
|
||||
|
||||
const current = await app().request("/project/current", { headers: { "x-opencode-directory": tmp.path } })
|
||||
expect(current.status).toBe(200)
|
||||
expect(await current.json()).toMatchObject({ vcs: "git", worktree: tmp.path })
|
||||
})
|
||||
|
||||
test("serves project update through Hono bridge", async () => {
|
||||
await using tmp = await tmpdir({ config: { formatter: false, lsp: false } })
|
||||
|
||||
const current = await app().request("/project/current", { headers: { "x-opencode-directory": tmp.path } })
|
||||
expect(current.status).toBe(200)
|
||||
const project = (await current.json()) as { id: string }
|
||||
|
||||
const response = await app().request(`/project/${project.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "x-opencode-directory": tmp.path, "content-type": "application/json" },
|
||||
body: JSON.stringify({ name: "patched-project", commands: { start: "bun dev" } }),
|
||||
})
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(await response.json()).toMatchObject({
|
||||
id: project.id,
|
||||
name: "patched-project",
|
||||
commands: { start: "bun dev" },
|
||||
})
|
||||
|
||||
const list = await app().request("/project", { headers: { "x-opencode-directory": tmp.path } })
|
||||
expect(list.status).toBe(200)
|
||||
expect(await list.json()).toContainEqual(
|
||||
expect.objectContaining({ id: project.id, name: "patched-project", commands: { start: "bun dev" } }),
|
||||
)
|
||||
})
|
||||
|
||||
test("serves instance dispose through Hono bridge", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
|
||||
const disposed = new Promise<string | undefined>((resolve) => {
|
||||
const onEvent = (event: { directory?: string; payload: { type?: string } }) => {
|
||||
if (event.payload.type !== "server.instance.disposed") return
|
||||
GlobalBus.off("event", onEvent)
|
||||
resolve(event.directory)
|
||||
}
|
||||
GlobalBus.on("event", onEvent)
|
||||
})
|
||||
|
||||
const response = await app().request(InstancePaths.dispose, {
|
||||
method: "POST",
|
||||
headers: { "x-opencode-directory": tmp.path },
|
||||
})
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(await response.json()).toBe(true)
|
||||
expect(await disposed).toBe(tmp.path)
|
||||
})
|
||||
})
|
||||
@@ -1,164 +1,83 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import path from "path"
|
||||
import { NodeHttpServer, NodeServices } from "@effect/platform-node"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { GlobalBus } from "@/bus/global"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { Server } from "../../src/server/server"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, FileSystem, Layer, Path } from "effect"
|
||||
import { HttpClient, HttpClientRequest, HttpRouter, HttpServer } from "effect/unstable/http"
|
||||
import * as Socket from "effect/unstable/socket/Socket"
|
||||
import { InstancePaths } from "../../src/server/routes/instance/httpapi/groups/instance"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import { tmpdirScoped } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
void Log.init({ print: false })
|
||||
// Flip the experimental HttpApi flag so backend selection telemetry on the
|
||||
// production routes reports the right backend, and reset the database around
|
||||
// the test so per-instance state does not leak between runs. resetDatabase()
|
||||
// already calls Instance.disposeAll(), so we don't repeat it.
|
||||
const testStateLayer = Layer.effectDiscard(
|
||||
Effect.gen(function* () {
|
||||
const originalHttpApi = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
|
||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
|
||||
yield* Effect.promise(() => resetDatabase())
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.promise(async () => {
|
||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = originalHttpApi
|
||||
await resetDatabase()
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
|
||||
// Mount the production HttpApi route tree on a real Node HTTP server bound to
|
||||
// 127.0.0.1:0 and a fetch-based HttpClient that prepends the server URL. This
|
||||
// keeps the test wired through the same route layer production uses, without
|
||||
// going through Server.Default()/Hono.
|
||||
const servedRoutes: Layer.Layer<never, never, HttpServer.HttpServer> = HttpRouter.serve(
|
||||
ExperimentalHttpApiServer.routes,
|
||||
{ disableListenLog: true, disableLogger: true },
|
||||
)
|
||||
|
||||
function app() {
|
||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
|
||||
return Server.Default().app
|
||||
}
|
||||
const httpApiServerLayer = servedRoutes.pipe(
|
||||
Layer.provide(Socket.layerWebSocketConstructorGlobal),
|
||||
Layer.provideMerge(NodeHttpServer.layerTest),
|
||||
Layer.provideMerge(NodeServices.layer),
|
||||
)
|
||||
|
||||
async function waitDisposed(directory: string) {
|
||||
return await new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
GlobalBus.off("event", onEvent)
|
||||
reject(new Error("timed out waiting for instance disposal"))
|
||||
}, 10_000)
|
||||
const it = testEffect(Layer.mergeAll(testStateLayer, httpApiServerLayer))
|
||||
|
||||
function onEvent(event: { directory?: string; payload: { type?: string } }) {
|
||||
if (event.payload.type !== "server.instance.disposed" || event.directory !== directory) return
|
||||
clearTimeout(timer)
|
||||
GlobalBus.off("event", onEvent)
|
||||
resolve()
|
||||
}
|
||||
|
||||
GlobalBus.on("event", onEvent)
|
||||
})
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original
|
||||
await Instance.disposeAll()
|
||||
await resetDatabase()
|
||||
})
|
||||
const directoryHeader = (dir: string) => HttpClientRequest.setHeader("x-opencode-directory", dir)
|
||||
|
||||
describe("instance HttpApi", () => {
|
||||
test("serves path and VCS read endpoints through Hono bridge", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await Bun.write(path.join(tmp.path, "changed.txt"), "hello")
|
||||
it.live("serves path and VCS read endpoints", () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdirScoped({ git: true })
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const path = yield* Path.Path
|
||||
yield* fs.writeFileString(path.join(dir, "changed.txt"), "hello")
|
||||
|
||||
const vcsDiff = new URL(`http://localhost${InstancePaths.vcsDiff}`)
|
||||
vcsDiff.searchParams.set("mode", "git")
|
||||
const [paths, vcs, diff] = yield* Effect.all(
|
||||
[
|
||||
HttpClientRequest.get(InstancePaths.path).pipe(directoryHeader(dir), HttpClient.execute),
|
||||
HttpClientRequest.get(InstancePaths.vcs).pipe(directoryHeader(dir), HttpClient.execute),
|
||||
HttpClientRequest.get(InstancePaths.vcsDiff).pipe(
|
||||
HttpClientRequest.setUrlParam("mode", "git"),
|
||||
directoryHeader(dir),
|
||||
HttpClient.execute,
|
||||
),
|
||||
],
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
|
||||
const [paths, vcs, diff] = await Promise.all([
|
||||
app().request(InstancePaths.path, { headers: { "x-opencode-directory": tmp.path } }),
|
||||
app().request(InstancePaths.vcs, { headers: { "x-opencode-directory": tmp.path } }),
|
||||
app().request(vcsDiff, { headers: { "x-opencode-directory": tmp.path } }),
|
||||
])
|
||||
expect(paths.status).toBe(200)
|
||||
expect(yield* paths.json).toMatchObject({ directory: dir, worktree: dir })
|
||||
|
||||
expect(paths.status).toBe(200)
|
||||
expect(await paths.json()).toMatchObject({ directory: tmp.path, worktree: tmp.path })
|
||||
expect(vcs.status).toBe(200)
|
||||
expect(yield* vcs.json).toMatchObject({ branch: expect.any(String) })
|
||||
|
||||
expect(vcs.status).toBe(200)
|
||||
expect(await vcs.json()).toMatchObject({ branch: expect.any(String) })
|
||||
|
||||
expect(diff.status).toBe(200)
|
||||
expect(await diff.json()).toContainEqual(
|
||||
expect.objectContaining({ file: "changed.txt", additions: 1, status: "added" }),
|
||||
)
|
||||
})
|
||||
|
||||
test("serves catalog read endpoints through Hono bridge", async () => {
|
||||
await using tmp = await tmpdir({ config: { formatter: false, lsp: false } })
|
||||
|
||||
const [commands, agents, skills, lsp, formatter] = await Promise.all([
|
||||
app().request(InstancePaths.command, { headers: { "x-opencode-directory": tmp.path } }),
|
||||
app().request(InstancePaths.agent, { headers: { "x-opencode-directory": tmp.path } }),
|
||||
app().request(InstancePaths.skill, { headers: { "x-opencode-directory": tmp.path } }),
|
||||
app().request(InstancePaths.lsp, { headers: { "x-opencode-directory": tmp.path } }),
|
||||
app().request(InstancePaths.formatter, { headers: { "x-opencode-directory": tmp.path } }),
|
||||
])
|
||||
|
||||
expect(commands.status).toBe(200)
|
||||
expect(await commands.json()).toContainEqual(expect.objectContaining({ name: "init", source: "command" }))
|
||||
|
||||
expect(agents.status).toBe(200)
|
||||
expect(await agents.json()).toContainEqual(expect.objectContaining({ name: "build", mode: "primary" }))
|
||||
|
||||
expect(skills.status).toBe(200)
|
||||
expect(await skills.json()).toBeArray()
|
||||
|
||||
expect(lsp.status).toBe(200)
|
||||
expect(await lsp.json()).toEqual([])
|
||||
|
||||
expect(formatter.status).toBe(200)
|
||||
expect(await formatter.json()).toEqual([])
|
||||
})
|
||||
|
||||
test("serves project git init through Hono bridge", async () => {
|
||||
await using tmp = await tmpdir({ config: { formatter: false, lsp: false } })
|
||||
const disposed = waitDisposed(tmp.path)
|
||||
|
||||
const response = await app().request("/project/git/init", {
|
||||
method: "POST",
|
||||
headers: { "x-opencode-directory": tmp.path },
|
||||
})
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(await response.json()).toMatchObject({ vcs: "git", worktree: tmp.path })
|
||||
await disposed
|
||||
|
||||
const current = await app().request("/project/current", { headers: { "x-opencode-directory": tmp.path } })
|
||||
expect(current.status).toBe(200)
|
||||
expect(await current.json()).toMatchObject({ vcs: "git", worktree: tmp.path })
|
||||
})
|
||||
|
||||
test("serves project update through Hono bridge", async () => {
|
||||
await using tmp = await tmpdir({ config: { formatter: false, lsp: false } })
|
||||
|
||||
const current = await app().request("/project/current", { headers: { "x-opencode-directory": tmp.path } })
|
||||
expect(current.status).toBe(200)
|
||||
const project = (await current.json()) as { id: string }
|
||||
|
||||
const response = await app().request(`/project/${project.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "x-opencode-directory": tmp.path, "content-type": "application/json" },
|
||||
body: JSON.stringify({ name: "patched-project", commands: { start: "bun dev" } }),
|
||||
})
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(await response.json()).toMatchObject({
|
||||
id: project.id,
|
||||
name: "patched-project",
|
||||
commands: { start: "bun dev" },
|
||||
})
|
||||
|
||||
const list = await app().request("/project", { headers: { "x-opencode-directory": tmp.path } })
|
||||
expect(list.status).toBe(200)
|
||||
expect(await list.json()).toContainEqual(
|
||||
expect.objectContaining({ id: project.id, name: "patched-project", commands: { start: "bun dev" } }),
|
||||
)
|
||||
})
|
||||
|
||||
test("serves instance dispose through Hono bridge", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
|
||||
const disposed = new Promise<string | undefined>((resolve) => {
|
||||
const onEvent = (event: { directory?: string; payload: { type?: string } }) => {
|
||||
if (event.payload.type !== "server.instance.disposed") return
|
||||
GlobalBus.off("event", onEvent)
|
||||
resolve(event.directory)
|
||||
}
|
||||
GlobalBus.on("event", onEvent)
|
||||
})
|
||||
|
||||
const response = await app().request(InstancePaths.dispose, {
|
||||
method: "POST",
|
||||
headers: { "x-opencode-directory": tmp.path },
|
||||
})
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(await response.json()).toBe(true)
|
||||
expect(await disposed).toBe(tmp.path)
|
||||
})
|
||||
expect(diff.status).toBe(200)
|
||||
expect(yield* diff.json).toContainEqual(
|
||||
expect.objectContaining({ file: "changed.txt", additions: 1, status: "added" }),
|
||||
)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { NodeHttpServer } from "@effect/platform-node"
|
||||
import { Session } from "@/session/session"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { HttpClient, HttpClientRequest, HttpRouter } from "effect/unstable/http"
|
||||
import { HttpApi, HttpApiBuilder } from "effect/unstable/httpapi"
|
||||
import { McpApi, McpPaths } from "../../src/server/routes/instance/httpapi/groups/mcp"
|
||||
import { Authorization } from "../../src/server/routes/instance/httpapi/middleware/authorization"
|
||||
import { InstanceContextMiddleware } from "../../src/server/routes/instance/httpapi/middleware/instance-context"
|
||||
import {
|
||||
WorkspaceRouteContext,
|
||||
WorkspaceRoutingMiddleware,
|
||||
} from "../../src/server/routes/instance/httpapi/middleware/workspace-routing"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const TestHttpApi = HttpApi.make("opencode-instance").addHttpApi(McpApi)
|
||||
const fakeSession = Layer.mock(Session.Service)({})
|
||||
const testMcpHandlers = HttpApiBuilder.group(TestHttpApi, "mcp", (handlers) =>
|
||||
Effect.succeed(
|
||||
handlers
|
||||
.handle("status", () => Effect.die("unexpected MCP status"))
|
||||
.handle("add", () => Effect.die("unexpected MCP add"))
|
||||
.handle("authStart", () =>
|
||||
Effect.succeed({ authorizationUrl: "https://auth.example/start", oauthState: "state-123" }),
|
||||
)
|
||||
.handle("authCallback", () => Effect.die("unexpected MCP authCallback"))
|
||||
.handle("authAuthenticate", () => Effect.die("unexpected MCP authAuthenticate"))
|
||||
.handle("authRemove", () => Effect.die("unexpected MCP authRemove"))
|
||||
.handle("connect", () => Effect.die("unexpected MCP connect"))
|
||||
.handle("disconnect", () => Effect.die("unexpected MCP disconnect")),
|
||||
),
|
||||
)
|
||||
|
||||
const passthroughAuthorization = Layer.succeed(
|
||||
Authorization,
|
||||
Authorization.of({
|
||||
basic: (effect) => effect,
|
||||
authToken: (effect) => effect,
|
||||
}),
|
||||
)
|
||||
|
||||
const passthroughInstanceContext = Layer.succeed(
|
||||
InstanceContextMiddleware,
|
||||
InstanceContextMiddleware.of((effect) => effect),
|
||||
)
|
||||
|
||||
const testWorkspaceRouting = Layer.succeed(
|
||||
WorkspaceRoutingMiddleware,
|
||||
WorkspaceRoutingMiddleware.of((effect) =>
|
||||
effect.pipe(
|
||||
Effect.provideService(
|
||||
WorkspaceRouteContext,
|
||||
WorkspaceRouteContext.of({ directory: process.cwd() }),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
const it = testEffect(
|
||||
HttpRouter.serve(
|
||||
HttpApiBuilder.layer(TestHttpApi).pipe(
|
||||
Layer.provide(testMcpHandlers),
|
||||
Layer.provide([passthroughAuthorization, passthroughInstanceContext, testWorkspaceRouting, fakeSession]),
|
||||
),
|
||||
{ disableListenLog: true, disableLogger: true },
|
||||
).pipe(Layer.provideMerge(NodeHttpServer.layerTest)),
|
||||
)
|
||||
|
||||
describe("mcp HttpApi OAuth", () => {
|
||||
it.live("preserves oauth state when starting OAuth", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* HttpClientRequest.post(McpPaths.auth.replace(":name", "demo")).pipe(HttpClient.execute)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(yield* response.json).toEqual({
|
||||
authorizationUrl: "https://auth.example/start",
|
||||
oauthState: "state-123",
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { handlePtyInput } from "../../src/pty/input"
|
||||
|
||||
describe("pty HttpApi websocket input", () => {
|
||||
test("does not forward invalid binary frames to the PTY handler", async () => {
|
||||
const messages: Array<string | ArrayBuffer> = []
|
||||
const handler = { onMessage: (message: string | ArrayBuffer) => messages.push(message) }
|
||||
|
||||
await Effect.runPromise(handlePtyInput(handler, "ready"))
|
||||
await Effect.runPromise(handlePtyInput(handler, new Uint8Array([0xff, 0xfe, 0xfd])))
|
||||
await Effect.runPromise(handlePtyInput(handler, new TextEncoder().encode("hello")))
|
||||
|
||||
expect(messages).toEqual(["ready", "hello"])
|
||||
})
|
||||
})
|
||||
@@ -1,11 +1,9 @@
|
||||
import { afterEach, describe, expect } from "bun:test"
|
||||
import { ConfigProvider, Effect, Layer } from "effect"
|
||||
import { Effect } 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"
|
||||
@@ -35,27 +33,7 @@ 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
|
||||
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))
|
||||
},
|
||||
}
|
||||
return backend === "httpapi" ? Server.Default().app : Server.Legacy().app
|
||||
}
|
||||
|
||||
function client(
|
||||
@@ -145,7 +123,7 @@ function firstEvent(open: () => Promise<{ stream: AsyncIterator<unknown> }>) {
|
||||
}
|
||||
|
||||
function record(value: unknown) {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? Object.fromEntries(Object.entries(value)) : {}
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : {}
|
||||
}
|
||||
|
||||
function array(value: unknown) {
|
||||
@@ -404,7 +382,7 @@ describe("HttpApi SDK", () => {
|
||||
lsp,
|
||||
}),
|
||||
project: { worktreeSelected: record(project.data).worktree === directory },
|
||||
paths: { cwdSelected: record(paths.data).cwd === directory },
|
||||
paths: { directorySelected: record(paths.data).directory === directory },
|
||||
file: record(file.data).content,
|
||||
hasProject: array(projects.data).length > 0,
|
||||
foundFile: JSON.stringify(findFiles.data).includes("hello.txt"),
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { afterEach, describe, expect } from "bun:test"
|
||||
import { mkdir } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { Effect } from "effect"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { PermissionID } from "../../src/permission/schema"
|
||||
@@ -9,7 +11,10 @@ import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/se
|
||||
import { Session } from "@/session/session"
|
||||
import { MessageID, PartID, type SessionID } from "../../src/session/schema"
|
||||
import { MessageV2 } from "../../src/session/message-v2"
|
||||
import { Database } from "@/storage/db"
|
||||
import { SessionTable } from "@/session/session.sql"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import { it } from "../lib/effect"
|
||||
@@ -18,9 +23,9 @@ void Log.init({ print: false })
|
||||
|
||||
const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
|
||||
|
||||
function app() {
|
||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
|
||||
return Server.Default().app
|
||||
function app(experimental = true) {
|
||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = experimental
|
||||
return experimental ? Server.Default().app : Server.Legacy().app
|
||||
}
|
||||
|
||||
function runSession<A, E>(fx: Effect.Effect<A, E, Session.Service>) {
|
||||
@@ -76,6 +81,10 @@ function request(path: string, init?: RequestInit) {
|
||||
return Effect.promise(async () => app().request(path, init))
|
||||
}
|
||||
|
||||
function requestWithBackend(experimental: boolean, path: string, init?: RequestInit) {
|
||||
return Effect.promise(async () => app(experimental).request(path, init))
|
||||
}
|
||||
|
||||
function json<T>(response: Response) {
|
||||
return Effect.promise(async () => {
|
||||
if (response.status !== 200) throw new Error(await response.text())
|
||||
@@ -217,6 +226,91 @@ describe("session HttpApi", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live(
|
||||
"matches legacy archived timestamp validation",
|
||||
withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const headers = { "x-opencode-directory": tmp.path, "content-type": "application/json" }
|
||||
const legacy = yield* createSession(tmp.path, { title: "legacy" })
|
||||
const effect = yield* createSession(tmp.path, { title: "effect" })
|
||||
const body = JSON.stringify({ time: { archived: -1 } })
|
||||
|
||||
const legacyResponse = yield* requestWithBackend(false, pathFor(SessionPaths.update, { sessionID: legacy.id }), {
|
||||
method: "PATCH",
|
||||
headers,
|
||||
body,
|
||||
})
|
||||
expect(legacyResponse.status).toBe(200)
|
||||
expect((yield* json<Session.Info>(legacyResponse)).time.archived).toBe(-1)
|
||||
|
||||
const effectResponse = yield* requestWithBackend(true, pathFor(SessionPaths.update, { sessionID: effect.id }), {
|
||||
method: "PATCH",
|
||||
headers,
|
||||
body,
|
||||
})
|
||||
expect(effectResponse.status).toBe(legacyResponse.status)
|
||||
expect((yield* json<Session.Info>(effectResponse)).time.archived).toBe(-1)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live(
|
||||
"matches legacy project-scoped path and directory precedence",
|
||||
withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const currentDir = path.join(tmp.path, "packages", "opencode", "src")
|
||||
yield* Effect.promise(() => mkdir(currentDir, { recursive: true }))
|
||||
|
||||
const pathSession = yield* createSession(currentDir)
|
||||
const pathlessSession = yield* createSession(currentDir)
|
||||
yield* Effect.sync(() =>
|
||||
Database.use((db) =>
|
||||
db.update(SessionTable).set({ path: null }).where(eq(SessionTable.id, pathlessSession.id)).run(),
|
||||
),
|
||||
)
|
||||
|
||||
const query = new URLSearchParams({
|
||||
scope: "project",
|
||||
path: "packages/opencode/src",
|
||||
directory: currentDir,
|
||||
})
|
||||
const headers = { "x-opencode-directory": tmp.path }
|
||||
const legacy = (yield* json<Session.Info[]>(
|
||||
yield* requestWithBackend(false, `${SessionPaths.list}?${query}`, { headers }),
|
||||
)).map((item) => item.id)
|
||||
const effect = (yield* json<Session.Info[]>(
|
||||
yield* requestWithBackend(true, `${SessionPaths.list}?${query}`, { headers }),
|
||||
)).map((item) => item.id)
|
||||
|
||||
expect(legacy).toContain(pathSession.id)
|
||||
expect(legacy).not.toContain(pathlessSession.id)
|
||||
expect(effect).toEqual(legacy)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live(
|
||||
"matches legacy paginated message link headers",
|
||||
withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const headers = { "x-opencode-directory": tmp.path }
|
||||
const session = yield* createSession(tmp.path, { title: "messages" })
|
||||
yield* createTextMessage(tmp.path, session.id, "first")
|
||||
yield* createTextMessage(tmp.path, session.id, "second")
|
||||
const route = `${pathFor(SessionPaths.messages, { sessionID: session.id })}?limit=1`
|
||||
|
||||
const legacy = yield* requestWithBackend(false, route, { headers })
|
||||
const effect = yield* requestWithBackend(true, route, { headers })
|
||||
|
||||
expect(effect.headers.get("x-next-cursor")).toBe(legacy.headers.get("x-next-cursor"))
|
||||
expect(effect.headers.get("link")).toBe(legacy.headers.get("link"))
|
||||
expect(effect.headers.get("access-control-expose-headers")).toBe(
|
||||
legacy.headers.get("access-control-expose-headers"),
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live(
|
||||
"serves message mutation routes through Hono bridge",
|
||||
withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) =>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import type { Context } from "hono"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { GlobalBus } from "../../src/bus/global"
|
||||
import { TuiEvent } from "../../src/cli/cmd/tui/event"
|
||||
import { SessionID } from "../../src/session/schema"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { TuiApi, TuiPaths } from "../../src/server/routes/instance/httpapi/groups/tui"
|
||||
@@ -15,9 +17,20 @@ void Log.init({ print: false })
|
||||
|
||||
const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
|
||||
|
||||
function app() {
|
||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
|
||||
return Server.Default().app
|
||||
function app(experimental = true) {
|
||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = experimental
|
||||
return experimental ? Server.Default().app : Server.Legacy().app
|
||||
}
|
||||
|
||||
function nextCommandExecute() {
|
||||
return new Promise<unknown>((resolve) => {
|
||||
const listener = (event: { payload: { type?: string; properties?: { command?: unknown } } }) => {
|
||||
if (event.payload.type !== TuiEvent.CommandExecute.type) return
|
||||
GlobalBus.off("event", listener)
|
||||
resolve(event.payload.properties?.command)
|
||||
}
|
||||
GlobalBus.on("event", listener)
|
||||
})
|
||||
}
|
||||
|
||||
async function expectTrue(path: string, headers: Record<string, string>, body?: unknown) {
|
||||
@@ -72,6 +85,27 @@ describe("tui HttpApi bridge", () => {
|
||||
expect(missing.status).toBe(404)
|
||||
})
|
||||
|
||||
test("matches legacy unknown execute command behavior", async () => {
|
||||
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
|
||||
const headers = { "x-opencode-directory": tmp.path, "content-type": "application/json" }
|
||||
const body = JSON.stringify({ command: "unknown_command" })
|
||||
|
||||
const legacyCommand = nextCommandExecute()
|
||||
const legacy = await app(false).request(TuiPaths.executeCommand, { method: "POST", headers, body })
|
||||
expect(legacy.status).toBe(200)
|
||||
expect(await legacy.json()).toBe(true)
|
||||
|
||||
const effectCommand = nextCommandExecute()
|
||||
const effect = await app().request(TuiPaths.executeCommand, { method: "POST", headers, body })
|
||||
expect(effect.status).toBe(200)
|
||||
expect(await effect.json()).toBe(true)
|
||||
|
||||
const legacyPublished = await legacyCommand
|
||||
const effectPublished = await effectCommand
|
||||
expect(effectPublished).toBe(legacyPublished)
|
||||
expect(legacyPublished).toBeUndefined()
|
||||
})
|
||||
|
||||
test("serves TUI control queue through experimental Effect routes", async () => {
|
||||
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
|
||||
const pending = callTui({ req: { json: async () => ({ value: 1 }), path: "/demo" } } as unknown as Context)
|
||||
|
||||
@@ -20,6 +20,7 @@ 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 { WorkspacePaths } from "../../src/server/routes/instance/httpapi/groups/workspace"
|
||||
import {
|
||||
WorkspaceRouteContext,
|
||||
workspaceRouterMiddleware,
|
||||
@@ -387,6 +388,36 @@ describe("HttpApi workspace routing middleware", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("keeps workspace control 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: "workspace-control-plane-target",
|
||||
directory: workspaceDir,
|
||||
})
|
||||
|
||||
// Workspace CRUD/status routes manage the control plane itself. Selecting
|
||||
// a workspace should preserve the selected id for handlers, but must not
|
||||
// swap the route context to the workspace target directory.
|
||||
yield* HttpRouter.add(
|
||||
"GET",
|
||||
WorkspacePaths.list,
|
||||
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(`${WorkspacePaths.list}?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()
|
||||
|
||||
@@ -1,39 +1,16 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
|
||||
import path from "path"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { Effect } from "effect"
|
||||
import { ModelID, ProviderID } from "../../src/provider/schema"
|
||||
import { Instruction } from "../../src/session/instruction"
|
||||
import type { MessageV2 } from "../../src/session/message-v2"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { MessageID, PartID, SessionID } from "../../src/session/schema"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { provideInstance, provideTmpdirInstance, tmpdirScoped } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
const it = testEffect(Layer.mergeAll(Instruction.defaultLayer, CrossSpawnSpawner.defaultLayer))
|
||||
|
||||
const write = (filepath: string, content: string) => Effect.promise(() => Bun.write(filepath, content))
|
||||
|
||||
const writeFiles = (dir: string, files: Record<string, string>) =>
|
||||
Effect.all(
|
||||
Object.entries(files).map(([file, content]) => write(path.join(dir, file), content)),
|
||||
{ discard: true },
|
||||
)
|
||||
|
||||
const withFiles = <A, E, R>(files: Record<string, string>, self: (dir: string) => Effect.Effect<A, E, R>) =>
|
||||
provideTmpdirInstance((dir) =>
|
||||
Effect.gen(function* () {
|
||||
yield* writeFiles(dir, files)
|
||||
return yield* self(dir)
|
||||
}),
|
||||
)
|
||||
|
||||
const tmpWithFiles = (files: Record<string, string>) =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdirScoped()
|
||||
yield* writeFiles(dir, files)
|
||||
return dir
|
||||
})
|
||||
const run = <A>(effect: Effect.Effect<A, any, Instruction.Service>) =>
|
||||
Effect.runPromise(effect.pipe(Effect.provide(Instruction.defaultLayer)))
|
||||
|
||||
function loaded(filepath: string): MessageV2.WithParts[] {
|
||||
const sessionID = SessionID.make("session-loaded-1")
|
||||
@@ -75,143 +52,224 @@ function loaded(filepath: string): MessageV2.WithParts[] {
|
||||
}
|
||||
|
||||
describe("Instruction.resolve", () => {
|
||||
it.live("returns empty when AGENTS.md is at project root (already in systemPaths)", () =>
|
||||
withFiles({ "AGENTS.md": "# Root Instructions", "src/file.ts": "const x = 1" }, (dir) =>
|
||||
Instruction.Service.use((svc) =>
|
||||
Effect.gen(function* () {
|
||||
const system = yield* svc.systemPaths()
|
||||
expect(system.has(path.join(dir, "AGENTS.md"))).toBe(true)
|
||||
test("returns empty when AGENTS.md is at project root (already in systemPaths)", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "AGENTS.md"), "# Root Instructions")
|
||||
await Bun.write(path.join(dir, "src", "file.ts"), "const x = 1")
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: () =>
|
||||
run(
|
||||
Instruction.Service.use((svc) =>
|
||||
Effect.gen(function* () {
|
||||
const system = yield* svc.systemPaths()
|
||||
expect(system.has(path.join(tmp.path, "AGENTS.md"))).toBe(true)
|
||||
|
||||
const results = yield* svc.resolve([], path.join(dir, "src", "file.ts"), MessageID.make("message-test-1"))
|
||||
expect(results).toEqual([])
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
const results = yield* svc.resolve(
|
||||
[],
|
||||
path.join(tmp.path, "src", "file.ts"),
|
||||
MessageID.make("message-test-1"),
|
||||
)
|
||||
expect(results).toEqual([])
|
||||
}),
|
||||
),
|
||||
),
|
||||
})
|
||||
})
|
||||
|
||||
it.live("returns AGENTS.md from subdirectory (not in systemPaths)", () =>
|
||||
withFiles({ "subdir/AGENTS.md": "# Subdir Instructions", "subdir/nested/file.ts": "const x = 1" }, (dir) =>
|
||||
Instruction.Service.use((svc) =>
|
||||
Effect.gen(function* () {
|
||||
const system = yield* svc.systemPaths()
|
||||
expect(system.has(path.join(dir, "subdir", "AGENTS.md"))).toBe(false)
|
||||
test("returns AGENTS.md from subdirectory (not in systemPaths)", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "subdir", "AGENTS.md"), "# Subdir Instructions")
|
||||
await Bun.write(path.join(dir, "subdir", "nested", "file.ts"), "const x = 1")
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: () =>
|
||||
run(
|
||||
Instruction.Service.use((svc) =>
|
||||
Effect.gen(function* () {
|
||||
const system = yield* svc.systemPaths()
|
||||
expect(system.has(path.join(tmp.path, "subdir", "AGENTS.md"))).toBe(false)
|
||||
|
||||
const results = yield* svc.resolve(
|
||||
[],
|
||||
path.join(dir, "subdir", "nested", "file.ts"),
|
||||
MessageID.make("message-test-2"),
|
||||
)
|
||||
expect(results.length).toBe(1)
|
||||
expect(results[0].filepath).toBe(path.join(dir, "subdir", "AGENTS.md"))
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
const results = yield* svc.resolve(
|
||||
[],
|
||||
path.join(tmp.path, "subdir", "nested", "file.ts"),
|
||||
MessageID.make("message-test-2"),
|
||||
)
|
||||
expect(results.length).toBe(1)
|
||||
expect(results[0].filepath).toBe(path.join(tmp.path, "subdir", "AGENTS.md"))
|
||||
}),
|
||||
),
|
||||
),
|
||||
})
|
||||
})
|
||||
|
||||
it.live("doesn't reload AGENTS.md when reading it directly", () =>
|
||||
withFiles({ "subdir/AGENTS.md": "# Subdir Instructions", "subdir/nested/file.ts": "const x = 1" }, (dir) =>
|
||||
Instruction.Service.use((svc) =>
|
||||
Effect.gen(function* () {
|
||||
const filepath = path.join(dir, "subdir", "AGENTS.md")
|
||||
const system = yield* svc.systemPaths()
|
||||
expect(system.has(filepath)).toBe(false)
|
||||
test("doesn't reload AGENTS.md when reading it directly", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "subdir", "AGENTS.md"), "# Subdir Instructions")
|
||||
await Bun.write(path.join(dir, "subdir", "nested", "file.ts"), "const x = 1")
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: () =>
|
||||
run(
|
||||
Instruction.Service.use((svc) =>
|
||||
Effect.gen(function* () {
|
||||
const filepath = path.join(tmp.path, "subdir", "AGENTS.md")
|
||||
const system = yield* svc.systemPaths()
|
||||
expect(system.has(filepath)).toBe(false)
|
||||
|
||||
const results = yield* svc.resolve([], filepath, MessageID.make("message-test-3"))
|
||||
expect(results).toEqual([])
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
const results = yield* svc.resolve([], filepath, MessageID.make("message-test-3"))
|
||||
expect(results).toEqual([])
|
||||
}),
|
||||
),
|
||||
),
|
||||
})
|
||||
})
|
||||
|
||||
it.live("does not reattach the same nearby instructions twice for one message", () =>
|
||||
withFiles({ "subdir/AGENTS.md": "# Subdir Instructions", "subdir/nested/file.ts": "const x = 1" }, (dir) =>
|
||||
Instruction.Service.use((svc) =>
|
||||
Effect.gen(function* () {
|
||||
const filepath = path.join(dir, "subdir", "nested", "file.ts")
|
||||
const id = MessageID.make("message-claim-1")
|
||||
test("does not reattach the same nearby instructions twice for one message", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "subdir", "AGENTS.md"), "# Subdir Instructions")
|
||||
await Bun.write(path.join(dir, "subdir", "nested", "file.ts"), "const x = 1")
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: () =>
|
||||
run(
|
||||
Instruction.Service.use((svc) =>
|
||||
Effect.gen(function* () {
|
||||
const filepath = path.join(tmp.path, "subdir", "nested", "file.ts")
|
||||
const id = MessageID.make("message-claim-1")
|
||||
|
||||
const first = yield* svc.resolve([], filepath, id)
|
||||
const second = yield* svc.resolve([], filepath, id)
|
||||
const first = yield* svc.resolve([], filepath, id)
|
||||
const second = yield* svc.resolve([], filepath, id)
|
||||
|
||||
expect(first).toHaveLength(1)
|
||||
expect(first[0].filepath).toBe(path.join(dir, "subdir", "AGENTS.md"))
|
||||
expect(second).toEqual([])
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
expect(first).toHaveLength(1)
|
||||
expect(first[0].filepath).toBe(path.join(tmp.path, "subdir", "AGENTS.md"))
|
||||
expect(second).toEqual([])
|
||||
}),
|
||||
),
|
||||
),
|
||||
})
|
||||
})
|
||||
|
||||
it.live("clear allows nearby instructions to be attached again for the same message", () =>
|
||||
withFiles({ "subdir/AGENTS.md": "# Subdir Instructions", "subdir/nested/file.ts": "const x = 1" }, (dir) =>
|
||||
Instruction.Service.use((svc) =>
|
||||
Effect.gen(function* () {
|
||||
const filepath = path.join(dir, "subdir", "nested", "file.ts")
|
||||
const id = MessageID.make("message-claim-2")
|
||||
test("clear allows nearby instructions to be attached again for the same message", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "subdir", "AGENTS.md"), "# Subdir Instructions")
|
||||
await Bun.write(path.join(dir, "subdir", "nested", "file.ts"), "const x = 1")
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: () =>
|
||||
run(
|
||||
Instruction.Service.use((svc) =>
|
||||
Effect.gen(function* () {
|
||||
const filepath = path.join(tmp.path, "subdir", "nested", "file.ts")
|
||||
const id = MessageID.make("message-claim-2")
|
||||
|
||||
const first = yield* svc.resolve([], filepath, id)
|
||||
yield* svc.clear(id)
|
||||
const second = yield* svc.resolve([], filepath, id)
|
||||
const first = yield* svc.resolve([], filepath, id)
|
||||
yield* svc.clear(id)
|
||||
const second = yield* svc.resolve([], filepath, id)
|
||||
|
||||
expect(first).toHaveLength(1)
|
||||
expect(second).toHaveLength(1)
|
||||
expect(second[0].filepath).toBe(path.join(dir, "subdir", "AGENTS.md"))
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
expect(first).toHaveLength(1)
|
||||
expect(second).toHaveLength(1)
|
||||
expect(second[0].filepath).toBe(path.join(tmp.path, "subdir", "AGENTS.md"))
|
||||
}),
|
||||
),
|
||||
),
|
||||
})
|
||||
})
|
||||
|
||||
it.live("skips instructions already reported by prior read metadata", () =>
|
||||
withFiles({ "subdir/AGENTS.md": "# Subdir Instructions", "subdir/nested/file.ts": "const x = 1" }, (dir) =>
|
||||
Instruction.Service.use((svc) =>
|
||||
Effect.gen(function* () {
|
||||
const agents = path.join(dir, "subdir", "AGENTS.md")
|
||||
const filepath = path.join(dir, "subdir", "nested", "file.ts")
|
||||
const id = MessageID.make("message-claim-3")
|
||||
test("skips instructions already reported by prior read metadata", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "subdir", "AGENTS.md"), "# Subdir Instructions")
|
||||
await Bun.write(path.join(dir, "subdir", "nested", "file.ts"), "const x = 1")
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: () =>
|
||||
run(
|
||||
Instruction.Service.use((svc) =>
|
||||
Effect.gen(function* () {
|
||||
const agents = path.join(tmp.path, "subdir", "AGENTS.md")
|
||||
const filepath = path.join(tmp.path, "subdir", "nested", "file.ts")
|
||||
const id = MessageID.make("message-claim-3")
|
||||
|
||||
const results = yield* svc.resolve(loaded(agents), filepath, id)
|
||||
expect(results).toEqual([])
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
const results = yield* svc.resolve(loaded(agents), filepath, id)
|
||||
expect(results).toEqual([])
|
||||
}),
|
||||
),
|
||||
),
|
||||
})
|
||||
})
|
||||
|
||||
test.todo("fetches remote instructions from config URLs via HttpClient", () => {})
|
||||
})
|
||||
|
||||
describe("Instruction.system", () => {
|
||||
it.live("loads both project and global AGENTS.md when both exist", () =>
|
||||
Effect.gen(function* () {
|
||||
const originalConfigDir = process.env["OPENCODE_CONFIG_DIR"]
|
||||
delete process.env["OPENCODE_CONFIG_DIR"]
|
||||
test("loads both project and global AGENTS.md when both exist", async () => {
|
||||
const originalConfigDir = process.env["OPENCODE_CONFIG_DIR"]
|
||||
delete process.env["OPENCODE_CONFIG_DIR"]
|
||||
|
||||
const globalTmp = yield* tmpWithFiles({ "AGENTS.md": "# Global Instructions" })
|
||||
const projectTmp = yield* tmpWithFiles({ "AGENTS.md": "# Project Instructions" })
|
||||
const originalGlobalConfig = Global.Path.config
|
||||
;(Global.Path as { config: string }).config = globalTmp
|
||||
await using globalTmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "AGENTS.md"), "# Global Instructions")
|
||||
},
|
||||
})
|
||||
await using projectTmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "AGENTS.md"), "# Project Instructions")
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
yield* Instruction.Service.use((svc) =>
|
||||
Effect.gen(function* () {
|
||||
const paths = yield* svc.systemPaths()
|
||||
expect(paths.has(path.join(projectTmp, "AGENTS.md"))).toBe(true)
|
||||
expect(paths.has(path.join(globalTmp, "AGENTS.md"))).toBe(true)
|
||||
const originalGlobalConfig = Global.Path.config
|
||||
;(Global.Path as { config: string }).config = globalTmp.path
|
||||
|
||||
const rules = yield* svc.system()
|
||||
expect(rules).toHaveLength(2)
|
||||
expect(rules[0]).toBe(`Instructions from: ${path.join(globalTmp, "AGENTS.md")}\n# Global Instructions`)
|
||||
expect(rules[1]).toBe(`Instructions from: ${path.join(projectTmp, "AGENTS.md")}\n# Project Instructions`)
|
||||
}),
|
||||
).pipe(provideInstance(projectTmp))
|
||||
} finally {
|
||||
;(Global.Path as { config: string }).config = originalGlobalConfig
|
||||
if (originalConfigDir === undefined) {
|
||||
delete process.env["OPENCODE_CONFIG_DIR"]
|
||||
} else {
|
||||
process.env["OPENCODE_CONFIG_DIR"] = originalConfigDir
|
||||
}
|
||||
try {
|
||||
await Instance.provide({
|
||||
directory: projectTmp.path,
|
||||
fn: () =>
|
||||
run(
|
||||
Instruction.Service.use((svc) =>
|
||||
Effect.gen(function* () {
|
||||
const paths = yield* svc.systemPaths()
|
||||
expect(paths.has(path.join(projectTmp.path, "AGENTS.md"))).toBe(true)
|
||||
expect(paths.has(path.join(globalTmp.path, "AGENTS.md"))).toBe(true)
|
||||
|
||||
const rules = yield* svc.system()
|
||||
expect(rules).toHaveLength(2)
|
||||
expect(rules[0]).toBe(
|
||||
`Instructions from: ${path.join(globalTmp.path, "AGENTS.md")}\n# Global Instructions`,
|
||||
)
|
||||
expect(rules[1]).toBe(
|
||||
`Instructions from: ${path.join(projectTmp.path, "AGENTS.md")}\n# Project Instructions`,
|
||||
)
|
||||
}),
|
||||
),
|
||||
),
|
||||
})
|
||||
} finally {
|
||||
;(Global.Path as { config: string }).config = originalGlobalConfig
|
||||
if (originalConfigDir === undefined) {
|
||||
delete process.env["OPENCODE_CONFIG_DIR"]
|
||||
} else {
|
||||
process.env["OPENCODE_CONFIG_DIR"] = originalConfigDir
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("Instruction.systemPaths OPENCODE_CONFIG_DIR", () => {
|
||||
@@ -229,73 +287,101 @@ describe("Instruction.systemPaths OPENCODE_CONFIG_DIR", () => {
|
||||
}
|
||||
})
|
||||
|
||||
it.live("prefers OPENCODE_CONFIG_DIR AGENTS.md over global when both exist", () =>
|
||||
Effect.gen(function* () {
|
||||
const profileTmp = yield* tmpWithFiles({ "AGENTS.md": "# Profile Instructions" })
|
||||
const globalTmp = yield* tmpWithFiles({ "AGENTS.md": "# Global Instructions" })
|
||||
const projectTmp = yield* tmpdirScoped()
|
||||
test("prefers OPENCODE_CONFIG_DIR AGENTS.md over global when both exist", async () => {
|
||||
await using profileTmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "AGENTS.md"), "# Profile Instructions")
|
||||
},
|
||||
})
|
||||
await using globalTmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "AGENTS.md"), "# Global Instructions")
|
||||
},
|
||||
})
|
||||
await using projectTmp = await tmpdir()
|
||||
|
||||
process.env["OPENCODE_CONFIG_DIR"] = profileTmp
|
||||
const originalGlobalConfig = Global.Path.config
|
||||
;(Global.Path as { config: string }).config = globalTmp
|
||||
process.env["OPENCODE_CONFIG_DIR"] = profileTmp.path
|
||||
const originalGlobalConfig = Global.Path.config
|
||||
;(Global.Path as { config: string }).config = globalTmp.path
|
||||
|
||||
try {
|
||||
yield* Instruction.Service.use((svc) =>
|
||||
Effect.gen(function* () {
|
||||
const paths = yield* svc.systemPaths()
|
||||
expect(paths.has(path.join(profileTmp, "AGENTS.md"))).toBe(true)
|
||||
expect(paths.has(path.join(globalTmp, "AGENTS.md"))).toBe(false)
|
||||
}),
|
||||
).pipe(provideInstance(projectTmp))
|
||||
} finally {
|
||||
;(Global.Path as { config: string }).config = originalGlobalConfig
|
||||
}
|
||||
}),
|
||||
)
|
||||
try {
|
||||
await Instance.provide({
|
||||
directory: projectTmp.path,
|
||||
fn: () =>
|
||||
run(
|
||||
Instruction.Service.use((svc) =>
|
||||
Effect.gen(function* () {
|
||||
const paths = yield* svc.systemPaths()
|
||||
expect(paths.has(path.join(profileTmp.path, "AGENTS.md"))).toBe(true)
|
||||
expect(paths.has(path.join(globalTmp.path, "AGENTS.md"))).toBe(false)
|
||||
}),
|
||||
),
|
||||
),
|
||||
})
|
||||
} finally {
|
||||
;(Global.Path as { config: string }).config = originalGlobalConfig
|
||||
}
|
||||
})
|
||||
|
||||
it.live("falls back to global AGENTS.md when OPENCODE_CONFIG_DIR has no AGENTS.md", () =>
|
||||
Effect.gen(function* () {
|
||||
const profileTmp = yield* tmpdirScoped()
|
||||
const globalTmp = yield* tmpWithFiles({ "AGENTS.md": "# Global Instructions" })
|
||||
const projectTmp = yield* tmpdirScoped()
|
||||
test("falls back to global AGENTS.md when OPENCODE_CONFIG_DIR has no AGENTS.md", async () => {
|
||||
await using profileTmp = await tmpdir()
|
||||
await using globalTmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "AGENTS.md"), "# Global Instructions")
|
||||
},
|
||||
})
|
||||
await using projectTmp = await tmpdir()
|
||||
|
||||
process.env["OPENCODE_CONFIG_DIR"] = profileTmp
|
||||
const originalGlobalConfig = Global.Path.config
|
||||
;(Global.Path as { config: string }).config = globalTmp
|
||||
process.env["OPENCODE_CONFIG_DIR"] = profileTmp.path
|
||||
const originalGlobalConfig = Global.Path.config
|
||||
;(Global.Path as { config: string }).config = globalTmp.path
|
||||
|
||||
try {
|
||||
yield* Instruction.Service.use((svc) =>
|
||||
Effect.gen(function* () {
|
||||
const paths = yield* svc.systemPaths()
|
||||
expect(paths.has(path.join(profileTmp, "AGENTS.md"))).toBe(false)
|
||||
expect(paths.has(path.join(globalTmp, "AGENTS.md"))).toBe(true)
|
||||
}),
|
||||
).pipe(provideInstance(projectTmp))
|
||||
} finally {
|
||||
;(Global.Path as { config: string }).config = originalGlobalConfig
|
||||
}
|
||||
}),
|
||||
)
|
||||
try {
|
||||
await Instance.provide({
|
||||
directory: projectTmp.path,
|
||||
fn: () =>
|
||||
run(
|
||||
Instruction.Service.use((svc) =>
|
||||
Effect.gen(function* () {
|
||||
const paths = yield* svc.systemPaths()
|
||||
expect(paths.has(path.join(profileTmp.path, "AGENTS.md"))).toBe(false)
|
||||
expect(paths.has(path.join(globalTmp.path, "AGENTS.md"))).toBe(true)
|
||||
}),
|
||||
),
|
||||
),
|
||||
})
|
||||
} finally {
|
||||
;(Global.Path as { config: string }).config = originalGlobalConfig
|
||||
}
|
||||
})
|
||||
|
||||
it.live("uses global AGENTS.md when OPENCODE_CONFIG_DIR is not set", () =>
|
||||
Effect.gen(function* () {
|
||||
const globalTmp = yield* tmpWithFiles({ "AGENTS.md": "# Global Instructions" })
|
||||
const projectTmp = yield* tmpdirScoped()
|
||||
test("uses global AGENTS.md when OPENCODE_CONFIG_DIR is not set", async () => {
|
||||
await using globalTmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "AGENTS.md"), "# Global Instructions")
|
||||
},
|
||||
})
|
||||
await using projectTmp = await tmpdir()
|
||||
|
||||
delete process.env["OPENCODE_CONFIG_DIR"]
|
||||
const originalGlobalConfig = Global.Path.config
|
||||
;(Global.Path as { config: string }).config = globalTmp
|
||||
delete process.env["OPENCODE_CONFIG_DIR"]
|
||||
const originalGlobalConfig = Global.Path.config
|
||||
;(Global.Path as { config: string }).config = globalTmp.path
|
||||
|
||||
try {
|
||||
yield* Instruction.Service.use((svc) =>
|
||||
Effect.gen(function* () {
|
||||
const paths = yield* svc.systemPaths()
|
||||
expect(paths.has(path.join(globalTmp, "AGENTS.md"))).toBe(true)
|
||||
}),
|
||||
).pipe(provideInstance(projectTmp))
|
||||
} finally {
|
||||
;(Global.Path as { config: string }).config = originalGlobalConfig
|
||||
}
|
||||
}),
|
||||
)
|
||||
try {
|
||||
await Instance.provide({
|
||||
directory: projectTmp.path,
|
||||
fn: () =>
|
||||
run(
|
||||
Instruction.Service.use((svc) =>
|
||||
Effect.gen(function* () {
|
||||
const paths = yield* svc.systemPaths()
|
||||
expect(paths.has(path.join(globalTmp.path, "AGENTS.md"))).toBe(true)
|
||||
}),
|
||||
),
|
||||
),
|
||||
})
|
||||
} finally {
|
||||
;(Global.Path as { config: string }).config = originalGlobalConfig
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user