Compare commits

...

7 Commits

Author SHA1 Message Date
Aiden Cline
0c0cd6845a fix: tests 2026-05-04 09:45:19 -05:00
Aiden Cline
803e01f377 fix: enure effect server middleware properly parses errors 2026-05-04 09:24:04 -05:00
opencode-agent[bot]
2c819f290f chore: generate 2026-05-04 13:55:28 +00:00
Kit Langton
6e9f10ad3f test(server): regression reproducers for #25698 (#25714) 2026-05-04 09:54:19 -04:00
OpeOginni
1251a870cb fix(opencode): strip transfer-encoding in UI proxy and allow public manifest assets (#25698)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-05-04 09:43:03 -04:00
opencode-agent[bot]
67047fa766 chore: generate 2026-05-04 13:26:08 +00:00
Luke Parker
a366128a93 fix(app): prevent terminal recovery loops (#25710) 2026-05-04 23:24:57 +10:00
13 changed files with 310 additions and 27 deletions

View File

@@ -480,15 +480,21 @@ export const Terminal = (props: TerminalProps) => {
})
const connectToken = async () => {
const result = await client.pty.connectToken(
{ ptyID: id },
{
throwOnError: false,
headers: { "x-opencode-ticket": "1" },
},
)
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) && password) return
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}`)

View File

@@ -1,6 +1,9 @@
import { beforeAll, describe, expect, mock, test } from "bun:test"
let getWorkspaceTerminalCacheKey: (dir: string) => string
type ServerKey = Parameters<typeof import("./terminal").getTerminalServerScope>[1]
let getWorkspaceTerminalCacheKey: (dir: string, scope?: string) => string
let getTerminalServerScope: typeof import("./terminal").getTerminalServerScope
let getLegacyTerminalStorageKeys: (dir: string, legacySessionID?: string) => string[]
let migrateTerminalState: (value: unknown) => unknown
@@ -17,6 +20,7 @@ beforeAll(async () => {
}))
const mod = await import("./terminal")
getWorkspaceTerminalCacheKey = mod.getWorkspaceTerminalCacheKey
getTerminalServerScope = mod.getTerminalServerScope
getLegacyTerminalStorageKeys = mod.getLegacyTerminalStorageKeys
migrateTerminalState = mod.migrateTerminalState
})
@@ -25,6 +29,45 @@ 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,6 +4,7 @@ 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"
@@ -82,10 +83,31 @@ export function migrateTerminalState(value: unknown) {
}
}
export function getWorkspaceTerminalCacheKey(dir: string) {
export function getWorkspaceTerminalCacheKey(dir: string, scope?: string) {
if (scope) return `${scope}:${dir}:${WORKSPACE_KEY}`
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`]
@@ -110,15 +132,16 @@ const trimTerminal = (pty: LocalPTY) => {
}
}
export function clearWorkspaceTerminals(dir: string, sessionIDs?: string[], platform?: Platform) {
const key = getWorkspaceTerminalCacheKey(dir)
export function clearWorkspaceTerminals(dir: string, sessionIDs?: string[], platform?: Platform, scope?: string) {
const key = getWorkspaceTerminalCacheKey(dir, scope)
for (const cache of caches) {
const entry = cache.get(key)
entry?.value.clear()
}
void removePersisted(Persist.workspace(dir, "terminal"), platform)
void removePersisted(Persist.workspace(dir, scope ? `terminal:${scope}` : "terminal"), platform)
if (scope) return
const legacy = new Set(getLegacyTerminalStorageKeys(dir))
for (const id of sessionIDs ?? []) {
for (const key of getLegacyTerminalStorageKeys(dir, id)) {
@@ -130,12 +153,17 @@ export function clearWorkspaceTerminals(dir: string, sessionIDs?: string[], plat
}
}
function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: string, legacySessionID?: string) {
const legacy = getLegacyTerminalStorageKeys(dir, legacySessionID)
function createWorkspaceTerminalSession(
sdk: ReturnType<typeof useSDK>,
dir: string,
legacySessionID?: string,
scope?: string,
) {
const legacy = scope ? [] : getLegacyTerminalStorageKeys(dir, legacySessionID)
const [store, setStore, _, ready] = persisted(
{
...Persist.workspace(dir, "terminal", legacy),
...Persist.workspace(dir, scope ? `terminal:${scope}` : "terminal", legacy),
migrate: migrateTerminalState,
},
createStore<{
@@ -357,8 +385,12 @@ 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))
@@ -382,9 +414,9 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
}
}
const loadWorkspace = (dir: string, legacySessionID?: string) => {
const loadWorkspace = (dir: string, legacySessionID: string | undefined, serverScope: string | undefined) => {
// Terminals are workspace-scoped so tabs persist while switching sessions in the same directory.
const key = getWorkspaceTerminalCacheKey(dir)
const key = getWorkspaceTerminalCacheKey(dir, serverScope)
const existing = cache.get(key)
if (existing) {
cache.delete(key)
@@ -393,7 +425,7 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
}
const entry = createRoot((dispose) => ({
value: createWorkspaceTerminalSession(sdk, dir, legacySessionID),
value: createWorkspaceTerminalSession(sdk, dir, legacySessionID, serverScope),
dispose,
}))
@@ -402,16 +434,16 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
return entry.value
}
const workspace = createMemo(() => loadWorkspace(params.dir!, params.id))
const workspace = createMemo(() => loadWorkspace(params.dir!, params.id, scope()))
createEffect(
on(
() => ({ dir: params.dir, id: params.id }),
() => ({ dir: params.dir, id: params.id, scope: scope() }),
(next, prev) => {
if (!prev?.dir) return
if (next.dir === prev.dir && next.id === prev.id) return
if (next.dir === prev.dir && next.id) return
loadWorkspace(prev.dir, prev.id).trimAll()
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()
},
{ defer: true },
),

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 } from "@/context/terminal"
import { clearWorkspaceTerminals, getTerminalServerScope } from "@/context/terminal"
import { dropSessionCaches, pickSessionCacheEvictions } from "@/context/global-sync/session-cache"
import {
clearSessionPrefetchInflight,
@@ -1557,6 +1557,7 @@ 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,6 +37,7 @@ 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),
})
@@ -145,6 +146,21 @@ 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
@@ -280,9 +296,9 @@ export function TerminalPanel() {
<Terminal
pty={pty()}
autoFocus={opened()}
onConnect={() => ops.trim(id)}
onConnect={() => markTerminalConnected(terminalRecoveryKey(pty()), id, ops.trim)}
onCleanup={ops.update}
onConnectError={() => ops.clone(id)}
onConnectError={() => recoverTerminal(terminalRecoveryKey(pty()), id, ops.clone)}
/>
</div>
)}

View File

@@ -13,6 +13,7 @@ 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" })
@@ -45,6 +46,7 @@ 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"

View File

@@ -3,6 +3,7 @@ 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"
const AUTH_TOKEN_QUERY = "auth_token"
const UNAUTHORIZED = 401
@@ -92,6 +93,7 @@ export const authorizationRouterMiddleware = HttpRouter.middleware()(
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)),

View File

@@ -0,0 +1,58 @@
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

@@ -73,6 +73,7 @@ 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())
@@ -144,6 +145,7 @@ 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,

View File

@@ -0,0 +1,12 @@
// 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,6 +33,7 @@ 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
}

View File

@@ -257,6 +257,49 @@ describe("HttpApi Server.listen", () => {
}
})
// 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()

View File

@@ -184,6 +184,52 @@ 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
@@ -257,6 +303,25 @@ 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