Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -341,7 +341,7 @@ app.route('/api/health', createHealthRoutes(db, openCodeSupervisor))

app.route('/api/mcp-oauth-proxy', createMcpOauthProxyRoutes(openCodeClient, requireAuth))
app.route('/api/internal', createInternalRoutes(db, scheduleService, notificationService, settingsService, openCodeClient, sessionPermissionModeService, repoWorkspaces, gitAuthService, sessionGoalService, multiRunService))
app.route('/api/opencode-proxy', createOpenCodeProxyRoutes(db, settingsService))
app.route('/api/opencode-proxy', createOpenCodeProxyRoutes(db, settingsService, upgradeWebSocket))

const protectedApi = new Hono()
protectedApi.use('/*', requireAuth)
Expand Down
74 changes: 65 additions & 9 deletions backend/src/routes/opencode-proxy.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Hono } from 'hono'
import type { Context } from 'hono'
import type { UpgradeWebSocket } from 'hono/ws'
import type { Database } from 'bun:sqlite'
import { buildOpenCodeBasicAuth } from '@opencode-manager/shared/opencode'
import { createInternalTokenMiddleware } from '../auth/internal-token-middleware'
Expand All @@ -8,12 +9,22 @@ import { opencodeServerManager } from '../services/opencode-single-server'
import {
getOpenCodeUpstreamBaseUrl,
OPENCODE_DIRECTORY_HEADER,
toWebSocketUrl,
withDefaultOpenCodeDirectory,
} from '../services/opencode/upstream'
import { getRepoById } from '../db/queries'
import { getWorkspacePath } from '@opencode-manager/shared/config/env'
import { getTrustedOrigins, getWorkspacePath } from '@opencode-manager/shared/config/env'
import { isPathWithinRoot } from '../services/sandbox/command'
import { buildProxyResponseHeaders, filterProxyHeaders } from '../utils/proxy-headers'
import {
connectUpstreamWebSocket,
createBridgedSocketEvents,
isAllowedUpgradeOrigin,
type PendingUpstream,
} from '../utils/websocket-bridge'

const PROXY_PREFIX = /^\/api\/opencode-proxy(?:\/repos\/[^/]+)?/
const PERSISTENT_PTY_CONNECT_PATH = /^\/api\/experimental\/persistent-pty\/[^/]+\/connect$/

interface ProxyRequestParts {
method: string
Expand All @@ -32,6 +43,26 @@ function isJsonContentType(contentType: string | undefined): boolean {
return (contentType ?? '').toLowerCase().includes('application/json')
}

function stripProxyPrefix(pathname: string): string {
return pathname.replace(PROXY_PREFIX, '') || '/'
}

function isWebSocketUpgrade(c: Context): boolean {
return (c.req.header('connection')?.toLowerCase() ?? '').includes('upgrade')
&& c.req.header('upgrade')?.toLowerCase() === 'websocket'
}

/**
* Matches OpenCode's ticketed persistent PTY socket connects. WebSocket clients cannot
* send an Authorization header, so these rely on the single-use ticket minted through the
* authenticated proxy and validated by the upstream server. Only persistent PTY connects
* are ticketed; OpenCode's /api/pty route builds per-directory services before it can
* validate a ticket, so it must never bypass the internal token.
*/
function isTicketedPersistentPtyConnect(pathSuffix: string, searchParams: URLSearchParams): boolean {
return PERSISTENT_PTY_CONNECT_PATH.test(pathSuffix) && !!searchParams.get('ticket')
}

function decodeDirectoryHeader(value: string | undefined): string | undefined {
if (!value) return undefined
try {
Expand Down Expand Up @@ -81,10 +112,37 @@ function rewriteRepoLocationBody(bodyText: string, resolveDirectory: RepoDirecto
return JSON.stringify(parsed)
}

export function createOpenCodeProxyRoutes(db: Database, settingsService: SettingsService) {
export function createOpenCodeProxyRoutes(db: Database, settingsService: SettingsService, upgradeWebSocket: UpgradeWebSocket) {
const app = new Hono()
const requireInternalToken = createInternalTokenMiddleware(db)

app.use('/*', (c, next) => {
const url = new URL(c.req.url)
const isTicketedSocket = isWebSocketUpgrade(c)
&& isTicketedPersistentPtyConnect(stripProxyPrefix(url.pathname), url.searchParams)
return isTicketedSocket ? proxyTicketedPtySocket(c, url) : requireInternalToken(c, next)
})

async function proxyTicketedPtySocket(c: Context, url: URL): Promise<Response> {
if (!opencodeServerManager.isLifecycleInitialized()) {
return c.json({ error: 'OpenCode lifecycle initialization is incomplete; refusing to proxy to an unmanaged server' }, 503)
}

if (!isAllowedUpgradeOrigin(c.req.header('origin'), getTrustedOrigins())) {
return c.json({ error: 'Origin not allowed' }, 403)
}

const pathSuffix = stripProxyPrefix(url.pathname)
let pending: PendingUpstream
try {
pending = await connectUpstreamWebSocket(toWebSocketUrl(`${getOpenCodeUpstreamBaseUrl()}${pathSuffix}${url.search}`))
} catch {
return c.json({ error: 'PTY connection rejected' }, 403)
}

app.use('/*', createInternalTokenMiddleware(db))
const upgrade = upgradeWebSocket(() => createBridgedSocketEvents((peer) => pending.attach(peer)))
return (await upgrade(c, async () => {})) ?? c.body(null)
}

async function forwardToOpenCode(
c: Context,
Expand All @@ -96,10 +154,8 @@ export function createOpenCodeProxyRoutes(db: Database, settingsService: Setting
return c.json({ error: 'OpenCode lifecycle initialization is incomplete; refusing to proxy to an unmanaged server' }, 503)
}

const connectionHeader = c.req.header('connection')?.toLowerCase() ?? ''
const upgradeHeader = c.req.header('upgrade')?.toLowerCase() ?? ''
if (connectionHeader.includes('upgrade') && upgradeHeader === 'websocket') {
return c.json({ error: 'WebSocket proxying is not supported' }, 501)
if (isWebSocketUpgrade(c)) {
return c.json({ error: 'WebSocket proxying is only supported for ticketed persistent PTY connects' }, 501)
}

const url = new URL(c.req.url)
Expand Down Expand Up @@ -164,7 +220,7 @@ export function createOpenCodeProxyRoutes(db: Database, settingsService: Setting
}

const url = new URL(c.req.url)
const pathSuffix = url.pathname.replace(/^\/api\/opencode-proxy\/repos\/[^/]+/, '') || '/'
const pathSuffix = stripProxyPrefix(url.pathname)
const resolveDirectory = createRepoDirectoryResolver(repo.fullPath)
const directory = resolveDirectory(
url.searchParams.get('location[directory]') || decodeDirectoryHeader(c.req.header(OPENCODE_DIRECTORY_HEADER)),
Expand All @@ -180,7 +236,7 @@ export function createOpenCodeProxyRoutes(db: Database, settingsService: Setting

app.all('/*', async (c) => {
const url = new URL(c.req.url)
const pathSuffix = url.pathname.replace(/^\/api\/opencode-proxy/, '') || '/'
const pathSuffix = stripProxyPrefix(url.pathname)

return forwardToOpenCode(c, pathSuffix)
})
Expand Down
55 changes: 2 additions & 53 deletions backend/src/routes/repo-terminal-socket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,26 +6,14 @@ import { getTrustedOrigins } from '@opencode-manager/shared/config/env'
import type { GitAuthService } from '../services/git-auth'
import type { OpenCodeClient } from '../services/opencode/client'
import type { TerminalService } from '../services/terminal'
import { forwardPeerMessage, peerBufferedAmount, type WebSocketBridge } from '../utils/websocket-bridge'
import { getErrorMessage } from '../utils/error-utils'
import { createBridgedSocketEvents, isAllowedUpgradeOrigin } from '../utils/websocket-bridge'
import { resolveRepoRequestDirectory } from './repo-directory'

interface RepoTerminalSocketVariables {
terminalDirectory: string
terminalCursor: number | undefined
}

const MAX_CLOSE_REASON_BYTES = 120

function isAllowedUpgradeOrigin(
origin: string | undefined,
trustedOrigins: readonly string[],
): boolean {
if (origin === undefined) return true
if (origin === 'null') return false
return trustedOrigins.includes(origin)
}

function parseCursor(raw: string | undefined): number | undefined | null {
if (raw === undefined) return undefined
if (!/^-?\d+$/.test(raw)) return null
Expand All @@ -34,12 +22,6 @@ function parseCursor(raw: string | undefined): number | undefined | null {
return parsed
}

function truncateCloseReason(message: string): string {
const bytes = Buffer.from(message, 'utf8')
if (bytes.length <= MAX_CLOSE_REASON_BYTES) return message
return bytes.subarray(0, MAX_CLOSE_REASON_BYTES).toString('utf8')
}

export function createRepoTerminalSocketRoutes(
database: Database,
gitAuthService: GitAuthService,
Expand Down Expand Up @@ -79,40 +61,7 @@ export function createRepoTerminalSocketRoutes(
const cursor = c.get('terminalCursor')
const ptyID = c.req.param('ptyID')

let bridge: WebSocketBridge | undefined
let closed = false
let connectPromise: Promise<void> = Promise.resolve()

return {
onOpen(_event, ws) {
connectPromise = terminalService
.connect(directory, ptyID, cursor, {
send: (data) => ws.send(data),
close: (code, reason) => ws.close(code, reason),
bufferedAmount: () => peerBufferedAmount(ws),
})
.then((connected) => {
if (closed) {
connected.close()
return
}
bridge = connected
})
.catch((error: unknown) => {
if (closed) return
ws.close(1011, truncateCloseReason(getErrorMessage(error)))
})
},
async onMessage(event) {
await connectPromise
if (!bridge) return
await forwardPeerMessage(bridge, event.data)
},
onClose() {
closed = true
bridge?.close()
},
}
return createBridgedSocketEvents((peer) => terminalService.connect(directory, ptyID, cursor, peer))
}),
)

Expand Down
8 changes: 8 additions & 0 deletions backend/src/services/opencode/upstream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,11 @@ export function withDefaultOpenCodeDirectory(headers: Record<string, string>): R
const hasDirectory = Object.keys(headers).some((key) => key.toLowerCase() === OPENCODE_DIRECTORY_HEADER)
return hasDirectory ? headers : { ...headers, [OPENCODE_DIRECTORY_HEADER]: encodeURIComponent(getWorkspacePath()) }
}

/** Maps an http(s) URL to its ws(s) equivalent, leaving ws/wss URLs unchanged. */
export function toWebSocketUrl(httpUrl: string): string {
const url = new URL(httpUrl)
if (url.protocol === 'https:') url.protocol = 'wss:'
else if (url.protocol === 'http:') url.protocol = 'ws:'
return url.toString()
}
54 changes: 15 additions & 39 deletions backend/src/services/preview/gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,7 @@ import { createNodeWebSocket, type NodeWebSocket } from '@hono/node-ws'
import type { PreviewPort } from '@opencode-manager/shared/schemas'
import { AUTH_COOKIE_PREFIX } from '../../auth/cookies'
import { buildProxyResponseHeaders, filterProxyHeaders } from '../../utils/proxy-headers'
import {
bridgeWebSocket,
forwardPeerMessage,
peerBufferedAmount,
type WebSocketBridge,
} from '../../utils/websocket-bridge'
import { bridgeWebSocket, createBridgedSocketEvents } from '../../utils/websocket-bridge'

export const PREVIEW_COOKIE = 'ocm_preview'

Expand Down Expand Up @@ -229,39 +224,20 @@ export function createPreviewGatewayApp(store: PreviewSessionStore, fetchFn: typ
upgradeWebSocket((c) => {
const requestUrl = new URL(c.req.url)
const protocols = parseProtocols(c.req.header('sec-websocket-protocol'))
let bridge: WebSocketBridge | undefined

return {
onOpen(_event, ws) {
const sessionId = readPreviewCookie(c.req.header('cookie'))
const session = sessionId ? store.getSession(sessionId) : undefined
if (!session) {
ws.close(1011)
return
}

let upstream: WebSocket
try {
upstream = new WebSocket(upstreamWebSocketUrl(session, requestUrl), protocols.length > 0 ? protocols : undefined)
} catch {
ws.close(1011)
return
}

bridge = bridgeWebSocket(upstream, {
send: (data) => ws.send(data),
close: (code, reason) => ws.close(code, reason),
bufferedAmount: () => peerBufferedAmount(ws),
})
},
async onMessage(event) {
if (!bridge) return
await forwardPeerMessage(bridge, event.data)
},
onClose() {
bridge?.close()
},
}

return createBridgedSocketEvents((peer) => {
const sessionId = readPreviewCookie(c.req.header('cookie'))
const session = sessionId ? store.getSession(sessionId) : undefined
if (!session) {
throw new Error('Preview session expired. Reopen it from OpenCode Manager.')
}

const upstream = new WebSocket(
upstreamWebSocketUrl(session, requestUrl),
protocols.length > 0 ? protocols : undefined,
)
return bridgeWebSocket(upstream, peer)
})
}),
)

Expand Down
36 changes: 34 additions & 2 deletions backend/src/services/terminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@ import { formatTerminalTitle, parseTerminalTitle } from '@opencode-manager/share
import type { TerminalInfo, TerminalKind } from '@opencode-manager/shared/types'
import type { OpenCodeClient } from './opencode/client'
import type { CredentialProvider } from './credential-provider'
import { toWebSocketUrl } from './opencode/upstream'
import { bridgeWebSocket, type WebSocketBridge, type WebSocketPeer } from '../utils/websocket-bridge'

type OpenCodePty = Awaited<ReturnType<OpenCodeClient['api']['pty']['list']>>['data'][number]
type OpenCodePersistentPty = Awaited<ReturnType<OpenCodeClient['api']['experimental']['persistentPty']['list']>>[number]

export interface CreateTerminalInput {
kind: TerminalKind
Expand Down Expand Up @@ -79,6 +81,7 @@ export class TerminalService {
throw error
}
}
await this.removePersistentTerminals(directory)
}

async connect(
Expand All @@ -101,9 +104,38 @@ export class TerminalService {
socketUrl.searchParams.set('ticket', result.data.ticket)
socketUrl.searchParams.set('location[directory]', directory)
if (cursor !== undefined) socketUrl.searchParams.set('cursor', String(cursor))
socketUrl.protocol = socketUrl.protocol === 'https:' ? 'wss:' : 'ws:'

return bridgeWebSocket(this.openSocket(socketUrl.toString()), peer)
return bridgeWebSocket(this.openSocket(toWebSocketUrl(socketUrl.toString())), peer)
}

private async removePersistentTerminals(directory: string): Promise<void> {
let cursor: string | undefined
do {
const page = await this.openCodeClient.api.session.list({ directory, cursor })
for (const session of page.data) {
await this.removeSessionPersistentTerminals(session.id)
}
cursor = page.cursor.next ?? undefined
} while (cursor !== undefined)
}

private async removeSessionPersistentTerminals(sessionID: string): Promise<void> {
let terminals: OpenCodePersistentPty[]
try {
terminals = await this.openCodeClient.api.experimental.persistentPty.list({ sessionID })
} catch (error: unknown) {
if (openCodeErrorStatus(error) === 404) return
throw error
}

for (const terminal of terminals) {
try {
await this.openCodeClient.api.experimental.persistentPty.remove({ ptyID: terminal.id })
} catch (error: unknown) {
if (openCodeErrorStatus(error) === 404) continue
throw error
}
}
}

private toTerminalInfo(pty: OpenCodePty): TerminalInfo {
Expand Down
Loading