mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-10 11:39:45 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bcd29d6d2e |
@@ -0,0 +1,29 @@
|
||||
import { $ } from "bun"
|
||||
import { readdir } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
|
||||
export type AppAsset = {
|
||||
readonly key: string
|
||||
readonly source: string
|
||||
}
|
||||
|
||||
export async function buildAppAssets(channel: string) {
|
||||
const root = path.resolve(import.meta.dirname, "../../app")
|
||||
await $`bun run build`.cwd(root).env({ ...process.env, OPENCODE_CHANNEL: channel })
|
||||
return (await files(path.join(root, "dist")))
|
||||
.filter((key) => !key.endsWith(".map"))
|
||||
.map((key): AppAsset => ({ key, source: path.join(root, "dist", key) }))
|
||||
}
|
||||
|
||||
async function files(root: string, current = root): Promise<string[]> {
|
||||
return (
|
||||
await Promise.all(
|
||||
(await readdir(current, { withFileTypes: true })).map((entry) => {
|
||||
const target = path.join(current, entry.name)
|
||||
return entry.isDirectory() ? files(root, target) : [path.relative(root, target).replaceAll(path.sep, "/")]
|
||||
}),
|
||||
)
|
||||
)
|
||||
.flat()
|
||||
.toSorted()
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { modelsData } from "./generate"
|
||||
import { collectNodeAssets, copyNodeAssets, hashNodeAssets, seaAssetMap } from "./node-assets"
|
||||
import { mainConfig } from "../vite.node.config"
|
||||
import { nodeExecArgv, nodeTarget, type NodeTarget } from "../src/node/target"
|
||||
import { buildAppAssets } from "./app-assets"
|
||||
|
||||
const NODE_VERSION = "26.4.0"
|
||||
const dir = path.resolve(import.meta.dirname, "..")
|
||||
@@ -26,6 +27,7 @@ if (outdir === path.join(dir, "dist-node")) {
|
||||
const bundleOnly = process.argv.includes("--bundle-only")
|
||||
const single = process.argv.includes("--single")
|
||||
const skipInstall = process.argv.includes("--skip-install")
|
||||
const skipEmbedWebUi = process.argv.includes("--skip-embed-web-ui")
|
||||
const requested = process.argv.find((arg) => arg.startsWith("--target="))?.slice("--target=".length)
|
||||
const allTargets = [
|
||||
nodeTarget("linux", "arm64"),
|
||||
@@ -55,13 +57,24 @@ const builder =
|
||||
!bundleOnly || targets.some((target) => target.platform === process.platform && target.arch === process.arch)
|
||||
? await resolveHostNode()
|
||||
: undefined
|
||||
const appAssets = skipEmbedWebUi ? [] : await buildAppAssets(Script.channel)
|
||||
|
||||
for (const target of targets) {
|
||||
console.log(`building cli-node-${targetName(target)}`)
|
||||
const assets = await collectNodeAssets(target)
|
||||
const assets = [
|
||||
...(await collectNodeAssets(target)),
|
||||
...appAssets.map((asset) => ({ key: `app/${asset.key}`, source: asset.source })),
|
||||
]
|
||||
await rm("dist-node", { recursive: true, force: true })
|
||||
const assetHash = await hashNodeAssets(assets)
|
||||
const input = { version: Script.version, channel: Script.channel, models: modelsData, assetHash, target }
|
||||
const input = {
|
||||
version: Script.version,
|
||||
channel: Script.channel,
|
||||
models: modelsData,
|
||||
assetHash,
|
||||
target,
|
||||
appAssets: appAssets.map((asset) => asset.key),
|
||||
}
|
||||
await copyNodeAssets(assets)
|
||||
await build(mainConfig(input))
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { createSolidTransformPlugin } from "@opentui/solid/bun-plugin"
|
||||
import type { BunPlugin } from "bun"
|
||||
import pkg from "../package.json"
|
||||
import { modelsData } from "./generate"
|
||||
import { buildAppAssets } from "./app-assets"
|
||||
|
||||
const dir = path.resolve(import.meta.dirname, "..")
|
||||
const binary = "opencode2"
|
||||
@@ -23,6 +24,7 @@ await rm(outdir, { recursive: true, force: true })
|
||||
const singleFlag = process.argv.includes("--single")
|
||||
const baselineFlag = process.argv.includes("--baseline")
|
||||
const skipInstall = process.argv.includes("--skip-install")
|
||||
const skipEmbedWebUi = process.argv.includes("--skip-embed-web-ui")
|
||||
const solidPlugin = createSolidTransformPlugin()
|
||||
|
||||
const allTargets: {
|
||||
@@ -54,6 +56,23 @@ const targets = singleFlag
|
||||
: allTargets
|
||||
|
||||
if (!skipInstall) await $`bun install --os="*" --cpu="*" @opentui/core@${pkg.dependencies["@opentui/core"]}`
|
||||
const appAssets = skipEmbedWebUi ? [] : await buildAppAssets(Script.channel)
|
||||
const appAssetsPlugin: BunPlugin = {
|
||||
name: "opencode-app-assets",
|
||||
setup(build) {
|
||||
build.onResolve({ filter: /^virtual:opencode-app-assets$/ }, () => ({
|
||||
path: "opencode-app-assets",
|
||||
namespace: "opencode",
|
||||
}))
|
||||
build.onLoad({ filter: /^opencode-app-assets$/, namespace: "opencode" }, () => ({
|
||||
loader: "js",
|
||||
contents: `${appAssets
|
||||
.map((asset, index) => `import asset_${index} from ${JSON.stringify(asset.source)} with { type: "file" }`)
|
||||
.join("\n")}
|
||||
export default {${appAssets.map((asset, index) => `${JSON.stringify(asset.key)}: asset_${index}`).join(",")}}`,
|
||||
}))
|
||||
},
|
||||
}
|
||||
|
||||
for (const item of targets) {
|
||||
const parcelWatcherPackage = `@parcel/watcher-${item.os}-${item.arch}${item.os === "linux" ? `-${item.abi ?? "glibc"}` : ""}`
|
||||
@@ -80,7 +99,7 @@ for (const item of targets) {
|
||||
const result = await Bun.build({
|
||||
entrypoints: ["./src/index.ts"],
|
||||
tsconfig: "./tsconfig.json",
|
||||
plugins: [solidPlugin, parcelWatcherPlugin],
|
||||
plugins: [appAssetsPlugin, solidPlugin, parcelWatcherPlugin],
|
||||
external: ["node-gyp"],
|
||||
format: "esm",
|
||||
minify: true,
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { readdir } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
|
||||
export type AssetMap = Readonly<Record<string, string>>
|
||||
|
||||
let result: Promise<AssetMap> | undefined
|
||||
|
||||
export function load() {
|
||||
return (result ??= import("virtual:opencode-app-assets")
|
||||
.then((module) => module.default)
|
||||
.catch(() => ({}))
|
||||
.then((assets) => (Object.keys(assets).length > 0 ? assets : sourceAssets())))
|
||||
}
|
||||
|
||||
async function sourceAssets(): Promise<AssetMap> {
|
||||
const root = path.resolve(import.meta.dirname, "../../app/dist")
|
||||
const entries = await files(root).catch(() => [])
|
||||
return Object.fromEntries(entries.filter((file) => !file.endsWith(".map")).map((file) => [file, path.join(root, file)]))
|
||||
}
|
||||
|
||||
async function files(root: string, current = root): Promise<string[]> {
|
||||
return (
|
||||
await Promise.all(
|
||||
(await readdir(current, { withFileTypes: true })).map((entry) => {
|
||||
const target = path.join(current, entry.name)
|
||||
return entry.isDirectory() ? files(root, target) : [path.relative(root, target).replaceAll(path.sep, "/")]
|
||||
}),
|
||||
)
|
||||
)
|
||||
.flat()
|
||||
.toSorted()
|
||||
}
|
||||
@@ -266,8 +266,15 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO
|
||||
],
|
||||
}),
|
||||
Spec.make("pair", { description: "Show server pairing information" }),
|
||||
Spec.make("web", {
|
||||
description: "Start the server and open the web interface",
|
||||
params: {
|
||||
hostname: Flag.string("hostname").pipe(Flag.optional),
|
||||
port: Flag.integer("port").pipe(Flag.optional),
|
||||
},
|
||||
}),
|
||||
Spec.make("serve", {
|
||||
description: "Start the v2 API server",
|
||||
description: "Start the v2 API and web server",
|
||||
params: {
|
||||
hostname: Flag.string("hostname").pipe(Flag.optional),
|
||||
port: Flag.integer("port").pipe(Flag.optional),
|
||||
|
||||
@@ -4,12 +4,13 @@ import { run } from "@opencode-ai/tui"
|
||||
import { Commands } from "../commands"
|
||||
import { Runtime } from "../../framework/runtime"
|
||||
import { Config } from "../../config"
|
||||
import { Context, Effect, FileSystem, Option } from "effect"
|
||||
import { Context, Effect, FileSystem, Option, Ref, Scope } from "effect"
|
||||
import { ServerConnection } from "../../services/server-connection"
|
||||
import { Updater } from "../../services/updater"
|
||||
import { UpdatePreflight } from "../../services/update-preflight"
|
||||
import { Npm } from "@opencode-ai/util/npm"
|
||||
import { OPENCODE_CHANNEL, OPENCODE_VERSION } from "../../version"
|
||||
import { WebUi } from "../../services/web-ui"
|
||||
|
||||
export default Runtime.handler(Commands, (input) =>
|
||||
Effect.gen(function* () {
|
||||
@@ -37,11 +38,13 @@ export default Runtime.handler(Commands, (input) =>
|
||||
),
|
||||
)
|
||||
preflight.loading()
|
||||
const endpoint = yield* Ref.make(server.endpoint)
|
||||
const web = yield* Effect.cached(WebUi.start(endpoint))
|
||||
const config = yield* Config.Service
|
||||
const npm = yield* Npm.Service
|
||||
const fileSystem = yield* FileSystem.FileSystem
|
||||
const runServicePromise = Effect.runPromiseWith(Context.make(FileSystem.FileSystem, fileSystem))
|
||||
const context = yield* Effect.context<FileSystem.FileSystem>()
|
||||
const context = yield* Effect.context<FileSystem.FileSystem | Scope.Scope>()
|
||||
const runFork = Effect.runForkWith(context)
|
||||
const runPromise = Effect.runPromiseWith(context)
|
||||
const service = server.service
|
||||
@@ -55,11 +58,16 @@ export default Runtime.handler(Commands, (input) =>
|
||||
endpoint: server.endpoint,
|
||||
service: service
|
||||
? {
|
||||
reconnect: (signal) => runServicePromise(service.reconnect(), { signal }),
|
||||
reconnect: (signal) =>
|
||||
runServicePromise(
|
||||
service.reconnect().pipe(Effect.tap((next) => Ref.set(endpoint, next))),
|
||||
{ signal },
|
||||
),
|
||||
restart: () => runServicePromise(service.restart()),
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
web: () => runPromise(web),
|
||||
args: {
|
||||
continue: input.continue,
|
||||
sessionID: Option.getOrUndefined(input.session),
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Effect, Option } from "effect"
|
||||
import { Commands } from "../commands"
|
||||
import { Runtime } from "../../framework/runtime"
|
||||
import { ServerProcess } from "../../server-process"
|
||||
|
||||
export default Runtime.handler(
|
||||
Commands.commands.web,
|
||||
Effect.fnUntraced(function* (input) {
|
||||
return yield* ServerProcess.run({
|
||||
mode: "web",
|
||||
hostname: Option.getOrUndefined(input.hostname),
|
||||
port: Option.getOrUndefined(input.port),
|
||||
})
|
||||
}),
|
||||
)
|
||||
@@ -52,6 +52,7 @@ const Handlers = Runtime.handlers(Commands, {
|
||||
unset: () => import("./commands/handlers/service/unset"),
|
||||
},
|
||||
serve: () => import("./commands/handlers/serve"),
|
||||
web: () => import("./commands/handlers/web"),
|
||||
})
|
||||
|
||||
Effect.logInfo("cli starting", {
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
export * as ServerProcess from "./server-process"
|
||||
|
||||
import { NodeServices } from "@effect/platform-node"
|
||||
import { Service, type DiscoverOptions, type Info } from "@opencode-ai/client/effect/service"
|
||||
import { Service, type DiscoverOptions, type Endpoint, type Info } from "@opencode-ai/client/effect/service"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { OPENCODE_CHANNEL, OPENCODE_VERSION } from "./version"
|
||||
import { AppProcess } from "@opencode-ai/util/process"
|
||||
import { randomBytes, randomUUID } from "node:crypto"
|
||||
import path from "node:path"
|
||||
import { Effect, FileSystem, Option, Redacted, Schedule, Schema } from "effect"
|
||||
import { Effect, FileSystem, Option, Redacted, Ref, Schedule, Schema } from "effect"
|
||||
import { HttpServer } from "effect/unstable/http"
|
||||
import { Env } from "./env"
|
||||
import { ServiceConfig } from "./services/service-config"
|
||||
import { Updater } from "./services/updater"
|
||||
import { WebUi } from "./services/web-ui"
|
||||
import open from "open"
|
||||
|
||||
export type Mode = "default" | "service" | "stdio"
|
||||
export type Mode = "default" | "service" | "stdio" | "web"
|
||||
|
||||
export type Options = {
|
||||
readonly mode: Mode
|
||||
@@ -42,6 +44,7 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
|
||||
if (options.mode === "service") yield* Effect.sync(() => process.chdir(Global.Path.home))
|
||||
return yield* Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const foreground = options.mode === "default" || options.mode === "web"
|
||||
const serviceOptions = options.mode === "service" ? yield* ServiceConfig.options() : undefined
|
||||
const config = options.mode === "service" ? yield* ServiceConfig.read() : {}
|
||||
const hostname = options.hostname ?? config.hostname ?? "127.0.0.1"
|
||||
@@ -74,8 +77,8 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
|
||||
version: OPENCODE_VERSION,
|
||||
channel: OPENCODE_CHANNEL,
|
||||
},
|
||||
hostname,
|
||||
port,
|
||||
hostname: foreground ? "127.0.0.1" : hostname,
|
||||
port: foreground ? 0 : port,
|
||||
password,
|
||||
simulation: truthy(process.env.OPENCODE_SIMULATE),
|
||||
database: {
|
||||
@@ -140,9 +143,24 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
|
||||
}),
|
||||
)
|
||||
if (server === undefined) return
|
||||
const url = HttpServer.formatAddress(server.address)
|
||||
const url =
|
||||
foreground
|
||||
? yield* WebUi.serve(
|
||||
yield* Ref.make<Endpoint>({
|
||||
url: HttpServer.formatAddress(server.address),
|
||||
auth: { type: "basic", username: "opencode", password },
|
||||
}),
|
||||
{ hostname, port, password },
|
||||
)
|
||||
: HttpServer.formatAddress(server.address)
|
||||
console.log(options.mode === "stdio" ? JSON.stringify({ url }) : `server listening on ${url}`)
|
||||
if (options.mode === "default" && !environmentPassword) console.log(`server password ${password}`)
|
||||
if (foreground && !environmentPassword) console.log(`server password ${password}`)
|
||||
if (options.mode === "web") {
|
||||
const target = new URL(url)
|
||||
if (target.hostname === "0.0.0.0" || target.hostname === "::") target.hostname = "localhost"
|
||||
target.searchParams.set("auth_token", Buffer.from(`opencode:${password}`).toString("base64"))
|
||||
yield* Effect.promise(() => open(target.toString()).catch(() => undefined))
|
||||
}
|
||||
const updater = yield* Updater.Service
|
||||
yield* updater.check().pipe(Effect.schedule(Schedule.spaced("10 minutes")), Effect.forkScoped)
|
||||
return yield* options.mode === "service"
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
import { NodeHttpServer, NodeSocket } from "@effect/platform-node"
|
||||
import { Service, type Endpoint } from "@opencode-ai/client/effect/service"
|
||||
import { ServerInfo } from "@opencode-ai/server/server-info"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Context, Effect, Exit, Ref, Scope, Stream } from "effect"
|
||||
import {
|
||||
FetchHttpClient,
|
||||
HttpBody,
|
||||
HttpClient,
|
||||
HttpClientRequest,
|
||||
HttpServer,
|
||||
HttpServerRequest,
|
||||
HttpServerResponse,
|
||||
} from "effect/unstable/http"
|
||||
import { Socket } from "effect/unstable/socket"
|
||||
import { createHash, randomBytes, timingSafeEqual } from "node:crypto"
|
||||
import { readFile } from "node:fs/promises"
|
||||
import { createServer } from "node:http"
|
||||
import { load } from "../app-assets"
|
||||
|
||||
const UI_UPSTREAM = new URL("https://app.opencode.ai")
|
||||
const COOKIE = "opencode-web"
|
||||
const hop = new Set([
|
||||
"connection",
|
||||
"keep-alive",
|
||||
"proxy-authenticate",
|
||||
"proxy-authorization",
|
||||
"proxy-connection",
|
||||
"te",
|
||||
"trailer",
|
||||
"transfer-encoding",
|
||||
"upgrade",
|
||||
"host",
|
||||
])
|
||||
|
||||
export const start = Effect.fn("cli.web-ui.start")(function* (
|
||||
endpoint: Ref.Ref<Endpoint>,
|
||||
options?: { readonly assets?: Readonly<Record<string, string>> },
|
||||
) {
|
||||
const token = randomBytes(32).toString("base64url")
|
||||
const origin = yield* listen(endpoint, {
|
||||
auth: { type: "cookie", token },
|
||||
hostname: "127.0.0.1",
|
||||
port: 0,
|
||||
assets: options?.assets,
|
||||
})
|
||||
return `${origin}/?cli_token=${encodeURIComponent(token)}`
|
||||
})
|
||||
|
||||
export const serve = Effect.fn("cli.web-ui.serve")(function* (
|
||||
endpoint: Ref.Ref<Endpoint>,
|
||||
options: {
|
||||
readonly hostname: string
|
||||
readonly port?: number
|
||||
readonly password: string
|
||||
readonly assets?: Readonly<Record<string, string>>
|
||||
},
|
||||
) {
|
||||
return yield* listen(endpoint, {
|
||||
auth: { type: "basic", password: options.password },
|
||||
hostname: options.hostname,
|
||||
port: options.port,
|
||||
assets: options.assets,
|
||||
})
|
||||
})
|
||||
|
||||
const listen = Effect.fnUntraced(function* (
|
||||
endpoint: Ref.Ref<Endpoint>,
|
||||
options: {
|
||||
readonly auth: { readonly type: "cookie"; readonly token: string } | { readonly type: "basic"; readonly password: string }
|
||||
readonly hostname: string
|
||||
readonly port?: number
|
||||
readonly assets?: Readonly<Record<string, string>>
|
||||
},
|
||||
) {
|
||||
const assets = options.assets ?? (yield* Effect.promise(load))
|
||||
const client = yield* HttpClient.HttpClient.pipe(Effect.provide(FetchHttpClient.layer))
|
||||
const websocket = yield* Socket.WebSocketConstructor.pipe(Effect.provide(NodeSocket.layerWebSocketConstructorWS))
|
||||
const server = yield* bind(options.hostname, options.port)
|
||||
const origin = formatAddress(server.http.address)
|
||||
const urls = ServerInfo.connectionURLs(origin, options.hostname)
|
||||
yield* server.http.serve(
|
||||
handle({ endpoint, auth: options.auth, assets, client, websocket, origin, urls }),
|
||||
).pipe(Effect.provideService(Scope.Scope, server.scope))
|
||||
return origin
|
||||
})
|
||||
|
||||
function handle(input: {
|
||||
readonly endpoint: Ref.Ref<Endpoint>
|
||||
readonly auth: { readonly type: "cookie"; readonly token: string } | { readonly type: "basic"; readonly password: string }
|
||||
readonly assets: Readonly<Record<string, string>>
|
||||
readonly client: HttpClient.HttpClient
|
||||
readonly websocket: Context.Service.Shape<typeof Socket.WebSocketConstructor>
|
||||
readonly origin: string
|
||||
readonly urls: ReadonlyArray<string>
|
||||
}) {
|
||||
return Effect.gen(function* () {
|
||||
const request = yield* HttpServerRequest.HttpServerRequest
|
||||
const url = new URL(request.url, input.origin)
|
||||
if (input.auth.type === "cookie" && request.headers.host !== new URL(input.origin).host)
|
||||
return HttpServerResponse.empty({ status: 403 })
|
||||
const queryToken = url.searchParams.get(input.auth.type === "cookie" ? "cli_token" : "auth_token")
|
||||
const queryAuthorized = input.auth.type === "cookie" && matches(queryToken, input.auth.token)
|
||||
if (input.auth.type === "cookie" && queryToken !== null && request.headers.upgrade?.toLowerCase() !== "websocket") {
|
||||
if (!queryAuthorized) return HttpServerResponse.empty({ status: 401 })
|
||||
url.searchParams.delete("cli_token")
|
||||
return HttpServerResponse.empty({
|
||||
status: 302,
|
||||
headers: {
|
||||
location: url.pathname + url.search + url.hash,
|
||||
"set-cookie": `${COOKIE}=${input.auth.token}; HttpOnly; SameSite=Strict; Path=/`,
|
||||
"cache-control": "no-store",
|
||||
},
|
||||
})
|
||||
}
|
||||
if (input.auth.type === "cookie" && !queryAuthorized && !authorized(request.headers.cookie, input.auth.token))
|
||||
return unauthorized(false)
|
||||
if (
|
||||
input.auth.type === "basic" &&
|
||||
!hasPtyTicket(url) &&
|
||||
!basicAuthorized(request.headers.authorization, queryToken, input.auth.password)
|
||||
)
|
||||
return unauthorized(true)
|
||||
url.searchParams.delete("cli_token")
|
||||
url.searchParams.delete("auth_token")
|
||||
const requestOrigin = request.headers.host ? `http://${request.headers.host}` : input.origin
|
||||
if (request.headers.origin !== undefined && request.headers.origin !== requestOrigin)
|
||||
return HttpServerResponse.empty({ status: 403 })
|
||||
|
||||
if (url.pathname === "/api" || url.pathname.startsWith("/api/")) {
|
||||
const endpoint = yield* Ref.get(input.endpoint)
|
||||
const target = new URL(url.pathname + url.search, endpoint.url)
|
||||
if (request.headers.upgrade?.toLowerCase() === "websocket")
|
||||
return yield* proxyWebSocket(request, target, input.websocket)
|
||||
return yield* proxyHttp(input.client, request, target, Service.headers(endpoint), false, input.urls)
|
||||
}
|
||||
return yield* serveUI(input.client, request, url, input.assets)
|
||||
})
|
||||
}
|
||||
|
||||
function serveUI(
|
||||
client: HttpClient.HttpClient,
|
||||
request: HttpServerRequest.HttpServerRequest,
|
||||
url: URL,
|
||||
assets: Readonly<Record<string, string>>,
|
||||
) {
|
||||
const key = url.pathname.replace(/^\//, "")
|
||||
const file = assets[key] ?? assets["index.html"]
|
||||
if (!file) return proxyHttp(client, request, new URL(url.pathname + url.search, UI_UPSTREAM), undefined, true)
|
||||
if (request.method !== "GET" && request.method !== "HEAD") return Effect.succeed(HttpServerResponse.empty({ status: 405 }))
|
||||
return Effect.tryPromise(() => readFile(file)).pipe(
|
||||
Effect.map((body) => {
|
||||
const html = key === "" || file === assets["index.html"]
|
||||
const headers = {
|
||||
"content-type": FSUtil.mimeType(file),
|
||||
"cache-control": html ? "no-cache" : "public, max-age=31536000, immutable",
|
||||
"content-security-policy": html ? cspForHtml(body.toString()) : csp(),
|
||||
"x-content-type-options": "nosniff",
|
||||
}
|
||||
if (request.method === "HEAD") return HttpServerResponse.empty({ headers })
|
||||
return HttpServerResponse.raw(body, { headers })
|
||||
}),
|
||||
Effect.catch(() => Effect.succeed(HttpServerResponse.empty({ status: 404 }))),
|
||||
)
|
||||
}
|
||||
|
||||
function proxyHttp(
|
||||
client: HttpClient.HttpClient,
|
||||
request: HttpServerRequest.HttpServerRequest,
|
||||
target: URL,
|
||||
extra: HeadersInit | undefined,
|
||||
ui = false,
|
||||
publicURLs?: ReadonlyArray<string>,
|
||||
) {
|
||||
return client
|
||||
.execute(
|
||||
HttpClientRequest.make(request.method as never)(target, {
|
||||
headers: proxyHeaders(request.headers, extra),
|
||||
body: requestBody(request),
|
||||
}),
|
||||
)
|
||||
.pipe(
|
||||
Effect.flatMap((response) => {
|
||||
const headers = new Headers(response.headers)
|
||||
headers.delete("content-encoding")
|
||||
headers.delete("content-length")
|
||||
headers.delete("set-cookie")
|
||||
if (publicURLs && target.pathname === "/api/server")
|
||||
return Effect.succeed(HttpServerResponse.jsonUnsafe({ urls: publicURLs }, { status: response.status }))
|
||||
if (ui && response.headers["content-type"]?.includes("text/html")) {
|
||||
return response.text.pipe(
|
||||
Effect.map((body) => {
|
||||
headers.set("content-security-policy", cspForHtml(body))
|
||||
headers.set("cache-control", "no-store")
|
||||
return HttpServerResponse.text(body, { status: response.status, headers })
|
||||
}),
|
||||
)
|
||||
}
|
||||
if (ui) headers.set("content-security-policy", csp())
|
||||
return Effect.succeed(
|
||||
HttpServerResponse.stream(response.stream.pipe(Stream.catchCause(() => Stream.empty)), {
|
||||
status: response.status,
|
||||
headers,
|
||||
}),
|
||||
)
|
||||
}),
|
||||
Effect.catch(() => Effect.succeed(HttpServerResponse.empty({ status: 502 }))),
|
||||
)
|
||||
}
|
||||
|
||||
function proxyWebSocket(
|
||||
request: HttpServerRequest.HttpServerRequest,
|
||||
target: URL,
|
||||
websocket: Context.Service.Shape<typeof Socket.WebSocketConstructor>,
|
||||
) {
|
||||
target.protocol = target.protocol === "https:" ? "wss:" : "ws:"
|
||||
return Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const inbound = yield* Effect.orDie(request.upgrade)
|
||||
const outbound = yield* Socket.makeWebSocket(target.toString(), {
|
||||
protocols: protocols(request.headers["sec-websocket-protocol"]),
|
||||
closeCodeIsError: () => false,
|
||||
}).pipe(Effect.provideService(Socket.WebSocketConstructor, websocket))
|
||||
const writeInbound = yield* inbound.writer
|
||||
const writeOutbound = yield* outbound.writer
|
||||
const close = Effect.all(
|
||||
[writeInbound(new Socket.CloseEvent()), writeOutbound(new Socket.CloseEvent())],
|
||||
{ concurrency: "unbounded", discard: true },
|
||||
).pipe(Effect.timeout("1 second"), Effect.catch(() => Effect.void))
|
||||
yield* Effect.raceFirst(
|
||||
outbound.runRaw((message) => writeInbound(typeof message === "string" ? message : message.slice())),
|
||||
inbound.runRaw((message) => writeOutbound(typeof message === "string" ? message : message.slice())),
|
||||
).pipe(Effect.catch(() => Effect.void), Effect.ensuring(close))
|
||||
return HttpServerResponse.empty()
|
||||
}).pipe(Effect.orDie),
|
||||
)
|
||||
}
|
||||
|
||||
function requestBody(request: HttpServerRequest.HttpServerRequest) {
|
||||
if (request.method === "GET" || request.method === "HEAD") return HttpBody.empty
|
||||
if (request.source instanceof Request && request.source.body === null) return HttpBody.empty
|
||||
const length = request.headers["content-length"]
|
||||
return HttpBody.stream(request.stream, request.headers["content-type"], length ? Number(length) : undefined)
|
||||
}
|
||||
|
||||
function proxyHeaders(input: Record<string, string>, extra?: HeadersInit) {
|
||||
const headers = new Headers(input)
|
||||
for (const key of input.connection?.split(",").map((item) => item.trim()) ?? []) headers.delete(key)
|
||||
for (const key of hop) headers.delete(key)
|
||||
headers.delete("accept-encoding")
|
||||
headers.delete("authorization")
|
||||
headers.delete("cookie")
|
||||
if (extra) for (const [key, value] of new Headers(extra)) headers.set(key, value)
|
||||
return headers
|
||||
}
|
||||
|
||||
function authorized(cookie: string | undefined, token: string) {
|
||||
const value = cookie
|
||||
?.split(";")
|
||||
.map((item) => item.trim().split("="))
|
||||
.find(([key]) => key === COOKIE)?.[1]
|
||||
return matches(value ?? null, token)
|
||||
}
|
||||
|
||||
function basicAuthorized(header: string | undefined, queryToken: string | null, password: string) {
|
||||
const expected = Buffer.from(`opencode:${password}`).toString("base64")
|
||||
if (matches(queryToken, expected)) return true
|
||||
if (!header?.startsWith("Basic ")) return false
|
||||
return matches(header.slice("Basic ".length), expected)
|
||||
}
|
||||
|
||||
function hasPtyTicket(url: URL) {
|
||||
return /^\/api\/pty\/[^/]+\/connect$/.test(url.pathname) && !!url.searchParams.get("ticket")
|
||||
}
|
||||
|
||||
function unauthorized(basic: boolean) {
|
||||
return HttpServerResponse.empty({
|
||||
status: 401,
|
||||
headers: basic ? { "www-authenticate": 'Basic realm="Secure Area"' } : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
function matches(value: string | null, expected: string) {
|
||||
if (value === null) return false
|
||||
const left = Buffer.from(value)
|
||||
const right = Buffer.from(expected)
|
||||
return left.length === right.length && timingSafeEqual(left, right)
|
||||
}
|
||||
|
||||
function protocols(value: string | undefined) {
|
||||
return value
|
||||
?.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
function csp(hash = "") {
|
||||
return `default-src 'self'; script-src 'self' 'wasm-unsafe-eval'${hash ? ` 'sha256-${hash}'` : ""}; style-src 'self' 'unsafe-inline'; img-src 'self' data: https: blob:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data: blob:`
|
||||
}
|
||||
|
||||
function cspForHtml(body: string) {
|
||||
const match = body.match(/<script\b(?![^>]*\bsrc\s*=)[^>]*\bid=(["'])oc-theme-preload-script\1[^>]*>([\s\S]*?)<\/script>/i)
|
||||
return csp(match ? createHash("sha256").update(match[2]).digest("base64") : "")
|
||||
}
|
||||
|
||||
function bind(hostname: string, port: number | undefined) {
|
||||
if (port !== undefined) return bindPort(hostname, port)
|
||||
const next = (candidate: number): ReturnType<typeof bindPort> =>
|
||||
bindPort(hostname, candidate).pipe(
|
||||
Effect.catch((error) =>
|
||||
candidate < 65_535 && addressInUse(error) ? next(candidate + 1) : Effect.fail(error),
|
||||
),
|
||||
)
|
||||
return next(4096)
|
||||
}
|
||||
|
||||
function bindPort(hostname: string, port: number) {
|
||||
return Effect.gen(function* () {
|
||||
const sockets = new Set<{ destroy(): void }>()
|
||||
const server = createServer()
|
||||
const scope = yield* Scope.make()
|
||||
server.on("connection", (socket) => {
|
||||
sockets.add(socket)
|
||||
socket.once("close", () => sockets.delete(socket))
|
||||
})
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.sync(() => sockets.forEach((socket) => socket.destroy())).pipe(
|
||||
Effect.andThen(Scope.close(scope, Exit.void)),
|
||||
),
|
||||
)
|
||||
const http = yield* NodeHttpServer.make(() => server, { host: hostname, port }).pipe(
|
||||
Effect.provideService(Scope.Scope, scope),
|
||||
)
|
||||
return { http, scope }
|
||||
})
|
||||
}
|
||||
|
||||
function formatAddress(address: HttpServer.Address) {
|
||||
if (address._tag === "UnixAddress") return HttpServer.formatAddress(address)
|
||||
const hostname = address.hostname.includes(":") ? `[${address.hostname}]` : address.hostname
|
||||
return `http://${hostname}:${address.port}`
|
||||
}
|
||||
|
||||
function addressInUse(error: unknown): boolean {
|
||||
if (typeof error !== "object" || error === null) return false
|
||||
if ("code" in error && error.code === "EADDRINUSE") return true
|
||||
return "cause" in error && addressInUse(error.cause)
|
||||
}
|
||||
|
||||
export * as WebUi from "./web-ui"
|
||||
Vendored
+4
@@ -0,0 +1,4 @@
|
||||
declare module "virtual:opencode-app-assets" {
|
||||
const assets: Readonly<Record<string, string>>
|
||||
export default assets
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
import { afterAll, describe, expect, test } from "bun:test"
|
||||
import { WebUi } from "../src/services/web-ui"
|
||||
import type { Endpoint } from "@opencode-ai/client/effect/service"
|
||||
import { Effect, Ref } from "effect"
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises"
|
||||
import { tmpdir } from "node:os"
|
||||
import path from "node:path"
|
||||
|
||||
const root = await mkdtemp(path.join(tmpdir(), "opencode-web-ui-"))
|
||||
afterAll(() => rm(root, { recursive: true, force: true }))
|
||||
|
||||
describe("TUI web UI", () => {
|
||||
test("bootstraps a private browser session and proxies the current API endpoint", async () => {
|
||||
const index = path.join(root, "index.html")
|
||||
await writeFile(index, "<html><body>embedded</body></html>")
|
||||
const first = Bun.serve({
|
||||
hostname: "127.0.0.1",
|
||||
port: 0,
|
||||
fetch: () => Response.json({ server: "first" }),
|
||||
})
|
||||
const second = Bun.serve({
|
||||
hostname: "127.0.0.1",
|
||||
port: 0,
|
||||
fetch: () => Response.json({ server: "second" }),
|
||||
})
|
||||
|
||||
try {
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const endpoint = yield* Ref.make({ url: first.url.toString() })
|
||||
const launch = yield* WebUi.start(endpoint, { assets: { "index.html": index } })
|
||||
const origin = new URL(launch).origin
|
||||
|
||||
expect((yield* Effect.promise(() => fetch(origin))).status).toBe(401)
|
||||
const bootstrap = yield* Effect.promise(() => fetch(launch, { redirect: "manual" }))
|
||||
expect(bootstrap.status).toBe(302)
|
||||
expect(bootstrap.headers.get("location")).toBe("/")
|
||||
const cookie = bootstrap.headers.get("set-cookie")?.split(";", 1)[0]
|
||||
expect(cookie).toStartWith("opencode-web=")
|
||||
|
||||
const page = yield* Effect.promise(() => fetch(origin, { headers: { cookie: cookie ?? "" } }))
|
||||
expect(yield* Effect.promise(() => page.text())).toContain("embedded")
|
||||
expect(page.headers.get("content-security-policy")).toContain("default-src 'self'")
|
||||
|
||||
const before = yield* Effect.promise(() => fetch(`${origin}/api/health`, { headers: { cookie: cookie ?? "" } }))
|
||||
expect(yield* Effect.promise(() => before.json())).toEqual({ server: "first" })
|
||||
yield* Ref.set(endpoint, { url: second.url.toString() })
|
||||
const after = yield* Effect.promise(() => fetch(`${origin}/api/health`, { headers: { cookie: cookie ?? "" } }))
|
||||
expect(yield* Effect.promise(() => after.json())).toEqual({ server: "second" })
|
||||
}),
|
||||
),
|
||||
)
|
||||
} finally {
|
||||
first.stop(true)
|
||||
second.stop(true)
|
||||
}
|
||||
})
|
||||
|
||||
test("rejects foreign origins", async () => {
|
||||
const index = path.join(root, "origin.html")
|
||||
await writeFile(index, "embedded")
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const endpoint = yield* Ref.make({ url: "http://127.0.0.1:1" })
|
||||
const launch = yield* WebUi.start(endpoint, { assets: { "index.html": index } })
|
||||
const bootstrap = yield* Effect.promise(() => fetch(launch, { redirect: "manual" }))
|
||||
const cookie = bootstrap.headers.get("set-cookie")?.split(";", 1)[0]
|
||||
const response = yield* Effect.promise(() =>
|
||||
fetch(new URL(launch).origin, {
|
||||
headers: { cookie: cookie ?? "", origin: "https://example.com" },
|
||||
}),
|
||||
)
|
||||
expect(response.status).toBe(403)
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
test("forwards websocket messages", async () => {
|
||||
const index = path.join(root, "websocket.html")
|
||||
await writeFile(index, "embedded")
|
||||
const upstream = Bun.serve({
|
||||
hostname: "127.0.0.1",
|
||||
port: 0,
|
||||
fetch(request, server) {
|
||||
if (server.upgrade(request)) return
|
||||
return new Response(null, { status: 426 })
|
||||
},
|
||||
websocket: {
|
||||
message(socket, message) {
|
||||
socket.send(message)
|
||||
},
|
||||
},
|
||||
})
|
||||
try {
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const endpoint = yield* Ref.make({ url: upstream.url.toString() })
|
||||
const launch = yield* WebUi.start(endpoint, { assets: { "index.html": index } })
|
||||
const target = new URL("/api/pty/test/connect?ticket=test", launch)
|
||||
target.searchParams.set("cli_token", new URL(launch).searchParams.get("cli_token") ?? "")
|
||||
target.protocol = "ws:"
|
||||
const message = yield* Effect.promise(
|
||||
() =>
|
||||
new Promise<string>((resolve, reject) => {
|
||||
const socket = new WebSocket(target)
|
||||
socket.addEventListener("open", () => socket.send("hello"), { once: true })
|
||||
socket.addEventListener("message", (event) => {
|
||||
resolve(event.data.toString())
|
||||
socket.close()
|
||||
}, { once: true })
|
||||
socket.addEventListener("error", reject, { once: true })
|
||||
}),
|
||||
)
|
||||
expect(message).toBe("hello")
|
||||
}),
|
||||
),
|
||||
)
|
||||
} finally {
|
||||
upstream.stop(true)
|
||||
}
|
||||
})
|
||||
|
||||
test("serves foreground UI with server credentials", async () => {
|
||||
const index = path.join(root, "serve.html")
|
||||
await writeFile(index, "<html>foreground</html>")
|
||||
const upstream = Bun.serve({
|
||||
hostname: "127.0.0.1",
|
||||
port: 0,
|
||||
fetch: (request) =>
|
||||
new URL(request.url).pathname === "/api/server"
|
||||
? Response.json({ urls: ["http://private"] })
|
||||
: Response.json({ url: request.url }),
|
||||
})
|
||||
try {
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const endpoint = yield* Ref.make<Endpoint>({
|
||||
url: upstream.url.toString(),
|
||||
auth: { type: "basic", username: "opencode", password: "private" },
|
||||
})
|
||||
const origin = yield* WebUi.serve(endpoint, {
|
||||
hostname: "127.0.0.1",
|
||||
port: 0,
|
||||
password: "secret",
|
||||
assets: { "index.html": index },
|
||||
})
|
||||
const denied = yield* Effect.promise(() => fetch(origin))
|
||||
expect(denied.status).toBe(401)
|
||||
expect(denied.headers.get("www-authenticate")).toContain("Basic")
|
||||
|
||||
const authorization = `Basic ${Buffer.from("opencode:secret").toString("base64")}`
|
||||
const page = yield* Effect.promise(() => fetch(origin, { headers: { authorization } }))
|
||||
expect(yield* Effect.promise(() => page.text())).toContain("foreground")
|
||||
|
||||
const token = Buffer.from("opencode:secret").toString("base64")
|
||||
const query = yield* Effect.promise(() => fetch(`${origin}/?auth_token=${encodeURIComponent(token)}`))
|
||||
expect(query.status).toBe(200)
|
||||
|
||||
const proxied = yield* Effect.promise(() =>
|
||||
fetch(`${origin}/api/health?auth_token=${encodeURIComponent(token)}&keep=yes`),
|
||||
)
|
||||
const proxiedBody = yield* Effect.promise(() => proxied.json())
|
||||
expect(new URL(proxiedBody.url).search).toBe("?keep=yes")
|
||||
|
||||
const info = yield* Effect.promise(() => fetch(`${origin}/api/server`, { headers: { authorization } }))
|
||||
expect(yield* Effect.promise(() => info.json())).toEqual({ urls: [origin] })
|
||||
}),
|
||||
),
|
||||
)
|
||||
} finally {
|
||||
upstream.stop(true)
|
||||
}
|
||||
})
|
||||
|
||||
test("formats localhost listeners as valid URLs", async () => {
|
||||
const index = path.join(root, "localhost.html")
|
||||
await writeFile(index, "embedded")
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const endpoint = yield* Ref.make({ url: "http://127.0.0.1:1" })
|
||||
const origin = yield* WebUi.serve(endpoint, {
|
||||
hostname: "localhost",
|
||||
port: 0,
|
||||
password: "secret",
|
||||
assets: { "index.html": index },
|
||||
})
|
||||
expect(new URL(origin).protocol).toBe("http:")
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
test("shuts down with an active websocket", async () => {
|
||||
const index = path.join(root, "shutdown.html")
|
||||
await writeFile(index, "embedded")
|
||||
const upstream = Bun.serve({
|
||||
hostname: "127.0.0.1",
|
||||
port: 0,
|
||||
fetch(request, server) {
|
||||
if (server.upgrade(request)) return
|
||||
return new Response(null, { status: 426 })
|
||||
},
|
||||
websocket: { message() {} },
|
||||
})
|
||||
let socket: WebSocket | undefined
|
||||
try {
|
||||
const run = Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const endpoint = yield* Ref.make({ url: upstream.url.toString() })
|
||||
const launch = yield* WebUi.start(endpoint, { assets: { "index.html": index } })
|
||||
const target = new URL("/api/pty/test/connect?ticket=test", launch)
|
||||
target.searchParams.set("cli_token", new URL(launch).searchParams.get("cli_token") ?? "")
|
||||
target.protocol = "ws:"
|
||||
socket = new WebSocket(target)
|
||||
yield* Effect.promise(
|
||||
() => new Promise<void>((resolve, reject) => {
|
||||
socket?.addEventListener("open", () => resolve(), { once: true })
|
||||
socket?.addEventListener("error", reject, { once: true })
|
||||
}),
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
await Promise.race([
|
||||
run,
|
||||
new Promise((_, reject) => setTimeout(() => reject(new Error("web UI shutdown timed out")), 2_000)),
|
||||
])
|
||||
} finally {
|
||||
socket?.close()
|
||||
upstream.stop(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -17,6 +17,23 @@ function rawTextPlugin(): Plugin {
|
||||
}
|
||||
}
|
||||
|
||||
function appAssetsPlugin(assets: readonly string[]): Plugin {
|
||||
return {
|
||||
name: "opencode:app-assets",
|
||||
resolveId(id) {
|
||||
if (id === "virtual:opencode-app-assets") return "\0virtual:opencode-app-assets"
|
||||
},
|
||||
load(id) {
|
||||
if (id !== "\0virtual:opencode-app-assets") return
|
||||
return `import path from "node:path"
|
||||
const root = process.env.OPENCODE_NODE_ASSETS_DIR
|
||||
export default root ? {${assets
|
||||
.map((key) => `${JSON.stringify(key)}: path.join(root, "app", ${JSON.stringify(key)})`)
|
||||
.join(",")}} : {}`
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function runtimeRequirePlugin(): Plugin {
|
||||
return {
|
||||
name: "opencode:runtime-require",
|
||||
@@ -212,12 +229,14 @@ export type NodeBuildInput = {
|
||||
readonly models: string
|
||||
readonly assetHash: string
|
||||
readonly target: NodeTarget
|
||||
readonly appAssets: readonly string[]
|
||||
}
|
||||
|
||||
export function mainConfig(input: NodeBuildInput): UserConfig {
|
||||
return defineConfig({
|
||||
root: dir,
|
||||
plugins: [
|
||||
appAssetsPlugin(input.appAssets),
|
||||
rawTextPlugin(),
|
||||
runtimeRequirePlugin(),
|
||||
fffNodePlugin(),
|
||||
@@ -259,4 +278,5 @@ export default mainConfig({
|
||||
models: "undefined",
|
||||
assetHash: "local",
|
||||
target: nodeTarget(process.platform, process.arch),
|
||||
appAssets: [],
|
||||
})
|
||||
|
||||
@@ -343,7 +343,7 @@ const layer = Layer.effect(
|
||||
Effect.flatMap(toolOutput.truncate),
|
||||
Effect.flatMap((outcome) => publisher.toolExecution(event.id, event.name, outcome)),
|
||||
Effect.catchTag("Tool.Error", (error) =>
|
||||
publisher.failTool(event.id, toSessionError(error), error.metadata).pipe(Effect.asVoid),
|
||||
publisher.failTool(event.id, toSessionError(error)).pipe(Effect.asVoid),
|
||||
),
|
||||
),
|
||||
).pipe(Effect.forkScoped),
|
||||
|
||||
@@ -92,11 +92,8 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
|
||||
progress?: Tool.Metadata
|
||||
}
|
||||
>()
|
||||
const failureSnapshot = (tool: { readonly progress?: Tool.Metadata }, metadata?: Tool.Metadata) => {
|
||||
if (tool.progress === undefined) return metadata === undefined ? {} : { metadata }
|
||||
if (metadata === undefined) return { metadata: tool.progress }
|
||||
return { metadata: { ...tool.progress, ...metadata } }
|
||||
}
|
||||
const failureSnapshot = (tool: { readonly progress?: Tool.Metadata }) =>
|
||||
tool.progress === undefined ? {} : { metadata: tool.progress }
|
||||
const assistantMessageID = input.assistantMessageID
|
||||
let stepStarted = false
|
||||
let stepFailed = false
|
||||
@@ -275,7 +272,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
|
||||
yield* flushFragments()
|
||||
})
|
||||
|
||||
const failTool = Effect.fnUntraced(function* (id: string, error: SessionError.Error, metadata?: Tool.Metadata) {
|
||||
const failTool = Effect.fnUntraced(function* (id: string, error: SessionError.Error) {
|
||||
const tool = tools.get(id)
|
||||
if (!tool || tool.settled) return false
|
||||
tool.settled = true
|
||||
@@ -284,7 +281,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
|
||||
assistantMessageID: tool.assistantMessageID,
|
||||
id,
|
||||
error,
|
||||
...failureSnapshot(tool, metadata),
|
||||
...failureSnapshot(tool),
|
||||
executed: tool.providerExecuted,
|
||||
})
|
||||
return true
|
||||
|
||||
@@ -3,7 +3,6 @@ export * as WebSearchTool from "./websearch"
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { ToolFailure } from "@opencode-ai/ai"
|
||||
import { Effect, Schema, Semaphore } from "effect"
|
||||
import { HttpClientError } from "effect/unstable/http"
|
||||
import { Form } from "../../form"
|
||||
import { KV } from "../../kv"
|
||||
import { Permission } from "../../permission"
|
||||
@@ -53,13 +52,7 @@ export const Plugin = {
|
||||
source: { type: "tool", messageID: context.messageID, id: context.id },
|
||||
})
|
||||
const search = (): Effect.Effect<Effect.Success<ReturnType<typeof ctx.websearch.query>>, unknown> =>
|
||||
websearch.default().pipe(
|
||||
Effect.flatMap((provider) => {
|
||||
if (!provider) return ctx.websearch.query(input)
|
||||
return context
|
||||
.progress({ provider: provider.id })
|
||||
.pipe(Effect.andThen(ctx.websearch.query({ ...input, providerID: provider.id })))
|
||||
}),
|
||||
ctx.websearch.query(input).pipe(
|
||||
Effect.catch((error) => {
|
||||
if (!Schema.is(WebSearch.ProviderRequiredError)(error)) return Effect.fail(error)
|
||||
return providerSelectionLock
|
||||
@@ -159,33 +152,9 @@ export const Plugin = {
|
||||
: NO_RESULTS
|
||||
return { output, content, metadata: { provider: output.provider } }
|
||||
}).pipe(
|
||||
Effect.mapError((error) => {
|
||||
const fallback = `Unable to search the web for ${input.query}`
|
||||
if (!Schema.is(WebSearch.RequestError)(error)) return new ToolFailure({ message: fallback, error })
|
||||
const status = HttpClientError.isHttpClientError(error.cause) ? error.cause.response?.status : undefined
|
||||
switch (status) {
|
||||
case 429:
|
||||
return new ToolFailure({
|
||||
message: "Web search rate limited (HTTP 429)",
|
||||
error,
|
||||
metadata: { provider: error.providerID },
|
||||
})
|
||||
case 401:
|
||||
return new ToolFailure({
|
||||
message: "Web search authentication failed (HTTP 401)",
|
||||
error,
|
||||
metadata: { provider: error.providerID },
|
||||
})
|
||||
case undefined:
|
||||
return new ToolFailure({ message: fallback, error, metadata: { provider: error.providerID } })
|
||||
default:
|
||||
return new ToolFailure({
|
||||
message: `Web search request failed (HTTP ${status})`,
|
||||
error,
|
||||
metadata: { provider: error.providerID },
|
||||
})
|
||||
}
|
||||
}),
|
||||
Effect.mapError(
|
||||
(error) => new ToolFailure({ message: `Unable to search the web for ${input.query}`, error }),
|
||||
),
|
||||
),
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -126,19 +126,6 @@ test("interrupted progress metadata remains in the terminal failure snapshot", a
|
||||
})
|
||||
})
|
||||
|
||||
test("local failure metadata completes the progress snapshot", async () => {
|
||||
const { published, publisher } = capture()
|
||||
await Effect.runPromise(publisher.publish(call))
|
||||
await Effect.runPromise(publisher.progress(call.id, { phase: "running", provider: "old" }))
|
||||
await Effect.runPromise(
|
||||
publisher.failTool(call.id, { type: "tool.execution", message: "failed" }, { provider: "exa" }),
|
||||
)
|
||||
|
||||
expect(published.find((event) => event.type === "session.tool.failed.2")?.data).toMatchObject({
|
||||
metadata: { phase: "running", provider: "exa" },
|
||||
})
|
||||
})
|
||||
|
||||
test("failure snapshot retains canonical progress above the default byte limit", async () => {
|
||||
const { published, publisher } = capture("anthropic", { interruptProgress: true })
|
||||
await Effect.runPromise(publisher.publish(call))
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { beforeEach, describe, expect } from "bun:test"
|
||||
import { Deferred, Effect, Layer } from "effect"
|
||||
import { HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Permission } from "@opencode-ai/core/permission"
|
||||
@@ -8,7 +7,6 @@ import { Form } from "@opencode-ai/core/form"
|
||||
import { KV } from "@opencode-ai/core/kv"
|
||||
import { WebSearch } from "@opencode-ai/core/websearch"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
import { toSessionError } from "@opencode-ai/core/session/to-session-error"
|
||||
import { Tool } from "@opencode-ai/core/tool"
|
||||
import { WebSearchTool } from "@opencode-ai/core/tool/plugin/websearch"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
@@ -43,7 +41,6 @@ let formResponse: Form.TerminalState = { status: "cancelled" }
|
||||
const formResponses: Form.TerminalState[] = []
|
||||
let queryBarrier: Deferred.Deferred<void> | undefined
|
||||
let synchronizedQueries = 0
|
||||
let queryError: WebSearch.Error | undefined
|
||||
let result = new WebSearch.Response({
|
||||
providerID: WebSearch.ID.make("exa"),
|
||||
results: [{ url: "https://example.com", title: "Search results", content: "search results", time: {} }],
|
||||
@@ -59,7 +56,6 @@ beforeEach(() => {
|
||||
formResponses.length = 0
|
||||
queryBarrier = undefined
|
||||
synchronizedQueries = 0
|
||||
queryError = undefined
|
||||
result = new WebSearch.Response({
|
||||
providerID: WebSearch.ID.make("exa"),
|
||||
results: [{ url: "https://example.com", title: "Search results", content: "search results", time: {} }],
|
||||
@@ -98,7 +94,6 @@ const websearch = Layer.succeed(
|
||||
if (synchronizedQueries === 5) yield* Deferred.succeed(queryBarrier, undefined)
|
||||
yield* Deferred.await(queryBarrier)
|
||||
}
|
||||
if (queryError) return yield* queryError
|
||||
if (providerRequired && typeof stored !== "string") return yield* new WebSearch.ProviderRequiredError()
|
||||
if (typeof stored === "string")
|
||||
return new WebSearch.Response({ providerID: WebSearch.ID.make(stored), results: result.results })
|
||||
@@ -381,55 +376,4 @@ describe("WebSearchTool registration", () => {
|
||||
expect(queries).toHaveLength(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("reports safe HTTP failures with the attempted provider", () =>
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* Tool.Service
|
||||
const tools = yield* registry.snapshot()
|
||||
values.set("websearch:provider", "exa")
|
||||
|
||||
yield* Effect.forEach(
|
||||
[
|
||||
{ status: 403, message: "Web search request failed (HTTP 403)" },
|
||||
{ status: 429, message: "Web search rate limited (HTTP 429)" },
|
||||
{ status: 401, message: "Web search authentication failed (HTTP 401)" },
|
||||
],
|
||||
({ status, message }, index) =>
|
||||
Effect.gen(function* () {
|
||||
const request = HttpClientRequest.post("https://mcp.exa.ai/mcp?exaApiKey=secret")
|
||||
queryError = new WebSearch.RequestError({
|
||||
providerID: WebSearch.ID.make("exa"),
|
||||
cause: new HttpClientError.HttpClientError({
|
||||
reason: new HttpClientError.StatusCodeError({
|
||||
request,
|
||||
response: HttpClientResponse.fromWeb(request, new Response(null, { status })),
|
||||
description: "non 2xx status code",
|
||||
}),
|
||||
}),
|
||||
})
|
||||
const progress: Tool.Metadata[] = []
|
||||
const error = yield* tools
|
||||
.execute({
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: {
|
||||
type: "tool-call",
|
||||
id: `call-http-${index}`,
|
||||
name: "websearch",
|
||||
input: { query: "effect" },
|
||||
},
|
||||
progress: (metadata) => Effect.sync(() => progress.push(metadata)),
|
||||
})
|
||||
.pipe(Effect.flip)
|
||||
|
||||
const sessionError = toSessionError(error)
|
||||
expect(sessionError).toEqual({ type: "tool.execution", message })
|
||||
expect(sessionError.message).not.toContain("secret")
|
||||
expect(error.metadata).toEqual({ provider: "exa" })
|
||||
expect(progress).toEqual([{ provider: "exa" }])
|
||||
}),
|
||||
{ discard: true },
|
||||
)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -179,6 +179,7 @@ export type TuiInput = {
|
||||
args: Args
|
||||
config: Config.Interface
|
||||
packages: PackageResolver
|
||||
web?: () => Promise<string>
|
||||
terminalHandoff?: () => Promise<
|
||||
| {
|
||||
readonly renderer: CliRenderer
|
||||
@@ -375,6 +376,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
||||
directories={pluginDirectories}
|
||||
>
|
||||
<App
|
||||
web={input.web}
|
||||
pair={
|
||||
input.server.endpoint.auth
|
||||
? input.server.endpoint.auth
|
||||
@@ -434,7 +436,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
||||
})
|
||||
})
|
||||
|
||||
function App(props: { pair?: DialogPairCredentials }) {
|
||||
function App(props: { pair?: DialogPairCredentials; web?: () => Promise<string> }) {
|
||||
const log = useLog({ component: "app" })
|
||||
const app = useTuiApp()
|
||||
const startup = useTuiStartup()
|
||||
@@ -952,6 +954,33 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
},
|
||||
category: "System",
|
||||
},
|
||||
...(props.web
|
||||
? [
|
||||
{
|
||||
name: "web.open",
|
||||
title: "Open web interface",
|
||||
slash: { name: "web" },
|
||||
run: async () => {
|
||||
const web = props.web
|
||||
if (!web) return
|
||||
const url = await web().catch((error) => {
|
||||
toast.error(error)
|
||||
return undefined
|
||||
})
|
||||
if (!url) return
|
||||
await open(url).catch(() =>
|
||||
toast.show({
|
||||
title: "Could not open browser",
|
||||
message: `Open ${url} manually.`,
|
||||
variant: "warning",
|
||||
}),
|
||||
)
|
||||
dialog.clear()
|
||||
},
|
||||
category: "System",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
name: "app.exit",
|
||||
title: "Exit the app",
|
||||
|
||||
@@ -64,7 +64,6 @@ export type PromptProps = {
|
||||
onEmptySubmit?: () => boolean | Promise<boolean>
|
||||
ref?: (ref: PromptRef | undefined) => void
|
||||
hint?: JSX.Element
|
||||
runningHint?: JSX.Element
|
||||
right?: JSX.Element
|
||||
showPlaceholder?: boolean
|
||||
placeholders?: {
|
||||
@@ -1659,7 +1658,6 @@ export function Prompt(props: PromptProps) {
|
||||
{store.interrupt > 0 ? "again to interrupt" : "interrupt"}
|
||||
</span>
|
||||
</text>
|
||||
{props.runningHint}
|
||||
</box>
|
||||
</Match>
|
||||
<Match when={move.progress()}>
|
||||
|
||||
@@ -312,12 +312,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
|
||||
const current = child.tools.get(key)
|
||||
const output = toolOutputText(part.name, toolDisplayContent(part.state))
|
||||
if (part.state.status === "running") {
|
||||
const ready = part.name !== "websearch" || typeof part.state.metadata.provider === "string"
|
||||
const awaitingProvider =
|
||||
current?.part.name === "websearch" &&
|
||||
current.part.state.status === "running" &&
|
||||
typeof current.part.state.metadata.provider !== "string"
|
||||
if (ready && (!current || current.part.state.status === "streaming" || awaitingProvider))
|
||||
if (!current || current.part.state.status === "streaming")
|
||||
setFrame(child, frame, toolCommit(part, messageID, "start", undefined, input.directory))
|
||||
if (output) setFrame(child, frame, toolCommit(part, messageID, "progress", output, input.directory))
|
||||
child.tools.set(key, { part })
|
||||
|
||||
@@ -120,7 +120,6 @@ type ToolState = {
|
||||
part: SessionMessageAssistantTool
|
||||
output: string
|
||||
version: number
|
||||
started: boolean
|
||||
}
|
||||
|
||||
type State = {
|
||||
@@ -610,7 +609,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||
}
|
||||
state.toolSources.set(key, part)
|
||||
if (part.state.status === "streaming") {
|
||||
state.tools.set(key, { part, output: "", version: 0, started: false })
|
||||
state.tools.set(key, { part, output: "", version: 0 })
|
||||
return
|
||||
}
|
||||
const current = state.tools.get(key)
|
||||
@@ -619,18 +618,16 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||
const version = current && !prefix ? current.version + 1 : (current?.version ?? 0)
|
||||
const delta = current && prefix ? output.slice(current.output.length) : output
|
||||
if (part.state.status === "running") {
|
||||
const started = current?.started === true
|
||||
const ready = part.name !== "websearch" || typeof part.state.metadata.provider === "string"
|
||||
if (render && !started && ready)
|
||||
if (render && (!current || current.part.state.status === "streaming"))
|
||||
write([toolCommit(part, messageID, "start", undefined, input.location?.directory, version)], {
|
||||
phase: "running",
|
||||
status: `running ${part.name}`,
|
||||
})
|
||||
if (render && delta) write([toolCommit(part, messageID, "progress", delta, input.location?.directory, version)])
|
||||
state.tools.set(key, { part, output, version, started: started || (render && ready) })
|
||||
state.tools.set(key, { part, output, version })
|
||||
return
|
||||
}
|
||||
if (render && !current?.started)
|
||||
if (render && (!current || current.part.state.status === "streaming"))
|
||||
write([toolCommit(part, messageID, "start", undefined, input.location?.directory, version)])
|
||||
state.finishedTools.add(key)
|
||||
state.tools.delete(key)
|
||||
|
||||
@@ -114,7 +114,6 @@ const NAVIGATION_SLACK_ID = "session-navigation-slack"
|
||||
const TRANSCRIPT_TAIL_ROWS = 40
|
||||
const TRANSCRIPT_BACKFILL_CHUNK = 60
|
||||
const TRANSCRIPT_BACKFILL_DELAY = 120
|
||||
const BACKGROUND_TOOL_HINT_DELAY = 3_000
|
||||
type PendingAction = "steer" | "queue" | "cancel"
|
||||
|
||||
const context = createContext<{
|
||||
@@ -1057,6 +1056,7 @@ export function Session() {
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
<BackgroundToolHint messages={messages()} />
|
||||
<Show when={session()?.revert?.messageID}>
|
||||
<RevertMessage
|
||||
count={messagesFromRevert().filter((message) => message.type === "user").length}
|
||||
@@ -1112,7 +1112,6 @@ export function Session() {
|
||||
return mutatePending("steer", next.id)
|
||||
}}
|
||||
sessionID={route.sessionID}
|
||||
runningHint={<BackgroundToolHint messages={messages()} />}
|
||||
/>
|
||||
</Match>
|
||||
</Switch>
|
||||
@@ -1327,42 +1326,26 @@ function turnTokenToolSummary(tool: SessionMessageAssistantTool) {
|
||||
function BackgroundToolHint(props: { messages: SessionMessageInfo[] }) {
|
||||
const theme = useTheme()
|
||||
const shortcut = Keymap.useShortcut("session.background")
|
||||
const tool = createMemo(() => {
|
||||
const visible = createMemo(() => {
|
||||
const current = props.messages.findLast(
|
||||
(message): message is SessionMessageAssistant => message.type === "assistant" && !message.time.completed,
|
||||
)
|
||||
return current?.content.find((part): part is SessionMessageAssistantTool => {
|
||||
if (part.type !== "tool" || part.state.status !== "running") return false
|
||||
const display = toolDisplay(part.name)
|
||||
return display === "shell" || display === "subagent"
|
||||
})
|
||||
return (
|
||||
current?.content.some((part) => {
|
||||
if (part.type !== "tool" || part.state.status !== "running") return false
|
||||
const display = toolDisplay(part.name)
|
||||
return display === "shell" || display === "subagent"
|
||||
}) ?? false
|
||||
)
|
||||
})
|
||||
const toolID = () => tool()?.id
|
||||
const toolStartedAt = () => {
|
||||
const current = tool()
|
||||
if (!current) return
|
||||
return current.time.ran ?? current.time.created
|
||||
}
|
||||
const [visible, setVisible] = createSignal(false)
|
||||
createEffect(
|
||||
on([toolID, toolStartedAt], ([id, startedAt]) => {
|
||||
setVisible(false)
|
||||
if (!id || startedAt === undefined) return
|
||||
const remaining = Math.max(0, BACKGROUND_TOOL_HINT_DELAY - (Date.now() - startedAt))
|
||||
if (remaining === 0) {
|
||||
setVisible(true)
|
||||
return
|
||||
}
|
||||
const timer = setTimeout(() => setVisible(true), remaining)
|
||||
onCleanup(() => clearTimeout(timer))
|
||||
}),
|
||||
)
|
||||
return (
|
||||
<Show when={visible() && shortcut()}>
|
||||
{(value) => (
|
||||
<text fg={theme.text.subdued} wrapMode="none" truncate flexShrink={1}>
|
||||
<span style={{ fg: theme.text.default }}>{value()}</span> background
|
||||
</text>
|
||||
<box marginTop={1} paddingLeft={3} flexShrink={0}>
|
||||
<text fg={theme.text.subdued}>
|
||||
Press <span style={{ fg: theme.text.default }}>{value()}</span> to move running work to the background
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
</Show>
|
||||
)
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
type PermissionRequest,
|
||||
} from "@opencode-ai/client/promise"
|
||||
import { createSessionTransport } from "../../src/mini/stream-v2.transport"
|
||||
import { entryBody } from "../../src/mini/entry.body"
|
||||
import type { StreamCommit } from "../../src/mini/types"
|
||||
import { createFooterApiFixture } from "./fixture/footer-api"
|
||||
import { canonicalToolPart } from "./fixture/tool-part"
|
||||
@@ -2202,80 +2201,6 @@ describe("V2 mini transport", () => {
|
||||
await transport.close()
|
||||
})
|
||||
|
||||
test("waits for the attempted web search provider before rendering its title", async () => {
|
||||
const events = feed()
|
||||
events.push(connected())
|
||||
const client = sdk({ streams: [events] })
|
||||
const ui = footer()
|
||||
const transport = await createSessionTransport({
|
||||
sdk: client,
|
||||
sessionID: "ses_1",
|
||||
thinking: false,
|
||||
footer: ui.api,
|
||||
})
|
||||
events.push({
|
||||
id: "evt_websearch_input",
|
||||
created: 1,
|
||||
type: "session.tool.input.started",
|
||||
durable: durable("ses_1"),
|
||||
data: {
|
||||
sessionID: "ses_1",
|
||||
assistantMessageID: "msg_websearch",
|
||||
id: "call_websearch",
|
||||
name: "websearch",
|
||||
},
|
||||
})
|
||||
events.push({
|
||||
id: "evt_websearch_called",
|
||||
created: 2,
|
||||
type: "session.tool.called",
|
||||
durable: durable("ses_1", 1),
|
||||
data: {
|
||||
sessionID: "ses_1",
|
||||
assistantMessageID: "msg_websearch",
|
||||
id: "call_websearch",
|
||||
input: { query: "effect" },
|
||||
executed: true,
|
||||
},
|
||||
})
|
||||
await Bun.sleep(0)
|
||||
expect(ui.commits.filter((item) => item.part?.id === "call_websearch")).toEqual([])
|
||||
|
||||
events.push({
|
||||
id: "evt_websearch_progress",
|
||||
created: 3,
|
||||
type: "session.tool.progress",
|
||||
data: {
|
||||
sessionID: "ses_1",
|
||||
assistantMessageID: "msg_websearch",
|
||||
id: "call_websearch",
|
||||
metadata: { provider: "exa" },
|
||||
},
|
||||
})
|
||||
events.push({
|
||||
id: "evt_websearch_failed",
|
||||
created: 4,
|
||||
type: "session.tool.failed",
|
||||
durable: durable("ses_1", 2, 2),
|
||||
data: {
|
||||
sessionID: "ses_1",
|
||||
assistantMessageID: "msg_websearch",
|
||||
id: "call_websearch",
|
||||
error: { type: "tool.execution", message: "Web search request failed (HTTP 403)" },
|
||||
metadata: { provider: "exa" },
|
||||
executed: true,
|
||||
},
|
||||
})
|
||||
await Bun.sleep(0)
|
||||
|
||||
const commits = ui.commits.filter((item) => item.part?.id === "call_websearch")
|
||||
expect(commits.map((item) => item.phase)).toEqual(["start", "final"])
|
||||
const start = commits[0]
|
||||
if (!start) throw new Error("Expected web search start commit")
|
||||
expect(entryBody(start)).toEqual({ type: "text", content: '◈ Exa Web Search "effect"' })
|
||||
await transport.close()
|
||||
})
|
||||
|
||||
test("falls back to the default model when selecting a variant on a fresh session", async () => {
|
||||
const events = feed()
|
||||
events.push(connected())
|
||||
|
||||
Reference in New Issue
Block a user