mirror of
https://github.com/stablyai/orca.git
synced 2026-10-27 00:02:48 +00:00
* Add worktree labels for localhost ports * Fix localhost label test fixtures * Extend localhost worktree labels to agents * Address localhost label review feedback * Remove localhost label page injection; stream responses untouched Drop the title/favicon HTML injection from the localhost label proxy. The proxy now only relabels the hostname and pipes responses straight through, so app CSP/cookies/bodies are preserved and large/streamed responses are no longer buffered in memory. - Normalize wildcard bind hosts (0.0.0.0 -> 127.0.0.1, :: -> ::1) before using them as a proxy connect target. - Delete the favicon SVG generator and dead repoIcon/badgeColor plumbing. - Remove orphaned LocalhostLabelMock i18n keys. Co-authored-by: Orca <help@stably.ai> * Harden localhost label proxy from review - IPC register: restrict proxy target to loopback or a matching scanned workspace port (close open-proxy/SSRF surface from untrusted renderer). - Proxy: guard against ERR_HTTP_HEADERS_SENT on mid-stream upstream error; add error/cleanup listeners on client request/response and upgrade socket. - labelLocalhostUrl: fall back to the raw URL when the proxy rejects a target (e.g. https) instead of throwing. - Port label route: include worktreePath so button-open and terminal/CLI paths produce the same label. - Terminal OSC link hover: discard stale async tooltip results via a hover token, matching the WebLinks path. Co-authored-by: Orca <help@stably.ai> * Match default-port advertised hosts in localhost label target check Co-authored-by: Orca <help@stably.ai> * Consolidate duplicated localhost label helpers - Move the loopback host set, loopback-URL parser, and wildcard connect-host normalizer into shared/localhost-worktree-labels.ts; proxy, runtime, terminal link routing, and the IPC guard now share one copy. - Extract the port -> repo -> worktree -> project label-route lookup into workspace-port-localhost-label-selector.ts (a hook plus an imperative resolver), replacing the block triplicated across the ports surfaces. Co-authored-by: Orca <help@stably.ai> * Extract command-code prompt-status seed to its own module Keeps launch-agent-in-new-tab.ts under the max-lines limit after the localhost-hint additions, without a lint disable. Co-authored-by: Orca <help@stably.ai> * Remove agent-facing localhost mechanism Orca does not mutate user prompts or inject prompt snippets, so drop the localhost-open agent hint entirely: - Remove appendLocalhostOpeningHint / includeLocalhostOpeningHint and the hint constant from tui-agent-startup; agent prompts are no longer rewritten. - Remove the ORCA_LOCALHOST_OPEN env var from local agent terminals (it was only discoverable via the now-removed hint). - Remove the orca localhost label|open CLI commands, their workspacePorts RPC methods, and the runtime labelLocalhostUrl/openLocalhostUrl methods. The feature is now purely structural: the loopback label proxy plus the ports-panel and terminal-link 'Open in Browser' paths, which surface a clickable labeled URL without touching agent prompts. Co-authored-by: Orca <help@stably.ai> * Make localhost worktree labels opt-in (default off) Serving a dev app under a different host than localhost:<port> can break apps that bind cookies/sessions to localhost, so the feature should not change existing users' Open-in-Browser behavior on upgrade. - Default localhostWorktreeLabelsEnabled to false. - Flip the gates to enable only when explicitly true (=== true / !== true) instead of treating undefined as enabled. - Update the setting switch to checked only when explicitly enabled. - Drop the now-unused runtime store settings field. Co-authored-by: Orca <help@stably.ai> --------- Co-authored-by: Jinwoo-H <jinwoo0825@gmail.com> Co-authored-by: Orca <help@stably.ai>
237 lines
7.8 KiB
TypeScript
237 lines
7.8 KiB
TypeScript
import http, { type IncomingMessage, type Server, type ServerResponse } from 'node:http'
|
|
import https from 'node:https'
|
|
import net from 'node:net'
|
|
import type { Duplex } from 'node:stream'
|
|
import { URL } from 'node:url'
|
|
import type {
|
|
LocalhostWorktreeLabelResult,
|
|
LocalhostWorktreeLabelRoute
|
|
} from '../shared/localhost-worktree-labels'
|
|
import {
|
|
connectableLoopbackHost,
|
|
getLocalhostWorktreeHostLabel,
|
|
getLocalhostWorktreeRouteKey
|
|
} from '../shared/localhost-worktree-labels'
|
|
|
|
type RegisteredRoute = LocalhostWorktreeLabelRoute & {
|
|
label: string
|
|
routeKey: string
|
|
target: URL
|
|
}
|
|
|
|
const ORCA_LOCALHOST_SUFFIX = '.orca.localhost'
|
|
|
|
export class LocalhostWorktreeLabelProxy {
|
|
private server: Server | null = null
|
|
private listenPort: number | null = null
|
|
private serverReady: Promise<void> | null = null
|
|
private readonly routes = new Map<string, RegisteredRoute>()
|
|
private readonly routeKeys = new Map<string, string>()
|
|
|
|
async registerRoute(route: LocalhostWorktreeLabelRoute): Promise<LocalhostWorktreeLabelResult> {
|
|
const target = parseTargetUrl(route.targetUrl)
|
|
await this.ensureServer()
|
|
const baseLabel = getLocalhostWorktreeHostLabel(route)
|
|
const routeKey = getLocalhostWorktreeRouteKey(route)
|
|
const previousLabel = this.routeKeys.get(routeKey)
|
|
const label = previousLabel ?? this.nextAvailableLabel(baseLabel)
|
|
const registered: RegisteredRoute = {
|
|
...route,
|
|
label,
|
|
routeKey,
|
|
target
|
|
}
|
|
this.routes.set(label, registered)
|
|
this.routeKeys.set(routeKey, label)
|
|
return {
|
|
label,
|
|
url: this.buildLabeledUrl(label, target)
|
|
}
|
|
}
|
|
|
|
private async ensureServer(): Promise<void> {
|
|
if (this.server && this.listenPort !== null) {
|
|
return
|
|
}
|
|
if (this.serverReady) {
|
|
await this.serverReady
|
|
return
|
|
}
|
|
|
|
const server = http.createServer((request, response) => {
|
|
void this.handleRequest(request, response)
|
|
})
|
|
server.on('upgrade', (request, socket, head) => {
|
|
this.handleUpgrade(request, socket, head)
|
|
})
|
|
this.serverReady = new Promise<void>((resolve, reject) => {
|
|
server.once('error', reject)
|
|
server.listen(0, '127.0.0.1', () => {
|
|
server.off('error', reject)
|
|
const address = server.address()
|
|
if (!address || typeof address === 'string') {
|
|
reject(new Error('Failed to start localhost label proxy.'))
|
|
return
|
|
}
|
|
this.listenPort = address.port
|
|
this.server = server
|
|
resolve()
|
|
})
|
|
})
|
|
try {
|
|
await this.serverReady
|
|
} catch (error) {
|
|
this.serverReady = null
|
|
throw error
|
|
}
|
|
}
|
|
|
|
private nextAvailableLabel(baseLabel: string): string {
|
|
if (!this.routes.has(baseLabel)) {
|
|
return baseLabel
|
|
}
|
|
for (let index = 2; index < 1000; index += 1) {
|
|
const candidate = `${baseLabel}-${index}`
|
|
if (!this.routes.has(candidate)) {
|
|
return candidate
|
|
}
|
|
}
|
|
throw new Error('No available localhost label.')
|
|
}
|
|
|
|
private buildLabeledUrl(label: string, target: URL): string {
|
|
if (this.listenPort === null) {
|
|
throw new Error('Localhost label proxy is not running.')
|
|
}
|
|
const url = new URL(target.toString())
|
|
url.hostname = `${label}${ORCA_LOCALHOST_SUFFIX}`
|
|
url.port = String(this.listenPort)
|
|
return url.toString()
|
|
}
|
|
|
|
private async handleRequest(request: IncomingMessage, response: ServerResponse): Promise<void> {
|
|
const route = this.routeForRequest(request)
|
|
if (!route) {
|
|
response.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' })
|
|
response.end('Unknown Orca localhost label.')
|
|
return
|
|
}
|
|
|
|
const target = targetUrlForRequest(route.target, request)
|
|
const proxyRequest = requestForTarget(target, {
|
|
method: request.method,
|
|
headers: requestHeadersForTarget(request, route.target)
|
|
})
|
|
|
|
// Why: a client abort or downstream socket error must tear down the
|
|
// upstream request instead of surfacing as an uncaught exception/leak.
|
|
request.on('error', () => proxyRequest.destroy())
|
|
response.on('error', () => proxyRequest.destroy())
|
|
|
|
// Why: the proxy only relabels the hostname; responses are streamed
|
|
// through untouched so app headers (CSP, cookies) and bodies are
|
|
// preserved exactly as the dev server sent them.
|
|
proxyRequest.on('response', (proxyResponse) => {
|
|
proxyResponse.on('error', () => response.destroy())
|
|
response.writeHead(proxyResponse.statusCode ?? 502, proxyResponse.headers)
|
|
proxyResponse.pipe(response)
|
|
})
|
|
proxyRequest.on('error', (error) => {
|
|
// Why: once headers/bytes are flushed we can't write a 502, so tear the
|
|
// socket down to avoid an ERR_HTTP_HEADERS_SENT crash.
|
|
if (response.headersSent) {
|
|
response.destroy(error)
|
|
return
|
|
}
|
|
response.writeHead(502, { 'content-type': 'text/plain; charset=utf-8' })
|
|
response.end(`Proxy failed for ${route.label}: ${error.message}`)
|
|
})
|
|
request.pipe(proxyRequest)
|
|
}
|
|
|
|
private handleUpgrade(request: IncomingMessage, socket: Duplex, head: Buffer): void {
|
|
const route = this.routeForRequest(request)
|
|
if (!route) {
|
|
socket.destroy()
|
|
return
|
|
}
|
|
|
|
const target = targetUrlForRequest(route.target, request)
|
|
const targetPort = Number(target.port || (target.protocol === 'https:' ? 443 : 80))
|
|
const targetSocket = net.connect(targetPort, connectableLoopbackHost(target.hostname), () => {
|
|
const headers = requestHeadersForTarget(request, route.target)
|
|
targetSocket.write(
|
|
`${request.method ?? 'GET'} ${target.pathname}${target.search} HTTP/${request.httpVersion}\r\n`
|
|
)
|
|
for (const [name, value] of Object.entries(headers)) {
|
|
if (Array.isArray(value)) {
|
|
for (const entry of value) {
|
|
targetSocket.write(`${name}: ${entry}\r\n`)
|
|
}
|
|
} else if (value !== undefined) {
|
|
targetSocket.write(`${name}: ${value}\r\n`)
|
|
}
|
|
}
|
|
targetSocket.write('\r\n')
|
|
if (head.length > 0) {
|
|
targetSocket.write(head)
|
|
}
|
|
targetSocket.pipe(socket)
|
|
socket.pipe(targetSocket)
|
|
})
|
|
targetSocket.on('error', () => socket.destroy())
|
|
socket.on('error', () => targetSocket.destroy())
|
|
}
|
|
|
|
private routeForRequest(request: IncomingMessage): RegisteredRoute | null {
|
|
const host =
|
|
String(request.headers.host ?? '')
|
|
.split(':')[0]
|
|
?.toLowerCase() ?? ''
|
|
if (!host.endsWith(ORCA_LOCALHOST_SUFFIX)) {
|
|
return null
|
|
}
|
|
const label = host.slice(0, -ORCA_LOCALHOST_SUFFIX.length)
|
|
return this.routes.get(label) ?? null
|
|
}
|
|
}
|
|
|
|
export const localhostWorktreeLabelProxy = new LocalhostWorktreeLabelProxy()
|
|
|
|
function parseTargetUrl(rawUrl: string): URL {
|
|
const url = new URL(rawUrl)
|
|
if (url.protocol !== 'http:') {
|
|
throw new Error('Only http workspace ports can be labeled.')
|
|
}
|
|
return url
|
|
}
|
|
|
|
function requestForTarget(
|
|
target: URL,
|
|
options: { method?: string; headers: http.OutgoingHttpHeaders }
|
|
): http.ClientRequest {
|
|
const requestOptions = {
|
|
protocol: target.protocol,
|
|
hostname: connectableLoopbackHost(target.hostname),
|
|
port: target.port || (target.protocol === 'https:' ? 443 : 80),
|
|
path: `${target.pathname}${target.search}`,
|
|
method: options.method,
|
|
headers: options.headers
|
|
}
|
|
return target.protocol === 'https:' ? https.request(requestOptions) : http.request(requestOptions)
|
|
}
|
|
|
|
function targetUrlForRequest(target: URL, request: IncomingMessage): URL {
|
|
const url = new URL(target.toString())
|
|
const incomingUrl = new URL(request.url || '/', target)
|
|
url.pathname = incomingUrl.pathname
|
|
url.search = incomingUrl.search
|
|
return url
|
|
}
|
|
|
|
function requestHeadersForTarget(request: IncomingMessage, target: URL): http.OutgoingHttpHeaders {
|
|
const headers: http.OutgoingHttpHeaders = { ...request.headers }
|
|
headers.host = target.host
|
|
return headers
|
|
}
|