Files
orca/src/main/ipc/localhost-worktree-labels.ts
T
a56da57bb8 Add worktree labels for localhost ports (#6424)
* 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>
2026-06-29 12:27:30 -07:00

95 lines
3.3 KiB
TypeScript

import { ipcMain } from 'electron'
import { URL } from 'node:url'
import {
LOOPBACK_LOCALHOST_HOSTS,
normalizeLocalhostHostname,
type LocalhostWorktreeLabelResult,
type LocalhostWorktreeLabelRoute
} from '../../shared/localhost-worktree-labels'
import type { Store } from '../persistence'
import { localhostWorktreeLabelProxy } from '../localhost-worktree-label-proxy'
import {
getStoreWorkspacePortProbes,
scanWorkspacePortProbes
} from '../ports/workspace-port-ownership'
export function registerLocalhostWorktreeLabelHandlers(store: Store): void {
ipcMain.handle(
'localhostWorktreeLabels:register',
async (_event, rawArgs: unknown): Promise<LocalhostWorktreeLabelResult> => {
const route = parseRegisterArgs(rawArgs)
// Why: the proxy will forward to any host it's given, so we restrict the
// target to loopback or a host:port that matches a live workspace port —
// otherwise this IPC is an open proxy / SSRF vector.
await assertAllowedTarget(store, route.targetUrl)
return localhostWorktreeLabelProxy.registerRoute(route)
}
)
}
async function assertAllowedTarget(store: Store, targetUrl: string): Promise<void> {
let parsed: URL
try {
parsed = new URL(targetUrl)
} catch {
throw new Error('Localhost label target is not a valid URL.')
}
const targetHost = normalizeLocalhostHostname(parsed.hostname)
if (LOOPBACK_LOCALHOST_HOSTS.has(targetHost)) {
return
}
// Why: URL drops the port for protocol defaults (e.g. http://host/ on 80),
// so compare against the effective port rather than the raw (empty) string.
const targetPort = parsed.port || (parsed.protocol === 'https:' ? '443' : '80')
const scan = await scanWorkspacePortProbes(getStoreWorkspacePortProbes(store))
const matches = scan.ports.some((port) => {
if (String(port.port) !== targetPort) {
return false
}
if (normalizeLocalhostHostname(port.connectHost) === targetHost) {
return true
}
const advertisedUrl = 'advertisedUrl' in port ? port.advertisedUrl : undefined
if (!advertisedUrl) {
return false
}
try {
return normalizeLocalhostHostname(new URL(advertisedUrl).hostname) === targetHost
} catch {
return false
}
})
if (!matches) {
throw new Error('Localhost label target is not an allowed workspace port.')
}
}
function parseRegisterArgs(value: unknown): LocalhostWorktreeLabelRoute {
if (!value || typeof value !== 'object') {
throw new Error('Invalid localhost label route.')
}
const candidate = value as Record<string, unknown>
const targetUrl = readRequiredString(candidate.targetUrl, 'targetUrl')
const projectName = readRequiredString(candidate.projectName, 'projectName')
const worktreeName = readRequiredString(candidate.worktreeName, 'worktreeName')
return {
targetUrl,
projectName,
worktreeName,
repoId: readOptionalString(candidate.repoId),
worktreeId: readOptionalString(candidate.worktreeId)
}
}
function readRequiredString(value: unknown, field: string): string {
if (typeof value !== 'string' || value.trim().length === 0) {
throw new Error(`Invalid localhost label ${field}.`)
}
return value.trim()
}
function readOptionalString(value: unknown): string | null {
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null
}