Add workspace port management (#2316)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo Hong 2026-05-19 13:23:40 -04:00 committed by GitHub
parent d5fb49b18f
commit 062725da76
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 2260 additions and 104 deletions

View File

@ -44,7 +44,8 @@ const {
registerOnboardingHandlersMock,
registerSpeechHandlersMock,
registerSkillsHandlersMock,
registerWorkspaceSpaceHandlersMock
registerWorkspaceSpaceHandlersMock,
registerWorkspacePortHandlersMock
} = vi.hoisted(() => ({
registerCliHandlersMock: vi.fn(),
registerPreflightHandlersMock: vi.fn(),
@ -87,7 +88,8 @@ const {
registerOnboardingHandlersMock: vi.fn(),
registerSpeechHandlersMock: vi.fn(),
registerSkillsHandlersMock: vi.fn(),
registerWorkspaceSpaceHandlersMock: vi.fn()
registerWorkspaceSpaceHandlersMock: vi.fn(),
registerWorkspacePortHandlersMock: vi.fn()
}))
vi.mock('./onboarding', () => ({
@ -166,6 +168,10 @@ vi.mock('./workspace-space', () => ({
registerWorkspaceSpaceHandlers: registerWorkspaceSpaceHandlersMock
}))
vi.mock('./workspace-ports', () => ({
registerWorkspacePortHandlers: registerWorkspacePortHandlersMock
}))
vi.mock('./telemetry', () => ({
registerTelemetryHandlers: registerTelemetryHandlersMock
}))
@ -297,6 +303,7 @@ describe('registerCoreHandlers', () => {
registerSpeechHandlersMock.mockReset()
registerSkillsHandlersMock.mockReset()
registerWorkspaceSpaceHandlersMock.mockReset()
registerWorkspacePortHandlersMock.mockReset()
})
it('passes the store through to handler registrars that need it', () => {
@ -349,6 +356,7 @@ describe('registerCoreHandlers', () => {
expect(registerSettingsHandlersMock).toHaveBeenCalledWith(store, agentAwakeService)
expect(registerSkillsHandlersMock).toHaveBeenCalledWith(store)
expect(registerWorkspaceSpaceHandlersMock).toHaveBeenCalledWith(store)
expect(registerWorkspacePortHandlersMock).toHaveBeenCalledWith(store)
expect(registerTelemetryHandlersMock).toHaveBeenCalledWith(store)
expect(registerSessionHandlersMock).toHaveBeenCalledWith(store)
expect(registerUIHandlersMock).toHaveBeenCalledWith(store)

View File

@ -32,6 +32,7 @@ import { registerSessionHandlers } from './session'
import { registerSettingsHandlers } from './settings'
import { registerSkillsHandlers } from './skills'
import { registerWorkspaceSpaceHandlers } from './workspace-space'
import { registerWorkspacePortHandlers } from './workspace-ports'
import { registerAutomationHandlers } from './automations'
import { registerTelemetryHandlers } from './telemetry'
import { registerBrowserHandlers } from './browser'
@ -131,6 +132,7 @@ export function registerCoreHandlers(
registerSessionHandlers(store)
registerUIHandlers(store)
registerWorkspaceSpaceHandlers(store)
registerWorkspacePortHandlers(store)
if (commitMessageAgentEnv) {
registerFilesystemHandlers(store, commitMessageAgentEnv)
} else {

View File

@ -0,0 +1,241 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { WorkspacePort, WorkspacePortScanResult } from '../../shared/workspace-ports'
const handlers = new Map<string, (_event: unknown, args: unknown) => Promise<unknown> | unknown>()
const { handleMock, scanWorkspacePortsMock, processKillMock } = vi.hoisted(() => ({
handleMock: vi.fn(),
scanWorkspacePortsMock: vi.fn(),
processKillMock: vi.fn()
}))
vi.mock('electron', () => ({
ipcMain: {
handle: handleMock
}
}))
vi.mock('../ports/local-workspace-port-scanner', () => ({
scanWorkspacePorts: scanWorkspacePortsMock
}))
import { registerWorkspacePortHandlers } from './workspace-ports'
const EMPTY_SCAN: WorkspacePortScanResult = {
ports: [],
platform: process.platform,
scannedAt: 0
}
function makeStore() {
return {
getRepos: vi.fn(() => [
{
id: 'local-repo',
path: '/workspace/repo',
displayName: 'Local Repo',
badgeColor: '#000',
addedAt: 0
},
{
id: 'remote-repo',
path: '/remote/repo',
displayName: 'Remote Repo',
badgeColor: '#000',
addedAt: 0,
connectionId: 'ssh-target'
},
{
id: 'other-repo',
path: '/workspace/other',
displayName: 'Other Repo',
badgeColor: '#000',
addedAt: 0
}
]),
getAllWorktreeMeta: vi.fn(() => ({
'local-repo::/workspace/repo': { displayName: 'Primary' },
'remote-repo::/remote/repo': { displayName: 'Remote' },
'other-repo::/workspace/other': { displayName: 'Other' },
'malformed-worktree-id': { displayName: 'Malformed' }
}))
}
}
describe('registerWorkspacePortHandlers', () => {
beforeEach(() => {
handlers.clear()
handleMock.mockReset()
scanWorkspacePortsMock.mockReset()
handleMock.mockImplementation((channel, handler) => {
handlers.set(channel, handler)
})
scanWorkspacePortsMock.mockResolvedValue(EMPTY_SCAN)
processKillMock.mockReset()
vi.spyOn(process, 'kill').mockImplementation(processKillMock)
})
it('derives local worktree probes from the main store instead of renderer input', async () => {
const store = makeStore()
registerWorkspacePortHandlers(store as never)
const handler = handlers.get('workspacePorts:scan')
expect(handler).toBeDefined()
await handler?.(null, {
repoId: 'local-repo',
worktrees: [{ id: 'attacker', path: '/tmp/not-authorized', repoId: 'local-repo' }]
})
expect(scanWorkspacePortsMock).toHaveBeenCalledWith([
{
id: 'local-repo::/workspace/repo',
repoId: 'local-repo',
displayName: 'Primary',
path: '/workspace/repo'
}
])
})
it('deduplicates concurrent scans for the same store-derived probe set', async () => {
const store = makeStore()
let resolveScan: (result: WorkspacePortScanResult) => void = () => {}
scanWorkspacePortsMock.mockReturnValue(
new Promise<WorkspacePortScanResult>((resolve) => {
resolveScan = resolve
})
)
registerWorkspacePortHandlers(store as never)
const handler = handlers.get('workspacePorts:scan')
expect(handler).toBeDefined()
const first = handler?.(null, { repoId: 'local-repo' })
const second = handler?.(null, { repoId: 'local-repo' })
expect(scanWorkspacePortsMock).toHaveBeenCalledTimes(1)
resolveScan(EMPTY_SCAN)
await expect(Promise.all([first, second])).resolves.toEqual([EMPTY_SCAN, EMPTY_SCAN])
})
it('handles malformed renderer scan input as an unfiltered local scan', async () => {
const store = makeStore()
registerWorkspacePortHandlers(store as never)
await handlers.get('workspacePorts:scan')?.(null, undefined)
expect(scanWorkspacePortsMock).toHaveBeenCalledWith([
{
id: 'local-repo::/workspace/repo',
repoId: 'local-repo',
displayName: 'Primary',
path: '/workspace/repo'
},
{
id: 'other-repo::/workspace/other',
repoId: 'other-repo',
displayName: 'Other',
path: '/workspace/other'
}
])
})
it('stops a process only after the current scan proves the pid owns a workspace port', async () => {
const store = makeStore()
const port = workspacePort({ pid: 1234, port: 5173 })
scanWorkspacePortsMock.mockResolvedValue({
...EMPTY_SCAN,
ports: [port]
})
registerWorkspacePortHandlers(store as never)
const result = await handlers.get('workspacePorts:kill')?.(null, {
repoId: 'local-repo',
pid: 1234,
port: 5173
})
expect(result).toEqual({ ok: true })
expect(processKillMock).toHaveBeenCalledWith(1234, 'SIGTERM')
})
it('refuses to stop external ports', async () => {
const store = makeStore()
scanWorkspacePortsMock.mockResolvedValue({
...EMPTY_SCAN,
ports: [
{
id: '127.0.0.1:55182:2222',
bindHost: '127.0.0.1',
connectHost: '127.0.0.1',
port: 55182,
pid: 2222,
processName: 'node',
protocol: 'unknown',
kind: 'external'
}
]
})
registerWorkspacePortHandlers(store as never)
const result = await handlers.get('workspacePorts:kill')?.(null, {
repoId: 'local-repo',
pid: 2222,
port: 55182
})
expect(result).toEqual({
ok: false,
reason: 'Only workspace-owned local processes can be stopped here.'
})
expect(processKillMock).not.toHaveBeenCalled()
})
it('refuses stale or forged pid requests that do not match the current scan', async () => {
const store = makeStore()
scanWorkspacePortsMock.mockResolvedValue({
...EMPTY_SCAN,
ports: [workspacePort({ pid: 1234, port: 5173 })]
})
registerWorkspacePortHandlers(store as never)
const result = await handlers.get('workspacePorts:kill')?.(null, {
repoId: 'local-repo',
pid: 9999,
port: 5173
})
expect(result).toEqual({ ok: false, reason: 'The port is no longer listening.' })
expect(processKillMock).not.toHaveBeenCalled()
})
it('refuses malformed renderer kill input without throwing', async () => {
const store = makeStore()
registerWorkspacePortHandlers(store as never)
const result = await handlers.get('workspacePorts:kill')?.(null, { pid: '1234', port: 5173 })
expect(result).toEqual({ ok: false, reason: 'Invalid process or port.' })
expect(scanWorkspacePortsMock).not.toHaveBeenCalled()
expect(processKillMock).not.toHaveBeenCalled()
})
})
function workspacePort({ pid, port }: { pid: number; port: number }): WorkspacePort {
return {
id: `127.0.0.1:${port}:${pid}`,
bindHost: '127.0.0.1',
connectHost: '127.0.0.1',
port,
pid,
processName: 'node',
protocol: 'unknown',
kind: 'workspace',
owner: {
worktreeId: 'local-repo::/workspace/repo',
repoId: 'local-repo',
displayName: 'Primary',
path: '/workspace/repo',
confidence: 'cwd'
}
}
}

View File

@ -0,0 +1,79 @@
import { ipcMain } from 'electron'
import type { Store } from '../persistence'
import type {
WorkspacePortKillRequest,
WorkspacePortKillResult,
WorkspacePortScanRequest,
WorkspacePortScanResult
} from '../../shared/workspace-ports'
import {
getStoreWorkspacePortProbes,
killWorkspacePort,
scanWorkspacePortProbes
} from '../ports/workspace-port-ownership'
export function registerWorkspacePortHandlers(store: Store): void {
const inFlightScans = new Map<string, Promise<WorkspacePortScanResult>>()
ipcMain.handle(
'workspacePorts:scan',
(_event, rawArgs?: unknown): Promise<WorkspacePortScanResult> => {
const args = parseScanRequest(rawArgs)
const worktrees = getStoreWorkspacePortProbes(store, args?.repoId)
const key = JSON.stringify(
worktrees
.map((worktree) => [worktree.id, worktree.repoId, worktree.displayName, worktree.path])
.sort(([a], [b]) => String(a).localeCompare(String(b)))
)
const existing = inFlightScans.get(key)
if (existing) {
return existing
}
const promise = scanWorkspacePortProbes(worktrees).finally(() => {
if (inFlightScans.get(key) === promise) {
inFlightScans.delete(key)
}
})
inFlightScans.set(key, promise)
return promise
}
)
ipcMain.handle(
'workspacePorts:kill',
async (_event, rawArgs?: unknown): Promise<WorkspacePortKillResult> => {
const args = parseKillRequest(rawArgs)
if (!args) {
return { ok: false, reason: 'Invalid process or port.' }
}
const worktrees = getStoreWorkspacePortProbes(store, args.repoId)
return killWorkspacePort(worktrees, args)
}
)
}
function parseScanRequest(value: unknown): WorkspacePortScanRequest | undefined {
if (!value || typeof value !== 'object') {
return undefined
}
const repoId = (value as { repoId?: unknown }).repoId
return typeof repoId === 'string' && repoId.length > 0 ? { repoId } : undefined
}
function parseKillRequest(value: unknown): WorkspacePortKillRequest | null {
if (!value || typeof value !== 'object') {
return null
}
const args = value as { repoId?: unknown; pid?: unknown; port?: unknown }
if (!Number.isSafeInteger(args.pid) || !Number.isSafeInteger(args.port)) {
return null
}
const pid = args.pid as number
const port = args.port as number
return {
...(typeof args.repoId === 'string' && args.repoId.length > 0 ? { repoId: args.repoId } : {}),
pid,
port
}
}

View File

@ -0,0 +1,136 @@
import { describe, expect, it } from 'vitest'
import {
attributePortToWorkspace,
isContainerProcess,
parseLsofListeningOutput,
parseNetstatListeningOutput,
parseProcNetTcp
} from './local-workspace-port-scanner'
const worktrees = [
{
id: 'repo::/repo',
repoId: 'repo',
displayName: 'main',
path: '/repo'
},
{
id: 'repo::/repo/worktrees/feature',
repoId: 'repo',
displayName: 'feature',
path: '/repo/worktrees/feature'
}
]
describe('local workspace port scanner parsing', () => {
it('parses lsof field output into listening ports', () => {
const ports = parseLsofListeningOutput(
['p123', 'cnode', 'n127.0.0.1:5173', 'p456', 'cnginx', 'n*:8080'].join('\n')
)
expect(ports).toEqual([
{ pid: 123, processName: 'node', host: '127.0.0.1', port: 5173 },
{ pid: 456, processName: 'nginx', host: '*', port: 8080 }
])
})
it('parses multiple lsof listening ports for the same process', () => {
const ports = parseLsofListeningOutput(
['p123', 'cnode', 'n127.0.0.1:5173', 'n127.0.0.1:55173'].join('\n')
)
expect(ports).toEqual([
{ pid: 123, processName: 'node', host: '127.0.0.1', port: 5173 },
{ pid: 123, processName: 'node', host: '127.0.0.1', port: 55173 }
])
})
it('parses Windows netstat listening rows', () => {
const ports = parseNetstatListeningOutput(
[
'Proto Local Address Foreign Address State PID',
'TCP 127.0.0.1:3000 0.0.0.0:0 LISTENING 4242',
'TCP [::]:5173 [::]:0 LISTENING 5151'
].join('\n')
)
expect(ports).toEqual([
{ host: '127.0.0.1', port: 3000, pid: 4242 },
{ host: '::', port: 5173, pid: 5151 }
])
})
it('parses Linux proc tcp listeners', () => {
const ports = parseProcNetTcp(
[
' sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode',
' 0: 0100007F:0BB8 00000000:0000 0A 00000000:00000000 00:00000000 00000000 1000 0 12345 1 0000000000000000 100 0 0 10 0'
].join('\n')
)
expect(ports).toEqual([{ host: '127.0.0.1', port: 3000, inode: 12345 }])
})
})
describe('attributePortToWorkspace', () => {
it('uses cwd ancestry and picks the deepest matching worktree', () => {
const owner = attributePortToWorkspace(
{ cwd: '/repo/worktrees/feature/packages/app', commandLine: 'node server.js' },
worktrees
)
expect(owner).toMatchObject({
worktreeId: 'repo::/repo/worktrees/feature',
displayName: 'feature',
confidence: 'cwd'
})
})
it('falls back to command-line path evidence', () => {
const owner = attributePortToWorkspace(
{ commandLine: 'node /repo/worktrees/feature/node_modules/vite/bin/vite.js' },
worktrees
)
expect(owner).toMatchObject({
worktreeId: 'repo::/repo/worktrees/feature',
confidence: 'command'
})
})
it('requires command-line path boundary evidence', () => {
const owner = attributePortToWorkspace(
{ commandLine: 'node /repo/worktrees/feature-other/server.js' },
[worktrees[1]]
)
expect(owner).toBeUndefined()
})
it('keeps path case significant on case-sensitive platforms', () => {
const owner = attributePortToWorkspace({ cwd: '/Repo/worktrees/feature' }, worktrees)
if (process.platform === 'win32') {
expect(owner).toMatchObject({ worktreeId: 'repo::/repo/worktrees/feature' })
} else {
expect(owner).toBeUndefined()
}
})
it('does not guess when there is no worktree evidence', () => {
const owner = attributePortToWorkspace({ cwd: '/Applications/ContainerRuntime.app' }, worktrees)
expect(owner).toBeUndefined()
})
})
describe('container process classification', () => {
it('detects common container listener owners without workspace attribution', () => {
expect(isContainerProcess({ processName: 'com.container.backend' })).toBe(true)
expect(isContainerProcess({ processName: 'com.vendor.backend' })).toBe(true)
expect(isContainerProcess({ commandLine: '/usr/bin/container-runtime port-forward' })).toBe(
true
)
expect(isContainerProcess({ processName: 'node', commandLine: 'node server.js' })).toBe(false)
})
})

View File

@ -0,0 +1,513 @@
/* eslint-disable max-lines -- Why: the platform-specific scan paths share parsing,
attribution, and normalization rules that must stay in lockstep. */
import { execFile } from 'child_process'
import { promisify } from 'util'
import { readFile, readdir, readlink } from 'fs/promises'
import path from 'path'
import type {
WorkspacePort,
WorkspacePortOwner,
WorkspacePortProbe,
WorkspacePortScanResult
} from '../../shared/workspace-ports'
const execFileAsync = promisify(execFile)
const COMMAND_TIMEOUT_MS = 4_000
const MAX_PORTS = 200
const HTTP_PORTS = new Set([80, 3000, 3001, 4200, 5000, 5173, 5174, 8000, 8080, 8888])
const HTTPS_PORTS = new Set([443, 8443])
type RawListeningPort = {
host: string
port: number
pid?: number
processName?: string
commandLine?: string
cwd?: string
}
type ProcessMetadata = {
processName?: string
commandLine?: string
cwd?: string
}
export async function scanWorkspacePorts(
worktrees: WorkspacePortProbe[]
): Promise<WorkspacePortScanResult> {
try {
const rawPorts = await scanPlatformListeningPorts()
const ports = rawPorts
.map((port) => enrichPort(port, worktrees))
.sort(compareWorkspacePorts)
.slice(0, MAX_PORTS)
return { platform: process.platform, scannedAt: Date.now(), ports }
} catch (error) {
console.warn('[workspace-ports] scan failed', error)
return {
platform: process.platform,
scannedAt: Date.now(),
ports: [],
unavailableReason: `Port scanning is unavailable on ${process.platform}.`
}
}
}
export function attributePortToWorkspace(
port: Pick<RawListeningPort, 'cwd' | 'commandLine'>,
worktrees: WorkspacePortProbe[]
): WorkspacePortOwner | undefined {
const cwd = port.cwd ? normalizeComparablePath(port.cwd) : null
const commandLine = port.commandLine ? normalizeComparableText(port.commandLine) : null
const cwdMatches = cwd
? worktrees
.map((worktree) => ({ worktree, normalizedPath: normalizeComparablePath(worktree.path) }))
.filter(({ normalizedPath }) => isSameOrDescendant(cwd, normalizedPath))
: []
const cwdMatch = pickDeepestMatch(cwdMatches)
if (cwdMatch) {
return toOwner(cwdMatch.worktree, 'cwd')
}
if (!commandLine) {
return undefined
}
const commandMatches = worktrees
.map((worktree) => ({ worktree, normalizedPath: normalizeComparablePath(worktree.path) }))
.filter(({ normalizedPath }) => includesPathBoundary(commandLine, normalizedPath))
const commandMatch = pickDeepestMatch(commandMatches)
return commandMatch ? toOwner(commandMatch.worktree, 'command') : undefined
}
export function parseLsofListeningOutput(output: string): RawListeningPort[] {
const ports: RawListeningPort[] = []
let currentPid: number | undefined
let currentProcessName: string | undefined
for (const line of output.split('\n')) {
if (!line) {
continue
}
const tag = line[0]
const value = line.slice(1)
if (tag === 'p') {
const pid = Number.parseInt(value, 10)
currentPid = Number.isFinite(pid) ? pid : undefined
currentProcessName = undefined
} else if (tag === 'c') {
currentProcessName = value
} else if (tag === 'n') {
const parsed = parseAddressWithPort(value)
if (parsed) {
ports.push({ pid: currentPid, processName: currentProcessName, ...parsed })
}
}
}
return dedupeRawPorts(ports)
}
export function parseNetstatListeningOutput(output: string): RawListeningPort[] {
const ports: RawListeningPort[] = []
for (const line of output.split('\n')) {
const trimmed = line.trim()
if (!trimmed.toUpperCase().startsWith('TCP')) {
continue
}
const fields = trimmed.split(/\s+/)
const stateIndex = fields.findIndex((field) => field.toUpperCase() === 'LISTENING')
if (stateIndex < 2) {
continue
}
const parsed = parseAddressWithPort(fields[1])
const pid = Number.parseInt(fields[stateIndex + 1] ?? '', 10)
if (!parsed) {
continue
}
ports.push({ ...parsed, pid: Number.isFinite(pid) ? pid : undefined })
}
return dedupeRawPorts(ports)
}
export function parseProcNetTcp(content: string): { host: string; port: number; inode: number }[] {
const results: { host: string; port: number; inode: number }[] = []
const lines = content.split('\n')
for (let i = 1; i < lines.length; i++) {
const fields = lines[i].trim().split(/\s+/)
if (fields.length < 10 || fields[3] !== '0A') {
continue
}
const parsed = parseProcAddress(fields[1])
const inode = Number.parseInt(fields[9], 10)
if (!parsed || !Number.isFinite(inode) || inode === 0) {
continue
}
results.push({ ...parsed, inode })
}
return results
}
async function scanPlatformListeningPorts(): Promise<RawListeningPort[]> {
if (process.platform === 'linux') {
return scanLinuxProcPorts()
}
if (process.platform === 'darwin') {
return scanDarwinLsofPorts()
}
if (process.platform === 'win32') {
return scanWindowsNetstatPorts()
}
throw new Error(`Port scanning is not supported on ${process.platform}`)
}
async function scanDarwinLsofPorts(): Promise<RawListeningPort[]> {
const { stdout } = await runCommand('lsof', ['-nP', '-iTCP', '-sTCP:LISTEN', '-F', 'pcn'])
const ports = parseLsofListeningOutput(stdout)
const metadata = await loadDarwinProcessMetadata(
new Set(ports.flatMap((p) => (p.pid ? [p.pid] : [])))
)
return ports.map((port) => ({ ...metadata.get(port.pid ?? -1), ...port }))
}
async function scanWindowsNetstatPorts(): Promise<RawListeningPort[]> {
const { stdout } = await runCommand('netstat', ['-ano', '-p', 'tcp'])
const ports = parseNetstatListeningOutput(stdout)
const metadata = await loadWindowsProcessMetadata(
new Set(ports.flatMap((p) => (p.pid ? [p.pid] : [])))
)
return ports.map((port) => ({ ...metadata.get(port.pid ?? -1), ...port }))
}
async function scanLinuxProcPorts(): Promise<RawListeningPort[]> {
const [tcp4, tcp6] = await Promise.all([
readProcNet('/proc/net/tcp'),
readProcNet('/proc/net/tcp6')
])
const sockets = [...tcp4, ...tcp6]
const inodeToPid = await mapLinuxInodesToPids(new Set(sockets.map((socket) => socket.inode)))
const metadata = new Map<number, ProcessMetadata>()
const rawPorts: RawListeningPort[] = []
for (const socket of sockets) {
const pid = inodeToPid.get(socket.inode)
if (pid != null && !metadata.has(pid)) {
metadata.set(pid, await loadLinuxProcessMetadata(pid))
}
rawPorts.push({
host: socket.host,
port: socket.port,
pid,
...metadata.get(pid ?? -1)
})
}
return dedupeRawPorts(rawPorts)
}
async function readProcNet(
filePath: string
): Promise<{ host: string; port: number; inode: number }[]> {
try {
return parseProcNetTcp(await readFile(filePath, 'utf-8'))
} catch {
return []
}
}
async function mapLinuxInodesToPids(inodes: Set<number>): Promise<Map<number, number>> {
const result = new Map<number, number>()
let pids: string[]
try {
pids = (await readdir('/proc')).filter((entry) => /^\d+$/.test(entry))
} catch {
return result
}
for (const pidText of pids) {
let fds: string[]
try {
fds = await readdir(`/proc/${pidText}/fd`)
} catch {
continue
}
const pid = Number.parseInt(pidText, 10)
for (const fd of fds) {
let link: string
try {
link = await readlink(`/proc/${pidText}/fd/${fd}`)
} catch {
continue
}
const match = link.match(/^socket:\[(\d+)\]$/)
if (!match) {
continue
}
const inode = Number.parseInt(match[1], 10)
if (inodes.has(inode)) {
result.set(inode, pid)
}
}
}
return result
}
async function loadLinuxProcessMetadata(pid: number): Promise<ProcessMetadata> {
const [comm, cmdline, cwd] = await Promise.all([
readTextIfAvailable(`/proc/${pid}/comm`),
readTextIfAvailable(`/proc/${pid}/cmdline`),
readlink(`/proc/${pid}/cwd`).catch(() => undefined)
])
return {
processName: comm?.trim() || undefined,
commandLine: cmdline?.split('\u0000').join(' ').trim() || undefined,
cwd
}
}
async function loadDarwinProcessMetadata(pids: Set<number>): Promise<Map<number, ProcessMetadata>> {
const result = new Map<number, ProcessMetadata>()
const pidList = Array.from(pids).join(',')
if (!pidList) {
return result
}
const [cwdOutput, commandOutput] = await Promise.all([
runCommand('lsof', ['-a', '-p', pidList, '-d', 'cwd', '-Fn']).catch(() => null),
runCommand('ps', ['-p', pidList, '-o', 'pid=', '-o', 'command=']).catch(() => null)
])
let currentPid: number | null = null
for (const line of cwdOutput?.stdout.split('\n') ?? []) {
if (line.startsWith('p')) {
const pid = Number.parseInt(line.slice(1), 10)
currentPid = Number.isFinite(pid) ? pid : null
} else if (line.startsWith('n') && currentPid != null) {
result.set(currentPid, { ...result.get(currentPid), cwd: line.slice(1) || undefined })
}
}
for (const line of commandOutput?.stdout.split('\n') ?? []) {
const match = line.match(/^\s*(\d+)\s+(.+)$/)
if (!match) {
continue
}
const pid = Number.parseInt(match[1], 10)
result.set(pid, { ...result.get(pid), commandLine: match[2].trim() || undefined })
}
return result
}
async function loadWindowsProcessMetadata(
pids: Set<number>
): Promise<Map<number, ProcessMetadata>> {
const result = new Map<number, ProcessMetadata>()
if (pids.size === 0) {
return result
}
try {
const pidFilter = Array.from(pids)
.filter(Number.isFinite)
.map((pid) => `ProcessId=${pid}`)
.join(' OR ')
const { stdout } = await runCommand('powershell.exe', [
'-NoProfile',
'-Command',
`Get-CimInstance Win32_Process -Filter "${pidFilter}" | Select-Object ProcessId,Name,CommandLine | ConvertTo-Json -Compress`
])
const parsed = JSON.parse(stdout) as
| { ProcessId: number; Name?: string; CommandLine?: string }
| { ProcessId: number; Name?: string; CommandLine?: string }[]
for (const row of Array.isArray(parsed) ? parsed : [parsed]) {
if (pids.has(row.ProcessId)) {
result.set(row.ProcessId, {
processName: row.Name,
commandLine: row.CommandLine
})
}
}
} catch {
// Process metadata is optional; port rows still render without attribution.
}
return result
}
async function runCommand(command: string, args: string[]): Promise<{ stdout: string }> {
const { stdout } = await execFileAsync(command, args, {
timeout: COMMAND_TIMEOUT_MS,
maxBuffer: 2 * 1024 * 1024,
windowsHide: true
})
return { stdout: String(stdout) }
}
async function readTextIfAvailable(filePath: string): Promise<string | undefined> {
try {
return await readFile(filePath, 'utf-8')
} catch {
return undefined
}
}
function enrichPort(port: RawListeningPort, worktrees: WorkspacePortProbe[]): WorkspacePort {
const owner = attributePortToWorkspace(port, worktrees)
const base = {
id: `${port.host}:${port.port}:${port.pid ?? 'unknown'}`,
bindHost: port.host,
connectHost: connectHostForBindHost(port.host),
port: port.port,
pid: port.pid,
processName: port.processName,
protocol: inferProtocol(port.port)
}
if (owner) {
return { ...base, kind: 'workspace', owner }
}
if (isContainerProcess(port)) {
return { ...base, kind: 'container' }
}
return { ...base, kind: 'external' }
}
function compareWorkspacePorts(a: WorkspacePort, b: WorkspacePort): number {
const aRank = a.kind === 'workspace' ? 0 : a.kind === 'container' ? 1 : 2
const bRank = b.kind === 'workspace' ? 0 : b.kind === 'container' ? 1 : 2
return aRank - bRank || a.port - b.port || a.connectHost.localeCompare(b.connectHost)
}
function inferProtocol(port: number): 'http' | 'https' | 'unknown' {
if (HTTPS_PORTS.has(port)) {
return 'https'
}
if (HTTP_PORTS.has(port)) {
return 'http'
}
return 'unknown'
}
export function isContainerProcess(
port: Pick<RawListeningPort, 'processName' | 'commandLine'>
): boolean {
const haystack = `${port.processName ?? ''} ${port.commandLine ?? ''}`.toLowerCase()
return /\b(com\.[\w.-]+\.backend|com\.container\w*|container\w*)\b/.test(haystack)
}
function toOwner(
worktree: WorkspacePortProbe,
confidence: WorkspacePortOwner['confidence']
): WorkspacePortOwner {
return {
worktreeId: worktree.id,
repoId: worktree.repoId,
displayName: worktree.displayName,
path: worktree.path,
confidence
}
}
function pickDeepestMatch<T extends { normalizedPath: string }>(matches: T[]): T | undefined {
return matches.sort((a, b) => b.normalizedPath.length - a.normalizedPath.length)[0]
}
function isSameOrDescendant(candidate: string, parent: string): boolean {
return candidate === parent || candidate.startsWith(`${parent}/`)
}
function includesPathBoundary(commandLine: string, normalizedPath: string): boolean {
let index = commandLine.indexOf(normalizedPath)
while (index !== -1) {
const before = index === 0 ? '' : commandLine[index - 1]
const after = commandLine[index + normalizedPath.length] ?? ''
const startsOnBoundary = before === '' || /\s|["'=]/.test(before)
const endsOnBoundary = after === '' || /[\s"'/:]/.test(after)
if (startsOnBoundary && endsOnBoundary) {
return true
}
index = commandLine.indexOf(normalizedPath, index + normalizedPath.length)
}
return false
}
function normalizeComparablePath(input: string): string {
return normalizeComparableText(path.resolve(input))
}
function normalizeComparableText(input: string): string {
const normalized = input.replace(/\\/g, '/').replace(/\/+/g, '/')
return process.platform === 'win32' ? normalized.toLowerCase() : normalized
}
function connectHostForBindHost(host: string): string {
if (host === '*' || host === '0.0.0.0' || host === '::') {
return 'localhost'
}
return host
}
function dedupeRawPorts(ports: RawListeningPort[]): RawListeningPort[] {
const seen = new Set<string>()
const result: RawListeningPort[] = []
for (const port of ports) {
const key = `${connectHostForBindHost(port.host)}:${port.port}:${port.pid ?? 'unknown'}`
if (seen.has(key)) {
continue
}
seen.add(key)
result.push(port)
}
return result
}
function parseAddressWithPort(value: string): { host: string; port: number } | null {
const trimmed = value.trim().replace(/\s+\(LISTEN\)$/i, '')
const bracketed = trimmed.match(/^\[([^\]]+)\]:(\d+)$/)
if (bracketed) {
return { host: bracketed[1], port: Number.parseInt(bracketed[2], 10) }
}
const match = trimmed.match(/^(.+):(\d+)$/)
if (!match) {
return null
}
const port = Number.parseInt(match[2], 10)
if (!Number.isFinite(port) || port <= 0 || port > 65535) {
return null
}
return { host: match[1], port }
}
function parseProcAddress(hexAddress: string): { host: string; port: number } | null {
const [addrHex, portHex] = hexAddress.split(':')
const port = Number.parseInt(portHex, 16)
if (!Number.isFinite(port) || port === 0) {
return null
}
if (addrHex.length === 8) {
const bytes = [6, 4, 2, 0].map((index) => Number.parseInt(addrHex.slice(index, index + 2), 16))
return { host: bytes.join('.'), port }
}
if (addrHex.length === 32) {
if (addrHex === '00000000000000000000000000000000') {
return { host: '::', port }
}
if (addrHex === '00000000000000000000000001000000') {
return { host: '::1', port }
}
return { host: formatIPv6Address(addrHex), port }
}
return null
}
function formatIPv6Address(hex: string): string {
const groups: string[] = []
for (let i = 0; i < 32; i += 8) {
const chunk = hex.slice(i, i + 8)
const reversed = chunk.slice(6, 8) + chunk.slice(4, 6) + chunk.slice(2, 4) + chunk.slice(0, 2)
groups.push(reversed.slice(0, 4), reversed.slice(4, 8))
}
return groups.map((group) => group.replace(/^0+/, '') || '0').join(':')
}

View File

@ -0,0 +1,102 @@
import path from 'path'
import type { Store } from '../persistence'
import { splitWorktreeId } from '../../shared/worktree-id'
import type {
WorkspacePortKillRequest,
WorkspacePortKillResult,
WorkspacePortProbe,
WorkspacePortScanResult
} from '../../shared/workspace-ports'
import { scanWorkspacePorts } from './local-workspace-port-scanner'
export type WorkspacePortProbeInput = WorkspacePortProbe & {
connectionId?: string | null
}
export function getStoreWorkspacePortProbes(
store: Pick<Store, 'getRepos' | 'getAllWorktreeMeta'>,
repoId?: string
): WorkspacePortProbe[] {
const reposById = new Map(store.getRepos().map((repo) => [repo.id, repo]))
return Object.entries(store.getAllWorktreeMeta()).flatMap(([worktreeId, meta]) => {
const parsed = splitWorktreeId(worktreeId)
if (!parsed || (repoId && parsed.repoId !== repoId)) {
return []
}
const repo = reposById.get(parsed.repoId)
if (!repo || repo.connectionId) {
return []
}
return [
{
id: worktreeId,
repoId: parsed.repoId,
displayName: meta.displayName || path.basename(parsed.worktreePath),
path: parsed.worktreePath
}
]
})
}
export function filterWorkspacePortProbes(
worktrees: readonly WorkspacePortProbeInput[],
repoId?: string
): WorkspacePortProbe[] {
return worktrees.flatMap((worktree) => {
if ((repoId && worktree.repoId !== repoId) || worktree.connectionId) {
return []
}
return [
{
id: worktree.id,
repoId: worktree.repoId,
displayName: worktree.displayName || path.basename(worktree.path),
path: worktree.path
}
]
})
}
export async function killWorkspacePort(
worktrees: readonly WorkspacePortProbe[],
args: WorkspacePortKillRequest
): Promise<WorkspacePortKillResult> {
if (!Number.isSafeInteger(args.pid) || args.pid <= 0 || !Number.isSafeInteger(args.port)) {
return { ok: false, reason: 'Invalid process or port.' }
}
const scan = await scanWorkspacePorts([...worktrees])
const port = scan.ports.find(
(candidate) => candidate.pid === args.pid && candidate.port === args.port
)
if (!port) {
return { ok: false, reason: 'The port is no longer listening.' }
}
if (port.kind !== 'workspace') {
return { ok: false, reason: 'Only workspace-owned local processes can be stopped here.' }
}
const pid = port.pid
if (!pid) {
return { ok: false, reason: 'The owning process is unknown.' }
}
if (pid === process.pid) {
return { ok: false, reason: 'Orca cannot stop its own process.' }
}
try {
// Why: caller-supplied pids are not trusted; the re-scan above proves
// this pid still owns the requested workspace listener before SIGTERM.
process.kill(pid, 'SIGTERM')
return { ok: true }
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
return { ok: false, reason: message || 'Failed to stop the process.' }
}
}
export async function scanWorkspacePortProbes(
worktrees: readonly WorkspacePortProbe[]
): Promise<WorkspacePortScanResult> {
return scanWorkspacePorts([...worktrees])
}

View File

@ -448,6 +448,7 @@ describe('OrcaRuntimeService', () => {
expect(status.runtimeProtocolVersion).toBe(status.protocolVersion)
expect(status.minCompatibleRuntimeClientVersion).toBe(status.minCompatibleMobileVersion)
expect(status.capabilities).toContain('terminal.binary-stream.v1')
expect(status.capabilities).toContain('workspace-ports.v1')
expect(typeof status.protocolVersion).toBe('number')
expect(typeof status.minCompatibleMobileVersion).toBe('number')
expect(status.protocolVersion).toBeGreaterThanOrEqual(1)

View File

@ -58,6 +58,17 @@ import {
RUNTIME_CAPABILITIES,
RUNTIME_PROTOCOL_VERSION
} from '../../shared/protocol-version'
import type {
WorkspacePortKillRequest,
WorkspacePortKillResult,
WorkspacePortProbe,
WorkspacePortScanResult
} from '../../shared/workspace-ports'
import {
filterWorkspacePortProbes,
killWorkspacePort,
scanWorkspacePortProbes
} from '../ports/workspace-port-ownership'
import type {
RuntimeGraphStatus,
RuntimeRepoSearchRefs,
@ -5653,6 +5664,34 @@ export class OrcaRuntimeService {
return await this.resolveWorktreeSelector(worktreeSelector)
}
async scanWorkspacePorts(repoId?: string): Promise<WorkspacePortScanResult> {
return scanWorkspacePortProbes(await this.getWorkspacePortProbes(repoId))
}
async killWorkspacePort(args: WorkspacePortKillRequest): Promise<WorkspacePortKillResult> {
return killWorkspacePort(await this.getWorkspacePortProbes(args.repoId), args)
}
// Why: remote clients may invoke this over RPC, so the runtime derives
// allowed worktree paths from its own store instead of trusting client paths.
private async getWorkspacePortProbes(repoId?: string): Promise<WorkspacePortProbe[]> {
const reposById = new Map(
this.requireStore()
.getRepos()
.map((repo) => [repo.id, repo])
)
return filterWorkspacePortProbes(
(await this.listResolvedWorktrees()).map((worktree) => ({
id: worktree.id,
repoId: worktree.repoId,
displayName: worktree.displayName,
path: worktree.git.path,
connectionId: reposById.get(worktree.repoId)?.connectionId ?? null
})),
repoId
)
}
async sleepManagedWorktree(worktreeSelector: string): Promise<{ worktreeId: string }> {
const worktree = await this.resolveWorktreeSelector(worktreeSelector)
// Why: sleep is renderer-initiated on desktop (it tears down tab state

View File

@ -21,6 +21,7 @@ import { HOSTED_REVIEW_METHODS } from './hosted-review'
import { LINEAR_METHODS } from './linear'
import { SPEECH_METHODS } from './speech'
import { CLIENT_UI_METHODS } from './client-ui'
import { WORKSPACE_PORT_METHODS } from './workspace-ports'
// Why: a flat manifest keeps registration order explicit and provides one
// grep-point for "what methods does the RPC server expose?" — useful when
@ -47,5 +48,6 @@ export const ALL_RPC_METHODS: readonly RpcAnyMethod[] = [
...HOSTED_REVIEW_METHODS,
...LINEAR_METHODS,
...SPEECH_METHODS,
...WORKSPACE_PORT_METHODS,
...CLIENT_UI_METHODS
]

View File

@ -0,0 +1,55 @@
import { describe, expect, it, vi } from 'vitest'
import { RpcDispatcher } from '../dispatcher'
import type { RpcRequest } from '../core'
import type { OrcaRuntimeService } from '../../orca-runtime'
import { WORKSPACE_PORT_METHODS } from './workspace-ports'
import type { WorkspacePortScanResult } from '../../../../shared/workspace-ports'
function makeRequest(method: string, params?: unknown): RpcRequest {
return { id: 'req-1', authToken: 'tok', method, params }
}
describe('workspace port RPC methods', () => {
it('scans workspace ports on the runtime host', async () => {
const scan: WorkspacePortScanResult = {
platform: process.platform,
scannedAt: 123,
ports: []
}
const runtime = {
getRuntimeId: () => 'test-runtime',
scanWorkspacePorts: vi.fn().mockResolvedValue(scan)
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: WORKSPACE_PORT_METHODS })
const response = await dispatcher.dispatch(
makeRequest('workspacePorts.scan', { repoId: 'repo-1' })
)
expect(runtime.scanWorkspacePorts).toHaveBeenCalledWith('repo-1')
expect(response).toMatchObject({ ok: true, result: scan })
})
it('kills a workspace-owned port on the runtime host', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
killWorkspacePort: vi.fn().mockResolvedValue({ ok: true })
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: WORKSPACE_PORT_METHODS })
const response = await dispatcher.dispatch(
makeRequest('workspacePorts.kill', {
repoId: 'repo-1',
pid: 1234,
port: 5173
})
)
expect(runtime.killWorkspacePort).toHaveBeenCalledWith({
repoId: 'repo-1',
pid: 1234,
port: 5173
})
expect(response).toMatchObject({ ok: true, result: { ok: true } })
})
})

View File

@ -0,0 +1,31 @@
import { z } from 'zod'
import { defineMethod, type RpcMethod } from '../core'
import { OptionalString, requiredNumber } from '../schemas'
const WorkspacePortScanParams = z.object({
repoId: OptionalString
})
const WorkspacePortKillParams = z.object({
repoId: OptionalString,
pid: requiredNumber('Missing process id'),
port: requiredNumber('Missing port')
})
export const WORKSPACE_PORT_METHODS: RpcMethod[] = [
defineMethod({
name: 'workspacePorts.scan',
params: WorkspacePortScanParams,
handler: async (params, { runtime }) => runtime.scanWorkspacePorts(params.repoId)
}),
defineMethod({
name: 'workspacePorts.kill',
params: WorkspacePortKillParams,
handler: async (params, { runtime }) =>
runtime.killWorkspacePort({
repoId: params.repoId,
pid: params.pid,
port: params.port
})
})
]

View File

@ -221,6 +221,12 @@ import type {
WorkspaceSpaceAnalyzeResult,
WorkspaceSpaceScanProgress
} from '../shared/workspace-space-types'
import type {
WorkspacePortKillRequest,
WorkspacePortKillResult,
WorkspacePortScanRequest,
WorkspacePortScanResult
} from '../shared/workspace-ports'
import type { GhAuthDiagnostic } from '../shared/github-auth-types'
import type {
SshConnectionState,
@ -658,6 +664,10 @@ export type PreloadApi = {
cancel: () => Promise<boolean>
onProgress: (callback: (progress: WorkspaceSpaceScanProgress) => void) => () => void
}
workspacePorts: {
scan: (args: WorkspacePortScanRequest) => Promise<WorkspacePortScanResult>
kill: (args: WorkspacePortKillRequest) => Promise<WorkspacePortKillResult>
}
pty: {
spawn: (opts: {
cols: number

View File

@ -55,6 +55,12 @@ import type {
WorkspaceSpaceAnalyzeResult,
WorkspaceSpaceScanProgress
} from '../shared/workspace-space-types'
import type {
WorkspacePortKillRequest,
WorkspacePortKillResult,
WorkspacePortScanRequest,
WorkspacePortScanResult
} from '../shared/workspace-ports'
import type { GhAuthDiagnostic } from '../shared/github-auth-types'
import type {
AddIssueCommentBySlugArgs,
@ -553,6 +559,13 @@ const api = {
}
},
workspacePorts: {
scan: (args: WorkspacePortScanRequest): Promise<WorkspacePortScanResult> =>
ipcRenderer.invoke('workspacePorts:scan', args),
kill: (args: WorkspacePortKillRequest): Promise<WorkspacePortKillResult> =>
ipcRenderer.invoke('workspacePorts:kill', args)
},
pty: {
spawn: (opts: {
cols: number

View File

@ -0,0 +1,174 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION,
RUNTIME_PROTOCOL_VERSION
} from '../../../../shared/protocol-version'
import type { WorkspacePort, WorkspacePortScanResult } from '../../../../shared/workspace-ports'
import { clearRuntimeCompatibilityCacheForTests } from '@/runtime/runtime-rpc-client'
const { activateAndRevealWorktreeMock } = vi.hoisted(() => ({
activateAndRevealWorktreeMock: vi.fn()
}))
vi.mock('@/lib/worktree-activation', () => ({
activateAndRevealWorktree: activateAndRevealWorktreeMock
}))
import {
browserUrlForPort,
killWorkspacePortForTarget,
openWorkspacePortInBrowser,
scanWorkspacePortsForTarget
} from './PortsPanel'
const workspacePort: WorkspacePort = {
id: '127.0.0.1:63468:1234',
bindHost: '127.0.0.1',
connectHost: '127.0.0.1',
port: 63468,
pid: 1234,
processName: 'node',
protocol: 'unknown',
kind: 'workspace',
owner: {
worktreeId: 'repo::/workspace/app',
repoId: 'repo',
displayName: 'app',
path: '/workspace/app',
confidence: 'cwd'
}
}
const emptyScan: WorkspacePortScanResult = {
platform: process.platform,
scannedAt: 1,
ports: []
}
const compatibleStatus = {
runtimeId: 'runtime-1',
graphStatus: 'ready',
runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION,
minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION
}
const localScan = vi.fn()
const localKill = vi.fn()
const runtimeCall = vi.fn()
const runtimeEnvironmentCall = vi.fn()
beforeEach(() => {
localScan.mockReset()
localKill.mockReset()
runtimeCall.mockReset()
runtimeEnvironmentCall.mockReset()
activateAndRevealWorktreeMock.mockReset()
clearRuntimeCompatibilityCacheForTests()
vi.stubGlobal('window', {
api: {
workspacePorts: {
scan: localScan,
kill: localKill
},
runtime: {
call: runtimeCall
},
runtimeEnvironments: {
call: runtimeEnvironmentCall
}
}
})
})
describe('PortsPanel runtime routing', () => {
it('uses local IPC for local workspace port scans and kills', async () => {
localScan.mockResolvedValueOnce(emptyScan)
localKill.mockResolvedValueOnce({ ok: true })
await expect(scanWorkspacePortsForTarget({ kind: 'local' }, 'repo')).resolves.toBe(emptyScan)
await expect(
killWorkspacePortForTarget({ kind: 'local' }, { repoId: 'repo', pid: 1234, port: 63468 })
).resolves.toEqual({ ok: true })
expect(localScan).toHaveBeenCalledWith({ repoId: 'repo' })
expect(localKill).toHaveBeenCalledWith({ repoId: 'repo', pid: 1234, port: 63468 })
expect(runtimeEnvironmentCall).not.toHaveBeenCalled()
})
it('routes remote scans through runtime RPC and degrades on older runtimes', async () => {
runtimeEnvironmentCall.mockImplementation(({ method }: { method: string }) =>
Promise.resolve(
method === 'status.get'
? { id: method, ok: true, result: compatibleStatus, _meta: { runtimeId: 'runtime-1' } }
: {
id: method,
ok: false,
error: { code: 'method_not_found', message: 'Unknown method' },
_meta: { runtimeId: 'runtime-1' }
}
)
)
const result = await scanWorkspacePortsForTarget(
{ kind: 'environment', environmentId: 'env-1' },
'repo'
)
expect(result).toMatchObject({
ports: [],
unavailableReason: 'The connected runtime does not support workspace port management yet.'
})
expect(runtimeEnvironmentCall.mock.calls.map((call) => call[0].method)).toEqual([
'status.get',
'workspacePorts.scan'
])
})
it('opens remote workspace ports in the server-side browser and binds the local page handle', async () => {
runtimeEnvironmentCall.mockImplementation(({ method }: { method: string }) =>
Promise.resolve({
id: method,
ok: true,
result:
method === 'status.get' ? compatibleStatus : { browserPageId: 'remote-browser-page-1' },
_meta: { runtimeId: 'runtime-1' }
})
)
const createBrowserTab = vi.fn(() => ({ activePageId: 'local-page-1' }))
const setRemoteBrowserPageHandle = vi.fn()
await expect(
openWorkspacePortInBrowser({
port: workspacePort,
runtimeTarget: { kind: 'environment', environmentId: 'env-1' },
createBrowserTab: createBrowserTab as never,
setRemoteBrowserPageHandle: setRemoteBrowserPageHandle as never
})
).resolves.toEqual({ ok: true })
expect(activateAndRevealWorktreeMock).toHaveBeenCalledWith('repo::/workspace/app')
expect(runtimeEnvironmentCall.mock.calls.map((call) => call[0].method)).toEqual([
'status.get',
'browser.tabCreate'
])
expect(runtimeEnvironmentCall.mock.calls[1][0].params).toEqual({
worktree: 'id:repo::/workspace/app',
url: 'http://127.0.0.1:63468'
})
expect(createBrowserTab).toHaveBeenCalledWith(
'repo::/workspace/app',
'http://127.0.0.1:63468',
{
activate: true
}
)
expect(setRemoteBrowserPageHandle).toHaveBeenCalledWith('local-page-1', {
environmentId: 'env-1',
remotePageId: 'remote-browser-page-1'
})
})
it('defaults unknown protocols to http for built-in browser opens', () => {
expect(browserUrlForPort(workspacePort)).toBe('http://127.0.0.1:63468')
})
})

View File

@ -1,10 +1,30 @@
/* oxlint-disable max-lines -- Why: co-locates forwarded list, detected list, modal form, and
per-entry actions in one file to keep the data flow straightforward. */
import React, { useCallback, useMemo, useState } from 'react'
import { ExternalLink, Copy, Trash2, Plus, Unplug, ChevronRight, Pencil } from 'lucide-react'
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
ExternalLink,
Copy,
Trash2,
Plus,
Unplug,
ChevronRight,
Pencil,
RefreshCw,
Server,
Box,
Info
} from 'lucide-react'
import { toast } from 'sonner'
import { useAppStore } from '@/store'
import { useActiveWorktree, useRepoById } from '@/store/selectors'
import { cn } from '@/lib/utils'
import {
callRuntimeRpc,
getActiveRuntimeTarget,
RuntimeRpcCallError,
type RuntimeClientTarget
} from '@/runtime/runtime-rpc-client'
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
import {
Dialog,
DialogContent,
@ -13,7 +33,28 @@ import {
DialogDescription
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuTrigger
} from '@/components/ui/context-menu'
import type { PortForwardEntry, DetectedPort } from '../../../../shared/ssh-types'
import type {
WorkspacePort,
WorkspacePortKillResult,
WorkspacePortScanResult
} from '../../../../shared/workspace-ports'
const LOCAL_PORT_SCAN_INTERVAL_MS = 5_000
const LOCAL_PORT_MENU_CONTENT_CLASS =
'!rounded-md !border-border/60 !bg-popover !text-popover-foreground !shadow-[0_10px_24px_rgba(0,0,0,0.18)] !backdrop-blur-none'
const LOCAL_PORT_MENU_ITEM_CLASS =
'rounded-md focus:bg-accent focus:text-accent-foreground dark:focus:bg-accent'
const LOCAL_PORT_MENU_LABEL_CLASS = 'px-2 py-1 text-[11px] font-semibold text-muted-foreground'
// Why: ports < 1024 require root to bind on the local machine. Remap them
// to a high port so the default "Forward" action doesn't fail with EACCES.
@ -24,7 +65,6 @@ function safeLocalPort(remotePort: number): number {
return remotePort
}
const HTTP_PORTS = new Set([80, 443, 3000, 3001, 4200, 5000, 5173, 5174, 8000, 8080, 8443, 8888])
const HTTPS_PORTS = new Set([443, 8443])
// Why: the scanner reports numeric addresses (127.0.0.1, 0.0.0.0, ::1, ::)
@ -38,6 +78,118 @@ function normalizeHost(host: string | undefined): string {
return host
}
function hostForLocalAction(host: string): string {
if (!host) {
return 'localhost'
}
return host.includes(':') ? `[${host}]` : host
}
function addressForPort(port: WorkspacePort): string {
return `${hostForLocalAction(port.connectHost)}:${port.port}`
}
export function browserUrlForPort(port: WorkspacePort): string {
const protocol = port.protocol === 'https' ? 'https' : 'http'
return `${protocol}://${addressForPort(port)}`
}
type BrowserTabCreator = ReturnType<typeof useAppStore.getState>['createBrowserTab']
type RemoteBrowserPageHandleSetter = ReturnType<
typeof useAppStore.getState
>['setRemoteBrowserPageHandle']
export async function openWorkspacePortInBrowser(args: {
port: WorkspacePort
activeWorktreeId?: string | null
runtimeTarget: RuntimeClientTarget
createBrowserTab: BrowserTabCreator
setRemoteBrowserPageHandle: RemoteBrowserPageHandleSetter
}): Promise<{ ok: true } | { ok: false; reason: string }> {
const worktreeId =
args.port.kind === 'workspace' ? args.port.owner.worktreeId : args.activeWorktreeId
if (!worktreeId) {
return { ok: false, reason: 'No workspace selected for the browser.' }
}
const url = browserUrlForPort(args.port)
activateAndRevealWorktree(worktreeId)
if (args.runtimeTarget.kind === 'environment') {
try {
const remotePage = await callRuntimeRpc<{ browserPageId: string }>(
args.runtimeTarget,
'browser.tabCreate',
{ worktree: `id:${worktreeId}`, url },
{ timeoutMs: 30_000 }
)
const tab = args.createBrowserTab(worktreeId, url, { activate: true })
if (!tab.activePageId) {
return { ok: false, reason: 'Failed to create a browser page.' }
}
args.setRemoteBrowserPageHandle(tab.activePageId, {
environmentId: args.runtimeTarget.environmentId,
remotePageId: remotePage.browserPageId
})
return { ok: true }
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
return { ok: false, reason: message || 'Failed to open remote browser.' }
}
}
args.createBrowserTab(worktreeId, url, { activate: true })
return { ok: true }
}
function runtimeTargetKey(target: RuntimeClientTarget): string {
return target.kind === 'local' ? 'local' : `environment:${target.environmentId}`
}
export async function scanWorkspacePortsForTarget(
target: RuntimeClientTarget,
repoId: string
): Promise<WorkspacePortScanResult> {
const params = { repoId }
if (target.kind === 'local') {
return window.api.workspacePorts.scan(params)
}
try {
return await callRuntimeRpc<WorkspacePortScanResult>(target, 'workspacePorts.scan', params, {
timeoutMs: 15_000
})
} catch (error) {
if (error instanceof RuntimeRpcCallError && error.code === 'method_not_found') {
return {
platform: 'unknown',
scannedAt: Date.now(),
ports: [],
unavailableReason: 'The connected runtime does not support workspace port management yet.'
}
}
throw error
}
}
export async function killWorkspacePortForTarget(
target: RuntimeClientTarget,
args: { repoId: string; pid: number; port: number }
): Promise<WorkspacePortKillResult> {
if (target.kind === 'local') {
return window.api.workspacePorts.kill(args)
}
try {
return await callRuntimeRpc<WorkspacePortKillResult>(target, 'workspacePorts.kill', args, {
timeoutMs: 15_000
})
} catch (error) {
if (error instanceof RuntimeRpcCallError && error.code === 'method_not_found') {
return {
ok: false,
reason: 'The connected runtime does not support workspace port management yet.'
}
}
throw error
}
}
type PortForwardDialogState =
| { mode: 'closed' }
| {
@ -46,10 +198,559 @@ type PortForwardDialogState =
}
| { mode: 'edit'; entry: PortForwardEntry }
export default function PortsPanel(): React.JSX.Element {
export default function PortsPanel({ isVisible }: { isVisible: boolean }): React.JSX.Element {
const activeWorktree = useActiveWorktree()
const activeRepo = useRepoById(activeWorktree?.repoId ?? null)
if (activeRepo?.connectionId) {
return <SshPortsPanel />
}
return <LocalWorkspacePortsPanel isVisible={isVisible} />
}
function LocalWorkspacePortsPanel({ isVisible }: { isVisible: boolean }): React.JSX.Element {
const activeWorktree = useActiveWorktree()
const activeRepo = useRepoById(activeWorktree?.repoId ?? null)
const settings = useAppStore((s) => s.settings)
const createBrowserTab = useAppStore((s) => s.createBrowserTab)
const setRemoteBrowserPageHandle = useAppStore((s) => s.setRemoteBrowserPageHandle)
const [scan, setScan] = useState<{ key: string; result: WorkspacePortScanResult } | null>(null)
const [refreshing, setRefreshing] = useState(false)
const [detailsPort, setDetailsPort] = useState<WorkspacePort | null>(null)
const [collapsedSections, setCollapsedSections] = useState<Record<string, boolean>>({
other: true,
external: true
})
const inFlightScanRef = useRef<Promise<void> | null>(null)
const inFlightScanKeyRef = useRef<string | null>(null)
const scanGenerationRef = useRef(0)
const runtimeTarget = useMemo(() => getActiveRuntimeTarget(settings), [settings])
const scanKey = `${runtimeTargetKey(runtimeTarget)}:${activeRepo?.id ?? ''}`
const refresh = useCallback(() => {
if (!activeRepo) {
setScan(null)
return Promise.resolve()
}
if (inFlightScanRef.current && inFlightScanKeyRef.current === scanKey) {
return inFlightScanRef.current
}
const generation = scanGenerationRef.current
setRefreshing(true)
const promise = scanWorkspacePortsForTarget(runtimeTarget, activeRepo.id)
.then((nextScan) => {
if (generation === scanGenerationRef.current) {
setScan({ key: scanKey, result: nextScan })
}
})
.finally(() => {
if (inFlightScanRef.current === promise) {
inFlightScanRef.current = null
inFlightScanKeyRef.current = null
}
if (generation === scanGenerationRef.current) {
setRefreshing(false)
}
})
inFlightScanRef.current = promise
inFlightScanKeyRef.current = scanKey
return promise
}, [activeRepo, runtimeTarget, scanKey])
useEffect(() => {
let cancelled = false
scanGenerationRef.current += 1
if (!isVisible) {
inFlightScanRef.current = null
inFlightScanKeyRef.current = null
setScan(null)
setRefreshing(false)
return () => {
cancelled = true
scanGenerationRef.current += 1
}
}
async function run(): Promise<void> {
await refresh()
if (!cancelled) {
timeout = setTimeout(() => void run(), LOCAL_PORT_SCAN_INTERVAL_MS)
}
}
let timeout: ReturnType<typeof setTimeout> | null = null
setScan(null)
void run()
return () => {
cancelled = true
scanGenerationRef.current += 1
inFlightScanRef.current = null
inFlightScanKeyRef.current = null
if (timeout) {
clearTimeout(timeout)
}
}
}, [isVisible, refresh])
const displayScan = scan?.key === scanKey && isVisible ? scan.result : null
const toggleSection = useCallback((sectionId: string) => {
setCollapsedSections((current) => ({ ...current, [sectionId]: !current[sectionId] }))
}, [])
const handleStopPort = useCallback(
async (port: WorkspacePort) => {
if (!activeRepo || !port.pid) {
return
}
const result = await killWorkspacePortForTarget(runtimeTarget, {
repoId: activeRepo.id,
pid: port.pid,
port: port.port
})
if (!result.ok) {
toast.error(result.reason)
return
}
toast.success(`Stopped process on :${port.port}`)
await refresh()
},
[activeRepo, refresh, runtimeTarget]
)
const handleOpenPortInBrowser = useCallback(
async (port: WorkspacePort) => {
const result = await openWorkspacePortInBrowser({
port,
activeWorktreeId: activeWorktree?.id,
runtimeTarget,
createBrowserTab,
setRemoteBrowserPageHandle
})
if (!result.ok) {
toast.error('Failed to open browser', { description: result.reason })
}
},
[activeWorktree?.id, createBrowserTab, runtimeTarget, setRemoteBrowserPageHandle]
)
const activePorts = useMemo(
() =>
(displayScan?.ports ?? []).filter(
(port) => port.kind === 'workspace' && port.owner.worktreeId === activeWorktree?.id
),
[activeWorktree?.id, displayScan?.ports]
)
const otherWorkspacePorts = useMemo(
() =>
(displayScan?.ports ?? []).filter(
(port) => port.kind === 'workspace' && port.owner.worktreeId !== activeWorktree?.id
),
[activeWorktree?.id, displayScan?.ports]
)
const externalPorts = useMemo(
() => (displayScan?.ports ?? []).filter((port) => port.kind !== 'workspace'),
[displayScan?.ports]
)
if (!activeRepo) {
return (
<div className="flex flex-col items-center justify-center h-full px-4 text-center text-muted-foreground">
<Server size={32} className="mb-3 opacity-50" />
<p className="text-sm">No workspace selected</p>
</div>
)
}
return (
<div className="flex flex-col h-full overflow-y-auto scrollbar-sleek">
<div className="flex items-center justify-between px-3 py-2 border-b border-border">
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
Ports
</span>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
className="text-muted-foreground hover:text-foreground"
onClick={() => void refresh()}
disabled={refreshing}
aria-label="Refresh Ports"
>
<RefreshCw size={14} className={cn(refreshing && 'animate-spin')} />
</Button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
Refresh Ports
</TooltipContent>
</Tooltip>
</div>
{displayScan?.unavailableReason && (
<div className="px-3 py-2 text-xs text-muted-foreground border-b border-border">
Port scan unavailable on {displayScan.platform}: {displayScan.unavailableReason}
</div>
)}
{!displayScan?.unavailableReason && (
<>
<LocalPortSection
id="active"
title="Active Workspace"
ports={activePorts}
emptyText={refreshing && !displayScan ? 'Scanning...' : 'No ports detected'}
collapsed={collapsedSections.active ?? false}
onToggle={() => toggleSection('active')}
onStopPort={(port) => void handleStopPort(port)}
onShowDetails={setDetailsPort}
onOpenInBrowser={handleOpenPortInBrowser}
/>
<LocalPortSection
id="other"
title="Other Workspaces"
ports={otherWorkspacePorts}
collapsed={collapsedSections.other ?? false}
onToggle={() => toggleSection('other')}
onStopPort={(port) => void handleStopPort(port)}
onShowDetails={setDetailsPort}
onOpenInBrowser={handleOpenPortInBrowser}
/>
<LocalPortSection
id="external"
title="External"
ports={externalPorts}
collapsed={collapsedSections.external ?? false}
onToggle={() => toggleSection('external')}
onStopPort={(port) => void handleStopPort(port)}
onShowDetails={setDetailsPort}
onOpenInBrowser={handleOpenPortInBrowser}
/>
</>
)}
{!displayScan?.unavailableReason &&
displayScan &&
activePorts.length === 0 &&
otherWorkspacePorts.length === 0 &&
externalPorts.length === 0 && (
<div className="flex flex-col items-center justify-center flex-1 px-4 text-center text-muted-foreground">
<Server size={32} className="mb-3 opacity-50" />
<p className="text-sm">No local ports detected</p>
</div>
)}
<LocalPortDetailsDialog port={detailsPort} onClose={() => setDetailsPort(null)} />
</div>
)
}
function LocalPortSection({
id,
title,
ports,
emptyText,
collapsed,
onToggle,
onStopPort,
onShowDetails,
onOpenInBrowser
}: {
id: string
title: string
ports: WorkspacePort[]
emptyText?: string
collapsed: boolean
onToggle: () => void
onStopPort: (port: WorkspacePort) => void
onShowDetails: (port: WorkspacePort) => void
onOpenInBrowser: (port: WorkspacePort) => void
}): React.JSX.Element | null {
if (ports.length === 0 && !emptyText) {
return null
}
return (
<div className="px-3 pt-2">
<button
type="button"
className="flex items-center gap-1 w-full text-left mb-1 text-muted-foreground hover:text-foreground transition-colors"
onClick={onToggle}
aria-expanded={!collapsed}
aria-controls={`local-port-section-${id}`}
>
<ChevronRight
size={12}
className={cn('shrink-0 transition-transform', !collapsed && 'rotate-90')}
/>
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
{title}
</span>
{ports.length > 0 && (
<span className="text-[10px] text-muted-foreground/60 ml-1">{ports.length}</span>
)}
</button>
{!collapsed && (
<div id={`local-port-section-${id}`}>
{ports.length > 0
? ports.map((port) => (
<LocalPortRow
key={port.id}
port={port}
onStop={onStopPort}
onShowDetails={onShowDetails}
onOpenInBrowser={onOpenInBrowser}
/>
))
: emptyText && <div className="py-1 text-xs text-muted-foreground">{emptyText}</div>}
</div>
)}
</div>
)
}
function LocalPortRow({
port,
onStop,
onShowDetails,
onOpenInBrowser
}: {
port: WorkspacePort
onStop: (port: WorkspacePort) => void
onShowDetails: (port: WorkspacePort) => void
onOpenInBrowser: (port: WorkspacePort) => void
}): React.JSX.Element {
const handleCopy = useCallback(() => {
void window.api.ui.writeClipboardText(addressForPort(port))
}, [port])
const handleOpenBrowser = useCallback(() => {
void onOpenInBrowser(port)
}, [onOpenInBrowser, port])
const handleCopyButtonClick = useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
handleCopy()
if (event.detail > 0) {
event.currentTarget.blur()
}
},
[handleCopy]
)
const handleOpenBrowserButtonClick = useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
handleOpenBrowser()
if (event.detail > 0) {
event.currentTarget.blur()
}
},
[handleOpenBrowser]
)
const handleStopButtonClick = useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
onStop(port)
if (event.detail > 0) {
event.currentTarget.blur()
}
},
[onStop, port]
)
const processLabel = port.processName ?? (port.pid ? `PID ${port.pid}` : 'Unknown process')
const ownerLabel =
port.kind === 'workspace'
? port.owner.displayName
: port.kind === 'container'
? 'Container or forwarded service'
: 'Unassigned'
const confidenceLabel =
port.kind === 'workspace' ? (port.owner.confidence === 'cwd' ? 'cwd' : 'command') : null
const canStopProcess =
port.kind === 'workspace' && Boolean(port.pid) && port.processName !== 'Electron'
return (
<ContextMenu>
<div className="group flex items-center gap-2 py-1 px-1 -mx-1 rounded hover:bg-accent/50 transition-colors">
<ContextMenuTrigger asChild>
<div
className="flex min-w-0 flex-1 items-center gap-2 rounded focus:outline-none focus-visible:ring-1 focus-visible:ring-ring"
tabIndex={0}
aria-label={`Port ${port.port} menu`}
>
<div className="flex size-5 shrink-0 items-center justify-center text-muted-foreground">
{port.kind === 'container' ? <Box size={13} /> : <Server size={13} />}
</div>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-1.5">
<span className="text-xs font-medium text-foreground">:{port.port}</span>
<span className="truncate text-xs text-muted-foreground">{processLabel}</span>
</div>
<div className="flex min-w-0 items-center gap-1.5 text-[11px] text-muted-foreground">
<span className="truncate">{ownerLabel}</span>
{confidenceLabel && (
<span className="shrink-0 text-muted-foreground/70">{confidenceLabel}</span>
)}
</div>
</div>
</div>
</ContextMenuTrigger>
<TooltipProvider delayDuration={400}>
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity">
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
className="text-muted-foreground hover:text-foreground"
onClick={handleOpenBrowserButtonClick}
aria-label="Open in Orca Browser"
>
<ExternalLink size={13} />
</Button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
Open in Orca Browser
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
className="text-muted-foreground hover:text-foreground"
onClick={handleCopyButtonClick}
aria-label="Copy Address"
>
<Copy size={13} />
</Button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
Copy Address
</TooltipContent>
</Tooltip>
{canStopProcess && (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
className="text-muted-foreground hover:text-destructive"
onClick={handleStopButtonClick}
aria-label="Stop Process"
>
<Trash2 size={13} />
</Button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
Stop Process
</TooltipContent>
</Tooltip>
)}
</div>
</TooltipProvider>
</div>
<ContextMenuContent className={LOCAL_PORT_MENU_CONTENT_CLASS}>
<ContextMenuLabel
className={LOCAL_PORT_MENU_LABEL_CLASS}
>{`:${port.port}`}</ContextMenuLabel>
<ContextMenuItem className={LOCAL_PORT_MENU_ITEM_CLASS} onSelect={handleOpenBrowser}>
<ExternalLink size={13} />
Open in Orca Browser
</ContextMenuItem>
<ContextMenuItem className={LOCAL_PORT_MENU_ITEM_CLASS} onSelect={handleCopy}>
<Copy size={13} />
Copy Address
</ContextMenuItem>
<ContextMenuItem
className={LOCAL_PORT_MENU_ITEM_CLASS}
onSelect={() => {
void window.api.ui.writeClipboardText(JSON.stringify(port, null, 2))
}}
>
<Copy size={13} />
Copy Details
</ContextMenuItem>
<ContextMenuItem
className={LOCAL_PORT_MENU_ITEM_CLASS}
onSelect={() => onShowDetails(port)}
>
<Info size={13} />
Show Details
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem
className={LOCAL_PORT_MENU_ITEM_CLASS}
variant="destructive"
disabled={!canStopProcess}
onSelect={() => onStop(port)}
>
<Trash2 size={13} />
Stop Process
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
)
}
function LocalPortDetailsDialog({
port,
onClose
}: {
port: WorkspacePort | null
onClose: () => void
}): React.JSX.Element {
return (
<Dialog open={Boolean(port)} onOpenChange={(open) => !open && onClose()}>
<DialogContent>
<DialogHeader>
<DialogTitle>{port ? `Port :${port.port}` : 'Port'}</DialogTitle>
<DialogDescription>
{port ? `${port.processName ?? 'Unknown process'} · ${addressForPort(port)}` : ''}
</DialogDescription>
</DialogHeader>
{port && (
<dl className="grid grid-cols-[88px_1fr] gap-x-3 gap-y-2 text-xs">
<dt className="text-muted-foreground">Address</dt>
<dd className="min-w-0 break-all text-foreground">{addressForPort(port)}</dd>
<dt className="text-muted-foreground">Bind</dt>
<dd className="min-w-0 break-all text-foreground">{`${port.bindHost}:${port.port}`}</dd>
<dt className="text-muted-foreground">Kind</dt>
<dd className="text-foreground">{port.kind}</dd>
<dt className="text-muted-foreground">Protocol</dt>
<dd className="text-foreground">{port.protocol}</dd>
<dt className="text-muted-foreground">Process</dt>
<dd className="min-w-0 break-all text-foreground">{port.processName ?? 'Unknown'}</dd>
<dt className="text-muted-foreground">PID</dt>
<dd className="text-foreground">{port.pid ?? 'Unknown'}</dd>
{port.kind === 'workspace' && (
<>
<dt className="text-muted-foreground">Workspace</dt>
<dd className="min-w-0 break-all text-foreground">{port.owner.displayName}</dd>
<dt className="text-muted-foreground">Evidence</dt>
<dd className="text-foreground">{port.owner.confidence}</dd>
</>
)}
</dl>
)}
</DialogContent>
</Dialog>
)
}
function SshPortsPanel(): React.JSX.Element {
const portForwardsByConnection = useAppStore((s) => s.portForwardsByConnection)
const detectedPortsByConnection = useAppStore((s) => s.detectedPortsByConnection)
const sshConnectionStates = useAppStore((s) => s.sshConnectionStates)
const createBrowserTab = useAppStore((s) => s.createBrowserTab)
// Why: scope the panel to the active worktree's SSH connection so
// actions target the correct machine and the disconnected state
// reflects the active worktree, not some other SSH session.
@ -107,6 +808,22 @@ export default function PortsPanel(): React.JSX.Element {
setDialogState({ mode: 'edit', entry })
}, [])
const handleOpenForwardInBrowser = useCallback(
(entry: PortForwardEntry) => {
if (!activeWorktree?.id) {
toast.error('No workspace selected for the browser.')
return
}
// Why: the protocol hint comes from the remote port (the actual service),
// not the local port which may be an arbitrary remap.
const protocol = HTTPS_PORTS.has(entry.remotePort) ? 'https' : 'http'
createBrowserTab(activeWorktree.id, `${protocol}://127.0.0.1:${entry.localPort}`, {
activate: true
})
},
[activeWorktree?.id, createBrowserTab]
)
const handleDialogClose = useCallback(() => {
setDialogState({ mode: 'closed' })
}, [])
@ -162,7 +879,12 @@ export default function PortsPanel(): React.JSX.Element {
</button>
{!forwardedCollapsed &&
allForwards.map((entry) => (
<ForwardedPortRow key={entry.id} entry={entry} onEdit={() => handleEdit(entry)} />
<ForwardedPortRow
key={entry.id}
entry={entry}
onEdit={() => handleEdit(entry)}
onOpenInBrowser={() => handleOpenForwardInBrowser(entry)}
/>
))}
</div>
)}
@ -231,10 +953,12 @@ export default function PortsPanel(): React.JSX.Element {
function ForwardedPortRow({
entry,
onEdit
onEdit,
onOpenInBrowser
}: {
entry: PortForwardEntry
onEdit: () => void
onOpenInBrowser: () => void
}): React.JSX.Element {
const [removing, setRemoving] = useState(false)
@ -256,13 +980,48 @@ function ForwardedPortRow({
}, [entry.localPort])
const handleOpenBrowser = useCallback(() => {
// Why: the protocol hint comes from the remote port (the actual service),
// not the local port which may be an arbitrary remap.
const protocol = HTTPS_PORTS.has(entry.remotePort) ? 'https' : 'http'
void window.api.shell.openUrl(`${protocol}://127.0.0.1:${entry.localPort}`)
}, [entry.localPort, entry.remotePort])
onOpenInBrowser()
}, [onOpenInBrowser])
const isHttpPort = HTTP_PORTS.has(entry.remotePort)
const handleCopyButtonClick = useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
handleCopy()
if (event.detail > 0) {
event.currentTarget.blur()
}
},
[handleCopy]
)
const handleOpenBrowserButtonClick = useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
handleOpenBrowser()
if (event.detail > 0) {
event.currentTarget.blur()
}
},
[handleOpenBrowser]
)
const handleEditButtonClick = useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
onEdit()
if (event.detail > 0) {
event.currentTarget.blur()
}
},
[onEdit]
)
const handleRemoveButtonClick = useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
void handleRemove()
if (event.detail > 0) {
event.currentTarget.blur()
}
},
[handleRemove]
)
return (
<div className="group flex items-center gap-2 py-1 px-1 -mx-1 rounded hover:bg-accent/50 transition-colors">
@ -281,21 +1040,19 @@ function ForwardedPortRow({
</span>
</div>
</div>
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
{isHttpPort && (
<button
type="button"
className="p-1 rounded hover:bg-accent transition-colors text-muted-foreground hover:text-foreground"
onClick={handleOpenBrowser}
title="Open in Browser"
>
<ExternalLink size={13} />
</button>
)}
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity">
<button
type="button"
className="p-1 rounded hover:bg-accent transition-colors text-muted-foreground hover:text-foreground"
onClick={handleCopy}
onClick={handleOpenBrowserButtonClick}
title="Open in Orca Browser"
>
<ExternalLink size={13} />
</button>
<button
type="button"
className="p-1 rounded hover:bg-accent transition-colors text-muted-foreground hover:text-foreground"
onClick={handleCopyButtonClick}
title="Copy Address"
>
<Copy size={13} />
@ -303,7 +1060,7 @@ function ForwardedPortRow({
<button
type="button"
className="p-1 rounded hover:bg-accent transition-colors text-muted-foreground hover:text-foreground"
onClick={onEdit}
onClick={handleEditButtonClick}
title="Edit"
>
<Pencil size={13} />
@ -314,7 +1071,7 @@ function ForwardedPortRow({
'p-1 rounded hover:bg-accent transition-colors text-muted-foreground hover:text-foreground',
removing && 'opacity-50'
)}
onClick={handleRemove}
onClick={handleRemoveButtonClick}
disabled={removing}
title="Remove"
>

View File

@ -67,8 +67,6 @@ type ActivityBarItem = {
shortcut: string
/** When true, hidden for non-git (folder-mode) repos. */
gitOnly?: boolean
/** When true, only shown when at least one SSH connection is active. */
sshOnly?: boolean
}
const isMac = navigator.userAgent.includes('Mac')
@ -107,8 +105,7 @@ const ACTIVITY_ITEMS: ActivityBarItem[] = [
title: 'Ports',
// Why: Ctrl+Shift+I is the DevTools accelerator on Windows/Linux, so this
// shortcut is macOS-only. On other platforms the tooltip omits it.
shortcut: isMac ? `\u21E7${mod}I` : '',
sshOnly: true
shortcut: isMac ? `\u21E7${mod}I` : ''
}
]
@ -128,84 +125,15 @@ function RightSidebarInner(): React.JSX.Element {
const activeRepo = useRepoById(activeWorktree?.repoId ?? null)
const isFolder = activeRepo ? isFolderRepo(activeRepo) : false
// Why: show the Ports tab only when the active worktree belongs to a
// remote (SSH) repo, not for any global SSH connection. Switching to a
// local worktree should hide the tab even if SSH sessions are alive.
const isRemoteWorktree = !!activeRepo?.connectionId
const hasActiveSshConnection = useAppStore((s) => {
if (!activeRepo?.connectionId) {
return false
}
const state = s.sshConnectionStates.get(activeRepo.connectionId)
return state?.status === 'connected'
})
// Why: when the SSH connection drops while the user is viewing the Ports
// panel, hiding the tab immediately would be jarring. Keep it visible
// during a 30-second grace period, then hide it.
const isPortsPanelActive = rightSidebarTab === 'ports'
// Why: graceActiveRef is set synchronously during render (not via useEffect)
// so that the very first render after disconnect already sees the grace flag,
// preventing a one-frame flicker to the Explorer tab.
const graceActiveRef = React.useRef(false)
const graceTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null)
const [, forceUpdate] = useState(0)
if (!hasActiveSshConnection && isPortsPanelActive && !graceActiveRef.current) {
graceActiveRef.current = true
} else if (graceActiveRef.current && (hasActiveSshConnection || !isPortsPanelActive)) {
// Why: clear grace when either (a) the SSH session reconnects, or (b) the
// user navigates away from the Ports tab — no reason to keep it visible
// once they've moved on.
graceActiveRef.current = false
if (graceTimerRef.current) {
clearTimeout(graceTimerRef.current)
graceTimerRef.current = null
}
}
const disconnectGraceActive = graceActiveRef.current
useEffect(() => {
if (disconnectGraceActive) {
graceTimerRef.current = setTimeout(() => {
graceActiveRef.current = false
graceTimerRef.current = null
// Why: only reset the tab if the user is still on Ports. If they
// already navigated to Search/Checks/etc during the grace period,
// forcing them back to Explorer would be disruptive.
if (useAppStore.getState().rightSidebarTab === 'ports') {
setRightSidebarTab('explorer')
}
forceUpdate((n) => n + 1)
}, 30_000)
return () => {
if (graceTimerRef.current) {
clearTimeout(graceTimerRef.current)
graceTimerRef.current = null
}
}
}
return undefined
}, [disconnectGraceActive, setRightSidebarTab])
const visibleItems = useMemo(
() =>
ACTIVITY_ITEMS.filter((item) => {
if (item.gitOnly && isFolder) {
return false
}
if (item.sshOnly) {
if (!isRemoteWorktree) {
return false
}
if (!hasActiveSshConnection && !disconnectGraceActive) {
return false
}
}
return true
}),
[isFolder, isRemoteWorktree, hasActiveSshConnection, disconnectGraceActive]
[isFolder]
)
// If the active tab is hidden (e.g. switched from a git repo to a folder),
@ -244,7 +172,7 @@ function RightSidebarInner(): React.JSX.Element {
{effectiveTab === 'search' && <SearchPanel />}
{effectiveTab === 'source-control' && <SourceControl />}
{effectiveTab === 'checks' && <ChecksPanel />}
{effectiveTab === 'ports' && <PortsPanel />}
{effectiveTab === 'ports' && <PortsPanel isVisible={rightSidebarOpen} />}
</div>
</div>
)

View File

@ -26,7 +26,8 @@ export const RUNTIME_CAPABILITIES = [
'runtime.environments.v1',
'browser.screencast.v1',
'terminal.binary-stream.v1',
'terminal.multiplex.v1'
'terminal.multiplex.v1',
'workspace-ports.v1'
] as const
export type RuntimeCapability = (typeof RUNTIME_CAPABILITIES)[number] | (string & {})

View File

@ -0,0 +1,64 @@
export type WorkspacePortProbe = {
id: string
repoId: string
displayName: string
path: string
}
export type WorkspacePortAttributionConfidence = 'cwd' | 'command' | 'none'
export type WorkspacePortOwner = {
worktreeId: string
repoId: string
displayName: string
path: string
confidence: WorkspacePortAttributionConfidence
}
type WorkspacePortBase = {
id: string
/** Address reported by the OS listener. May be a wildcard bind. */
bindHost: string
/** Address the renderer should copy/open. Wildcard binds are normalized to localhost. */
connectHost: string
port: number
pid?: number
processName?: string
protocol: 'http' | 'https' | 'unknown'
}
export type WorkspacePort =
| (WorkspacePortBase & {
kind: 'workspace'
owner: WorkspacePortOwner
})
| (WorkspacePortBase & {
kind: 'container'
})
| (WorkspacePortBase & {
kind: 'external'
})
export type WorkspacePortScanRequest = {
repoId?: string
}
export type WorkspacePortKillRequest = {
repoId?: string
pid: number
port: number
}
export type WorkspacePortKillResult =
| { ok: true }
| {
ok: false
reason: string
}
export type WorkspacePortScanResult = {
platform: NodeJS.Platform | 'unknown'
scannedAt: number
ports: WorkspacePort[]
unavailableReason?: string
}