Compare commits

..

6 Commits

Author SHA1 Message Date
Dax Raad
34968f0b9f fix(tui): align v2 inline tool content 2026-05-03 15:18:54 -04:00
Dax Raad
d46b22c281 fix(tui): calculate v2 assistant duration from user prompt 2026-05-03 15:11:05 -04:00
Dax Raad
a1bfbd7852 fix(tui): keep v2 realtime messages newest first 2026-05-03 15:09:45 -04:00
Dax Raad
59814286af fix(tui): collapse v2 inline tool spacing 2026-05-03 15:08:18 -04:00
Dax Raad
2364c2cc9b chore(v2): remove session message model converter 2026-05-03 14:03:51 -04:00
Dax Raad
a8b02aec38 feat(v2): add session message model conversion 2026-05-03 14:01:24 -04:00
51 changed files with 781 additions and 2131 deletions

View File

@@ -15,7 +15,6 @@ import { terminalFontFamily, useSettings } from "@/context/settings"
import type { LocalPTY } from "@/context/terminal"
import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters"
import { terminalWriter } from "@/utils/terminal-writer"
import { terminalWebSocketURL } from "@/utils/terminal-websocket-url"
const TOGGLE_TERMINAL_ID = "terminal.toggle"
const DEFAULT_TOGGLE_TERMINAL_KEYBIND = "ctrl+`"
@@ -68,6 +67,13 @@ const debugTerminal = (...values: unknown[]) => {
console.debug("[terminal]", ...values)
}
const errorName = (err: unknown) => {
if (!err || typeof err !== "object") return
if (!("name" in err)) return
const errorName = err.name
return typeof errorName === "string" ? errorName : undefined
}
const useTerminalUiBindings = (input: {
container: HTMLDivElement
term: Term
@@ -472,34 +478,14 @@ export const Terminal = (props: TerminalProps) => {
const gone = () =>
client.pty
.get({ ptyID: id }, { throwOnError: false })
.then((result) => result.response.status === 404)
.get({ ptyID: id })
.then(() => false)
.catch((err) => {
if (errorName(err) === "NotFoundError") return true
debugTerminal("failed to inspect terminal session", err)
return false
})
const connectToken = async () => {
const result = await client.pty
.connectToken(
{ ptyID: id, directory },
{
throwOnError: false,
headers: { "x-opencode-ticket": "1" },
},
)
.catch((err: unknown) => {
if (err instanceof Error && err.message.includes("Request is not supported")) return
throw err
})
if (!result) return
if (result.response.status === 200 && result.data?.ticket) return result.data.ticket
if (result.response.status === 404 || result.response.status === 405) return
if (result.response.status === 403)
throw new Error("PTY connect ticket rejected by origin or CSRF checks. Check the server CORS config.")
throw new Error(`PTY connect ticket failed with ${result.response.status}`)
}
const retry = (err: unknown) => {
if (disposed) return
if (reconn !== undefined) return
@@ -519,30 +505,22 @@ export const Terminal = (props: TerminalProps) => {
}, ms)
}
const open = async () => {
const open = () => {
if (disposed) return
drop?.()
const ticket = await connectToken().catch((err) => {
fail(err)
return undefined
})
if (once.value) return
if (disposed) return
const next = new URL(url + `/pty/${id}/connect`)
next.searchParams.set("directory", directory)
next.searchParams.set("cursor", String(seek))
next.protocol = next.protocol === "https:" ? "wss:" : "ws:"
if (!sameOrigin && password) {
next.searchParams.set("auth_token", btoa(`${username}:${password}`))
// For same-origin requests, let the browser reuse the page's existing auth.
next.username = username
next.password = password
}
const socket = new WebSocket(
terminalWebSocketURL({
url,
id,
directory,
cursor: seek,
ticket,
sameOrigin,
username,
password,
authToken: server.current?.type === "http" ? server.current.authToken : false,
}),
)
const socket = new WebSocket(next)
socket.binaryType = "arraybuffer"
ws = socket

View File

@@ -1,53 +0,0 @@
import { describe, expect, test } from "bun:test"
import { resolveServerList, ServerConnection } from "./server"
describe("resolveServerList", () => {
test("lets startup auth_token credentials override a persisted same-url server", () => {
const list = resolveServerList({
stored: [{ url: "https://server.example.test" }],
props: [
{
type: "http",
authToken: true,
http: {
url: "https://server.example.test",
username: "opencode",
password: "secret",
},
},
],
})
expect(list).toHaveLength(1)
expect(list[0]?.type).toBe("http")
expect(list[0]?.http).toEqual({
url: "https://server.example.test",
username: "opencode",
password: "secret",
})
expect(list[0]?.type === "http" ? list[0].authToken : false).toBe(true)
expect(ServerConnection.key(list[0]!) as string).toBe("https://server.example.test")
})
test("keeps persisted credentials when startup has no auth_token", () => {
const list = resolveServerList({
stored: [
{
url: "https://server.example.test",
username: "opencode",
password: "saved",
},
],
props: [{ type: "http", http: { url: "https://server.example.test" } }],
})
expect(list).toHaveLength(1)
expect(list[0]?.type).toBe("http")
expect(list[0]?.http).toEqual({
url: "https://server.example.test",
username: "opencode",
password: "saved",
})
expect(list[0]?.type === "http" ? list[0].authToken : true).toBeUndefined()
})
})

View File

@@ -33,33 +33,6 @@ function isLocalHost(url: string) {
if (host === "localhost" || host === "127.0.0.1") return "local"
}
export function resolveServerList(input: {
props?: Array<ServerConnection.Any>
stored: StoredServer[]
}): Array<ServerConnection.Any> {
const servers = [
...input.stored.map((value) =>
typeof value === "string"
? {
type: "http" as const,
http: { url: value },
}
: value,
),
...(input.props ?? []),
]
const deduped = new Map<ServerConnection.Key, ServerConnection.Any>()
for (const value of servers) {
const conn: ServerConnection.Any = "type" in value ? value : { type: "http", http: value }
const key = ServerConnection.key(conn)
if (deduped.has(key) && conn.type === "http" && !conn.authToken) continue
deduped.set(key, conn)
}
return [...deduped.values()]
}
export namespace ServerConnection {
type Base = { displayName?: string }
@@ -73,7 +46,6 @@ export namespace ServerConnection {
export type Http = {
type: "http"
http: HttpBase
authToken?: boolean
} & Base
export type Sidecar = {
@@ -141,7 +113,26 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
const url = (x: StoredServer) => (typeof x === "string" ? x : "type" in x ? x.http.url : x.url)
const allServers = createMemo((): Array<ServerConnection.Any> => {
return resolveServerList({ stored: store.list, props: props.servers })
const servers = [
...(props.servers ?? []),
...store.list.map((value) =>
typeof value === "string"
? {
type: "http" as const,
http: { url: value },
}
: value,
),
]
const deduped = new Map(
servers.map((value) => {
const conn: ServerConnection.Any = "type" in value ? value : { type: "http", http: value }
return [ServerConnection.key(conn), conn]
}),
)
return [...deduped.values()]
})
const [state, setState] = createStore({
@@ -183,7 +174,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
function add(input: ServerConnection.Http) {
const url_ = normalizeServerUrl(input.http.url)
if (!url_) return
const conn: ServerConnection.Http = { ...input, authToken: undefined, http: { ...input.http, url: url_ } }
const conn = { ...input, http: { ...input.http, url: url_ } }
return batch(() => {
const existing = store.list.findIndex((x) => url(x) === url_)
if (existing !== -1) {

View File

@@ -1,9 +1,6 @@
import { beforeAll, describe, expect, mock, test } from "bun:test"
type ServerKey = Parameters<typeof import("./terminal").getTerminalServerScope>[1]
let getWorkspaceTerminalCacheKey: (dir: string, scope?: string) => string
let getTerminalServerScope: typeof import("./terminal").getTerminalServerScope
let getWorkspaceTerminalCacheKey: (dir: string) => string
let getLegacyTerminalStorageKeys: (dir: string, legacySessionID?: string) => string[]
let migrateTerminalState: (value: unknown) => unknown
@@ -20,7 +17,6 @@ beforeAll(async () => {
}))
const mod = await import("./terminal")
getWorkspaceTerminalCacheKey = mod.getWorkspaceTerminalCacheKey
getTerminalServerScope = mod.getTerminalServerScope
getLegacyTerminalStorageKeys = mod.getLegacyTerminalStorageKeys
migrateTerminalState = mod.migrateTerminalState
})
@@ -29,45 +25,6 @@ describe("getWorkspaceTerminalCacheKey", () => {
test("uses workspace-only directory cache key", () => {
expect(getWorkspaceTerminalCacheKey("/repo")).toBe("/repo:__workspace__")
})
test("can include a server scope", () => {
expect(getWorkspaceTerminalCacheKey("/repo", "wsl:Debian")).toBe("wsl:Debian:/repo:__workspace__")
})
})
describe("getTerminalServerScope", () => {
test("preserves local server keys", () => {
expect(
getTerminalServerScope(
{ type: "sidecar", variant: "base", http: { url: "http://127.0.0.1:4096" } },
"sidecar" as ServerKey,
),
).toBeUndefined()
expect(
getTerminalServerScope(
{ type: "http", http: { url: "http://localhost:4096" } },
"http://localhost:4096" as ServerKey,
),
).toBeUndefined()
expect(
getTerminalServerScope({ type: "http", http: { url: "http://[::1]:4096" } }, "http://[::1]:4096" as ServerKey),
).toBeUndefined()
})
test("scopes non-local server keys", () => {
expect(
getTerminalServerScope(
{ type: "sidecar", variant: "wsl", distro: "Debian", http: { url: "http://127.0.0.1:4096" } },
"wsl:Debian" as ServerKey,
),
).toBe("wsl:Debian" as ServerKey)
expect(
getTerminalServerScope(
{ type: "http", http: { url: "https://example.com" } },
"https://example.com" as ServerKey,
),
).toBe("https://example.com" as ServerKey)
})
})
describe("getLegacyTerminalStorageKeys", () => {

View File

@@ -4,7 +4,6 @@ import { batch, createEffect, createMemo, createRoot, on, onCleanup } from "soli
import { useParams } from "@solidjs/router"
import { useSDK } from "./sdk"
import type { Platform } from "./platform"
import { ServerConnection, useServer } from "./server"
import { defaultTitle, titleNumber } from "./terminal-title"
import { Persist, persisted, removePersisted } from "@/utils/persist"
@@ -83,31 +82,10 @@ export function migrateTerminalState(value: unknown) {
}
}
export function getWorkspaceTerminalCacheKey(dir: string, scope?: string) {
if (scope) return `${scope}:${dir}:${WORKSPACE_KEY}`
export function getWorkspaceTerminalCacheKey(dir: string) {
return `${dir}:${WORKSPACE_KEY}`
}
export function getTerminalServerScope(conn: ServerConnection.Any | undefined, key: ServerConnection.Key) {
if (!conn) return
if (conn.type === "sidecar" && conn.variant === "base") return
if (conn.type === "http") {
try {
const url = new URL(conn.http.url)
if (
url.hostname === "localhost" ||
url.hostname === "127.0.0.1" ||
url.hostname === "::1" ||
url.hostname === "[::1]"
)
return
} catch {
return key
}
}
return key
}
export function getLegacyTerminalStorageKeys(dir: string, legacySessionID?: string) {
if (!legacySessionID) return [`${dir}/terminal.v1`]
return [`${dir}/terminal/${legacySessionID}.v1`, `${dir}/terminal.v1`]
@@ -132,16 +110,15 @@ const trimTerminal = (pty: LocalPTY) => {
}
}
export function clearWorkspaceTerminals(dir: string, sessionIDs?: string[], platform?: Platform, scope?: string) {
const key = getWorkspaceTerminalCacheKey(dir, scope)
export function clearWorkspaceTerminals(dir: string, sessionIDs?: string[], platform?: Platform) {
const key = getWorkspaceTerminalCacheKey(dir)
for (const cache of caches) {
const entry = cache.get(key)
entry?.value.clear()
}
void removePersisted(Persist.workspace(dir, scope ? `terminal:${scope}` : "terminal"), platform)
void removePersisted(Persist.workspace(dir, "terminal"), platform)
if (scope) return
const legacy = new Set(getLegacyTerminalStorageKeys(dir))
for (const id of sessionIDs ?? []) {
for (const key of getLegacyTerminalStorageKeys(dir, id)) {
@@ -153,17 +130,12 @@ export function clearWorkspaceTerminals(dir: string, sessionIDs?: string[], plat
}
}
function createWorkspaceTerminalSession(
sdk: ReturnType<typeof useSDK>,
dir: string,
legacySessionID?: string,
scope?: string,
) {
const legacy = scope ? [] : getLegacyTerminalStorageKeys(dir, legacySessionID)
function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: string, legacySessionID?: string) {
const legacy = getLegacyTerminalStorageKeys(dir, legacySessionID)
const [store, setStore, _, ready] = persisted(
{
...Persist.workspace(dir, scope ? `terminal:${scope}` : "terminal", legacy),
...Persist.workspace(dir, "terminal", legacy),
migrate: migrateTerminalState,
},
createStore<{
@@ -385,12 +357,8 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
gate: false,
init: () => {
const sdk = useSDK()
const server = useServer()
const params = useParams()
const cache = new Map<string, TerminalCacheEntry>()
const scope = createMemo(() => {
return getTerminalServerScope(server.current, server.key)
})
caches.add(cache)
onCleanup(() => caches.delete(cache))
@@ -414,9 +382,9 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
}
}
const loadWorkspace = (dir: string, legacySessionID: string | undefined, serverScope: string | undefined) => {
const loadWorkspace = (dir: string, legacySessionID?: string) => {
// Terminals are workspace-scoped so tabs persist while switching sessions in the same directory.
const key = getWorkspaceTerminalCacheKey(dir, serverScope)
const key = getWorkspaceTerminalCacheKey(dir)
const existing = cache.get(key)
if (existing) {
cache.delete(key)
@@ -425,7 +393,7 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
}
const entry = createRoot((dispose) => ({
value: createWorkspaceTerminalSession(sdk, dir, legacySessionID, serverScope),
value: createWorkspaceTerminalSession(sdk, dir, legacySessionID),
dispose,
}))
@@ -434,16 +402,16 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
return entry.value
}
const workspace = createMemo(() => loadWorkspace(params.dir!, params.id, scope()))
const workspace = createMemo(() => loadWorkspace(params.dir!, params.id))
createEffect(
on(
() => ({ dir: params.dir, id: params.id, scope: scope() }),
() => ({ dir: params.dir, id: params.id }),
(next, prev) => {
if (!prev?.dir) return
if (next.dir === prev.dir && next.id === prev.id && next.scope === prev.scope) return
if (next.dir === prev.dir && next.id && next.scope === prev.scope) return
loadWorkspace(prev.dir, prev.id, prev.scope).trimAll()
if (next.dir === prev.dir && next.id === prev.id) return
if (next.dir === prev.dir && next.id) return
loadWorkspace(prev.dir, prev.id).trimAll()
},
{ defer: true },
),

View File

@@ -7,7 +7,6 @@ import { type Platform, PlatformProvider } from "@/context/platform"
import { dict as en } from "@/i18n/en"
import { dict as zh } from "@/i18n/zh"
import { handleNotificationClick } from "@/utils/notification-click"
import { authFromToken } from "@/utils/server"
import pkg from "../package.json"
import { ServerConnection } from "./context/server"
@@ -112,13 +111,6 @@ const getDefaultUrl = () => {
return getCurrentUrl()
}
const clearAuthToken = () => {
const params = new URLSearchParams(location.search)
if (!params.has("auth_token")) return
params.delete("auth_token")
history.replaceState(null, "", location.pathname + (params.size ? `?${params}` : "") + location.hash)
}
const platform: Platform = {
platform: "web",
version: pkg.version,
@@ -154,16 +146,7 @@ if (import.meta.env.VITE_SENTRY_DSN) {
}
if (root instanceof HTMLElement) {
const auth = authFromToken(new URLSearchParams(location.search).get("auth_token"))
clearAuthToken()
const server: ServerConnection.Http = {
type: "http",
authToken: !!auth,
http: {
url: getCurrentUrl(),
...auth,
},
}
const server: ServerConnection.Http = { type: "http", http: { url: getCurrentUrl() } }
render(
() => (
<PlatformProvider value={platform}>

View File

@@ -35,7 +35,7 @@ import type { DragEvent } from "@thisbeyond/solid-dnd"
import { useProviders } from "@/hooks/use-providers"
import { showToast, Toast, toaster } from "@opencode-ai/ui/toast"
import { useGlobalSDK } from "@/context/global-sdk"
import { clearWorkspaceTerminals, getTerminalServerScope } from "@/context/terminal"
import { clearWorkspaceTerminals } from "@/context/terminal"
import { dropSessionCaches, pickSessionCacheEvictions } from "@/context/global-sync/session-cache"
import {
clearSessionPrefetchInflight,
@@ -1557,7 +1557,6 @@ export default function Layout(props: ParentProps) {
directory,
sessions.map((s) => s.id),
platform,
getTerminalServerScope(server.current, server.key),
)
await globalSDK.client.instance.dispose({ directory }).catch(() => undefined)

View File

@@ -37,7 +37,6 @@ export function TerminalPanel() {
const [store, setStore] = createStore({
autoCreated: false,
activeDraggable: undefined as string | undefined,
recovered: {} as Record<string, boolean>,
view: typeof window === "undefined" ? 1000 : (window.visualViewport?.height ?? window.innerHeight),
})
@@ -146,21 +145,6 @@ export function TerminalPanel() {
const all = terminal.all
const ids = createMemo(() => all().map((pty) => pty.id))
const recoverTerminal = (key: string, id: string, clone: (id: string) => Promise<void>) => {
if (store.recovered[key]) return
setStore("recovered", key, true)
void clone(id)
}
const terminalRecoveryKey = (pty: { id: string; title: string; titleNumber: number }) => {
return String(pty.titleNumber || pty.title || pty.id)
}
const markTerminalConnected = (key: string, id: string, trim: (id: string) => void) => {
setStore("recovered", key, false)
trim(id)
}
const handleTerminalDragStart = (event: unknown) => {
const id = getDraggableId(event)
if (!id) return
@@ -296,9 +280,9 @@ export function TerminalPanel() {
<Terminal
pty={pty()}
autoFocus={opened()}
onConnect={() => markTerminalConnected(terminalRecoveryKey(pty()), id, ops.trim)}
onConnect={() => ops.trim(id)}
onCleanup={ops.update}
onConnectError={() => recoverTerminal(terminalRecoveryKey(pty()), id, ops.clone)}
onConnectError={() => ops.clone(id)}
/>
</div>
)}

View File

@@ -1,23 +0,0 @@
import { describe, expect, test } from "bun:test"
import { authFromToken, authTokenFromCredentials } from "./server"
describe("authFromToken", () => {
test("decodes basic auth credentials from auth_token", () => {
expect(authFromToken(btoa("kit:secret"))).toEqual({ username: "kit", password: "secret" })
})
test("defaults blank username to opencode", () => {
expect(authFromToken(btoa(":secret"))).toEqual({ username: "opencode", password: "secret" })
})
test("ignores malformed tokens", () => {
expect(authFromToken("not base64")).toBeUndefined()
expect(authFromToken(btoa("missing-separator"))).toBeUndefined()
})
})
describe("authTokenFromCredentials", () => {
test("encodes credentials with the default username", () => {
expect(authTokenFromCredentials({ password: "secret" })).toBe(btoa("opencode:secret"))
})
})

View File

@@ -1,21 +1,5 @@
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
import type { ServerConnection } from "@/context/server"
import { decode64 } from "@/utils/base64"
export function authTokenFromCredentials(input: { username?: string; password: string }) {
return btoa(`${input.username ?? "opencode"}:${input.password}`)
}
export function authFromToken(token: string | null) {
const decoded = decode64(token ?? undefined)
if (!decoded) return
const separator = decoded.indexOf(":")
if (separator === -1) return
return {
username: decoded.slice(0, separator) || "opencode",
password: decoded.slice(separator + 1),
}
}
export function createSdkForServer({
server,
@@ -26,7 +10,7 @@ export function createSdkForServer({
const auth = (() => {
if (!server.password) return
return {
Authorization: `Basic ${authTokenFromCredentials({ username: server.username, password: server.password })}`,
Authorization: `Basic ${btoa(`${server.username ?? "opencode"}:${server.password}`)}`,
}
})()

View File

@@ -1,52 +0,0 @@
import { describe, expect, test } from "bun:test"
import { terminalWebSocketURL } from "./terminal-websocket-url"
describe("terminalWebSocketURL", () => {
test("uses query auth without embedding credentials in websocket URL", () => {
const url = terminalWebSocketURL({
url: "http://127.0.0.1:49365",
id: "pty_test",
directory: "/tmp/project",
cursor: 0,
sameOrigin: false,
username: "opencode",
password: "secret",
})
expect(url.protocol).toBe("ws:")
expect(url.username).toBe("")
expect(url.password).toBe("")
expect(url.searchParams.get("auth_token")).toBe(btoa("opencode:secret"))
})
test("omits query auth for same-origin saved credentials", () => {
const url = terminalWebSocketURL({
url: "https://app.example.test",
id: "pty_test",
directory: "/tmp/project",
cursor: 10,
sameOrigin: true,
username: "opencode",
password: "secret",
})
expect(url.protocol).toBe("wss:")
expect(url.searchParams.has("auth_token")).toBe(false)
})
test("uses query auth for same-origin credentials from auth_token", () => {
const url = terminalWebSocketURL({
url: "https://app.example.test",
id: "pty_test",
directory: "/tmp/project",
cursor: 10,
sameOrigin: true,
username: "opencode",
password: "secret",
authToken: true,
})
expect(url.protocol).toBe("wss:")
expect(url.searchParams.get("auth_token")).toBe(btoa("opencode:secret"))
})
})

View File

@@ -1,28 +0,0 @@
import { authTokenFromCredentials } from "@/utils/server"
export function terminalWebSocketURL(input: {
url: string
id: string
directory: string
cursor: number
ticket?: string
sameOrigin?: boolean
username?: string
password?: string
authToken?: boolean
}) {
const next = new URL(`${input.url}/pty/${input.id}/connect`)
next.searchParams.set("directory", input.directory)
next.searchParams.set("cursor", String(input.cursor))
next.protocol = next.protocol === "https:" ? "wss:" : "ws:"
if (input.ticket) {
next.searchParams.set("ticket", input.ticket)
return next
}
if (input.password && (!input.sameOrigin || input.authToken))
next.searchParams.set(
"auth_token",
authTokenFromCredentials({ username: input.username, password: input.password }),
)
return next
}

View File

@@ -37,11 +37,6 @@
"bun": "./src/server/adapter.bun.ts",
"node": "./src/server/adapter.node.ts",
"default": "./src/server/adapter.bun.ts"
},
"#httpapi-server": {
"bun": "./src/server/httpapi-server.node.ts",
"node": "./src/server/httpapi-server.node.ts",
"default": "./src/server/httpapi-server.node.ts"
}
},
"devDependencies": {

View File

@@ -1,8 +1,9 @@
import { Auth } from "../../auth"
import { AppRuntime } from "../../effect/app-runtime"
import { cmd } from "./cmd"
import { CliError, effectCmd, fail } from "../effect-cmd"
import { effectCmd } from "../effect-cmd"
import * as prompts from "@clack/prompts"
import { UI } from "../ui"
import * as Prompt from "../effect/prompt"
import { ModelsDev } from "@/provider/models"
import { map, pipe, sortBy, values } from "remeda"
@@ -13,57 +14,44 @@ import { Global } from "@opencode-ai/core/global"
import { Plugin } from "../../plugin"
import type { Hooks } from "@opencode-ai/plugin"
import { Process } from "@/util/process"
import { errorMessage } from "@/util/error"
import { text } from "node:stream/consumers"
import { Effect, Option } from "effect"
import { Effect } from "effect"
type PluginAuth = NonNullable<Hooks["auth"]>
const promptValue = <Value>(value: Option.Option<Value>) => {
if (Option.isNone(value)) return Effect.die(new UI.CancelledError())
return Effect.succeed(value.value)
}
const put = (key: string, info: Auth.Info) =>
AppRuntime.runPromise(
Effect.gen(function* () {
const auth = yield* Auth.Service
yield* auth.set(key, info)
}),
)
const put = Effect.fn("Cli.providers.put")(function* (key: string, info: Auth.Info) {
const auth = yield* Auth.Service
yield* Effect.orDie(auth.set(key, info))
})
const cliTry = <Value>(message: string, fn: () => PromiseLike<Value>) =>
Effect.tryPromise({
try: fn,
catch: (error) => new CliError({ message: message + errorMessage(error) }),
})
const handlePluginAuth = Effect.fn("Cli.providers.pluginAuth")(function* (
plugin: { auth: PluginAuth },
provider: string,
methodName?: string,
) {
const index = yield* Effect.gen(function* () {
if (!methodName) {
if (plugin.auth.methods.length <= 1) return 0
return yield* promptValue(
yield* Prompt.select({
message: "Login method",
options: plugin.auth.methods.map((x, index) => ({
label: x.label,
value: index,
})),
}),
)
}
async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string, methodName?: string): Promise<boolean> {
let index = 0
if (methodName) {
const match = plugin.auth.methods.findIndex((x) => x.label.toLowerCase() === methodName.toLowerCase())
if (match === -1) {
return yield* fail(
prompts.log.error(
`Unknown method "${methodName}" for ${provider}. Available: ${plugin.auth.methods.map((x) => x.label).join(", ")}`,
)
process.exit(1)
}
return match
})
index = match
} else if (plugin.auth.methods.length > 1) {
const method = await prompts.select({
message: "Login method",
options: plugin.auth.methods.map((x, index) => ({
label: x.label,
value: index.toString(),
})),
})
if (prompts.isCancel(method)) throw new UI.CancelledError()
index = parseInt(method)
}
const method = plugin.auth.methods[index]
yield* Effect.sleep("10 millis")
await new Promise((r) => setTimeout(r, 10))
const inputs: Record<string, string> = {}
if (method.prompts) {
for (const prompt of method.prompts) {
@@ -75,44 +63,46 @@ const handlePluginAuth = Effect.fn("Cli.providers.pluginAuth")(function* (
}
if (prompt.condition && !prompt.condition(inputs)) continue
if (prompt.type === "select") {
const value = yield* Prompt.select({
const value = await prompts.select({
message: prompt.message,
options: prompt.options,
})
inputs[prompt.key] = yield* promptValue(value)
continue
if (prompts.isCancel(value)) throw new UI.CancelledError()
inputs[prompt.key] = value
} else {
const value = await prompts.text({
message: prompt.message,
placeholder: prompt.placeholder,
validate: prompt.validate ? (v) => prompt.validate!(v ?? "") : undefined,
})
if (prompts.isCancel(value)) throw new UI.CancelledError()
inputs[prompt.key] = value
}
const value = yield* Prompt.text({
message: prompt.message,
placeholder: prompt.placeholder,
validate: prompt.validate ? (v) => prompt.validate!(v ?? "") : undefined,
})
inputs[prompt.key] = yield* promptValue(value)
}
}
if (method.type === "oauth") {
const authorize = yield* cliTry("Failed to authorize: ", () => method.authorize(inputs))
const authorize = await method.authorize(inputs)
if (authorize.url) {
yield* Prompt.log.info("Go to: " + authorize.url)
prompts.log.info("Go to: " + authorize.url)
}
if (authorize.method === "auto") {
if (authorize.instructions) {
yield* Prompt.log.info(authorize.instructions)
prompts.log.info(authorize.instructions)
}
const spinner = Prompt.spinner()
yield* spinner.start("Waiting for authorization...")
const result = yield* cliTry("Failed to authorize: ", () => authorize.callback())
const spinner = prompts.spinner()
spinner.start("Waiting for authorization...")
const result = await authorize.callback()
if (result.type === "failed") {
yield* spinner.stop("Failed to authorize", 1)
spinner.stop("Failed to authorize", 1)
}
if (result.type === "success") {
const saveProvider = result.provider ?? provider
if ("refresh" in result) {
const { type: _, provider: __, refresh, access, expires, ...extraFields } = result
yield* put(saveProvider, {
await put(saveProvider, {
type: "oauth",
refresh,
access,
@@ -121,30 +111,30 @@ const handlePluginAuth = Effect.fn("Cli.providers.pluginAuth")(function* (
})
}
if ("key" in result) {
yield* put(saveProvider, {
await put(saveProvider, {
type: "api",
key: result.key,
})
}
yield* spinner.stop("Login successful")
spinner.stop("Login successful")
}
}
if (authorize.method === "code") {
const code = yield* Prompt.text({
const code = await prompts.text({
message: "Paste the authorization code here: ",
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
})
const authorizationCode = yield* promptValue(code)
const result = yield* cliTry("Failed to authorize: ", () => authorize.callback(authorizationCode))
if (prompts.isCancel(code)) throw new UI.CancelledError()
const result = await authorize.callback(code)
if (result.type === "failed") {
yield* Prompt.log.error("Failed to authorize")
prompts.log.error("Failed to authorize")
}
if (result.type === "success") {
const saveProvider = result.provider ?? provider
if ("refresh" in result) {
const { type: _, provider: __, refresh, access, expires, ...extraFields } = result
yield* put(saveProvider, {
await put(saveProvider, {
type: "oauth",
refresh,
access,
@@ -153,57 +143,56 @@ const handlePluginAuth = Effect.fn("Cli.providers.pluginAuth")(function* (
})
}
if ("key" in result) {
yield* put(saveProvider, {
await put(saveProvider, {
type: "api",
key: result.key,
})
}
yield* Prompt.log.success("Login successful")
prompts.log.success("Login successful")
}
}
yield* Prompt.outro("Done")
prompts.outro("Done")
return true
}
if (method.type === "api") {
const key = yield* Prompt.password({
const key = await prompts.password({
message: "Enter your API key",
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
})
const apiKey = yield* promptValue(key)
if (prompts.isCancel(key)) throw new UI.CancelledError()
const metadata = Object.keys(inputs).length ? { metadata: inputs } : {}
const authorizeApi = method.authorize
if (!authorizeApi) {
yield* put(provider, {
if (!method.authorize) {
await put(provider, {
type: "api",
key: apiKey,
key,
...metadata,
})
yield* Prompt.outro("Done")
prompts.outro("Done")
return true
}
const result = yield* cliTry("Failed to authorize: ", () => authorizeApi(inputs))
const result = await method.authorize(inputs)
if (result.type === "failed") {
yield* Prompt.log.error("Failed to authorize")
prompts.log.error("Failed to authorize")
}
if (result.type === "success") {
const saveProvider = result.provider ?? provider
yield* put(saveProvider, {
await put(saveProvider, {
type: "api",
key: result.key ?? apiKey,
key: result.key ?? key,
...metadata,
})
yield* Prompt.log.success("Login successful")
prompts.log.success("Login successful")
}
yield* Prompt.outro("Done")
prompts.outro("Done")
return true
}
return false
})
}
export function resolvePluginProviders(input: {
hooks: Hooks[]
@@ -255,16 +244,16 @@ export const ProvidersListCommand = effectCmd({
const authPath = path.join(Global.Path.data, "auth.json")
const homedir = os.homedir()
const displayPath = authPath.startsWith(homedir) ? authPath.replace(homedir, "~") : authPath
yield* Prompt.intro(`Credentials ${UI.Style.TEXT_DIM}${displayPath}`)
prompts.intro(`Credentials ${UI.Style.TEXT_DIM}${displayPath}`)
const results = Object.entries(yield* Effect.orDie(authSvc.all()))
const database = yield* modelsDev.get()
for (const [providerID, result] of results) {
const name = database[providerID]?.name || providerID
yield* Prompt.log.info(`${name} ${UI.Style.TEXT_DIM}${result.type}`)
prompts.log.info(`${name} ${UI.Style.TEXT_DIM}${result.type}`)
}
yield* Prompt.outro(`${results.length} credentials`)
prompts.outro(`${results.length} credentials`)
const activeEnvVars: Array<{ provider: string; envVar: string }> = []
@@ -281,13 +270,13 @@ export const ProvidersListCommand = effectCmd({
if (activeEnvVars.length > 0) {
UI.empty()
yield* Prompt.intro("Environment")
prompts.intro("Environment")
for (const { provider, envVar } of activeEnvVars) {
yield* Prompt.log.info(`${provider} ${UI.Style.TEXT_DIM}${envVar}`)
prompts.log.info(`${provider} ${UI.Style.TEXT_DIM}${envVar}`)
}
yield* Prompt.outro(`${activeEnvVars.length} environment variable` + (activeEnvVars.length === 1 ? "" : "s"))
prompts.outro(`${activeEnvVars.length} environment variable` + (activeEnvVars.length === 1 ? "" : "s"))
}
}),
})
@@ -312,42 +301,36 @@ export const ProvidersLoginCommand = effectCmd({
type: "string",
}),
handler: Effect.fn("Cli.providers.login")(function* (args) {
const authSvc = yield* Auth.Service
UI.empty()
yield* Prompt.intro("Add credential")
if (args.url) {
const url = args.url.replace(/\/+$/, "")
const wellknown = (yield* cliTry(`Failed to load auth provider metadata from ${url}: `, () =>
fetch(`${url}/.well-known/opencode`).then((x) => x.json()),
)) as {
auth: { command: string[]; env: string }
}
yield* Prompt.log.info(`Running \`${wellknown.auth.command.join(" ")}\``)
const abort = new AbortController()
const proc = Process.spawn(wellknown.auth.command, { stdout: "pipe", stderr: "inherit", abort: abort.signal })
if (!proc.stdout) {
yield* Prompt.log.error("Failed")
yield* Prompt.outro("Done")
return
}
const [exit, token] = yield* cliTry("Failed to run auth provider command: ", () =>
Promise.all([proc.exited, text(proc.stdout!)]),
).pipe(Effect.ensuring(Effect.sync(() => abort.abort())))
if (exit !== 0) {
yield* Prompt.log.error("Failed")
yield* Prompt.outro("Done")
return
}
yield* Effect.orDie(authSvc.set(url, { type: "wellknown", key: wellknown.auth.env, token: token.trim() }))
yield* Prompt.log.success("Logged into " + url)
yield* Prompt.outro("Done")
return
}
const cfgSvc = yield* Config.Service
const pluginSvc = yield* Plugin.Service
const modelsDev = yield* ModelsDev.Service
const authSvc = yield* Auth.Service
UI.empty()
prompts.intro("Add credential")
if (args.url) {
const url = args.url.replace(/\/+$/, "")
const wellknown = (yield* Effect.promise(() => fetch(`${url}/.well-known/opencode`).then((x) => x.json()))) as {
auth: { command: string[]; env: string }
}
prompts.log.info(`Running \`${wellknown.auth.command.join(" ")}\``)
const proc = Process.spawn(wellknown.auth.command, { stdout: "pipe", stderr: "inherit" })
if (!proc.stdout) {
prompts.log.error("Failed")
prompts.outro("Done")
return
}
const [exit, token] = yield* Effect.promise(() => Promise.all([proc.exited, text(proc.stdout!)]))
if (exit !== 0) {
prompts.log.error("Failed")
prompts.outro("Done")
return
}
yield* Effect.orDie(authSvc.set(url, { type: "wellknown", key: wellknown.auth.env, token: token.trim() }))
prompts.log.success("Logged into " + url)
prompts.outro("Done")
return
}
yield* Effect.ignore(modelsDev.refresh(true))
const config = yield* cfgSvc.get()
@@ -409,46 +392,53 @@ export const ProvidersLoginCommand = effectCmd({
const byName = options.find((x) => x.label.toLowerCase() === input.toLowerCase())
const match = byID ?? byName
if (!match) {
return yield* fail(`Unknown provider "${input}"`)
prompts.log.error(`Unknown provider "${input}"`)
process.exit(1)
}
provider = match.value
} else {
provider = yield* promptValue(
yield* Prompt.autocomplete({
const selected = yield* Effect.promise(() =>
prompts.autocomplete({
message: "Select provider",
maxItems: 8,
options: [...options, { value: "other", label: "Other" }],
}),
)
if (prompts.isCancel(selected)) yield* Effect.die(new UI.CancelledError())
provider = selected as string
}
const plugin = hooks.findLast((x) => x.auth?.provider === provider)
if (plugin && plugin.auth) {
const handled = yield* handlePluginAuth({ auth: plugin.auth! }, provider, args.method)
const handled = yield* Effect.promise(() => handlePluginAuth({ auth: plugin.auth! }, provider, args.method))
if (handled) return
}
if (provider === "other") {
provider = (yield* promptValue(
yield* Prompt.text({
const custom = yield* Effect.promise(() =>
prompts.text({
message: "Enter provider id",
validate: (x) => (x && x.match(/^[0-9a-z-]+$/) ? undefined : "a-z, 0-9 and hyphens only"),
}),
)).replace(/^@ai-sdk\//, "")
)
if (prompts.isCancel(custom)) yield* Effect.die(new UI.CancelledError())
provider = (custom as string).replace(/^@ai-sdk\//, "")
const customPlugin = hooks.findLast((x) => x.auth?.provider === provider)
if (customPlugin && customPlugin.auth) {
const handled = yield* handlePluginAuth({ auth: customPlugin.auth! }, provider, args.method)
const handled = yield* Effect.promise(() =>
handlePluginAuth({ auth: customPlugin.auth! }, provider, args.method),
)
if (handled) return
}
yield* Prompt.log.warn(
prompts.log.warn(
`This only stores a credential for ${provider} - you will need configure it in opencode.json, check the docs for examples.`,
)
}
if (provider === "amazon-bedrock") {
yield* Prompt.log.info(
prompts.log.info(
"Amazon Bedrock authentication priority:\n" +
" 1. Bearer token (AWS_BEARER_TOKEN_BEDROCK or /connect)\n" +
" 2. AWS credential chain (profile, access keys, IAM roles, EKS IRSA)\n\n" +
@@ -458,27 +448,29 @@ export const ProvidersLoginCommand = effectCmd({
}
if (provider === "opencode") {
yield* Prompt.log.info("Create an api key at https://opencode.ai/auth")
prompts.log.info("Create an api key at https://opencode.ai/auth")
}
if (provider === "vercel") {
yield* Prompt.log.info("You can create an api key at https://vercel.link/ai-gateway-token")
prompts.log.info("You can create an api key at https://vercel.link/ai-gateway-token")
}
if (["cloudflare", "cloudflare-ai-gateway"].includes(provider)) {
yield* Prompt.log.info(
prompts.log.info(
"Cloudflare AI Gateway can be configured with CLOUDFLARE_GATEWAY_ID, CLOUDFLARE_ACCOUNT_ID, and CLOUDFLARE_API_TOKEN environment variables. Read more: https://opencode.ai/docs/providers/#cloudflare-ai-gateway",
)
}
const key = yield* Prompt.password({
message: "Enter your API key",
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
})
const apiKey = yield* promptValue(key)
yield* Effect.orDie(authSvc.set(provider, { type: "api", key: apiKey }))
const key = yield* Effect.promise(() =>
prompts.password({
message: "Enter your API key",
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
}),
)
if (prompts.isCancel(key)) yield* Effect.die(new UI.CancelledError())
yield* Effect.orDie(authSvc.set(provider, { type: "api", key: key as string }))
yield* Prompt.outro("Done")
prompts.outro("Done")
}),
})
@@ -493,20 +485,24 @@ export const ProvidersLogoutCommand = effectCmd({
UI.empty()
const credentials: Array<[string, Auth.Info]> = Object.entries(yield* Effect.orDie(authSvc.all()))
yield* Prompt.intro("Remove credential")
prompts.intro("Remove credential")
if (credentials.length === 0) {
yield* Prompt.log.error("No credentials found")
prompts.log.error("No credentials found")
return
}
const database = yield* modelsDev.get()
const selected = yield* Prompt.select({
message: "Select provider",
options: credentials.map(([key, value]) => ({
label: (database[key]?.name || key) + UI.Style.TEXT_DIM + " (" + value.type + ")",
value: key,
})),
})
yield* Effect.orDie(authSvc.remove(yield* promptValue(selected)))
yield* Prompt.outro("Logout successful")
const selected = yield* Effect.promise(() =>
prompts.select({
message: "Select provider",
options: credentials.map(([key, value]) => ({
label: (database[key]?.name || key) + UI.Style.TEXT_DIM + " (" + value.type + ")",
value: key,
})),
}),
)
if (prompts.isCancel(selected)) yield* Effect.die(new UI.CancelledError())
const providerID = selected as string
yield* Effect.orDie(authSvc.remove(providerID))
prompts.outro("Logout successful")
}),
})

View File

@@ -276,11 +276,6 @@ export const RunCommand = effectCmd({
type: "string",
describe: "basic auth password (defaults to OPENCODE_SERVER_PASSWORD)",
})
.option("username", {
alias: ["u"],
type: "string",
describe: "basic auth username (defaults to OPENCODE_SERVER_USERNAME or 'opencode')",
})
.option("dir", {
type: "string",
describe: "directory to run in, path on remote server if attaching",
@@ -662,7 +657,7 @@ export const RunCommand = effectCmd({
}
if (args.attach) {
const headers = ServerAuth.headers({ password: args.password, username: args.username })
const headers = ServerAuth.headers({ password: args.password })
const sdk = createOpencodeClient({ baseUrl: args.attach, directory, headers })
return await execute(sdk)
}

View File

@@ -11,21 +11,21 @@ import { createSimpleContext } from "./helper"
import { useSDK } from "./sdk"
function activeAssistant(messages: SessionMessage[]) {
const index = messages.findLastIndex((message) => message.type === "assistant" && !message.time.completed)
const index = messages.findIndex((message) => message.type === "assistant" && !message.time.completed)
if (index < 0) return
const assistant = messages[index]
return assistant?.type === "assistant" ? assistant : undefined
}
function activeCompaction(messages: SessionMessage[]) {
const index = messages.findLastIndex((message) => message.type === "compaction")
const index = messages.findIndex((message) => message.type === "compaction")
if (index < 0) return
const compaction = messages[index]
return compaction?.type === "compaction" ? compaction : undefined
}
function activeShell(messages: SessionMessage[], callID: string) {
const index = messages.findLastIndex((message) => message.type === "shell" && message.callID === callID)
const index = messages.findIndex((message) => message.type === "shell" && message.callID === callID)
if (index < 0) return
const shell = messages[index]
return shell?.type === "shell" ? shell : undefined
@@ -74,7 +74,7 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
switch (event.type) {
case "session.next.prompted": {
update(event.properties.sessionID, (draft) => {
draft.push({
draft.unshift({
id: event.id,
type: "user",
text: event.properties.prompt.text,
@@ -87,7 +87,7 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
}
case "session.next.synthetic":
update(event.properties.sessionID, (draft) => {
draft.push({
draft.unshift({
id: event.id,
type: "synthetic",
sessionID: event.properties.sessionID,
@@ -98,7 +98,7 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
break
case "session.next.shell.started":
update(event.properties.sessionID, (draft) => {
draft.push({
draft.unshift({
id: event.id,
type: "shell",
callID: event.properties.callID,
@@ -120,7 +120,7 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
update(event.properties.sessionID, (draft) => {
const currentAssistant = activeAssistant(draft)
if (currentAssistant) currentAssistant.time.completed = event.properties.timestamp
draft.push({
draft.unshift({
id: event.id,
type: "assistant",
agent: event.properties.agent,
@@ -259,7 +259,7 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
break
case "session.next.compaction.started":
update(event.properties.sessionID, (draft) => {
draft.push({
draft.unshift({
id: event.id,
type: "compaction",
reason: event.properties.reason,

View File

@@ -5,7 +5,7 @@ import { Spinner } from "@tui/component/spinner"
import { useTheme } from "@tui/context/theme"
import { useLocal } from "@tui/context/local"
import { useKeyboard, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
import type { SyntaxStyle } from "@opentui/core"
import type { BoxRenderable, SyntaxStyle } from "@opentui/core"
import { Locale } from "@/util/locale"
import { LANGUAGE_EXTENSIONS } from "@/lsp/language"
import path from "path"
@@ -44,6 +44,10 @@ function View(props: { api: TuiPluginApi; sessionID: string }) {
const messages = createMemo(() => sync.data.messages[props.sessionID] ?? [])
const renderedMessages = createMemo(() => messages().toReversed())
const lastAssistant = createMemo(() => renderedMessages().findLast((message) => message.type === "assistant"))
const lastUserCreated = (index: number) =>
renderedMessages()
.slice(0, index)
.findLast((message) => message.type === "user")?.time.created
createEffect(() => {
void sync.session.message.sync(props.sessionID)
@@ -83,6 +87,7 @@ function View(props: { api: TuiPluginApi; sessionID: string }) {
last={lastAssistant()?.id === message.id}
syntax={syntax()}
subtleSyntax={subtleSyntax()}
start={lastUserCreated(index())}
/>
</Match>
<Match when={message.type === "synthetic"}>
@@ -294,12 +299,13 @@ function AssistantMessage(props: {
last: boolean
syntax: SyntaxStyle
subtleSyntax: SyntaxStyle
start?: number
}) {
const { theme } = useTheme()
const local = useLocal()
const duration = createMemo(() => {
if (!props.message.time.completed) return 0
return props.message.time.completed - props.message.time.created
return props.message.time.completed - (props.start ?? props.message.time.created)
})
const model = createMemo(() => {
const variant = props.message.model.variant ? `/${props.message.model.variant}` : ""
@@ -521,6 +527,7 @@ function InlineTool(props: {
part: SessionMessageAssistantTool
}) {
const { theme } = useTheme()
const [margin, setMargin] = createSignal(0)
const error = createMemo(() => (props.part.state.status === "error" ? props.part.state.error.message : undefined))
const denied = createMemo(() => {
const message = error()
@@ -532,21 +539,46 @@ function InlineTool(props: {
)
})
return (
<box marginTop={1} paddingLeft={3} flexShrink={0}>
<Switch>
<Match when={props.spinner}>
<Spinner color={theme.text}>{props.children}</Spinner>
</Match>
<Match when={true}>
<text paddingLeft={3} fg={props.complete ? theme.textMuted : theme.text}>
<Show fallback={<>~ {props.pending}</>} when={props.complete}>
{props.icon} {props.children}
</Show>
</text>
</Match>
</Switch>
<box
marginTop={margin()}
paddingLeft={3}
flexShrink={0}
renderBefore={function () {
const el = this as BoxRenderable
const parent = el.parent
if (!parent) return
if (el.height > 1) {
setMargin(1)
return
}
const previous = parent.getChildren()[parent.getChildren().indexOf(el) - 1]
if (!previous) {
setMargin(0)
return
}
if (previous.height > 1 || previous.id.startsWith("text-")) setMargin(1)
}}
>
<box flexDirection="row">
<box width={3} flexShrink={0}>
<Show
when={props.spinner}
fallback={<text fg={props.complete ? theme.textMuted : theme.text}>{props.complete ? props.icon : "~"}</text>}
>
<Spinner color={theme.text} />
</Show>
</box>
<text fg={props.complete ? theme.textMuted : theme.text}>
<Show fallback={props.pending} when={props.complete || props.spinner}>
{props.children}
</Show>
</text>
</box>
<Show when={error() && !denied()}>
<text fg={theme.error}>{error()}</text>
<box flexDirection="row">
<box width={3} flexShrink={0} />
<text fg={theme.error}>{error()}</text>
</box>
</Show>
</box>
)

View File

@@ -6,27 +6,15 @@ export const outro = (msg: string) => Effect.sync(() => prompts.outro(msg))
export const log = {
info: (msg: string) => Effect.sync(() => prompts.log.info(msg)),
error: (msg: string) => Effect.sync(() => prompts.log.error(msg)),
warn: (msg: string) => Effect.sync(() => prompts.log.warn(msg)),
success: (msg: string) => Effect.sync(() => prompts.log.success(msg)),
}
const optional = <Value>(result: Value | symbol) => {
if (prompts.isCancel(result)) return Option.none<Value>()
return Option.some(result)
}
export const select = <Value>(opts: Parameters<typeof prompts.select<Value>>[0]) =>
Effect.promise(() => prompts.select(opts)).pipe(Effect.map((result) => optional(result)))
export const autocomplete = <Value>(opts: Parameters<typeof prompts.autocomplete<Value>>[0]) =>
Effect.promise(() => prompts.autocomplete(opts)).pipe(Effect.map((result) => optional(result)))
export const text = (opts: Parameters<typeof prompts.text>[0]) =>
Effect.promise(() => prompts.text(opts)).pipe(Effect.map((result) => optional(result)))
export const password = (opts: Parameters<typeof prompts.password>[0]) =>
Effect.promise(() => prompts.password(opts)).pipe(Effect.map((result) => optional(result)))
Effect.tryPromise(() => prompts.select(opts)).pipe(
Effect.map((result) => {
if (prompts.isCancel(result)) return Option.none<Value>()
return Option.some(result)
}),
)
export const spinner = () => {
const s = prompts.spinner()

View File

@@ -46,7 +46,6 @@ import { Vcs } from "@/project/vcs"
import { Workspace } from "@/control-plane/workspace"
import { Worktree } from "@/worktree"
import { Pty } from "@/pty"
import { PtyTicket } from "@/pty/ticket"
import { Installation } from "@/installation"
import { ShareNext } from "@/share/share-next"
import { SessionShare } from "@/share/session"
@@ -99,7 +98,6 @@ export const AppLayer = Layer.mergeAll(
Workspace.defaultLayer,
Worktree.appLayer,
Pty.defaultLayer,
PtyTicket.defaultLayer,
Installation.defaultLayer,
ShareNext.defaultLayer,
SessionShare.defaultLayer,

View File

@@ -14,14 +14,7 @@ const ISSUER = "https://auth.openai.com"
const CODEX_API_ENDPOINT = "https://chatgpt.com/backend-api/codex/responses"
const OAUTH_PORT = 1455
const OAUTH_POLLING_SAFETY_MARGIN_MS = 3000
const ALLOWED_MODELS = new Set([
"gpt-5.5",
"gpt-5.2",
"gpt-5.3-codex",
"gpt-5.3-codex-spark",
"gpt-5.4",
"gpt-5.4-mini",
])
const ALLOWED_MODELS = new Set(["gpt-5.5", "gpt-5.2", "gpt-5.3-codex", "gpt-5.4", "gpt-5.4-mini"])
interface PkceCodes {
verifier: string

View File

@@ -1,68 +0,0 @@
export * as PtyTicket from "./ticket"
import { WorkspaceID } from "@/control-plane/schema"
import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref"
import { PtyID } from "@/pty/schema"
import { PositiveInt } from "@/util/schema"
import { Cache, Context, Duration, Effect, Layer, Schema } from "effect"
const DEFAULT_TTL = Duration.seconds(60)
const CAPACITY = 10_000
export const ConnectToken = Schema.Struct({
ticket: Schema.String,
expires_in: PositiveInt,
})
export type Scope = {
readonly ptyID: PtyID
readonly directory?: string
readonly workspaceID?: WorkspaceID
}
export interface Interface {
issue(input: Scope): Effect.Effect<typeof ConnectToken.Type>
consume(input: Scope & { readonly ticket: string }): Effect.Effect<boolean>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/PtyTicket") {}
function matches(record: Scope, input: Scope) {
return (
record.ptyID === input.ptyID && record.directory === input.directory && record.workspaceID === input.workspaceID
)
}
// Tickets are inserted via Cache.set and removed atomically via invalidateWhen. The lookup is
// never invoked; it dies if it ever is, which would signal a misuse of the Service interface.
const noLookup = () => Effect.die("PtyTicket cache must be used via set/invalidateWhen, never get")
// Visible for tests so the TTL can be shortened. Production uses `layer` with the default TTL.
export const make = (ttl: Duration.Input = DEFAULT_TTL) =>
Effect.gen(function* () {
const cache = yield* Cache.make<string, Scope>({ capacity: CAPACITY, lookup: noLookup, timeToLive: ttl })
const expiresIn = Math.max(1, Math.round(Duration.toSeconds(Duration.fromInputUnsafe(ttl))))
return Service.of({
issue: Effect.fn("PtyTicket.issue")(function* (input) {
const ticket = crypto.randomUUID()
yield* Cache.set(cache, ticket, input)
return { ticket, expires_in: expiresIn }
}),
consume: Effect.fn("PtyTicket.consume")(function* (input) {
return yield* Cache.invalidateWhen(cache, input.ticket, (stored) => matches(stored, input))
}),
})
})
export const layer = Layer.effect(Service, make())
export const defaultLayer = layer
export const scope = Effect.gen(function* () {
const instance = yield* InstanceRef
const workspaceID = yield* WorkspaceRef
return {
directory: instance?.directory,
workspaceID,
}
})

View File

@@ -1,13 +1,7 @@
import { Context } from "effect"
const opencodeOrigin = /^https:\/\/([a-z0-9-]+\.)*opencode\.ai$/
export type CorsOptions = { readonly cors?: ReadonlyArray<string> }
export const CorsConfig = Context.Reference<CorsOptions | undefined>("@opencode/ServerCorsConfig", {
defaultValue: () => undefined,
})
export function isAllowedCorsOrigin(input: string | undefined, opts?: CorsOptions) {
if (!input) return true
if (input.startsWith("http://localhost:")) return true
@@ -18,17 +12,3 @@ export function isAllowedCorsOrigin(input: string | undefined, opts?: CorsOption
if (opencodeOrigin.test(input)) return true
return opts?.cors?.includes(input) ?? false
}
export function isAllowedRequestOrigin(input: string | undefined, host: string | undefined, opts?: CorsOptions) {
if (!input) return true
if (host && sameHost(input, host)) return true
return isAllowedCorsOrigin(input, opts)
}
function sameHost(origin: string, host: string) {
try {
return new URL(origin).host === host
} catch {
return false
}
}

View File

@@ -21,9 +21,6 @@ export const ERRORS = {
},
},
},
403: {
description: "Forbidden",
},
404: {
description: "Not found",
content: {

View File

@@ -0,0 +1,244 @@
// TODO: Node adapter forthcoming — same pattern but using `node:http` + `ws` library,
// and `node:http`'s `upgrade` event.
//
// This module is a Bun-only proof-of-concept for a native `Bun.serve` listener that
// drives the experimental HttpApi handler directly (no Hono in the middle) and handles
// WebSocket upgrades inline based on path-matching. It exists to validate the pattern
// before deleting the Hono backend; `Server.listen()` is intentionally NOT wired to it.
import type { ServerWebSocket } from "bun"
import { Effect, Schema } from "effect"
import { AppRuntime } from "@/effect/app-runtime"
import { WithInstance } from "@/project/with-instance"
import { Pty } from "@/pty"
import { handlePtyInput } from "@/pty/input"
import { PtyID } from "@/pty/schema"
import { PtyPaths } from "@/server/routes/instance/httpapi/groups/pty"
import { ExperimentalHttpApiServer } from "@/server/routes/instance/httpapi/server"
import * as Log from "@opencode-ai/core/util/log"
import type { CorsOptions } from "./cors"
const log = Log.create({ service: "httpapi-listener" })
const decodePtyID = Schema.decodeUnknownSync(PtyID)
export type Listener = {
hostname: string
port: number
url: URL
stop: (close?: boolean) => Promise<void>
}
export type ListenOptions = CorsOptions & {
port: number
hostname: string
}
type WsKind = { kind: "pty"; ptyID: string; cursor: number | undefined; directory: string }
type PtyHandler = {
onMessage: (message: string | ArrayBuffer) => void
onClose: () => void
}
type WsState = WsKind & {
handler?: PtyHandler
pending: Array<string | Uint8Array>
ready: boolean
closed: boolean
}
// Derive from the OpenAPI path so this stays in sync if the route literal moves.
const ptyConnectPattern = new RegExp(`^${PtyPaths.connect.replace(/:[^/]+/g, "([^/]+)")}$`)
function parseCursor(value: string | null): number | undefined {
if (!value) return undefined
const parsed = Number(value)
if (!Number.isSafeInteger(parsed) || parsed < -1) return undefined
return parsed
}
function asAdapter(ws: ServerWebSocket<WsState>) {
return {
get readyState() {
return ws.readyState
},
send: (data: string | Uint8Array | ArrayBuffer) => {
try {
if (data instanceof ArrayBuffer) ws.send(new Uint8Array(data))
else ws.send(data)
} catch {
// socket likely already closed; ignore
}
},
close: (code?: number, reason?: string) => {
try {
ws.close(code, reason)
} catch {
// ignore
}
},
}
}
/**
* Spin up a native Bun.serve that:
* 1. Routes all HTTP traffic through the HttpApi web handler.
* 2. Intercepts known WebSocket upgrade paths and handles them inline.
*
* This bypasses Hono entirely. The Hono code path remains untouched.
*/
export async function listen(opts: ListenOptions): Promise<Listener> {
const built = ExperimentalHttpApiServer.webHandler(opts)
const handler = built.handler
const context = ExperimentalHttpApiServer.context
const start = (port: number) => {
try {
return Bun.serve<WsState>({
hostname: opts.hostname,
port,
idleTimeout: 0,
fetch(request, server) {
const url = new URL(request.url)
const ptyMatch = url.pathname.match(ptyConnectPattern)
if (ptyMatch && request.headers.get("upgrade")?.toLowerCase() === "websocket") {
const ptyID = ptyMatch[1]!
const cursor = parseCursor(url.searchParams.get("cursor"))
// Resolve the instance directory the same way the HttpApi
// `instance-context` middleware does (search params, then header,
// then process.cwd()).
const directory =
url.searchParams.get("directory") ?? request.headers.get("x-opencode-directory") ?? process.cwd()
const upgraded = server.upgrade(request, {
data: {
kind: "pty",
ptyID,
cursor,
directory,
pending: [],
ready: false,
closed: false,
} satisfies WsState,
})
if (upgraded) return undefined
return new Response("upgrade failed", { status: 400 })
}
// TODO: workspace-proxy WS upgrade detection. The Hono path forwards via a
// remote `new WebSocket(url, ...)` (see ServerProxy.websocket). To support
// that here we'd need to (a) resolve the workspace target the same way
// `WorkspaceRouterMiddleware` does today, then (b) `server.upgrade(request,
// { data: { kind: "proxy", target, headers, protocols } })` and bridge the
// ServerWebSocket to a remote WebSocket inside the `websocket` handlers.
// Deferred to a follow-up — the proxy story needs more design (auth header
// forwarding, fence sync, reconnection semantics) than fits this PR.
return handler(request as Request, context as never)
},
websocket: {
open(ws) {
const data = ws.data
if (data.kind !== "pty") {
ws.close(1011, "unknown ws kind")
return
}
const id = (() => {
try {
return decodePtyID(data.ptyID)
} catch {
ws.close(1008, "invalid pty id")
return undefined
}
})()
if (!id) return
;(async () => {
const result = await WithInstance.provide({
directory: data.directory,
fn: () =>
AppRuntime.runPromise(
Effect.gen(function* () {
const pty = yield* Pty.Service
return yield* pty.connect(id, asAdapter(ws), data.cursor)
}).pipe(Effect.withSpan("HttpApiListener.pty.connect.open")),
),
})
return await result
})()
.then((handler) => {
if (data.closed) {
handler?.onClose()
return
}
if (!handler) {
ws.close(4404, "session not found")
return
}
data.handler = handler
data.ready = true
for (const msg of data.pending) {
AppRuntime.runPromise(handlePtyInput(handler, msg)).catch(() => undefined)
}
data.pending.length = 0
})
.catch((err) => {
log.error("pty connect failed", { error: err })
ws.close(1011, "pty connect failed")
})
},
message(ws, message) {
const data = ws.data
if (data.kind !== "pty") return
const payload =
typeof message === "string"
? message
: message instanceof Buffer
? new Uint8Array(message.buffer, message.byteOffset, message.byteLength)
: (message as Uint8Array)
if (!data.ready || !data.handler) {
data.pending.push(payload)
return
}
AppRuntime.runPromise(handlePtyInput(data.handler, payload)).catch(() => undefined)
},
close(ws) {
const data = ws.data
data.closed = true
data.handler?.onClose()
},
},
})
} catch (err) {
log.error("Bun.serve failed", { error: err })
return undefined
}
}
const server = opts.port === 0 ? (start(4096) ?? start(0)) : start(opts.port)
if (!server) throw new Error(`Failed to start server on port ${opts.port}`)
const port = server.port
if (port === undefined) throw new Error("Bun.serve started without a numeric port")
const url = new URL("http://localhost")
url.hostname = opts.hostname
url.port = String(port)
let closing: Promise<void> | undefined
return {
hostname: opts.hostname,
port,
url,
stop(close?: boolean) {
closing ??= (async () => {
await server.stop(close)
// NOTE: we deliberately do NOT call `built.dispose()` here. The
// underlying `webHandler` is memoized at module level (same as the
// Hono path), so disposing it would tear down shared services for
// every other consumer in the process. Lifecycle teardown is owned
// by the AppRuntime itself.
})()
return closing
},
}
}
export * as HttpApiListener from "./httpapi-listener"

View File

@@ -1,34 +0,0 @@
import { NodeHttpServer } from "@effect/platform-node"
import { Effect, Layer } from "effect"
import { createServer } from "node:http"
import type { Opts } from "./adapter"
import { Service } from "./httpapi-server"
export { Service }
export const name = "node-http-server"
export const layer = (opts: Opts) => {
const server = createServer()
const serverRef = { closeStarted: false, forceStop: false }
const close = server.close.bind(server)
// Keep shutdown owned by NodeHttpServer, but honor listener.stop(true) by
// force-closing active HTTP sockets when its finalizer calls server.close().
server.close = ((callback?: Parameters<typeof server.close>[0]) => {
serverRef.closeStarted = true
const result = close(callback)
if (serverRef.forceStop) server.closeAllConnections()
return result
}) as typeof server.close
return Layer.mergeAll(
NodeHttpServer.layer(() => server, { port: opts.port, host: opts.hostname, gracefulShutdownTimeout: "1 second" }),
Layer.succeed(Service)(
Service.of({
closeAll: Effect.sync(() => {
serverRef.forceStop = true
if (serverRef.closeStarted) server.closeAllConnections()
}),
}),
),
)
}

View File

@@ -1,9 +0,0 @@
import { Context, Effect } from "effect"
export interface Interface {
readonly closeAll: Effect.Effect<void>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/HttpApiServer") {}
export * as HttpApiServer from "./httpapi-server"

View File

@@ -12,8 +12,6 @@ import { cors } from "hono/cors"
import { compress } from "hono/compress"
import * as ServerBackend from "./backend"
import { isAllowedCorsOrigin, type CorsOptions } from "./cors"
import { isPtyConnectPath, PTY_CONNECT_TICKET_QUERY } from "./shared/pty-ticket"
import { isPublicUIPath } from "./shared/public-ui"
const log = Log.create({ service: "server" })
@@ -46,8 +44,6 @@ export const AuthMiddleware: MiddlewareHandler = (c, next) => {
if (c.req.method === "OPTIONS") return next()
const password = Flag.OPENCODE_SERVER_PASSWORD
if (!password) return next()
if (isPublicUIPath(c.req.method, c.req.path)) return next()
if (isPtyConnectPath(c.req.path) && c.req.query(PTY_CONNECT_TICKET_QUERY)) return next()
const username = Flag.OPENCODE_SERVER_USERNAME ?? "opencode"
if (c.req.query("auth_token")) c.req.raw.headers.set("authorization", `Basic ${c.req.query("auth_token")}`)
@@ -62,7 +58,6 @@ export function LoggerMiddleware(backendAttributes: ServerBackend.Attributes): M
const attributes = {
method: c.req.method,
path: c.req.path,
// If this logger grows full-URL fields, redact auth_token and ticket query params.
...backendAttributes,
}
log.info("request", attributes)

View File

@@ -1,5 +1,4 @@
import { Pty } from "@/pty"
import { PtyTicket } from "@/pty/ticket"
import { PtyID } from "@/pty/schema"
import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
@@ -24,7 +23,6 @@ export const PtyPaths = {
get: `${root}/:ptyID`,
update: `${root}/:ptyID`,
remove: `${root}/:ptyID`,
connectToken: `${root}/:ptyID/connect-token`,
connect: `${root}/:ptyID/connect`,
} as const
@@ -95,17 +93,6 @@ export const PtyApi = HttpApi.make("pty")
description: "Remove and terminate a specific pseudo-terminal (PTY) session.",
}),
),
HttpApiEndpoint.post("connectToken", PtyPaths.connectToken, {
params: { ptyID: PtyID },
success: described(PtyTicket.ConnectToken, "WebSocket connect token"),
error: [HttpApiError.Forbidden, HttpApiError.NotFound],
}).annotateMerge(
OpenApi.annotations({
identifier: "pty.connectToken",
summary: "Create PTY WebSocket token",
description: "Create a short-lived ticket for opening a PTY WebSocket connection.",
}),
),
)
.annotateMerge(OpenApi.annotations({ title: "pty", description: "Experimental HttpApi PTY routes." }))
.middleware(InstanceContextMiddleware)
@@ -126,7 +113,7 @@ export const PtyConnectApi = HttpApi.make("pty-connect").add(
HttpApiEndpoint.get("connect", PtyPaths.connect, {
params: Params,
success: described(Schema.Boolean, "Connected session"),
error: [HttpApiError.Forbidden, HttpApiError.NotFound],
error: HttpApiError.NotFound,
}).annotateMerge(
OpenApi.annotations({
identifier: "pty.connect",

View File

@@ -1,32 +1,17 @@
import { Pty } from "@/pty"
import { PtyID } from "@/pty/schema"
import { PtyTicket } from "@/pty/ticket"
import { handlePtyInput } from "@/pty/input"
import { Shell } from "@/shell/shell"
import { EffectBridge } from "@/effect/bridge"
import { CorsConfig, isAllowedRequestOrigin, type CorsOptions } from "@/server/cors"
import {
PTY_CONNECT_TICKET_QUERY,
PTY_CONNECT_TOKEN_HEADER,
PTY_CONNECT_TOKEN_HEADER_VALUE,
} from "@/server/shared/pty-ticket"
import { Effect } from "effect"
import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi"
import * as Socket from "effect/unstable/socket/Socket"
import { InstanceHttpApi } from "../api"
import { CursorQuery, Params, PtyPaths } from "../groups/pty"
import { WebSocketTracker } from "../websocket-tracker"
function validOrigin(request: HttpServerRequest.HttpServerRequest, opts: CorsOptions | undefined) {
return isAllowedRequestOrigin(request.headers.origin, request.headers.host, opts)
}
export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handlers) =>
Effect.gen(function* () {
const pty = yield* Pty.Service
const tickets = yield* PtyTicket.Service
const cors = yield* CorsConfig
const shells = Effect.fn("PtyHttpApi.shells")(function* () {
return yield* Effect.promise(() => Shell.list())
@@ -67,14 +52,6 @@ export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handler
return true
})
const connectToken = Effect.fn("PtyHttpApi.connectToken")(function* (ctx: { params: { ptyID: PtyID } }) {
const request = yield* HttpServerRequest.HttpServerRequest
if (request.headers[PTY_CONNECT_TOKEN_HEADER] !== PTY_CONNECT_TOKEN_HEADER_VALUE || !validOrigin(request, cors))
return yield* new HttpApiError.Forbidden({})
if (!(yield* pty.get(ctx.params.ptyID))) return yield* new HttpApiError.NotFound({})
return yield* tickets.issue({ ptyID: ctx.params.ptyID, ...(yield* PtyTicket.scope) })
})
return handlers
.handle("shells", shells)
.handle("list", list)
@@ -82,15 +59,12 @@ export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handler
.handle("get", get)
.handle("update", update)
.handle("remove", remove)
.handle("connectToken", connectToken)
}),
)
export const ptyConnectRoute = HttpRouter.use((router) =>
Effect.gen(function* () {
const pty = yield* Pty.Service
const tickets = yield* PtyTicket.Service
const cors = yield* CorsConfig
yield* router.add(
"GET",
PtyPaths.connect,
@@ -99,37 +73,16 @@ export const ptyConnectRoute = HttpRouter.use((router) =>
if (!(yield* pty.get(params.ptyID))) return HttpServerResponse.empty({ status: 404 })
const query = yield* HttpServerRequest.schemaSearchParams(CursorQuery)
const request = yield* HttpServerRequest.HttpServerRequest
const ticket = new URL(request.url, "http://localhost").searchParams.get(PTY_CONNECT_TICKET_QUERY)
if (ticket) {
const valid = validOrigin(request, cors)
? yield* tickets.consume({ ticket, ptyID: params.ptyID, ...(yield* PtyTicket.scope) })
: false
if (!valid) return HttpServerResponse.empty({ status: 403 })
}
const parsedCursor = query.cursor === undefined ? undefined : Number(query.cursor)
const cursor =
parsedCursor !== undefined && Number.isSafeInteger(parsedCursor) && parsedCursor >= -1
? parsedCursor
: undefined
const socket = yield* Effect.orDie(request.upgrade)
const socket = yield* Effect.orDie((yield* HttpServerRequest.HttpServerRequest).upgrade)
const write = yield* socket.writer
const closeAccepted = (event: Socket.CloseEvent) =>
socket
.runRaw(() => Effect.void, { onOpen: write(event).pipe(Effect.catch(() => Effect.void)) })
.pipe(
Effect.timeout("1 second"),
Effect.catchReason("SocketError", "SocketCloseError", () => Effect.void),
Effect.catch(() => Effect.void),
)
const registered = yield* WebSocketTracker.register(write(WebSocketTracker.SERVER_CLOSING_EVENT()))
if (!registered) {
yield* closeAccepted(WebSocketTracker.SERVER_CLOSING_EVENT())
return HttpServerResponse.empty()
}
const bridge = yield* EffectBridge.make()
const services = yield* Effect.context()
const writeScoped = (effect: Effect.Effect<void, unknown>) => {
bridge.fork(effect.pipe(Effect.catch(() => Effect.void)))
Effect.runForkWith(services)(effect.pipe(Effect.catch(() => Effect.void)))
}
let closed = false
const adapter = {
@@ -147,10 +100,7 @@ export const ptyConnectRoute = HttpRouter.use((router) =>
},
}
const handler = yield* pty.connect(params.ptyID, adapter, cursor)
if (!handler) {
yield* closeAccepted(new Socket.CloseEvent(4404, "session not found"))
return HttpServerResponse.empty()
}
if (!handler) return HttpServerResponse.empty()
yield* socket
.runRaw((message) => handlePtyInput(handler, message))

View File

@@ -1,31 +1,23 @@
import { ServerAuth } from "@/server/auth"
import { Effect, Encoding, Layer, Redacted } from "effect"
import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import { HttpApiError, HttpApiMiddleware } from "effect/unstable/httpapi"
import { hasPtyConnectTicketURL } from "@/server/shared/pty-ticket"
import { isPublicUIPath } from "@/server/shared/public-ui"
import { HttpApiError, HttpApiMiddleware, HttpApiSecurity } from "effect/unstable/httpapi"
const AUTH_TOKEN_QUERY = "auth_token"
const UNAUTHORIZED = 401
const WWW_AUTHENTICATE = 'Basic realm="Secure Area"'
// Avoid HttpApiSecurity alternatives here: Effect security middleware wraps the
// full handler, so a downstream failure can make the next auth alternative run
// and remap an authorized NotFound into Unauthorized.
export class Authorization extends HttpApiMiddleware.Service<Authorization>()(
"@opencode/ExperimentalHttpApiAuthorization",
{
error: HttpApiError.UnauthorizedNoContent,
security: {
basic: HttpApiSecurity.basic,
authToken: HttpApiSecurity.apiKey({ in: "query", key: AUTH_TOKEN_QUERY }),
},
},
) {}
function emptyCredential() {
return {
username: "",
password: Redacted.make(""),
}
}
function validateCredential<A, E, R>(
effect: Effect.Effect<A, E, R>,
credential: ServerAuth.DecodedCredentials,
@@ -39,14 +31,19 @@ function validateCredential<A, E, R>(
}
function decodeCredential(input: string) {
const emptyCredential = {
username: "",
password: Redacted.make(""),
}
return Encoding.decodeBase64String(input)
.asEffect()
.pipe(
Effect.match({
onFailure: emptyCredential,
onFailure: () => emptyCredential,
onSuccess: (header) => {
const parts = header.split(":")
if (parts.length !== 2) return emptyCredential()
if (parts.length !== 2) return emptyCredential
return {
username: parts[0],
password: Redacted.make(parts[1]),
@@ -56,18 +53,6 @@ function decodeCredential(input: string) {
)
}
function credentialFromRequest(request: HttpServerRequest.HttpServerRequest) {
return credentialFromURL(new URL(request.url, "http://localhost"), request)
}
function credentialFromURL(url: URL, request: HttpServerRequest.HttpServerRequest) {
const token = url.searchParams.get(AUTH_TOKEN_QUERY)
if (token) return decodeCredential(token)
const match = /^Basic\s+(.+)$/i.exec(request.headers.authorization ?? "")
if (match) return decodeCredential(match[1])
return Effect.succeed(emptyCredential())
}
function validateRawCredential<A, E, R>(
effect: Effect.Effect<A, E, R>,
credential: ServerAuth.DecodedCredentials,
@@ -92,12 +77,21 @@ export const authorizationRouterMiddleware = HttpRouter.middleware()(
return (effect) =>
Effect.gen(function* () {
const request = yield* HttpServerRequest.HttpServerRequest
const url = new URL(request.url, "http://localhost")
if (isPublicUIPath(request.method, url.pathname)) return yield* effect
if (hasPtyConnectTicketURL(url)) return yield* effect
return yield* credentialFromURL(url, request).pipe(
Effect.flatMap((credential) => validateRawCredential(effect, credential, config)),
)
const match = /^Basic\s+(.+)$/i.exec(request.headers.authorization ?? "")
if (match) {
return yield* decodeCredential(match[1]).pipe(
Effect.flatMap((credential) => validateRawCredential(effect, credential, config)),
)
}
const token = new URL(request.url, "http://localhost").searchParams.get(AUTH_TOKEN_QUERY)
if (token) {
return yield* decodeCredential(token).pipe(
Effect.flatMap((credential) => validateRawCredential(effect, credential, config)),
)
}
return yield* validateRawCredential(effect, { username: "", password: Redacted.make("") }, config)
})
}),
)
@@ -106,14 +100,12 @@ export const authorizationLayer = Layer.effect(
Authorization,
Effect.gen(function* () {
const config = yield* ServerAuth.Config
if (!ServerAuth.required(config)) return Authorization.of((effect) => effect)
return Authorization.of((effect) =>
Effect.gen(function* () {
const request = yield* HttpServerRequest.HttpServerRequest
return yield* credentialFromRequest(request).pipe(
Effect.flatMap((credential) => validateCredential(effect, credential, config)),
)
}),
)
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)),
),
})
}),
)

View File

@@ -1,58 +0,0 @@
import { Provider } from "@/provider/provider"
import { Session } from "@/session/session"
import { NotFoundError } from "@/storage/storage"
import { iife } from "@/util/iife"
import { NamedError } from "@opencode-ai/core/util/error"
import * as Log from "@opencode-ai/core/util/log"
import { Cause, Effect } from "effect"
import { HttpRouter, HttpServerError, HttpServerRespondable, HttpServerResponse } from "effect/unstable/http"
const log = Log.create({ service: "server" })
// Keep typed HttpApi failures on their declared error path; this boundary only replaces defect-only empty 500s.
export const errorLayer = HttpRouter.middleware<{ handles: unknown }>()((effect) =>
effect.pipe(
Effect.catchCause((cause) => {
const defect = cause.reasons.filter(Cause.isDieReason).find((reason) => {
if (HttpServerResponse.isHttpServerResponse(reason.defect)) return false
if (HttpServerError.isHttpServerError(reason.defect)) return false
if (HttpServerRespondable.isRespondable(reason.defect)) return false
return true
})
if (!defect) return Effect.failCause(cause)
const error = defect.defect
log.error("failed", { error, cause: Cause.pretty(cause) })
if (error instanceof NamedError) {
return Effect.succeed(
HttpServerResponse.jsonUnsafe(error.toObject(), {
status: iife(() => {
if (error instanceof NotFoundError) return 404
if (error instanceof Provider.ModelNotFoundError) return 400
if (error.name === "ProviderAuthValidationFailed") return 400
if (error.name.startsWith("Worktree")) return 400
return 500
}),
}),
)
}
if (error instanceof Session.BusyError) {
return Effect.succeed(
HttpServerResponse.jsonUnsafe(new NamedError.Unknown({ message: error.message }).toObject(), {
status: 400,
}),
)
}
return Effect.succeed(
HttpServerResponse.jsonUnsafe(
new NamedError.Unknown({
message: error instanceof Error && error.stack ? error.stack : String(error),
}).toObject(),
{ status: 500 },
),
)
}),
),
).layer

View File

@@ -2,7 +2,6 @@ import { ProxyUtil } from "@/server/proxy-util"
import { Effect, Stream } from "effect"
import { HttpBody, HttpClient, HttpClientRequest, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import * as Socket from "effect/unstable/socket/Socket"
import { WebSocketTracker } from "../websocket-tracker"
function webSource(request: HttpServerRequest.HttpServerRequest): Request | undefined {
return request.source instanceof Request ? request.source : undefined
@@ -29,33 +28,6 @@ export function websocket(
})
const writeInbound = yield* inbound.writer
const writeOutbound = yield* outbound.writer
const closeSocket = (socket: Socket.Socket, write: (event: Socket.CloseEvent) => Effect.Effect<void, unknown>) =>
socket
.runRaw(() => Effect.void, {
onOpen: write(WebSocketTracker.SERVER_CLOSING_EVENT()).pipe(Effect.catch(() => Effect.void)),
})
.pipe(
Effect.timeout("1 second"),
Effect.catchReason("SocketError", "SocketCloseError", () => Effect.void),
Effect.catch(() => Effect.void),
)
const closeAccepted = Effect.all([closeSocket(inbound, writeInbound), closeSocket(outbound, writeOutbound)], {
concurrency: "unbounded",
discard: true,
})
const registered = yield* WebSocketTracker.register(
Effect.all(
[
writeInbound(WebSocketTracker.SERVER_CLOSING_EVENT()),
writeOutbound(WebSocketTracker.SERVER_CLOSING_EVENT()),
],
{ concurrency: "unbounded", discard: true },
),
)
if (!registered) {
yield* closeAccepted
return HttpServerResponse.empty()
}
yield* outbound
.runRaw((message) => writeInbound(message))

View File

@@ -25,7 +25,6 @@ import { ProviderAuth } from "@/provider/auth"
import { ModelsDev } from "@/provider/models"
import { Provider } from "@/provider/provider"
import { Pty } from "@/pty"
import { PtyTicket } from "@/pty/ticket"
import { Question } from "@/question"
import { Session } from "@/session/session"
import { SessionCompaction } from "@/session/compaction"
@@ -45,7 +44,7 @@ import { lazy } from "@/util/lazy"
import { Vcs } from "@/project/vcs"
import { Worktree } from "@/worktree"
import { Workspace } from "@/control-plane/workspace"
import { CorsConfig, isAllowedCorsOrigin, type CorsOptions } from "@/server/cors"
import { isAllowedCorsOrigin, type CorsOptions } from "@/server/cors"
import { serveUIEffect } from "@/server/shared/ui"
import { ServerAuth } from "@/server/auth"
import { InstanceHttpApi, RootHttpApi } from "./api"
@@ -73,7 +72,6 @@ import { workspaceRouterMiddleware, workspaceRoutingLayer } from "./middleware/w
import { disposeMiddleware } from "./lifecycle"
import { memoMap } from "@opencode-ai/core/effect/memo-map"
import * as ServerBackend from "@/server/backend"
import { errorLayer } from "./middleware/error"
export const context = Context.makeUnsafe<unknown>(new Map())
@@ -145,7 +143,6 @@ const uiRoute = HttpRouter.use((router) =>
export function createRoutes(corsOptions?: CorsOptions) {
return Layer.mergeAll(rootApiRoutes, eventApiRoutes, instanceRoutes, uiRoute).pipe(
Layer.provide([
errorLayer,
cors(corsOptions),
runtime,
Account.defaultLayer,
@@ -166,7 +163,6 @@ export function createRoutes(corsOptions?: CorsOptions) {
ProviderAuth.defaultLayer,
Provider.defaultLayer,
Pty.defaultLayer,
PtyTicket.defaultLayer,
Question.defaultLayer,
Ripgrep.defaultLayer,
Session.defaultLayer,
@@ -191,7 +187,6 @@ export function createRoutes(corsOptions?: CorsOptions) {
FetchHttpClient.layer,
HttpServer.layerServices,
]),
Layer.provideMerge(Layer.succeed(CorsConfig)(corsOptions)),
Layer.provideMerge(InstanceLayer.layer),
Layer.provideMerge(Observability.layer),
)

View File

@@ -1,57 +0,0 @@
import { Context, Effect, Layer, Option } from "effect"
import * as Socket from "effect/unstable/socket/Socket"
export const SERVER_CLOSING_EVENT = () => new Socket.CloseEvent(1001, "server closing")
type Close = Effect.Effect<void, unknown>
export interface Interface {
readonly add: (close: Close) => Effect.Effect<boolean>
readonly remove: (close: Close) => Effect.Effect<void>
readonly closeAll: Effect.Effect<void>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/HttpApiWebSocketTracker") {}
export const layer = Layer.sync(Service)(() => {
const sockets = new Set<Close>()
let closing = false
return Service.of({
add: (close) =>
Effect.gen(function* () {
if (closing) return false
sockets.add(close)
return true
}),
remove: (close) =>
Effect.sync(() => {
sockets.delete(close)
}),
closeAll: Effect.gen(function* () {
closing = true
const active = Array.from(sockets)
sockets.clear()
yield* Effect.all(
active.map((close) =>
close.pipe(
Effect.timeout("1 second"),
Effect.catch(() => Effect.void),
),
),
{ concurrency: "unbounded", discard: true },
)
}),
})
})
export const register = (close: Close) =>
Effect.gen(function* () {
const tracker = yield* Effect.serviceOption(Service)
if (Option.isNone(tracker)) return true
const registered = yield* tracker.value.add(close)
if (!registered) return false
yield* Effect.addFinalizer(() => tracker.value.remove(close))
return true
})
export * as WebSocketTracker from "./websocket-tracker"

View File

@@ -39,11 +39,10 @@ import { SessionPaths } from "./httpapi/groups/session"
import { SyncPaths } from "./httpapi/groups/sync"
import { TuiPaths } from "./httpapi/groups/tui"
import { WorkspacePaths } from "./httpapi/groups/workspace"
import type { CorsOptions } from "@/server/cors"
export const InstanceRoutes = (upgrade: UpgradeWebSocket, opts?: CorsOptions): Hono => {
export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => {
const app = new Hono()
const handler = ExperimentalHttpApiServer.webHandler(opts).handler
const handler = ExperimentalHttpApiServer.webHandler().handler
const context = Context.empty() as Context.Context<unknown>
app.all("/api/*", (c) => handler(c.req.raw, context))
@@ -108,7 +107,6 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket, opts?: CorsOptions): H
app.get(PtyPaths.get, (c) => handler(c.req.raw, context))
app.put(PtyPaths.update, (c) => handler(c.req.raw, context))
app.delete(PtyPaths.remove, (c) => handler(c.req.raw, context))
app.post(PtyPaths.connectToken, (c) => handler(c.req.raw, context))
app.get(PtyPaths.connect, (c) => handler(c.req.raw, context))
app.get(SessionPaths.list, (c) => handler(c.req.raw, context))
app.get(SessionPaths.status, (c) => handler(c.req.raw, context))
@@ -160,7 +158,7 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket, opts?: CorsOptions): H
return app
.route("/project", ProjectRoutes())
.route("/pty", PtyRoutes(upgrade, opts))
.route("/pty", PtyRoutes(upgrade))
.route("/config", ConfigRoutes())
.route("/experimental", ExperimentalRoutes())
.route("/session", SessionRoutes())

View File

@@ -1,5 +1,4 @@
import { Hono } from "hono"
import type { Context } from "hono"
import { describeRoute, validator, resolver } from "hono-openapi"
import type { UpgradeWebSocket } from "hono/ws"
import { Effect, Schema } from "effect"
@@ -7,19 +6,10 @@ import z from "zod"
import { AppRuntime } from "@/effect/app-runtime"
import { Pty } from "@/pty"
import { PtyID } from "@/pty/schema"
import { PtyTicket } from "@/pty/ticket"
import { Shell } from "@/shell/shell"
import { NotFoundError } from "@/storage/storage"
import { errors } from "../../error"
import { jsonRequest, runRequest } from "./trace"
import { HTTPException } from "hono/http-exception"
import { isAllowedRequestOrigin, type CorsOptions } from "@/server/cors"
import {
PTY_CONNECT_TICKET_QUERY,
PTY_CONNECT_TOKEN_HEADER,
PTY_CONNECT_TOKEN_HEADER_VALUE,
} from "@/server/shared/pty-ticket"
import { zod as effectZod } from "@/util/effect-zod"
const ShellItem = z.object({
path: z.string(),
@@ -28,11 +18,7 @@ const ShellItem = z.object({
})
const decodePtyID = Schema.decodeUnknownSync(PtyID)
function validOrigin(c: Context, opts?: CorsOptions) {
return isAllowedRequestOrigin(c.req.header("origin"), c.req.header("host"), opts)
}
export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket, opts?: CorsOptions) {
export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
return new Hono()
.get(
"/shells",
@@ -189,43 +175,6 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket, opts?: CorsOptions
return true
}),
)
.post(
"/:ptyID/connect-token",
describeRoute({
summary: "Create PTY WebSocket token",
description: "Create a short-lived token for opening a PTY WebSocket connection.",
operationId: "pty.connectToken",
responses: {
200: {
description: "WebSocket connect token",
content: {
"application/json": {
schema: resolver(effectZod(PtyTicket.ConnectToken)),
},
},
},
...errors(403, 404),
},
}),
validator("param", z.object({ ptyID: PtyID.zod })),
async (c) => {
if (c.req.header(PTY_CONNECT_TOKEN_HEADER) !== PTY_CONNECT_TOKEN_HEADER_VALUE || !validOrigin(c, opts))
throw new HTTPException(403)
const result = await runRequest(
"PtyRoutes.connectToken",
c,
Effect.gen(function* () {
const pty = yield* Pty.Service
const id = c.req.valid("param").ptyID
if (!(yield* pty.get(id))) return
const tickets = yield* PtyTicket.Service
return yield* tickets.issue({ ptyID: id, ...(yield* PtyTicket.scope) })
}),
)
if (!result) throw new NotFoundError({ message: "Session not found" })
return c.json(result)
},
)
.get(
"/:ptyID/connect",
describeRoute({
@@ -241,7 +190,7 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket, opts?: CorsOptions
},
},
},
...errors(403, 404),
...errors(404),
},
}),
validator("param", z.object({ ptyID: PtyID.zod })),
@@ -252,6 +201,14 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket, opts?: CorsOptions
}
const id = decodePtyID(c.req.param("ptyID"))
const cursor = (() => {
const value = c.req.query("cursor")
if (!value) return
const parsed = Number(value)
if (!Number.isSafeInteger(parsed) || parsed < -1) return
return parsed
})()
let handler: Handler | undefined
if (
!(await runRequest(
"PtyRoutes.connect",
@@ -262,29 +219,8 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket, opts?: CorsOptions
}),
))
) {
throw new NotFoundError({ message: "Session not found" })
throw new Error("Session not found")
}
const ticket = c.req.query(PTY_CONNECT_TICKET_QUERY)
if (ticket) {
if (!validOrigin(c, opts)) throw new HTTPException(403)
const valid = await runRequest(
"PtyRoutes.connect.ticket",
c,
Effect.gen(function* () {
const tickets = yield* PtyTicket.Service
return yield* tickets.consume({ ticket, ptyID: id, ...(yield* PtyTicket.scope) })
}),
)
if (!valid) throw new HTTPException(403)
}
const cursor = (() => {
const value = c.req.query("cursor")
if (!value) return
const parsed = Number(value)
if (!Number.isSafeInteger(parsed) || parsed < -1) return
return parsed
})()
let handler: Handler | undefined
type Socket = {
readyState: number

View File

@@ -5,10 +5,7 @@ import { lazy } from "@/util/lazy"
import * as Log from "@opencode-ai/core/util/log"
import { Flag } from "@opencode-ai/core/flag/flag"
import { WorkspaceID } from "@/control-plane/schema"
import { Context, Effect, Exit, Layer, Scope } from "effect"
import { HttpRouter, HttpServer } from "effect/unstable/http"
import { OpenApi } from "effect/unstable/httpapi"
import * as HttpApiServer from "#httpapi-server"
import { MDNS } from "./mdns"
import { AuthMiddleware, CompressionMiddleware, CorsMiddleware, ErrorMiddleware, LoggerMiddleware } from "./middleware"
import { FenceMiddleware } from "./fence"
@@ -21,8 +18,6 @@ import { WorkspaceRouterMiddleware } from "./workspace"
import { InstanceMiddleware } from "./routes/instance/middleware"
import { WorkspaceRoutes } from "./routes/control/workspace"
import { ExperimentalHttpApiServer } from "./routes/instance/httpapi/server"
import { disposeMiddleware } from "./routes/instance/httpapi/lifecycle"
import { WebSocketTracker } from "./routes/instance/httpapi/websocket-tracker"
import { PublicApi } from "./routes/instance/httpapi/public"
import * as ServerBackend from "./backend"
import type { CorsOptions } from "./cors"
@@ -120,7 +115,7 @@ function createHono(opts: CorsOptions, selection: ServerBackend.Selection = Serv
app: app
.use(InstanceMiddleware(Flag.OPENCODE_WORKSPACE_ID ? WorkspaceID.make(Flag.OPENCODE_WORKSPACE_ID) : undefined))
.use(FenceMiddleware)
.route("/", InstanceRoutes(runtime.upgradeWebSocket, opts)),
.route("/", InstanceRoutes(runtime.upgradeWebSocket)),
runtime,
}
}
@@ -136,7 +131,7 @@ function createHono(opts: CorsOptions, selection: ServerBackend.Selection = Serv
app: app
.route("/", ControlPlaneRoutes())
.route("/", workspaceApp)
.route("/", InstanceRoutes(runtime.upgradeWebSocket, opts))
.route("/", InstanceRoutes(runtime.upgradeWebSocket))
.route("/", UIRoutes()),
runtime,
}
@@ -187,145 +182,37 @@ export async function openapiHono() {
export let url: URL
export async function listen(opts: ListenOptions): Promise<Listener> {
const selected = select()
const inner: Listener =
selected.backend === "effect-httpapi" ? await listenHttpApi(opts, selected) : await listenLegacy(opts)
const built = create(opts)
const server = await built.runtime.listen(opts)
const next = new URL(inner.url)
const next = new URL("http://localhost")
next.hostname = opts.hostname
next.port = String(server.port)
url = next
const mdns =
opts.mdns && inner.port && opts.hostname !== "127.0.0.1" && opts.hostname !== "localhost" && opts.hostname !== "::1"
opts.mdns &&
server.port &&
opts.hostname !== "127.0.0.1" &&
opts.hostname !== "localhost" &&
opts.hostname !== "::1"
if (mdns) {
MDNS.publish(inner.port, opts.mdnsDomain)
MDNS.publish(server.port, opts.mdnsDomain)
} else if (opts.mdns) {
log.warn("mDNS enabled but hostname is loopback; skipping mDNS publish")
}
let closing: Promise<void> | undefined
let mdnsUnpublished = false
const unpublish = () => {
if (!mdns || mdnsUnpublished) return
mdnsUnpublished = true
MDNS.unpublish()
}
return {
hostname: inner.hostname,
port: inner.port,
url: next,
stop(close?: boolean) {
unpublish()
// Always forward stop(true), even if a graceful stop was requested
// first, so native listeners can escalate shutdown in-place.
const next = inner.stop(close)
closing ??= next
return close ? next.then(() => closing!) : closing
},
}
}
async function listenLegacy(opts: ListenOptions): Promise<Listener> {
const built = create(opts)
const server = await built.runtime.listen(opts)
const innerUrl = new URL("http://localhost")
innerUrl.hostname = opts.hostname
innerUrl.port = String(server.port)
return {
hostname: opts.hostname,
port: server.port,
url: innerUrl,
stop: (close?: boolean) => server.stop(close),
}
}
/**
* Run the effect-httpapi backend on a native Effect HTTP server. This
* lets HttpApi routes that call `request.upgrade` (PTY connect, the
* workspace-routing proxy WS bridge) work end-to-end; the legacy Hono
* adapter path can't surface `request.upgrade` because its fetch handler has
* no reference to the platform server instance for websocket upgrades.
*/
async function listenHttpApi(opts: ListenOptions, selection: ServerBackend.Selection): Promise<Listener> {
log.info("server backend selected", {
...ServerBackend.attributes(selection),
"opencode.server.runtime": HttpApiServer.name,
})
const buildLayer = (port: number) =>
HttpRouter.serve(ExperimentalHttpApiServer.createRoutes(opts), {
middleware: disposeMiddleware,
disableLogger: true,
disableListenLog: true,
}).pipe(
Layer.provideMerge(WebSocketTracker.layer),
Layer.provideMerge(HttpApiServer.layer({ port, hostname: opts.hostname })),
)
const start = async (port: number) => {
const scope = Scope.makeUnsafe()
try {
// Effect's `HttpMiddleware` interface returns `Effect<…, any, any>` by
// design, which leaks `R = any` through `HttpRouter.serve`. The actual
// requirements at this point are fully satisfied by `createRoutes` and the
// platform HTTP server layer; cast away the `any` to satisfy `runPromise`.
const layer = buildLayer(port) as Layer.Layer<
HttpServer.HttpServer | WebSocketTracker.Service | HttpApiServer.Service,
unknown,
never
>
const ctx = await Effect.runPromise(Layer.buildWithMemoMap(layer, Layer.makeMemoMapUnsafe(), scope))
return { scope, ctx }
} catch (err) {
await Effect.runPromise(Scope.close(scope, Exit.void)).catch(() => undefined)
throw err
}
}
// Match the legacy adapter port-resolution behavior: explicit `0` prefers
// 4096 first, then any free port.
let resolved: Awaited<ReturnType<typeof start>> | undefined
if (opts.port === 0) {
resolved = await start(4096).catch(() => undefined)
if (!resolved) resolved = await start(0)
} else {
resolved = await start(opts.port)
}
if (!resolved) throw new Error(`Failed to start server on port ${opts.port}`)
const server = Context.get(resolved.ctx, HttpServer.HttpServer)
if (server.address._tag !== "TcpAddress") {
await Effect.runPromise(Scope.close(resolved.scope, Exit.void))
throw new Error(`Unexpected HttpServer address tag: ${server.address._tag}`)
}
const port = server.address.port
const innerUrl = new URL("http://localhost")
innerUrl.hostname = opts.hostname
innerUrl.port = String(port)
let forceStopPromise: Promise<void> | undefined
let stopPromise: Promise<void> | undefined
const forceStop = () => {
forceStopPromise ??= Effect.runPromiseExit(
Effect.gen(function* () {
yield* Context.get(resolved!.ctx, HttpApiServer.Service).closeAll
yield* Context.get(resolved!.ctx, WebSocketTracker.Service).closeAll
}),
).then(() => undefined)
return forceStopPromise
}
return {
hostname: opts.hostname,
port,
url: innerUrl,
stop: (close?: boolean) => {
const requested = close ? forceStop() : Promise.resolve()
// The first call starts scope shutdown. A later stop(true) cannot undo
// that, but it still runs forceStop() before awaiting the original close.
stopPromise ??= requested
.then(() => Effect.runPromiseExit(Scope.close(resolved!.scope, Exit.void)))
.then(() => undefined)
return requested.then(() => stopPromise!)
url: next,
stop(close?: boolean) {
closing ??= (async () => {
if (mdns) MDNS.unpublish()
await server.stop(close)
})()
return closing
},
}
}

View File

@@ -1,15 +0,0 @@
export const PTY_CONNECT_TICKET_QUERY = "ticket"
export const PTY_CONNECT_TOKEN_HEADER = "x-opencode-ticket"
export const PTY_CONNECT_TOKEN_HEADER_VALUE = "1"
const PTY_CONNECT_PATH = /^\/pty\/[^/]+\/connect$/
// Auth middleware skips Basic Auth when this matches; the PTY connect handler
// is then responsible for validating the ticket.
export function isPtyConnectPath(pathname: string) {
return PTY_CONNECT_PATH.test(pathname)
}
export function hasPtyConnectTicketURL(url: URL) {
return isPtyConnectPath(url.pathname) && !!url.searchParams.get(PTY_CONNECT_TICKET_QUERY)
}

View File

@@ -1,12 +0,0 @@
// Static UI assets the browser fetches without app-managed credentials, e.g.
// the manifest link in <head>. These bypass auth so the page can install/render
// the manifest icons even when a server password is configured.
export const PUBLIC_UI_PATHS = new Set<string>([
"/site.webmanifest",
"/web-app-manifest-192x192.png",
"/web-app-manifest-512x512.png",
])
export function isPublicUIPath(method: string, pathname: string) {
return method === "GET" && PUBLIC_UI_PATHS.has(pathname)
}

View File

@@ -33,7 +33,6 @@ function proxyResponseHeaders(headers: Record<string, string>) {
// transfer metadata makes browsers decode already-decoded assets again.
result.delete("content-encoding")
result.delete("content-length")
result.delete("transfer-encoding")
return result
}
@@ -46,31 +45,6 @@ export function embeddedUI() {
return embeddedUIPromise
}
function notFound() {
return HttpServerResponse.jsonUnsafe({ error: "Not Found" }, { status: 404 })
}
function embeddedUIResponse(file: string, body: Uint8Array) {
const mime = AppFileSystem.mimeType(file)
const headers = new Headers({ "content-type": mime })
if (mime.startsWith("text/html")) headers.set("content-security-policy", DEFAULT_CSP)
return HttpServerResponse.raw(body, { headers })
}
export function serveEmbeddedUIEffect(
requestPath: string,
fs: AppFileSystem.Interface,
embeddedWebUI: Record<string, string>,
) {
const file = embeddedWebUI[requestPath.replace(/^\//, "")] ?? embeddedWebUI["index.html"] ?? null
if (!file) return Effect.succeed(notFound())
return fs.readFile(file).pipe(
Effect.map((body) => embeddedUIResponse(file, body)),
Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(notFound())),
)
}
export function serveUIEffect(
request: HttpServerRequest.HttpServerRequest,
services: { fs: AppFileSystem.Interface; client: HttpClient.HttpClient },
@@ -79,7 +53,19 @@ export function serveUIEffect(
const embeddedWebUI = yield* Effect.promise(() => embeddedUI())
const path = new URL(request.url, "http://localhost").pathname
if (embeddedWebUI) return yield* serveEmbeddedUIEffect(path, services.fs, embeddedWebUI)
if (embeddedWebUI) {
const match = embeddedWebUI[path.replace(/^\//, "")] ?? embeddedWebUI["index.html"] ?? null
if (!match) return HttpServerResponse.jsonUnsafe({ error: "Not Found" }, { status: 404 })
if (yield* services.fs.existsSafe(match)) {
const mime = AppFileSystem.mimeType(match)
const headers = new Headers({ "content-type": mime })
if (mime.startsWith("text/html")) headers.set("content-security-policy", DEFAULT_CSP)
return HttpServerResponse.raw(yield* services.fs.readFile(match), { headers })
}
return HttpServerResponse.jsonUnsafe({ error: "Not Found" }, { status: 404 })
}
const response = yield* services.client.execute(
HttpClientRequest.make(request.method)(upstreamURL(path), {

View File

@@ -1,4 +1,4 @@
export function withTimeout<T>(promise: Promise<T>, ms: number, label?: string): Promise<T> {
export function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
let timeout: NodeJS.Timeout
return Promise.race([
promise.finally(() => {
@@ -6,7 +6,7 @@ export function withTimeout<T>(promise: Promise<T>, ms: number, label?: string):
}),
new Promise<never>((_, reject) => {
timeout = setTimeout(() => {
reject(new Error(label ?? `Operation timed out after ${ms}ms`))
reject(new Error(`Operation timed out after ${ms}ms`))
}, ms)
}),
])

View File

@@ -1,65 +1,52 @@
import { expect } from "bun:test"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { Effect, Layer } from "effect"
import { afterEach, expect, test } from "bun:test"
import path from "path"
import { pathToFileURL } from "url"
import { AppRuntime } from "../../src/effect/app-runtime"
import { Agent } from "../../src/agent/agent"
import { InstanceRef } from "../../src/effect/instance-ref"
import { InstanceLayer } from "../../src/project/instance-layer"
import { InstanceStore } from "../../src/project/instance-store"
import { tmpdirScoped } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { Instance } from "../../src/project/instance"
import { WithInstance } from "../../src/project/with-instance"
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
const pluginAgent = {
name: "plugin_added",
description: "Added by a plugin via the config hook",
mode: "subagent",
} as const
afterEach(async () => {
await disposeAllInstances()
})
const it = testEffect(Layer.mergeAll(Agent.defaultLayer, InstanceLayer.layer, CrossSpawnSpawner.defaultLayer))
test("plugin-registered agents appear in Agent.list", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const pluginFile = path.join(dir, "plugin.ts")
await Bun.write(
pluginFile,
[
"export default async () => ({",
" config: async (cfg) => {",
" cfg.agent = cfg.agent ?? {}",
" cfg.agent.plugin_added = {",
' description: "Added by a plugin via the config hook",',
' mode: "subagent",',
" }",
" },",
"})",
"",
].join("\n"),
)
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
plugin: [pathToFileURL(pluginFile).href],
}),
)
},
})
it.live("plugin-registered agents appear in Agent.list", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
const pluginFile = path.join(dir, "plugin.ts")
yield* Effect.promise(async () => {
await Promise.all([
Bun.write(
pluginFile,
[
"export default async () => ({",
" config: async (cfg) => {",
" cfg.agent = cfg.agent ?? {}",
` cfg.agent[${JSON.stringify(pluginAgent.name)}] = {`,
` description: ${JSON.stringify(pluginAgent.description)},`,
` mode: ${JSON.stringify(pluginAgent.mode)},`,
" }",
" },",
"})",
"",
].join("\n"),
),
Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
plugin: [pathToFileURL(pluginFile).href],
}),
),
])
})
const agents = yield* InstanceStore.Service.use((store) =>
Effect.gen(function* () {
const ctx = yield* store.load({ directory: dir })
yield* Effect.addFinalizer(() => store.dispose(ctx).pipe(Effect.ignore))
return yield* Agent.Service.use((svc) => svc.list()).pipe(Effect.provideService(InstanceRef, ctx))
}),
)
const added = agents.find((agent) => agent.name === pluginAgent.name)
expect(added?.description).toBe(pluginAgent.description)
expect(added?.mode).toBe(pluginAgent.mode)
}),
)
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const agents = await AppRuntime.runPromise(Agent.Service.use((svc) => svc.list()))
const added = agents.find((agent) => agent.name === "plugin_added")
expect(added?.description).toBe("Added by a plugin via the config hook")
expect(added?.mode).toBe("subagent")
},
})
})

View File

@@ -1,57 +0,0 @@
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { WorkspaceID } from "../../src/control-plane/schema"
import { PtyID } from "../../src/pty/schema"
import { PtyTicket } from "../../src/pty/ticket"
import { testEffect } from "../lib/effect"
const it = testEffect(PtyTicket.layer)
const itExpiring = testEffect(Layer.effect(PtyTicket.Service, PtyTicket.make(5)))
describe("PTY websocket tickets", () => {
it.live("consumes tickets once", () =>
Effect.gen(function* () {
const tickets = yield* PtyTicket.Service
const scope = { ptyID: PtyID.ascending(), directory: "/tmp/a" }
const issued = yield* tickets.issue(scope)
expect(yield* tickets.consume({ ...scope, ticket: issued.ticket })).toBe(true)
expect(yield* tickets.consume({ ...scope, ticket: issued.ticket })).toBe(false)
}),
)
it.live("rejects tickets scoped to a different request", () =>
Effect.gen(function* () {
const tickets = yield* PtyTicket.Service
const ptyID = PtyID.ascending()
const issued = yield* tickets.issue({ ptyID, directory: "/tmp/a" })
expect(yield* tickets.consume({ ptyID, directory: "/tmp/b", ticket: issued.ticket })).toBe(false)
expect(yield* tickets.consume({ ptyID, directory: "/tmp/a", ticket: issued.ticket })).toBe(true)
}),
)
itExpiring.live("rejects tickets after the TTL elapses", () =>
Effect.gen(function* () {
const tickets = yield* PtyTicket.Service
const ptyID = PtyID.ascending()
const issued = yield* tickets.issue({ ptyID })
yield* Effect.promise(() => new Promise((resolve) => setTimeout(resolve, 25)))
expect(yield* tickets.consume({ ptyID, ticket: issued.ticket })).toBe(false)
}),
)
it.live("rejects tickets scoped to a different workspace", () =>
Effect.gen(function* () {
const tickets = yield* PtyTicket.Service
const ptyID = PtyID.ascending()
const workspaceID = WorkspaceID.ascending()
const issued = yield* tickets.issue({ ptyID, workspaceID })
expect(yield* tickets.consume({ ptyID, workspaceID: WorkspaceID.ascending(), ticket: issued.ticket })).toBe(false)
expect(yield* tickets.consume({ ptyID, workspaceID, ticket: issued.ticket })).toBe(true)
}),
)
})

View File

@@ -2,7 +2,7 @@ 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, HttpApiError, HttpApiGroup } from "effect/unstable/httpapi"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"
import { ServerAuth } from "../../src/server/auth"
import { Authorization, authorizationLayer } from "../../src/server/routes/instance/httpapi/middleware/authorization"
import { testEffect } from "../lib/effect"
@@ -13,19 +13,11 @@ const Api = HttpApi.make("test-authorization").add(
HttpApiEndpoint.get("probe", "/probe", {
success: Schema.String,
}),
HttpApiEndpoint.get("missing", "/missing", {
success: Schema.String,
error: HttpApiError.NotFound,
}),
)
.middleware(Authorization),
)
const handlers = HttpApiBuilder.group(Api, "test", (handlers) =>
handlers
.handle("probe", () => Effect.succeed("ok"))
.handle("missing", () => Effect.fail(new HttpApiError.NotFound({}))),
)
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)),
@@ -40,7 +32,8 @@ 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) => ServerAuth.header({ username, password }) ?? ""
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")
@@ -97,35 +90,6 @@ describe("HttpApi authorization middleware", () => {
}),
)
itSecret.live("prefers auth token query credentials over basic auth", () =>
Effect.gen(function* () {
const response = yield* HttpClientRequest.get(
`/probe?auth_token=${encodeURIComponent(token("opencode", "secret"))}`,
).pipe(HttpClientRequest.setHeader("authorization", basic("opencode", "wrong")), HttpClient.execute)
expect(response.status).toBe(200)
}),
)
itSecret.live("preserves handler errors when basic auth succeeds", () =>
Effect.gen(function* () {
const response = yield* HttpClientRequest.get("/missing").pipe(
HttpClientRequest.setHeader("authorization", basic("opencode", "secret")),
HttpClient.execute,
)
expect(response.status).toBe(404)
}),
)
itSecret.live("preserves handler errors when auth token query succeeds", () =>
Effect.gen(function* () {
const response = yield* HttpClient.get(`/missing?auth_token=${encodeURIComponent(token("opencode", "secret"))}`)
expect(response.status).toBe(404)
}),
)
itSecret.live("rejects malformed auth token query credentials", () =>
Effect.gen(function* () {
const response = yield* HttpClient.get("/probe?auth_token=not-base64")

View File

@@ -1,317 +0,0 @@
import { afterEach, describe, expect, test } from "bun:test"
import { Flag } from "@opencode-ai/core/flag/flag"
import * as Log from "@opencode-ai/core/util/log"
import { Server } from "../../src/server/server"
import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty"
import { withTimeout } from "../../src/util/timeout"
import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
void Log.init({ print: false })
const original = {
OPENCODE_EXPERIMENTAL_HTTPAPI: Flag.OPENCODE_EXPERIMENTAL_HTTPAPI,
OPENCODE_SERVER_PASSWORD: Flag.OPENCODE_SERVER_PASSWORD,
OPENCODE_SERVER_USERNAME: Flag.OPENCODE_SERVER_USERNAME,
envPassword: process.env.OPENCODE_SERVER_PASSWORD,
envUsername: process.env.OPENCODE_SERVER_USERNAME,
}
const auth = { username: "opencode", password: "listen-secret" }
const testPty = process.platform === "win32" ? test.skip : test
afterEach(async () => {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original.OPENCODE_EXPERIMENTAL_HTTPAPI
Flag.OPENCODE_SERVER_PASSWORD = original.OPENCODE_SERVER_PASSWORD
Flag.OPENCODE_SERVER_USERNAME = original.OPENCODE_SERVER_USERNAME
if (original.envPassword === undefined) delete process.env.OPENCODE_SERVER_PASSWORD
else process.env.OPENCODE_SERVER_PASSWORD = original.envPassword
if (original.envUsername === undefined) delete process.env.OPENCODE_SERVER_USERNAME
else process.env.OPENCODE_SERVER_USERNAME = original.envUsername
await disposeAllInstances()
await resetDatabase()
})
async function startListener(backend: "effect-httpapi" | "hono" = "effect-httpapi") {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = backend === "effect-httpapi"
Flag.OPENCODE_SERVER_PASSWORD = auth.password
Flag.OPENCODE_SERVER_USERNAME = auth.username
process.env.OPENCODE_SERVER_PASSWORD = auth.password
process.env.OPENCODE_SERVER_USERNAME = auth.username
return Server.listen({ hostname: "127.0.0.1", port: 0 })
}
async function startNoAuthListener() {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = false
Flag.OPENCODE_SERVER_PASSWORD = undefined
Flag.OPENCODE_SERVER_USERNAME = auth.username
delete process.env.OPENCODE_SERVER_PASSWORD
process.env.OPENCODE_SERVER_USERNAME = auth.username
return Server.listen({ hostname: "127.0.0.1", port: 0 })
}
function authorization() {
return `Basic ${btoa(`${auth.username}:${auth.password}`)}`
}
function socketURL(listener: Awaited<ReturnType<typeof startListener>>, id: string, dir: string, ticket?: string) {
const url = new URL(PtyPaths.connect.replace(":ptyID", id), listener.url)
url.protocol = "ws:"
url.searchParams.set("directory", dir)
url.searchParams.set("cursor", "-1")
if (ticket) url.searchParams.set("ticket", ticket)
return url
}
async function requestTicket(
listener: Awaited<ReturnType<typeof startListener>>,
id: string,
dir: string,
options?: { ticketHeader?: boolean; origin?: string },
) {
const response = await fetch(new URL(PtyPaths.connectToken.replace(":ptyID", id), listener.url), {
method: "POST",
headers: {
authorization: authorization(),
"x-opencode-directory": dir,
...(options?.ticketHeader === false ? {} : { "x-opencode-ticket": "1" }),
...(options?.origin ? { origin: options.origin } : {}),
},
})
return response
}
async function connectTicket(listener: Awaited<ReturnType<typeof startListener>>, id: string, dir: string) {
const response = await requestTicket(listener, id, dir)
expect(response.status).toBe(200)
return (await response.json()) as { ticket: string; expires_in: number }
}
async function createCat(listener: Awaited<ReturnType<typeof startListener>>, dir: string) {
const response = await fetch(new URL(PtyPaths.create, listener.url), {
method: "POST",
headers: {
authorization: authorization(),
"x-opencode-directory": dir,
"content-type": "application/json",
},
body: JSON.stringify({ command: "/bin/cat", title: "listen-smoke" }),
})
expect(response.status).toBe(200)
return (await response.json()) as { id: string }
}
async function openSocket(url: URL) {
const ws = new WebSocket(url)
ws.binaryType = "arraybuffer"
await withTimeout(
new Promise<void>((resolve, reject) => {
ws.addEventListener("open", () => resolve(), { once: true })
ws.addEventListener("error", () => reject(new Error("websocket failed before open")), { once: true })
}),
5_000,
"timed out waiting for websocket open",
)
return ws
}
async function expectSocketRejected(url: URL, init?: { headers?: Record<string, string> }) {
// Bun's WebSocket accepts an init object with headers; standard DOM types don't reflect that.
const Ctor = WebSocket as unknown as new (url: URL, init?: { headers?: Record<string, string> }) => WebSocket
const ws = new Ctor(url, init)
await withTimeout(
new Promise<void>((resolve, reject) => {
ws.addEventListener(
"open",
() => {
ws.close(1000)
reject(new Error("websocket opened"))
},
{ once: true },
)
ws.addEventListener("error", () => resolve(), { once: true })
ws.addEventListener("close", () => resolve(), { once: true })
}),
5_000,
"timed out waiting for websocket rejection",
)
}
function stop(listener: Awaited<ReturnType<typeof startListener>>, label: string) {
return withTimeout(listener.stop(true), 10_000, label)
}
function waitForMessage(ws: WebSocket, predicate: (message: string) => boolean) {
const decoder = new TextDecoder()
let onMessage: ((event: MessageEvent) => void) | undefined
return withTimeout(
new Promise<string>((resolve) => {
onMessage = (event: MessageEvent) => {
const message = typeof event.data === "string" ? event.data : decoder.decode(event.data as ArrayBuffer)
if (!predicate(message)) return
resolve(message)
}
ws.addEventListener("message", onMessage)
}),
5_000,
"timed out waiting for websocket message",
).finally(() => {
if (onMessage) ws.removeEventListener("message", onMessage)
})
}
describe("HttpApi Server.listen", () => {
testPty("serves HTTP routes and upgrades PTY websocket through Server.listen", async () => {
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
const listener = await startListener()
let stopped = false
try {
const response = await fetch(new URL(PtyPaths.shells, listener.url), {
headers: { authorization: authorization(), "x-opencode-directory": tmp.path },
})
expect(response.status).toBe(200)
expect(await response.json()).toEqual(
expect.arrayContaining([
expect.objectContaining({
path: expect.any(String),
name: expect.any(String),
acceptable: expect.any(Boolean),
}),
]),
)
const info = await createCat(listener, tmp.path)
const ticket = await connectTicket(listener, info.id, tmp.path)
expect(ticket.expires_in).toBeGreaterThan(0)
const ws = await openSocket(socketURL(listener, info.id, tmp.path, ticket.ticket))
const closed = new Promise<void>((resolve) => ws.addEventListener("close", () => resolve(), { once: true }))
const message = waitForMessage(ws, (message) => message.includes("ping-listen"))
ws.send("ping-listen\n")
expect(await message).toContain("ping-listen")
await stop(listener, "timed out waiting for listener.stop(true)")
stopped = true
await withTimeout(closed, 5_000, "timed out waiting for websocket close")
expect(ws.readyState).toBe(WebSocket.CLOSED)
const restarted = await startListener()
try {
const nextInfo = await createCat(restarted, tmp.path)
const nextTicket = await connectTicket(restarted, nextInfo.id, tmp.path)
const nextWs = await openSocket(socketURL(restarted, nextInfo.id, tmp.path, nextTicket.ticket))
const nextMessage = waitForMessage(nextWs, (message) => message.includes("ping-restarted"))
nextWs.send("ping-restarted\n")
expect(await nextMessage).toContain("ping-restarted")
nextWs.close(1000)
} finally {
await stop(restarted, "timed out waiting for restarted listener.stop(true)")
}
} finally {
if (!stopped) await stop(listener, "timed out cleaning up listener").catch(() => undefined)
}
})
testPty("serves PTY websocket tickets through legacy Hono Server.listen", async () => {
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
const listener = await startListener("hono")
try {
const info = await createCat(listener, tmp.path)
const ticket = await connectTicket(listener, info.id, tmp.path)
const ws = await openSocket(socketURL(listener, info.id, tmp.path, ticket.ticket))
const message = waitForMessage(ws, (message) => message.includes("ping-hono-ticket"))
ws.send("ping-hono-ticket\n")
expect(await message).toContain("ping-hono-ticket")
ws.close(1000)
} finally {
await stop(listener, "timed out cleaning up hono listener").catch(() => undefined)
}
})
testPty("rejects unsafe PTY ticket mint and connect requests", async () => {
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
const listener = await startListener()
try {
const info = await createCat(listener, tmp.path)
expect((await requestTicket(listener, info.id, tmp.path, { ticketHeader: false })).status).toBe(403)
expect((await requestTicket(listener, info.id, tmp.path, { origin: "https://evil.example" })).status).toBe(403)
await expectSocketRejected(socketURL(listener, info.id, tmp.path, "not-a-ticket"))
const reusable = await connectTicket(listener, info.id, tmp.path)
const ws = await openSocket(socketURL(listener, info.id, tmp.path, reusable.ticket))
await expectSocketRejected(socketURL(listener, info.id, tmp.path, reusable.ticket))
ws.close(1000)
const other = await createCat(listener, tmp.path)
const scoped = await connectTicket(listener, info.id, tmp.path)
await expectSocketRejected(socketURL(listener, other.id, tmp.path, scoped.ticket))
const crossOrigin = await connectTicket(listener, info.id, tmp.path)
await expectSocketRejected(socketURL(listener, info.id, tmp.path, crossOrigin.ticket), {
headers: { origin: "https://evil.example" },
})
} finally {
await stop(listener, "timed out cleaning up rejected ticket listener").catch(() => undefined)
}
})
// Regression for #25698 (Ope): the app's SDK call to
// `client.pty.connectToken({ ptyID })` originally omitted `directory`, so
// the server resolved the PTY in its own cwd context — where the project
// PTY isn't registered — and returned 404. The fix is to always pass
// `directory` from the app side; this test locks in two contracts:
// 1. Mint without directory cannot find a PTY registered in another dir.
// 2. Mint with the project directory succeeds; the resulting ticket
// consumes cleanly when the WS upgrade carries the same directory.
testPty("PTY connect token requires matching directory across mint and connect", async () => {
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
const listener = await startListener()
try {
const info = await createCat(listener, tmp.path)
// Mint without directory — server uses its own cwd, can't find the PTY.
const ambiguous = await fetch(new URL(PtyPaths.connectToken.replace(":ptyID", info.id), listener.url), {
method: "POST",
headers: { authorization: authorization(), "x-opencode-ticket": "1" },
})
expect(ambiguous.status).toBe(404)
// Mint with the project directory — succeeds, ticket binds to that scope.
const scoped = await fetch(
new URL(
`${PtyPaths.connectToken.replace(":ptyID", info.id)}?directory=${encodeURIComponent(tmp.path)}`,
listener.url,
),
{
method: "POST",
headers: { authorization: authorization(), "x-opencode-ticket": "1" },
},
)
expect(scoped.status).toBe(200)
const mint = (await scoped.json()) as { ticket: string }
// Same directory on the WS upgrade → consume succeeds.
const ws = await openSocket(socketURL(listener, info.id, tmp.path, mint.ticket))
ws.close(1000)
} finally {
await stop(listener, "timed out cleaning up directory-scope listener").catch(() => undefined)
}
})
testPty("keeps PTY websocket tickets optional when server auth is disabled", async () => {
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
const listener = await startNoAuthListener()
try {
const info = await createCat(listener, tmp.path)
const ws = await openSocket(socketURL(listener, info.id, tmp.path))
const message = waitForMessage(ws, (message) => message.includes("ping-no-auth"))
ws.send("ping-no-auth\n")
expect(await message).toContain("ping-no-auth")
ws.close(1000)
} finally {
await stop(listener, "timed out cleaning up no-auth listener").catch(() => undefined)
}
})
})

View File

@@ -0,0 +1,109 @@
import { afterEach, describe, expect, test } from "bun:test"
import { Flag } from "@opencode-ai/core/flag/flag"
import * as Log from "@opencode-ai/core/util/log"
import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
import { HttpApiListener } from "../../src/server/httpapi-listener"
import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty"
void Log.init({ print: false })
const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
const testPty = process.platform === "win32" ? test.skip : test
afterEach(async () => {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original
await disposeAllInstances()
await resetDatabase()
})
async function startListener() {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
return HttpApiListener.listen({ hostname: "127.0.0.1", port: 0 })
}
describe("native HttpApi listener", () => {
test("serves HTTP routes via the HttpApi web handler", async () => {
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
const listener = await startListener()
try {
const response = await fetch(`${listener.url.origin}${PtyPaths.shells}`, {
headers: { "x-opencode-directory": tmp.path },
})
expect(response.status).toBe(200)
const body = await response.json()
expect(Array.isArray(body)).toBe(true)
expect(body[0]).toMatchObject({
path: expect.any(String),
name: expect.any(String),
acceptable: expect.any(Boolean),
})
} finally {
await listener.stop(true)
}
})
testPty("PTY websocket connect echoes input back to the client", async () => {
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
const listener = await startListener()
try {
const created = await fetch(`${listener.url.origin}${PtyPaths.create}`, {
method: "POST",
headers: {
"x-opencode-directory": tmp.path,
"content-type": "application/json",
},
body: JSON.stringify({ command: "/bin/cat", title: "listener-smoke" }),
})
expect(created.status).toBe(200)
const info = (await created.json()) as { id: string }
try {
const wsURL = new URL(PtyPaths.connect.replace(":ptyID", info.id), listener.url)
wsURL.protocol = "ws:"
wsURL.searchParams.set("directory", tmp.path)
wsURL.searchParams.set("cursor", "-1")
const messages: string[] = []
const ws = new WebSocket(wsURL)
ws.binaryType = "arraybuffer"
const opened = new Promise<void>((resolve, reject) => {
ws.addEventListener("open", () => resolve(), { once: true })
ws.addEventListener("error", () => reject(new Error("ws error before open")), { once: true })
})
const closed = new Promise<void>((resolve) => {
ws.addEventListener("close", () => resolve(), { once: true })
})
ws.addEventListener("message", (event) => {
const data = event.data
messages.push(typeof data === "string" ? data : new TextDecoder().decode(data as ArrayBuffer))
})
await opened
ws.send("ping-listener\n")
const start = Date.now()
while (!messages.some((m) => m.includes("ping-listener")) && Date.now() - start < 5_000) {
await new Promise((r) => setTimeout(r, 50))
}
ws.close(1000, "done")
expect(messages.some((m) => m.includes("ping-listener"))).toBe(true)
// Verify close event fires (handler.onClose path runs and the
// Bun.serve websocket lifecycle reaches close).
await closed
expect(ws.readyState).toBe(WebSocket.CLOSED)
} finally {
await fetch(`${listener.url.origin}${PtyPaths.remove.replace(":ptyID", info.id)}`, {
method: "DELETE",
headers: { "x-opencode-directory": tmp.path },
}).catch(() => undefined)
}
} finally {
await listener.stop(true)
}
})
})

View File

@@ -33,7 +33,10 @@ const testMcpHandlers = HttpApiBuilder.group(TestHttpApi, "mcp", (handlers) =>
const passthroughAuthorization = Layer.succeed(
Authorization,
Authorization.of((effect) => effect),
Authorization.of({
basic: (effect) => effect,
authToken: (effect) => effect,
}),
)
const passthroughInstanceContext = Layer.succeed(

View File

@@ -15,7 +15,7 @@ import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { ServerAuth } from "../../src/server/auth"
import { authorizationRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/authorization"
import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server"
import { serveEmbeddedUIEffect, serveUIEffect } from "../../src/server/shared/ui"
import { serveUIEffect } from "../../src/server/shared/ui"
import { Server } from "../../src/server/server"
void Log.init({ print: false })
@@ -184,82 +184,6 @@ describe("HttpApi UI fallback", () => {
expect(await response.text()).toBe("console.log('ok')")
})
// Regression for #25698 (Ope): upstream `transfer-encoding: chunked` was
// forwarded through the proxy while the proxy itself re-frames the body,
// causing browsers to fail with `ERR_INVALID_CHUNKED_ENCODING`.
test("strips upstream transfer-encoding header from proxied assets", async () => {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI = true
const response = await Effect.runPromise(
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const client = yield* HttpClient.HttpClient
return yield* serveUIEffect(HttpServerRequest.fromWeb(new Request("http://localhost/")), {
fs,
client,
})
}).pipe(
Effect.provide(
Layer.mergeAll(
AppFileSystem.defaultLayer,
Layer.succeed(
HttpClient.HttpClient,
HttpClient.make((request) =>
Effect.succeed(
HttpClientResponse.fromWeb(
request,
new Response("<html>opencode</html>", {
headers: {
"transfer-encoding": "chunked",
"content-type": "text/html",
},
}),
),
),
),
),
),
),
Effect.map(HttpServerResponse.toWeb),
),
)
expect(response.status).toBe(200)
expect(response.headers.get("transfer-encoding")).toBeNull()
expect(await response.text()).toBe("<html>opencode</html>")
})
test("serves embedded UI assets when Bun can read them but access reports missing", async () => {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
let readPath: string | undefined
const response = await Effect.runPromise(
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
return yield* serveEmbeddedUIEffect(
"/assets/app.js",
{
...fs,
existsSafe: () => Effect.die("embedded UI should not rely on filesystem access checks"),
readFile: (path) => {
readPath = path
return path === "/$bunfs/root/assets/app.js"
? Effect.succeed(new TextEncoder().encode("console.log('embedded')"))
: Effect.die(`unexpected embedded UI path: ${path}`)
},
},
{ "assets/app.js": "/$bunfs/root/assets/app.js" },
)
}).pipe(Effect.provide(AppFileSystem.defaultLayer), Effect.map(HttpServerResponse.toWeb)),
)
expect(response.status).toBe(200)
expect(readPath).toBe("/$bunfs/root/assets/app.js")
expect(response.headers.get("content-type")).toContain("text/javascript")
expect(await response.text()).toBe("console.log('embedded')")
})
test("keeps matched API routes ahead of the UI fallback", async () => {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
@@ -303,25 +227,6 @@ describe("HttpApi UI fallback", () => {
expect(response.status).toBe(200)
})
// Regression for #25698 (Ope): the browser fetches the PWA manifest and
// its icons via flows that don't carry app-managed credentials (the
// `<link rel="manifest">` request is not under page-auth control), so the
// server returning 401 breaks PWA install. These specific public assets
// should bypass auth.
test("serves the PWA manifest without auth even when a server password is set", async () => {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI = true
for (const path of ["/site.webmanifest", "/web-app-manifest-192x192.png", "/web-app-manifest-512x512.png"]) {
const response = await uiApp({
password: "secret",
username: "opencode",
client: httpClient(new Response("ok")),
}).request(path)
expect(response.status).not.toBe(401)
}
})
test("allows web UI preflight without auth", async () => {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true

View File

@@ -99,8 +99,6 @@ import type {
ProviderOauthCallbackResponses,
PtyConnectErrors,
PtyConnectResponses,
PtyConnectTokenErrors,
PtyConnectTokenResponses,
PtyCreateErrors,
PtyCreateResponses,
PtyGetErrors,
@@ -2347,38 +2345,6 @@ export class Pty extends HeyApiClient {
})
}
/**
* Create PTY WebSocket token
*
* Create a short-lived ticket for opening a PTY WebSocket connection.
*/
public connectToken<ThrowOnError extends boolean = false>(
parameters: {
ptyID: string
directory?: string
workspace?: string
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "path", key: "ptyID" },
{ in: "query", key: "directory" },
{ in: "query", key: "workspace" },
],
},
],
)
return (options?.client ?? this.client).post<PtyConnectTokenResponses, PtyConnectTokenErrors, ThrowOnError>({
url: "/pty/{ptyID}/connect-token",
...options,
...params,
})
}
/**
* Connect to PTY session
*

View File

@@ -1563,10 +1563,6 @@ export type McpUnsupportedOAuthError = {
error: string
}
export type EffectHttpApiErrorForbidden = {
_tag: "Forbidden"
}
export type ProviderAuthMethod = {
type: "oauth" | "api"
label: string
@@ -4675,43 +4671,6 @@ export type PtyUpdateResponses = {
export type PtyUpdateResponse = PtyUpdateResponses[keyof PtyUpdateResponses]
export type PtyConnectTokenData = {
body?: never
path: {
ptyID: string
}
query?: {
directory?: string
workspace?: string
}
url: "/pty/{ptyID}/connect-token"
}
export type PtyConnectTokenErrors = {
/**
* Forbidden
*/
403: EffectHttpApiErrorForbidden
/**
* Not found
*/
404: NotFoundError
}
export type PtyConnectTokenError = PtyConnectTokenErrors[keyof PtyConnectTokenErrors]
export type PtyConnectTokenResponses = {
/**
* WebSocket connect token
*/
200: {
ticket: string
expires_in: number
}
}
export type PtyConnectTokenResponse = PtyConnectTokenResponses[keyof PtyConnectTokenResponses]
export type QuestionListData = {
body?: never
path?: never
@@ -6693,10 +6652,6 @@ export type PtyConnectData = {
}
export type PtyConnectErrors = {
/**
* Forbidden
*/
403: EffectHttpApiErrorForbidden
/**
* Not found
*/

View File

@@ -3414,91 +3414,6 @@
]
}
},
"/pty/{ptyID}/connect-token": {
"post": {
"tags": ["pty"],
"operationId": "pty.connectToken",
"parameters": [
{
"name": "directory",
"in": "query",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "workspace",
"in": "query",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "ptyID",
"in": "path",
"schema": {
"type": "string",
"pattern": "^pty.*"
},
"required": true
}
],
"responses": {
"200": {
"description": "WebSocket connect token",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"ticket": {
"type": "string"
},
"expires_in": {
"type": "integer",
"exclusiveMinimum": 0
}
},
"required": ["ticket", "expires_in"],
"additionalProperties": false,
"description": "WebSocket connect token"
}
}
}
},
"403": {
"description": "Forbidden",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/effect_HttpApiError_Forbidden"
}
}
}
},
"404": {
"description": "Not found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/NotFoundError"
}
}
}
}
},
"description": "Create a short-lived ticket for opening a PTY WebSocket connection.",
"summary": "Create PTY WebSocket token",
"x-codeSamples": [
{
"lang": "js",
"source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.pty.connectToken({\n ...\n})"
}
]
}
},
"/question": {
"get": {
"tags": ["question"],
@@ -8412,16 +8327,6 @@
}
}
},
"403": {
"description": "Forbidden",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/effect_HttpApiError_Forbidden"
}
}
}
},
"404": {
"description": "Not found",
"content": {
@@ -8607,9 +8512,6 @@
{
"$ref": "#/components/schemas/EventSessionNextStepEnded"
},
{
"$ref": "#/components/schemas/EventSessionNextStepFailed"
},
{
"$ref": "#/components/schemas/EventSessionNextTextStarted"
},
@@ -8647,7 +8549,7 @@
"$ref": "#/components/schemas/EventSessionNextToolSuccess"
},
{
"$ref": "#/components/schemas/EventSessionNextToolFailed"
"$ref": "#/components/schemas/EventSessionNextToolError"
},
{
"$ref": "#/components/schemas/EventSessionNextRetried"
@@ -10806,9 +10708,6 @@
{
"$ref": "#/components/schemas/EventSessionNextStepEnded"
},
{
"$ref": "#/components/schemas/EventSessionNextStepFailed"
},
{
"$ref": "#/components/schemas/EventSessionNextTextStarted"
},
@@ -10846,7 +10745,7 @@
"$ref": "#/components/schemas/EventSessionNextToolSuccess"
},
{
"$ref": "#/components/schemas/EventSessionNextToolFailed"
"$ref": "#/components/schemas/EventSessionNextToolError"
},
{
"$ref": "#/components/schemas/EventSessionNextRetried"
@@ -10911,9 +10810,6 @@
{
"$ref": "#/components/schemas/SyncEventSessionNextStepEnded"
},
{
"$ref": "#/components/schemas/SyncEventSessionNextStepFailed"
},
{
"$ref": "#/components/schemas/SyncEventSessionNextTextStarted"
},
@@ -10951,7 +10847,7 @@
"$ref": "#/components/schemas/SyncEventSessionNextToolSuccess"
},
{
"$ref": "#/components/schemas/SyncEventSessionNextToolFailed"
"$ref": "#/components/schemas/SyncEventSessionNextToolError"
},
{
"$ref": "#/components/schemas/SyncEventSessionNextRetried"
@@ -12847,17 +12743,6 @@
"required": ["error"],
"additionalProperties": false
},
"effect_HttpApiError_Forbidden": {
"type": "object",
"properties": {
"_tag": {
"type": "string",
"enum": ["Forbidden"]
}
},
"required": ["_tag"],
"additionalProperties": false
},
"ProviderAuthMethod": {
"type": "object",
"properties": {
@@ -14276,57 +14161,6 @@
"required": ["type", "name", "id", "seq", "aggregateID", "data"],
"additionalProperties": false
},
"SyncEventSessionNextStepFailed": {
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": ["sync"]
},
"name": {
"type": "string",
"enum": ["session.next.step.failed.1"]
},
"id": {
"type": "string"
},
"seq": {
"type": "number"
},
"aggregateID": {
"type": "string",
"enum": ["sessionID"]
},
"data": {
"type": "object",
"properties": {
"timestamp": {
"type": "number"
},
"sessionID": {
"type": "string"
},
"error": {
"type": "object",
"properties": {
"type": {
"type": "string"
},
"message": {
"type": "string"
}
},
"required": ["type", "message"],
"additionalProperties": false
}
},
"required": ["timestamp", "sessionID", "error"],
"additionalProperties": false
}
},
"required": ["type", "name", "id", "seq", "aggregateID", "data"],
"additionalProperties": false
},
"SyncEventSessionNextTextStarted": {
"type": "object",
"properties": {
@@ -14895,7 +14729,7 @@
"required": ["type", "name", "id", "seq", "aggregateID", "data"],
"additionalProperties": false
},
"SyncEventSessionNextToolFailed": {
"SyncEventSessionNextToolError": {
"type": "object",
"properties": {
"type": {
@@ -14904,7 +14738,7 @@
},
"name": {
"type": "string",
"enum": ["session.next.tool.failed.1"]
"enum": ["session.next.tool.error.1"]
},
"id": {
"type": "string"
@@ -16565,46 +16399,6 @@
"required": ["id", "type", "properties"],
"additionalProperties": false
},
"EventSessionNextStepFailed": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"type": {
"type": "string",
"enum": ["session.next.step.failed"]
},
"properties": {
"type": "object",
"properties": {
"timestamp": {
"type": "number"
},
"sessionID": {
"type": "string"
},
"error": {
"type": "object",
"properties": {
"type": {
"type": "string"
},
"message": {
"type": "string"
}
},
"required": ["type", "message"],
"additionalProperties": false
}
},
"required": ["timestamp", "sessionID", "error"],
"additionalProperties": false
}
},
"required": ["id", "type", "properties"],
"additionalProperties": false
},
"EventSessionNextTextStarted": {
"type": "object",
"properties": {
@@ -17075,7 +16869,7 @@
"required": ["id", "type", "properties"],
"additionalProperties": false
},
"EventSessionNextToolFailed": {
"EventSessionNextToolError": {
"type": "object",
"properties": {
"id": {
@@ -17083,7 +16877,7 @@
},
"type": {
"type": "string",
"enum": ["session.next.tool.failed"]
"enum": ["session.next.tool.error"]
},
"properties": {
"type": "object",
@@ -17906,17 +17700,7 @@
"additionalProperties": false
},
"error": {
"type": "object",
"properties": {
"type": {
"type": "string"
},
"message": {
"type": "string"
}
},
"required": ["type", "message"],
"additionalProperties": false
"type": "string"
}
},
"required": ["id", "time", "type", "agent", "model", "content"],